Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
19 changes: 19 additions & 0 deletions app/ios/RunnerTests/RunnerTests.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
Expand Down
3 changes: 2 additions & 1 deletion app/ios/TimerActivity/TimerActivityState.swift
Original file line number Diff line number Diff line change
Expand Up @@ -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()
}
Expand Down
13 changes: 12 additions & 1 deletion app/lib/providers/reading_timer_provider.dart
Original file line number Diff line number Diff line change
Expand Up @@ -35,14 +35,15 @@ class ReadingTimerProvider extends ChangeNotifier {
int _generation = 0;
int? _lastSecond;
bool _disposed = false;
bool _loaded = false;
bool loading = false;
bool busy = false;
String? error;

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;
Expand Down Expand Up @@ -74,6 +75,7 @@ class ReadingTimerProvider extends ChangeNotifier {
_subscription = null;
_repository = repository;
_session = null;
_loaded = false;
error = null;
busy = false;
loading = repository != null;
Expand All @@ -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();
Expand Down Expand Up @@ -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 {
Expand Down Expand Up @@ -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<bool> applyNotificationActions(Map<String, dynamic> batch) async {
if (!available || busy) {
return false;
}

final current = session;
final actions = (batch['actions'] as List).map((value) => Map<String, dynamic>.from(value as Map)).toList();
final appliedIndex = actions.indexWhere((action) => action['id'] == current?.version);
Expand Down
42 changes: 42 additions & 0 deletions app/test/services/reading_timer_notifications_test.dart
Original file line number Diff line number Diff line change
Expand Up @@ -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<String, dynamic>? 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;
Expand Down
9 changes: 8 additions & 1 deletion app/test/timer/timer_test_library.dart
Original file line number Diff line number Diff line change
Expand Up @@ -17,13 +17,20 @@ class TimerTestRepository implements TrackingRepository, ReadingTimerRepository
int? savedPage;
int? savedSeconds;
bool failWrite = false;
bool failLoad = false;
int saves = 0;
Completer<void>? writeGate;
Completer<ReadingTimer?>? loadGate;
final changes = StreamController<ReadingTimer?>.broadcast();

@override
Future<ReadingTimer?> loadTimer() async => loadGate == null ? timer : await loadGate!.future;
Future<ReadingTimer?> loadTimer() async {
if (failLoad) {
throw StateError('Load failed. Try again.');
}

return loadGate == null ? timer : await loadGate!.future;
}

@override
Stream<ReadingTimer?> watchTimer() async* {
Expand Down
Loading