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:
co-authored by
Claude Sonnet 5.5
parent
27c8ac12f2
commit
22a6602f08
@@ -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);
|
||||
});
|
||||
}
|
||||
@@ -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,
|
||||
);
|
||||
});
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user