mirror of
https://gitea.tendokyu.moe/ppc/amnet.git
synced 2026-10-06 21:58:06 +03:00
Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
7f51e4a0ee | ||
|
|
4d43943568 | ||
|
|
d8d29c3f84 | ||
|
|
0a204b249a | ||
|
|
ae7483b156 | ||
|
|
5bb477741d | ||
|
|
3fbea0f925 | ||
|
|
2bc9c9f673 | ||
|
|
5b19207a91 | ||
|
|
bc48917fe8 | ||
|
|
13cee9a431 | ||
|
|
874ba06e29 | ||
|
|
16c0ff54c6 | ||
|
|
73d333cc23 | ||
|
|
393965d252 | ||
|
|
29b303cde8 | ||
|
|
e83287c7b7 | ||
|
|
9324b30e7b | ||
|
|
d0c7616ed1 | ||
|
|
99fd999c06 | ||
|
|
0a42a0a912 | ||
|
|
a6ad82ffda | ||
|
|
31c56d2449 | ||
|
|
b33670d849 | ||
|
|
9c8c4fd286 | ||
|
|
0a80da6614 | ||
|
|
489b56015c | ||
|
|
89338c794a | ||
|
|
aae5039660 | ||
|
|
690a5b591c | ||
|
|
f9b2326853 | ||
|
|
b2be622271 | ||
|
|
e0ba6c04f2 | ||
|
|
6c7479225c | ||
|
|
42cb241487 | ||
|
|
297ad685a7 | ||
|
|
d52757f134 | ||
|
|
a4d4e03eb9 | ||
|
|
3e3755471d | ||
|
|
adb60dd928 | ||
|
|
c8763dd65e |
@@ -3,7 +3,6 @@
|
|||||||
<PropertyGroup>
|
<PropertyGroup>
|
||||||
<PublishAot>true</PublishAot>
|
<PublishAot>true</PublishAot>
|
||||||
<OutputType>Library</OutputType>
|
<OutputType>Library</OutputType>
|
||||||
<EventSourceSupport>false</EventSourceSupport>
|
|
||||||
<TargetFramework>net9.0-windows</TargetFramework>
|
<TargetFramework>net9.0-windows</TargetFramework>
|
||||||
<OptimizationPreference>Size</OptimizationPreference>
|
<OptimizationPreference>Size</OptimizationPreference>
|
||||||
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
|
||||||
|
|||||||
@@ -13,11 +13,11 @@ internal class CardPresenter
|
|||||||
private readonly Lock _cardLock = new();
|
private readonly Lock _cardLock = new();
|
||||||
private StoredCard _currentCard;
|
private StoredCard _currentCard;
|
||||||
|
|
||||||
public record StoredCard(byte[] Value, string OriginalValue, long ExpiresAt)
|
public record StoredCard(byte[] AccessCode, byte[] IDm, long ExpiresAt)
|
||||||
{
|
{
|
||||||
public bool Expired => Environment.TickCount64 >= ExpiresAt;
|
public bool Expired => Environment.TickCount64 >= ExpiresAt;
|
||||||
|
|
||||||
public override string ToString() => string.Join(" ", Enumerable.Range(0, 5).Select(x => OriginalValue.Substring(x * 4, 4)));
|
public override string ToString() => FormatHexCode(AccessCode);
|
||||||
}
|
}
|
||||||
|
|
||||||
/// <summary>
|
/// <summary>
|
||||||
@@ -31,10 +31,16 @@ internal class CardPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
public StoredCard TakeActiveCard()
|
public StoredCard TakeActiveCard(bool? requireIDm = null)
|
||||||
{
|
{
|
||||||
lock (_cardLock)
|
lock (_cardLock)
|
||||||
{
|
{
|
||||||
|
// IDm and requireIDm are mutually exclusive
|
||||||
|
if (_currentCard == null || (requireIDm.HasValue && (_currentCard.IDm != null) ^ requireIDm.Value))
|
||||||
|
{
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
var card = _currentCard;
|
var card = _currentCard;
|
||||||
_currentCard = null;
|
_currentCard = null;
|
||||||
|
|
||||||
@@ -45,13 +51,14 @@ internal class CardPresenter
|
|||||||
/// <summary>
|
/// <summary>
|
||||||
/// Sets the card
|
/// Sets the card
|
||||||
/// </summary>
|
/// </summary>
|
||||||
/// <param name="cardId">The card id to set</param>
|
/// <param name="accessCode">The access code of the card to use</param>
|
||||||
|
/// <param name="idmHex">The FeliCa card IDm, if a physical card was used</param>
|
||||||
/// <param name="validFor">How long the card should be presented to the game for in milliseconds</param>
|
/// <param name="validFor">How long the card should be presented to the game for in milliseconds</param>
|
||||||
/// <returns>Whether the card was set successfully</returns>
|
/// <returns>Whether the card was set successfully</returns>
|
||||||
public StoredCard SetCard(string cardId, long validFor = 5000)
|
public StoredCard SetCard(string accessCode, string idmHex, long validFor = 5000)
|
||||||
{
|
{
|
||||||
// ensure the matrix code is 20-digits long, otherwise pad with zeros
|
// ensure the matrix code is 20-digits long, otherwise pad with zeros
|
||||||
var matrixCode = cardId.Replace(" ", "").PadLeft(20, '0');
|
var matrixCode = accessCode.Replace(" ", "").PadLeft(20, '0');
|
||||||
var bytes = new byte[10];
|
var bytes = new byte[10];
|
||||||
|
|
||||||
for (var i = 0; i < bytes.Length; i++)
|
for (var i = 0; i < bytes.Length; i++)
|
||||||
@@ -67,7 +74,8 @@ internal class CardPresenter
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
var card = new StoredCard(bytes, matrixCode, Environment.TickCount64 + validFor);
|
var idmBytes = !string.IsNullOrWhiteSpace(idmHex) && idmHex.Length <= 16 && idmHex.All(char.IsAsciiHexDigit) ? Convert.FromHexString(idmHex.PadLeft(16, '0')) : null;
|
||||||
|
var card = new StoredCard(bytes, idmBytes, Environment.TickCount64 + validFor);
|
||||||
|
|
||||||
lock (_cardLock)
|
lock (_cardLock)
|
||||||
{
|
{
|
||||||
@@ -76,4 +84,9 @@ internal class CardPresenter
|
|||||||
|
|
||||||
return card;
|
return card;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Formats a byte array as a hex string, inserting a space every 2 characters
|
||||||
|
/// </summary>
|
||||||
|
public static string FormatHexCode(byte[] code) => string.Join(" ", code.Chunk(2).Select(Convert.ToHexString));
|
||||||
}
|
}
|
||||||
+21
-2
@@ -1,4 +1,5 @@
|
|||||||
using System;
|
using System;
|
||||||
|
using System.Reflection;
|
||||||
using Windows.Win32;
|
using Windows.Win32;
|
||||||
using Windows.Win32.Foundation;
|
using Windows.Win32.Foundation;
|
||||||
|
|
||||||
@@ -6,25 +7,43 @@ namespace AMNet.Server;
|
|||||||
|
|
||||||
internal static class Config
|
internal static class Config
|
||||||
{
|
{
|
||||||
private const string ConfigFileName = @".\segatools.ini";
|
|
||||||
|
|
||||||
internal const string IOSection = "aimeio";
|
internal const string IOSection = "aimeio";
|
||||||
|
private const string ConfigFileName = @".\segatools.ini";
|
||||||
|
|
||||||
static Config()
|
static Config()
|
||||||
{
|
{
|
||||||
GameId = ReadKey(IOSection, "gameId", 4);
|
GameId = ReadKey(IOSection, "gameId", 4);
|
||||||
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
|
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
|
||||||
|
|
||||||
|
PhysicalCardUseIDm = PInvoke.GetPrivateProfileInt(IOSection, "useAimeDBForPhysicalCards", 0, ConfigFileName) > 0;
|
||||||
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFileName) > 0;
|
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFileName) > 0;
|
||||||
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
|
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
|
||||||
}
|
}
|
||||||
|
|
||||||
public static string GameId { get; }
|
public static string GameId { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// The server name to show on devices. Defaults to the computer name.
|
||||||
|
/// </summary>
|
||||||
public static string ServerName { get; }
|
public static string ServerName { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether physically scanned cards should have their IDm forwarded to the game instead of the access code.
|
||||||
|
/// </summary>
|
||||||
|
public static bool PhysicalCardUseIDm { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Whether holding the enter key should trigger a card scan using the code from the aime.txt file
|
||||||
|
/// </summary>
|
||||||
public static bool EnableAimeTxt { get; }
|
public static bool EnableAimeTxt { get; }
|
||||||
|
|
||||||
|
/// <summary>
|
||||||
|
/// Path to the aime.txt file. Defaults to .\DEVICE\aime.txt
|
||||||
|
/// </summary>
|
||||||
public static string AimeTxtPath { get; }
|
public static string AimeTxtPath { get; }
|
||||||
|
|
||||||
|
public static Version ServerVersion { get; } = new(Assembly.GetExecutingAssembly().GetCustomAttribute<AssemblyFileVersionAttribute>()?.Version ?? "1.0");
|
||||||
|
|
||||||
internal static unsafe string ReadKey(string section, string key, uint maxLength, string @default = null)
|
internal static unsafe string ReadKey(string section, string key, uint maxLength, string @default = null)
|
||||||
{
|
{
|
||||||
// +1 for null terminator
|
// +1 for null terminator
|
||||||
|
|||||||
+31
-13
@@ -36,7 +36,7 @@ public static class DllMain
|
|||||||
CancellationTokenRegistration? cancellationRegistration = null;
|
CancellationTokenRegistration? cancellationRegistration = null;
|
||||||
cancellationRegistration = App.Lifetime.ApplicationStarted.Register(() =>
|
cancellationRegistration = App.Lifetime.ApplicationStarted.Register(() =>
|
||||||
{
|
{
|
||||||
App.Logger.LogInformation("AMNet Server ({gameId}) started successfully.", Config.GameId);
|
App.Logger.LogInformation("AMNet Server v{version} ({gameId}) started.", Config.ServerVersion.ToString(Config.ServerVersion.Build > 0 ? 3 : 2), Config.GameId ?? "SXXX");
|
||||||
App.Logger.LogInformation("Visit {addr} from a mobile device on the same network to get started.", WebAddress);
|
App.Logger.LogInformation("Visit {addr} from a mobile device on the same network to get started.", WebAddress);
|
||||||
|
|
||||||
// ReSharper disable once AccessToModifiedClosure
|
// ReSharper disable once AccessToModifiedClosure
|
||||||
@@ -71,7 +71,7 @@ public static class DllMain
|
|||||||
using var aimeTxtReader = new StreamReader(File.OpenRead(Config.AimeTxtPath), Encoding.UTF8);
|
using var aimeTxtReader = new StreamReader(File.OpenRead(Config.AimeTxtPath), Encoding.UTF8);
|
||||||
|
|
||||||
var fileName = Path.GetFileName(Config.AimeTxtPath);
|
var fileName = Path.GetFileName(Config.AimeTxtPath);
|
||||||
var cardResult = App.Services.GetRequiredService<CardPresenter>().SetCard(aimeTxtReader.ReadLine());
|
var cardResult = App.Services.GetRequiredService<CardPresenter>().SetCard(aimeTxtReader.ReadLine(), null);
|
||||||
|
|
||||||
if (cardResult == null)
|
if (cardResult == null)
|
||||||
{
|
{
|
||||||
@@ -98,25 +98,43 @@ public static class DllMain
|
|||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard();
|
// if physical cards are being directed to
|
||||||
|
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard(Config.PhysicalCardUseIDm ? false : null);
|
||||||
if (card?.Expired != false)
|
if (card?.Expired != false)
|
||||||
{
|
{
|
||||||
return 1;
|
return 1;
|
||||||
}
|
}
|
||||||
|
|
||||||
try
|
Marshal.Copy(card.AccessCode, 0, luid, (int)luidSize);
|
||||||
{
|
App.Logger.LogInformation("Access Code read: {cardId}", CardPresenter.FormatHexCode(card.AccessCode));
|
||||||
Marshal.Copy(card.Value, 0, luid, (int)luidSize);
|
return 0;
|
||||||
return 0;
|
|
||||||
}
|
|
||||||
finally
|
|
||||||
{
|
|
||||||
App.Logger.LogInformation("Card read in: {cardId}", card);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")]
|
[UnmanagedCallersOnly(EntryPoint = "aime_io_nfc_get_felica_id")]
|
||||||
public static unsafe int GetFelicaId(byte unitNo, ulong* idm) => 1; // felica not supported
|
public static unsafe int GetFelicaId(byte unitNo, ulong* idm)
|
||||||
|
{
|
||||||
|
if (!Config.PhysicalCardUseIDm || unitNo != 0)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
var card = App.Services.GetRequiredService<CardPresenter>().TakeActiveCard(true);
|
||||||
|
if (card?.Expired != false)
|
||||||
|
{
|
||||||
|
return 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
ulong idmValue = 0;
|
||||||
|
for (var i = 0 ; i < 8 ; i++)
|
||||||
|
{
|
||||||
|
idmValue = (idmValue << 8) | card.IDm[i];
|
||||||
|
}
|
||||||
|
|
||||||
|
*idm = idmValue;
|
||||||
|
|
||||||
|
App.Logger.LogInformation("FeliCa IDm read: {idm}", CardPresenter.FormatHexCode(card.IDm));
|
||||||
|
return 0;
|
||||||
|
}
|
||||||
|
|
||||||
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
|
[UnmanagedCallersOnly(EntryPoint = "aime_io_led_set_color")]
|
||||||
public static void SetLedColour(byte unitNo, byte r, byte g, byte b)
|
public static void SetLedColour(byte unitNo, byte r, byte g, byte b)
|
||||||
|
|||||||
+29
-18
@@ -1,5 +1,6 @@
|
|||||||
using System;
|
using System;
|
||||||
using System.Globalization;
|
using System.Globalization;
|
||||||
|
using System.Linq;
|
||||||
using System.Text.Json;
|
using System.Text.Json;
|
||||||
using System.Text.Json.Serialization;
|
using System.Text.Json.Serialization;
|
||||||
using System.Threading;
|
using System.Threading;
|
||||||
@@ -24,7 +25,11 @@ internal static partial class WebServer
|
|||||||
[property: JsonPropertyName("timeSinceLastPoll")] long? TimeSinceLastPoll);
|
[property: JsonPropertyName("timeSinceLastPoll")] long? TimeSinceLastPoll);
|
||||||
|
|
||||||
public record CardReadRequest(
|
public record CardReadRequest(
|
||||||
[property: JsonPropertyName("cardId")] string MatrixCode);
|
[property: JsonPropertyName("cardId")] string AccessCode,
|
||||||
|
[property: JsonPropertyName("physicalCardIDm")] string PhysicalCardIDm);
|
||||||
|
|
||||||
|
public record ServerErrorMessage(
|
||||||
|
[property: JsonPropertyName("message")] string Message);
|
||||||
|
|
||||||
public static WebApplication BuildServer(params string[] listenAddresses)
|
public static WebApplication BuildServer(params string[] listenAddresses)
|
||||||
{
|
{
|
||||||
@@ -97,23 +102,25 @@ internal static partial class WebServer
|
|||||||
}
|
}
|
||||||
catch (JsonException e)
|
catch (JsonException e)
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 400;
|
await WriteErrorResponse(ctx, StatusCodes.Status400BadRequest, e.Message);
|
||||||
await ctx.Response.WriteAsync(e.Message);
|
|
||||||
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (string.IsNullOrEmpty(request?.MatrixCode))
|
if (string.IsNullOrEmpty(request?.AccessCode))
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 400;
|
await WriteErrorResponse(ctx, StatusCodes.Status400BadRequest, "Access Code not provided.");
|
||||||
await ctx.Response.WriteAsync("Card id not provided.");
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (request.MatrixCode.Length is 0 or < 20)
|
if (request.AccessCode.Length is 0 or < 20 || !request.AccessCode.Any(char.IsAsciiDigit))
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 422;
|
await WriteErrorResponse(ctx, StatusCodes.Status422UnprocessableEntity, "Invalid Access Code.");
|
||||||
await ctx.Response.WriteAsync("Invalid card id length.");
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (request.AccessCode.All(x => x == '0'))
|
||||||
|
{
|
||||||
|
await WriteErrorResponse(ctx, StatusCodes.Status403Forbidden, "All-zero access codes are forbidden.");
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -122,26 +129,30 @@ internal static partial class WebServer
|
|||||||
|
|
||||||
if (currentCard?.Expired == false)
|
if (currentCard?.Expired == false)
|
||||||
{
|
{
|
||||||
var timeLeft = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds);
|
ctx.Response.StatusCode = StatusCodes.Status429TooManyRequests;
|
||||||
|
ctx.Response.Headers.RetryAfter = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds).ToString(CultureInfo.InvariantCulture);
|
||||||
ctx.Response.StatusCode = 429;
|
|
||||||
ctx.Response.Headers.RetryAfter = timeLeft.ToString(CultureInfo.InvariantCulture);
|
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
if (cardPresenter.SetCard(request.MatrixCode) != null)
|
if (cardPresenter.SetCard(request.AccessCode, request.PhysicalCardIDm) != null)
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 202;
|
ctx.Response.StatusCode = StatusCodes.Status202Accepted;
|
||||||
}
|
}
|
||||||
else
|
else
|
||||||
{
|
{
|
||||||
ctx.Response.StatusCode = 422;
|
await WriteErrorResponse(ctx, StatusCodes.Status422UnprocessableEntity, "Invalid card id format.");
|
||||||
await ctx.Response.WriteAsync("Invalid card id format.");
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private static Task WriteErrorResponse(HttpContext ctx, int code, string message)
|
||||||
|
{
|
||||||
|
ctx.Response.StatusCode = code;
|
||||||
|
return ctx.Response.WriteAsJsonAsync(new ServerErrorMessage(message), SerializerContext.Default.ServerErrorMessage);
|
||||||
|
}
|
||||||
|
|
||||||
[JsonSerializable(typeof(SystemState))]
|
[JsonSerializable(typeof(SystemState))]
|
||||||
[JsonSerializable(typeof(CardReadRequest))]
|
[JsonSerializable(typeof(CardReadRequest))]
|
||||||
|
[JsonSerializable(typeof(ServerErrorMessage))]
|
||||||
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
|
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
|
||||||
private partial class SerializerContext : JsonSerializerContext;
|
private partial class SerializerContext : JsonSerializerContext;
|
||||||
}
|
}
|
||||||
+10
-6
@@ -1,14 +1,14 @@
|
|||||||
import React, {useEffect} from "react";
|
import React from "react";
|
||||||
import {StatusBar} from "expo-status-bar";
|
import {StatusBar} from "expo-status-bar";
|
||||||
import {useColorScheme, View} from "react-native";
|
|
||||||
import {Server, WalletCards} from "lucide-react-native";
|
import {Server, WalletCards} from "lucide-react-native";
|
||||||
|
import {Platform, useColorScheme, View} from "react-native";
|
||||||
import {NavigationContainer} from "@react-navigation/native";
|
import {NavigationContainer} from "@react-navigation/native";
|
||||||
import {SafeAreaProvider} from "react-native-safe-area-context";
|
import {SafeAreaProvider} from "react-native-safe-area-context";
|
||||||
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
import {MD3Colors} from "react-native-paper/lib/typescript/types";
|
||||||
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
|
||||||
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
|
||||||
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
|
import {JetBrainsMono_400Regular, useFonts} from "@expo-google-fonts/jetbrains-mono";
|
||||||
|
import {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
|
||||||
import {
|
import {
|
||||||
ActivityIndicator,
|
ActivityIndicator,
|
||||||
adaptNavigationTheme,
|
adaptNavigationTheme,
|
||||||
@@ -19,12 +19,15 @@ import {
|
|||||||
Paragraph
|
Paragraph
|
||||||
} from 'react-native-paper';
|
} from 'react-native-paper';
|
||||||
|
|
||||||
import RealmContext from "./models/RealmContext";
|
|
||||||
import linking from "./Linking";
|
import linking from "./Linking";
|
||||||
|
import RealmContext from "./models/RealmContext";
|
||||||
|
|
||||||
import {AMUITabBar} from "./components/AMUITabBar";
|
import {AMUITabBar} from "./components/AMUITabBar";
|
||||||
import CardAddModal from "./app/CardAddModal";
|
|
||||||
|
import CardEditorModal from "./app/card/CardEditorModal";
|
||||||
|
import CardAddModal from "./app/card/CardAddModal";
|
||||||
import ServerAddModal from "./app/ServerAddModal";
|
import ServerAddModal from "./app/ServerAddModal";
|
||||||
|
|
||||||
import CardManagerStack from "./app/CardManagerStack";
|
import CardManagerStack from "./app/CardManagerStack";
|
||||||
import ServerManagerStack from "./app/ServerManagerStack";
|
import ServerManagerStack from "./app/ServerManagerStack";
|
||||||
|
|
||||||
@@ -118,7 +121,8 @@ export default function AppRoot() {
|
|||||||
|
|
||||||
<Stack.Group screenOptions={{presentation: "modal"}}>
|
<Stack.Group screenOptions={{presentation: "modal"}}>
|
||||||
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
|
<Stack.Screen name="add-server" component={ServerAddModal} options={{title: "Add Server"}}/>
|
||||||
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card"}}/>
|
<Stack.Screen name="add-card" component={CardAddModal} options={{title: "Add Card", headerShown: Platform.OS !== 'ios'}}/>
|
||||||
|
<Stack.Screen name="edit-card" component={CardEditorModal} options={{title: "Edit Card"}}/>
|
||||||
</Stack.Group>
|
</Stack.Group>
|
||||||
</Stack.Navigator>
|
</Stack.Navigator>
|
||||||
</NavigationContainer>
|
</NavigationContainer>
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
{
|
{
|
||||||
"name": "AMNet",
|
"name": "AMNet",
|
||||||
"scheme": "amnet",
|
"scheme": "amnet",
|
||||||
"version": "1.2.0",
|
"version": "1.3.1",
|
||||||
"slug": "amnet-native",
|
"slug": "amnet-native",
|
||||||
"icon": "./assets/icon.png",
|
"icon": "./assets/icon.png",
|
||||||
"userInterfaceStyle": "automatic",
|
"userInterfaceStyle": "automatic",
|
||||||
@@ -12,7 +12,12 @@
|
|||||||
"applinks:card.ppc.moe"
|
"applinks:card.ppc.moe"
|
||||||
],
|
],
|
||||||
"infoPlist": {
|
"infoPlist": {
|
||||||
"UIViewControllerBasedStatusBarAppearance": "NO"
|
"UIViewControllerBasedStatusBarAppearance": "NO",
|
||||||
|
"NFCReaderUsageDescription": "AMNet uses NFC to scan Physical IC Cards",
|
||||||
|
"com.apple.developer.nfc.readersession.felica.systemcodes": ["0003", "88b4"]
|
||||||
|
},
|
||||||
|
"entitlements": {
|
||||||
|
"com.apple.developer.nfc.readersession.formats": ["TAG"]
|
||||||
},
|
},
|
||||||
"config": {
|
"config": {
|
||||||
"usesNonExemptEncryption": false
|
"usesNonExemptEncryption": false
|
||||||
@@ -20,7 +25,7 @@
|
|||||||
},
|
},
|
||||||
"android": {
|
"android": {
|
||||||
"package": "moe.ppc.amnet",
|
"package": "moe.ppc.amnet",
|
||||||
"versionCode": 3,
|
"versionCode": 5,
|
||||||
"adaptiveIcon": {
|
"adaptiveIcon": {
|
||||||
"foregroundImage": "./assets/icon-android.png",
|
"foregroundImage": "./assets/icon-android.png",
|
||||||
"backgroundColor": "#DED8FD"
|
"backgroundColor": "#DED8FD"
|
||||||
|
|||||||
@@ -1,124 +0,0 @@
|
|||||||
import {generateCardNumber} from "amnet-shared";
|
|
||||||
import {useModalStyles} from "../utils/ModalStyles";
|
|
||||||
import {useCallback, useLayoutEffect, useState} from "react";
|
|
||||||
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
|
||||||
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
|
||||||
|
|
||||||
import {AMCard} from "../models/AMCard";
|
|
||||||
import RealmContext from "../models/RealmContext";
|
|
||||||
|
|
||||||
const {useRealm} = RealmContext;
|
|
||||||
|
|
||||||
export default function CardAddModal({navigation, route}) {
|
|
||||||
const realm = useRealm();
|
|
||||||
const theme = useTheme();
|
|
||||||
const modalStyles = useModalStyles();
|
|
||||||
|
|
||||||
// state values
|
|
||||||
const [cardName, setCardName] = useState('');
|
|
||||||
const [cardNumber, setCardNumber] = useState('');
|
|
||||||
const [cardNumberReadOnly, setCardNumberReadOnly] = useState(false);
|
|
||||||
|
|
||||||
const cardAddCallback = useCallback((cardId: string, cardName: string) => {
|
|
||||||
const duplicateCard = realm.objects<AMCard>("card").find(x => x.code === cardId);
|
|
||||||
|
|
||||||
if (duplicateCard) {
|
|
||||||
realm.write(() => {
|
|
||||||
duplicateCard.name = cardName;
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
realm.write(() => {
|
|
||||||
realm.create("card", AMCard.generate(cardId, cardName));
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation.goBack();
|
|
||||||
}, [realm]);
|
|
||||||
|
|
||||||
// header effects
|
|
||||||
if (Platform.OS === 'ios') {
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
navigation.setOptions({
|
|
||||||
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
|
||||||
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
|
||||||
</TouchableOpacity>,
|
|
||||||
});
|
|
||||||
}, [navigation, modalStyles]);
|
|
||||||
}
|
|
||||||
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
const canSave = cardName.length && cardNumber.length === 20;
|
|
||||||
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
|
||||||
|
|
||||||
navigation.setOptions({
|
|
||||||
headerRight: () => <TouchableOpacity disabled={!canSave}
|
|
||||||
onPress={() => cardAddCallback(cardNumber, cardName)}>
|
|
||||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
|
||||||
</TouchableOpacity>
|
|
||||||
})
|
|
||||||
}, [navigation, modalStyles, cardName, cardNumber]);
|
|
||||||
|
|
||||||
// enable renaming by passing cardId to modal
|
|
||||||
useLayoutEffect(() => {
|
|
||||||
if (!route.params?.cardId) {
|
|
||||||
setCardNumberReadOnly(false);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setCardNumber(route.params.cardId);
|
|
||||||
setCardNumberReadOnly(true);
|
|
||||||
|
|
||||||
if (route.params.cardName) {
|
|
||||||
setCardName(route.params.cardName);
|
|
||||||
}
|
|
||||||
|
|
||||||
navigation.setOptions({
|
|
||||||
title: "Edit Card"
|
|
||||||
});
|
|
||||||
}, [route]);
|
|
||||||
|
|
||||||
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
|
||||||
<Card mode="contained">
|
|
||||||
<Card.Content>
|
|
||||||
<View style={modalStyles.formItemRow}>
|
|
||||||
<Paragraph style={modalStyles.formItemName}>
|
|
||||||
Card Name
|
|
||||||
</Paragraph>
|
|
||||||
<TextInput style={modalStyles.formItemInput}
|
|
||||||
numberOfLines={1} autoFocus
|
|
||||||
value={cardName}
|
|
||||||
onChangeText={setCardName}
|
|
||||||
placeholder="Primary Card"
|
|
||||||
placeholderTextColor={theme.colors.outline}/>
|
|
||||||
</View>
|
|
||||||
|
|
||||||
<Divider style={{marginVertical: 15}}/>
|
|
||||||
|
|
||||||
<View style={modalStyles.formItemRow}>
|
|
||||||
<Paragraph style={modalStyles.formItemName}>
|
|
||||||
Matrix Code
|
|
||||||
</Paragraph>
|
|
||||||
<TextInput numberOfLines={1}
|
|
||||||
keyboardType="numeric"
|
|
||||||
readOnly={cardNumberReadOnly}
|
|
||||||
placeholder="0000 0000 0000 0000 0000"
|
|
||||||
placeholderTextColor={theme.colors.outline}
|
|
||||||
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
|
|
||||||
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
|
|
||||||
style={{...modalStyles.formItemInput, color: cardNumberReadOnly ? theme.colors.onSurfaceDisabled : theme.colors.onSurface}}/>
|
|
||||||
</View>
|
|
||||||
</Card.Content>
|
|
||||||
</Card>
|
|
||||||
|
|
||||||
{/*only show card entry info when in create mode*/}
|
|
||||||
{!cardNumberReadOnly && (
|
|
||||||
<>
|
|
||||||
<Paragraph style={modalStyles.formFootnote}>
|
|
||||||
If you have a physical card, you can enter the 20-digit code on the back.
|
|
||||||
</Paragraph>
|
|
||||||
|
|
||||||
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Matrix Code</Button>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</View>)
|
|
||||||
}
|
|
||||||
@@ -5,7 +5,7 @@ import {Button, useTheme} from "react-native-paper";
|
|||||||
import {CreditCard, Plus} from "lucide-react-native";
|
import {CreditCard, Plus} from "lucide-react-native";
|
||||||
import ContextMenu from "react-native-context-menu-view";
|
import ContextMenu from "react-native-context-menu-view";
|
||||||
|
|
||||||
import {AimeCard} from "../components/AimeCard";
|
import {ICCard} from "../components/ICCard";
|
||||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||||
|
|
||||||
import {AMCard} from "../models/AMCard";
|
import {AMCard} from "../models/AMCard";
|
||||||
@@ -21,19 +21,20 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
switch (indexPath.toString()) {
|
switch (indexPath.toString()) {
|
||||||
// copy card
|
// copy card access code
|
||||||
case "0":
|
case "0":
|
||||||
|
case "0,0":
|
||||||
await Clipboard.setStringAsync(props.card.code);
|
await Clipboard.setStringAsync(props.card.code);
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// edit card
|
// edit card
|
||||||
case "1,0":
|
case "1,0":
|
||||||
props.navigation.navigate('add-card', {cardId: props.card.code, cardName: props.card.name});
|
props.navigation.navigate('edit-card', {cardId: props.card._id.toHexString()});
|
||||||
return;
|
return;
|
||||||
|
|
||||||
// remove card
|
// remove card
|
||||||
case "1,1":
|
case "1,1":
|
||||||
realm.write(() => realm.delete(props.card));
|
AMCard.delete(realm, props.card);
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
}, [realm, props.card, props.navigation]);
|
}, [realm, props.card, props.navigation]);
|
||||||
@@ -50,7 +51,7 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
|
|||||||
{title: "Edit Card", systemIcon: "square.and.pencil"},
|
{title: "Edit Card", systemIcon: "square.and.pencil"},
|
||||||
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
|
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
|
||||||
}]}>
|
}]}>
|
||||||
<AimeCard card={props.card} showFullCardNumber/>
|
<ICCard card={props.card} showFullCardNumber/>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,7 +1,7 @@
|
|||||||
import {useModalStyles} from "../utils/ModalStyles";
|
import {useModalStyles} from "../utils/ModalStyles";
|
||||||
import {Card, Paragraph, useTheme} from "react-native-paper";
|
import {Card, Paragraph, useTheme} from "react-native-paper";
|
||||||
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
import RealmContext from "../models/RealmContext";
|
import RealmContext from "../models/RealmContext";
|
||||||
import {AMServer} from "../models/AMServer";
|
import {AMServer} from "../models/AMServer";
|
||||||
@@ -34,25 +34,48 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
let urlValid = false;
|
let urlValid = false;
|
||||||
|
|
||||||
try {
|
try {
|
||||||
new URL(serverAddress);
|
const protocol = new URL(serverAddress).protocol;
|
||||||
urlValid = true;
|
urlValid = protocol.localeCompare("http:", undefined, {sensitivity: "case"}) === 0 || protocol.localeCompare("https:", undefined, {sensitivity: "case"}) === 0;
|
||||||
} catch {
|
} catch {
|
||||||
// do nothing
|
// do nothing
|
||||||
}
|
}
|
||||||
|
|
||||||
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
navigation.setOptions({
|
navigation.setOptions({
|
||||||
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer()}>
|
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer(serverAddress)}>
|
||||||
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
||||||
</TouchableOpacity>
|
</TouchableOpacity>
|
||||||
})
|
})
|
||||||
}, [navigation, modalStyles, serverAddress]);
|
}, [navigation, modalStyles, serverAddress]);
|
||||||
|
|
||||||
const saveServer = useCallback(() => {
|
const saveServer = useCallback(async (address: string) => {
|
||||||
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == serverAddress);
|
if (address.startsWith("https:")) {
|
||||||
|
await new Promise<void>(done => {
|
||||||
|
Alert.alert(
|
||||||
|
"HTTPS Warning",
|
||||||
|
"By default, AMNet doesn't support HTTPS without advanced configuration. It's highly likely you won't be able to connect.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
text: "Keep HTTPS",
|
||||||
|
style: "destructive",
|
||||||
|
onPress: () => done()
|
||||||
|
},
|
||||||
|
{
|
||||||
|
text: "Change to HTTP",
|
||||||
|
isPreferred: true,
|
||||||
|
onPress: () => {
|
||||||
|
address = address.replace(/^https:/i, "http:");
|
||||||
|
done();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == address);
|
||||||
if (!serverObject) {
|
if (!serverObject) {
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
serverObject = realm.create<AMServer>("server", AMServer.generate(serverAddress));
|
serverObject = realm.create<AMServer>("server", AMServer.generate(address));
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -60,7 +83,7 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
setTimeout(() => navigation.navigate('server', {
|
setTimeout(() => navigation.navigate('server', {
|
||||||
serverId: serverObject._id.toHexString()
|
serverId: serverObject._id.toHexString()
|
||||||
}), 500);
|
}), 500);
|
||||||
}, [serverAddress, realm]);
|
}, [realm]);
|
||||||
|
|
||||||
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
|
||||||
<Card mode="contained">
|
<Card mode="contained">
|
||||||
@@ -85,7 +108,7 @@ export default function ServerAddModal({route, navigation}) {
|
|||||||
</Card>
|
</Card>
|
||||||
|
|
||||||
<Paragraph style={modalStyles.formFootnote}>
|
<Paragraph style={modalStyles.formFootnote}>
|
||||||
If you're using HTTPS, ensure a trusted certificate has been configured via HTTP.SYS
|
AMNet uses HTTP by default. Unless has been configured to work with HTTP.SYS, HTTPS will not work.
|
||||||
</Paragraph>
|
</Paragraph>
|
||||||
</View>)
|
</View>)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,39 +1,32 @@
|
|||||||
import {BSON} from "realm";
|
import {BSON} from "realm";
|
||||||
|
import NfcManager from "react-native-nfc-manager";
|
||||||
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
|
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
|
||||||
import {ActivityIndicator, Button, Paragraph, Title, useTheme} from "react-native-paper";
|
import {ActivityIndicator, Button, IconButton, Paragraph, Title, useTheme} from "react-native-paper";
|
||||||
import {
|
import {Platform, RefreshControl, ScrollView, Share, StyleSheet, Text, TouchableOpacity, View} from "react-native";
|
||||||
Alert,
|
|
||||||
Platform,
|
|
||||||
RefreshControl,
|
|
||||||
ScrollView,
|
|
||||||
Share,
|
|
||||||
StyleSheet,
|
|
||||||
Text,
|
|
||||||
TouchableOpacity,
|
|
||||||
View
|
|
||||||
} from "react-native";
|
|
||||||
import {
|
import {
|
||||||
CircleCheck,
|
CircleCheck,
|
||||||
CircleDot,
|
CircleDot,
|
||||||
CreditCard,
|
CreditCard,
|
||||||
Nfc,
|
|
||||||
Plus,
|
Plus,
|
||||||
SmartphoneNfcIcon,
|
SmartphoneNfcIcon,
|
||||||
Share as ShareIcon,
|
Share as ShareIcon,
|
||||||
WifiOff,
|
WifiOff,
|
||||||
TvMinimalPlay,
|
TvMinimalPlay,
|
||||||
Star
|
Star,
|
||||||
|
SmartphoneNfc,
|
||||||
|
SquareArrowOutUpRight
|
||||||
} from "lucide-react-native";
|
} from "lucide-react-native";
|
||||||
|
|
||||||
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
|
||||||
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
|
||||||
import {AMUICardDetail} from "../components/AMUICard";
|
import {AMUICardDetail} from "../components/AMUICard";
|
||||||
import {AimeCard} from "../components/AimeCard";
|
import {ICCard} from "../components/ICCard";
|
||||||
|
|
||||||
import RealmContext from "../models/RealmContext";
|
import RealmContext from "../models/RealmContext";
|
||||||
import {AMServer} from "../models/AMServer";
|
import {AMServer} from "../models/AMServer";
|
||||||
import {AMCard} from "../models/AMCard";
|
import {AMCard} from "../models/AMCard";
|
||||||
import ObjectId = BSON.ObjectId;
|
|
||||||
|
import {felicaCardReadAsync, icCard0008Number, ICType} from "../lib/iccard";
|
||||||
import {
|
import {
|
||||||
AMServerConnectionInfo,
|
AMServerConnectionInfo,
|
||||||
AMServerInstanceMetrics,
|
AMServerInstanceMetrics,
|
||||||
@@ -42,6 +35,8 @@ import {
|
|||||||
performCardIn
|
performCardIn
|
||||||
} from "amnet-shared";
|
} from "amnet-shared";
|
||||||
|
|
||||||
|
import ObjectId = BSON.ObjectId;
|
||||||
|
|
||||||
const {useRealm, useQuery, useObject} = RealmContext;
|
const {useRealm, useQuery, useObject} = RealmContext;
|
||||||
|
|
||||||
export default function ServerCardIn({route, navigation}) {
|
export default function ServerCardIn({route, navigation}) {
|
||||||
@@ -55,6 +50,16 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
query: q => q.sorted("lastUsed", true)
|
query: q => q.sorted("lastUsed", true)
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// nfc support
|
||||||
|
const [nfcSupported, setNfcSupported] = useState<boolean | null>(Platform.OS === 'android' ? false : null);
|
||||||
|
useEffect(() => {
|
||||||
|
if (nfcSupported !== null) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
NfcManager.isSupported().then(setNfcSupported);
|
||||||
|
}, []);
|
||||||
|
|
||||||
// server state
|
// server state
|
||||||
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
|
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
|
||||||
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
|
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
|
||||||
@@ -145,16 +150,21 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
}
|
}
|
||||||
}, [serverId, serverReloadCounter, realm]);
|
}, [serverId, serverReloadCounter, realm]);
|
||||||
|
|
||||||
const cardInCallback = useCallback(() => {
|
const cardInCallback = useCallback((card: AMCard) => {
|
||||||
setCardInCompleted(false);
|
setCardInCompleted(false);
|
||||||
|
|
||||||
const abortController = new AbortController();
|
const abortController = new AbortController();
|
||||||
performCardIn(server, {cardId: selectedCard.code}, abortController.signal).then(async r => {
|
|
||||||
|
performCardIn(server, {
|
||||||
|
cardId: card.code,
|
||||||
|
physicalCardIDm: card.physicalCardData?.cardIDm ?? undefined
|
||||||
|
}, abortController.signal).then(r => {
|
||||||
const now = new Date();
|
const now = new Date();
|
||||||
realm.write(() => {
|
realm.write(() => {
|
||||||
server.lastUsed = now;
|
server.lastUsed = now;
|
||||||
selectedCard.lastUsed = now;
|
|
||||||
selectedCard.lastUsedGameId = server.gameId;
|
card.lastUsed = now;
|
||||||
|
card.lastUsedGameId = server.gameId;
|
||||||
});
|
});
|
||||||
|
|
||||||
setCardInCompleted(r);
|
setCardInCompleted(r);
|
||||||
@@ -166,7 +176,39 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
|
|
||||||
setTimeout(() => abortController.abort(), 5000);
|
setTimeout(() => abortController.abort(), 5000);
|
||||||
return () => abortController.abort();
|
return () => abortController.abort();
|
||||||
}, [server, selectedCard]);
|
}, [server]);
|
||||||
|
|
||||||
|
const performPhysicalCardIn = useCallback(async () => {
|
||||||
|
const card = await felicaCardReadAsync();
|
||||||
|
|
||||||
|
if (!card) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const savedCard = cards.find(x => x.physicalCardData?.cardIDm === card.idm);
|
||||||
|
|
||||||
|
if (savedCard) {
|
||||||
|
setUserSelectedCard(savedCard);
|
||||||
|
cardInCallback(savedCard);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
setCardInCompleted(false);
|
||||||
|
|
||||||
|
const cardId = card.type !== ICType.Amusement ? icCard0008Number(card.idm) : card.accessCode;
|
||||||
|
const abortController = new AbortController();
|
||||||
|
|
||||||
|
performCardIn(server, {cardId, physicalCardIDm: card.idm}, abortController.signal).then(r => {
|
||||||
|
setCardInCompleted(r);
|
||||||
|
}).catch(() => {
|
||||||
|
setCardInCompleted(false);
|
||||||
|
}).finally(() => {
|
||||||
|
setTimeout(() => setCardInCompleted(null), 3000);
|
||||||
|
});
|
||||||
|
|
||||||
|
setTimeout(() => abortController.abort(), 5000);
|
||||||
|
return () => abortController.abort();
|
||||||
|
}, [server]);
|
||||||
|
|
||||||
const performRefresh = () => {
|
const performRefresh = () => {
|
||||||
setServerRefreshing(true);
|
setServerRefreshing(true);
|
||||||
@@ -187,35 +229,51 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<ScrollView contentInsetAdjustmentBehavior="automatic" refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
|
<ScrollView contentInsetAdjustmentBehavior="automatic"
|
||||||
|
refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
|
||||||
<View style={styles.container}>
|
<View style={styles.container}>
|
||||||
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
|
||||||
<View style={styles.section}>
|
<View style={styles.section}>
|
||||||
<Title style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Title>
|
<Title
|
||||||
|
style={styles.sectionTitle}>{server.gameId ? `Play ${getGameName(server.gameId)}` : "Selected Card"}</Title>
|
||||||
{selectedCard ? <>
|
{selectedCard ? <>
|
||||||
<AimeCard actionDisabled
|
<ICCard actionDisabled
|
||||||
card={selectedCard}
|
card={selectedCard}
|
||||||
actionIcon={<CircleCheck color="green" size={24}/>}>
|
actionIcon={<CircleCheck color="green" size={24}/>}>
|
||||||
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
|
||||||
</AimeCard>
|
</ICCard>
|
||||||
<Button textColor="white"
|
<View style={{display: "flex", flexDirection: "row", gap: 5}}>
|
||||||
onPress={cardInCallback}
|
<Button style={{flex: 1}}
|
||||||
disabled={disableCardIn}
|
textColor="white"
|
||||||
contentStyle={{flexDirection: 'row-reverse'}}
|
disabled={disableCardIn}
|
||||||
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
contentStyle={{flexDirection: 'row-reverse'}}
|
||||||
buttonColor={cardInCompleted === false ? "red" : "green"}
|
mode={serverConnectionSuccess ? "contained" : "outlined"}
|
||||||
icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
|
buttonColor={cardInCompleted === false ? "red" : "green"}
|
||||||
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
onPress={() => cardInCallback(selectedCard)}
|
||||||
</Button>
|
icon={() => <SquareArrowOutUpRight color={disableCardIn ? "gray" : "white"}
|
||||||
|
size={18}/>}>
|
||||||
|
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
|
||||||
|
</Button>
|
||||||
|
{nfcSupported === true && (
|
||||||
|
<IconButton
|
||||||
|
mode="outlined"
|
||||||
|
disabled={disableCardIn}
|
||||||
|
onPress={performPhysicalCardIn}
|
||||||
|
style={{borderRadius: 10, margin: 0, height: "100%", flex: 0.2}}
|
||||||
|
icon={() => <SmartphoneNfc
|
||||||
|
color={disableCardIn ? "gray" : theme.colors.onBackground} size={18}/>}/>
|
||||||
|
)}
|
||||||
|
</View>
|
||||||
</>
|
</>
|
||||||
: <View style={styles.emptyPlaceholderView}>
|
: <View style={styles.emptyPlaceholderView}>
|
||||||
<EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
|
<EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
|
||||||
<Button mode="text"
|
<Button mode="text"
|
||||||
icon={() => <Plus color={theme.colors.primary} size={18}/>}
|
icon={() => <Plus color={theme.colors.primary} size={18}/>}
|
||||||
onPress={() => navigation.navigate('add-card')}>
|
onPress={() => navigation.navigate('add-card')}>
|
||||||
Add card
|
Add Card
|
||||||
</Button>
|
</Button>
|
||||||
</View>}
|
</View>
|
||||||
|
}
|
||||||
</View>
|
</View>
|
||||||
|
|
||||||
{cards.length > 0 && (
|
{cards.length > 0 && (
|
||||||
@@ -224,20 +282,20 @@ export default function ServerCardIn({route, navigation}) {
|
|||||||
<Button mode="contained"
|
<Button mode="contained"
|
||||||
textColor="white"
|
textColor="white"
|
||||||
buttonColor={theme.colors.tertiary}
|
buttonColor={theme.colors.tertiary}
|
||||||
icon={() => <Plus color="white" size={18}/>}
|
|
||||||
contentStyle={{flexDirection: 'row-reverse'}}
|
contentStyle={{flexDirection: 'row-reverse'}}
|
||||||
|
icon={() => <Plus color="white" size={18}/>}
|
||||||
onPress={() => navigation.navigate('add-card')}>
|
onPress={() => navigation.navigate('add-card')}>
|
||||||
Add card
|
Add card
|
||||||
</Button>
|
</Button>
|
||||||
|
|
||||||
{unselectedCards.length
|
{unselectedCards.length
|
||||||
? unselectedCards.map((card: AMCard) => (
|
? unselectedCards.map((card: AMCard) => (
|
||||||
<AimeCard card={card}
|
<ICCard card={card}
|
||||||
key={card._id.toHexString()}
|
key={card._id.toHexString()}
|
||||||
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
|
||||||
onPress={() => setUserSelectedCard(card)}>
|
onPress={() => setUserSelectedCard(card)}>
|
||||||
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
|
||||||
</AimeCard>))
|
</ICCard>))
|
||||||
: <View style={styles.emptyPlaceholderView}>
|
: <View style={styles.emptyPlaceholderView}>
|
||||||
<EmptyStatePlaceholder message="No additional cards"
|
<EmptyStatePlaceholder message="No additional cards"
|
||||||
icon={<SmartphoneNfcIcon color="gray"/>}/>
|
icon={<SmartphoneNfcIcon color="gray"/>}/>
|
||||||
|
|||||||
@@ -87,7 +87,7 @@ export default function ServerManager({navigation}) {
|
|||||||
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
|
||||||
}
|
}
|
||||||
|
|
||||||
return q.sorted("lastUsed");
|
return q.sorted("lastUsed", true);
|
||||||
}, [query, searchQuery]);
|
}, [query, searchQuery]);
|
||||||
|
|
||||||
// enable searchbar
|
// enable searchbar
|
||||||
|
|||||||
@@ -0,0 +1,105 @@
|
|||||||
|
import NfcManager from "react-native-nfc-manager";
|
||||||
|
|
||||||
|
import React, {useEffect, useState} from "react";
|
||||||
|
import {Platform, TouchableOpacity, View} from "react-native";
|
||||||
|
import {createNativeStackNavigator} from "@react-navigation/native-stack";
|
||||||
|
|
||||||
|
import {Card, Paragraph, Text, Title, useTheme} from "react-native-paper";
|
||||||
|
import {SmartphoneNfc, SquareAsterisk, SquarePlus} from "lucide-react-native";
|
||||||
|
|
||||||
|
import ManualCardEntry from "./ManualCardEntry";
|
||||||
|
import PhysicalCardScan from "./PhyscialCardScan";
|
||||||
|
|
||||||
|
const Stack = createNativeStackNavigator();
|
||||||
|
|
||||||
|
function CardAddModal() {
|
||||||
|
return (
|
||||||
|
<Stack.Navigator initialRouteName="Select">
|
||||||
|
<Stack.Screen name="Select" component={CardAddMethodSelector} options={{headerShown: false}}/>
|
||||||
|
|
||||||
|
<Stack.Screen name="Scan" component={PhysicalCardScan} options={{title: "Add Physical Card"}}/>
|
||||||
|
<Stack.Screen name="Manual" component={ManualCardEntry} options={{title: "Add Card Manually"}}/>
|
||||||
|
</Stack.Navigator>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function CardAddMethodSelector({navigation}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const [nfcSupported, setNfcSupported] = useState<boolean | null>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
NfcManager.isSupported().then(setNfcSupported);
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={{paddingHorizontal: 15, paddingTop: 90, gap: 8}}>
|
||||||
|
<View style={{paddingBottom: 25, gap: 5}}>
|
||||||
|
<Title style={{textAlign: "center", fontSize: 32, fontWeight: "bold"}}>Add new Card</Title>
|
||||||
|
<Paragraph style={{textAlign: "center"}}>
|
||||||
|
AMNet supports scanning physical cards, entering details manually or generating new ones.
|
||||||
|
</Paragraph>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
{nfcSupported !== null && (
|
||||||
|
<>
|
||||||
|
<MethodCard color="#03a9f4"
|
||||||
|
icon={<SmartphoneNfc/>}
|
||||||
|
disabled={!nfcSupported}
|
||||||
|
title="Scan Physical Card"
|
||||||
|
description={nfcSupported ? "Amusement IC or Transport cards supported" : "NFC is not supported on this device"}
|
||||||
|
onPress={() => navigation.navigate('Scan')}/>
|
||||||
|
|
||||||
|
<View></View>
|
||||||
|
</>
|
||||||
|
)}
|
||||||
|
|
||||||
|
<MethodCard color={theme.colors.secondary}
|
||||||
|
icon={<SquareAsterisk/>}
|
||||||
|
title="Enter Details Manually"
|
||||||
|
onPress={() => navigation.navigate('Manual')}/>
|
||||||
|
|
||||||
|
<MethodCard color="#4caf50"
|
||||||
|
icon={<SquarePlus/>}
|
||||||
|
title="Generate New Card"
|
||||||
|
onPress={() => navigation.navigate('Manual', {autoGenerate: true})}/>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function MethodCard(props: {
|
||||||
|
title: string,
|
||||||
|
description?: string,
|
||||||
|
color: string,
|
||||||
|
disabled?: boolean,
|
||||||
|
icon: React.ReactElement,
|
||||||
|
onPress: () => void
|
||||||
|
}) {
|
||||||
|
const theme = useTheme();
|
||||||
|
const large = props.description?.length > 0;
|
||||||
|
|
||||||
|
return (
|
||||||
|
<TouchableOpacity onPress={() => !props.disabled && props.onPress()} disabled={props.disabled}>
|
||||||
|
<Card mode={theme.dark ? "elevated" : "contained"} disabled={props.disabled}>
|
||||||
|
<Card.Content style={{display: "flex", flexDirection: "row", alignItems: "center", gap: 15}}>
|
||||||
|
<View style={{
|
||||||
|
borderRadius: large ? 10 : 5,
|
||||||
|
height: large ? 50 : 30,
|
||||||
|
width: large ? 50 : 30,
|
||||||
|
backgroundColor: props.color,
|
||||||
|
justifyContent: "center",
|
||||||
|
alignItems: "center"
|
||||||
|
}}>
|
||||||
|
{React.cloneElement(props.icon, {size: large ? 24 : 16, color: theme.colors.surfaceVariant})}
|
||||||
|
</View>
|
||||||
|
<View style={{flexShrink: 1}}>
|
||||||
|
<Text style={{fontSize: large ? 16 : 14}}>{props.title}</Text>
|
||||||
|
{props.description && (<Paragraph style={{fontSize: 12}}>{props.description}</Paragraph>)}
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</TouchableOpacity>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
// the nfc library doesn't offer the same support for felica cards on android compared to ios...
|
||||||
|
export default Platform.OS === 'ios' ? CardAddModal : ManualCardEntry;
|
||||||
@@ -0,0 +1,97 @@
|
|||||||
|
import {BSON} from "realm";
|
||||||
|
import {Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
||||||
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
|
import {Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
import {AMCard} from "../../models/AMCard";
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
|
||||||
|
const {useObject, useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function CardEditorModal({navigation, route}) {
|
||||||
|
if (!route.params || !route.params.cardId) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const theme = useTheme();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
const realm = useRealm();
|
||||||
|
const card = useObject(AMCard, BSON.ObjectId.createFromHexString(route.params.cardId));
|
||||||
|
|
||||||
|
const [cardName, setCardName] = useState<string>('');
|
||||||
|
|
||||||
|
const updateCardCallback = useCallback((newName: string) => {
|
||||||
|
realm.write(() => {
|
||||||
|
card.name = newName;
|
||||||
|
});
|
||||||
|
|
||||||
|
navigation.goBack();
|
||||||
|
}, [realm, navigation]);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (card?.isValid() !== true) {
|
||||||
|
navigation.goBack();
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!cardName.length) {
|
||||||
|
setCardName(card.name);
|
||||||
|
}
|
||||||
|
}, [card]);
|
||||||
|
|
||||||
|
// header effects
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerLeft: () => <TouchableOpacity onPress={() => navigation.goBack()}>
|
||||||
|
<Paragraph style={modalStyles.headerText}>Cancel</Paragraph>
|
||||||
|
</TouchableOpacity>,
|
||||||
|
});
|
||||||
|
}, [navigation, modalStyles]);
|
||||||
|
}
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity onPress={() => updateCardCallback(cardName)} disabled={!cardName.length}>
|
||||||
|
<Paragraph style={modalStyles.headerText}>Save</Paragraph>
|
||||||
|
</TouchableOpacity>
|
||||||
|
});
|
||||||
|
}, [cardName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider style={{marginVertical: 15}}/>
|
||||||
|
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Access Code
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput readOnly
|
||||||
|
numberOfLines={1}
|
||||||
|
value={card?.code.match(/.{1,4}/g)?.join(" ")}
|
||||||
|
style={{...modalStyles.formItemInput, color: theme.colors.onSurfaceDisabled}}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,127 @@
|
|||||||
|
import {generateCardNumber, DecodeAMAccessCode} from "amnet-shared";
|
||||||
|
import {useCallback, useEffect, useLayoutEffect, useState} from "react";
|
||||||
|
import {Alert, Platform, TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
import {Button, Card, Divider, Paragraph, useTheme} from "react-native-paper";
|
||||||
|
|
||||||
|
import {AMCard} from "../../models/AMCard";
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
const {useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function CardAddModal({navigation, route}) {
|
||||||
|
const realm = useRealm();
|
||||||
|
const theme = useTheme();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
// state values
|
||||||
|
const [cardName, setCardName] = useState('');
|
||||||
|
const [cardNumber, setCardNumber] = useState('');
|
||||||
|
|
||||||
|
const cardAddCallback = useCallback(async (cardId: string, cardName: string) => {
|
||||||
|
try {
|
||||||
|
// validate AM card numbers
|
||||||
|
cardId.startsWith('5') && DecodeAMAccessCode(cardId);
|
||||||
|
} catch {
|
||||||
|
const shouldContinue = await new Promise<boolean>(done => {
|
||||||
|
Alert.alert(
|
||||||
|
"Invalid Access Code",
|
||||||
|
"It looks like the access code isn't from an Amusement IC card. Please double-check the code, and if it's correct, note that some networks might still reject it.",
|
||||||
|
[
|
||||||
|
{
|
||||||
|
style: "destructive",
|
||||||
|
text: "Continue Anyway",
|
||||||
|
onPress: () => done(true)
|
||||||
|
},
|
||||||
|
{
|
||||||
|
style: "cancel",
|
||||||
|
text: "Cancel",
|
||||||
|
onPress: () => done(false)
|
||||||
|
}
|
||||||
|
], {
|
||||||
|
cancelable: true,
|
||||||
|
onDismiss: () => done(false)
|
||||||
|
});
|
||||||
|
})
|
||||||
|
|
||||||
|
if (!shouldContinue) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const duplicateCard = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === cardId);
|
||||||
|
|
||||||
|
if (duplicateCard) {
|
||||||
|
realm.write(() => {
|
||||||
|
duplicateCard.name = cardName;
|
||||||
|
});
|
||||||
|
} else {
|
||||||
|
realm.write(() => {
|
||||||
|
realm.create<AMCard>(AMCard.schema.name, AMCard.generate(cardId, cardName));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if (Platform.OS === 'ios') {
|
||||||
|
navigation.getParent()?.goBack();
|
||||||
|
} else {
|
||||||
|
navigation.goBack();
|
||||||
|
}
|
||||||
|
}, [realm]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const canSave = cardName.length && cardNumber.length === 20;
|
||||||
|
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity disabled={!canSave}
|
||||||
|
onPress={() => cardAddCallback(cardNumber, cardName)}>
|
||||||
|
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
|
||||||
|
</TouchableOpacity>
|
||||||
|
})
|
||||||
|
}, [navigation, modalStyles, cardName, cardNumber]);
|
||||||
|
|
||||||
|
// check params for autoGenerate request
|
||||||
|
useEffect(() => {
|
||||||
|
if (route.params?.autoGenerate) {
|
||||||
|
setCardNumber(generateCardNumber());
|
||||||
|
}
|
||||||
|
}, []);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
|
||||||
|
<Divider style={{marginVertical: 15}}/>
|
||||||
|
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Access Code
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput numberOfLines={1}
|
||||||
|
keyboardType="numeric"
|
||||||
|
placeholder="0000 0000 0000 0000 0000"
|
||||||
|
placeholderTextColor={theme.colors.outline}
|
||||||
|
value={cardNumber.match(/.{1,4}/g)?.join(" ")}
|
||||||
|
onChangeText={val => setCardNumber(val?.replaceAll(/\D/g, '').substring(0, 20) ?? '')}
|
||||||
|
style={{...modalStyles.formItemInput, color: theme.colors.onSurface}}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Button mode="text" onPress={() => setCardNumber(generateCardNumber())}>Generate Access Code</Button>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -0,0 +1,84 @@
|
|||||||
|
import {useNavigation} from "@react-navigation/native";
|
||||||
|
import {Card, Paragraph, useTheme} from "react-native-paper";
|
||||||
|
import {useCallback, useLayoutEffect, useState} from "react";
|
||||||
|
import {TextInput, TouchableOpacity, View} from "react-native";
|
||||||
|
|
||||||
|
import {useModalStyles} from "../../utils/ModalStyles";
|
||||||
|
|
||||||
|
import RealmContext from "../../models/RealmContext";
|
||||||
|
import {AMCard, AMPhysicalCardData} from "../../models/AMCard";
|
||||||
|
import {felicaCardReadAsync, icCard0008Number, ICType} from "../../lib/iccard";
|
||||||
|
|
||||||
|
const {useRealm} = RealmContext;
|
||||||
|
|
||||||
|
export default function PhysicalCardScan() {
|
||||||
|
const realm = useRealm();
|
||||||
|
const theme = useTheme();
|
||||||
|
const navigation = useNavigation();
|
||||||
|
const modalStyles = useModalStyles();
|
||||||
|
|
||||||
|
const [cardName, setCardName] = useState<string>('');
|
||||||
|
|
||||||
|
const handleScan = useCallback(async () => {
|
||||||
|
const cardInfo = await felicaCardReadAsync();
|
||||||
|
|
||||||
|
if (!cardInfo) {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
realm.write(() => {
|
||||||
|
// check if the card has already been registered (convert a manually entered card to a physically scanned card)
|
||||||
|
const accessCode = cardInfo.type === ICType.Amusement ? cardInfo.accessCode : icCard0008Number(cardInfo.idm);
|
||||||
|
const cardEntity = realm.objects<AMCard>(AMCard.schema.name).find(x => x.code === accessCode) ?? realm.create<AMCard>(AMCard.schema.name, AMCard.generate(accessCode, cardName));
|
||||||
|
|
||||||
|
// set physical card props
|
||||||
|
cardEntity.name = cardName;
|
||||||
|
cardEntity.added = new Date();
|
||||||
|
cardEntity.physicalCardData ??= realm.objectForPrimaryKey<AMPhysicalCardData>(AMPhysicalCardData.schema.name, cardInfo.idm) ?? realm.create<AMPhysicalCardData>(AMPhysicalCardData.schema.name, {
|
||||||
|
cardIDm: cardInfo.idm,
|
||||||
|
cardType: cardInfo.type,
|
||||||
|
cardVendor: cardInfo.type === ICType.Amusement ? cardInfo.vendor : null
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// dismiss the parent screen (this screen can only be accessed from modal -> stack)
|
||||||
|
navigation.getParent()?.goBack();
|
||||||
|
}, [realm, navigation, cardName]);
|
||||||
|
|
||||||
|
useLayoutEffect(() => {
|
||||||
|
const canSave = cardName.length;
|
||||||
|
const themeOptions = canSave ? modalStyles.headerText : {color: theme.colors.outline};
|
||||||
|
|
||||||
|
navigation.setOptions({
|
||||||
|
headerRight: () => <TouchableOpacity disabled={!canSave} onPress={() => handleScan()}>
|
||||||
|
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Scan</Paragraph>
|
||||||
|
</TouchableOpacity>
|
||||||
|
})
|
||||||
|
}, [navigation, modalStyles, cardName]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<View style={modalStyles.rootLayout}>
|
||||||
|
<Card mode="contained">
|
||||||
|
<Card.Content>
|
||||||
|
<View style={modalStyles.formItemRow}>
|
||||||
|
<Paragraph style={modalStyles.formItemName}>
|
||||||
|
Card Name
|
||||||
|
</Paragraph>
|
||||||
|
<TextInput style={modalStyles.formItemInput}
|
||||||
|
autoFocus
|
||||||
|
numberOfLines={1}
|
||||||
|
value={cardName}
|
||||||
|
onChangeText={setCardName}
|
||||||
|
placeholder="Primary Card"
|
||||||
|
placeholderTextColor={theme.colors.outline}/>
|
||||||
|
</View>
|
||||||
|
</Card.Content>
|
||||||
|
</Card>
|
||||||
|
|
||||||
|
<Paragraph style={modalStyles.formFootnote}>
|
||||||
|
Ensure you have either an Amusement IC or Transport card ready to scan.
|
||||||
|
After the scan has completed, the card will be saved to your device.
|
||||||
|
</Paragraph>
|
||||||
|
</View>
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,36 +0,0 @@
|
|||||||
import {ReactNode, useMemo} from "react";
|
|
||||||
import {Text} from "react-native";
|
|
||||||
import {CreditCard, History} from "lucide-react-native";
|
|
||||||
|
|
||||||
import TimeAgo from "./TimeAgo";
|
|
||||||
import {AMCardData} from "../models/AMCard";
|
|
||||||
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
|
|
||||||
|
|
||||||
export interface AimeCardProps extends AMUICardActionProps {
|
|
||||||
card: AMCardData,
|
|
||||||
children?: ReactNode,
|
|
||||||
showFullCardNumber?: boolean
|
|
||||||
}
|
|
||||||
|
|
||||||
export function AimeCard(props: AimeCardProps) {
|
|
||||||
const displayedCardNumber = useMemo(() => props.showFullCardNumber
|
|
||||||
? props.card.code.match(/.{1,4}/g)?.join(" ")
|
|
||||||
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
|
|
||||||
[props.showFullCardNumber, props.card.code]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<AMUICard title={props.card.name} {...props}>
|
|
||||||
<AMUICardDetail>
|
|
||||||
<CreditCard size={18} color="gray"/>
|
|
||||||
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
|
|
||||||
</AMUICardDetail>
|
|
||||||
{props.card.lastUsed && (
|
|
||||||
<AMUICardDetail>
|
|
||||||
<History size={18} color="gray"/>
|
|
||||||
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
|
|
||||||
</AMUICardDetail>
|
|
||||||
)}
|
|
||||||
{props.children}
|
|
||||||
</AMUICard>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,85 @@
|
|||||||
|
import {Text} from "react-native";
|
||||||
|
import {ReactNode, useMemo} from "react";
|
||||||
|
import {CreditCard, History, Joystick, RailSymbol} from "lucide-react-native";
|
||||||
|
|
||||||
|
import TimeAgo from "./TimeAgo";
|
||||||
|
import {AMUICard, AMUICardActionProps, AMUICardDetail} from "./AMUICard";
|
||||||
|
|
||||||
|
import {AMCardData, AMPhysicalCardData} from "../models/AMCard";
|
||||||
|
import {AmusementICVendor, ICType} from "../lib/iccard";
|
||||||
|
|
||||||
|
export interface ICCardProps extends AMUICardActionProps {
|
||||||
|
card: AMCardData,
|
||||||
|
children?: ReactNode,
|
||||||
|
showFullCardNumber?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export function ICCard(props: ICCardProps) {
|
||||||
|
const displayedCardNumber = useMemo(() => props.showFullCardNumber
|
||||||
|
? props.card.code.match(/.{1,4}/g)?.join(" ")
|
||||||
|
: `•••• ${props.card.code.substring(props.card.code.length - 4)}`,
|
||||||
|
[props.showFullCardNumber, props.card.code]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<AMUICard title={props.card.name} {...props}>
|
||||||
|
<PhysicalICInfo physicalCard={props.card.physicalCardData}/>
|
||||||
|
|
||||||
|
<AMUICardDetail>
|
||||||
|
<CreditCard size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray", fontFamily: "JetBrainsMono_400Regular"}}>{displayedCardNumber}</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
|
||||||
|
{props.card.lastUsed && (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<History size={18} color="gray"/>
|
||||||
|
<TimeAgo style={{color: "gray"}} dateTo={props.card.lastUsed}/>
|
||||||
|
</AMUICardDetail>
|
||||||
|
)}
|
||||||
|
{props.children}
|
||||||
|
</AMUICard>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function PhysicalICInfo(props: { physicalCard: AMPhysicalCardData | null }) {
|
||||||
|
if (props.physicalCard?.cardType === ICType.Transport) {
|
||||||
|
return (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<RailSymbol size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray"}}>Transport IC Card</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
if (props.physicalCard?.cardVendor) {
|
||||||
|
return (
|
||||||
|
<AMUICardDetail>
|
||||||
|
<Joystick size={18} color="gray"/>
|
||||||
|
<Text style={{color: "gray"}}>
|
||||||
|
{props.physicalCard.isBootlegAmusementCard ? "Bootleg " : null}{ICVendorName(props.physicalCard.cardVendor)}
|
||||||
|
</Text>
|
||||||
|
</AMUICardDetail>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ICVendorName(vendor: AmusementICVendor): string {
|
||||||
|
switch (vendor) {
|
||||||
|
case AmusementICVendor.Sega:
|
||||||
|
return "AiMe Card";
|
||||||
|
|
||||||
|
case AmusementICVendor.Konami:
|
||||||
|
return "e-Amusement Pass";
|
||||||
|
|
||||||
|
case AmusementICVendor.BandaiNamcoGames:
|
||||||
|
case AmusementICVendor.BandaiNamcoEntertainment:
|
||||||
|
return "Banapass";
|
||||||
|
|
||||||
|
case AmusementICVendor.Nesica:
|
||||||
|
return "NESiCA Card";
|
||||||
|
|
||||||
|
default:
|
||||||
|
return "Amusement IC Card";
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,49 @@
|
|||||||
|
import {Buffer} from "buffer";
|
||||||
|
|
||||||
|
const s_box_inv = [
|
||||||
|
"243cba36e385a4d0934373b9706ec9f1100e9b2c97e70b636c2920fe86f3e1f5f69fb616047b8fabb1392a1beb5ca8ac3811125f893e7dcaec53db6d1ed281789646fff9541c287a4fd3c0dcc16af2bccb57fd4ae4f0b2c79540625241e2ad49a6b51f02c8da92e5b0c564764821de0f45584cdfa784cfd5154e27806b7ffc44714722dd300ca13be037a03523903274bf8dc2ead650bbd9c483b43168558b5df47218b7eff7982d010361cc0a8e1300e814af095175a52f1d0d658acd66073d05d84be99d997c91d1b819c32b426988c679173a4d5ba25afb253fbdf8edcee687a326a92ebe94087e67608c9c5e6fb39e06fa82aeee5977d71a9a34d456aa33",
|
||||||
|
"f9817c00b93037d590516ef0b206fbcd39145ff81bc74a82708c921deac34ec812e6b610d32f95842542a572e58f55ef86a253aeed262047de786828e445cc35bc3ebb8ec0be34afa60964017b44f5f3b13fa4b33280c94f6f40bf214c74e1115eeb3d712e9d6275d41648771367ad6b5d0787f7a89a59768b9b1ed8eeaae9992dc597f1a783fccadcbab84be88917050ba06523d1ce3603d7e0f291df0e9c2c0d66d673585cb40a4dc23bd2b7e2ac33db6027bd56432404028d7d7ff6b5f4fddd5bec79c4221fa18854e71998947e31a329e35acb6ab0cf6d936c7a08a93c1cd06350c68538c14149ab61522a9fd9181a57462b69dafaff0f15963a8afe9e0c",
|
||||||
|
"11a29ac394a051015bf7d130ee1f06d57767d3c16ee76a1aa48e9f6566d60c2dc6e469b7565a5760fc791ee11652f007d0cdca78c98bb38827f6e0c084cf2fa304008040a7fa759d4b89462228373413ff20b4da08266c8ff25d7b735c4c9071418a9ebb12e8556d2a254aafbd9519a531baadd9c5a66b83c4b6a9ccf9a1b17adcc7db2e7e7f8d4e620f0339fdb8d418c2ec6102531c3b7dbc1d59f58c98f16f9385f82c74d85e4d97ab87820a21425f0d5833443adde9fb5047c8d7819be51b1754862beff429320e7c2315ebe63c35e296683f0b43cede9cbe4f457276b510e3df92d2a84891b9acbf49b2996470eaf33e6314aeedaa24fe3dcbb009383605",
|
||||||
|
"c4f02849554af5fd75af2069c843866bc9a8c6544cdd025be89b597734d7c0515ff37ed4f19081ce198a7833cd978cd66a4b8fa480dc1a1714c5078766ad9d85b2f88d98df3e1f3f645840ac6c5c085d53baffd2a92c25abe432389af9cbc29167d0e322292d9248b40e9905a36f0a15efd5104fd8f600e6462b31578394ae03eb8e1324a11e5ed126d9a7ca366e7137eeb1fa7c7606b823be21bc9ec1da7a3b62277bb0e56318827f0feda02a9cbf11bde0880d3a795256f7b6d309161b70b342602f1ca29f72124547bbe10b018b1ddeec0c4ee9cfcc9574f4a593c7db4dfb3565fee739b5ea96c304414484fc6d30e2f268b7897d733db95a50a63c61aa2e",
|
||||||
|
"1a599cadc8e41154ed370f3ae65f3c4bb81589b1e8da697791568bdb0624cf18f8b087df8c35cb86539da4664a7a710a4838ffdc8320ce9832f6d7af705e738a14721ec129790708e94346d02fde2a4f3d2c50b375fc0b64ae317b61a530a093d2bec255ba6cf36268fdac3b95491f6bb485f7a603ec6e9a81090c6aee9e4ef0ab2d7ea1e1bbc9bc41f2fa2e1bdd2734d36004b501d640f9d50247d9d8e08f2bfbb7c5eb5716e5788df400cd8239c696bdbfe336450e261d63e7843eb69b22a3f58e236dc3997d9097102580d44ce274ccb25c33c7ea05123f51a8fef11c42cad176286f92a767a919a2445baaef587f21c08865b95d4d0d5ac413527c9f9417",
|
||||||
|
"c72b82615bd09684d34a70a19b59339fc02014532917c50bc97b97020d3a1e7c3f6b52e8753df6e40c8a4fc35f2665733123284874aaa73609b1e291045122fc08a605a4f1121c19eb4037c2a0411dd4dc07438f47afd12e98ab01baf06668acf9e769b6cb8d78871503d5dfa31a9d6aea2f944e9e42d7b83892d8bbdedd9abcb04c79f4583ee98381ffe355fd5db2ef9c6d549960da3becfa11d6c42aed4bae13bfb9068be01f7f5aad90390ff3bd466c2cf2f8fed9e6720e89be5ec6a8cff5577e8cb7e1887ac81b18db6f35d216326463a58ef7b47695867de5a95c8500c1934dfb30a2b5253410773c67712d4445560a50b3cd6280ce214924caee276ecc",
|
||||||
|
"a5b06db251551fbf3e8ddd1992ea1bbd326529a48967b1906800da0da65954f210142f45e03b23fad750ac93e643010a5c78709846a97bf395072ad374b33fa36082394e3448c01cf6c291644d3cf89d359a94e37af0f96eb912b8042d02281385728087db2bf14f26a2e1497e9ccca7b6a0d09b3677ad8b6b4a031d058a064bafe531b5d4c60c66ba83fd090fae71b76fdc41e8178e407f6230ffa88425fb16ce3744ab991eeb183a47f75f81cfed582c6cfe9e57539720ca791a5a88f5699fe7d90ebe42df56e44c22aa730b15c5eefcc7d6cbcd8ce27621e9d1ecc87dd88f617c2ebcdeb475d2c4633da15e5d6a0824c927bbef33865bd5385211f4c396c1",
|
||||||
|
"345adb2c5957122b30c2a092bfedbc45de279b96d3e6c5ebd8244ba421cca8d6ce3cbab109e0d732664a831d19ca89670f420771e9bb445e8517c4ae9c3f6b78e76d02a7fe33e3d90a7deaf118877b6203795223003e25b9dd16683b1f90a62ac629918a9def1e8476ee4f39fb111b0b93ad49b705e14dcbe838d055f2f70d8b65cdfad49ec9814e14c826b6f9aaf580f86a98ab58f3b88eb5974372a2da64c1405c13b0e5bd089f1c7e8f06be6e501a2f37a1fc102e705304206348e2fd6f7a7561b3353acf5b888251d247a5a9746c31947c9ac0ec150e28012dc3f0b4e48ddcac3d5f86c79522f4b2a35446d1778c0cffaf565d36697f994cd56073df41f6",
|
||||||
|
"04da79631ebdeae30b652501cda992ed187557308903096adcc798a450911fb10c778566ca121c67c2e01783599dd5228256a89fc66048b311fca328fb06dd5dba297a4fe8fee910cbf3937b6c6954e744a2841d8dcefffa1a879074a1f814aabcc0cf31b4d9bfd85e262dd0e43f19d68c2fab3958726edf3be63cb66288de40b28f9b7c9543d19eac9cd423e10e4b5333462013341b97f7f1c3614a6f5a217f702e554105c5d776e52715ec425c4d78358befd2eeb5beae023ad35fc424f0f9514eeb000ffdaf3ec19a528681807ef62bccb97d68f2ad99a7072c7338b06bb78e71a0f43da60d37db0a473616966d322a5be24594f5a54cc88a4964bb08c9b8"
|
||||||
|
];
|
||||||
|
|
||||||
|
function rotate_right(data: Uint8Array, nBytes: number, nBits: number) {
|
||||||
|
let prior = data[nBytes - 1];
|
||||||
|
|
||||||
|
for (let i = 0; i < nBytes; i++) {
|
||||||
|
let current = data[i];
|
||||||
|
|
||||||
|
data[i] = (current >> nBits) | ((prior & ((1 << nBits) - 1)) << (8 - nBits));
|
||||||
|
prior = current;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const S_BOX_INV = s_box_inv.map(x => new Uint8Array(Buffer.from(x, 'hex')));
|
||||||
|
const N_TABLES = S_BOX_INV.length - 1;
|
||||||
|
const ITER_ADD = 5;
|
||||||
|
|
||||||
|
export function performDecrypt(data: Uint8Array) {
|
||||||
|
const spad = data.map(x => S_BOX_INV[N_TABLES][x]);
|
||||||
|
|
||||||
|
let count = (spad[15] >> 4) + 7;
|
||||||
|
let table = spad[15] + ITER_ADD * count;
|
||||||
|
|
||||||
|
for (let z = 0; z < count; z++) {
|
||||||
|
table -= ITER_ADD;
|
||||||
|
rotate_right(spad, 15, 5);
|
||||||
|
|
||||||
|
for (let i = 0; i < 15; i++) {
|
||||||
|
spad[i] = S_BOX_INV[table % N_TABLES][spad[i]];
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
version: spad[0],
|
||||||
|
accessCodeBytes: spad.slice(6) // access code is a binary-coded decimal
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -0,0 +1,133 @@
|
|||||||
|
import {Buffer} from "buffer";
|
||||||
|
import NfcManager, {NfcTech} from "react-native-nfc-manager";
|
||||||
|
|
||||||
|
import {performDecrypt} from "./iccard-crypto";
|
||||||
|
|
||||||
|
export enum ICType {
|
||||||
|
Amusement = 1,
|
||||||
|
Transport = 2
|
||||||
|
}
|
||||||
|
|
||||||
|
export enum AmusementICVendor {
|
||||||
|
Sega = 0x0078,
|
||||||
|
Konami = 0x0068,
|
||||||
|
BandaiNamcoEntertainment = 0x002A,
|
||||||
|
BandaiNamcoGames = 0x003A,
|
||||||
|
Nesica = 0x0079,
|
||||||
|
Unknown = 0x0000
|
||||||
|
}
|
||||||
|
|
||||||
|
type AmusementICCard = {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
|
||||||
|
idm: string,
|
||||||
|
accessCode: string,
|
||||||
|
vendor: AmusementICVendor
|
||||||
|
}
|
||||||
|
|
||||||
|
type TransportICCard = {
|
||||||
|
type: ICType.Transport,
|
||||||
|
|
||||||
|
idm: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export type ICCard = AmusementICCard | TransportICCard;
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Converts an IC card IDm to the 20-digit "0008" number used by some networks
|
||||||
|
* @param idm The IDm to convert
|
||||||
|
*/
|
||||||
|
export function icCard0008Number(idm: string): string {
|
||||||
|
// 0008 is the numerical representation of the idm padded with zeros until it is 20 digits
|
||||||
|
return BigInt(`0x${idm}`).toString().padStart(20, '0');
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Reads a FeliCa card, returning access code information.
|
||||||
|
*/
|
||||||
|
export async function felicaCardReadAsync(): Promise<ICCard | null> {
|
||||||
|
try {
|
||||||
|
await NfcManager.requestTechnology(NfcTech.FelicaIOS, {
|
||||||
|
isReaderModeEnabled: true,
|
||||||
|
invalidateAfterFirstRead: false,
|
||||||
|
alertMessage: "Place an Amusement IC or Transport card on your device."
|
||||||
|
});
|
||||||
|
|
||||||
|
const tag = await NfcManager.getTag();
|
||||||
|
const idm = tag['idm'] as string;
|
||||||
|
|
||||||
|
switch (tag['systemCode'] as string) {
|
||||||
|
case "0003":
|
||||||
|
return {
|
||||||
|
type: ICType.Transport,
|
||||||
|
idm
|
||||||
|
};
|
||||||
|
|
||||||
|
case "88B4":
|
||||||
|
return await readAmusementICCardContents(idm);
|
||||||
|
|
||||||
|
default:
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
} catch (ex) {
|
||||||
|
console.warn(ex);
|
||||||
|
return null;
|
||||||
|
} finally {
|
||||||
|
await NfcManager.cancelTechnologyRequest();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function readAmusementICCardContents(idm: string): Promise<AmusementICCard | null> {
|
||||||
|
const idmBytes = Uint8Array.from(Buffer.from(idm, 'hex'));
|
||||||
|
const readCommand = [
|
||||||
|
0x06, // read
|
||||||
|
...idmBytes,
|
||||||
|
0x01, // service count (fixed)
|
||||||
|
0x0B, 0x00, // service code (000b, le)
|
||||||
|
0x02, // block count
|
||||||
|
0x80, 0x00, // S_PAD0 block
|
||||||
|
0x80, 0x82 // ID block
|
||||||
|
];
|
||||||
|
|
||||||
|
let response = await NfcManager.sendFelicaCommandIOS(readCommand);
|
||||||
|
const offset = response.indexOf(0x07); // look for 0x07 as for some reason the response may not start at response[0]
|
||||||
|
|
||||||
|
// response must have 44 bytes
|
||||||
|
if (offset === -1 || response.length - offset !== 44) {
|
||||||
|
return null;
|
||||||
|
} else if (offset !== 0) {
|
||||||
|
response = response.slice(offset);
|
||||||
|
}
|
||||||
|
|
||||||
|
// response format:
|
||||||
|
// 0 - read response (0x07)
|
||||||
|
// 1-8 - IDm
|
||||||
|
// 9 - status flag 1
|
||||||
|
// 10 - status flag 2
|
||||||
|
// 11 - number of blocks (0x02)
|
||||||
|
// 12-27 - S_PAD0 block
|
||||||
|
// 28-43 - ID block (DFC is 35-36)
|
||||||
|
|
||||||
|
if (response[9] !== 0x00 || response[11] !== 0x02) {
|
||||||
|
return null;
|
||||||
|
}
|
||||||
|
|
||||||
|
const dfcValue = response[37] | (response[36] << 8);
|
||||||
|
|
||||||
|
if (dfcValue in AmusementICVendor && dfcValue !== AmusementICVendor.Unknown) {
|
||||||
|
const accessCode = performDecrypt(new Uint8Array(response.slice(12, 28)));
|
||||||
|
return {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
accessCode: [...accessCode.accessCodeBytes].map(x => x.toString(16).padStart(2, '0')).join(''),
|
||||||
|
vendor: dfcValue,
|
||||||
|
idm
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
type: ICType.Amusement,
|
||||||
|
accessCode: icCard0008Number(idm),
|
||||||
|
vendor: AmusementICVendor.Unknown,
|
||||||
|
idm
|
||||||
|
};
|
||||||
|
}
|
||||||
@@ -6,7 +6,8 @@ module.exports = (async () => {
|
|||||||
const config = await getDefaultConfig(__dirname);
|
const config = await getDefaultConfig(__dirname);
|
||||||
|
|
||||||
config.resolver.extraNodeModules = {
|
config.resolver.extraNodeModules = {
|
||||||
'amnet-shared': resolve(__dirname, '../amnet-shared')
|
'amnet-shared': resolve(__dirname, '../amnet-shared'),
|
||||||
|
'@babel/runtime': resolve(__dirname, 'node_modules/@babel/runtime')
|
||||||
};
|
};
|
||||||
|
|
||||||
config.watchFolders = [
|
config.watchFolders = [
|
||||||
|
|||||||
@@ -1,4 +1,5 @@
|
|||||||
import {Realm} from "realm";
|
import {Realm} from "realm";
|
||||||
|
import {AmusementICVendor, ICType} from "../lib/iccard";
|
||||||
|
|
||||||
export interface AMCardData {
|
export interface AMCardData {
|
||||||
code: string;
|
code: string;
|
||||||
@@ -6,6 +7,7 @@ export interface AMCardData {
|
|||||||
|
|
||||||
added: Date;
|
added: Date;
|
||||||
lastUsed?: Date | null;
|
lastUsed?: Date | null;
|
||||||
|
physicalCardData?: AMPhysicalCardData | null;
|
||||||
}
|
}
|
||||||
|
|
||||||
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
||||||
@@ -18,6 +20,8 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
|||||||
lastUsed?: Date;
|
lastUsed?: Date;
|
||||||
lastUsedGameId?: string | null;
|
lastUsedGameId?: string | null;
|
||||||
|
|
||||||
|
physicalCardData?: AMPhysicalCardData | null = null;
|
||||||
|
|
||||||
static generate(code: string, name: string) {
|
static generate(code: string, name: string) {
|
||||||
return {
|
return {
|
||||||
_id: new Realm.BSON.ObjectId(),
|
_id: new Realm.BSON.ObjectId(),
|
||||||
@@ -27,6 +31,16 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
|||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
|
static delete(realm: Realm, card: AMCard) {
|
||||||
|
realm.write(() => {
|
||||||
|
if (card.physicalCardData) {
|
||||||
|
realm.delete(card.physicalCardData);
|
||||||
|
}
|
||||||
|
|
||||||
|
realm.delete(card);
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
static schema: Realm.ObjectSchema = {
|
static schema: Realm.ObjectSchema = {
|
||||||
name: "card",
|
name: "card",
|
||||||
primaryKey: "_id",
|
primaryKey: "_id",
|
||||||
@@ -36,8 +50,32 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
|
|||||||
code: "string",
|
code: "string",
|
||||||
name: {type: "string", default: "New Card"},
|
name: {type: "string", default: "New Card"},
|
||||||
added: {type: "date", default: new Date()},
|
added: {type: "date", default: new Date()},
|
||||||
lastUsed: {type: "date", optional: true},
|
lastUsed: {type: "date", optional: true, indexed: true},
|
||||||
lastUsedGameId: {type: "string", optional: true}
|
lastUsedGameId: {type: "string", optional: true, indexed: true},
|
||||||
|
physicalCardData: {type: "object", objectType: "physicalCard", optional: true}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
export class AMPhysicalCardData extends Realm.Object<AMPhysicalCardData> {
|
||||||
|
|
||||||
|
cardIDm: string;
|
||||||
|
cardType: ICType;
|
||||||
|
cardVendor: AmusementICVendor | null;
|
||||||
|
|
||||||
|
get isBootlegAmusementCard() {
|
||||||
|
// real cards have an IDm manufacturer code of 012E (first two bytes of the IDm or in this case, the first 4 characters)
|
||||||
|
return this.cardType === ICType.Amusement && !this.cardIDm.startsWith("012E");
|
||||||
|
}
|
||||||
|
|
||||||
|
static schema: Realm.ObjectSchema = {
|
||||||
|
name: "physicalCard",
|
||||||
|
primaryKey: "cardIDm",
|
||||||
|
|
||||||
|
properties: {
|
||||||
|
cardIDm: "string",
|
||||||
|
cardType: "int",
|
||||||
|
cardVendor: {type: "int", optional: true}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -13,6 +13,8 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerConnecti
|
|||||||
lastUsed: Date;
|
lastUsed: Date;
|
||||||
createdAt: Date;
|
createdAt: Date;
|
||||||
|
|
||||||
|
preferredCardIdFormat?: "idm" | "access" | null;
|
||||||
|
|
||||||
get shareUrl() {
|
get shareUrl() {
|
||||||
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
|
return `http://card.ppc.moe/#/servers/add?address=${encodeURIComponent(this.serverAddress)}`;
|
||||||
}
|
}
|
||||||
@@ -40,9 +42,9 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerConnecti
|
|||||||
apiVersion: {type: "int", default: 0},
|
apiVersion: {type: "int", default: 0},
|
||||||
gameId: {type: "string", optional: true},
|
gameId: {type: "string", optional: true},
|
||||||
serverAddress: "string",
|
serverAddress: "string",
|
||||||
serverName: {type: "string", default: "New Server"},
|
serverName: {type: "string", default: "New Server", indexed: "full-text"},
|
||||||
createdAt: {type: "date", default: new Date()},
|
createdAt: {type: "date", default: new Date()},
|
||||||
lastUsed: {type: "date", optional: true}
|
lastUsed: {type: "date", optional: true, indexed: true}
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
@@ -1,10 +1,10 @@
|
|||||||
import {createRealmContext} from "@realm/react";
|
import {createRealmContext} from "@realm/react";
|
||||||
import {AMCard} from "./AMCard";
|
import {AMCard, AMPhysicalCardData} from "./AMCard";
|
||||||
import {AMServer} from "./AMServer";
|
import {AMServer} from "./AMServer";
|
||||||
|
|
||||||
// @ts-ignore
|
// @ts-ignore
|
||||||
export default createRealmContext({
|
export default createRealmContext({
|
||||||
schemaVersion: 3,
|
schemaVersion: 4,
|
||||||
schema: [AMCard, AMServer],
|
schema: [AMPhysicalCardData, AMCard, AMServer],
|
||||||
deleteRealmIfMigrationNeeded: false
|
deleteRealmIfMigrationNeeded: false
|
||||||
});
|
});
|
||||||
Generated
+306
-633
File diff suppressed because it is too large
Load Diff
+16
-13
@@ -6,9 +6,10 @@
|
|||||||
"android": "expo run:android",
|
"android": "expo run:android",
|
||||||
"ios": "expo run:ios",
|
"ios": "expo run:ios",
|
||||||
"ios-prebuild": "expo prebuild --platform ios",
|
"ios-prebuild": "expo prebuild --platform ios",
|
||||||
"postinstall": "patch-package"
|
"android-prebuild": "expo prebuild --platform android"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@babel/runtime": "^7.26.0",
|
||||||
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
|
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
|
||||||
"@react-navigation/bottom-tabs": "^6.6.1",
|
"@react-navigation/bottom-tabs": "^6.6.1",
|
||||||
"@react-navigation/native": "^6.1.18",
|
"@react-navigation/native": "^6.1.18",
|
||||||
@@ -17,29 +18,31 @@
|
|||||||
"@types/react": "~18.3.12",
|
"@types/react": "~18.3.12",
|
||||||
"amnet-shared": "file:../amnet-shared",
|
"amnet-shared": "file:../amnet-shared",
|
||||||
"date-fns": "^3.6.0",
|
"date-fns": "^3.6.0",
|
||||||
"expo": "^52.0.0",
|
"expo": "^52.0.27",
|
||||||
"expo-blur": "~14.0.1",
|
"expo-blur": "~14.0.3",
|
||||||
"expo-build-properties": "^0.13.1",
|
"expo-build-properties": "~0.13.2",
|
||||||
"expo-clipboard": "~7.0.0",
|
"expo-clipboard": "~7.0.1",
|
||||||
"expo-constants": "~17.0.3",
|
"expo-constants": "~17.0.3",
|
||||||
"expo-dev-client": "~5.0.4",
|
"expo-dev-client": "~5.0.10",
|
||||||
"expo-linking": "~7.0.3",
|
"expo-linking": "~7.0.4",
|
||||||
"expo-status-bar": "~2.0.0",
|
"expo-status-bar": "~2.0.1",
|
||||||
"expo-system-ui": "~4.0.4",
|
"expo-system-ui": "~4.0.7",
|
||||||
"lucide-react-native": "^0.427.0",
|
"lucide-react-native": "^0.427.0",
|
||||||
"patch-package": "^8.0.0",
|
"pusher-js": "^8.2.0",
|
||||||
"react": "18.3.1",
|
"react": "18.3.1",
|
||||||
"react-native": "0.76.5",
|
"react-native": "0.76.6",
|
||||||
"react-native-context-menu-view": "^1.16.0",
|
"react-native-context-menu-view": "^1.16.0",
|
||||||
"react-native-gesture-handler": "~2.20.2",
|
"react-native-gesture-handler": "~2.20.2",
|
||||||
"react-native-get-random-values": "^1.11.0",
|
"react-native-get-random-values": "^1.11.0",
|
||||||
|
"react-native-nfc-manager": "^3.16.1",
|
||||||
"react-native-paper": "^5.12.5",
|
"react-native-paper": "^5.12.5",
|
||||||
"react-native-reanimated": "~3.16.1",
|
"react-native-reanimated": "~3.16.1",
|
||||||
"react-native-safe-area-context": "4.12.0",
|
"react-native-safe-area-context": "4.12.0",
|
||||||
"react-native-screens": "~4.1.0",
|
"react-native-screens": "~4.4.0",
|
||||||
"react-native-svg": "15.8.0",
|
"react-native-svg": "15.8.0",
|
||||||
"react-native-url-polyfill": "^2.0.0",
|
"react-native-url-polyfill": "^2.0.0",
|
||||||
"realm": "^12.14.0",
|
"realm": "^12.14.0",
|
||||||
"typescript": "~5.3.3"
|
"typescript": "~5.3.3",
|
||||||
|
"expo-splash-screen": "~0.29.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,64 +0,0 @@
|
|||||||
diff --git a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
index 8ba09a7..8d7d2b0 100644
|
|
||||||
--- a/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
+++ b/node_modules/expo-modules-core/ios/AppDelegates/ExpoAppDelegate.swift
|
|
||||||
@@ -101,59 +101,6 @@ open class ExpoAppDelegate: UIResponder, UIApplicationDelegate {
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
- // MARK: - Handling Remote Notification Registration
|
|
||||||
-
|
|
||||||
- open func application(_ application: UIApplication, didRegisterForRemoteNotificationsWithDeviceToken deviceToken: Data) {
|
|
||||||
- subscribers.forEach { $0.application?(application, didRegisterForRemoteNotificationsWithDeviceToken: deviceToken) }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- open func application(_ application: UIApplication, didFailToRegisterForRemoteNotificationsWithError error: Error) {
|
|
||||||
- subscribers.forEach { $0.application?(application, didFailToRegisterForRemoteNotificationsWithError: error) }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- open func application(
|
|
||||||
- _ application: UIApplication,
|
|
||||||
- didReceiveRemoteNotification userInfo: [AnyHashable: Any],
|
|
||||||
- fetchCompletionHandler completionHandler: @escaping (UIBackgroundFetchResult) -> Void
|
|
||||||
- ) {
|
|
||||||
- let selector = #selector(application(_:didReceiveRemoteNotification:fetchCompletionHandler:))
|
|
||||||
- let subs = subscribers.filter { $0.responds(to: selector) }
|
|
||||||
- var subscribersLeft = subs.count
|
|
||||||
- let dispatchQueue = DispatchQueue(label: "expo.application.remoteNotification", qos: .userInteractive)
|
|
||||||
- var failedCount = 0
|
|
||||||
- var newDataCount = 0
|
|
||||||
-
|
|
||||||
- let handler = { (result: UIBackgroundFetchResult) in
|
|
||||||
- dispatchQueue.sync {
|
|
||||||
- if result == .failed {
|
|
||||||
- failedCount += 1
|
|
||||||
- } else if result == .newData {
|
|
||||||
- newDataCount += 1
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- subscribersLeft -= 1
|
|
||||||
-
|
|
||||||
- if subscribersLeft == 0 {
|
|
||||||
- if newDataCount > 0 {
|
|
||||||
- completionHandler(.newData)
|
|
||||||
- } else if failedCount > 0 {
|
|
||||||
- completionHandler(.failed)
|
|
||||||
- } else {
|
|
||||||
- completionHandler(.noData)
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
- if subs.isEmpty {
|
|
||||||
- completionHandler(.noData)
|
|
||||||
- } else {
|
|
||||||
- subs.forEach { subscriber in
|
|
||||||
- subscriber.application?(application, didReceiveRemoteNotification: userInfo, fetchCompletionHandler: handler)
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
- }
|
|
||||||
-
|
|
||||||
// MARK: - Continuing User Activity and Handling Quick Actions
|
|
||||||
|
|
||||||
open func application(_ application: UIApplication, willContinueUserActivityWithType userActivityType: String) -> Bool {
|
|
||||||
@@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({
|
|||||||
},
|
},
|
||||||
formItemDivider: {
|
formItemDivider: {
|
||||||
marginVertical: 15
|
marginVertical: 15
|
||||||
}
|
},
|
||||||
|
rootLayout: {
|
||||||
|
display: "flex",
|
||||||
|
gap: 10,
|
||||||
|
justifyContent: "center",
|
||||||
|
paddingHorizontal: 15,
|
||||||
|
paddingTop: 25
|
||||||
|
},
|
||||||
});
|
});
|
||||||
|
|
||||||
export function useModalStyles() {
|
export function useModalStyles() {
|
||||||
|
|||||||
Vendored
+10
-1
@@ -8,6 +8,7 @@ declare const AMNET_API_VERSION = 1;
|
|||||||
declare const MIN_AMNET_API_VERSION = 1;
|
declare const MIN_AMNET_API_VERSION = 1;
|
||||||
interface AMCardInRequest {
|
interface AMCardInRequest {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
physicalCardIDm?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Server connection info (api version, address, etc.)
|
* Server connection info (api version, address, etc.)
|
||||||
@@ -49,6 +50,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn
|
|||||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
||||||
|
|
||||||
declare const CARD_ID_REGEX: RegExp;
|
declare const CARD_ID_REGEX: RegExp;
|
||||||
|
declare const AMNET_COMPANY_ID = 597;
|
||||||
declare function generateCardNumber(): string;
|
declare function generateCardNumber(): string;
|
||||||
/**
|
/**
|
||||||
* Validates the provided card id by known parameters
|
* Validates the provided card id by known parameters
|
||||||
@@ -62,4 +64,11 @@ declare function validateCardId(id: string): boolean;
|
|||||||
*/
|
*/
|
||||||
declare function getGameName(id: string): string | null;
|
declare function getGameName(id: string): string | null;
|
||||||
|
|
||||||
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
type DecodedAccessCode = {
|
||||||
|
revision: number;
|
||||||
|
serial: number;
|
||||||
|
};
|
||||||
|
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
|
||||||
|
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
|
||||||
|
|
||||||
|
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||||
|
|||||||
Vendored
+10
-1
@@ -8,6 +8,7 @@ declare const AMNET_API_VERSION = 1;
|
|||||||
declare const MIN_AMNET_API_VERSION = 1;
|
declare const MIN_AMNET_API_VERSION = 1;
|
||||||
interface AMCardInRequest {
|
interface AMCardInRequest {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
physicalCardIDm?: string;
|
||||||
}
|
}
|
||||||
/**
|
/**
|
||||||
* Server connection info (api version, address, etc.)
|
* Server connection info (api version, address, etc.)
|
||||||
@@ -49,6 +50,7 @@ declare function performCardIn(server: AMServerConnectionInfo, request: AMCardIn
|
|||||||
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
declare function fetchServerInfo(serverAddress: string, abort: AbortSignal): Promise<AMServerInfo | null>;
|
||||||
|
|
||||||
declare const CARD_ID_REGEX: RegExp;
|
declare const CARD_ID_REGEX: RegExp;
|
||||||
|
declare const AMNET_COMPANY_ID = 597;
|
||||||
declare function generateCardNumber(): string;
|
declare function generateCardNumber(): string;
|
||||||
/**
|
/**
|
||||||
* Validates the provided card id by known parameters
|
* Validates the provided card id by known parameters
|
||||||
@@ -62,4 +64,11 @@ declare function validateCardId(id: string): boolean;
|
|||||||
*/
|
*/
|
||||||
declare function getGameName(id: string): string | null;
|
declare function getGameName(id: string): string | null;
|
||||||
|
|
||||||
export { type AMCardInRequest, AMNET_API_VERSION, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
type DecodedAccessCode = {
|
||||||
|
revision: number;
|
||||||
|
serial: number;
|
||||||
|
};
|
||||||
|
declare function decodeAccessCode(code: string): DecodedAccessCode | null;
|
||||||
|
declare function encodeAccessCode(company: number, serial: number, rev?: number): string;
|
||||||
|
|
||||||
|
export { type AMCardInRequest, AMNET_API_VERSION, AMNET_COMPANY_ID, type AMServerConnectionInfo, type AMServerInfo, type AMServerInstanceMetrics, type AMServerMetadata, CARD_ID_REGEX, decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode, MIN_AMNET_API_VERSION, fetchServerInfo, generateCardNumber, getGameName, performCardIn, validateCardId };
|
||||||
|
|||||||
Vendored
+119
-65
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Vendored
+106
-65
File diff suppressed because one or more lines are too long
Vendored
+1
-1
File diff suppressed because one or more lines are too long
Generated
+30
-3
@@ -7,7 +7,12 @@
|
|||||||
"": {
|
"": {
|
||||||
"name": "amnet-shared",
|
"name": "amnet-shared",
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.52",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
@@ -771,6 +776,13 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/@types/lodash": {
|
||||||
|
"version": "4.17.14",
|
||||||
|
"resolved": "https://registry.npmjs.org/@types/lodash/-/lodash-4.17.14.tgz",
|
||||||
|
"integrity": "sha512-jsxagdikDiDBeIRaPYtArcT8my4tN1og7MtMRquFT3XNA6axxyHDRUemqDz/taRDdOUn0GnGHRCuff4q48sW9A==",
|
||||||
|
"dev": true,
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/ansi-regex": {
|
"node_modules/ansi-regex": {
|
||||||
"version": "6.0.1",
|
"version": "6.0.1",
|
||||||
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
|
||||||
@@ -835,6 +847,15 @@
|
|||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT"
|
"license": "MIT"
|
||||||
},
|
},
|
||||||
|
"node_modules/big-integer": {
|
||||||
|
"version": "1.6.52",
|
||||||
|
"resolved": "https://registry.npmjs.org/big-integer/-/big-integer-1.6.52.tgz",
|
||||||
|
"integrity": "sha512-QxD8cf2eVqJOOz63z6JIN9BzvVs/dlySa5HGSBH5xtR8dPteIRQnBxxKqkNTiT6jbDTF6jAfrd4oMcND9RGbQg==",
|
||||||
|
"license": "Unlicense",
|
||||||
|
"engines": {
|
||||||
|
"node": ">=0.6"
|
||||||
|
}
|
||||||
|
},
|
||||||
"node_modules/binary-extensions": {
|
"node_modules/binary-extensions": {
|
||||||
"version": "2.3.0",
|
"version": "2.3.0",
|
||||||
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
|
||||||
@@ -963,9 +984,9 @@
|
|||||||
}
|
}
|
||||||
},
|
},
|
||||||
"node_modules/cross-spawn": {
|
"node_modules/cross-spawn": {
|
||||||
"version": "7.0.3",
|
"version": "7.0.6",
|
||||||
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
|
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
|
||||||
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
|
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
|
||||||
"dev": true,
|
"dev": true,
|
||||||
"license": "MIT",
|
"license": "MIT",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
@@ -1391,6 +1412,12 @@
|
|||||||
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
"node": "^12.20.0 || ^14.13.1 || >=16.0.0"
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
|
"node_modules/lodash": {
|
||||||
|
"version": "4.17.21",
|
||||||
|
"resolved": "https://registry.npmjs.org/lodash/-/lodash-4.17.21.tgz",
|
||||||
|
"integrity": "sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==",
|
||||||
|
"license": "MIT"
|
||||||
|
},
|
||||||
"node_modules/lodash.sortby": {
|
"node_modules/lodash.sortby": {
|
||||||
"version": "4.7.0",
|
"version": "4.7.0",
|
||||||
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
|
||||||
|
|||||||
@@ -16,7 +16,12 @@
|
|||||||
".": "./dist/index.mjs"
|
".": "./dist/index.mjs"
|
||||||
},
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.52",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -10,6 +10,7 @@ export const MIN_AMNET_API_VERSION = 1;
|
|||||||
|
|
||||||
export interface AMCardInRequest {
|
export interface AMCardInRequest {
|
||||||
cardId: string;
|
cardId: string;
|
||||||
|
physicalCardIDm?: string;
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
File diff suppressed because one or more lines are too long
@@ -1,23 +1,12 @@
|
|||||||
|
import {encodeAccessCode} from "./card-encoding";
|
||||||
|
|
||||||
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
|
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
|
||||||
|
export const AMNET_COMPANY_ID = 597;
|
||||||
|
|
||||||
export function generateCardNumber(): string {
|
export function generateCardNumber(): string {
|
||||||
let cardId = '';
|
// serial between 5000 and 99999999999
|
||||||
|
const serial = Math.floor(Math.random() * 99999994999) + 5000;
|
||||||
while (true) {
|
return encodeAccessCode(AMNET_COMPANY_ID, serial, 0);
|
||||||
// don't start with a 3
|
|
||||||
const firstDigit = Math.floor(Math.random() * 10);
|
|
||||||
|
|
||||||
if (firstDigit !== 3) {
|
|
||||||
cardId = firstDigit.toString();
|
|
||||||
break;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
for (let i = 0; i < 19; i++) {
|
|
||||||
cardId += Math.floor(Math.random() * 10).toString();
|
|
||||||
}
|
|
||||||
|
|
||||||
return cardId;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|||||||
@@ -1,3 +1,5 @@
|
|||||||
export * from './api';
|
export * from './api';
|
||||||
export * from './card';
|
export * from './card';
|
||||||
export * from './gameIds';
|
export * from './gameIds';
|
||||||
|
|
||||||
|
export {decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode} from './card-encoding';
|
||||||
@@ -1,14 +1,15 @@
|
|||||||
{
|
{
|
||||||
"compilerOptions": {
|
"compilerOptions": {
|
||||||
"target": "ES2016",
|
"baseUrl": ".",
|
||||||
|
"target": "ES2017",
|
||||||
"module": "CommonJS",
|
"module": "CommonJS",
|
||||||
"noEmit": true,
|
"noEmit": true,
|
||||||
|
"strict": true,
|
||||||
|
"skipLibCheck": true,
|
||||||
"esModuleInterop": true,
|
"esModuleInterop": true,
|
||||||
"forceConsistentCasingInFileNames": true,
|
"forceConsistentCasingInFileNames": true,
|
||||||
"strict": true,
|
|
||||||
"noUncheckedIndexedAccess": true,
|
|
||||||
"skipLibCheck": true,
|
|
||||||
"baseUrl": "."
|
|
||||||
},
|
},
|
||||||
"include": ["src/**/*.ts"]
|
"include": [
|
||||||
|
"src/**/*.ts"
|
||||||
|
]
|
||||||
}
|
}
|
||||||
|
|||||||
Generated
+5
@@ -56,7 +56,12 @@
|
|||||||
},
|
},
|
||||||
"../amnet-shared": {
|
"../amnet-shared": {
|
||||||
"version": "1.0.0",
|
"version": "1.0.0",
|
||||||
|
"dependencies": {
|
||||||
|
"big-integer": "^1.6.52",
|
||||||
|
"lodash": "^4.17.21"
|
||||||
|
},
|
||||||
"devDependencies": {
|
"devDependencies": {
|
||||||
|
"@types/lodash": "^4.17.14",
|
||||||
"tsup": "^8.2.4",
|
"tsup": "^8.2.4",
|
||||||
"typescript": "^5.5.4"
|
"typescript": "^5.5.4"
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -98,10 +98,9 @@ function AimeCardCreationForm(props: { callback?: () => void }) {
|
|||||||
<FormControl>
|
<FormControl>
|
||||||
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
|
||||||
</FormControl>
|
</FormControl>
|
||||||
<FormDescription>
|
<FormDescription className="select-none">
|
||||||
If you've got a card, enter the matrix code here. If not, you can
|
If you've got a card, enter the matrix code here. If not, you can
|
||||||
<span className="font-bold hover:cursor-pointer ml-1"
|
<span className="font-bold hover:cursor-pointer ml-1" onClick={() => form.setValue("id", generateCardNumber())}>
|
||||||
onClick={() => form.setValue("id", generateCardNumber())}>
|
|
||||||
generate a new one.
|
generate a new one.
|
||||||
</span>
|
</span>
|
||||||
</FormDescription>
|
</FormDescription>
|
||||||
|
|||||||
@@ -9,12 +9,12 @@ An Aime Card Switcher for segatools-based games.
|
|||||||
|
|
||||||
## Overview
|
## Overview
|
||||||
AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone.
|
AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone.
|
||||||
This was originally intended for use with maimai and Chunithm, but should be compatible with any segatools-based game with Aime support.
|
This was originally intended for use with maimai and CHUNITHM, but should be compatible with any segatools-based game with Aime support.
|
||||||
|
|
||||||
## Setup
|
## Setup
|
||||||
1. Download the latest `amnet.dll` from the releases page
|
1. Download the latest `amnet.dll` from the releases page
|
||||||
2. Move `amnet.dll` to the game directory
|
2. Move `amnet.dll` to the game directory
|
||||||
3. Edit `segatools.ini` to use the new DLL:
|
3. Edit `segatools.ini`, replacing the existing [aimeio] section with this one (read all the comments and change settings as needed):
|
||||||
```ini
|
```ini
|
||||||
[aimeio]
|
[aimeio]
|
||||||
; To use a custom card reader IO DLL enter its path here.
|
; To use a custom card reader IO DLL enter its path here.
|
||||||
@@ -26,13 +26,27 @@ This was originally intended for use with maimai and Chunithm, but should be com
|
|||||||
; serverName - the name of the server to show to users (defaults to the computer name if not set)
|
; serverName - the name of the server to show to users (defaults to the computer name if not set)
|
||||||
; serverAddress - the address to listen on, including the protocol (defaults to http://+:6070, where + is a wildcard)
|
; serverAddress - the address to listen on, including the protocol (defaults to http://+:6070, where + is a wildcard)
|
||||||
; enableKeyboardMode - whether to enable hold-enter-to-login mode. Reads the file set at aimePath for card number (or DEVICE/aime.txt if not set).
|
; enableKeyboardMode - whether to enable hold-enter-to-login mode. Reads the file set at aimePath for card number (or DEVICE/aime.txt if not set).
|
||||||
|
; useAimeDBForPhysicalCards - Whether physical cards should use AiMeDB to retrieve access codes. If the game is using a hosted network, set this to 1 to load the same account data/profile as you would get on a physical cab.
|
||||||
gameId=SDEZ
|
gameId=SDEZ
|
||||||
;serverName=CHUNI-PENGUIN
|
;serverName=CHUNI-PENGUIN
|
||||||
;serverAddress=http://+:6070
|
;serverAddress=http://+:6070
|
||||||
;enableKeyboardMode=1
|
;enableKeyboardMode=1
|
||||||
|
;useAimeDBForPhysicalCards=1
|
||||||
```
|
```
|
||||||
4. Launch the game, and check the amdaemon console for server startup messages
|
4. Launch the game, and check the amdaemon console for server startup messages
|
||||||
5. Access the using one of the interfaces (see below), add a new server `http://<your-ip-address>:6070`, add a card and start playing!
|
5. Access the using one of the interfaces (see below), add a new server `http://<your-ip-address>:6070`, import your existing card (or create a new one) and start playing!
|
||||||
|
|
||||||
|
### Physical Cards
|
||||||
|
AMNet 1.3 adds support for scanning physical cards on iOS devices. If you own a physical AiMe/Banapass/e-Amusement/Suica card, you can scan it using the app and use it in-game.
|
||||||
|
By changing the `useAimeDBForPhysicalCards` in segatools.ini, you can choose how the card is read on a per-game basis:
|
||||||
|
|
||||||
|
- `0` will send the 20-digit access code on the back of the card to the game and load the save data directly (default option).
|
||||||
|
- `1` - Send the card's IDm to the game and use AiMeDB to retrieve the 20-digit code assigned to the card.
|
||||||
|
|
||||||
|
Mode `1` makes the app function in the same way to a real card reader and should be used if you want the same access code to show when scanning the card on a cab.
|
||||||
|
You should also use this option if you're connecting to a hosted server (AquaNet/rinnet/etc).
|
||||||
|
|
||||||
|
Additionally, if `useAimeDBForPhysicalCards` is set to `1` and the game is failing to read the card, make sure an up-to-date version of segatools is being used.
|
||||||
|
|
||||||
## Interfaces
|
## Interfaces
|
||||||
AMNet can be used on most platforms, see below for options available for each platform:
|
AMNet can be used on most platforms, see below for options available for each platform:
|
||||||
@@ -47,7 +61,7 @@ AMNet can be used on most platforms, see below for options available for each pl
|
|||||||
<tr>
|
<tr>
|
||||||
<td>iOS</td>
|
<td>iOS</td>
|
||||||
<td><a href="https://testflight.apple.com/join/kHbeJTsQ" target="_blank">TestFlight</a></td>
|
<td><a href="https://testflight.apple.com/join/kHbeJTsQ" target="_blank">TestFlight</a></td>
|
||||||
<td></td>
|
<td>Aime/Banapass/e-Amusement/Suica IC cards supported</td>
|
||||||
</tr>
|
</tr>
|
||||||
<tr>
|
<tr>
|
||||||
<td rowspan="2">Android</td>
|
<td rowspan="2">Android</td>
|
||||||
|
|||||||
Reference in New Issue
Block a user