-
Notifications
You must be signed in to change notification settings - Fork 310
login: Support logging out of an account #948
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
Conversation
This implements the logic where per-account routes on the nav are removed on logout, but it doesn't animate their exits; they just disappear suddenly. I think that's probably fine, and I didn't find a way to add the animation in. To test a scenario where there are per-account routes when the logout happens, you can add a button to the "under construction" screen and copy-paste |
Marked as a draft because in particular I think this may be lacking some test coverage that we'll want; for example, of the various |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Looking forward to having this.
Comments below on reading mainly the logic asked about in your last comment above.
lib/model/store.dart
Outdated
// Abort if the account has been logged out. | ||
if (store.maybeAccount == null) return; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
(In the office we discussed some changes to this part: have a bool disposed
on UpdateMachine, so this is if (disposed) return;
; check just after each await
in this method; and handleEvent
and sendMessage
don't need the check, but perhaps should get an assert for it instead.)
test/model/store_test.dart
Outdated
check(notifyCount).equals(1); // no extra notify | ||
}); | ||
|
||
// TODO test database gets updated correctly (an integration test with sqlite?) |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think we're fine without this.
Probably the right thing in principle is that the contents of LiveGlobalStore.doRemoveAccount
should go into a removeAccount
method on AppDatabase
, and similarly doUpdateAccount
and doInsertAccount
should be reduced to one-line wrappers around AppDatabase
methods. Then we can write tests for those in database_test.dart
so those tests run against actual SQLite, like the existing tests in that file.
That'll become important if we start having more logic in those methods — like for example in a future where we cache all the server data locally, and so have a much more complex data model in the database.
But for now while these methods are so trivial, I think we're fine without such tests.
lib/api/route/notifications.dart
Outdated
// https://zulip.com/api/add-fcm-token | ||
Future<void> registerFcmToken(ApiConnection connection, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: should be doc comment
lib/model/store.dart
Outdated
@@ -75,7 +75,10 @@ abstract class GlobalStore extends ChangeNotifier { | |||
email: account.email, apiKey: account.apiKey); | |||
} | |||
|
|||
Map<int, PerAccountStore> get debugPerAccountStores => _perAccountStores; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
How about like this?
Map<int, PerAccountStore> get debugPerAccountStores => _perAccountStores; | |
UnmodifiableMapView<int, PerAccountStore> get debugPerAccountStores => UnmodifiableMapView(_perAccountStores); |
That just makes a bit clearer that this is only meant for inspection (and that one doesn't have to wonder if the maps might get modified through these).
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Oh, interesting. Sure.
lib/model/store.dart
Outdated
final updateMachine = UpdateMachine.fromInitialSnapshot( | ||
store: store, initialSnapshot: initialSnapshot); | ||
store.updateMachine = updateMachine; |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Let's put this assignment inside the UpdateMachine
constructor. That way it's more crisply an invariant that the UpdateMachine's store will always point back to it.
Then relatedly (also about making invariants more explicit), PerAccountStore.updateMachine
can get a setter that asserts the old value was null, so we're not redirecting the same store to start pointing to a different update machine.
lib/model/store.dart
Outdated
_perAccountStores.remove(accountId) | ||
// TODO close connection inside `.dispose` instead (once tests can adapt) | ||
?..dispose()..connection.close(); | ||
_perAccountStoresLoading.remove(accountId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Even though this is logic I largely suggested a week or two ago, I found it kind of confusing to read. 🙂 I think the logic is good but would benefit from some comments.
I can take a pass at writing those comments in the next round.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Cool; thanks.
test/model/store_test.dart
Outdated
expectStore | ||
? check(store.debugPerAccountStores[accountId]).isNotNull() | ||
: check(store.debugPerAccountStores[accountId]).isNull(); | ||
expectStoreLoading | ||
? check(store.debugPerAccountStoresLoading[accountId]).isNotNull() | ||
: check(store.debugPerAccountStoresLoading[accountId]).isNull(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Ideally these tests would be written without this inspection of the internal state of GlobalStore, and instead in terms of its outward-facing behavior seen by its callers. Ultimately all the behavior we care about should be observable that way.
test/model/store_test.dart
Outdated
checkGlobalStore(globalStore, eg.selfAccount.id, | ||
expectAccount: false, expectStore: false, expectStoreLoading: false); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
For example here, I think the fact that we remove the account from _perAccountStoresLoading
can be observed by checking:
- A fresh
perAccount
call at this point throws directly, rather than returning a Future that throws. (Though this fact isn't that important — maybe shouldn't even get a test, since it'd be fine if it changed.) - A fresh
perAccount
call after the oldloadingFuture
has completed will throw directly, rather than returning a Future that throws. This basically expresses the fact that we don't have an entry in_perAccountStoresLoading
sticking around indefinitely after the whole interaction is done.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Or thinking again, maybe the only fact we really want here about _perAccountStoresLoading
is:
- At the end of the whole story,
_perAccountStoresLoading
is empty. (Could have a debug getter that just reports its length.)
In other words that means we aren't leaking a reference to an old Future that's no longer relevant.
The difference between throwing immediately and returning a Future that throws is maybe nice but isn't really fundamental — it's not like we need to have an extra fast answer when looking for an account that no longer exists.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
- At the end of the whole story,
_perAccountStoresLoading
is empty. (Could have a debug getter that just reports its length.)
Cool; sure, I'll try this for the next revision.
test/model/store_test.dart
Outdated
? check(store.debugPerAccountStores[accountId]).isNotNull() | ||
: check(store.debugPerAccountStores[accountId]).isNull(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
These can just use perAccountSync
, right?
60515c1
to
054da31
Compare
Thanks for the review! Revision pushed. |
lib/api/route/notifications.dart
Outdated
/// https://zulip.com/api/add-fcm-token | ||
Future<void> registerFcmToken(ApiConnection connection, { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks! Generally this looks good. Comments below, having read completely through the first 7 commits:
1147845 test: Assert isOpen
on FakeApiConnection.prepare
e20fa5e api: Add unregister{Apns,Fcm}Token; link to new docs for register-token fns
de0213e store [nfc]: Add indirection doLoadPerAccount
a73e929 store [nfc]: Comment in _PerAccountStoreWidgetState.didChangeDependencies
5e7d2f5 store: Add nullable [PerAccountStore.updateMachine]; dispose in [dispose]
a271863 store: Implement removeAccount
3e11686 notif: Implement NotificationService.unregisterToken
and skimmed the last one:
054da31 login: Support logging out of an account
I also sent #998 as mentioned above; and I have a draft locally of the comments I mentioned at #948 (comment) last time.
set updateMachine(UpdateMachine? value) { | ||
assert(_updateMachine == null); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
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.
lib/model/store.dart
Outdated
@@ -445,10 +492,14 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess | |||
unreads.dispose(); | |||
_messages.dispose(); | |||
typingStatus.dispose(); | |||
updateMachine?.dispose(); // TODO is updateMachine ever null except in tests? |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
No, only in tests.
I think there's nothing actionable for a TODO here, though.
lib/model/store.dart
Outdated
await doRemoveAccount(accountId); | ||
assert(_accounts.containsKey(accountId)); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
await doRemoveAccount(accountId); | |
assert(_accounts.containsKey(accountId)); | |
assert(_accounts.containsKey(accountId)); | |
await doRemoveAccount(accountId); | |
if (!_accounts.containsKey(accountId)) return; // Already removed. |
Otherwise I think there's a race here if we try to remove the same account twice concurrently.
lib/model/store.dart
Outdated
// TODO close connection inside `.dispose` instead (once tests can adapt) | ||
store..dispose()..connection.close(); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
What's the obstacle to doing this?
(I think we've discussed this before, but I've forgotten.)
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for going back over this with me in the office! :)
lib/model/store.dart
Outdated
super.dispose(); | ||
_disposed = true; | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
I think this order feels more logical:
super.dispose(); | |
_disposed = true; | |
} | |
_disposed = true; | |
super.dispose(); | |
} |
The _disposed
flag is part of this subclass's data; so we set it to the "yes, disposed" state before calling the base class's implementation to tear down the base class's data.
lib/model/store.dart
Outdated
@@ -890,6 +959,8 @@ class UpdateMachine { | |||
} | |||
} | |||
|
|||
if (_disposed) return; |
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
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.
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Things do feel more consistent and better with this pattern! :)
lib/model/store.dart
Outdated
return _apiSendMessage(connection, | ||
await _apiSendMessage(connection, |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: can stick with return
here (and no async
) — that also matches how the underlying route bindings are written, which end like return connection.post(…);
lib/widgets/store.dart
Outdated
} catch (e) { | ||
// TODO(log) | ||
} |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
} catch (e) { | |
// TODO(log) | |
} | |
} |
If we don't catch the exception, it'll become an unhandled exception (because nothing awaits the future returned by this IIFE, and the exception will cause that future to complete with an error). That'll automatically cause it to get logged, once we have any kind of generic logging in place.
Could also say something like
rethrow; // TODO(log)
but, apart from playing host to the comment, that has exactly the same effect as not having the catch
block in the first place. Because I think we don't need the TODO, that makes it redundant.
lib/widgets/app.dart
Outdated
// Async IIFE to not block removing the account on the unregister-token request. | ||
unawaited(() async { |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
nit: move this anonymous function to be its own private method with a name; it's 17 lines long, so I think that'll help make the overall structure easier to see
lib/widgets/app.dart
Outdated
// TODO error handling? disable logout button during this? | ||
await globalStore.removeAccount(accountId); |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Eh — this is only talking to the local database, not the network, so it should pretty consistently be fast and reliable. I'd guess p50 of <1ms, p99 of <50ms.
(That's jargon/shorthand for "50th percentile" and "99th percentile" respectively.)
And this is an uncommon operation. So it isn't worth building extra UI for the small fraction of users where it takes more than a frame for the operation to complete.
054da31
to
6bc8bcc
Compare
Thanks for the review! Revision pushed. |
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
Thanks for the revision! Generally this looks great; comments below.
I haven't yet read fully through the final commit:
6bc8bcc login: Support logging out of an account
In the interest of getting things merged efficiently, maybe let's split this PR up — let's have this first PR cover the commits leading up to removeAccount
:
ed1adbe test: Assert isOpen
on FakeApiConnection.prepare
18914a4 store [nfc]: Add indirection doLoadPerAccount
6cfe900 store [nfc]: Comment in _PerAccountStoreWidgetState.didChangeDependencies
2609910 store: Add nullable [PerAccountStore.updateMachine]; dispose in [dispose]
b2cedd5 store: Add dartdoc on UpdateMachine.dispose
3e0d499 store: Add and check _disposed on PerAccountStore and UpdateMachine
7aff4d1 store test: Expose TestGlobalStore.useCachedApiConnections; default false
f3a3361 store: Call connection.close in PerAccountStore.dispose
ab9ce6d store: Implement removeAccount
and then a follow-up PR can cover the remaining commits:
325c663 api: Add remove{Apns,Fcm}Token
b4b9569 notif: Implement NotificationService.unregisterToken
6bc8bcc login: Support logging out of an account
/// https://zulip.com/api/remove-fcm-token | ||
Future<void> removeFcmToken(ApiConnection connection, { |
There was a problem hiding this comment.
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.
lib/model/store.dart
Outdated
@@ -430,6 +474,8 @@ class PerAccountStore extends ChangeNotifier with EmojiStore, ChannelStore, Mess | |||
// End of data. | |||
//////////////////////////////////////////////////////////////// | |||
|
|||
bool _disposed = false; |
There was a problem hiding this comment.
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
.)
lib/model/store.dart
Outdated
@@ -890,6 +959,8 @@ class UpdateMachine { | |||
} | |||
} | |||
|
|||
if (_disposed) return; |
There was a problem hiding this comment.
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.
lib/model/store.dart
Outdated
Future<void> registerNotificationToken() async { | ||
if (_disposed) return; |
There was a problem hiding this comment.
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.
recentDmConversationsView.dispose(); | ||
unreads.dispose(); | ||
_messages.dispose(); | ||
typingStatus.dispose(); | ||
updateMachine?.dispose(); | ||
connection.close(); |
There was a problem hiding this comment.
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.
Do the screenshots need to be updated for this one? |
We'll add more to this soon.
This seems hygienic. It makes sense to explicitly clean up an old HTTP client's resources when we've set up a new one, for the dead-queue reload. (The dead-queue reload is currently the only path where this dispose method is called.) It will also make sense to clean up these resources when an account is logged out. That's a feature we'll be implementing soon, with PerAccountStore.dispose in that new path as well. The "abort long-poll and close ApiConnection" TODO was on UpdateMachine's dispose method, not PerAccountStore's. But since PerAccountStore is what owns the connection, its dispose method seemed like the more appropriate place to close the connection.
Ah right! Actually the UI hasn't changed from what's shown in the screenshots in the PR description. I remember us talking in the office about making it look better, but I don't remember if we settled on something different. If there are changes to make, how about doing them as a followup? |
6bc8bcc
to
6ac378d
Compare
Sounds good. I just sent #1007 for those first several commits, and I'll send a followup with the remaining ones (implementing logout) as a followup. Then as mentioned in my previous comment, maybe any UI changes can be a followup to that. |
Screenshots coming soon. :)
Fixes: #463