|
| 1 | +import 'dart:async'; |
| 2 | + |
| 3 | +import 'package:flutter/scheduler.dart'; |
| 4 | +import 'package:flutter/widgets.dart'; |
| 5 | + |
| 6 | +import '../api/model/events.dart'; |
| 7 | +import '../api/model/model.dart'; |
| 8 | +import '../api/route/users.dart'; |
| 9 | +import 'store.dart'; |
| 10 | + |
| 11 | +/// The model for tracking which users are online, idle, and offline. |
| 12 | +/// |
| 13 | +/// Use [presenceStatusForUser]. If that returns null, the user is offline. |
| 14 | +/// |
| 15 | +/// This substore is its own [ChangeNotifier], |
| 16 | +/// so callers need to remember to add a listener (and remove it on dispose). |
| 17 | +/// In particular, [PerAccountStoreWidget] doesn't subscribe a widget subtree |
| 18 | +/// to updates. |
| 19 | +class Presence extends PerAccountStoreBase with ChangeNotifier { |
| 20 | + Presence({ |
| 21 | + required super.core, |
| 22 | + required this.serverPresencePingInterval, |
| 23 | + required this.serverPresenceOfflineThresholdSeconds, |
| 24 | + required this.realmPresenceDisabled, |
| 25 | + required Map<int, PerUserPresence> initial, |
| 26 | + }) : _map = initial; |
| 27 | + |
| 28 | + final Duration serverPresencePingInterval; |
| 29 | + final int serverPresenceOfflineThresholdSeconds; |
| 30 | + // TODO(#668): update this realm setting (probably by accessing it from a new |
| 31 | + // realm/server-settings substore that gets passed to Presence) |
| 32 | + final bool realmPresenceDisabled; |
| 33 | + |
| 34 | + Map<int, PerUserPresence> _map; |
| 35 | + |
| 36 | + AppLifecycleListener? _appLifecycleListener; |
| 37 | + |
| 38 | + void _handleLifecycleStateChange(AppLifecycleState newState) { |
| 39 | + assert(!_disposed); // We remove the listener in [dispose]. |
| 40 | + |
| 41 | + // Since this handler can cause multiple requests within a |
| 42 | + // serverPresencePingInterval period, we pass `pingOnly: true`, for now, because: |
| 43 | + // - This makes the request cheap for the server. |
| 44 | + // - We don't want to record stale presence data when responses arrive out |
| 45 | + // of order. This handler would increase the risk of that by potentially |
| 46 | + // sending requests more frequently than serverPresencePingInterval. |
| 47 | + // (`pingOnly: true` causes presence data to be omitted in the response.) |
| 48 | + // TODO(#1611) Both of these reasons can be easily addressed by passing |
| 49 | + // lastUpdateId. Do that, and stop sending `pingOnly: true`. |
| 50 | + // (For the latter point, we'd ignore responses with a stale lastUpdateId.) |
| 51 | + _maybePingAndRecordResponse(newState, pingOnly: true); |
| 52 | + } |
| 53 | + |
| 54 | + bool _hasStarted = false; |
| 55 | + |
| 56 | + void start() async { |
| 57 | + if (!debugEnable) return; |
| 58 | + if (_hasStarted) { |
| 59 | + throw StateError('Presence.start should only be called once.'); |
| 60 | + } |
| 61 | + _hasStarted = true; |
| 62 | + |
| 63 | + _appLifecycleListener = AppLifecycleListener( |
| 64 | + onStateChange: _handleLifecycleStateChange); |
| 65 | + |
| 66 | + _poll(); |
| 67 | + } |
| 68 | + |
| 69 | + Future<void> _maybePingAndRecordResponse(AppLifecycleState? appLifecycleState, { |
| 70 | + required bool pingOnly, |
| 71 | + }) async { |
| 72 | + if (realmPresenceDisabled) return; |
| 73 | + |
| 74 | + final UpdatePresenceResult result; |
| 75 | + switch (appLifecycleState) { |
| 76 | + case null: |
| 77 | + case AppLifecycleState.hidden: |
| 78 | + case AppLifecycleState.paused: |
| 79 | + // No presence update. |
| 80 | + return; |
| 81 | + case AppLifecycleState.detached: |
| 82 | + // > The application is still hosted by a Flutter engine but is |
| 83 | + // > detached from any host views. |
| 84 | + // TODO see if this actually works as a way to send an "idle" update |
| 85 | + // when the user closes the app completely. |
| 86 | + result = await updatePresence(connection, |
| 87 | + pingOnly: pingOnly, |
| 88 | + status: PresenceStatus.idle, |
| 89 | + newUserInput: false); |
| 90 | + case AppLifecycleState.resumed: |
| 91 | + // > […] the default running mode for a running application that has |
| 92 | + // > input focus and is visible. |
| 93 | + result = await updatePresence(connection, |
| 94 | + pingOnly: pingOnly, |
| 95 | + status: PresenceStatus.active, |
| 96 | + newUserInput: true); |
| 97 | + case AppLifecycleState.inactive: |
| 98 | + // > At least one view of the application is visible, but none have |
| 99 | + // > input focus. The application is otherwise running normally. |
| 100 | + // For example, we expect this state when the user is selecting a file |
| 101 | + // to upload. |
| 102 | + result = await updatePresence(connection, |
| 103 | + pingOnly: pingOnly, |
| 104 | + status: PresenceStatus.active, |
| 105 | + newUserInput: false); |
| 106 | + } |
| 107 | + if (!pingOnly) { |
| 108 | + _map = result.presences!; |
| 109 | + notifyListeners(); |
| 110 | + } |
| 111 | + } |
| 112 | + |
| 113 | + void _poll() async { |
| 114 | + assert(!_disposed); |
| 115 | + while (true) { |
| 116 | + // We put the wait upfront because we already have data when [start] is |
| 117 | + // called; it comes from /register. |
| 118 | + await Future<void>.delayed(serverPresencePingInterval); |
| 119 | + if (_disposed) return; |
| 120 | + |
| 121 | + await _maybePingAndRecordResponse( |
| 122 | + SchedulerBinding.instance.lifecycleState, pingOnly: false); |
| 123 | + if (_disposed) return; |
| 124 | + } |
| 125 | + } |
| 126 | + |
| 127 | + bool _disposed = false; |
| 128 | + |
| 129 | + @override |
| 130 | + void dispose() { |
| 131 | + _appLifecycleListener?.dispose(); |
| 132 | + _disposed = true; |
| 133 | + super.dispose(); |
| 134 | + } |
| 135 | + |
| 136 | + /// The [PresenceStatus] for [userId], or null if the user is offline. |
| 137 | + PresenceStatus? presenceStatusForUser(int userId, {required DateTime utcNow}) { |
| 138 | + final now = utcNow.millisecondsSinceEpoch ~/ 1000; |
| 139 | + final perUserPresence = _map[userId]; |
| 140 | + if (perUserPresence == null) return null; |
| 141 | + final PerUserPresence(:activeTimestamp, :idleTimestamp) = perUserPresence; |
| 142 | + |
| 143 | + if (now - activeTimestamp <= serverPresenceOfflineThresholdSeconds) { |
| 144 | + return PresenceStatus.active; |
| 145 | + } else if (now - idleTimestamp <= serverPresenceOfflineThresholdSeconds) { |
| 146 | + // The API doc is kind of confusing, but this seems correct: |
| 147 | + // https://chat.zulip.org/#narrow/channel/378-api-design/topic/presence.3A.20.22potentially.20present.22.3F/near/2202431 |
| 148 | + // TODO clarify that API doc |
| 149 | + return PresenceStatus.idle; |
| 150 | + } else { |
| 151 | + return null; |
| 152 | + } |
| 153 | + } |
| 154 | + |
| 155 | + void handlePresenceEvent(PresenceEvent event) { |
| 156 | + // TODO(#1618) |
| 157 | + } |
| 158 | + |
| 159 | + /// In debug mode, controls whether presence requests are made. |
| 160 | + /// |
| 161 | + /// Outside of debug mode, this is always true and the setter has no effect. |
| 162 | + static bool get debugEnable { |
| 163 | + bool result = true; |
| 164 | + assert(() { |
| 165 | + result = _debugEnable; |
| 166 | + return true; |
| 167 | + }()); |
| 168 | + return result; |
| 169 | + } |
| 170 | + static bool _debugEnable = true; |
| 171 | + static set debugEnable(bool value) { |
| 172 | + assert(() { |
| 173 | + _debugEnable = value; |
| 174 | + return true; |
| 175 | + }()); |
| 176 | + } |
| 177 | + |
| 178 | + @visibleForTesting |
| 179 | + static void debugReset() { |
| 180 | + debugEnable = true; |
| 181 | + } |
| 182 | +} |
0 commit comments