Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

login: Support logging out of an account #948

Open
wants to merge 12 commits into
base: main
Choose a base branch
from
Open
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
4 changes: 4 additions & 0 deletions assets/l10n/app_en.arb
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,10 @@
"@chooseAccountPageTitle": {
"description": "Title for ChooseAccountPage"
},
"chooseAccountPageLogOutButton": "Log out",
"@chooseAccountPageLogOutButton": {
"description": "Label for the 'Log out' button for an account on the choose-account page"
},
"chooseAccountButtonAddAnAccount": "Add an account",
"@chooseAccountButtonAddAnAccount": {
"description": "Label for ChooseAccountPage button to add an account"
Expand Down
19 changes: 18 additions & 1 deletion lib/api/route/notifications.dart
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

import '../core.dart';

/// https://zulip.com/api/add-fcm-token
Expand All @@ -10,6 +9,15 @@ Future<void> addFcmToken(ApiConnection connection, {
});
}

/// https://zulip.com/api/remove-fcm-token
Future<void> removeFcmToken(ApiConnection connection, {
Comment on lines +12 to +13
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: paragraph in commit message is no longer relevant in this revision:

api: Add remove{Apns,Fcm}Token

It's no longer true that these endpoints are undocumented, so, link
to them.

required String token,
}) {
return connection.delete('removeFcmToken', (_) {}, 'users/me/android_gcm_reg_id', {
'token': RawParameter(token),
});
}

/// https://zulip.com/api/add-apns-token
Future<void> addApnsToken(ApiConnection connection, {
required String token,
Expand All @@ -20,3 +28,12 @@ Future<void> addApnsToken(ApiConnection connection, {
'appid': RawParameter(appid),
});
}

/// https://zulip.com/api/remove-apns-token
Future<void> removeApnsToken(ApiConnection connection, {
required String token,
}) {
return connection.delete('removeApnsToken', (_) {}, 'users/me/apns_device_token', {
'token': RawParameter(token),
});
}
95 changes: 90 additions & 5 deletions lib/model/store.dart
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,8 @@ abstract class GlobalStore extends ChangeNotifier {
}

final Map<int, PerAccountStore> _perAccountStores = {};

int get debugNumPerAccountStoresLoading => _perAccountStoresLoading.length;
final Map<int, Future<PerAccountStore>> _perAccountStoresLoading = {};

/// The store's per-account data for the given account, if already loaded.
Expand Down Expand Up @@ -144,7 +146,21 @@ abstract class GlobalStore extends ChangeNotifier {
/// This method should be called only by the implementation of [perAccount].
/// Other callers interested in per-account data should use [perAccount]
/// and/or [perAccountSync].
Future<PerAccountStore> loadPerAccount(int accountId);
Future<PerAccountStore> loadPerAccount(int accountId) async {
assert(_accounts.containsKey(accountId));
final store = await doLoadPerAccount(accountId);
if (!_accounts.containsKey(accountId)) {
// [removeAccount] was called during [doLoadPerAccount].
store.dispose();
throw AccountNotFoundException();
}
return store;
}

/// Load per-account data for the given account, unconditionally.
///
/// This method should be called only by [loadPerAccount].
Future<PerAccountStore> doLoadPerAccount(int accountId);

// Just the Iterables, not the actual Map, to avoid clients mutating the map.
// Mutations should go through the setters/mutators below.
Expand Down Expand Up @@ -192,10 +208,26 @@ abstract class GlobalStore extends ChangeNotifier {
/// Update an account in the underlying data store.
Future<void> doUpdateAccount(int accountId, AccountsCompanion data);

/// Remove an account from the store.
Future<void> removeAccount(int accountId) async {
assert(_accounts.containsKey(accountId));
await doRemoveAccount(accountId);
if (!_accounts.containsKey(accountId)) return; // Already removed.
_accounts.remove(accountId);
_perAccountStores.remove(accountId)?.dispose();
unawaited(_perAccountStoresLoading.remove(accountId));
notifyListeners();
}

/// Remove an account from the underlying data store.
Future<void> doRemoveAccount(int accountId);

@override
String toString() => '${objectRuntimeType(this, 'GlobalStore')}#${shortHash(this)}';
}

class AccountNotFoundException implements Exception {}

/// Store for the user's data for a given Zulip account.
///
/// This should always have a consistent snapshot of the state on the server,
Expand Down Expand Up @@ -303,6 +335,14 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
final GlobalStore _globalStore;
final ApiConnection connection; // TODO(#135): update zulipFeatureLevel with events

UpdateMachine? get updateMachine => _updateMachine;
UpdateMachine? _updateMachine;
set updateMachine(UpdateMachine? value) {
assert(_updateMachine == null);
Comment on lines +340 to +341
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
set updateMachine(UpdateMachine? value) {
assert(_updateMachine == null);
set updateMachine(UpdateMachine? value) {
assert(_updateMachine == null);
assert(value != null);

That way it's clear this only ever gets set to non-null once for a given PerAccountStore.

assert(value != null);
_updateMachine = value;
}

bool get isLoading => _isLoading;
bool _isLoading = false;
@visibleForTesting
Expand Down Expand Up @@ -361,6 +401,10 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
// Data attached to the self-account on the realm.

final int accountId;

/// The [Account] this store belongs to.
///
/// Will throw if called after [dispose] has been called.
Account get account => _globalStore.getAccount(accountId)!;

/// Always equal to `account.userId`.
Expand Down Expand Up @@ -430,6 +474,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
// End of data.
////////////////////////////////////////////////////////////////

bool _disposed = false;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: put this just above dispose

(Alternatively it could go up in the first section of data fields, after isLoading. But if it's outside the "data" section anyway, there's no benefit from having reassemble separate it from dispose.)


/// Called when the app is reassembled during debugging, e.g. for hot reload.
///
/// This will redo from scratch any computations we can, such as parsing
Expand All @@ -441,14 +487,20 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess

@override
void dispose() {
assert(!_disposed);
recentDmConversationsView.dispose();
unreads.dispose();
_messages.dispose();
typingStatus.dispose();
updateMachine?.dispose();
connection.close();
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit:

This seems hygienic. It makes sense to explicitly clean up HTTP
client resources when we know we'll be setting up a new client
instance to replace the old one. (The dead-queue reload is currently
the only path where PerAccountStore.dispose is called.)

In this situation it's not that we will be setting up a new HTTP client instance; rather we already have, which was passed to the new PerAccountStore (and even before that, was used for fetching the new store's initial snapshot). The new store gets constructed before the old one is disposed.

_disposed = true;
super.dispose();
}

Future<void> handleEvent(Event event) async {
assert(!_disposed);

switch (event) {
case HeartbeatEvent():
assert(debugLog("server event: heartbeat"));
Expand Down Expand Up @@ -590,6 +642,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess
}

Future<void> sendMessage({required MessageDestination destination, required String content}) {
assert(!_disposed);

// TODO implement outbox; see design at
// https://chat.zulip.org/#narrow/stream/243-mobile-team/topic/.23M3881.20Sending.20outbox.20messages.20is.20fraught.20with.20issues/near/1405739
return _apiSendMessage(connection,
Expand Down Expand Up @@ -682,7 +736,7 @@ class LiveGlobalStore extends GlobalStore {
final AppDatabase _db;

@override
Future<PerAccountStore> loadPerAccount(int accountId) async {
Future<PerAccountStore> doLoadPerAccount(int accountId) async {
final updateMachine = await UpdateMachine.load(this, accountId);
return updateMachine.store;
}
Expand All @@ -708,6 +762,14 @@ class LiveGlobalStore extends GlobalStore {
assert(rowsAffected == 1);
}

@override
Future<void> doRemoveAccount(int accountId) async {
final rowsAffected = await (_db.delete(_db.accounts)
..where((a) => a.id.equals(accountId))
).go();
assert(rowsAffected == 1);
}

@override
String toString() => '${objectRuntimeType(this, 'LiveGlobalStore')}#${shortHash(this)}';
}
Expand All @@ -722,7 +784,9 @@ class UpdateMachine {
// case of unauthenticated access to a web-public realm. We authenticated.
throw Exception("bad initial snapshot: missing queueId");
})(),
lastEventId = initialSnapshot.lastEventId;
lastEventId = initialSnapshot.lastEventId {
store.updateMachine = this;
}

/// Load the user's data from the server, and start an event queue going.
///
Expand Down Expand Up @@ -772,6 +836,8 @@ class UpdateMachine {
final String queueId;
int lastEventId;

bool _disposed = false;

static Future<InitialSnapshot> _registerQueueWithRetry(
ApiConnection connection) async {
BackoffMachine? backoffMachine;
Expand Down Expand Up @@ -858,17 +924,21 @@ class UpdateMachine {
}());
}

if (_disposed) return;

final GetEventsResult result;
try {
result = await getEvents(store.connection,
queueId: queueId, lastEventId: lastEventId);
} catch (e) {
if (_disposed) return;

store.isLoading = true;
switch (e) {
case ZulipApiException(code: 'BAD_EVENT_QUEUE_ID'):
assert(debugLog('Lost event queue for $store. Replacing…'));
// This disposes the store, which disposes this update machine.
await store._globalStore._reloadPerAccount(store.accountId);
dispose();
debugLog('… Event queue replaced.');
return;

Expand All @@ -890,6 +960,8 @@ class UpdateMachine {
}
}

if (_disposed) return;
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

I think the _disposed flag, together with all these conditions checking for it, would be good as its own commit.

Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

nit: let's put these checks consistently right after each await.

That's where we want them semantically; so arranging it that way overtly in the source code is I think the only reasonable way to be sure we're doing that.

So e.g. this one moves to right after result = await getEvents(…. (And we'll still need one at the top of the catch block, which is the other place that's "right after" that await.)

And the one toward the top of the loop moves further up, to right after await _debugLoopSignal….

Then for completeness we can also put an assert at the top of the method itself, like we're doing on other methods.


// After one successful request, we reset backoff to its initial state.
// That way if the user is off the network and comes back on, the app
// doesn't wind up in a state where it's slow to recover the next time
Expand All @@ -911,6 +983,7 @@ class UpdateMachine {
final events = result.events;
for (final event in events) {
await store.handleEvent(event);
if (_disposed) return;
}
if (events.isNotEmpty) {
lastEventId = events.last.id;
Expand All @@ -926,6 +999,8 @@ class UpdateMachine {
// TODO(#322) save acked token, to dedupe updating it on the server
// TODO(#323) track the addFcmToken/etc request, warn if not succeeding
Future<void> registerNotificationToken() async {
if (_disposed) return;
Comment on lines 1001 to +1002
Copy link
Member

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Should be an assert, I think — seems like a bug if we get here when it's already disposed.


if (!debugEnableRegisterNotificationToken) {
return;
}
Expand All @@ -939,8 +1014,18 @@ class UpdateMachine {
await NotificationService.registerToken(store.connection, token: token);
}

void dispose() { // TODO abort long-poll and close ApiConnection
/// Cleans up resources and tells the instance not to make new API requests.
///
/// After this is called, the instance is not in a usable state
/// and should be abandoned.
///
/// To abort polling mid-request, [store]'s [PerAccountStore.connection]
/// needs to be closed using [ApiConnection.close], which causes in-progress
/// requests to error. [PerAccountStore.dispose] does that.
void dispose() {
assert(!_disposed);
NotificationService.instance.token.removeListener(_registerNotificationToken);
_disposed = true;
}

/// In debug mode, controls whether [fetchEmojiData] should
Expand Down
16 changes: 16 additions & 0 deletions lib/notifications/receive.dart
Original file line number Diff line number Diff line change
Expand Up @@ -159,6 +159,22 @@ class NotificationService {
}
}

static Future<void> unregisterToken(ApiConnection connection, {required String token}) async {
switch (defaultTargetPlatform) {
case TargetPlatform.android:
await removeFcmToken(connection, token: token);

case TargetPlatform.iOS:
await removeApnsToken(connection, token: token);

case TargetPlatform.linux:
case TargetPlatform.macOS:
case TargetPlatform.windows:
case TargetPlatform.fuchsia:
assert(false);
}
}

static void _onForegroundMessage(FirebaseRemoteMessage message) {
assert(debugLog("notif message: ${message.data}"));
_onRemoteMessage(message);
Expand Down
53 changes: 53 additions & 0 deletions lib/widgets/app.dart
Original file line number Diff line number Diff line change
Expand Up @@ -8,6 +8,8 @@ import 'package:flutter_gen/gen_l10n/zulip_localizations.dart';
import '../log.dart';
import '../model/localizations.dart';
import '../model/narrow.dart';
import '../model/store.dart';
import '../notifications/receive.dart';
import 'about_zulip.dart';
import 'app_bar.dart';
import 'dialog.dart';
Expand Down Expand Up @@ -221,15 +223,64 @@ class ChooseAccountPage extends StatelessWidget {
required Widget title,
Widget? subtitle,
}) {
final designVariables = DesignVariables.of(context);
final zulipLocalizations = ZulipLocalizations.of(context);
return Card(
clipBehavior: Clip.hardEdge,
child: ListTile(
title: title,
subtitle: subtitle,
trailing: PopupMenuButton<AccountItemOverflowMenuItem>(
iconColor: designVariables.icon,
itemBuilder: (context) => [
PopupMenuItem(
value: AccountItemOverflowMenuItem.logOut,
child: Text(zulipLocalizations.chooseAccountPageLogOutButton)),
],
onSelected: (item) async {
switch (item) {
case AccountItemOverflowMenuItem.logOut: {
unawaited(_logOutAccount(context, accountId));
}
}
}),
// The default trailing padding with M3 is 24px. Decrease by 12 because
// PopupMenuButton adds 12px padding on all sides of the "…" icon.
contentPadding: const EdgeInsetsDirectional.only(start: 16, end: 12),
onTap: () => Navigator.push(context,
HomePage.buildRoute(accountId: accountId))));
}

Future<void> _logOutAccount(BuildContext context, int accountId) async {
final globalStore = GlobalStoreWidget.of(context);

final account = globalStore.getAccount(accountId);
if (account == null) return; // TODO(log)

// Unawaited, to not block removing the account on this request.
unawaited(_unregisterToken(globalStore, account));

await globalStore.removeAccount(accountId);
}

Future<void> _unregisterToken(GlobalStore globalStore, Account account) async {
// TODO(#322) use actual acked push token; until #322, this is just null.
final token = account.ackedPushToken
// Try the current token as a fallback; maybe the server has registered
// it and we just haven't recorded that fact in the client.
?? NotificationService.instance.token.value;
if (token == null) return;

final connection = globalStore.apiConnectionFromAccount(account);
try {
await NotificationService.unregisterToken(connection, token: token);
} catch (e) {
// TODO retry? handle failures?
} finally {
connection.close();
}
}

@override
Widget build(BuildContext context) {
final zulipLocalizations = ZulipLocalizations.of(context);
Expand Down Expand Up @@ -286,6 +337,8 @@ class ChooseAccountPageOverflowButton extends StatelessWidget {
}
}

enum AccountItemOverflowMenuItem { logOut }

class HomePage extends StatelessWidget {
const HomePage({super.key});

Expand Down
1 change: 1 addition & 0 deletions lib/widgets/page.dart
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,7 @@ mixin AccountPageRouteMixin<T extends Object?> on PageRoute<T> {
return PerAccountStoreWidget(
accountId: accountId,
placeholder: const LoadingPlaceholderPage(),
routeToRemoveOnLogout: this,
child: super.buildPage(context, animation, secondaryAnimation));
}
}
Expand Down
Loading