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