Security locks work independently; other-account avatars flush right in the avatar menu

Login lock, lock account switching and lock hidden files no longer depend on
each other: passGate prompts on its own flag, the two sub-locks can be set
with login lock off, and disabling login lock leaves them alone. Turning any
lock on or off asks for device auth (on also checks the device can). The iOS
Share Extension's unlock rules follow. The avatar dropdown's additional
accounts lose the 44px spacer so their avatars sit at the right edge.

Co-Authored-By: Claude Sonnet 5.5 <noreply@anthropic.com>
This commit is contained in:
Ayushya Amitabh
2026-10-06 20:07:28 -04:00
co-authored by Claude Sonnet 5.5
parent 27c8ac12f2
commit 22a6602f08
10 changed files with 323 additions and 55 deletions
+156
View File
@@ -0,0 +1,156 @@
import 'package:flutter/services.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:noo/providers/connectivity_controller.dart';
import 'package:noo/providers/session_controller.dart';
import 'package:noo/services/app_lock_service.dart';
/// The three Settings -> Security locks (open the app, switch accounts, reveal
/// hidden files) each work on their own - none needs or implies another. The
/// device prompt is replaced with a recorder so the gates can be exercised.
void main() {
TestWidgetsFlutterBinding.ensureInitialized();
setUpAll(() {
final messenger =
TestDefaultBinaryMessengerBinding.instance.defaultBinaryMessenger;
messenger.setMockMethodCallHandler(
const MethodChannel('plugins.it_nomads.com/flutter_secure_storage'),
(call) async => call.method == 'readAll' ? <String, String>{} : null,
);
messenger.setMockMethodCallHandler(
const MethodChannel('dev.fluttercommunity.plus/connectivity'),
(call) async => call.method == 'check' ? <String>['none'] : null,
);
messenger.setMockStreamHandler(
const EventChannel('dev.fluttercommunity.plus/connectivity_status'),
MockStreamHandler.inline(onListen: (arguments, events) {}),
);
});
late List<String> prompts;
var authSucceeds = true;
var deviceSupported = true;
setUp(() {
SharedPreferences.setMockInitialValues({});
prompts = [];
authSucceeds = true;
deviceSupported = true;
AppLockService.debugAuthenticate = (reason) async {
prompts.add(reason);
return authSucceeds;
};
AppLockService.debugIsDeviceSupported = () async => deviceSupported;
});
tearDown(() {
AppLockService.debugAuthenticate = null;
AppLockService.debugIsDeviceSupported = null;
});
SessionController build() => SessionController(ConnectivityController());
test(
'hidden files and account switching can be locked without login lock',
() async {
final session = build();
expect(await session.setLockHiddenFiles(true), isTrue);
expect(await session.setLockAccountSwitching(true), isTrue);
expect(session.lockHiddenFiles, isTrue);
expect(session.lockAccountSwitching, isTrue);
expect(session.loginLockEnabled, isFalse);
expect(
session.needsUnlock,
isFalse,
reason: 'opening the app stays open',
);
},
);
test('a gate prompts on its own, with login lock off', () async {
final session = build();
expect(await session.passGate(false, 'unused'), isTrue);
expect(prompts, isEmpty, reason: 'a gate that is off never prompts');
expect(await session.passGate(true, 'Unlock to show hidden files'), isTrue);
expect(prompts, ['Unlock to show hidden files']);
authSucceeds = false;
expect(await session.passGate(true, 'again'), isFalse);
});
test(
'turning a lock on needs a capable device and a successful auth',
() async {
final session = build();
deviceSupported = false;
expect(await session.setLockHiddenFiles(true), isFalse);
expect(session.lockHiddenFiles, isFalse);
deviceSupported = true;
authSucceeds = false;
expect(await session.setLockHiddenFiles(true), isFalse);
expect(session.lockHiddenFiles, isFalse);
authSucceeds = true;
expect(await session.setLockHiddenFiles(true), isTrue);
expect(session.lockHiddenFiles, isTrue);
},
);
test(
'turning a lock off needs auth, so it cannot just be flipped away',
() async {
final session = build();
await session.setLockAccountSwitching(true);
prompts.clear();
authSucceeds = false;
expect(await session.setLockAccountSwitching(false), isFalse);
expect(session.lockAccountSwitching, isTrue);
authSucceeds = true;
expect(await session.setLockAccountSwitching(false), isTrue);
expect(session.lockAccountSwitching, isFalse);
expect(prompts, hasLength(2));
},
);
test('setting a lock to its current value does not prompt', () async {
final session = build();
expect(await session.setLockHiddenFiles(false), isTrue);
expect(prompts, isEmpty);
});
test('turning login lock off leaves the other two locks alone', () async {
final session = build();
await session.setupLoginLock();
await session.setLockAccountSwitching(true);
await session.setLockHiddenFiles(true);
expect(session.loginLockEnabled, isTrue);
expect(await session.disableLoginLock(), isTrue);
expect(session.loginLockEnabled, isFalse);
expect(session.lockAccountSwitching, isTrue);
expect(session.lockHiddenFiles, isTrue);
});
test('each lock is saved on its own and restored', () async {
final first = build();
await first.setLockHiddenFiles(true);
// Let the fire-and-forget pref write land.
await Future<void>.delayed(const Duration(milliseconds: 50));
final second = build();
await Future<void>.delayed(const Duration(milliseconds: 100));
expect(second.lockHiddenFiles, isTrue);
expect(second.lockAccountSwitching, isFalse);
expect(second.loginLockEnabled, isFalse);
});
}
+46
View File
@@ -15,6 +15,8 @@ import 'package:noo/providers/settings_controller.dart';
import 'package:noo/providers/sync_status_controller.dart';
import 'package:noo/providers/trash_controller.dart';
import 'package:noo/theme/app_theme.dart';
import 'package:noo/theme/design_tokens.dart';
import 'package:noo/widgets/noo/core/noo_avatar.dart';
import 'package:noo/widgets/app_top_bar.dart';
import 'package:noo/widgets/noo/nav/noo_top_bar.dart';
@@ -174,4 +176,48 @@ void main() {
expect(find.text('Add account'), findsOneWidget);
},
);
testWidgets("other accounts' avatars sit flush right in the dropdown", (
tester,
) async {
SharedPreferences.setMockInitialValues({
'account_migration_v1_done': true,
'accounts_list': jsonEncode([
const SavedAccount(
id: accountId,
serverUrl: 'https://server.example.com',
username: 'alice',
).toJson(),
const SavedAccount(
id: 'other_example_org__bob',
serverUrl: 'https://other.example.org',
username: 'bob',
).toJson(),
]),
'active_account_id': accountId,
});
await pumpTopBar(tester, navMenuStyle: NooNavMenuStyle.avatarMenu);
await tester.tap(find.byTooltip('Menu'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 200));
// Expand the account list from the header.
await tester.tap(find.text('alice'));
await tester.pump();
await tester.pump(const Duration(milliseconds: 400));
final row = find.ancestor(
of: find.text('bob'),
matching: find.byType(InkWell),
);
final avatar = find.descendant(of: row, matching: find.byType(NooAvatar));
expect(avatar, findsOneWidget);
// Only the row's own padding between the avatar and the card's edge -
// no extra gutter pushing it in from the right.
expect(
tester.getTopRight(row).dx - tester.getTopRight(avatar).dx,
NooSpace.md,
);
});
}