diff --git a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart index ba566566ca..9ed4d30571 100644 --- a/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart +++ b/mobile/lib/features/channels/channel_mutes/channel_mutes_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/relay/relay.dart'; @@ -67,8 +66,8 @@ class ChannelMutesNotifier extends Notifier { _manager = manager; // Resume re-read catches an EVENT a healthy socket never delivered. - ref.listen(appLifecycleProvider, (_, next) { - if (next == AppLifecycleState.resumed) manager.refreshFromRelay(); + ref.listen(appForegroundProvider, (_, foreground) { + if (foreground) manager.refreshFromRelay(); }); ref.onDispose(() { diff --git a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart index ed7eb73f89..069292652b 100644 --- a/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart +++ b/mobile/lib/features/channels/channel_sections/channel_sections_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/relay/relay.dart'; @@ -67,8 +66,8 @@ class ChannelSectionsNotifier extends Notifier { _manager = manager; // Resume re-read catches an EVENT a healthy socket never delivered. - ref.listen(appLifecycleProvider, (_, next) { - if (next == AppLifecycleState.resumed) manager.refreshFromRelay(); + ref.listen(appForegroundProvider, (_, foreground) { + if (foreground) manager.refreshFromRelay(); }); ref.onDispose(() { diff --git a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart index c0201e4a9f..d3b21a137b 100644 --- a/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart +++ b/mobile/lib/features/channels/channel_sort/channel_sort_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/relay/relay.dart'; @@ -74,8 +73,8 @@ class ChannelSortNotifier extends Notifier { _manager = manager; // Resume re-read catches an EVENT a healthy socket never delivered. - ref.listen(appLifecycleProvider, (_, next) { - if (next == AppLifecycleState.resumed) manager.refreshFromRelay(); + ref.listen(appForegroundProvider, (_, foreground) { + if (foreground) manager.refreshFromRelay(); }); ref.onDispose(() { diff --git a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart index 5fabd67818..a33f9ed139 100644 --- a/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart +++ b/mobile/lib/features/channels/channel_stars/channel_stars_provider.dart @@ -1,4 +1,3 @@ -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../../shared/relay/relay.dart'; @@ -67,8 +66,8 @@ class ChannelStarsNotifier extends Notifier { _manager = manager; // Resume re-read catches an EVENT a healthy socket never delivered. - ref.listen(appLifecycleProvider, (_, next) { - if (next == AppLifecycleState.resumed) manager.refreshFromRelay(); + ref.listen(appForegroundProvider, (_, foreground) { + if (foreground) manager.refreshFromRelay(); }); ref.onDispose(() { diff --git a/mobile/lib/features/channels/channels_provider.dart b/mobile/lib/features/channels/channels_provider.dart index 644ba98435..0c9e9a9c6b 100644 --- a/mobile/lib/features/channels/channels_provider.dart +++ b/mobile/lib/features/channels/channels_provider.dart @@ -2,7 +2,6 @@ import 'dart:async'; import 'dart:convert'; import 'dart:math'; -import 'package:flutter/widgets.dart'; import 'package:flutter/foundation.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; @@ -273,8 +272,8 @@ class ChannelsNotifier extends AsyncNotifier> { // Re-fetch when the app returns to foreground so channels created on // another device while mobile was backgrounded appear immediately. - ref.listen(appLifecycleProvider, (prev, next) { - if (next != AppLifecycleState.resumed) return; + ref.listen(appForegroundProvider, (_, foreground) { + if (!foreground) return; // Resume is published before the session hears it. Let the session // decide first: one that reconnects reconciles when it connects, so // refreshing here too would fetch the same list twice. diff --git a/mobile/lib/features/channels/channels_provider_lifecycle.dart b/mobile/lib/features/channels/channels_provider_lifecycle.dart index f5a627fbc3..196e988789 100644 --- a/mobile/lib/features/channels/channels_provider_lifecycle.dart +++ b/mobile/lib/features/channels/channels_provider_lifecycle.dart @@ -248,10 +248,9 @@ extension _ChannelsNotifierLiveSubscriptions on ChannelsNotifier { if (!_lifecycleRef.mounted) return; _noteReopenedSubscriptions(); // Resume and reconnect read the list anyway, so a read that comes due - // in the background or offline is left to them. - if (_lifecycleRef.read(appLifecycleProvider) != AppLifecycleState.resumed) { - return; - } + // in the background or offline is left to them. Control Center and the + // like stay in the foreground and end in no resume, so they don't wait. + if (!_lifecycleRef.read(appForegroundProvider)) return; // A retry can come due inside the gap after the read it retries. if (_listReadGap != null) { _listReadDueAfterGap = true; diff --git a/mobile/lib/features/profile/presence_cache_provider.dart b/mobile/lib/features/profile/presence_cache_provider.dart index 8feac12831..a4845e3db1 100644 --- a/mobile/lib/features/profile/presence_cache_provider.dart +++ b/mobile/lib/features/profile/presence_cache_provider.dart @@ -1,7 +1,6 @@ import 'dart:async'; import 'package:flutter/foundation.dart'; -import 'package:flutter/widgets.dart'; import 'package:hooks_riverpod/hooks_riverpod.dart'; import '../../shared/agents/remote_agents.dart'; @@ -49,7 +48,7 @@ class PresenceCacheNotifier extends Notifier> { @override Map build() { final sessionState = ref.watch(relaySessionProvider); - final lifecycle = ref.watch(appLifecycleProvider); + final foreground = ref.watch(appForegroundProvider); final scope = ref.watch( relayConfigProvider.select((config) => (config.baseUrl, config.nsec)), ); @@ -80,8 +79,7 @@ class PresenceCacheNotifier extends Notifier> { _resubscribe?.cancel(); _resubscribe = null; }); - if (sessionState.status == SessionStatus.connected && - lifecycle == AppLifecycleState.resumed) { + if (sessionState.status == SessionStatus.connected && foreground) { _poll = Timer.periodic(const Duration(seconds: 60), (_) { // A replacement the relay never answered must not hold the slot, so a // lost REQ costs at most one poll interval. diff --git a/mobile/lib/features/profile/profile_provider.dart b/mobile/lib/features/profile/profile_provider.dart index 7036915ce1..76a8602418 100644 --- a/mobile/lib/features/profile/profile_provider.dart +++ b/mobile/lib/features/profile/profile_provider.dart @@ -322,7 +322,7 @@ final profileProvider = AsyncNotifierProvider( /// /// Sends a heartbeat every 60s while the app is active by publishing a /// kind:20001 presence event over the relay WebSocket. Watches -/// [appLifecycleProvider] to send "away" when backgrounded. +/// [appForegroundProvider] to send "away" when backgrounded. class PresenceNotifier extends AsyncNotifier { static const _heartbeatInterval = Duration(seconds: 60); static const _preferenceKeyPrefix = 'buzz_presence_preference_'; @@ -346,7 +346,7 @@ class PresenceNotifier extends AsyncNotifier { _manualPresence = stored == 'away' || stored == 'offline' ? stored : null; } - final lifecycle = ref.watch(appLifecycleProvider); + final foreground = ref.watch(appForegroundProvider); ref.onDispose(() { _heartbeatTimer?.cancel(); @@ -360,19 +360,13 @@ class PresenceNotifier extends AsyncNotifier { return _setPresence(manualPresence); } - if (lifecycle == AppLifecycleState.resumed) { + if (foreground) { _startHeartbeat(); return _setPresence('online'); - } else if (lifecycle == AppLifecycleState.paused || - lifecycle == AppLifecycleState.detached) { - _heartbeatTimer?.cancel(); - _heartbeatTimer = null; - return _setPresence('away'); } - - // Default: we don't know. Reflect the most recent state we set, or - // 'offline' if never set. - return Future.value('offline'); + _heartbeatTimer?.cancel(); + _heartbeatTimer = null; + return _setPresence('away'); } void _startHeartbeat() { @@ -397,8 +391,7 @@ class PresenceNotifier extends AsyncNotifier { .setString('$_preferenceKeyPrefix$pubkey', _manualPresence ?? 'auto'); } - if (_manualPresence == null && - ref.read(appLifecycleProvider) == AppLifecycleState.resumed) { + if (_manualPresence == null && ref.read(appForegroundProvider)) { _startHeartbeat(); } else { _heartbeatTimer?.cancel(); diff --git a/mobile/lib/shared/me/me_sync.dart b/mobile/lib/shared/me/me_sync.dart index 47c38bf674..e0733b383e 100644 --- a/mobile/lib/shared/me/me_sync.dart +++ b/mobile/lib/shared/me/me_sync.dart @@ -124,8 +124,8 @@ final meGroupsProvider = StreamProvider((ref) { } } - ref.listen(appLifecycleProvider, (_, next) { - if (next != AppLifecycleState.resumed) return; + ref.listen(appForegroundProvider, (_, foreground) { + if (!foreground) return; // Recover an interrupted reader by resubscribing instead of asking the user. // Before reconnecting, leave recovery to the reconnect listener above. if (subscriptionClosed || syncFailed) { diff --git a/mobile/lib/shared/relay/app_lifecycle_provider.dart b/mobile/lib/shared/relay/app_lifecycle_provider.dart index 18e3eaebdf..74b32fabd4 100644 --- a/mobile/lib/shared/relay/app_lifecycle_provider.dart +++ b/mobile/lib/shared/relay/app_lifecycle_provider.dart @@ -53,3 +53,26 @@ final appLifecycleProvider = NotifierProvider( AppLifecycleNotifier.new, ); + +/// Whether the app is in the foreground. Unlike [appLifecycleProvider], it +/// ignores `inactive`: pulling down Control Center or Notification Center, a +/// system alert or a glance at the app switcher passes through `inactive` and +/// back to `resumed` without leaving the foreground, and is not a resume. +class AppForegroundNotifier extends Notifier { + @override + bool build() { + ref.listen(appLifecycleProvider, (_, next) { + if (next != AppLifecycleState.inactive) { + state = next == AppLifecycleState.resumed; + } + }); + return switch (ref.read(appLifecycleProvider)) { + AppLifecycleState.resumed || AppLifecycleState.inactive => true, + _ => false, + }; + } +} + +final appForegroundProvider = NotifierProvider( + AppForegroundNotifier.new, +); diff --git a/mobile/test/features/channels/channels_sync_journey_test.dart b/mobile/test/features/channels/channels_sync_journey_test.dart index 3de5dc8d0f..6d740ea300 100644 --- a/mobile/test/features/channels/channels_sync_journey_test.dart +++ b/mobile/test/features/channels/channels_sync_journey_test.dart @@ -15,6 +15,7 @@ import '../../helpers/fake_relay.dart'; const _channel = '11111111-1111-4111-8111-111111111111'; const _added = '22222222-2222-4222-8222-222222222222'; const _third = '33333333-3333-4333-8333-333333333333'; +const _fourth = '55555555-5555-4555-8555-555555555555'; const _dm = '44444444-4444-4444-8444-444444444444'; void main() { @@ -628,6 +629,13 @@ void main() { expect(channels.read().requireValue.single.name, 'general'); expect(membershipReads(), 1); + // Control Center, Notification Center or a system alert passes + // through inactive without leaving the foreground: not a resume. + lifecycle(AppLifecycleState.inactive); + lifecycle(AppLifecycleState.resumed); + settle(); + expect(membershipReads(), 1); + // Backgrounded while the session is still connected through its // grace period: nothing is read until the app returns. async.elapse(const Duration(seconds: 58)); @@ -742,6 +750,31 @@ void main() { async.elapse(const Duration(minutes: 10)); expect(membershipReads(), 7); + // The retry comes due while Control Center is open. That is still the + // foreground and no resume follows it, so the retry is not left to one. + lifecycle(AppLifecycleState.inactive); + lifecycle(AppLifecycleState.hidden); + lifecycle(AppLifecycleState.paused); + async.elapse(const Duration(seconds: 1)); + addChannel(_fourth, 'fourth'); + failNextMembershipRead(); + lifecycle(AppLifecycleState.hidden); + lifecycle(AppLifecycleState.inactive); + lifecycle(AppLifecycleState.resumed); + settle(); + expect(membershipReads(), 8); + lifecycle(AppLifecycleState.inactive); + async.elapse(const Duration(seconds: 7)); + lifecycle(AppLifecycleState.resumed); + settle(); + expect(membershipReads(), 9); + expect( + names(), + unorderedEquals(['general', 'added', 'third', 'fourth']), + ); + async.elapse(const Duration(minutes: 10)); + expect(membershipReads(), 9); + // The connection drops and a message lands in a quiet channel. The // unread catch-up after the reconnect fails, and is retried until the // channel shows as unread.