diff --git a/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml b/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml index a464db4..35803e7 100644 --- a/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml +++ b/stronglift_replacement/workout_app/android/app/src/main/AndroidManifest.xml @@ -72,6 +72,18 @@ android:foregroundServiceType="dataSync" android:exported="false" android:stopWithTask="false" /> + + + + + + result.notImplemented() } } + // Hands a key parked by PairPcReceiver to Dart, which stores it in + // secure storage and only then asks for the parked copy to go. + MethodChannel(flutterEngine.dartExecutor.binaryMessenger, PAIRING_CHANNEL) + .setMethodCallHandler { call, result -> + when (call.method) { + "takePending" -> try { + result.success(PairPcReceiver.takePending(applicationContext)) + } catch (error: Exception) { + result.error("pairing_unreadable", error.toString(), null) + } + "clearPending" -> { + PairPcReceiver.clearPending(applicationContext) + result.success(null) + } + else -> result.notImplemented() + } + } } companion object { + const val PAIRING_CHANNEL = "com.kuhy.workout_app/pc_pairing" const val CHANNEL = "com.kuhy.workout_app/sandbox" const val LOG_TAG = "WorkoutSandbox" } diff --git a/stronglift_replacement/workout_app/android/app/src/main/kotlin/com/kuhy/workout_app/PairPcReceiver.kt b/stronglift_replacement/workout_app/android/app/src/main/kotlin/com/kuhy/workout_app/PairPcReceiver.kt new file mode 100644 index 0000000..cde141d --- /dev/null +++ b/stronglift_replacement/workout_app/android/app/src/main/kotlin/com/kuhy/workout_app/PairPcReceiver.kt @@ -0,0 +1,94 @@ +package com.kuhy.workout_app + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.os.Build +import android.os.Process +import android.util.Log +import org.json.JSONObject +import java.io.File + +/** + * Pairs this install with the PC for the workout poke, over adb only. + * + * `scripts/pair_phone.sh` on the PC runs + * `adb shell am broadcast -a com.kuhy.workout_app.PAIR_PC -n /com.kuhy.workout_app.PairPcReceiver --es key <64 hex> [--es host ]`. + * + * Two gates keep anything but the adb shell out: + * - the manifest requires the sender to hold `android.permission.DUMP`, + * which the shell has and no third-party app can be granted; + * - on API >= 34 the sender uid, when the platform shares it, must be the + * shell (2000) or root (0). + * + * A manifest receiver runs without a Dart isolate, so the key is parked in + * a private no-backup file; Dart moves it into secure storage the next time + * it asks (`PcPairing.absorbPending`) and only then deletes the file. + * The result code/data are what `am broadcast` prints and what + * `scripts/pair_phone.sh` parses: `result=-1 data="paired"` on success, + * `result=0 data=""` on rejection. `result=0` with NO data means the + * receiver never ran (sender lacks DUMP, or the OEM blocked a stopped app). + * An omitted host keeps the current setting. + */ +class PairPcReceiver : BroadcastReceiver() { + override fun onReceive(context: Context, intent: Intent) { + if (intent.action != ACTION) return refuse("unexpected action ${intent.action}") + if (Build.VERSION.SDK_INT >= 34) { + val uid = sentFromUid + Log.i(TAG, "pair request sentFromUid=$uid") + if (uid != Process.INVALID_UID && uid != SHELL_UID && uid != ROOT_UID) { + return refuse("sender uid $uid is not the adb shell") + } + } + val key = intent.getStringExtra("key")?.trim()?.lowercase() + if (key == null || !KEY_PATTERN.matches(key)) { + return refuse("key must be exactly 64 hex characters") + } + val host = intent.getStringExtra("host")?.trim() + if (host != null && !HOST_PATTERN.matches(host)) { + return refuse("host '$host' is not an IPv4 address or hostname") + } + val json = JSONObject().put("key", key) + if (host != null) json.put("host", host) + try { + pendingFile(context).writeText(json.toString()) + } catch (error: Exception) { + return refuse("could not park the key: $error") + } + Log.i(TAG, "pairing parked for ${context.packageName}; the app absorbs it on next use") + setResult(RESULT_PAIRED, "paired", null) + } + + private fun refuse(reason: String) { + Log.w(TAG, "pairing refused: $reason") + setResult(RESULT_REFUSED, reason, null) + } + + companion object { + const val ACTION = "com.kuhy.workout_app.PAIR_PC" + const val TAG = "WorkoutPair" + const val RESULT_PAIRED = -1 // Activity.RESULT_OK + const val RESULT_REFUSED = 0 // Activity.RESULT_CANCELED + private const val SHELL_UID = 2000 + private const val ROOT_UID = 0 + private val KEY_PATTERN = Regex("^[0-9a-f]{64}$") + private val HOST_PATTERN = Regex("^[A-Za-z0-9][A-Za-z0-9.-]{0,252}$") + + fun pendingFile(context: Context): File = + File(context.noBackupFilesDir, "pc_pairing_pending.json") + + /** `{key, host?}` parked by [onReceive], or null. */ + fun takePending(context: Context): Map? { + val file = pendingFile(context) + if (!file.exists()) return null + val json = JSONObject(file.readText()) + val out = mutableMapOf("key" to json.getString("key")) + if (json.has("host")) out["host"] = json.getString("host") + return out + } + + fun clearPending(context: Context) { + pendingFile(context).delete() + } + } +} diff --git a/stronglift_replacement/workout_app/lib/main.dart b/stronglift_replacement/workout_app/lib/main.dart index 172da2a..c6445c6 100644 --- a/stronglift_replacement/workout_app/lib/main.dart +++ b/stronglift_replacement/workout_app/lib/main.dart @@ -12,6 +12,7 @@ import 'package:workout_app/services/break_service_controller.dart'; import 'package:workout_app/services/foreground_break_client_flutter.dart'; import 'package:workout_app/services/http_server_service.dart'; import 'package:workout_app/services/lock_mode.dart'; +import 'package:workout_app/services/pc_pairing.dart'; import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_device_id.dart'; @@ -71,6 +72,9 @@ void main(List args) async { ); } await StorageService.init(); + // Absorbs a key parked by an adb pairing and points the sandbox's one + // network exception at the configured PC, before anything can poke. + await PcPairing.load(); if (Sandbox.enabled) { Sandbox.restSecs = await StorageService.instance.getSandboxRestSecs( Sandbox.defaultRestSecs, diff --git a/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart b/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart index b18af3e..d5fc120 100644 --- a/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart +++ b/stronglift_replacement/workout_app/lib/sandbox/sandbox_http.dart @@ -4,19 +4,44 @@ library; import 'dart:io'; import 'package:flutter/foundation.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; -/// Makes every `dart:io` HTTP connection fail before a socket is opened. +/// Makes every `dart:io` HTTP connection fail before a socket is opened -- +/// except the workout poke to the configured PC. /// /// Installed as `HttpOverrides.global` by `main` in the sandbox. Firebase and /// GitHub sync both go through `package:http`, whose IO client is built from /// `HttpClient()` and therefore from these overrides — one seam, not one /// guard per service. A refused connection surfaces as a normal sync failure /// in the UI, which is the honest answer: the sandbox is offline by design. +/// +/// The single exception is `http://:8773/v1/workout`, so a sandbox +/// workout can time the PC round trip. The PC verifies the signed +/// `"sandbox": true` and never writes or credits it. Firebase and GitHub are +/// https on 443 and can never match. class SandboxHttpOverrides extends HttpOverrides { + /// The PC host the poke may reach; set by `PcPairing` whenever the host is + /// loaded or saved. Null means no exception at all. + static String? pokeHost; + + /// Whether [uri] is exactly the poke endpoint on the configured PC. + static bool allows(Uri uri) => + pokeHost != null && + uri.scheme == 'http' && + uri.host == pokeHost && + uri.port == kPcPokePort && + uri.path == kPcPokePath; + @override HttpClient createHttpClient(SecurityContext? context) { return super.createHttpClient(context) ..connectionFactory = (uri, proxyHost, proxyPort) { + if (proxyHost == null && allows(uri)) { + debugPrint( + 'WorkoutSandbox: allowing the PC poke to ${uri.host}:${uri.port}', + ); + return Socket.startConnect(uri.host, uri.port); + } debugPrint( 'WorkoutSandbox: refused network connection to ${uri.host} — the ' 'sandbox never talks to Firebase or GitHub.', diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart index fa7e076..c18ec9f 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen.dart @@ -20,6 +20,7 @@ import 'package:workout_app/screens/github_mirror_screen.dart'; import 'package:workout_app/services/backup_service.dart'; import 'package:workout_app/services/firebase_backend.dart'; import 'package:workout_app/services/google_sign_in_backend.dart'; +import 'package:workout_app/services/pc_pairing.dart'; import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; @@ -29,6 +30,7 @@ part 'settings_screen_actions.dart'; part 'settings_screen_deload.dart'; part 'settings_screen_exercise_list.dart'; part 'settings_screen_exercise_sections.dart'; +part 'settings_screen_pc_link.dart'; part 'settings_screen_rows.dart'; part 'settings_screen_sandbox.dart'; part 'settings_screen_sections.dart'; @@ -230,6 +232,8 @@ class _SettingsScreenState extends State { onOpenGitHubMirror: () => unawaited(_openGitHubMirror()), ), const SizedBox(height: 20), + _PcLinkSection(pairingLoader: widget.pcPairingLoader), + const SizedBox(height: 20), _OfflineBackupSection( storageGranted: _storageGranted, onGrantStorage: _grantStorage, diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_pc_link.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_pc_link.dart new file mode 100644 index 0000000..7eb77c4 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_pc_link.dart @@ -0,0 +1,123 @@ +// The PC LINK section: where the instant workout credit is sent. +// +// A `part` like the other sections. It owns its state: the address field and +// the paired flag come from PcPairing, never from the exercise state the +// surrounding screen edits. The key itself is never shown or editable here -- +// it only ever arrives over adb (scripts/pair_phone.sh on the PC). +part of 'settings_screen.dart'; + +/// PC address field and the "paired" indicator. +class _PcLinkSection extends StatefulWidget { + const _PcLinkSection({required this.pairingLoader}); + + /// Loads the pairing; injected through [SettingsScreen.pcPairingLoader]. + final Future Function() pairingLoader; + + @override + State<_PcLinkSection> createState() => _PcLinkSectionState(); +} + +class _PcLinkSectionState extends State<_PcLinkSection> { + final TextEditingController _host = TextEditingController(); + bool? _paired; + String? _status; + + @override + void initState() { + super.initState(); + unawaited(_load()); + } + + Future _load() async { + final state = await widget.pairingLoader(); + if (!mounted) return; + _host.text = state.host; + setState(() => _paired = state.paired); + } + + @override + void dispose() { + _host.dispose(); + super.dispose(); + } + + Future _save() async { + final host = _host.text.trim(); + if (!isValidPcHost(host)) { + setState(() => _status = 'Not an IPv4 address or hostname: "$host".'); + return; + } + await PcPairing.saveHost(host); + SandboxLog.event('pc host saved', {'host': host}); + setState(() => _status = 'Workouts are now sent to $host:$kPcPokePort.'); + } + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + final paired = _paired; + return Column( + crossAxisAlignment: CrossAxisAlignment.stretch, + children: [ + const _SectionHeader('PC LINK'), + const SizedBox(height: AppSpacing.xs), + Text( + 'A finished workout is sent straight to the PC on the home Wi-Fi, ' + 'so the credit lands in a second. Sync still delivers it otherwise.', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + const SizedBox(height: AppSpacing.sm), + Row( + children: [ + Expanded( + child: TextField( + key: const Key('pc-host'), + controller: _host, + keyboardType: TextInputType.url, + decoration: const InputDecoration( + isDense: true, + labelText: 'PC address', + ), + ), + ), + const SizedBox(width: AppSpacing.sm), + TextButton( + key: const Key('pc-host-save'), + onPressed: () => unawaited(_save()), + // Distinct label: the sandbox section has its own "Save", and + // two identical labels cannot be driven by element. + child: const Text('Save', semanticsLabel: 'Save PC address'), + ), + ], + ), + const SizedBox(height: AppSpacing.sm), + Text( + switch (paired) { + null => 'PC paired: checking…', + true => 'PC paired ✓', + false => 'PC paired ✗ — run scripts/pair_phone.sh on the PC', + }, + key: const Key('pc-paired'), + style: TextStyle( + color: paired ?? false ? status.success : status.warning, + fontSize: AppTextSize.caption, + ), + ), + if (_status != null) ...[ + const SizedBox(height: AppSpacing.xs), + Text( + _status!, + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ], + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart index 1d08285..634d238 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_sandbox.dart @@ -69,8 +69,9 @@ class _SandboxSectionState extends State<_SandboxSection> { const _SectionHeader('SANDBOX'), const SizedBox(height: 4), Text( - 'This install is the sandbox: its own data, no network, no LAN ' - 'server. Nothing here can reach the daily build.', + 'This install is the sandbox: its own data, no network (only the ' + 'signed sandbox poke to the PC LINK address, which the PC never ' + 'credits), no LAN server. Nothing here can reach the daily build.', style: TextStyle( color: colorScheme.onSurfaceVariant, fontSize: AppTextSize.caption, diff --git a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart index d51873b..6034163 100644 --- a/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart +++ b/stronglift_replacement/workout_app/lib/screens/settings_screen_widget.dart @@ -34,6 +34,7 @@ class SettingsScreen extends StatefulWidget { this.storageRequester, this.progressionPuller, this.progressionPusher, + this.pcPairingLoader = PcPairing.load, }); /// Injectable HTTP client, passed through to [GitHubMirrorScreen] so its @@ -89,6 +90,10 @@ class SettingsScreen extends StatefulWidget { /// Injected so tests never reach Firebase. final Future Function()? progressionPusher; + /// Loads the PC pairing for the PC LINK section. Injected so tests need + /// neither the keystore nor the pairing channel. + final Future Function() pcPairingLoader; + @override State createState() => _SettingsScreenState(); } diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart index 881da89..a4b137c 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen.dart @@ -22,6 +22,7 @@ import 'package:workout_app/services/break_service_controller.dart'; import 'package:workout_app/services/foreground_break_client.dart'; import 'package:workout_app/services/foreground_break_client_flutter.dart'; import 'package:workout_app/services/lock_mode.dart'; +import 'package:workout_app/services/pc_poke_service.dart'; import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/services/sync_service.dart'; @@ -197,13 +198,13 @@ class _WorkoutScreenState extends State { /// Only the `setState` lives here — `setState` is `@protected` and cannot be /// called from an extension, so the rest is in [_persistFinishedWorkout]. Future _finishWorkout() async { - SandboxLog.event('workout finish', {'type': widget.workoutType}); - _breakTimer?.cancel(); - // Before the state flips: the service must not outlive the workout, and - // with stopWithTask="false" nothing else will ever stop it. - await _breaks.stop(); + final sinceTap = Stopwatch()..start(); + // The service stop starts first and is awaited last (see _beginFinish): + // the summary must not wait on the notification teardown. + final stopped = _beginFinish(); setState(() => _finished = true); - await _persistFinishedWorkout(); + await _persistFinishedWorkout(sinceTap); + await stopped; } // ── Build ────────────────────────────────────────────────────────────────── diff --git a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart index 5ef8754..766039e 100644 --- a/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart +++ b/stronglift_replacement/workout_app/lib/screens/workout_screen_finish.dart @@ -5,11 +5,35 @@ part of 'workout_screen.dart'; /// The write-everything step that runs once a workout is marked finished. extension _WorkoutScreenFinish on _WorkoutScreenState { + /// Starts tearing down the break service and returns the stop, which the + /// caller awaits only after the summary is up. + /// + /// The stop is *started* before `_finished` flips -- the service must not + /// outlive the workout, and with stopWithTask="false" nothing else stops it + /// -- and its synchronous part (dropping the drain-nudge listener) runs + /// right here. Only its completion (~120 ms of platform-channel teardown on + /// the phone, 2026-10-09) no longer gates the summary. If it fails, the + /// failure is logged loudly and the next launch's `stopStaleBreakService` + /// takes the service down, since the active session is cleared by then. + Future _beginFinish() { + SandboxLog.event('workout finish', {'type': widget.workoutType}); + _breakTimer?.cancel(); + return _breaks.stop().catchError((Object error, StackTrace stack) { + log( + 'Break service did not stop at finish ($error); the next launch ' + 'stops it as stale.', + level: 1000, + error: error, + stackTrace: stack, + ); + }); + } + /// Writes the finished session, applies progression, and shows the summary. /// /// Called by [_WorkoutScreenState._finishWorkout], which owns the `setState` /// that marks the workout finished before this runs. - Future _persistFinishedWorkout() async { + Future _persistFinishedWorkout(Stopwatch sinceTap) async { final endTime = DateTime.now(); final results = []; @@ -41,6 +65,16 @@ extension _WorkoutScreenFinish on _WorkoutScreenState { endTime: endTime, exercises: results, ); + // The direct PC poke starts the moment the session exists, before any + // local write: it needs nothing from storage, and every millisecond here + // is a millisecond of "instant" credit. It runs in parallel with the + // sync push below (never after it); the PC dedups by record id, so + // whichever lands first credits and the other reads as a duplicate. + // Bounded to 2 s and never throws. + final pcPoke = PcPokeService().poke(session); + SandboxLog.event('pc poke started', { + 'ms_since_tap': sinceTap.elapsedMilliseconds, + }); final storage = StorageService.instance; await storage.saveSession( @@ -99,12 +133,24 @@ extension _WorkoutScreenFinish on _WorkoutScreenState { ); if (!mounted) return; + SandboxLog.event('summary shown', { + 'ms_since_tap': sinceTap.elapsedMilliseconds, + }); + // The first frame that draws the dialog: "finish tap -> summary visible". + WidgetsBinding.instance.addPostFrameCallback( + (_) => SandboxLog.event('summary first frame', { + 'ms_since_tap': sinceTap.elapsedMilliseconds, + }), + ); unawaited( showDialog( context: context, barrierDismissible: false, - builder: (_) => - WorkoutSummaryDialog(session: session, syncResult: syncResult), + builder: (_) => WorkoutSummaryDialog( + session: session, + syncResult: syncResult, + pcPoke: pcPoke, + ), ), ); } diff --git a/stronglift_replacement/workout_app/lib/services/pc_pairing.dart b/stronglift_replacement/workout_app/lib/services/pc_pairing.dart new file mode 100644 index 0000000..2281ecb --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/pc_pairing.dart @@ -0,0 +1,131 @@ +/// Where the PC is and the key the poke is signed with. +/// +/// The key arrives over adb (`scripts/pair_phone.sh` -> `PairPcReceiver`). +/// A manifest receiver runs without a Dart isolate, so it parks the key in a +/// private no-backup file and Dart moves it into secure storage the next +/// time it asks -- at startup, in Settings, and before every poke. +library; + +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:workout_app/sandbox/sandbox_http.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; +import 'package:workout_app/services/storage_service.dart'; + +export 'package:workout_app/services/pc_poke_wire.dart' show kPcPokePort; + +/// Snapshot of the pairing. [keyHex] never leaves the services layer: the +/// UI only ever reads [paired]. +@immutable +class PcPairingState { + /// Creates a snapshot. + const PcPairingState({required this.host, this.keyHex}); + + /// The PC's LAN host. + final String host; + + /// The shared HMAC key, or null when this install is not paired. + final String? keyHex; + + /// Whether a key is stored. + bool get paired => keyHex != null; +} + +final RegExp _hostPattern = RegExp(r'^[A-Za-z0-9](?:[A-Za-z0-9.-]{0,252})$'); + +/// Whether [host] is usable as the PC address (an IPv4 or a hostname). +bool isValidPcHost(String host) => _hostPattern.hasMatch(host); + +/// Reads and writes the pairing. Each package (daily, sandbox) has its own +/// keystore and database, so each has its own key slot by construction. +abstract final class PcPairing { + /// Channel `MainActivity` answers `takePending` / `clearPending` on. + static const MethodChannel channel = MethodChannel( + 'com.kuhy.workout_app/pc_pairing', + ); + + /// Secure-storage entry the key is kept under. + static const String keyName = 'pc_poke_key'; + + /// The keystore; tests swap the platform channel, not this. + static const FlutterSecureStorage _secure = FlutterSecureStorage(); + + static bool _channelDown = false; + + /// Absorbs any pending adb pairing, then returns the current pairing and + /// points the sandbox's network exception at its host. + static Future load() async { + await absorbPending(); + final host = await StorageService.instance.getPcHost() ?? kDefaultPcHost; + String? key; + try { + key = await _secure.read(key: keyName); + } on Exception catch (error) { + log( + 'PC pairing key unreadable ($error) -- treating as not paired', + name: 'PcPairing', + level: 900, + ); + } + SandboxHttpOverrides.pokeHost = host; + return PcPairingState( + host: host, + keyHex: key != null && isValidPokeKeyHex(key) ? key : null, + ); + } + + /// Persists the PC address the Settings field holds. + static Future saveHost(String host) async { + await StorageService.instance.setPcHost(host); + SandboxHttpOverrides.pokeHost = host; + } + + /// Moves a key the receiver parked into secure storage. Returns what + /// happened as a sentence; the parked copy is deleted only after the + /// secure write succeeded, so a failure here loses nothing. + static Future absorbPending() async { + if (_channelDown) return 'no pairing channel on this platform'; + Map? pending; + try { + pending = await channel.invokeMapMethod('takePending'); + } on MissingPluginException catch (error) { + _channelDown = true; + log( + 'no pairing channel ($error) -- adb pairing is Android-only', + name: 'PcPairing', + level: 900, + ); + return 'no pairing channel on this platform'; + } on PlatformException catch (error) { + log('reading the parked pairing failed: $error', level: 1000); + return 'reading the parked pairing failed: $error'; + } + if (pending == null) return 'nothing pending'; + final key = pending['key']; + final host = pending['host']; + if (key is! String || !isValidPokeKeyHex(key)) { + log('parked pairing key is malformed -- discarded', level: 1000); + await channel.invokeMethod('clearPending'); + return 'parked pairing key is malformed'; + } + try { + await _secure.write(key: keyName, value: key.toLowerCase()); + } on PlatformException catch (error) { + log('storing the pairing key failed: $error', level: 1000); + return 'storing the pairing key failed: $error'; + } + if (host is String && isValidPcHost(host)) { + await saveHost(host); + } + await channel.invokeMethod('clearPending'); + log('PC pairing absorbed from adb', name: 'PcPairing', level: 800); + return 'paired'; + } + + /// Forgets a failed channel so the next call retries it. + @visibleForTesting + static void resetForTesting() => _channelDown = false; +} diff --git a/stronglift_replacement/workout_app/lib/services/pc_poke_result.dart b/stronglift_replacement/workout_app/lib/services/pc_poke_result.dart new file mode 100644 index 0000000..85ea934 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/pc_poke_result.dart @@ -0,0 +1,150 @@ +/// The typed outcome of one phone->PC workout poke. +library; + +import 'dart:convert'; + +import 'package:flutter/foundation.dart'; + +/// What the PC did with the poke, as far as the phone can tell. +enum PokeOutcome { + /// Verified, ingested and credited just now. + credited, + + /// Already in the PC's log (the sync got there first): nothing new to do. + duplicate, + + /// A sandbox poke: verified and timed, never written or credited. + sandbox, + + /// New to the PC, but today's workout slot was already paid (`ok:true, + /// credited:false, duplicate:false`). + alreadyPaidToday, + + /// Correctly signed, but the session does not count (too short, no date): + /// HTTP 200 with `ok:false`. [PokeResult.reason] says why. + notCounted, + + /// No usable answer: not paired, unreachable, timed out, rejected. + failed, +} + +/// One poke's result. Never null, never thrown: [reason] is always a +/// sentence a human can act on. +@immutable +class PokeResult { + /// Creates a result. + const PokeResult({ + required this.outcome, + required this.reason, + this.shutdown, + this.gamingBudgetMinutes, + this.roundTripMs, + }); + + /// A failure that never got a usable reply. + const PokeResult.failed(this.reason, {this.roundTripMs}) + : outcome = PokeOutcome.failed, + shutdown = null, + gamingBudgetMinutes = null; + + /// Parses the PC's JSON reply. Any shape the contract does not allow is a + /// [PokeOutcome.failed] naming what was wrong, not an exception. + factory PokeResult.fromReply(int status, String body, int roundTripMs) { + Object? decoded; + FormatException? notJson; + try { + decoded = jsonDecode(body); + } on FormatException catch (error) { + // Handed on in the result; PcPokeService logs every failed result. + notJson = error; + } + if (notJson != null) { + return PokeResult.failed( + 'PC answered HTTP $status with a non-JSON body ($notJson)', + roundTripMs: roundTripMs, + ); + } + if (decoded is! Map) { + return PokeResult.failed( + 'PC answered HTTP $status with JSON that is not an object', + roundTripMs: roundTripMs, + ); + } + final reason = decoded['reason'] is String + ? decoded['reason'] as String + : 'PC gave no reason'; + // 401/409/400/404/405/411/413/500 all carry JSON, but none of them is + // an answer about this workout: the sync fallback covers them. + if (status != 200) { + return PokeResult.failed( + 'PC rejected the poke (HTTP $status): $reason', + roundTripMs: roundTripMs, + ); + } + // shutdown / gaming_budget_minutes may be null (refusals): never assumed. + final budget = decoded['gaming_budget_minutes']; + final shutdown = decoded['shutdown']; + final PokeOutcome outcome; + if (decoded['ok'] != true) { + outcome = PokeOutcome.notCounted; + } else if (decoded['sandbox'] == true) { + outcome = PokeOutcome.sandbox; + } else if (decoded['duplicate'] == true) { + outcome = PokeOutcome.duplicate; + } else if (decoded['credited'] == true) { + outcome = PokeOutcome.credited; + } else { + outcome = PokeOutcome.alreadyPaidToday; + } + return PokeResult( + outcome: outcome, + reason: reason, + shutdown: shutdown is String ? shutdown : null, + gamingBudgetMinutes: budget is int ? budget : null, + roundTripMs: roundTripMs, + ); + } + + /// What happened. + final PokeOutcome outcome; + + /// Why, in words. + final String reason; + + /// The PC's shutdown time after this poke, `HH:MM`. + final String? shutdown; + + /// Today's gaming budget after this poke, in minutes. + final int? gamingBudgetMinutes; + + /// Measured phone-side round trip, when a request was actually sent. + final int? roundTripMs; + + /// The one line the finish screen shows for this result. + String get label { + switch (outcome) { + case PokeOutcome.credited: + final parts = ['PC ✓']; + if (shutdown != null) parts.add('shutdown $shutdown'); + final minutes = gamingBudgetMinutes; + final gaming = minutes == null + ? '' + : ' · gaming ${(minutes / 60).toStringAsFixed(1)}h'; + return '${parts.join(' ')}$gaming'; + case PokeOutcome.duplicate: + return 'PC: already credited'; + case PokeOutcome.sandbox: + return 'PC ✓ (sandbox, not credited) · ${roundTripMs ?? '?'} ms'; + case PokeOutcome.alreadyPaidToday: + return 'PC: already credited today'; + case PokeOutcome.notCounted: + return 'PC: not counted — $reason'; + case PokeOutcome.failed: + return 'PC: will credit within 60 s (via sync)'; + } + } + + @override + String toString() => + 'PokeResult(${outcome.name}, rtt=${roundTripMs}ms, reason: $reason)'; +} diff --git a/stronglift_replacement/workout_app/lib/services/pc_poke_service.dart b/stronglift_replacement/workout_app/lib/services/pc_poke_service.dart new file mode 100644 index 0000000..7dc681b --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/pc_poke_service.dart @@ -0,0 +1,142 @@ +/// Sends a finished workout straight to the PC so the credit lands in under +/// a second instead of waiting for the sync round trip. +library; + +import 'dart:async'; +import 'dart:developer'; + +import 'package:flutter/foundation.dart'; +import 'package:http/http.dart' as http; +import 'package:http/io_client.dart'; +import 'package:uuid/uuid.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/sandbox/sandbox.dart'; +import 'package:workout_app/sandbox/sandbox_log.dart'; +import 'package:workout_app/services/pc_pairing.dart'; +import 'package:workout_app/services/pc_poke_result.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; +import 'package:workout_app/services/workout_sync_service.dart'; + +export 'package:workout_app/services/pc_poke_result.dart'; + +/// Signs and POSTs one session to `http://:8773/v1/workout`. +/// +/// Runs beside [WorkoutSyncService.push], never instead of it: the PC dedups +/// by record id, so whichever path lands first credits and the other is a +/// duplicate. Off the home LAN this fails fast and the sync is the only path. +class PcPokeService { + /// Creates a service. Every collaborator is injectable so tests need no + /// network, keystore or clock. + PcPokeService({ + http.Client Function()? clientFactory, + Future Function()? pairingLoader, + DateTime Function()? clock, + String Function()? nonce, + this.sandboxOverride, + this.timeout = const Duration(seconds: 2), + }) : _clientFactory = clientFactory ?? IOClient.new, + _pairingLoader = pairingLoader ?? PcPairing.load, + _clock = clock ?? DateTime.now, + _nonce = nonce ?? const Uuid().v4; + + /// A fresh client per call, so it is built under whatever `HttpOverrides` + /// are current (the sandbox's narrow exception included). + final http.Client Function() _clientFactory; + final Future Function() _pairingLoader; + final DateTime Function() _clock; + final String Function() _nonce; + + /// Forces the body's `sandbox` flag; null follows [Sandbox.enabled]. + final bool? sandboxOverride; + + /// Budget for the whole poke, key load included. Past it the finish screen + /// says the sync will credit instead. + final Duration timeout; + + /// Pokes the PC with [session]. Never throws; failures log at warning. + Future poke(WorkoutSession session) async { + final watch = Stopwatch()..start(); + http.Client? client; + // Set by the catch below and reported (at warning) right after it. + Exception failure; + try { + final pairing = await _pairingLoader().timeout(timeout); + final keyHex = pairing.keyHex; + if (keyHex == null) { + return _report( + const PokeResult.failed( + 'this phone is not paired with the PC -- run ' + 'scripts/pair_phone.sh there; the sync will credit meanwhile', + ), + ); + } + final sandbox = sandboxOverride ?? Sandbox.enabled; + final body = buildPokeBody( + sentAtMs: _clock().millisecondsSinceEpoch, + nonce: _nonce(), + sandbox: sandbox, + recordId: workoutRecordId(session), + payload: workoutRecordPayload(session), + ); + final signature = signPokeBody(decodePokeKeyHex(keyHex), body); + final uri = Uri( + scheme: 'http', + host: pairing.host, + port: kPcPokePort, + path: kPcPokePath, + ); + client = _clientFactory(); + final left = timeout - watch.elapsed; + final response = await client + .post( + uri, + headers: { + 'Content-Type': 'application/json', + kPcPokeSignatureHeader: signature, + }, + body: body, + ) + .timeout(left.isNegative ? Duration.zero : left); + return _report( + PokeResult.fromReply( + response.statusCode, + response.body, + watch.elapsedMilliseconds, + ), + ); + } on Exception catch (error) { + failure = error; + } finally { + // Closing aborts a request the timeout gave up on, so a dead PC never + // keeps a socket open behind the finish screen. + client?.close(); + } + return _report( + PokeResult.failed( + failure is TimeoutException + ? 'no reply from the PC within ${timeout.inMilliseconds} ms' + : 'could not reach the PC: $failure', + roundTripMs: watch.elapsedMilliseconds, + ), + ); + } + + /// Logs every result -- a poke that fails says so at warning level, never + /// silently -- and mirrors it into the sandbox trace. + PokeResult _report(PokeResult result) { + final failed = + result.outcome == PokeOutcome.failed || + result.outcome == PokeOutcome.notCounted; + final line = + 'PC poke ${result.outcome.name} in ${result.roundTripMs ?? '-'} ms: ' + '${result.reason}'; + log(line, name: 'PcPoke', level: failed ? 900 : 800); + debugPrint('WorkoutApp: $line'); + SandboxLog.event('pc poke', { + 'outcome': result.outcome.name, + 'rtt_ms': result.roundTripMs, + 'reason': result.reason, + }); + return result; + } +} diff --git a/stronglift_replacement/workout_app/lib/services/pc_poke_wire.dart b/stronglift_replacement/workout_app/lib/services/pc_poke_wire.dart new file mode 100644 index 0000000..4dd7bec --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/pc_poke_wire.dart @@ -0,0 +1,69 @@ +/// Wire format of the phone->PC workout poke (v1). +/// +/// Binding spec: `docs/DOCS-workout-poke-contract.md` at the screen-locker +/// repo root, with the literal fixture `contracts/workout_poke_v1.json` that +/// both this file and the Python receiver are tested against. Pure Dart, no +/// Flutter: the contract test loads it without a binding. +library; + +import 'dart:convert'; +import 'dart:typed_data'; + +import 'package:crypto/crypto.dart'; + +/// TCP port the PC's poke listener binds (8771 belongs to another service). +const int kPcPokePort = 8773; + +/// Request path on the PC. +const String kPcPokePath = '/v1/workout'; + +/// Header carrying `hex(HMAC-SHA256(key, raw body bytes))`. +const String kPcPokeSignatureHeader = 'X-Poke-Signature'; + +/// Wire-format version this phone speaks. +const int kPcPokeVersion = 1; + +/// The PC's static LAN address; the Settings field starts here. +const String kDefaultPcHost = '192.168.1.43'; + +final RegExp _keyHex = RegExp(r'^[0-9a-fA-F]{64}$'); + +/// Whether [text] is a pairing key: exactly 32 bytes as 64 hex characters. +bool isValidPokeKeyHex(String text) => _keyHex.hasMatch(text); + +/// Decodes a key accepted by [isValidPokeKeyHex]; throws [FormatException] +/// on anything else so a malformed key can never sign a request. +Uint8List decodePokeKeyHex(String keyHex) { + if (!isValidPokeKeyHex(keyHex)) { + throw const FormatException('pairing key must be exactly 64 hex chars'); + } + final out = Uint8List(32); + for (var i = 0; i < 32; i++) { + out[i] = int.parse(keyHex.substring(i * 2, i * 2 + 2), radix: 16); + } + return out; +} + +/// `hex(HMAC-SHA256(key, bodyBytes))` over the bytes exactly as sent. +String signPokeBody(List key, List bodyBytes) => + Hmac(sha256, key).convert(bodyBytes).toString(); + +/// Serializes the request body ONCE; the caller signs and sends these exact +/// bytes. Key order follows the contract for readability only -- the PC +/// verifies the MAC over the received bytes and never re-serializes. +Uint8List buildPokeBody({ + required int sentAtMs, + required String nonce, + required bool sandbox, + required String recordId, + required Map payload, +}) => utf8.encode( + jsonEncode({ + 'v': kPcPokeVersion, + 'sent_at_ms': sentAtMs, + 'nonce': nonce, + 'sandbox': sandbox, + 'record_id': recordId, + 'payload': payload, + }), +); diff --git a/stronglift_replacement/workout_app/lib/services/storage_service.dart b/stronglift_replacement/workout_app/lib/services/storage_service.dart index a7a5a17..0aa76e4 100644 --- a/stronglift_replacement/workout_app/lib/services/storage_service.dart +++ b/stronglift_replacement/workout_app/lib/services/storage_service.dart @@ -26,6 +26,7 @@ export 'package:workout_app/models/progression.dart'; part 'storage_service_backup.dart'; part 'storage_service_deload.dart'; part 'storage_service_exercises.dart'; +part 'storage_service_pc_link.dart'; part 'storage_service_sandbox.dart'; part 'storage_service_schema.dart'; part 'storage_service_sessions.dart'; diff --git a/stronglift_replacement/workout_app/lib/services/storage_service_pc_link.dart b/stronglift_replacement/workout_app/lib/services/storage_service_pc_link.dart new file mode 100644 index 0000000..9870d3d --- /dev/null +++ b/stronglift_replacement/workout_app/lib/services/storage_service_pc_link.dart @@ -0,0 +1,18 @@ +// The PC address the workout poke is sent to. +// +// A `part` of StorageService for the same reason as the sandbox part: it +// needs `_getSetting`, and one setting does not earn a second storage layer. +// The key itself is NOT here -- it lives in secure storage (pc_pairing.dart). +part of 'storage_service.dart'; + +/// Settings key the PC host is stored under. +const String kPcHostKey = 'pc_host'; + +/// Persistence for the PC link. +extension StorageServicePcLink on StorageService { + /// The saved PC host, or null when the default should be used. + Future getPcHost() => _getSetting(kPcHostKey); + + /// Persists the PC host. + Future setPcHost(String host) => _setSetting(kPcHostKey, host); +} diff --git a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart index c5cdb4d..0390ca6 100644 --- a/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart +++ b/stronglift_replacement/workout_app/lib/services/workout_sync_service.dart @@ -1,7 +1,6 @@ /// Pushes completed workout sessions to the shared sync backend. library; - import 'dart:convert'; import 'dart:developer'; import 'package:crdt_sync/crdt_sync.dart'; @@ -27,6 +26,19 @@ Log _decode(String text) => (jsonDecode(text) as Map).map( (id, data) => MapEntry(id, Record.fromJson(data as Map)), ); +/// The sync record id a finished [session] is pushed under. +/// +/// The PC's `ingest_session_records` keys on this (it equals the payload's +/// `start_time`), so the direct phone->PC poke and the regular push must +/// both build it here -- two constructions could drift and credit twice. +String workoutRecordId(WorkoutSession session) => + session.startTime.toIso8601String(); + +/// The payload a finished [session] carries in its sync record; shared with +/// the poke for the same reason as [workoutRecordId]. +Map workoutRecordPayload(WorkoutSession session) => + session.toJson(); + /// The outcome of a [WorkoutSyncService.push] / [WorkoutSyncService.pushManual]. /// /// A bare `Future` used to hide every failure: the caller could not @@ -124,9 +136,12 @@ class WorkoutSyncService { successReason: 'pushed', failurePrefix: 'push failed', addition: Record( - id: session.startTime.toIso8601String(), + id: workoutRecordId(session), fields: { - 'payload': (session.toJson(), Hlc.newTick(currentSyncDeviceId)), + 'payload': ( + workoutRecordPayload(session), + Hlc.newTick(currentSyncDeviceId), + ), }, ), ); @@ -172,7 +187,6 @@ class WorkoutSyncService { ); } - /// Pushes a pre-built manual-workout [record] to this device's log. /// /// Same never-throw, always-report contract as [push]. @@ -197,6 +211,7 @@ class WorkoutSyncService { /// Returns an empty list if sync isn't configured or the repo is unreachable. Future>> readMergedManualPayloads() => _readMergedPayloads(kind: kManualWorkoutSyncKind); + /// Every synced workout, whatever kind — manual, StrongLifts or RunnerUp. /// /// The PC publishes its whole `log.json` (including verified runs), @@ -205,6 +220,7 @@ class WorkoutSyncService { /// uses [readMergedManualPayloads] instead. Future>> readMergedWorkoutPayloads() => _readMergedPayloads(); + /// Whether this device has ANY sync credentials -- a GitHub token or a /// Firebase account. /// diff --git a/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart b/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart new file mode 100644 index 0000000..dd6e212 --- /dev/null +++ b/stronglift_replacement/workout_app/lib/widgets/pc_poke_line.dart @@ -0,0 +1,63 @@ +/// The one-line PC status on the workout summary. +library; + +import 'package:flutter/material.dart'; +import 'package:workout_app/services/pc_poke_result.dart'; +import 'package:workout_app/ui/theme.dart'; + +/// Shows "PC: applying…" until [poke] resolves, then its [PokeResult.label]. +/// +/// The service bounds [poke] to 2 s, so this never spins forever: an +/// unreachable PC turns into the sync fallback line on its own. +class PcPokeLine extends StatelessWidget { + /// Creates the line for an in-flight [poke]. + const PcPokeLine({required this.poke, super.key}); + + /// The poke started when the workout finished. + final Future poke; + + @override + Widget build(BuildContext context) { + final colorScheme = Theme.of(context).colorScheme; + final status = Theme.of(context).extension()!; + return FutureBuilder( + future: poke, + builder: (context, snapshot) { + final result = snapshot.data; + if (result == null) { + return Row( + key: const Key('pc-poke-pending'), + children: [ + SizedBox.square( + dimension: AppTextSize.caption, + child: CircularProgressIndicator( + strokeWidth: 2, + color: colorScheme.onSurfaceVariant, + ), + ), + const SizedBox(width: AppSpacing.sm), + Text( + 'PC: applying…', + style: TextStyle( + color: colorScheme.onSurfaceVariant, + fontSize: AppTextSize.caption, + ), + ), + ], + ); + } + final color = switch (result.outcome) { + PokeOutcome.credited || PokeOutcome.sandbox => status.success, + PokeOutcome.duplicate || + PokeOutcome.alreadyPaidToday => colorScheme.onSurfaceVariant, + PokeOutcome.notCounted || PokeOutcome.failed => status.warning, + }; + return Text( + result.label, + key: const Key('pc-poke-result'), + style: TextStyle(color: color, fontSize: AppTextSize.caption), + ); + }, + ); + } +} diff --git a/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart b/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart index 3af1dc3..d19e97e 100644 --- a/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart +++ b/stronglift_replacement/workout_app/lib/widgets/workout_summary_dialog.dart @@ -3,8 +3,10 @@ library; import 'package:flutter/material.dart'; import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/pc_poke_result.dart'; import 'package:workout_app/services/sync_service.dart'; import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/pc_poke_line.dart'; /// Dialog that summarises a completed workout and reports the sync status. class WorkoutSummaryDialog extends StatelessWidget { @@ -12,6 +14,7 @@ class WorkoutSummaryDialog extends StatelessWidget { const WorkoutSummaryDialog({ required this.session, required this.syncResult, + this.pcPoke, super.key, }); @@ -21,6 +24,10 @@ class WorkoutSummaryDialog extends StatelessWidget { /// Result of writing the session to disk/HTTP server. final SyncResult syncResult; + /// The direct phone->PC poke started at finish, shown as one status line; + /// null where none was sent. + final Future? pcPoke; + String _fmt(Duration d) { final m = d.inMinutes.remainder(60).toString().padLeft(2, '0'); final s = d.inSeconds.remainder(60).toString().padLeft(2, '0'); @@ -70,6 +77,10 @@ class WorkoutSummaryDialog extends StatelessWidget { fontSize: AppTextSize.caption, ), ), + if (pcPoke != null) ...[ + const SizedBox(height: AppSpacing.sm), + PcPokeLine(poke: pcPoke!), + ], ], ), actions: [ diff --git a/stronglift_replacement/workout_app/pubspec.lock b/stronglift_replacement/workout_app/pubspec.lock index ed16ab4..146ead3 100644 --- a/stronglift_replacement/workout_app/pubspec.lock +++ b/stronglift_replacement/workout_app/pubspec.lock @@ -147,7 +147,7 @@ packages: source: git version: "0.11.1" crypto: - dependency: transitive + dependency: "direct main" description: name: crypto sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf diff --git a/stronglift_replacement/workout_app/pubspec.yaml b/stronglift_replacement/workout_app/pubspec.yaml index 4457545..ad07340 100644 --- a/stronglift_replacement/workout_app/pubspec.yaml +++ b/stronglift_replacement/workout_app/pubspec.yaml @@ -14,6 +14,8 @@ dependencies: url: https://github.com/kuhyx/utils ref: crdt_sync_dart-v0.11.1 path: crdt_sync_dart + # HMAC-SHA256 for the signed phone->PC workout poke (pc_poke_wire.dart). + crypto: 3.0.7 flutter: sdk: flutter # Keeps the rest-period deadline ticking with the screen off, and owns the diff --git a/stronglift_replacement/workout_app/test/fake_pc_pairing.dart b/stronglift_replacement/workout_app/test/fake_pc_pairing.dart new file mode 100644 index 0000000..091ffd2 --- /dev/null +++ b/stronglift_replacement/workout_app/test/fake_pc_pairing.dart @@ -0,0 +1,7 @@ +import 'package:workout_app/services/pc_pairing.dart'; + +/// A settled, unpaired pairing for screens that only need the PC LINK +/// section to render: no pairing channel, keystore or database involved, and +/// `SandboxHttpOverrides.pokeHost` stays untouched. +Future fakePcPairing() async => + const PcPairingState(host: '10.0.0.2'); diff --git a/stronglift_replacement/workout_app/test/pc_poke_contract_test.dart b/stronglift_replacement/workout_app/test/pc_poke_contract_test.dart new file mode 100644 index 0000000..1f2a44c --- /dev/null +++ b/stronglift_replacement/workout_app/test/pc_poke_contract_test.dart @@ -0,0 +1,50 @@ +// The phone half of the cross-language contract check for the workout poke. +// +// `contracts/workout_poke_v1.json` (screen-locker repo root) is a literal key, +// body and signature the Python receiver is tested against too. Each side's +// own round-trip test would pass while the two disagreed; this one cannot. +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; + +/// Relative to the package root, which is `flutter test`'s cwd. +const _fixturePath = '../../contracts/workout_poke_v1.json'; + +void main() { + final fixture = + jsonDecode(File(_fixturePath).readAsStringSync()) as Map; + final keyHex = fixture['key_hex'] as String; + final body = fixture['body_utf8'] as String; + final expected = fixture['signature_hex'] as String; + + test('signer reproduces the shared fixture signature byte for byte', () { + expect(signPokeBody(decodePokeKeyHex(keyHex), utf8.encode(body)), expected); + }); + + test('the fixture key is a valid pairing key', () { + expect(isValidPokeKeyHex(keyHex), isTrue); + }); + + test('buildPokeBody emits the fixture body byte for byte', () { + // Float (100.0, 7020.5), key order and raw non-ASCII are where encoders + // differ. The PC never re-serializes, so this is not required for the MAC + // to verify -- it pins that the phone's body looks like the documented one. + final reparsed = jsonDecode(body) as Map; + final rebuilt = buildPokeBody( + sentAtMs: reparsed['sent_at_ms'] as int, + nonce: reparsed['nonce'] as String, + sandbox: reparsed['sandbox'] as bool, + recordId: reparsed['record_id'] as String, + payload: reparsed['payload'] as Map, + ); + expect(utf8.decode(rebuilt), body); + }); + + test('malformed keys are rejected before they can sign', () { + expect(isValidPokeKeyHex('ab' * 31), isFalse); + expect(isValidPokeKeyHex('zz' * 32), isFalse); + expect(() => decodePokeKeyHex('nope'), throwsFormatException); + }); +} diff --git a/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart b/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart index d161285..c620cee 100644 --- a/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart +++ b/stronglift_replacement/workout_app/test/sandbox/sandbox_http_test.dart @@ -3,6 +3,7 @@ import 'dart:io'; import 'package:flutter/foundation.dart'; import 'package:flutter_test/flutter_test.dart'; import 'package:workout_app/sandbox/sandbox_http.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; void main() { final printed = []; @@ -10,7 +11,10 @@ void main() { printed.clear(); debugPrint = (String? message, {int? wrapWidth}) => printed.add(message ?? ''); }); - tearDown(() => debugPrint = debugPrintThrottled); + tearDown(() { + debugPrint = debugPrintThrottled; + SandboxHttpOverrides.pokeHost = null; + }); test('refuses every connection before a socket is opened', () async { final client = SandboxHttpOverrides().createHttpClient(null); @@ -23,4 +27,73 @@ void main() { expect(printed.single, contains('refused network connection to firebase.example.invalid')); client.close(force: true); }); + + group('the one PC-poke exception', () { + const host = '127.0.0.1'; + final poke = Uri.parse('http://$host:$kPcPokePort$kPcPokePath'); + + test('allows nothing until a PC host is configured', () { + expect(SandboxHttpOverrides.allows(poke), isFalse); + }); + + test('allows exactly http + configured host + 8773 + /v1/workout', () { + SandboxHttpOverrides.pokeHost = host; + expect(SandboxHttpOverrides.allows(poke), isTrue); + for (final other in [ + poke.replace(scheme: 'https'), + poke.replace(host: '127.0.0.2'), + poke.replace(port: 443), + poke.replace(path: '/v1/other'), + Uri.parse('https://firebaseio.com/x.json'), + ]) { + expect(SandboxHttpOverrides.allows(other), isFalse, reason: '$other'); + } + }); + + test('https to the same host and port is still refused', () async { + SandboxHttpOverrides.pokeHost = host; + final client = SandboxHttpOverrides().createHttpClient(null); + await expectLater( + () async => client.postUrl( + Uri.parse('https://$host:$kPcPokePort$kPcPokePath'), + ), + throwsA(isA()), + ); + expect(printed.single, contains('refused network connection to $host')); + client.close(force: true); + }); + + test('another host is still refused', () async { + SandboxHttpOverrides.pokeHost = host; + final client = SandboxHttpOverrides().createHttpClient(null); + await expectLater( + () async => client.postUrl( + Uri.parse('http://10.0.0.9:$kPcPokePort$kPcPokePath'), + ), + throwsA(isA()), + ); + expect(printed.single, contains('refused network connection to 10.0.0.9')); + client.close(force: true); + }); + + test('the poke itself reaches the socket layer', () async { + SandboxHttpOverrides.pokeHost = host; + final client = SandboxHttpOverrides().createHttpClient(null); + Object outcome = 'connected'; + try { + // Nothing is sent: the request is opened, never written. + final request = await client.postUrl(poke); + request.abort(); + } on SocketException catch (error) { + outcome = error; + } + // No listener on this host's 8773 is fine -- what matters is that a + // refusal, if any, did not come from the sandbox. + if (outcome is SocketException) { + expect(outcome.message, isNot(contains('sandbox'))); + } + expect(printed.first, contains('allowing the PC poke to $host:8773')); + client.close(force: true); + }); + }); } diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart index 27186f5..0c37249 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_deload_test.dart @@ -10,6 +10,7 @@ import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/exercise_settings_sheet.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; const _situp = 'Situp exercise settings'; @@ -42,6 +43,7 @@ void main() { MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, firebaseFactory: () async => null, accountLoader: () async => null, sessionProbe: () async => false, diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart index 670e0ec..61daacc 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_exercises_test.dart @@ -9,6 +9,7 @@ import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; import 'package:workout_app/widgets/exercise_settings_sheet.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; const _situp = 'Situp exercise settings'; @@ -36,6 +37,7 @@ void main() { MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, firebaseFactory: () async => null, accountLoader: () async => null, sessionProbe: () async => false, diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_pc_link_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_pc_link_test.dart new file mode 100644 index 0000000..169dc3f --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_pc_link_test.dart @@ -0,0 +1,108 @@ +// The PC LINK section: shows the pairing, validates and persists the host. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/sandbox/sandbox_http.dart'; +import 'package:workout_app/screens/settings_screen.dart'; +import 'package:workout_app/services/pc_pairing.dart'; +import 'package:workout_app/services/progression_sync_service.dart'; +import 'package:workout_app/services/storage_service.dart'; +import 'package:workout_app/ui/theme.dart'; + +import '../fake_secure_storage.dart'; + +void main() { + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + }); + tearDown(() => SandboxHttpOverrides.pokeHost = null); + + Widget wrap(PcPairingState pairing) => MaterialApp( + theme: buildAppTheme(), + home: SettingsScreen( + pcPairingLoader: () async => pairing, + firebaseFactory: () async => null, + sessionProbe: () async => false, + storageChecker: () async => false, + progressionPuller: () async => + const ProgressionSyncResult(changed: false, reason: 'stubbed'), + ), + ); + + Future pump(WidgetTester tester, PcPairingState pairing) async { + await tester.runAsync(() async { + await tester.pumpWidget(wrap(pairing)); + await Future.delayed(const Duration(milliseconds: 300)); + }); + await tester.pump(); + await tester.scrollUntilVisible( + find.byKey(const Key('pc-paired')), + 300, + scrollable: find.byType(Scrollable).first, + ); + await tester.pump(); + } + + Future save(WidgetTester tester, String host) async { + await tester.enterText(find.byKey(const Key('pc-host')), host); + final button = find.byKey(const Key('pc-host-save')); + await tester.ensureVisible(button); + await tester.runAsync(() async { + await tester.tap(button); + await Future.delayed(const Duration(milliseconds: 200)); + }); + await tester.pump(); + } + + String pairedLine(WidgetTester tester) => + tester.widget(find.byKey(const Key('pc-paired'))).data!; + + testWidgets('a paired phone shows the host and a check', (tester) async { + await pump( + tester, + PcPairingState(host: '192.168.1.43', keyHex: 'c3' * 32), + ); + expect(find.text('PC LINK'), findsOneWidget); + expect(pairedLine(tester), 'PC paired ✓'); + final field = tester.widget(find.byKey(const Key('pc-host'))); + expect(field.controller!.text, '192.168.1.43'); + }); + + testWidgets('an unpaired phone names the pairing script', (tester) async { + await pump(tester, const PcPairingState(host: '10.0.0.2')); + expect(pairedLine(tester), contains('run scripts/pair_phone.sh')); + }); + + testWidgets('a junk host is refused and nothing is saved', (tester) async { + await pump(tester, const PcPairingState(host: '10.0.0.2')); + await save(tester, 'not a host'); + expect( + find.text('Not an IPv4 address or hostname: "not a host".'), + findsOneWidget, + ); + final saved = await tester.runAsync(StorageService.instance.getPcHost); + expect(saved, isNull); + expect(SandboxHttpOverrides.pokeHost, isNull); + }); + + testWidgets('a valid host is persisted and moves the exception', ( + tester, + ) async { + await pump(tester, const PcPairingState(host: '10.0.0.2')); + await save(tester, ' 192.168.1.77 '); + expect( + find.text('Workouts are now sent to 192.168.1.77:$kPcPokePort.'), + findsOneWidget, + ); + final saved = await tester.runAsync(StorageService.instance.getPcHost); + expect(saved, '192.168.1.77'); + expect(SandboxHttpOverrides.pokeHost, '192.168.1.77'); + }); +} diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart index f7f7e3c..558c7f4 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_reset_test.dart @@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; void main() { @@ -47,6 +48,7 @@ void main() { }) => MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, httpClient: httpClient, // Injected so the widget never reaches the OS keystore, which // `flutter test` has no platform-channel binding for. diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart index ee8200a..e673ac8 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_sandbox_test.dart @@ -9,6 +9,7 @@ import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; void main() { @@ -32,6 +33,7 @@ void main() { Widget wrap() => MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, firebaseFactory: () async => null, sessionProbe: () async => false, storageChecker: () async => false, @@ -119,17 +121,22 @@ void main() { ) async { await pump(tester); final field = find.byKey(const Key('sandbox-rest-secs')); + // The row's own button: the PC LINK section has a "Save" too. + final save = find.descendant( + of: find.ancestor(of: field, matching: find.byType(Row)).first, + matching: find.text('Save'), + ); await tester.enterText(field, 'abc'); - await tapReal(tester, find.text('Save')); + await tapReal(tester, save); expect(find.textContaining('whole number of seconds'), findsOneWidget); expect(Sandbox.restSecs, Sandbox.defaultRestSecs); await tester.enterText(field, '0'); - await tapReal(tester, find.text('Save')); + await tapReal(tester, save); expect(find.textContaining('whole number of seconds'), findsOneWidget); await tester.enterText(field, '45'); - await tapReal(tester, find.text('Save')); + await tapReal(tester, save); expect(find.text('Every rest now lasts 45 s.'), findsOneWidget); expect(Sandbox.restSecs, 45); final saved = await tester.runAsync( diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart index a908bd3..4f448f8 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_sync_test.dart @@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; void main() { @@ -47,6 +48,7 @@ void main() { }) => MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, httpClient: httpClient, // Injected so the widget never reaches the OS keystore, which // `flutter test` has no platform-channel binding for. @@ -138,6 +140,9 @@ void main() { findsOneWidget, ); + // The scroll step can leave the row just below the fold. + await tester.ensureVisible(find.text('Grant storage permission')); + await tester.pump(); await tester.tap(find.text('Grant storage permission')); await tester.pumpAndSettle(); diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart index 2fc377a..e2f411d 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_test.dart @@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; void main() { @@ -47,6 +48,7 @@ void main() { }) => MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, httpClient: httpClient, // Injected so the widget never reaches the OS keystore, which // `flutter test` has no platform-channel binding for. diff --git a/stronglift_replacement/workout_app/test/screens/settings_screen_thresholds_test.dart b/stronglift_replacement/workout_app/test/screens/settings_screen_thresholds_test.dart index bf4d208..b34ffa4 100644 --- a/stronglift_replacement/workout_app/test/screens/settings_screen_thresholds_test.dart +++ b/stronglift_replacement/workout_app/test/screens/settings_screen_thresholds_test.dart @@ -10,6 +10,7 @@ import 'package:workout_app/services/progression_sync_service.dart'; import 'package:workout_app/services/storage_service.dart'; import 'package:workout_app/ui/theme.dart'; +import '../fake_pc_pairing.dart'; import '../fake_secure_storage.dart'; void main() { @@ -47,6 +48,7 @@ void main() { }) => MaterialApp( theme: buildAppTheme(), home: SettingsScreen( + pcPairingLoader: fakePcPairing, httpClient: httpClient, // Injected so the widget never reaches the OS keystore, which // `flutter test` has no platform-channel binding for. diff --git a/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart b/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart new file mode 100644 index 0000000..2ea5b93 --- /dev/null +++ b/stronglift_replacement/workout_app/test/screens/workout_finish_stop_failure_test.dart @@ -0,0 +1,71 @@ +// Finishing a workout whose break service will not stop: the summary still +// appears (the stop no longer gates it) and the failure is logged, not thrown. +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:shared_preferences_platform_interface/in_memory_shared_preferences_async.dart'; +import 'package:shared_preferences_platform_interface/shared_preferences_async_platform_interface.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../fake_audio_platform.dart'; +import '../fake_break_service.dart'; +import '../fake_secure_storage.dart'; +import '_workout_screen_test_fixtures.dart'; + +/// A client whose teardown fails once [failStop] is armed. +class _FailingStopClient extends FakeForegroundBreakClient { + bool failStop = false; + + @override + void stopListeningForDrainNudges() { + if (failStop) { + failStop = false; + throw StateError('notification teardown failed'); + } + super.stopListeningForDrainNudges(); + } +} + +void main() { + late _FailingStopClient client; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + installFakeSecureStorage(); + installFakeAudioPlatform(); + SharedPreferencesAsyncPlatform.instance = + InMemorySharedPreferencesAsync.empty(); + client = _FailingStopClient(); + }); + + testWidgets('a failed service stop still shows the summary', (tester) async { + await pumpWorkout( + tester, + wrapWorkout(savedState: completeSaved(), breakClient: client), + ); + client.failStop = true; + await tester.runAsync(() async { + await tester.tap(find.widgetWithText(TextButton, 'Finish')); + await Future.delayed(const Duration(milliseconds: 200)); + await tester.pump(); + await tester.tap(find.widgetWithText(TextButton, 'Finish').last); + await Future.delayed(const Duration(milliseconds: 800)); + await tester.pump(); + }); + await tester.pump(); + + expect(client.failStop, isFalse, reason: 'the failing stop really ran'); + expect(tester.takeException(), isNull); + // The summary's PC line: this test host is never paired. + expect( + find.text('PC: will credit within 60 s (via sync)'), + findsOneWidget, + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/pc_pairing_test.dart b/stronglift_replacement/workout_app/test/services/pc_pairing_test.dart new file mode 100644 index 0000000..107a690 --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/pc_pairing_test.dart @@ -0,0 +1,143 @@ +// PcPairing: the adb-parked key moves into secure storage, and only then is +// the parked copy deleted -- a failure anywhere loses nothing. +import 'package:flutter/services.dart'; +import 'package:flutter_secure_storage/flutter_secure_storage.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'package:workout_app/sandbox/sandbox_http.dart'; +import 'package:workout_app/services/pc_pairing.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; +import 'package:workout_app/services/storage_service.dart'; + +import '../fake_secure_storage.dart'; + +final String _key = 'b2' * 32; + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + late List calls; + + setUpAll(() { + sqfliteFfiInit(); + databaseFactory = databaseFactoryFfi; + }); + + setUp(() async { + StorageService.resetForTesting(); + await StorageService.init(); + PcPairing.resetForTesting(); + calls = []; + }); + + tearDown(() { + SandboxHttpOverrides.pokeHost = null; + messenger.setMockMethodCallHandler(PcPairing.channel, null); + }); + + /// Answers `takePending` with [pending] (or throws [error]) and records + /// every call. + void parked(Map? pending, {Exception? error}) { + messenger.setMockMethodCallHandler(PcPairing.channel, (call) async { + calls.add(call.method); + if (call.method != 'takePending') return null; + if (error != null) throw error; + return pending; + }); + } + + Future storedKey() => + const FlutterSecureStorage().read(key: PcPairing.keyName); + + test('nothing parked: default host, not paired, sandbox host set', () async { + installFakeSecureStorage(); + parked(null); + final state = await PcPairing.load(); + expect(state.host, kDefaultPcHost); + expect(state.paired, isFalse); + expect(SandboxHttpOverrides.pokeHost, kDefaultPcHost); + expect(calls, ['takePending']); + }); + + test('a parked key is stored lowercase, host saved, then cleared', () async { + installFakeSecureStorage(); + parked({'key': _key.toUpperCase(), 'host': '192.168.1.50'}); + expect(await PcPairing.absorbPending(), 'paired'); + expect(calls, ['takePending', 'clearPending']); + expect(await storedKey(), _key); + + final state = await PcPairing.load(); + expect(state.host, '192.168.1.50'); + expect(state.keyHex, _key); + expect(state.paired, isTrue); + }); + + test('an invalid parked host is ignored, the key still pairs', () async { + installFakeSecureStorage(); + parked({'key': _key, 'host': '-bad host'}); + expect(await PcPairing.absorbPending(), 'paired'); + expect(await StorageService.instance.getPcHost(), isNull); + }); + + test('a malformed parked key is discarded, never stored', () async { + installFakeSecureStorage(); + parked({'key': 'nope'}); + expect(await PcPairing.absorbPending(), 'parked pairing key is malformed'); + expect(calls, ['takePending', 'clearPending']); + expect(await storedKey(), isNull); + }); + + test('a failed secure write keeps the parked copy', () async { + installFakeSecureStorage(throwing: true); + parked({'key': _key}); + final outcome = await PcPairing.absorbPending(); + expect(outcome, startsWith('storing the pairing key failed')); + expect(calls, ['takePending'], reason: 'clearPending must not run'); + }); + + test('an unreadable parked file is reported, not thrown', () async { + installFakeSecureStorage(); + parked(null, error: PlatformException(code: 'pairing_unreadable')); + final outcome = await PcPairing.absorbPending(); + expect(outcome, startsWith('reading the parked pairing failed')); + }); + + test('no channel (desktop): said once, then not asked again', () async { + installFakeSecureStorage(); + // No handler: the call raises MissingPluginException. + expect(await PcPairing.absorbPending(), 'no pairing channel on this platform'); + parked(null); + expect(await PcPairing.absorbPending(), 'no pairing channel on this platform'); + expect(calls, isEmpty); + }); + + test('a corrupt stored key reads as not paired', () async { + installFakeSecureStorage(initial: {PcPairing.keyName: 'garbage'}); + parked(null); + expect((await PcPairing.load()).paired, isFalse); + }); + + test('a keystore that throws reads as not paired', () async { + installFakeSecureStorage(throwing: true); + parked(null); + final state = await PcPairing.load(); + expect(state.paired, isFalse); + expect(state.host, kDefaultPcHost); + }); + + test('saveHost persists and moves the sandbox exception', () async { + await PcPairing.saveHost('pc.lan'); + expect(await StorageService.instance.getPcHost(), 'pc.lan'); + expect(SandboxHttpOverrides.pokeHost, 'pc.lan'); + }); + + test('isValidPcHost accepts IPv4 and hostnames only', () { + expect(isValidPcHost('192.168.1.43'), isTrue); + expect(isValidPcHost('pc.lan'), isTrue); + expect(isValidPcHost(''), isFalse); + expect(isValidPcHost('-x'), isFalse); + expect(isValidPcHost('a b'), isFalse); + expect(isValidPcHost('http://x'), isFalse); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/pc_poke_result_test.dart b/stronglift_replacement/workout_app/test/services/pc_poke_result_test.dart new file mode 100644 index 0000000..52d770f --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/pc_poke_result_test.dart @@ -0,0 +1,91 @@ +// Maps every PC reply shape the poke contract allows onto the finish line. +import 'dart:convert'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/pc_poke_result.dart'; + +String _reply({ + bool ok = true, + bool credited = false, + bool duplicate = false, + bool sandbox = false, + String? shutdown = '21:00', + int? gaming = 90, + String reason = 'because', +}) => jsonEncode({ + 'ok': ok, + 'credited': credited, + 'duplicate': duplicate, + 'sandbox': sandbox, + 'shutdown': shutdown, + 'gaming_budget_minutes': gaming, + 'pc_ms': 3, + 'reason': reason, +}); + +void main() { + test('credited shows shutdown and gaming hours', () { + final r = PokeResult.fromReply(200, _reply(credited: true), 40); + expect(r.outcome, PokeOutcome.credited); + expect(r.label, 'PC ✓ shutdown 21:00 · gaming 1.5h'); + }); + + test('credited with null shutdown/budget still renders', () { + final r = PokeResult.fromReply( + 200, + _reply(credited: true, shutdown: null, gaming: null), + 40, + ); + expect(r.label, 'PC ✓'); + }); + + test('duplicate, already-paid and sandbox replies', () { + expect( + PokeResult.fromReply(200, _reply(duplicate: true), 1).label, + 'PC: already credited', + ); + expect( + PokeResult.fromReply(200, _reply(), 1).label, + 'PC: already credited today', + ); + expect( + PokeResult.fromReply(200, _reply(sandbox: true), 25).label, + 'PC ✓ (sandbox, not credited) · 25 ms', + ); + }); + + test('200 ok:false is "not counted" with the PC reason', () { + final r = PokeResult.fromReply( + 200, + _reply(ok: false, reason: 'session too short', shutdown: null), + 1, + ); + expect(r.outcome, PokeOutcome.notCounted); + expect(r.label, 'PC: not counted — session too short'); + }); + + test('every non-200 status falls back to the sync line', () { + for (final status in [400, 401, 404, 405, 409, 411, 413, 500]) { + final r = PokeResult.fromReply( + status, + _reply(ok: false, shutdown: null, gaming: null, reason: 'nope'), + 1, + ); + expect(r.outcome, PokeOutcome.failed, reason: 'HTTP $status'); + expect(r.label, 'PC: will credit within 60 s (via sync)'); + expect(r.reason, contains('HTTP $status')); + } + }); + + test('non-JSON and non-object bodies fail with a reason', () { + expect(PokeResult.fromReply(200, '', 1).reason, contains('non-JSON')); + expect(PokeResult.fromReply(200, '[1]', 1).reason, contains('not an')); + }); + + test('toString names the outcome, round trip and reason', () { + expect( + const PokeResult.failed('down', roundTripMs: 12).toString(), + 'PokeResult(failed, rtt=12ms, reason: down)', + ); + }); +} diff --git a/stronglift_replacement/workout_app/test/services/pc_poke_service_test.dart b/stronglift_replacement/workout_app/test/services/pc_poke_service_test.dart new file mode 100644 index 0000000..a4a97ac --- /dev/null +++ b/stronglift_replacement/workout_app/test/services/pc_poke_service_test.dart @@ -0,0 +1,209 @@ +// PcPokeService against a fake PC: signing, every failure shape, and the +// bounded budget. Plain `test()` so `.timeout()` runs on real timers. +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:http/http.dart' as http; +import 'package:http/testing.dart'; +import 'package:workout_app/models/exercise.dart'; +import 'package:workout_app/models/exercise_result.dart'; +import 'package:workout_app/models/set_result.dart'; +import 'package:workout_app/models/workout_session.dart'; +import 'package:workout_app/services/pc_pairing.dart'; +import 'package:workout_app/services/pc_poke_service.dart'; +import 'package:workout_app/services/pc_poke_wire.dart'; + +final String _keyHex = 'a1' * 32; + +WorkoutSession _session() { + final start = DateTime(2026, 10, 9, 7, 30); + return WorkoutSession( + workoutType: 'A', + startTime: start, + endTime: start.add(const Duration(minutes: 40)), + exercises: [ + ExerciseResult( + exercise: const Exercise(name: 'Squat', sets: 1, reps: 5, weight: 60), + sets: [SetResult(targetReps: 5, doneReps: 5, weight: 60)], + ), + ], + ); +} + +/// A MockClient that records whether it was closed. +class _ClosingClient extends MockClient { + _ClosingClient(super.fn); + + bool closed = false; + + @override + void close() { + closed = true; + super.close(); + } +} + +String _reply({ + bool ok = true, + bool credited = false, + bool sandbox = false, + Object? shutdown, + Object? gaming, + String reason = 'fine', +}) => jsonEncode({ + 'ok': ok, + 'credited': credited, + 'duplicate': false, + 'sandbox': sandbox, + 'shutdown': shutdown, + 'gaming_budget_minutes': gaming, + 'reason': reason, +}); + +PcPokeService _service( + http.Client client, { + String? keyHex, + bool? sandbox, + Future Function()? pairing, + Duration timeout = const Duration(seconds: 2), +}) => PcPokeService( + clientFactory: () => client, + pairingLoader: + pairing ?? + () async => PcPairingState(host: '10.0.0.7', keyHex: keyHex ?? _keyHex), + clock: () => DateTime.fromMillisecondsSinceEpoch(1760000000000), + nonce: () => 'nonce-1', + sandboxOverride: sandbox, + timeout: timeout, +); + +void main() { + test('signs the exact body bytes and parses a credited reply', () async { + late http.Request seen; + final client = _ClosingClient((request) async { + seen = request; + return http.Response( + _reply(credited: true, shutdown: '22:00', gaming: 120), + 200, + ); + }); + final result = await _service(client, sandbox: false).poke(_session()); + + expect(result.outcome, PokeOutcome.credited); + expect(result.label, 'PC ✓ shutdown 22:00 · gaming 2.0h'); + expect(result.roundTripMs, isNotNull); + expect(seen.url.toString(), 'http://10.0.0.7:$kPcPokePort$kPcPokePath'); + expect( + seen.headers[kPcPokeSignatureHeader], + signPokeBody(decodePokeKeyHex(_keyHex), seen.bodyBytes), + ); + final body = jsonDecode(utf8.decode(seen.bodyBytes)) as Map; + expect(body['record_id'], _session().startTime.toIso8601String()); + expect(body['sandbox'], isFalse); + expect(body['nonce'], 'nonce-1'); + expect(body['sent_at_ms'], 1760000000000); + expect(client.closed, isTrue); + }); + + test('a sandbox poke is flagged and reads back as sandbox', () async { + late Map body; + final client = MockClient((request) async { + body = jsonDecode(request.body) as Map; + return http.Response(_reply(sandbox: true), 200); + }); + final result = await _service(client, sandbox: true).poke(_session()); + expect(body['sandbox'], isTrue); + expect(result.outcome, PokeOutcome.sandbox); + expect(result.label, startsWith('PC ✓ (sandbox, not credited) · ')); + }); + + test('null shutdown and budget are carried as null, not guessed', () async { + final client = MockClient( + (_) async => http.Response(_reply(credited: true), 200), + ); + final result = await _service(client).poke(_session()); + expect(result.shutdown, isNull); + expect(result.gamingBudgetMinutes, isNull); + expect(result.label, 'PC ✓'); + }); + + test('a 200 ok:false is "not counted" with the PC reason', () async { + final client = MockClient( + (_) async => http.Response(_reply(ok: false, reason: 'too short'), 200), + ); + final result = await _service(client).poke(_session()); + expect(result.outcome, PokeOutcome.notCounted); + expect(result.label, 'PC: not counted — too short'); + }); + + test('a non-200 falls back to the sync line', () async { + final client = MockClient( + (_) async => http.Response(_reply(ok: false, reason: 'bad mac'), 401), + ); + final result = await _service(client).poke(_session()); + expect(result.outcome, PokeOutcome.failed); + expect(result.reason, contains('HTTP 401')); + expect(result.label, 'PC: will credit within 60 s (via sync)'); + }); + + test('a connection reset is a failure with the cause, not a throw', () async { + final client = _ClosingClient( + (_) async => throw http.ClientException('Connection reset by peer'), + ); + final result = await _service(client).poke(_session()); + expect(result.outcome, PokeOutcome.failed); + expect(result.reason, contains('could not reach the PC')); + expect(result.reason, contains('Connection reset by peer')); + expect(client.closed, isTrue); + }); + + test('a refused socket is a failure too', () async { + final client = MockClient( + (_) async => throw const SocketException('Connection refused'), + ); + final result = await _service(client).poke(_session()); + expect(result.reason, contains('Connection refused')); + }); + + test('a PC that never answers is cut off and its socket closed', () async { + final never = Completer(); + final client = _ClosingClient((_) => never.future); + final result = await _service( + client, + timeout: const Duration(milliseconds: 50), + ).poke(_session()); + expect(result.outcome, PokeOutcome.failed); + expect(result.reason, 'no reply from the PC within 50 ms'); + // Timer and Stopwatch clocks differ by a millisecond either way. + expect(result.roundTripMs, inInclusiveRange(40, 1000)); + expect(client.closed, isTrue); + }); + + test('a hanging keystore counts against the same budget', () async { + final client = _ClosingClient((_) async => http.Response('{}', 200)); + final result = await _service( + client, + pairing: () => Completer().future, + timeout: const Duration(milliseconds: 30), + ).poke(_session()); + expect(result.reason, 'no reply from the PC within 30 ms'); + expect(client.closed, isFalse, reason: 'no client was ever built'); + }); + + test('an unpaired phone never sends anything', () async { + var sent = false; + final client = MockClient((_) async { + sent = true; + return http.Response('{}', 200); + }); + final result = await _service( + client, + pairing: () async => const PcPairingState(host: '10.0.0.7'), + ).poke(_session()); + expect(sent, isFalse); + expect(result.reason, contains('not paired')); + expect(result.roundTripMs, isNull); + }); +} diff --git a/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart b/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart new file mode 100644 index 0000000..40e0be6 --- /dev/null +++ b/stronglift_replacement/workout_app/test/widgets/pc_poke_line_test.dart @@ -0,0 +1,70 @@ +// PcPokeLine: a spinner while the poke is in flight, then one coloured line. +import 'dart:async'; + +import 'package:flutter/material.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:workout_app/services/pc_poke_result.dart'; +import 'package:workout_app/ui/theme.dart'; +import 'package:workout_app/widgets/pc_poke_line.dart'; + +void main() { + final theme = buildAppTheme(); + final status = theme.extension()!; + + Future show(WidgetTester tester, Future poke) async { + await tester.pumpWidget( + MaterialApp( + theme: theme, + home: Scaffold(body: PcPokeLine(poke: poke)), + ), + ); + await tester.pump(); + } + + Color? colorOf(WidgetTester tester) => + tester.widget(find.byKey(const Key('pc-poke-result'))).style!.color; + + testWidgets('pending shows "applying" with a spinner', (tester) async { + final never = Completer(); + await show(tester, never.future); + expect(find.byKey(const Key('pc-poke-pending')), findsOneWidget); + expect(find.text('PC: applying…'), findsOneWidget); + expect(find.byType(CircularProgressIndicator), findsOneWidget); + }); + + testWidgets('a sandbox round trip is a success line with its time', ( + tester, + ) async { + await show( + tester, + Future.value( + const PokeResult( + outcome: PokeOutcome.sandbox, + reason: 'sandbox', + roundTripMs: 77, + ), + ), + ); + expect(find.text('PC ✓ (sandbox, not credited) · 77 ms'), findsOneWidget); + expect(colorOf(tester), status.success); + }); + + testWidgets('a duplicate is neutral', (tester) async { + await show( + tester, + Future.value( + const PokeResult(outcome: PokeOutcome.duplicate, reason: 'dup'), + ), + ); + expect(find.text('PC: already credited'), findsOneWidget); + expect(colorOf(tester), theme.colorScheme.onSurfaceVariant); + }); + + testWidgets('a failure is a warning naming the sync fallback', ( + tester, + ) async { + await show(tester, Future.value(const PokeResult.failed('timed out'))); + expect(find.text('PC: will credit within 60 s (via sync)'), findsOneWidget); + expect(colorOf(tester), status.warning); + }); +}