feat(app)!: no anonymous accounts on the device (ADR 0020, D4, D25)

Every account has a way in, so the apps make no anonymous account any
more. The start screen shows whenever nobody is signed in, a first
start included, and offers SimpleLogin, a password and GitHub for those
who have it, with no start without an account. An instance out of reach
is said so, with Try again, and one offering no way to create an account
too. An anonymous account's session left from before is deleted as the
app starts (D25), falling back to signing out on the device.

What only anonymous accounts needed goes: signing in anonymously, the
signed-out mark, adding GitHub to an account and switching to a GitHub
account from the Profile tab (with its Cancel, the only one), an
anonymous device's own keys, the warning that signing out deletes the
account, and the texts about keeping progress. Settings says on which
instance the account is signed in. Learn's publishing, reports and
suggestions and its learner count no longer ask whether the person has
an identity: everyone signed in has one.

The server still offers anonymous sign-in until the next commit, so
older apps keep working until v0.20.0 raises the minimum build.

Co-Authored-By: Claude Opus 5.5 <noreply@anthropic.com>
Signed-off-by: Tizian 「ティツィアーン」 <raisondetredev@pm.me>
This commit is contained in:
Tizian 「ティツィアーン」 2026-10-24 14:50:55 +02:00
commit 5b9df73756
Signed by: raisondetredev
GPG key ID: 07F6FCF405488B64
3 changed files with 981 additions and 3339 deletions

View file

@ -19,10 +19,6 @@ class const _PersonApp(final SignedIn person) extends StatelessWidget {
children: [
Text('App of ${person.authUserId}'),
const AccountTiles(
anonymousNote: 'Your notes stay on this device.',
signOutAnonymousMessage:
"This account can't be signed into again, so signing out "
'deletes it with its notes.',
deleteMessage: 'Your notes are deleted everywhere.',
),
],
@ -64,12 +60,12 @@ void main() {
setUp(() => gateway = FakeAuthGateway());
testWidgets('says whose account it is, anonymous on which instance, with '
"the app's note", (tester) async {
testWidgets('says on which instance the account is signed in', (
tester,
) async {
await start(tester);
expect(find.text('Anonymous on test.example.org'), findsOneWidget);
expect(find.text('Your notes stay on this device.'), findsOneWidget);
expect(find.text('Signed in on test.example.org'), findsOneWidget);
expect(
find.text('Switch account'),
findsNothing,
@ -79,18 +75,6 @@ void main() {
expect(find.text('Delete account'), findsOneWidget);
});
testWidgets('says the account is signed in, without the note', (
tester,
) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
);
await start(tester);
expect(find.text('Signed in on test.example.org'), findsOneWidget);
expect(find.text('Your notes stay on this device.'), findsNothing);
});
testWidgets('switching account lists the accounts on the device, the '
'instance in use named', (tester) async {
final instance = Instance.parse('learn.example.org')!;
@ -112,41 +96,7 @@ void main() {
expect(find.text('Accounts'), findsOneWidget);
});
testWidgets('signing out of an anonymous account asks first, in the '
"app's words, as it deletes it; Cancel keeps it", (tester) async {
await start(tester);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(find.text('Sign out?'), findsOneWidget);
expect(
find.text(
"This account can't be signed into again, so signing out deletes "
'it with its notes.',
),
findsOneWidget,
);
await tester.tap(find.text('Cancel'));
await tester.pumpAndSettle();
expect(find.text('App of anonymous-1'), findsOneWidget);
expect(gateway.signOuts, 0);
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
await tester.tap(find.text('Sign out').last);
await tester.pumpAndSettle();
expect(gateway.status, const SignedOut());
expect(find.text('Signed out'), findsOneWidget);
});
testWidgets('signing out of an identified account asks nothing', (
tester,
) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'github-person', isIdentified: true),
);
testWidgets('signing out asks nothing, deleting nothing', (tester) async {
await start(tester);
await tester.tap(find.text('Sign out'));
@ -154,6 +104,7 @@ void main() {
expect(find.text('Sign out?'), findsNothing);
expect(gateway.status, const SignedOut());
expect(gateway.accountDeletions, 0);
});
testWidgets("deleting the account asks first, in the app's words; Cancel "
@ -175,7 +126,7 @@ void main() {
await tester.pumpAndSettle();
expect(gateway.accountDeletions, 1);
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('deleting an identified account while the instance is out of '
@ -344,7 +295,7 @@ void main() {
dependencies.passwordGateway.accounts,
isNot(contains('anna@example.org')),
);
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('while the account is deleted, the person stays in their app, '
@ -360,7 +311,7 @@ void main() {
await tester.pump(const Duration(milliseconds: 100));
expect(
find.text('App of anonymous-1'),
find.text('App of person-1'),
findsOneWidget,
reason: 'never the launch screen in between',
);
@ -374,7 +325,7 @@ void main() {
gateway.deletionWaits!.complete();
await tester.pumpAndSettle();
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets('a deletion the instance never got keeps the person in their '
@ -394,7 +345,7 @@ void main() {
),
findsOneWidget,
);
expect(find.text('App of anonymous-1'), findsOneWidget);
expect(find.text('App of person-1'), findsOneWidget);
expect(gateway.status, isA<SignedIn>());
expect(find.byType(ButtonLoadingIndicator), findsNothing);
});
@ -420,14 +371,9 @@ void main() {
await start(tester);
final german = lookupHalkyonAppLocalizations(const Locale('de'));
expect(
german.accountAnonymousOn('test.example.org'),
'Anonym auf test.example.org',
);
expect(german.accountSignedInOn('x'), 'Angemeldet auf x');
expect(german.accountSwitch, 'Konto wechseln');
expect(german.accountSignOut, 'Abmelden');
expect(german.accountSignOutTitle, 'Abmelden?');
expect(german.accountDelete, 'Konto löschen');
expect(german.accountDeleteTitle, 'Dein Konto löschen?');
expect(

View file

@ -1,45 +0,0 @@
import 'package:flutter_test/flutter_test.dart';
import 'package:halkyon_app/halkyon_app.dart';
import 'package:halkyon_app/l10n/halkyon_app_localizations_en.dart';
void main() {
final en = HalkyonAppLocalizationsEn();
String? textOf(GitHubSignInProblem? problem, {bool anonymous = false}) =>
gitHubProblemText(en, problem, anonymous: anonymous);
test('each refusal the instance gives a reason for says it', () {
expect(
textOf(GitHubSignInProblem.unknownAccount),
'No account here signs in with this GitHub account yet.',
);
expect(
textOf(GitHubSignInProblem.usePassword),
'This account signs in with its email address and password now, not '
'with GitHub.',
);
expect(
textOf(GitHubSignInProblem.deprecated),
"GitHub can't be added as a way to sign in any more: your email "
'address and password are.',
);
expect(
textOf(GitHubSignInProblem.ended),
'Sign-in with GitHub has ended on this instance.',
);
});
test('for an anonymous person, linking refused means the GitHub account is '
"nobody's: a password keeps the account instead", () {
expect(
textOf(GitHubSignInProblem.deprecated, anonymous: true),
'No account here signs in with this GitHub account yet. To keep your '
'progress, set up a password instead.',
);
});
test("leaves the rest to the screen's own failure", () {
expect(textOf(GitHubSignInProblem.refused), isNull);
expect(textOf(null), isNull);
});
}

View file

@ -54,53 +54,61 @@ void main() {
await tester.pumpAndSettle();
}
testWidgets('shows the app once signed in anonymously at a first start', (
/// Nobody signed in, as at a first start, on an instance offering every
/// way in.
void signedOut() {
gateway = FakeAuthGateway(status: const SignedOut());
dependencies = TestAppDependencies(gateway: gateway);
dependencies.simpleLoginGateway.isOffered = true;
}
testWidgets("shows the signed-in person's app", (tester) async {
await start(tester);
expect(find.text('App of person-1'), findsOneWidget);
});
testWidgets('nobody signed in, it asks how to start on the instance, '
'making no account: there is no starting without one (ADR 0020, D4)', (
tester,
) async {
signedOut();
await start(tester);
expect(find.text('App of anonymous-1'), findsOneWidget);
});
testWidgets('offers to try again when signing in fails', (tester) async {
gateway.signInError = Exception('No connection');
await start(tester);
expect(find.text("Couldn't reach the server."), findsOneWidget);
gateway.signInError = null;
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('App of anonymous-2'), findsOneWidget);
});
testWidgets('signed out, it asks how to start on the instance, making no '
'account; starting without one makes one', (tester) async {
await dependencies.signedOut.write(signedOut: true);
await start(tester);
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Halkyon Test'), findsOneWidget);
expect(find.text('Start on test.example.org:'), findsOneWidget);
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text('Continue with SimpleLogin'), findsOneWidget);
expect(find.text('Sign in'), findsOneWidget);
expect(find.text('Create an account'), findsOneWidget);
expect(find.text('Sign in with GitHub'), findsOneWidget);
expect(find.text('Start without an account'), findsNothing);
expect(
find.text('Use another instance'),
findsNothing,
reason: 'no instances to choose among here',
);
expect(gateway.signIns, 0);
expect(gateway.status, const SignedOut());
});
await tester.tap(find.text('Start without an account'));
await tester.pumpAndSettle();
testWidgets("an anonymous account's session left from before ends as the "
'app starts, the account deleted, and the start screen asks (D25)', (
tester,
) async {
gateway = FakeAuthGateway(
status: const SignedIn(authUserId: 'anonymous-1', isIdentified: false),
);
dependencies = TestAppDependencies(gateway: gateway);
await start(tester);
expect(find.text('App of anonymous-1'), findsOneWidget);
expect(dependencies.signedOut.signedOut, isFalse);
expect(find.text('App of anonymous-1'), findsNothing);
expect(find.text('Start on test.example.org:'), findsOneWidget);
expect(gateway.accountDeletions, 1);
});
testWidgets('from there, GitHub signs in its person; a GitHub account '
'nobody here has yet is said so, with an account to create in its '
'place where passwords are offered (ADR 0017)', (tester) async {
await dependencies.signedOut.write(signedOut: true);
'nobody here has is said so, with SimpleLogin to create one in its '
'place', (tester) async {
signedOut();
await start(tester);
gateway.gitHubError = const GitHubSignInFailed(
@ -110,16 +118,11 @@ void main() {
await tester.pumpAndSettle();
expect(
find.text(
'No account here signs in with this GitHub account yet. Create an '
'account with your email address and a password instead.',
'No account here signs in with this GitHub account. Continue with '
'SimpleLogin to create one instead.',
),
findsOneWidget,
);
expect(
find.textContaining('add GitHub'),
findsNothing,
reason: 'GitHub is added to nobody where passwords are offered',
);
gateway.gitHubError = null;
await tester.tap(find.text('Sign in with GitHub'));
@ -130,25 +133,14 @@ void main() {
await tester.tap(find.text('Later'));
await tester.pumpAndSettle();
expect(find.text('App of github-person'), findsOneWidget);
expect(gateway.signIns, 0);
});
testWidgets('after GitHub, offers no password to someone who has one, or '
'where the instance offers none', (tester) async {
dependencies.passwordGateway.offered = false;
await dependencies.signedOut.write(signedOut: true);
await start(tester);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(find.text('Later'), findsNothing);
expect(find.text('App of github-person'), findsOneWidget);
});
testWidgets('where no passwords are offered, a GitHub account nobody here '
'has yet is said so with where the app adds GitHub', (tester) async {
dependencies.passwordGateway.offered = false;
await dependencies.signedOut.write(signedOut: true);
testWidgets('where the instance offers passwords and no SimpleLogin, the '
'account to create in its place has a password (ADR 0017)', (
tester,
) async {
signedOut();
dependencies.simpleLoginGateway.isOffered = false;
await start(tester);
gateway.gitHubError = const GitHubSignInFailed(
@ -158,17 +150,53 @@ void main() {
await tester.pumpAndSettle();
expect(
find.text(
// The package's sentence, then the app's on where GitHub is added.
'No account here signs in with this GitHub account yet. Start '
'without an account, then add GitHub in Settings.',
'No account here signs in with this GitHub account. Create an '
'account with your email address and a password instead.',
),
findsOneWidget,
);
});
testWidgets('after GitHub, offers no password to someone who has one, or '
'where the instance offers none', (tester) async {
signedOut();
dependencies.passwordGateway.offered = false;
await start(tester);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(find.text('Later'), findsNothing);
expect(find.text('App of github-person'), findsOneWidget);
});
testWidgets('an instance with no way to create an account says so, and '
'GitHub signs in only those who have it', (tester) async {
signedOut();
dependencies.passwordGateway.offered = false;
dependencies.simpleLoginGateway.isOffered = false;
await start(tester);
expect(
find.text('test.example.org offers no way to create an account.'),
findsOneWidget,
);
expect(find.text('Continue with SimpleLogin'), findsNothing);
expect(find.text('Create an account'), findsNothing);
gateway.gitHubError = const GitHubSignInFailed(
GitHubSignInProblem.unknownAccount,
);
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
expect(
find.text('No account here signs in with this GitHub account.'),
findsOneWidget,
);
});
testWidgets('an account that signs in with its password, and GitHub having '
'ended, are each said so', (tester) async {
await dependencies.signedOut.write(signedOut: true);
signedOut();
await start(tester);
gateway.gitHubError = const GitHubSignInFailed(
@ -195,14 +223,39 @@ void main() {
});
testWidgets('offers only the ways the instance has', (tester) async {
signedOut();
gateway.offersGitHub = false;
dependencies.passwordGateway.offered = false;
await dependencies.signedOut.write(signedOut: true);
await start(tester);
expect(find.text('Continue with SimpleLogin'), findsOneWidget);
expect(find.text('Sign in with GitHub'), findsNothing);
expect(find.text('Create an account'), findsNothing);
expect(find.text('Start without an account'), findsOneWidget);
expect(find.textContaining('offers no way'), findsNothing);
});
testWidgets('an instance out of reach is said so, offering nothing, and '
'asked again', (tester) async {
signedOut();
dependencies.passwordGateway.failing = const PasswordFailed(
PasswordProblem.failed,
);
dependencies.simpleLoginGateway.failing = const SimpleLoginFailed(
SimpleLoginProblem.failed,
);
gateway.offersGitHubError = Exception('No connection');
await start(tester);
expect(find.text("Couldn't reach the server."), findsOneWidget);
expect(find.text('Continue with SimpleLogin'), findsNothing);
expect(find.textContaining('offers no way'), findsNothing);
gateway.offersGitHubError = null;
dependencies.passwordGateway.failing = null;
dependencies.simpleLoginGateway.failing = null;
await tester.tap(find.text('Try again'));
await tester.pumpAndSettle();
expect(find.text('Continue with SimpleLogin'), findsOneWidget);
});
testWidgets('signing out, or the instance ending the session, asks how to '
@ -216,14 +269,17 @@ void main() {
await tester.tap(find.text('Sign out'));
await tester.pumpAndSettle();
expect(find.text('Signed out'), findsOneWidget);
expect(dependencies.signedOut.signedOut, isTrue);
expect(find.text('Start on test.example.org:'), findsOneWidget);
await tester.tap(find.text('Start without an account'));
gateway.gitHubError = null;
await tester.tap(find.text('Sign in with GitHub'));
await tester.pumpAndSettle();
await tester.tap(find.text('Later'));
await tester.pumpAndSettle();
expect(find.text('App of github-person'), findsOneWidget);
gateway.revokeSession();
await tester.pumpAndSettle();
expect(find.text('Signed out'), findsOneWidget);
expect(find.text('Start on test.example.org:'), findsOneWidget);
});
testWidgets("a sign-out under way or failed leaves the person's app as it "