mirror of
https://github.com/kuhyx/screen-locker.git
synced 2026-11-10 19:07:16 +01:00
workout_app: send finished workout to the PC instantly; PC link pairing; summary <100 ms
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_018RHwVPyiiQMPh4M3JQwQku
This commit is contained in:
parent
a826cfa166
commit
2dac472331
@ -72,6 +72,18 @@
|
||||
android:foregroundServiceType="dataSync"
|
||||
android:exported="false"
|
||||
android:stopWithTask="false" />
|
||||
<!-- adb-only pairing for the phone->PC workout poke. Exported because
|
||||
the shell is another uid; android.permission.DUMP is held by the
|
||||
adb shell and cannot be granted to a third-party app, so nothing
|
||||
else on the phone can send it. See PairPcReceiver. -->
|
||||
<receiver
|
||||
android:name=".PairPcReceiver"
|
||||
android:exported="true"
|
||||
android:permission="android.permission.DUMP">
|
||||
<intent-filter>
|
||||
<action android:name="com.kuhy.workout_app.PAIR_PC" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
<!-- Don't delete the meta-data below.
|
||||
This is used by the Flutter tool to generate GeneratedPluginRegistrant.java -->
|
||||
<meta-data
|
||||
|
||||
@ -24,9 +24,27 @@ class MainActivity : FlutterActivity() {
|
||||
else -> 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"
|
||||
}
|
||||
|
||||
@ -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 <pkg>/com.kuhy.workout_app.PairPcReceiver --es key <64 hex> [--es host <ip>]`.
|
||||
*
|
||||
* 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="<reason>"` 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<String, String>? {
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -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<String> 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,
|
||||
|
||||
@ -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://<pokeHost>: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.',
|
||||
|
||||
@ -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<SettingsScreen> {
|
||||
onOpenGitHubMirror: () => unawaited(_openGitHubMirror()),
|
||||
),
|
||||
const SizedBox(height: 20),
|
||||
_PcLinkSection(pairingLoader: widget.pcPairingLoader),
|
||||
const SizedBox(height: 20),
|
||||
_OfflineBackupSection(
|
||||
storageGranted: _storageGranted,
|
||||
onGrantStorage: _grantStorage,
|
||||
|
||||
@ -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<PcPairingState> 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<void> _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<void> _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<AppStatusColors>()!;
|
||||
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,
|
||||
),
|
||||
),
|
||||
],
|
||||
],
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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,
|
||||
|
||||
@ -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<ProgressionSyncResult> Function()? progressionPusher;
|
||||
|
||||
/// Loads the PC pairing for the PC LINK section. Injected so tests need
|
||||
/// neither the keystore nor the pairing channel.
|
||||
final Future<PcPairingState> Function() pcPairingLoader;
|
||||
|
||||
@override
|
||||
State<SettingsScreen> createState() => _SettingsScreenState();
|
||||
}
|
||||
|
||||
@ -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<WorkoutScreen> {
|
||||
/// Only the `setState` lives here — `setState` is `@protected` and cannot be
|
||||
/// called from an extension, so the rest is in [_persistFinishedWorkout].
|
||||
Future<void> _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 ──────────────────────────────────────────────────────────────────
|
||||
|
||||
@ -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<void> _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<void> _persistFinishedWorkout() async {
|
||||
Future<void> _persistFinishedWorkout(Stopwatch sinceTap) async {
|
||||
final endTime = DateTime.now();
|
||||
final results = <ExerciseResult>[];
|
||||
|
||||
@ -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<void>(
|
||||
context: context,
|
||||
barrierDismissible: false,
|
||||
builder: (_) =>
|
||||
WorkoutSummaryDialog(session: session, syncResult: syncResult),
|
||||
builder: (_) => WorkoutSummaryDialog(
|
||||
session: session,
|
||||
syncResult: syncResult,
|
||||
pcPoke: pcPoke,
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
|
||||
131
stronglift_replacement/workout_app/lib/services/pc_pairing.dart
Normal file
131
stronglift_replacement/workout_app/lib/services/pc_pairing.dart
Normal file
@ -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<PcPairingState> 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<void> 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<String> absorbPending() async {
|
||||
if (_channelDown) return 'no pairing channel on this platform';
|
||||
Map<Object?, Object?>? pending;
|
||||
try {
|
||||
pending = await channel.invokeMapMethod<Object?, Object?>('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<void>('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<void>('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;
|
||||
}
|
||||
@ -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<String, dynamic>) {
|
||||
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 = <String>['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)';
|
||||
}
|
||||
@ -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://<pc-host>: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<PcPairingState> 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<PcPairingState> 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<PokeResult> 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;
|
||||
}
|
||||
}
|
||||
@ -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<int> key, List<int> 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<String, dynamic> payload,
|
||||
}) => utf8.encode(
|
||||
jsonEncode({
|
||||
'v': kPcPokeVersion,
|
||||
'sent_at_ms': sentAtMs,
|
||||
'nonce': nonce,
|
||||
'sandbox': sandbox,
|
||||
'record_id': recordId,
|
||||
'payload': payload,
|
||||
}),
|
||||
);
|
||||
@ -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';
|
||||
|
||||
@ -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<String?> getPcHost() => _getSetting(kPcHostKey);
|
||||
|
||||
/// Persists the PC host.
|
||||
Future<void> setPcHost(String host) => _setSetting(kPcHostKey, host);
|
||||
}
|
||||
@ -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<String, dynamic>).map(
|
||||
(id, data) => MapEntry(id, Record.fromJson(data as Map<String, dynamic>)),
|
||||
);
|
||||
|
||||
/// 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<String, dynamic> workoutRecordPayload(WorkoutSession session) =>
|
||||
session.toJson();
|
||||
|
||||
/// The outcome of a [WorkoutSyncService.push] / [WorkoutSyncService.pushManual].
|
||||
///
|
||||
/// A bare `Future<void>` 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<List<Map<String, dynamic>>> 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<List<Map<String, dynamic>>> readMergedWorkoutPayloads() =>
|
||||
_readMergedPayloads();
|
||||
|
||||
/// Whether this device has ANY sync credentials -- a GitHub token or a
|
||||
/// Firebase account.
|
||||
///
|
||||
|
||||
@ -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<PokeResult> poke;
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final status = Theme.of(context).extension<AppStatusColors>()!;
|
||||
return FutureBuilder<PokeResult>(
|
||||
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),
|
||||
);
|
||||
},
|
||||
);
|
||||
}
|
||||
}
|
||||
@ -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<PokeResult>? 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: [
|
||||
|
||||
@ -147,7 +147,7 @@ packages:
|
||||
source: git
|
||||
version: "0.11.1"
|
||||
crypto:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: crypto
|
||||
sha256: c8ea0233063ba03258fbcf2ca4d6dadfefe14f02fab57702265467a19f27fadf
|
||||
|
||||
@ -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
|
||||
|
||||
@ -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<PcPairingState> fakePcPairing() async =>
|
||||
const PcPairingState(host: '10.0.0.2');
|
||||
@ -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<String, dynamic>;
|
||||
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<String, dynamic>;
|
||||
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<String, dynamic>,
|
||||
);
|
||||
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);
|
||||
});
|
||||
}
|
||||
@ -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 = <String>[];
|
||||
@ -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<SocketException>()),
|
||||
);
|
||||
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<SocketException>()),
|
||||
);
|
||||
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);
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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,
|
||||
|
||||
@ -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<void> pump(WidgetTester tester, PcPairingState pairing) async {
|
||||
await tester.runAsync(() async {
|
||||
await tester.pumpWidget(wrap(pairing));
|
||||
await Future<void>.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<void> 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<void>.delayed(const Duration(milliseconds: 200));
|
||||
});
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
String pairedLine(WidgetTester tester) =>
|
||||
tester.widget<Text>(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<TextField>(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');
|
||||
});
|
||||
}
|
||||
@ -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.
|
||||
|
||||
@ -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(
|
||||
|
||||
@ -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();
|
||||
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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.
|
||||
|
||||
@ -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<void>.delayed(const Duration(milliseconds: 200));
|
||||
await tester.pump();
|
||||
await tester.tap(find.widgetWithText(TextButton, 'Finish').last);
|
||||
await Future<void>.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,
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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<String> 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<String, String>? 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<String?> 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);
|
||||
});
|
||||
}
|
||||
@ -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, '<html>', 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)',
|
||||
);
|
||||
});
|
||||
}
|
||||
@ -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<PcPairingState> 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<String, dynamic> body;
|
||||
final client = MockClient((request) async {
|
||||
body = jsonDecode(request.body) as Map<String, dynamic>;
|
||||
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<http.Response>();
|
||||
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<PcPairingState>().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);
|
||||
});
|
||||
}
|
||||
@ -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<AppStatusColors>()!;
|
||||
|
||||
Future<void> show(WidgetTester tester, Future<PokeResult> poke) async {
|
||||
await tester.pumpWidget(
|
||||
MaterialApp(
|
||||
theme: theme,
|
||||
home: Scaffold(body: PcPokeLine(poke: poke)),
|
||||
),
|
||||
);
|
||||
await tester.pump();
|
||||
}
|
||||
|
||||
Color? colorOf(WidgetTester tester) =>
|
||||
tester.widget<Text>(find.byKey(const Key('pc-poke-result'))).style!.color;
|
||||
|
||||
testWidgets('pending shows "applying" with a spinner', (tester) async {
|
||||
final never = Completer<PokeResult>();
|
||||
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);
|
||||
});
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user