diff --git a/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt b/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt index 1d64c3b1..a9dc022a 100644 --- a/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt +++ b/app/android/app/src/main/kotlin/com/papyrus/reader/TimerNotifications.kt @@ -64,10 +64,10 @@ internal class TimerNotificationStore(context: Context) { if (action.getString("id") == through) found = true } if (!found) return - // A different session invalidates controls from the previous notification. - val same = snapshot?.optString("id") == state.optJSONObject("base")?.optString("id") + val committed = snapshot?.optString("id") == state.optJSONObject("base")?.optString("id") && + snapshot?.optString("version") == through write(JSONObject().put("base", snapshot ?: JSONObject.NULL) - .put("actions", if (same) remaining else JSONArray())) + .put("actions", if (committed) remaining else JSONArray())) } fun record(id: String?, token: String?, kind: String) { diff --git a/app/ios/RunnerTests/RunnerTests.swift b/app/ios/RunnerTests/RunnerTests.swift index 2bf2c1fb..7fd84532 100644 --- a/app/ios/RunnerTests/RunnerTests.swift +++ b/app/ios/RunnerTests/RunnerTests.swift @@ -49,6 +49,25 @@ class RunnerTests: XCTestCase { XCTAssertNil(journal.view(at: later)?.runningSinceMs) } + func testRejectedAcknowledgementDiscardsSuccessorsFromTheOldVersion() throws { + var journal = TimerJournal(base: snapshot()) + try journal.record(sessionId: "native-test", token: "v1", kind: "pause") + let pause = journal.actions[0] + try journal.record(sessionId: "native-test", token: pause.id, kind: "finish") + var remote = snapshot() + remote.version = "remote-version" + remote.phase = "paused" + remote.runningSinceMs = nil + remote.recordedMs = 10_000 + try journal.acknowledge(through: pause.id, snapshot: remote) + journal = try TimerJournal.load() + XCTAssertTrue(journal.actions.isEmpty) + XCTAssertEqual(journal.base, remote) + XCTAssertEqual(journal.view()?.phase, "paused") + XCTAssertEqual(journal.view()?.recordedMs, 10_000) + try TimerJournal().save() + } + func testLiveActivityIntentPausesResumesAndFinishesWithoutOpeningApp() async throws { guard #available(iOS 17.0, *) else { return } try TimerJournal(base: snapshot()).save() diff --git a/app/ios/TimerActivity/TimerActivityState.swift b/app/ios/TimerActivity/TimerActivityState.swift index 428951eb..6a2e5180 100644 --- a/app/ios/TimerActivity/TimerActivityState.swift +++ b/app/ios/TimerActivity/TimerActivityState.swift @@ -82,7 +82,8 @@ struct TimerJournal: Codable { mutating func acknowledge(through: String, snapshot: TimerSnapshot?) throws { guard let index = actions.firstIndex(where: { $0.id == through }) else { return } - actions = snapshot?.id == base?.id ? Array(actions.dropFirst(index + 1)) : [] + let committed = snapshot?.id == base?.id && snapshot?.version == through + actions = committed ? Array(actions.dropFirst(index + 1)) : [] base = snapshot try save() } diff --git a/app/lib/providers/reading_timer_provider.dart b/app/lib/providers/reading_timer_provider.dart index 864db214..0c150b11 100644 --- a/app/lib/providers/reading_timer_provider.dart +++ b/app/lib/providers/reading_timer_provider.dart @@ -35,6 +35,7 @@ class ReadingTimerProvider extends ChangeNotifier { int _generation = 0; int? _lastSecond; bool _disposed = false; + bool _loaded = false; bool loading = false; bool busy = false; String? error; @@ -42,7 +43,7 @@ class ReadingTimerProvider extends ChangeNotifier { DateTime get now => _now().toUtc(); Object? get libraryIdentity => _repository; ReadingTimer? get session => _repository?.isCurrent == true ? _session : null; - bool get available => _repository?.isCurrent == true && !loading; + bool get available => _repository?.isCurrent == true && _loaded && !loading; Duration get elapsed => session?.elapsed(now) ?? Duration.zero; bool get running => session?.isRunning(now) == true; bool get goalReached => session?.reachedGoal(now) == true; @@ -74,6 +75,7 @@ class ReadingTimerProvider extends ChangeNotifier { _subscription = null; _repository = repository; _session = null; + _loaded = false; error = null; busy = false; loading = repository != null; @@ -85,12 +87,15 @@ class ReadingTimerProvider extends ChangeNotifier { (value) { if (_isCurrent(generation, repository)) { _session = value; + _loaded = true; loading = false; + error = null; notifyListeners(); } }, onError: (Object failure) { if (_isCurrent(generation, repository)) { + _loaded = false; loading = false; error = 'Your timer could not be loaded. Try again.'; notifyListeners(); @@ -119,9 +124,11 @@ class ReadingTimerProvider extends ChangeNotifier { if (_isCurrent(generation, repository)) { _session = restored; + _loaded = true; } } catch (_) { if (_isCurrent(generation, repository)) { + _loaded = false; error = 'Your timer could not be loaded. Try again.'; } } finally { @@ -219,6 +226,10 @@ class ReadingTimerProvider extends ChangeNotifier { /// Command IDs double as versions so a crash between writing and acknowledging /// cannot apply the same interval twice. A changed remote version wins. Future applyNotificationActions(Map batch) async { + if (!available || busy) { + return false; + } + final current = session; final actions = (batch['actions'] as List).map((value) => Map.from(value as Map)).toList(); final appliedIndex = actions.indexWhere((action) => action['id'] == current?.version); diff --git a/app/test/services/reading_timer_notifications_test.dart b/app/test/services/reading_timer_notifications_test.dart index 897e6a0d..0e95764d 100644 --- a/app/test/services/reading_timer_notifications_test.dart +++ b/app/test/services/reading_timer_notifications_test.dart @@ -170,6 +170,48 @@ void main() { expect(timer.elapsed, const Duration(seconds: 30)); }); + test('failed initial loads preserve native actions until the timer is restored', () async { + final original = timer.session!; + Map? pending = batch(original, [action('pause', 'pause', 30)]); + var acknowledgements = 0; + + binding.defaultBinaryMessenger.setMockMethodCallHandler(channel, (call) async { + if (call.method == 'pending') return pending; + + if (call.method == 'acknowledge') { + acknowledgements++; + pending = null; + } + + return null; + }); + + timer.dispose(); + library.trackingRepository.failLoad = true; + timer = ReadingTimerProvider(store, now: () => now, tick: false); + final notifications = ReadingTimerNotifications(timer, onOpen: () {}, channel: channel, supported: true); + addTearDown(notifications.dispose); + await pumpEventQueue(); + expect(timer.loading, isFalse); + expect(timer.error, isNotNull); + expect(acknowledgements, 0); + expect(timer.available, isFalse); + expect(await timer.applyNotificationActions(pending!), isFalse); + await timer.reload(); + await pumpEventQueue(); + expect(acknowledgements, 0); + expect(pending, isNotNull); + library.trackingRepository.failLoad = false; + now = start.add(const Duration(minutes: 3)); + await timer.reload(); + await pumpEventQueue(); + expect(timer.available, isTrue); + expect(acknowledgements, 1); + expect(pending, isNull); + expect(timer.running, isFalse); + expect(timer.elapsed, const Duration(seconds: 30)); + }); + test('switching profiles clears native state and rejects old controls', () async { final original = timer.session!; var clears = 0; diff --git a/app/test/timer/timer_test_library.dart b/app/test/timer/timer_test_library.dart index 93242d6b..4ac0490f 100644 --- a/app/test/timer/timer_test_library.dart +++ b/app/test/timer/timer_test_library.dart @@ -17,13 +17,20 @@ class TimerTestRepository implements TrackingRepository, ReadingTimerRepository int? savedPage; int? savedSeconds; bool failWrite = false; + bool failLoad = false; int saves = 0; Completer? writeGate; Completer? loadGate; final changes = StreamController.broadcast(); @override - Future loadTimer() async => loadGate == null ? timer : await loadGate!.future; + Future loadTimer() async { + if (failLoad) { + throw StateError('Load failed. Try again.'); + } + + return loadGate == null ? timer : await loadGate!.future; + } @override Stream watchTimer() async* {