Author SHA1 Message Date
ppc 7f51e4a0ee Merge branch 'ic-scanning'
# Conflicts:
#	amnet-native/app.json
2025-01-24 12:41:29 +00:00
ppc 4d43943568 bump expo version (fix android splash screen) 2025-01-24 12:36:38 +00:00
ppc d8d29c3f84 formatting fix 2025-01-21 19:08:32 +00:00
ppc 0a204b249a Merge remote-tracking branch 'tendokyu/ic-scanning' into ic-scanning 2025-01-21 19:05:54 +00:00
ppc ae7483b156 version bump 2025-01-21 19:05:49 +00:00
ppc 5bb477741d remove diagnostic logging, update readme 2025-01-19 20:01:07 +00:00
ppc 3fbea0f925 fix idm value passing 2025-01-19 19:23:59 +00:00
ppc 2bc9c9f673 perform dfc checks before spad decryption 2025-01-15 13:55:47 +00:00
ppc 5b19207a91 Merge remote-tracking branch 'tendokyu/ic-scanning' into ic-scanning 2025-01-15 11:25:39 +00:00
ppc bc48917fe8 fix card scanning using previously selected card 2025-01-15 11:25:30 +00:00
ppc 13cee9a431 update logging output 2025-01-15 11:13:52 +00:00
ppc 874ba06e29 fix logic check 2025-01-15 11:13:21 +00:00
ppc 16c0ff54c6 add message for bootleg ic cards 2025-01-15 00:19:03 +00:00
ppc 73d333cc23 validate cards when manually entering them 2025-01-14 23:57:36 +00:00
ppc 393965d252 vulnerable package fix 2025-01-14 23:30:54 +00:00
ppc 29b303cde8 generate valid access codes on web 2025-01-14 23:30:04 +00:00
ppc e83287c7b7 add amusement ic access code encode/decode 2025-01-14 23:29:52 +00:00
ppc 9324b30e7b check for existing entry to select when scanning a physical card 2025-01-14 22:20:53 +00:00
ppc d0c7616ed1 remove unused type 2025-01-14 22:18:21 +00:00
ppc 99fd999c06 fix server ordering (latest first) 2025-01-14 22:17:48 +00:00
ppc 0a42a0a912 send idm with card in requests 2025-01-14 18:52:08 +00:00
ppc a6ad82ffda documentation fixes/config key simplification 2025-01-14 15:56:53 +00:00
ppc 31c56d2449 update server to implement felica polling 2025-01-14 15:46:35 +00:00
ppc b33670d849 move modal options to container 2025-01-13 21:17:51 +00:00
ppc 9c8c4fd286 improve HTTPS server warning 2025-01-13 21:17:38 +00:00
ppc 0a80da6614 improve ux when scanning already imported physical card 2025-01-13 21:17:26 +00:00
ppc 489b56015c change transport ic icon to rail symbol 2025-01-13 21:17:06 +00:00
ppc 89338c794a additional nfc support checks 2025-01-13 20:16:11 +00:00
ppc aae5039660 entitlement fix 2025-01-12 21:24:00 +00:00
ppc 690a5b591c better NFCReaderUsageDescription 2025-01-12 21:17:29 +00:00
ppc f9b2326853 add physical card scan to server page 2025-01-12 21:14:01 +00:00
ppc b2be622271 version bump 2025-01-12 20:53:47 +00:00
ppc e0ba6c04f2 add idm/access client-side handling 2025-01-12 20:53:35 +00:00
ppc 6c7479225c update readme 2025-01-12 20:42:46 +00:00
ppc 42cb241487 add idm/access code preference for physical cards (server) 2025-01-12 20:19:46 +00:00
ppc 297ad685a7 add comment 2025-01-12 20:07:21 +00:00
ppc d52757f134 make modal navigation headers the same 2025-01-12 20:06:55 +00:00
ppc a4d4e03eb9 new "card add" flow 2025-01-12 19:45:24 +00:00
ppc 3e3755471d refactor aimecard to iccard 2025-01-12 19:44:45 +00:00
ppc adb60dd928 update realm models 2025-01-12 19:44:06 +00:00
ppc c8763dd65e add ICCard scanning support 2025-01-12 12:48:48 +00:00
ppc 2d3b129126 bump android package version 2024-12-13 20:46:08 +00:00
ppc b729b4e31b more package updates 2024-12-13 20:22:34 +00:00
ppc 377fe93645 Merge remote-tracking branch 'origin/master' 2024-12-13 19:52:42 +00:00
ppc cbd2825475 update to expo 52 2024-11-28 14:52:31 +00:00
ppc b9bbcc9228 update workflow to .net 9 2024-11-28 14:00:33 +00:00
ppc 61fb81c75c add android links to readme 2024-11-28 13:52:09 +00:00
ppc d3b96d3017 don't render invisible icons on android 2024-11-22 16:22:51 +00:00
ppc 9b136006a0 add build-properties plugin 2024-11-21 22:52:48 +00:00
49 changed files with 6005 additions and 5897 deletions
+1 -1
View File
@@ -40,7 +40,7 @@ jobs:
- uses: actions/checkout@v4
- uses: actions/setup-dotnet@v4
with:
dotnet-version: '8.x'
dotnet-version: '9.0.x'
- name: Set version
run: |
-1
View File
@@ -3,7 +3,6 @@
<PropertyGroup>
<PublishAot>true</PublishAot>
<OutputType>Library</OutputType>
<EventSourceSupport>false</EventSourceSupport>
<TargetFramework>net9.0-windows</TargetFramework>
<OptimizationPreference>Size</OptimizationPreference>
<NoDefaultLaunchSettingsFile>true</NoDefaultLaunchSettingsFile>
+20 -7
View File
@@ -13,11 +13,11 @@ internal class CardPresenter
private readonly Lock _cardLock = new();
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 override string ToString() => string.Join(" ", Enumerable.Range(0, 5).Select(x => OriginalValue.Substring(x * 4, 4)));
public override string ToString() => FormatHexCode(AccessCode);
}
/// <summary>
@@ -31,10 +31,16 @@ internal class CardPresenter
}
}
public StoredCard TakeActiveCard()
public StoredCard TakeActiveCard(bool? requireIDm = null)
{
lock (_cardLock)
{
// IDm and requireIDm are mutually exclusive
if (_currentCard == null || (requireIDm.HasValue && (_currentCard.IDm != null) ^ requireIDm.Value))
{
return null;
}
var card = _currentCard;
_currentCard = null;
@@ -45,13 +51,14 @@ internal class CardPresenter
/// <summary>
/// Sets the card
/// </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>
/// <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
var matrixCode = cardId.Replace(" ", "").PadLeft(20, '0');
var matrixCode = accessCode.Replace(" ", "").PadLeft(20, '0');
var bytes = new byte[10];
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)
{
@@ -76,4 +84,9 @@ internal class CardPresenter
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));
}
+22 -3
View File
@@ -1,4 +1,5 @@
using System;
using System.Reflection;
using Windows.Win32;
using Windows.Win32.Foundation;
@@ -6,25 +7,43 @@ namespace AMNet.Server;
internal static class Config
{
private const string ConfigFileName = @".\segatools.ini";
internal const string IOSection = "aimeio";
private const string ConfigFileName = @".\segatools.ini";
static Config()
{
GameId = ReadKey(IOSection, "gameId", 4);
ServerName = ReadKey(IOSection, "serverName", 26, Environment.MachineName);
PhysicalCardUseIDm = PInvoke.GetPrivateProfileInt(IOSection, "useAimeDBForPhysicalCards", 0, ConfigFileName) > 0;
EnableAimeTxt = PInvoke.GetPrivateProfileInt(IOSection, "enableKeyboardMode", 1, ConfigFileName) > 0;
AimeTxtPath = ReadKey("aime", "aimePath", PInvoke.MAX_PATH, @"DEVICE\aime.txt");
}
public static string GameId { get; }
/// <summary>
/// The server name to show on devices. Defaults to the computer name.
/// </summary>
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; }
/// <summary>
/// Path to the aime.txt file. Defaults to .\DEVICE\aime.txt
/// </summary>
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)
{
// +1 for null terminator
+31 -13
View File
@@ -36,7 +36,7 @@ public static class DllMain
CancellationTokenRegistration? cancellationRegistration = null;
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);
// ReSharper disable once AccessToModifiedClosure
@@ -71,7 +71,7 @@ public static class DllMain
using var aimeTxtReader = new StreamReader(File.OpenRead(Config.AimeTxtPath), Encoding.UTF8);
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)
{
@@ -98,25 +98,43 @@ public static class DllMain
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)
{
return 1;
}
try
{
Marshal.Copy(card.Value, 0, luid, (int)luidSize);
return 0;
}
finally
{
App.Logger.LogInformation("Card read in: {cardId}", card);
}
Marshal.Copy(card.AccessCode, 0, luid, (int)luidSize);
App.Logger.LogInformation("Access Code read: {cardId}", CardPresenter.FormatHexCode(card.AccessCode));
return 0;
}
[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")]
public static void SetLedColour(byte unitNo, byte r, byte g, byte b)
+30 -19
View File
@@ -1,5 +1,6 @@
using System;
using System.Globalization;
using System.Linq;
using System.Text.Json;
using System.Text.Json.Serialization;
using System.Threading;
@@ -24,7 +25,11 @@ internal static partial class WebServer
[property: JsonPropertyName("timeSinceLastPoll")] long? TimeSinceLastPoll);
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)
{
@@ -97,23 +102,25 @@ internal static partial class WebServer
}
catch (JsonException e)
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync(e.Message);
await WriteErrorResponse(ctx, StatusCodes.Status400BadRequest, e.Message);
return;
}
if (string.IsNullOrEmpty(request?.MatrixCode))
if (string.IsNullOrEmpty(request?.AccessCode))
{
ctx.Response.StatusCode = 400;
await ctx.Response.WriteAsync("Card id not provided.");
await WriteErrorResponse(ctx, StatusCodes.Status400BadRequest, "Access Code not provided.");
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 ctx.Response.WriteAsync("Invalid card id length.");
await WriteErrorResponse(ctx, StatusCodes.Status422UnprocessableEntity, "Invalid Access Code.");
return;
}
if (request.AccessCode.All(x => x == '0'))
{
await WriteErrorResponse(ctx, StatusCodes.Status403Forbidden, "All-zero access codes are forbidden.");
return;
}
@@ -122,26 +129,30 @@ internal static partial class WebServer
if (currentCard?.Expired == false)
{
var timeLeft = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds);
ctx.Response.StatusCode = 429;
ctx.Response.Headers.RetryAfter = timeLeft.ToString(CultureInfo.InvariantCulture);
ctx.Response.StatusCode = StatusCodes.Status429TooManyRequests;
ctx.Response.Headers.RetryAfter = Math.Ceiling(TimeSpan.FromMilliseconds(currentCard.ExpiresAt - Environment.TickCount64).TotalSeconds).ToString(CultureInfo.InvariantCulture);
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
{
ctx.Response.StatusCode = 422;
await ctx.Response.WriteAsync("Invalid card id format.");
await WriteErrorResponse(ctx, StatusCodes.Status422UnprocessableEntity, "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(CardReadRequest))]
[JsonSerializable(typeof(ServerErrorMessage))]
[JsonSourceGenerationOptions(WriteIndented = true, DefaultIgnoreCondition = JsonIgnoreCondition.Never)]
private partial class SerializerContext : JsonSerializerContext;
}
+10 -6
View File
@@ -1,14 +1,14 @@
import React, {useEffect} from "react";
import React from "react";
import {StatusBar} from "expo-status-bar";
import {useColorScheme, View} from "react-native";
import {Server, WalletCards} from "lucide-react-native";
import {Platform, useColorScheme, View} from "react-native";
import {NavigationContainer} from "@react-navigation/native";
import {SafeAreaProvider} from "react-native-safe-area-context";
import {MD3Colors} from "react-native-paper/lib/typescript/types";
import {createBottomTabNavigator} from "@react-navigation/bottom-tabs";
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 {DefaultTheme as NavigationLightTheme, DarkTheme as NavigationDarkTheme} from '@react-navigation/native';
import {
ActivityIndicator,
adaptNavigationTheme,
@@ -19,12 +19,15 @@ import {
Paragraph
} from 'react-native-paper';
import RealmContext from "./models/RealmContext";
import linking from "./Linking";
import RealmContext from "./models/RealmContext";
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 CardManagerStack from "./app/CardManagerStack";
import ServerManagerStack from "./app/ServerManagerStack";
@@ -118,7 +121,8 @@ export default function AppRoot() {
<Stack.Group screenOptions={{presentation: "modal"}}>
<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.Navigator>
</NavigationContainer>
+11 -5
View File
@@ -1,7 +1,7 @@
{
"name": "AMNet",
"scheme": "amnet",
"version": "1.2.0",
"version": "1.3.1",
"slug": "amnet-native",
"icon": "./assets/icon.png",
"userInterfaceStyle": "automatic",
@@ -12,7 +12,12 @@
"applinks:card.ppc.moe"
],
"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": {
"usesNonExemptEncryption": false
@@ -20,13 +25,14 @@
},
"android": {
"package": "moe.ppc.amnet",
"versionCode": 3,
"versionCode": 5,
"adaptiveIcon": {
"foregroundImage": "./assets/icon-android-alt.png",
"foregroundImage": "./assets/icon-android.png",
"backgroundColor": "#DED8FD"
},
"splash": {
"image": "./assets/icon-android-alt.png",
"resizeMode": "cover",
"image": "./assets/icon-android.png",
"backgroundColor": "#DED8FD",
"dark": {
"backgroundColor": "#3B5ACC"
-124
View File
@@ -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>)
}
+6 -5
View File
@@ -5,7 +5,7 @@ import {Button, useTheme} from "react-native-paper";
import {CreditCard, Plus} from "lucide-react-native";
import ContextMenu from "react-native-context-menu-view";
import {AimeCard} from "../components/AimeCard";
import {ICCard} from "../components/ICCard";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMCard} from "../models/AMCard";
@@ -21,19 +21,20 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
}
switch (indexPath.toString()) {
// copy card
// copy card access code
case "0":
case "0,0":
await Clipboard.setStringAsync(props.card.code);
return;
// edit card
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;
// remove card
case "1,1":
realm.write(() => realm.delete(props.card));
AMCard.delete(realm, props.card);
return;
}
}, [realm, props.card, props.navigation]);
@@ -50,7 +51,7 @@ function CardEntry(props: { card: AMCard, navigation: any }) {
{title: "Edit Card", systemIcon: "square.and.pencil"},
{title: "Delete Card", destructive: true, systemIcon: "trash"}]
}]}>
<AimeCard card={props.card} showFullCardNumber/>
<ICCard card={props.card} showFullCardNumber/>
</ContextMenu>
);
}
+2 -2
View File
@@ -1,6 +1,6 @@
import {Plus} from "lucide-react-native";
import {TouchableOpacity} from "react-native";
import {useTheme} from "@react-navigation/native";
import {Platform, TouchableOpacity} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import CardManager from "./CardManager";
@@ -22,7 +22,7 @@ export default function CardManagerStack({navigation}) {
component={CardManager}
options={{
title: "Cards",
headerRight: () => <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity>
headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity onPress={() => navigation.navigate('add-card')}><Plus/></TouchableOpacity>
}}/>
</Stack.Navigator>
);
+32 -9
View File
@@ -1,7 +1,7 @@
import {useModalStyles} from "../utils/ModalStyles";
import {Card, Paragraph, useTheme} from "react-native-paper";
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 {AMServer} from "../models/AMServer";
@@ -34,25 +34,48 @@ export default function ServerAddModal({route, navigation}) {
let urlValid = false;
try {
new URL(serverAddress);
urlValid = true;
const protocol = new URL(serverAddress).protocol;
urlValid = protocol.localeCompare("http:", undefined, {sensitivity: "case"}) === 0 || protocol.localeCompare("https:", undefined, {sensitivity: "case"}) === 0;
} catch {
// do nothing
}
const themeOptions = urlValid ? modalStyles.headerText : {color: theme.colors.outline};
navigation.setOptions({
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer()}>
headerRight: () => <TouchableOpacity disabled={!urlValid} onPress={() => saveServer(serverAddress)}>
<Paragraph style={{...themeOptions, fontWeight: "600"}}>Save</Paragraph>
</TouchableOpacity>
})
}, [navigation, modalStyles, serverAddress]);
const saveServer = useCallback(() => {
let serverObject = realm.objects<AMServer>("server").find(x => x.serverAddress == serverAddress);
const saveServer = useCallback(async (address: string) => {
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) {
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', {
serverId: serverObject._id.toHexString()
}), 500);
}, [serverAddress, realm]);
}, [realm]);
return (<View style={{display: "flex", gap: 10, justifyContent: "center", paddingHorizontal: 15, paddingTop: 25}}>
<Card mode="contained">
@@ -85,7 +108,7 @@ export default function ServerAddModal({route, navigation}) {
</Card>
<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>
</View>)
}
+102 -44
View File
@@ -1,39 +1,32 @@
import {BSON} from "realm";
import NfcManager from "react-native-nfc-manager";
import React, {useCallback, useEffect, useLayoutEffect, useMemo, useState} from "react";
import {ActivityIndicator, Button, Paragraph, Title, useTheme} from "react-native-paper";
import {
Alert,
Platform,
RefreshControl,
ScrollView,
Share,
StyleSheet,
Text,
TouchableOpacity,
View
} from "react-native";
import {ActivityIndicator, Button, IconButton, Paragraph, Title, useTheme} from "react-native-paper";
import {Platform, RefreshControl, ScrollView, Share, StyleSheet, Text, TouchableOpacity, View} from "react-native";
import {
CircleCheck,
CircleDot,
CreditCard,
Nfc,
Plus,
SmartphoneNfcIcon,
Share as ShareIcon,
WifiOff,
TvMinimalPlay,
Star
Star,
SmartphoneNfc,
SquareArrowOutUpRight
} from "lucide-react-native";
import {EmptyStatePlaceholder} from "../components/EmptyStatePlaceholder";
import {AMUIStatusAlert} from "../components/AMUIStatusAlert";
import {AMUICardDetail} from "../components/AMUICard";
import {AimeCard} from "../components/AimeCard";
import {ICCard} from "../components/ICCard";
import RealmContext from "../models/RealmContext";
import {AMServer} from "../models/AMServer";
import {AMCard} from "../models/AMCard";
import ObjectId = BSON.ObjectId;
import {felicaCardReadAsync, icCard0008Number, ICType} from "../lib/iccard";
import {
AMServerConnectionInfo,
AMServerInstanceMetrics,
@@ -42,6 +35,8 @@ import {
performCardIn
} from "amnet-shared";
import ObjectId = BSON.ObjectId;
const {useRealm, useQuery, useObject} = RealmContext;
export default function ServerCardIn({route, navigation}) {
@@ -55,6 +50,16 @@ export default function ServerCardIn({route, navigation}) {
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
const [serverRefreshing, setServerRefreshing] = useState<boolean>(false);
const [serverReloadCounter, setServerReloadCounter] = useState<number>(0);
@@ -145,16 +150,21 @@ export default function ServerCardIn({route, navigation}) {
}
}, [serverId, serverReloadCounter, realm]);
const cardInCallback = useCallback(() => {
const cardInCallback = useCallback((card: AMCard) => {
setCardInCompleted(false);
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();
realm.write(() => {
server.lastUsed = now;
selectedCard.lastUsed = now;
selectedCard.lastUsedGameId = server.gameId;
card.lastUsed = now;
card.lastUsedGameId = server.gameId;
});
setCardInCompleted(r);
@@ -166,7 +176,39 @@ export default function ServerCardIn({route, navigation}) {
setTimeout(() => abortController.abort(), 5000);
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 = () => {
setServerRefreshing(true);
@@ -187,35 +229,51 @@ export default function ServerCardIn({route, navigation}) {
}
return (
<ScrollView contentInsetAdjustmentBehavior="automatic" refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
<ScrollView contentInsetAdjustmentBehavior="automatic"
refreshControl={<RefreshControl refreshing={serverRefreshing} onRefresh={performRefresh}/>}>
<View style={styles.container}>
<StatusAlert server={server} connectionEstablished={serverConnectionSuccess}/>
<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 ? <>
<AimeCard actionDisabled
card={selectedCard}
actionIcon={<CircleCheck color="green" size={24}/>}>
<ICCard actionDisabled
card={selectedCard}
actionIcon={<CircleCheck color="green" size={24}/>}>
{serverDefaultCard?.code == selectedCard.code && <DefaultCardDetail/>}
</AimeCard>
<Button textColor="white"
onPress={cardInCallback}
disabled={disableCardIn}
contentStyle={{flexDirection: 'row-reverse'}}
mode={serverConnectionSuccess ? "contained" : "outlined"}
buttonColor={cardInCompleted === false ? "red" : "green"}
icon={() => <Nfc color={disableCardIn ? "gray" : "white"} size={18}/>}>
{cardInCompleted === null ? "Card In" : cardInCompleted ? "Card In Success" : "Failed"}
</Button>
</ICCard>
<View style={{display: "flex", flexDirection: "row", gap: 5}}>
<Button style={{flex: 1}}
textColor="white"
disabled={disableCardIn}
contentStyle={{flexDirection: 'row-reverse'}}
mode={serverConnectionSuccess ? "contained" : "outlined"}
buttonColor={cardInCompleted === false ? "red" : "green"}
onPress={() => cardInCallback(selectedCard)}
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}>
<EmptyStatePlaceholder message="No card selected" icon={<CreditCard color="gray"/>}/>
<Button mode="text"
icon={() => <Plus color={theme.colors.primary} size={18}/>}
onPress={() => navigation.navigate('add-card')}>
Add card
Add Card
</Button>
</View>}
</View>
}
</View>
{cards.length > 0 && (
@@ -224,20 +282,20 @@ export default function ServerCardIn({route, navigation}) {
<Button mode="contained"
textColor="white"
buttonColor={theme.colors.tertiary}
icon={() => <Plus color="white" size={18}/>}
contentStyle={{flexDirection: 'row-reverse'}}
icon={() => <Plus color="white" size={18}/>}
onPress={() => navigation.navigate('add-card')}>
Add card
</Button>
{unselectedCards.length
? unselectedCards.map((card: AMCard) => (
<AimeCard card={card}
key={card._id.toHexString()}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
onPress={() => setUserSelectedCard(card)}>
<ICCard card={card}
key={card._id.toHexString()}
actionIcon={<CircleDot color={theme.colors.tertiary} size={24}/>}
onPress={() => setUserSelectedCard(card)}>
{serverDefaultCard?.code == card.code && <DefaultCardDetail/>}
</AimeCard>))
</ICCard>))
: <View style={styles.emptyPlaceholderView}>
<EmptyStatePlaceholder message="No additional cards"
icon={<SmartphoneNfcIcon color="gray"/>}/>
+1 -1
View File
@@ -87,7 +87,7 @@ export default function ServerManager({navigation}) {
q = q.filtered("serverName CONTAINS[c] $0 OR serverAddress CONTAINS[c] $0", searchQuery);
}
return q.sorted("lastUsed");
return q.sorted("lastUsed", true);
}, [query, searchQuery]);
// enable searchbar
+2 -2
View File
@@ -1,6 +1,6 @@
import {Plus} from "lucide-react-native";
import {TouchableOpacity} from "react-native";
import {useTheme} from "@react-navigation/native";
import {Platform, TouchableOpacity} from "react-native";
import {createNativeStackNavigator} from "@react-navigation/native-stack";
import ServerCardIn from "./ServerCardIn";
@@ -24,7 +24,7 @@ export default function ServerManagerStack({navigation}) {
component={ServerManager}
options={{
title: "Servers",
headerRight: () => <TouchableOpacity
headerRight: () => Platform.OS === 'android' ? null : <TouchableOpacity
onPress={() => navigation.navigate('add-server')}>
<Plus/>
</TouchableOpacity>
+105
View File
@@ -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;
+97
View File
@@ -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>
)
}
+127
View File
@@ -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>
)
}
Binary file not shown.

Before

Width:  |  Height:  |  Size: 18 KiB

-36
View File
@@ -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>
);
}
+85
View File
@@ -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";
}
}
+49
View File
@@ -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
};
}
+133
View File
@@ -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
};
}
+2 -1
View File
@@ -6,7 +6,8 @@ module.exports = (async () => {
const config = await getDefaultConfig(__dirname);
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 = [
+40 -2
View File
@@ -1,4 +1,5 @@
import {Realm} from "realm";
import {AmusementICVendor, ICType} from "../lib/iccard";
export interface AMCardData {
code: string;
@@ -6,6 +7,7 @@ export interface AMCardData {
added: Date;
lastUsed?: Date | null;
physicalCardData?: AMPhysicalCardData | null;
}
export class AMCard extends Realm.Object<AMCard> implements AMCardData {
@@ -18,6 +20,8 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
lastUsed?: Date;
lastUsedGameId?: string | null;
physicalCardData?: AMPhysicalCardData | null = null;
static generate(code: string, name: string) {
return {
_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 = {
name: "card",
primaryKey: "_id",
@@ -36,8 +50,32 @@ export class AMCard extends Realm.Object<AMCard> implements AMCardData {
code: "string",
name: {type: "string", default: "New Card"},
added: {type: "date", default: new Date()},
lastUsed: {type: "date", optional: true},
lastUsedGameId: {type: "string", optional: true}
lastUsed: {type: "date", optional: true, indexed: 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}
}
};
}
+5 -3
View File
@@ -12,7 +12,9 @@ export class AMServer extends Realm.Object<AMServer> implements AMServerConnecti
lastUsed: Date;
createdAt: Date;
preferredCardIdFormat?: "idm" | "access" | null;
get shareUrl() {
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},
gameId: {type: "string", optional: true},
serverAddress: "string",
serverName: {type: "string", default: "New Server"},
serverName: {type: "string", default: "New Server", indexed: "full-text"},
createdAt: {type: "date", default: new Date()},
lastUsed: {type: "date", optional: true}
lastUsed: {type: "date", optional: true, indexed: true}
}
};
}
+3 -3
View File
@@ -1,10 +1,10 @@
import {createRealmContext} from "@realm/react";
import {AMCard} from "./AMCard";
import {AMCard, AMPhysicalCardData} from "./AMCard";
import {AMServer} from "./AMServer";
// @ts-ignore
export default createRealmContext({
schemaVersion: 3,
schema: [AMCard, AMServer],
schemaVersion: 4,
schema: [AMPhysicalCardData, AMCard, AMServer],
deleteRealmIfMigrationNeeded: false
});
+3857 -4673
View File
File diff suppressed because it is too large Load Diff
+25 -22
View File
@@ -6,40 +6,43 @@
"android": "expo run:android",
"ios": "expo run:ios",
"ios-prebuild": "expo prebuild --platform ios",
"postinstall": "patch-package"
"android-prebuild": "expo prebuild --platform android"
},
"dependencies": {
"@babel/runtime": "^7.26.0",
"@expo-google-fonts/jetbrains-mono": "^0.2.3",
"@react-navigation/bottom-tabs": "^6.6.1",
"@react-navigation/native": "^6.1.18",
"@react-navigation/native-stack": "^6.11.0",
"@realm/react": "^0.9.0",
"@types/react": "~18.2.79",
"@realm/react": "^0.11.0",
"@types/react": "~18.3.12",
"amnet-shared": "file:../amnet-shared",
"date-fns": "^3.6.0",
"expo": "^51.0.38",
"expo-blur": "~13.0.2",
"expo-build-properties": "~0.12.5",
"expo-clipboard": "~6.0.3",
"expo-constants": "~16.0.2",
"expo-dev-client": "~4.0.29",
"expo-linking": "~6.3.1",
"expo-status-bar": "~1.12.1",
"expo-system-ui": "~3.0.7",
"expo": "^52.0.27",
"expo-blur": "~14.0.3",
"expo-build-properties": "~0.13.2",
"expo-clipboard": "~7.0.1",
"expo-constants": "~17.0.3",
"expo-dev-client": "~5.0.10",
"expo-linking": "~7.0.4",
"expo-status-bar": "~2.0.1",
"expo-system-ui": "~4.0.7",
"lucide-react-native": "^0.427.0",
"patch-package": "^8.0.0",
"react": "18.2.0",
"react-native": "0.74.5",
"pusher-js": "^8.2.0",
"react": "18.3.1",
"react-native": "0.76.6",
"react-native-context-menu-view": "^1.16.0",
"react-native-gesture-handler": "~2.16.1",
"react-native-gesture-handler": "~2.20.2",
"react-native-get-random-values": "^1.11.0",
"react-native-nfc-manager": "^3.16.1",
"react-native-paper": "^5.12.5",
"react-native-reanimated": "~3.10.1",
"react-native-safe-area-context": "4.10.5",
"react-native-screens": "3.31.1",
"react-native-svg": "15.2.0",
"react-native-reanimated": "~3.16.1",
"react-native-safe-area-context": "4.12.0",
"react-native-screens": "~4.4.0",
"react-native-svg": "15.8.0",
"react-native-url-polyfill": "^2.0.0",
"realm": "^12.12.1",
"typescript": "~5.3.3"
"realm": "^12.14.0",
"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 {
+8 -1
View File
@@ -13,7 +13,14 @@ const modalStyles = StyleSheet.create({
},
formItemDivider: {
marginVertical: 15
}
},
rootLayout: {
display: "flex",
gap: 10,
justifyContent: "center",
paddingHorizontal: 15,
paddingTop: 25
},
});
export function useModalStyles() {
+10 -1
View File
@@ -8,6 +8,7 @@ declare const AMNET_API_VERSION = 1;
declare const MIN_AMNET_API_VERSION = 1;
interface AMCardInRequest {
cardId: string;
physicalCardIDm?: string;
}
/**
* 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 const CARD_ID_REGEX: RegExp;
declare const AMNET_COMPANY_ID = 597;
declare function generateCardNumber(): string;
/**
* 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;
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 };
+10 -1
View File
@@ -8,6 +8,7 @@ declare const AMNET_API_VERSION = 1;
declare const MIN_AMNET_API_VERSION = 1;
interface AMCardInRequest {
cardId: string;
physicalCardIDm?: string;
}
/**
* 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 const CARD_ID_REGEX: RegExp;
declare const AMNET_COMPANY_ID = 597;
declare function generateCardNumber(): string;
/**
* 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;
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 };
+119 -65
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+106 -65
View File
File diff suppressed because one or more lines are too long
+1 -1
View File
File diff suppressed because one or more lines are too long
+30 -3
View File
@@ -7,7 +7,12 @@
"": {
"name": "amnet-shared",
"version": "1.0.0",
"dependencies": {
"big-integer": "^1.6.52",
"lodash": "^4.17.21"
},
"devDependencies": {
"@types/lodash": "^4.17.14",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
}
@@ -771,6 +776,13 @@
"dev": true,
"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": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/ansi-regex/-/ansi-regex-6.0.1.tgz",
@@ -835,6 +847,15 @@
"dev": true,
"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": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
@@ -963,9 +984,9 @@
}
},
"node_modules/cross-spawn": {
"version": "7.0.3",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.3.tgz",
"integrity": "sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==",
"version": "7.0.6",
"resolved": "https://registry.npmjs.org/cross-spawn/-/cross-spawn-7.0.6.tgz",
"integrity": "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA==",
"dev": true,
"license": "MIT",
"dependencies": {
@@ -1391,6 +1412,12 @@
"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": {
"version": "4.7.0",
"resolved": "https://registry.npmjs.org/lodash.sortby/-/lodash.sortby-4.7.0.tgz",
+5
View File
@@ -16,7 +16,12 @@
".": "./dist/index.mjs"
},
"devDependencies": {
"@types/lodash": "^4.17.14",
"tsup": "^8.2.4",
"typescript": "^5.5.4"
},
"dependencies": {
"big-integer": "^1.6.52",
"lodash": "^4.17.21"
}
}
+3 -2
View File
@@ -10,6 +10,7 @@ export const MIN_AMNET_API_VERSION = 1;
export interface AMCardInRequest {
cardId: string;
physicalCardIDm?: string;
}
/**
@@ -51,7 +52,7 @@ export async function performCardIn(server: AMServerConnectionInfo, request: AMC
if (server.apiVersion < MIN_AMNET_API_VERSION || server.apiVersion > AMNET_API_VERSION) {
throw new Error("Unsupported server API version");
}
try {
const response = await fetch(`${new URL(server.serverAddress).origin}/amnet/signin`, {
method: "POST",
@@ -78,7 +79,7 @@ export async function fetchServerInfo(serverAddress: string, abort: AbortSignal)
const response = await fetch(`${new URL(serverAddress).origin}/amnet/info`, {
signal: abort
});
if (!response.ok) {
return null;
}
File diff suppressed because one or more lines are too long
+6 -17
View File
@@ -1,23 +1,12 @@
import {encodeAccessCode} from "./card-encoding";
export const CARD_ID_REGEX = /^(?:[0-9]{4} ?){5}$/i;
export const AMNET_COMPANY_ID = 597;
export function generateCardNumber(): string {
let cardId = '';
while (true) {
// 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;
// serial between 5000 and 99999999999
const serial = Math.floor(Math.random() * 99999994999) + 5000;
return encodeAccessCode(AMNET_COMPANY_ID, serial, 0);
}
/**
+3 -1
View File
@@ -1,3 +1,5 @@
export * from './api';
export * from './card';
export * from './gameIds';
export * from './gameIds';
export {decodeAccessCode as DecodeAMAccessCode, encodeAccessCode as EncodeAMAccessCode} from './card-encoding';
+7 -6
View File
@@ -1,14 +1,15 @@
{
"compilerOptions": {
"target": "ES2016",
"baseUrl": ".",
"target": "ES2017",
"module": "CommonJS",
"noEmit": true,
"strict": true,
"skipLibCheck": true,
"esModuleInterop": true,
"forceConsistentCasingInFileNames": true,
"strict": true,
"noUncheckedIndexedAccess": true,
"skipLibCheck": true,
"baseUrl": "."
},
"include": ["src/**/*.ts"]
"include": [
"src/**/*.ts"
]
}
+649 -651
View File
File diff suppressed because it is too large Load Diff
@@ -98,10 +98,9 @@ function AimeCardCreationForm(props: { callback?: () => void }) {
<FormControl>
<Input placeholder="0000 0000 0000 0000 0000" {...field} />
</FormControl>
<FormDescription>
<FormDescription className="select-none">
If you&apos;ve got a card, enter the matrix code here. If not, you can
<span className="font-bold hover:cursor-pointer ml-1"
onClick={() => form.setValue("id", generateCardNumber())}>
<span className="font-bold hover:cursor-pointer ml-1" onClick={() => form.setValue("id", generateCardNumber())}>
generate a new one.
</span>
</FormDescription>
+4 -2
View File
@@ -1,5 +1,7 @@
import * as animate from "tailwindcss-animate";
/** @type {import('tailwindcss').Config} */
module.exports = {
export default {
darkMode: ["class"],
content: [
'./src/**/*.{ts,tsx}',
@@ -70,5 +72,5 @@ module.exports = {
},
},
},
plugins: [require("tailwindcss-animate")],
plugins: [animate],
}
+65 -31
View File
@@ -1,7 +1,7 @@
<div align="center">
# AMNet
[TestFlight](https://testflight.apple.com/join/kHbeJTsQ) • [Website](http://card.ppc.moe) • [Server DLLs](https://gitea.tendokyu.moe/ppc/amnet/releases)
[TestFlight](https://testflight.apple.com/join/kHbeJTsQ) • [Firebase](https://appdistribution.firebase.dev/i/6acc3151357deaa8) • [Website](http://card.ppc.moe) • [Server DLLs](https://gitea.tendokyu.moe/ppc/amnet/releases)
An Aime Card Switcher for segatools-based games.
@@ -9,42 +9,76 @@ An Aime Card Switcher for segatools-based games.
## Overview
AMNet is designed to simulate the `DEVICE/aime.txt` file, allowing multiple users to log into a game using their phone.
This was originally designed for use with maimai/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
2. Move `amnet.dll` to the game directory
3. Edit `segatools.ini` to use the new DLL:
```ini
[aimeio]
; To use a custom card reader IO DLL enter its path here.
; Leave empty if you want to use Segatools built-in keyboard input.
path=amnet.dll
; AMNet-specific options (all optional)
; gameId - the 4 character game ID (will show the game icon in the UI)
; 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)
; 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).
gameId=SDEZ
;serverName=CHUNI-PENGUIN
;serverAddress=http://+:6070
;enableKeyboardMode=1
```
3. Edit `segatools.ini`, replacing the existing [aimeio] section with this one (read all the comments and change settings as needed):
```ini
[aimeio]
; To use a custom card reader IO DLL enter its path here.
; Leave empty if you want to use Segatools built-in keyboard input.
path=amnet.dll
; AMNet-specific options (all optional)
; gameId - the 4 character game ID (will show the game icon in the UI)
; 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)
; 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
;serverName=CHUNI-PENGUIN
;serverAddress=http://+:6070
;enableKeyboardMode=1
;useAimeDBForPhysicalCards=1
```
4. Launch the game, and check the amdaemon console for server startup messages
5. Access the using one of the interfaces (see below), connect to the running server, 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!
### Web Interface
The web interface can be accessed by navigating to http://card.ppc.moe/ on a device on the same network as the server.
This website is also installable as a PWA, which will allow it to be registered as an app on your device.
### 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:
> Because the server can only listen on HTTP, the website needs to be visited over HTTP as well. This may cause issues with some devices, as they may block HTTP requests on HTTPS websites.
- `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.
[![](./res/web.jpeg)](http://card.ppc.moe)
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).
### Native Application
The AMNet application is available (and fully supported) for iOS devices, and can be downloaded by joining the TestFlight (see links at the top of the file) or built manually.
Android is technically supported but hasn't been tested; as such APKs aren't provided at this moment.
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
AMNet can be used on most platforms, see below for options available for each platform:
<table>
<tbody>
<tr>
<th>Platform</th>
<th>Method</th>
<th>Notes</th>
</tr>
<tr>
<td>iOS</td>
<td><a href="https://testflight.apple.com/join/kHbeJTsQ" target="_blank">TestFlight</a></td>
<td>Aime/Banapass/e-Amusement/Suica IC cards supported</td>
</tr>
<tr>
<td rowspan="2">Android</td>
<td><a href="https://appdistribution.firebase.dev/i/6acc3151357deaa8" target="_blank">Firebase</a></td>
<td>Recommended for reduced download/install size and easier upgrading</td>
</tr>
<tr>
<td>.apk</td>
<td>Larger download, manual installation required.</td>
</tr>
<tr>
<td>All</td>
<td><a href="http://card.ppc.moe" target="_blank">Web</a></td>
<td>Works on all platforms and is installable as a PWA. Ensure it's used on <code>http</code> to avoid CORS-related errors</td>
</tr>
</tbody>
</table>
<div align="center">
<img src="./res/native.png" height="600"/>
@@ -62,7 +96,7 @@ Building the server without publish mode will result in a DLL that cannot be run
dotnet publish -c Release -r win-x64 -p:Version=1.0.0 AMNet.Server/AMNet.Server.csproj
```
The dll will be available at `.\AMNet.Server\bin\Release\net8.0-windows\win-x64\publish\amnet.dll`.
The dll will be available at `.\AMNet.Server\bin\Release\net9.0-windows\win-x64\publish\amnet.dll`.
### Website (amnet-web)
The web interface is built using Vite and React. Building the web interface requires Node.js (or alternative runtime).
@@ -79,7 +113,7 @@ npm run build # build the website, outputs to ./dist
The mobile app is built using Expo and React Native. The app requires Node.js and npm to build, along with either the Android SDK or Xcode for iOS.
After cloning the repository, install dependencies then run the app, or prebuild for the platform of your choice then complete the build process for a standalone app:
> amnet-native relies on one or more native libraries, so Expo Go cannot be used. The app must be built and run on a physical device or simulator.
> amnet-native relies on multiple native libraries not included in Expo Go. The app must be built from scratch and run on a physical device or simulator.
```shell
cd amnet-native