From da5412528998cc45549e0542e63358e6435aa905 Mon Sep 17 00:00:00 2001 From: chayapats Date: Sat, 31 Oct 2026 19:28:21 +0700 Subject: [PATCH 1/3] fix(ble): verify initial pairing before reporting success --- lib/ble/ble_engine.dart | 33 ++- lib/ble/ble_state.dart | 37 +++ lib/l10n/app_en.arb | 6 +- lib/state/app_state.dart | 55 +++-- lib/state/sync_controller.dart | 60 +++++ lib/ui2/onboarding/pairing.dart | 8 +- test/app_state_pairing_confirmation_test.dart | 210 ++++++++++++++++++ test/ble_adapter_readiness_test.dart | 78 +++++++ test/ble_state_test.dart | 24 ++ test/sync_controller_test.dart | 43 ++++ 10 files changed, 522 insertions(+), 32 deletions(-) create mode 100644 test/app_state_pairing_confirmation_test.dart create mode 100644 test/ble_adapter_readiness_test.dart diff --git a/lib/ble/ble_engine.dart b/lib/ble/ble_engine.dart index ae5be1ed6..7579883a0 100644 --- a/lib/ble/ble_engine.dart +++ b/lib/ble/ble_engine.dart @@ -2796,17 +2796,17 @@ class BleEngine { bondRefusals: state.bondRefusals, ); - /// Read the adapter state without hanging: `unknown` is the pre-init value and - /// never a verdict, so wait past it, but never longer than [_blockerProbe]. + /// Read past pre-init/resetting states without an unbounded startup await. static const Duration _blockerProbe = Duration(seconds: 2); + Future _readAdapterState() => waitForBleAdapterState( + FlutterBluePlus.adapterState.map((s) => s.name), + timeout: _blockerProbe, + ); + Future _detectBlocker() async { try { - final s = await FlutterBluePlus.adapterState - .firstWhere((s) => s != BluetoothAdapterState.unknown) - .timeout(_blockerProbe, - onTimeout: () => BluetoothAdapterState.unknown); - return classifyBleBlocker(adapterState: s.name); + return classifyBleBlocker(adapterState: await _readAdapterState()); } catch (e) { return classifyBleBlocker(error: e); } @@ -2819,7 +2819,7 @@ class BleEngine { onState(state); } - /// Anything that actually reached the radio proves the stack is usable again. + /// Remove a previous blocker once current evidence no longer supports it. void _clearBlocker() { if (_blocker == null) return; _blocker = null; @@ -2920,6 +2920,23 @@ class BleEngine { ); try { + // ASK authorizes the accessory before FBP creates its own central. That + // central initially reports Unknown even with the radio on. Calling + // connect immediately makes Darwin reject it with generic "Bluetooth + // must be turned on" text, forcing an unnecessary user retry. + final adapter = await _readAdapterState(); + if (adapter != 'on') { + final blocker = classifyBleBlocker(adapterState: adapter); + if (blocker != null) { + _noteBlocker(blocker); + } else { + _clearBlocker(); + } + _log('[BLE] connect not started: adapter state is $adapter.'); + await _failConnect(); + return false; + } + _clearBlocker(); await device.connect( timeout: const Duration(seconds: 20), autoConnect: false, diff --git a/lib/ble/ble_state.dart b/lib/ble/ble_state.dart index fa8db715f..416159564 100644 --- a/lib/ble/ble_state.dart +++ b/lib/ble/ble_state.dart @@ -105,6 +105,34 @@ class BleUnavailableException implements Exception { String toString() => 'BleUnavailableException(${blocker.name})'; } +/// Wait past the adapter's initial/transition states before issuing a radio +/// command. A deadline or an ended stream leaves the state unknown; neither +/// proves that Bluetooth is off. State names keep this independent of FBP. +Future waitForBleAdapterState( + Stream states, { + required Duration timeout, +}) async { + final result = Completer(); + final sub = states.listen((state) { + if (state != 'unknown' && state != 'turningOn' && !result.isCompleted) { + result.complete(state); + } + }, onError: (Object error, StackTrace stack) { + if (!result.isCompleted) result.completeError(error, stack); + }, onDone: () { + if (!result.isCompleted) result.complete('unknown'); + }); + final deadline = Timer(timeout, () { + if (!result.isCompleted) result.complete('unknown'); + }); + try { + return await result.future; + } finally { + deadline.cancel(); + await sub.cancel(); + } +} + /// Map an adapter-state name and/or a thrown transport error onto a blocker. /// Returns null when neither says the stack is unusable — i.e. the failure is /// about the band, not the phone. @@ -124,6 +152,15 @@ BleBlocker? classifyBleBlocker({String? adapterState, Object? error}) { } if (error == null) return null; final s = error.toString().toLowerCase(); + // FBP's Darwin plugin uses "bluetooth must be turned on" for EVERY + // CBManager state other than PoweredOn, including its initial Unknown. + // Prefer the native state it includes over that generic wording. + if (s.contains('cbmanagerstateunknown') || + s.contains('cbmanagerstateresetting') || + s.contains('cbmanagerstatepoweredon')) { + return null; + } + if (s.contains('cbmanagerstateunsupported')) return BleBlocker.unsupported; if (s.contains('unauthorized') || s.contains('permission') || s.contains('not authorized') || diff --git a/lib/l10n/app_en.arb b/lib/l10n/app_en.arb index 3e09adf20..f34e9125a 100644 --- a/lib/l10n/app_en.arb +++ b/lib/l10n/app_en.arb @@ -68,11 +68,11 @@ "@pairingSkipNote": { "description": "Note under the skip button." }, - "pairingIdleTitle": "Wake the band and hold it close", + "pairingIdleTitle": "Put your band in pairing mode", "@pairingIdleTitle": { "description": "Pairing state title: idle." }, - "pairingIdleBody": "Take the band off the charger, put it on your wrist and keep the phone within arm’s reach.", + "pairingIdleBody": "Take the band off your wrist and tap the top firmly until it flashes blue. Keep your phone nearby.", "@pairingIdleBody": { "description": "Pairing state body: idle." }, @@ -168,7 +168,7 @@ "@pairingFailedAdviceTitle": { "description": "Advice card title: failed." }, - "pairingFailedAdviceBody": "Scanning again from a metre away normally works.", + "pairingFailedAdviceBody": "Put the band in pairing mode (flashing blue), then tap Try again.", "@pairingFailedAdviceBody": { "description": "Advice card body: failed." }, diff --git a/lib/state/app_state.dart b/lib/state/app_state.dart index a2fb7c240..4cf981a18 100644 --- a/lib/state/app_state.dart +++ b/lib/state/app_state.dart @@ -42,7 +42,7 @@ import '../ble/hrs_link.dart'; import '../ble/polar_pmd_link.dart'; import '../ble/live_step_runs.dart'; import '../ble/ble_state.dart' - show AlarmConfirmation, AlarmEffect, LiveStreamOwners; + show AlarmConfirmation, AlarmEffect, BleUnavailableException, LiveStreamOwners; import '../ble/ios_ble_restore.dart'; import '../ble/session_link.dart' show SessionLink; import '../ble/colmi_link.dart' show ColmiLink; @@ -4316,7 +4316,8 @@ class AppState extends ChangeNotifier { // structurally blank for everyone who paired once, and every per-family // metric abstains for a reason that is our bookkeeping, not the band's. // Change-gated: this handler fires ~1 Hz during live HR. - if (s.generation != null && s.generation != _lastSeenGeneration) { + if (!_pairingInProgress && + s.generation != null && s.generation != _lastSeenGeneration) { _lastSeenGeneration = s.generation; unawaited(LocalDb.upsertDevice(adapterId: s.generation)); } @@ -4439,8 +4440,8 @@ class AppState extends ChangeNotifier { Future accessorySetupSupported() => AccessorySetup.isSupported(); /// iOS 18+ pairing: show the ASK picker, persist the provisioned band by its - /// CoreBluetooth UUID (== flutter_blue_plus remoteId), then open the session. Throws - /// if the user cancels or no accessory is provisioned. The picker is skipped (returns + /// CoreBluetooth UUID (== flutter_blue_plus remoteId) AFTER a working bootstrap, + /// then open the session. Throws if setup fails. The picker is skipped (returns /// the known id) if a WHOOP is already provisioned via ASK. Future pairViaAccessorySetup({String? serial}) async { final remoteId = await AccessorySetup.showPicker(); @@ -4456,20 +4457,40 @@ class AppState extends ChangeNotifier { await _persistPaired(d.remoteId.str, serial); } + bool _pairingInProgress = false; + Future _persistPaired(String remoteId, String? serial) async { - // Which band this is, if the link has already said. Passed HERE and not at - // the two call sites (`pairWith`, `pairViaAccessorySetup`) so neither can - // forget it — and COALESCE'd inside `upsertDevice`, so a null leaves - // whatever the row already knows. - // - // It is null on a FIRST pair, always: `DeviceState.generation` is only set - // at service discovery and pairing runs before any session exists. That is - // why `_onEngineState` stamps it too — this line alone would leave - // `device.adapter_id` blank on every install that pairs once and never - // re-pairs. - await PairedDevice.save(remoteId, serial ?? device.serial, - generation: device.generation); - paired = await PairedDevice.load(); + if (_pairingInProgress) throw Exception('Pairing is already in progress.'); + final existing = paired; + if (existing != null) { + if (existing.remoteId != remoteId) { + throw Exception('Forget the current band before pairing another.'); + } + await openSession(); + if (!engine.isConnected) throw Exception('Connection setup did not complete.'); + return; + } + _pairingInProgress = true; + try { + final confirmed = await _sync.connectForPairing( + PairedDevice(remoteId, serial), + confirm: () async { + // Only READY proves we can talk to this band. Until then neither + // the persisted record nor isPaired may advance onboarding. Save + // the identity THIS bootstrap discovered, not a stale device hint. + await PairedDevice.save(remoteId, serial ?? device.serial, + generation: device.generation); + paired = await PairedDevice.load(); + }, + ); + if (!confirmed) { + final blocker = engine.bluetoothBlocker; + if (blocker != null) throw BleUnavailableException(blocker); + throw Exception('Connection setup did not complete.'); + } + } finally { + _pairingInProgress = false; + } // Now that there's a band to alert about, ask for notification permission // (a natural moment; battery/charging alerts depend on it). Best-effort. unawaited(NotificationService.instance.ensurePermission()); diff --git a/lib/state/sync_controller.dart b/lib/state/sync_controller.dart index 0993b6a5d..0baa0282e 100644 --- a/lib/state/sync_controller.dart +++ b/lib/state/sync_controller.dart @@ -494,6 +494,66 @@ class SyncController { } } + /// Verify a first pairing before the host saves it. A picker grant is only + /// permission to reach a peripheral; the engine must complete its bootstrap + /// (including the required subscriptions and gen5 HELLO) before [confirm]. + /// No reconnect loop is started for an unconfirmed band. On failure retain + /// its ASK grant so Retry can use it, but retire native restore intent. + Future connectForPairing( + PairedDevice candidate, { + required Future Function() confirm, + }) async { + if (busy || _reconnecting || paired != null || _isDisposed || ResetGate.active) { + return false; + } + final generation = _reconnectGeneration; + final resetGeneration = ResetGate.generation; + bool current() => !_isDisposed && + generation == _reconnectGeneration && + !ResetGate.changedSince(resetGeneration); + var confirmed = false; + _setBusy(true); + BandOwnership.markForegroundIntent(true); + IosBleRestore.foregroundActive = true; + try { + background = false; + engine.setBackground(false); + _scheduler.setBackground(false); + await _ensureForegroundLease(); + await IosBleRestore.setOwnsBand(true); + if (!current()) return false; + _log('===== PAIRING VERIFICATION ====='); + final ready = await engine.connectToRemoteId(candidate.remoteId, + generationHint: candidate.generation); + if (!ready || !engine.isConnected || !current()) { + _log('Pairing: bootstrap did not complete; pairing not confirmed.'); + return false; + } + await confirm(); + if (!current() || paired?.remoteId != candidate.remoteId) return false; + confirmed = true; + return true; + } finally { + try { + if (!confirmed) { + // No unpair/removeAccessory here: removing an ASK grant also + // removes the system bond. A failed setup must remain retryable. + try { + await engine.disconnect(); + } catch (e) { + _log('Pairing cleanup: disconnect failed: $e'); + } + await IosBleRestore.disarmOne(candidate.remoteId); + IosBleRestore.foregroundActive = false; + BandOwnership.markForegroundIntent(false); + _releaseForegroundLease(); + } + } finally { + _setBusy(false); + } + } + } + Future openSession({bool foreground = true}) async { if (paired == null) return; // Returning to the foreground with the connection still alive (kept during diff --git a/lib/ui2/onboarding/pairing.dart b/lib/ui2/onboarding/pairing.dart index 7441162c1..36a9005ff 100644 --- a/lib/ui2/onboarding/pairing.dart +++ b/lib/ui2/onboarding/pairing.dart @@ -254,7 +254,7 @@ class PairingView extends StatelessWidget { PairPhase.bluetoothBlocked => localizedBandStatus( c, bandStatusFor(connection: 'disconnected', blocker: blocker)) .title, - PairPhase.idle => l?.pairingIdleTitle ?? 'Wake the band and hold it close', + PairPhase.idle => l?.pairingIdleTitle ?? 'Put your band in pairing mode', PairPhase.scanning => l?.pairingScanningTitle ?? 'Looking for your band', PairPhase.notFound => l?.pairingNotFoundTitle ?? 'No band in range', PairPhase.bondRefused => @@ -272,8 +272,8 @@ class PairingView extends StatelessWidget { c, bandStatusFor(connection: 'disconnected', blocker: blocker)) .reason, PairPhase.idle => l?.pairingIdleBody ?? - 'Take the band off the charger, put it on your wrist and keep the ' - 'phone within arm’s reach.', + 'Take the band off your wrist and tap the top firmly until it ' + 'flashes blue. Keep your phone nearby.', PairPhase.scanning => l?.pairingScanningBody ?? 'A band that has just come off the charger can take up to half a ' 'minute to start advertising.', @@ -343,7 +343,7 @@ class PairingView extends StatelessWidget { l?.pairingFailedAdviceTitle ?? 'The band was found but the session did not finish', l?.pairingFailedAdviceBody ?? - 'Scanning again from a metre away normally works.', + 'Put the band in pairing mode (flashing blue), then tap Try again.', icon: LucideIcons.triangleAlert, ), if (detail.isNotEmpty) ...[ diff --git a/test/app_state_pairing_confirmation_test.dart b/test/app_state_pairing_confirmation_test.dart new file mode 100644 index 000000000..b89ae8618 --- /dev/null +++ b/test/app_state_pairing_confirmation_test.dart @@ -0,0 +1,210 @@ +// Selecting a band in the system picker is not a completed BLE bootstrap. +// Pair through the real AppState entry point with only the radio replaced, +// so a failed first connect cannot persist a pairing or advance onboarding. +import 'package:flutter/services.dart'; +import 'package:flutter_blue_plus/flutter_blue_plus.dart'; +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ios_ble_restore.dart'; +import 'package:openstrap_edge/data/db.dart'; +import 'package:openstrap_edge/notify/notification_service.dart'; +import 'package:openstrap_edge/sync/band_ownership.dart'; +import 'package:openstrap_edge/sync/paired_device.dart'; + +import 'support/app_state_sync_harness.dart'; + +const _db = 'app_state_pairing_confirmation.db'; + +Future> _history() async { + final db = await LocalDb.instance; + return { + 'raw': await db.query('decoded_onehz'), + 'days': await db.query('day_result'), + }; +} + +void main() { + TestWidgetsFlutterBinding.ensureInitialized(); + setUp(() async { + await deriveDbSetUp(_db); + NotificationService.instance.invalidatePermissionCache(); + NotificationService.instance.debugRequestPermission = () async => false; + NotificationService.instance.debugProbePermission = () async => false; + await (await LocalDb.instance).insert('decoded_onehz', { + 'device_id': '', + 'ts_ms': 1786233600000, + 'rec_ts': 1786233600, + 'counter': 7, + 'hr': 62, + }); + await (await LocalDb.instance).insert('day_result', { + 'day_id': '2026-08-09', + 'algo_version': 61, + 'payload_json': '{"scalars":{"rhr":52.0}}', + 'window_json': '{}', + 'computed_at': 1, + 'finalized': 1, + 'skipped': 0, + 'partial': 0, + }); + }); + tearDown(() async { + NotificationService.instance.debugRequestPermission = null; + NotificationService.instance.debugProbePermission = null; + await deriveDbTearDown(_db); + }); + + test( + 'a failed first connect is not saved or reported as paired', + syncCase((rig, timers) async { + final before = await _history(); + final pairedTicks = []; + rig.app.addListener(() => pairedTicks.add(rig.app.isPaired)); + rig.engine.connectScript.add(false); + rig.engine.connectHook = () async { + // Discovery can identify a gen5 before CCC registration fails. That + // partial bootstrap must not create a persisted device binding. + rig.engine.state.generation = 'gen5'; + rig.app.debugFeedEngineState('', rig.engine.state); + }; + + await expectLater( + rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)), + throwsA(isA()), + ); + + expect(rig.app.isPaired, isFalse); + expect(await PairedDevice.load(), isNull); + expect(pairedTicks, isNot(contains(true))); + expect(rig.app.busy, isFalse); + expect(BandOwnership.owner, isNull); + expect(BandOwnership.foregroundIntent, isFalse); + expect(IosBleRestore.foregroundActive, isFalse); + expect( + timers.activePeriodic(kSuperviseEvery), + isEmpty, + reason: 'an unconfirmed band must not start auto-reconnect', + ); + expect(await _history(), before); + }, paired: false), + ); + + test( + 'pairing waits for bootstrap, then saves the discovered identity', + syncCase((rig, timers) async { + final before = await _history(); + final gate = rig.holdConnect(); + final pairing = rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)); + await rig.waitFor(() => rig.engine.count('connect') == 1); + + expect(rig.app.isPaired, isFalse); + expect(await PairedDevice.load(), isNull); + expect(timers.activePeriodic(kSuperviseEvery), isEmpty); + + rig.engine.state.generation = 'gen5'; + rig.engine.state.serial = 'MGB12345'; + gate.complete(); + await pairing; + + final saved = await PairedDevice.load(); + expect(saved?.remoteId, kRemoteId); + expect(saved?.serial, 'MGB12345'); + expect(saved?.generation, 'gen5'); + expect(rig.app.isPaired, isTrue); + expect(rig.app.isConnected, isTrue); + expect(timers.activePeriodic(kSuperviseEvery), hasLength(1)); + expect(await _history(), before); + }, paired: false), + ); + + test( + 'a thrown bootstrap releases ownership and remains retryable', + syncCase((rig, timers) async { + final before = await _history(); + final failure = StateError('CCC registration failed'); + rig.engine.connectScript.add(failure); + await expectLater( + rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)), + throwsA(same(failure)), + ); + expect(rig.app.isPaired, isFalse); + expect(await PairedDevice.load(), isNull); + expect(rig.app.busy, isFalse); + expect(BandOwnership.owner, isNull); + expect(IosBleRestore.foregroundActive, isFalse); + + await rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)); + expect(rig.app.isPaired, isTrue); + expect(await _history(), before); + }, paired: false), + ); + + test( + 'a failed attempt can be retried without forgetting or deleting data', + syncCase((rig, timers) async { + final before = await _history(); + rig.engine.connectScript.add(false); + await expectLater( + rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)), + throwsA(isA()), + ); + + rig.engine.state.generation = 'gen5'; + await rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)); + + expect(rig.app.isPaired, isTrue); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + expect(await _history(), before); + }, paired: false), + ); + + test( + 'an ASK picker grant alone does not confirm pairing, and can be retried', + syncCase((rig, timers) async { + const channel = MethodChannel('openstrap/accessory_setup'); + final calls = []; + final messenger = + TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger; + messenger.setMockMethodCallHandler(channel, (call) async { + calls.add(call.method); + if (call.method == 'showPicker') return kRemoteId; + return null; + }); + addTearDown(() => messenger.setMockMethodCallHandler(channel, null)); + rig.engine.connectScript.add(false); + + await expectLater( + rig.app.pairViaAccessorySetup(), + throwsA(isA()), + ); + expect(rig.app.isPaired, isFalse); + expect(await PairedDevice.load(), isNull); + + await rig.app.pairViaAccessorySetup(); + expect(rig.app.isPaired, isTrue); + expect(calls, ['showPicker', 'showPicker']); + }, paired: false), + ); + + test( + 'forgetting while bootstrap waits cannot recreate the pairing', + syncCase((rig, timers) async { + final before = await _history(); + final gate = rig.holdConnect(); + final pairing = rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)); + final failed = expectLater(pairing, throwsA(isA())); + await rig.waitFor(() => rig.engine.count('connect') == 1); + + await rig.app.unpair(); + gate.complete(); + await failed; + + expect(rig.app.isPaired, isFalse); + expect(await PairedDevice.load(), isNull); + expect(rig.engine.isConnected, isFalse); + expect(rig.app.busy, isFalse); + expect(BandOwnership.owner, isNull); + expect(timers.activePeriodic(kSuperviseEvery), isEmpty); + expect(await _history(), before); + }, paired: false), + ); +} diff --git a/test/ble_adapter_readiness_test.dart b/test/ble_adapter_readiness_test.dart new file mode 100644 index 000000000..b7fbb8a9a --- /dev/null +++ b/test/ble_adapter_readiness_test.dart @@ -0,0 +1,78 @@ +import 'dart:async'; + +import 'package:flutter_test/flutter_test.dart'; +import 'package:openstrap_edge/ble/ble_state.dart'; + +void main() { + test('startup Unknown and Resetting wait for a usable state', () async { + final states = StreamController(); + String? result; + final ready = waitForBleAdapterState( + states.stream, + timeout: const Duration(seconds: 2), + ).then((s) => result = s); + states.add('unknown'); + states.add('turningOn'); + await Future.delayed(Duration.zero); + expect(result, isNull, reason: 'startup is not a Bluetooth-off verdict'); + + states.add('on'); + await ready; + expect(result, 'on'); + await states.close(); + }); + + test('real blockers are returned without waiting for the deadline', () async { + for (final state in ['off', 'turningOff', 'unauthorized', 'unavailable']) { + expect( + await waitForBleAdapterState( + Stream.value(state), + timeout: const Duration(seconds: 2), + ), + state, + ); + } + }); + + test( + 'a startup that never completes times out as Unknown, not Off', + () async { + final states = StreamController(); + final ready = waitForBleAdapterState( + states.stream, + timeout: const Duration(milliseconds: 10), + ); + states.add('unknown'); + final result = await ready; + expect(result, 'unknown'); + expect(classifyBleBlocker(adapterState: result), isNull); + expect( + states.hasListener, + isFalse, + reason: 'the timed-out probe must release its adapter subscription', + ); + await states.close(); + }, + ); + + test('an ended startup stream remains Unknown', () async { + expect( + await waitForBleAdapterState( + Stream.value('unknown'), + timeout: const Duration(seconds: 2), + ), + 'unknown', + ); + }); + + test('an adapter-stream error reaches the caller', () async { + final error = StateError('adapter unavailable'); + await expectLater( + waitForBleAdapterState( + Stream.error(error), + timeout: const Duration(seconds: 2), + ), + throwsA(same(error)), + ); + }); +} diff --git a/test/ble_state_test.dart b/test/ble_state_test.dart index 656b7db0c..dc913b1cc 100644 --- a/test/ble_state_test.dart +++ b/test/ble_state_test.dart @@ -588,6 +588,30 @@ void main() { BleBlocker.unsupported); }); + test('CoreBluetooth startup is not a powered-off radio', () { + for (final state in ['Unknown', 'Resetting']) { + expect( + classifyBleBlocker(error: Exception('PlatformException(connect, ' + 'bluetooth must be turned on. (CBManagerState$state), null, null)')), + isNull, + ); + } + }); + + test('CoreBluetooth reports its real blocker despite generic off wording', () { + for (final entry in { + 'PoweredOff': BleBlocker.adapterOff, + 'Unauthorized': BleBlocker.permissionDenied, + 'Unsupported': BleBlocker.unsupported, + }.entries) { + expect( + classifyBleBlocker(error: Exception('PlatformException(connect, ' + 'bluetooth must be turned on. (CBManagerState${entry.key}), null, null)')), + entry.value, + ); + } + }); + test('a usable stack and an ordinary band failure classify as null', () { expect(classifyBleBlocker(adapterState: 'on'), isNull); expect(classifyBleBlocker(adapterState: 'unknown'), isNull); diff --git a/test/sync_controller_test.dart b/test/sync_controller_test.dart index 3ee3ba819..1506d2e73 100644 --- a/test/sync_controller_test.dart +++ b/test/sync_controller_test.dart @@ -268,6 +268,49 @@ void main() { setUp(() => deriveDbSetUp(_db)); tearDown(() => deriveDbTearDown(_db)); + group('first pairing verification', () { + final candidate = PairedDevice(kRemoteId, kSerial, generation: 'gen5'); + + test('a failed bootstrap never confirms or starts reconnecting', + _rigCase((r, timers) async { + r.engine.connectScript.add(false); + var confirmations = 0; + expect(await r.c.connectForPairing(candidate, confirm: () async { + confirmations++; + }), isFalse); + expect(confirmations, 0); + expect(r.c.busy, isFalse); + expect(BandOwnership.owner, isNull); + expect(BandOwnership.foregroundIntent, isFalse); + expect(timers.live, isEmpty); + }, paired: false)); + + test('a failed save disconnects the unconfirmed link and clears busy', + _rigCase((r, timers) async { + final error = StateError('save failed'); + await expectLater(r.c.connectForPairing(candidate, confirm: () async { + throw error; + }), throwsA(same(error))); + expect(r.engine.isConnected, isFalse); + expect(r.c.busy, isFalse); + expect(BandOwnership.owner, isNull); + expect(BandOwnership.foregroundIntent, isFalse); + expect(timers.live, isEmpty); + }, paired: false)); + + test('a busy session is not mistaken for a successful pairing', + _rigCase((r, timers) async { + r.c.busy = true; + var confirmations = 0; + expect(await r.c.connectForPairing(candidate, confirm: () async { + confirmations++; + }), isFalse); + expect(confirmations, 0); + expect(r.trace, isEmpty); + expect(r.c.busy, isTrue, reason: 'the existing operation owns the flag'); + }, paired: false)); + }); + group('openSession and the busy early return', () { test('the foreground flip lands before the busy return, with the nudge', _rigCase((r, timers) async { From 5e5a946112f1f10adf70013d3a6df5e48a4cfbad Mon Sep 17 00:00:00 2001 From: chayapats Date: Sat, 31 Oct 2026 19:56:46 +0700 Subject: [PATCH 2/3] fix(sync): roll back failed pairing persistence --- lib/data/db.dart | 8 +- lib/sync/paired_device.dart | 100 +++++++---- test/app_state_pairing_confirmation_test.dart | 39 +++++ test/paired_device_test.dart | 158 ++++++++++++++++++ test/support/failing_preferences_store.dart | 40 +++++ 5 files changed, 311 insertions(+), 34 deletions(-) create mode 100644 test/support/failing_preferences_store.dart diff --git a/lib/data/db.dart b/lib/data/db.dart index 1dfb259b8..5e23ec8ee 100644 --- a/lib/data/db.dart +++ b/lib/data/db.dart @@ -2172,12 +2172,14 @@ class LocalDb { 'UPDATE device SET wearing = ?, wearing_set_ts = ? ' 'WHERE id = ? AND wearing_set_ts IS NULL'; + /// [executor] includes adoption in the caller's pairing transaction. static Future adoptDeviceWearing( int wearing, int setTs, [ String id = kPrimaryDeviceId, + DatabaseExecutor? executor, ]) async { - final db = await instance; + final db = executor ?? await instance; return db.rawUpdate(_adoptWearingSql, [wearing, setTs, id]); } @@ -2811,6 +2813,7 @@ class LocalDb { if (n > 0) deviceRowChanged.add(id); } + /// [executor] includes the upsert in the caller's persistence transaction. static Future upsertDevice({ String id = kPrimaryDeviceId, String? adapterId, @@ -2818,8 +2821,9 @@ class LocalDb { String? label, String? tier, bool clearAdapterId = false, + DatabaseExecutor? executor, }) async { - final db = await instance; + final db = executor ?? await instance; final now = DateTime.now().millisecondsSinceEpoch ~/ 1000; await db.insert('device', { 'id': id, diff --git a/lib/sync/paired_device.dart b/lib/sync/paired_device.dart index b61622908..f33ca1be8 100644 --- a/lib/sync/paired_device.dart +++ b/lib/sync/paired_device.dart @@ -183,40 +183,76 @@ class PairedDevice { // A forget that landed while the reads above were in flight wins: this // save is describing a band the user has just told us to drop. if (epoch != _forgetEpoch) return; - await LocalDb.upsertDevice( - adapterId: gen, - remoteId: remoteId, - label: clean, - tier: kBandSourceTier, - // `adapter_id` COALESCEs like every other column, and the primary row is - // reused for whatever band is primary — so a new band needs it said out - // loud that the old family no longer applies. - clearAdapterId: !sameDevice, - ); - // Re-checked between the two copies as well: `clear()` empties the table - // and the mirror in that order, so a forget landing inside this window - // would otherwise leave the mirror pointing at a band the table no longer - // has — and `load()` heals FROM the mirror. - if (epoch != _forgetEpoch) return; + final mirror = { + _kRemoteId: prefs.getString(_kRemoteId), + _kSerial: prefs.getString(_kSerial), + _kGeneration: prefs.getString(_kGeneration), + }; final worn = prefs.getStringList(_kWornOn); - if (worn != null && - worn.length == 3 && - (worn[0] == remoteId || worn[0].isEmpty)) { - final w = int.tryParse(worn[1]); - final ts = int.tryParse(worn[2]); - if (w != null && ts != null) await LocalDb.adoptDeviceWearing(w, ts); - await prefs.remove(_kWornOn); - } - await prefs.setString(_kRemoteId, remoteId); - if (clean != null) { - await prefs.setString(_kSerial, clean); - } else { - await prefs.remove(_kSerial); // never persist junk + + Future putString(String key, String? value) => + value == null ? prefs.remove(key) : prefs.setString(key, value); + Future requireWrite(Future write) async { + if (!await write) { + throw StateError('Could not persist paired device preferences.'); + } } - if (gen != null) { - await prefs.setString(_kGeneration, gen); - } else if (!sameDevice) { - await prefs.remove(_kGeneration); + + // A failed mirror must not leave a row that load() reports as paired. + // Keep the row and wearing adoption in one transaction until every mirror + // write succeeds, including plugins that report failure by returning false. + final db = await LocalDb.instance; + try { + await db.transaction((txn) async { + await LocalDb.upsertDevice( + adapterId: gen, + remoteId: remoteId, + label: clean, + tier: kBandSourceTier, + // A different band must not inherit the previous band's family. + clearAdapterId: !sameDevice, + executor: txn, + ); + if (worn != null && + worn.length == 3 && + (worn[0] == remoteId || worn[0].isEmpty)) { + final w = int.tryParse(worn[1]); + final ts = int.tryParse(worn[2]); + if (w != null && ts != null) { + await LocalDb.adoptDeviceWearing( + w, + ts, + LocalDb.kPrimaryDeviceId, + txn, + ); + } + await requireWrite(prefs.remove(_kWornOn)); + } + await requireWrite(putString(_kSerial, clean)); // never persist junk + if (gen != null) { + await requireWrite(putString(_kGeneration, gen)); + } else if (!sameDevice) { + await requireWrite(putString(_kGeneration, null)); + } + // Publish the mirror's identity after its dependent fields are ready. + await requireWrite(putString(_kRemoteId, remoteId)); + }); + } catch (error, stack) { + // SharedPreferences changes its cache before the native write completes. + // Restore every key so a partial mirror cannot heal the rolled-back row + // on the next load. The serialized queue keeps this cleanup ahead of any + // subsequent forget or re-pair. + try { + await Future.wait([ + for (final entry in mirror.entries) putString(entry.key, entry.value), + worn == null + ? prefs.remove(_kWornOn) + : prefs.setStringList(_kWornOn, worn), + ]); + } finally { + // Preserve the original failure if storage also refuses the rollback. + Error.throwWithStackTrace(error, stack); + } } } diff --git a/test/app_state_pairing_confirmation_test.dart b/test/app_state_pairing_confirmation_test.dart index b89ae8618..d016eac9c 100644 --- a/test/app_state_pairing_confirmation_test.dart +++ b/test/app_state_pairing_confirmation_test.dart @@ -9,8 +9,11 @@ import 'package:openstrap_edge/data/db.dart'; import 'package:openstrap_edge/notify/notification_service.dart'; import 'package:openstrap_edge/sync/band_ownership.dart'; import 'package:openstrap_edge/sync/paired_device.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'support/app_state_sync_harness.dart'; +import 'support/failing_preferences_store.dart'; const _db = 'app_state_pairing_confirmation.db'; @@ -116,6 +119,42 @@ void main() { }, paired: false), ); + for (final throws in [true, false]) { + test( + 'failed pairing persistence (throws=$throws) cleans up and can retry', + syncCase((rig, timers) async { + final before = await _history(); + final store = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = FailingPreferencesStore( + store, + key: 'paired_serial', + throws: throws, + ); + addTearDown(() => SharedPreferencesStorePlatform.instance = store); + + await expectLater( + rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)), + throwsStateError, + ); + + expect(rig.app.isPaired, isFalse); + expect(await PairedDevice.load(), isNull); + expect(rig.engine.isConnected, isFalse); + expect(rig.app.busy, isFalse); + expect(BandOwnership.owner, isNull); + expect(BandOwnership.foregroundIntent, isFalse); + expect(IosBleRestore.foregroundActive, isFalse); + expect(timers.activePeriodic(kSuperviseEvery), isEmpty); + expect(await _history(), before); + + await rig.app.pairWith(BluetoothDevice.fromId(kRemoteId)); + expect(rig.app.isPaired, isTrue); + expect((await PairedDevice.load())?.remoteId, kRemoteId); + expect(await _history(), before); + }, paired: false), + ); + } + test( 'a thrown bootstrap releases ownership and remains retryable', syncCase((rig, timers) async { diff --git a/test/paired_device_test.dart b/test/paired_device_test.dart index d89c6b232..1af020a20 100644 --- a/test/paired_device_test.dart +++ b/test/paired_device_test.dart @@ -15,8 +15,12 @@ import 'package:openstrap_edge/data/step_calibration.dart'; import 'package:openstrap_edge/sync/paired_device.dart'; import 'package:path/path.dart' as p; import 'package:shared_preferences/shared_preferences.dart'; +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; import 'package:sqflite_common_ffi/sqflite_ffi.dart'; +import 'support/failing_preferences_store.dart'; + void main() { TestWidgetsFlutterBinding.ensureInitialized(); @@ -56,6 +60,160 @@ void main() { expect(p.generation, 'gen5'); }); + for (final throws in [true, false]) { + for (final key in [ + 'paired_remote_id', + 'paired_serial', + 'paired_generation', + LocalDb.kPendingWornOnPref, + ]) { + test( + 'failed mirror $key (throws=$throws) leaves no new pairing', + () async { + final prefs = await SharedPreferences.getInstance(); + await prefs.setStringList(LocalDb.kPendingWornOnPref, [ + '', + '${Wearing.bicep}', + '1786000000', + ]); + final before = { + for (final key in prefs.getKeys()) key: prefs.get(key), + }; + final store = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = FailingPreferencesStore( + store, + key: key, + throws: throws, + ); + addTearDown(() => SharedPreferencesStorePlatform.instance = store); + + await expectLater( + PairedDevice.save('NEW-UUID', 'NEW001', generation: 'gen5'), + throwsStateError, + ); + + expect(await LocalDb.deviceRow(), isNull); + expect(await PairedDevice.load(), isNull); + expect({ + for (final key in prefs.getKeys()) key: prefs.get(key), + }, before); + await prefs.reload(); + expect({ + for (final key in prefs.getKeys()) key: prefs.get(key), + }, before); + expect( + await PairedDevice.load(), + isNull, + reason: 'the partial mirror must not heal a failed pairing', + ); + + await PairedDevice.save('NEW-UUID', 'NEW001', generation: 'gen5'); + expect((await PairedDevice.load())?.remoteId, 'NEW-UUID'); + expect(await LocalDb.deviceWearingRaw(), Wearing.bicep); + }, + ); + } + + for (final key in [ + 'paired_remote_id', + 'paired_serial', + 'paired_generation', + ]) { + test( + 'failed replacement $key (throws=$throws) preserves the old band', + () async { + await PairedDevice.save('OLD-UUID', 'OLD001', generation: 'gen4'); + await LocalDb.setDeviceWearing(Wearing.bicep); + final row = await LocalDb.deviceRow(); + final prefs = await SharedPreferences.getInstance(); + final mirror = { + for (final key in prefs.getKeys()) key: prefs.get(key), + }; + final store = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = FailingPreferencesStore( + store, + key: key, + throws: throws, + ); + addTearDown(() => SharedPreferencesStorePlatform.instance = store); + + // null fields exercise failed removals of the old serial/generation, + // not just failed setString calls. + await expectLater( + PairedDevice.save('NEW-UUID', null), + throwsStateError, + ); + + expect(await LocalDb.deviceRow(), row); + expect((await PairedDevice.load())?.remoteId, 'OLD-UUID'); + await prefs.reload(); + expect({ + for (final key in prefs.getKeys()) key: prefs.get(key), + }, mirror); + // The recovery mirror must also name the OLD band after a rebuild. + await LocalDb.deleteDevice(); + final recovered = await PairedDevice.load(); + expect(recovered?.remoteId, 'OLD-UUID'); + expect(recovered?.serial, 'OLD001'); + expect(recovered?.generation, 'gen4'); + }, + ); + } + } + + test( + 'a failed save cannot roll back a queued forget and new pairing', + () async { + final store = SharedPreferencesStorePlatform.instance; + SharedPreferencesStorePlatform.instance = FailingPreferencesStore( + store, + key: 'paired_serial', + ); + addTearDown(() => SharedPreferencesStorePlatform.instance = store); + + final failed = expectLater( + PairedDevice.save('FAILED-UUID', 'FAILED001', generation: 'gen4'), + throwsStateError, + ); + final forget = PairedDevice.clear(); + final repair = PairedDevice.save( + 'NEW-UUID', + 'NEW001', + generation: 'gen5', + ); + await Future.wait([failed, forget, repair]); + + final paired = await PairedDevice.load(); + expect(paired?.remoteId, 'NEW-UUID'); + expect(paired?.serial, 'NEW001'); + expect(paired?.generation, 'gen5'); + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + expect(prefs.getString('paired_remote_id'), 'NEW-UUID'); + }, + ); + + test('a failed database update rolls back the initial insert', () async { + final db = await LocalDb.instance; + await db.execute( + 'CREATE TEMP TRIGGER refuse_pairing_update ' + 'BEFORE UPDATE ON device BEGIN ' + "SELECT RAISE(ABORT, 'cannot update pairing'); END", + ); + addTearDown(() => db.execute('DROP TRIGGER refuse_pairing_update')); + + await expectLater( + PairedDevice.save('NEW-UUID', 'NEW001', generation: 'gen5'), + throwsA(isA()), + ); + + expect(await LocalDb.deviceRow(), isNull); + expect(await PairedDevice.load(), isNull); + final prefs = await SharedPreferences.getInstance(); + await prefs.reload(); + expect(prefs.getString('paired_remote_id'), isNull); + }); + test( 'a same-device save without a generation preserves the stored one', () async { diff --git a/test/support/failing_preferences_store.dart b/test/support/failing_preferences_store.dart new file mode 100644 index 000000000..e35330dec --- /dev/null +++ b/test/support/failing_preferences_store.dart @@ -0,0 +1,40 @@ +// Fail an actual preferences write without replacing PairedDevice.save. +// ignore: depend_on_referenced_packages +import 'package:shared_preferences_platform_interface/shared_preferences_platform_interface.dart'; + +class FailingPreferencesStore extends SharedPreferencesStorePlatform { + FailingPreferencesStore( + this.delegate, { + required this.key, + this.throws = true, + }); + + final SharedPreferencesStorePlatform delegate; + final String key; + final bool throws; + var _failed = false; + + Future _write(String key, Future Function() write) async { + if (!_failed && key == 'flutter.${this.key}') { + _failed = true; + // A platform failure can arrive after the value has already changed. + await write(); + if (throws) throw StateError('preferences write failed'); + return false; + } + return write(); + } + + @override + Future> getAll() => delegate.getAll(); + + @override + Future clear() => delegate.clear(); + + @override + Future remove(String key) => _write(key, () => delegate.remove(key)); + + @override + Future setValue(String valueType, String key, Object value) => + _write(key, () => delegate.setValue(valueType, key, value)); +} From 14e619b1b2e21976ded6e86e83947f657d4ddd01 Mon Sep 17 00:00:00 2001 From: chayapats Date: Sat, 31 Oct 2026 19:56:46 +0700 Subject: [PATCH 3/3] fix(l10n): align pairing-mode guidance across locales --- lib/l10n/app_de.arb | 6 +++--- lib/l10n/app_es.arb | 6 +++--- lib/l10n/app_fr.arb | 6 +++--- lib/l10n/app_hi.arb | 6 +++--- lib/l10n/app_ru.arb | 6 +++--- lib/l10n/app_zh.arb | 6 +++--- 6 files changed, 18 insertions(+), 18 deletions(-) diff --git a/lib/l10n/app_de.arb b/lib/l10n/app_de.arb index e8ad5b170..3b548e670 100644 --- a/lib/l10n/app_de.arb +++ b/lib/l10n/app_de.arb @@ -17,8 +17,8 @@ "pillLocalNoCloud": "Lokal · keine Cloud", "pairingSkipForNow": "Vorerst überspringen", "pairingSkipNote": "Die App startet ohne Band. Gemessen wird erst, wenn eines gekoppelt ist.", - "pairingIdleTitle": "Band aufwecken und nah heranhalten", - "pairingIdleBody": "Nimm das Band vom Ladegerät, leg es ans Handgelenk und halte dein Smartphone in Reichweite.", + "pairingIdleTitle": "Versetze dein Band in den Kopplungsmodus", + "pairingIdleBody": "Nimm das Band vom Handgelenk und tippe kräftig auf die Oberseite, bis es blau blinkt. Halte dein Smartphone in der Nähe.", "pairingScanningTitle": "Suche nach deinem Band", "pairingScanningBody": "Ein Band, das gerade vom Ladegerät kommt, braucht bis zu einer halben Minute, bis es sichtbar ist.", "pairingNotFoundTitle": "Kein Band in Reichweite", @@ -42,7 +42,7 @@ "pairingBondRefusedAdviceBody": "Öffne die Bluetooth-Einstellungen deines Smartphones, entferne das Band und suche dann hier erneut. Der abgelehnte Schlüssel stammt aus der alten Kopplung, und nur das System kann ihn löschen.", "pairingBondRefusedAdviceFix": "Dann zurückkommen und auf „Erneut versuchen“ tippen", "pairingFailedAdviceTitle": "Band gefunden, aber Verbindung nicht abgeschlossen", - "pairingFailedAdviceBody": "Meist klappt es, wenn du aus etwa einem Meter Entfernung erneut suchst.", + "pairingFailedAdviceBody": "Versetze das Band in den Kopplungsmodus (blaues Blinken) und tippe dann auf „Erneut versuchen“.", "profileSetupTitle": "Über dich", "profileSetupBody": "Das Geschlecht ist die einzige Angabe, die zum Fortfahren nötig ist. Alter, Größe und Gewicht beeinflussen, wie deine Daten ausgewertet werden; lässt du eine davon leer, bleiben nur die Werte aus, die sie benötigen.", "profileSetupSexHeader": "GESCHLECHT", diff --git a/lib/l10n/app_es.arb b/lib/l10n/app_es.arb index 4d2118ed2..d32fd6316 100644 --- a/lib/l10n/app_es.arb +++ b/lib/l10n/app_es.arb @@ -17,8 +17,8 @@ "pillLocalNoCloud": "Local · sin nube", "pairingSkipForNow": "Omitir por ahora", "pairingSkipNote": "La app se abre sin banda. No se mide nada hasta que se empareje una.", - "pairingIdleTitle": "Despierta la banda y acércala", - "pairingIdleBody": "Quita la banda del cargador, póntela en la muñeca y mantén el teléfono cerca.", + "pairingIdleTitle": "Pon tu banda en modo de emparejamiento", + "pairingIdleBody": "Quítate la banda de la muñeca y toca con firmeza la parte superior hasta que parpadee en azul. Mantén el teléfono cerca.", "pairingScanningTitle": "Buscando tu banda", "pairingScanningBody": "Una banda recién sacada del cargador puede tardar hasta medio minuto en empezar a anunciarse.", "pairingNotFoundTitle": "No hay banda al alcance", @@ -42,7 +42,7 @@ "pairingBondRefusedAdviceBody": "Abre los ajustes de Bluetooth del teléfono, olvida la banda y vuelve a buscar aquí. La clave rechazada es el registro de emparejamiento antiguo, y solo el sistema puede borrarlo.", "pairingBondRefusedAdviceFix": "Luego vuelve y toca Intentar de nuevo", "pairingFailedAdviceTitle": "Se encontró la banda pero la sesión no terminó", - "pairingFailedAdviceBody": "Volver a buscar desde un metro de distancia suele funcionar.", + "pairingFailedAdviceBody": "Pon la banda en modo de emparejamiento (parpadeo azul) y toca «Intentar de nuevo».", "profileSetupTitle": "Sobre ti", "profileSetupBody": "El sexo es la única opción obligatoria para continuar. La edad, la altura y el peso cambian cómo se puntúan tus datos; deja alguno en blanco y solo las métricas que lo necesiten quedarán sin disponibilidad.", "profileSetupSexHeader": "SEXO", diff --git a/lib/l10n/app_fr.arb b/lib/l10n/app_fr.arb index d30c7c634..94e79fd79 100644 --- a/lib/l10n/app_fr.arb +++ b/lib/l10n/app_fr.arb @@ -17,8 +17,8 @@ "pillLocalNoCloud": "Local · sans cloud", "pairingSkipForNow": "Ignorer pour l’instant", "pairingSkipNote": "L’application s’ouvre sans bracelet. Rien n’est mesuré tant qu’aucun n’est jumelé.", - "pairingIdleTitle": "Réveillez le bracelet et rapprochez-le", - "pairingIdleBody": "Retirez le bracelet du chargeur, mettez-le à votre poignet et gardez le téléphone à portée de main.", + "pairingIdleTitle": "Mettez votre bracelet en mode jumelage", + "pairingIdleBody": "Retirez le bracelet de votre poignet et tapotez fermement le dessus jusqu’à ce qu’il clignote en bleu. Gardez votre téléphone à proximité.", "pairingScanningTitle": "Recherche de votre bracelet", "pairingScanningBody": "Un bracelet qui vient d’être retiré du chargeur peut mettre jusqu’à trente secondes avant de se signaler.", "pairingNotFoundTitle": "Aucun bracelet à portée", @@ -42,7 +42,7 @@ "pairingBondRefusedAdviceBody": "Ouvrez les réglages Bluetooth du téléphone, oubliez le bracelet, puis relancez la recherche ici. La clé refusée est l’ancien enregistrement de jumelage, et seul le système peut l’effacer.", "pairingBondRefusedAdviceFix": "Revenez ensuite et touchez Réessayer", "pairingFailedAdviceTitle": "Le bracelet a été trouvé mais la session ne s’est pas terminée", - "pairingFailedAdviceBody": "Relancer la recherche à un mètre de distance fonctionne généralement.", + "pairingFailedAdviceBody": "Mettez le bracelet en mode jumelage (clignotement bleu), puis appuyez sur Réessayer.", "profileSetupTitle": "À propos de vous", "profileSetupBody": "Le sexe est le seul choix obligatoire pour continuer. L'âge, la taille et le poids modifient la façon dont vos données sont notées ; laissez-en un vide et seules les métriques qui en ont besoin resteront indisponibles.", "profileSetupSexHeader": "SEXE", diff --git a/lib/l10n/app_hi.arb b/lib/l10n/app_hi.arb index 89f1e08a4..708046e09 100644 --- a/lib/l10n/app_hi.arb +++ b/lib/l10n/app_hi.arb @@ -17,8 +17,8 @@ "pillLocalNoCloud": "स्थानीय · कोई क्लाउड नहीं", "pairingSkipForNow": "अभी के लिए छोड़ें", "pairingSkipNote": "ऐप बिना बैंड के खुलता है। जब तक कोई बैंड जोड़ा नहीं जाता, कुछ भी मापा नहीं जाता।", - "pairingIdleTitle": "बैंड को जगाएं और पास रखें", - "pairingIdleBody": "बैंड को चार्जर से हटाएं, कलाई पर पहनें और फ़ोन को पास रखें।", + "pairingIdleTitle": "अपने बैंड को पेयरिंग मोड में डालें", + "pairingIdleBody": "बैंड को कलाई से उतारें और उसके ऊपरी हिस्से को ज़ोर से थपथपाएँ, जब तक नीली रोशनी न झपकने लगे। फ़ोन को पास रखें।", "pairingScanningTitle": "आपका बैंड खोजा जा रहा है", "pairingScanningBody": "अभी चार्जर से हटाया गया बैंड सिग्नल भेजना शुरू करने में आधा मिनट तक ले सकता है।", "pairingNotFoundTitle": "सीमा में कोई बैंड नहीं", @@ -42,7 +42,7 @@ "pairingBondRefusedAdviceBody": "फ़ोन की ब्लूटूथ सेटिंग्स खोलें, बैंड को भूलें, फिर यहां दोबारा स्कैन करें। अस्वीकृत कुंजी पुराना पेयरिंग रिकॉर्ड है, और केवल सिस्टम ही इसे साफ़ कर सकता है।", "pairingBondRefusedAdviceFix": "फिर वापस आकर फिर से प्रयास करें पर टैप करें", "pairingFailedAdviceTitle": "बैंड मिल गया लेकिन सत्र पूरा नहीं हुआ", - "pairingFailedAdviceBody": "एक मीटर की दूरी से दोबारा स्कैन करना आमतौर पर काम करता है।", + "pairingFailedAdviceBody": "बैंड को पेयरिंग मोड में डालें (नीली रोशनी झपकती है), फिर «फिर से प्रयास करें» पर टैप करें।", "profileSetupTitle": "आपके बारे में", "profileSetupBody": "आगे बढ़ने के लिए केवल लिंग चुनना ज़रूरी है। उम्र, लंबाई और वज़न आपके डेटा के स्कोर होने के तरीके को बदलते हैं; इनमें से किसी को भी खाली छोड़ें, केवल उसकी आवश्यकता वाले मेट्रिक्स ही अनुपलब्ध रहेंगे।", "profileSetupSexHeader": "लिंग", diff --git a/lib/l10n/app_ru.arb b/lib/l10n/app_ru.arb index 9010da858..7c62f4dc8 100644 --- a/lib/l10n/app_ru.arb +++ b/lib/l10n/app_ru.arb @@ -16,8 +16,8 @@ "pillLocalNoCloud": "Локально · без облака", "pairingSkipForNow": "Пропустить", "pairingSkipNote": "Приложение откроется без браслета. Пока браслет не подключён, измерения выполняться не будут.", - "pairingIdleTitle": "Разбудите браслет и держите его рядом", - "pairingIdleBody": "Снимите браслет с зарядки, наденьте на запястье и держите телефон на расстоянии вытянутой руки.", + "pairingIdleTitle": "Переведите браслет в режим сопряжения", + "pairingIdleBody": "Снимите браслет с запястья и уверенно постучите по верхней части, пока он не начнёт мигать синим. Держите телефон рядом.", "pairingScanningTitle": "Ищем браслет", "pairingScanningBody": "После снятия с зарядки браслету может потребоваться до 30 секунд, чтобы начать передавать сигнал.", "pairingNotFoundTitle": "Браслет не найден", @@ -41,7 +41,7 @@ "pairingBondRefusedAdviceBody": "В настройках Bluetooth телефона забудьте браслет и снова запустите поиск здесь. Старую запись о сопряжении может удалить только система.", "pairingBondRefusedAdviceFix": "Затем вернитесь и нажмите «Повторить»", "pairingFailedAdviceTitle": "Браслет найден, но подключение не завершилось", - "pairingFailedAdviceBody": "Обычно помогает повторный поиск с расстояния около метра.", + "pairingFailedAdviceBody": "Переведите браслет в режим сопряжения (мигающий синий индикатор), затем нажмите «Повторить».", "profileSetupTitle": "О вас", "profileSetupBody": "Чтобы продолжить, нужно указать только пол. Возраст, рост и вес влияют на расчёт ваших показателей; если оставить какой-либо из них пустым, недоступными останутся только зависящие от него показатели.", "profileSetupSexHeader": "ПОЛ", diff --git a/lib/l10n/app_zh.arb b/lib/l10n/app_zh.arb index 858d2a12d..3edb2468a 100644 --- a/lib/l10n/app_zh.arb +++ b/lib/l10n/app_zh.arb @@ -17,8 +17,8 @@ "pillLocalNoCloud": "本地 · 无云端", "pairingSkipForNow": "暂时跳过", "pairingSkipNote": "应用将在没有腕带的情况下打开。配对之前不会测量任何数据。", - "pairingIdleTitle": "唤醒腕带并靠近手机", - "pairingIdleBody": "将腕带从充电器上取下,戴在手腕上,并将手机放在触手可及的地方。", + "pairingIdleTitle": "将腕带置于配对模式", + "pairingIdleBody": "从手腕上取下腕带,用力轻拍顶部,直到蓝灯闪烁。请将手机放在附近。", "pairingScanningTitle": "正在寻找您的腕带", "pairingScanningBody": "刚从充电器上取下的腕带最多可能需要半分钟才能开始广播。", "pairingNotFoundTitle": "范围内未找到腕带", @@ -42,7 +42,7 @@ "pairingBondRefusedAdviceBody": "打开手机的蓝牙设置,忘记该腕带,然后在此重新扫描。被拒绝的密钥是旧的配对记录,只有系统才能清除它。", "pairingBondRefusedAdviceFix": "然后返回并点按“重试”", "pairingFailedAdviceTitle": "找到了腕带,但会话未完成", - "pairingFailedAdviceBody": "从一米距离再次扫描通常有效。", + "pairingFailedAdviceBody": "将腕带置于配对模式(蓝灯闪烁),然后点按“重试”。", "profileSetupTitle": "关于你", "profileSetupBody": "继续前只需选择性别。年龄、身高和体重会影响你的数据评分方式;留空其中任何一个,只有需要它的指标会不可用。", "profileSetupSexHeader": "性别",