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
@@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
/// session-restore that failed, e.g. transient network trouble at cold
|
||||
/// start), this still retries rather than no-op, since that's exactly the
|
||||
/// case LoginView's "Continue as" tile exists to recover from. Gated
|
||||
/// behind login lock when [lockAccountSwitching] is on. Returns whether
|
||||
/// behind its own unlock when [lockAccountSwitching] is on. Returns whether
|
||||
/// the account ended up logged in, so callers (LoginView's saved-account
|
||||
/// tile) can surface a failure - e.g. a stored app password that no
|
||||
/// longer works and needs the account removed/re-added.
|
||||
@@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return true;
|
||||
}
|
||||
|
||||
/// Turns login lock off, along with both of its sub-toggles (meaningless
|
||||
/// once the base lock is gone). Requires a successful auth first, same as
|
||||
/// turning it on - otherwise anyone with momentary access to an unlocked
|
||||
/// phone could just switch it off.
|
||||
/// Turns login lock off. Requires a successful auth first, same as turning
|
||||
/// it on - otherwise anyone with momentary access to an unlocked phone
|
||||
/// could just switch it off. Only this lock: the account-switching and
|
||||
/// hidden-files locks are independent and stay as they are.
|
||||
Future<bool> disableLoginLock() async {
|
||||
if (!_loginLockEnabled) return true;
|
||||
final confirmed = await AppLockService.authenticate(
|
||||
@@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
);
|
||||
if (!confirmed) return false;
|
||||
_loginLockEnabled = false;
|
||||
_lockAccountSwitching = false;
|
||||
_lockHiddenFiles = false;
|
||||
_isUnlocked = false;
|
||||
notifyListeners();
|
||||
prefsFuture.then((p) {
|
||||
p.setBool(_prefLoginLockEnabled, false);
|
||||
p.setBool(_prefLockAccountSwitching, false);
|
||||
p.setBool(_prefLockHiddenFiles, false);
|
||||
});
|
||||
prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false));
|
||||
return true;
|
||||
}
|
||||
|
||||
void setLockAccountSwitching(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
/// Turns the "unlock to switch accounts" gate on or off. Independent of
|
||||
/// login lock. Both directions need a successful auth - enabling proves the
|
||||
/// device can authenticate at all (a gate nobody can pass would lock the
|
||||
/// user out of switching), disabling stops anyone with momentary access to
|
||||
/// an unlocked phone from just removing it. Returns whether it changed.
|
||||
Future<bool> setLockAccountSwitching(bool value) async {
|
||||
if (value == _lockAccountSwitching) return true;
|
||||
if (!await _confirmLockChange(
|
||||
value,
|
||||
'Confirm to lock account switching',
|
||||
'Confirm to unlock account switching',
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_lockAccountSwitching = value;
|
||||
notifyListeners();
|
||||
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||
return true;
|
||||
}
|
||||
|
||||
void setLockHiddenFiles(bool value) {
|
||||
if (!_loginLockEnabled) return;
|
||||
/// Same as [setLockAccountSwitching], for revealing hidden files.
|
||||
Future<bool> setLockHiddenFiles(bool value) async {
|
||||
if (value == _lockHiddenFiles) return true;
|
||||
if (!await _confirmLockChange(
|
||||
value,
|
||||
'Confirm to lock hidden files',
|
||||
'Confirm to unlock hidden files',
|
||||
)) {
|
||||
return false;
|
||||
}
|
||||
_lockHiddenFiles = value;
|
||||
notifyListeners();
|
||||
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
||||
return true;
|
||||
}
|
||||
|
||||
Future<bool> _confirmLockChange(
|
||||
bool enabling,
|
||||
String enableReason,
|
||||
String disableReason,
|
||||
) async {
|
||||
if (enabling && !await AppLockService.isDeviceSupported()) return false;
|
||||
return AppLockService.authenticate(enabling ? enableReason : disableReason);
|
||||
}
|
||||
|
||||
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||
@@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
||||
return success;
|
||||
}
|
||||
|
||||
/// Prompts for auth if [gate] is on and login lock is configured;
|
||||
/// returns true immediately (no prompt) otherwise. Shared by the
|
||||
/// account-switching gate above and [FilesController]'s/`PhotosController`'s
|
||||
/// hidden-files gates.
|
||||
/// Prompts for auth if [gate] is on; returns true immediately (no prompt)
|
||||
/// otherwise. Each gate stands on its own - it doesn't depend on login lock
|
||||
/// being on. Shared by the account-switching gate above and
|
||||
/// [FilesController]'s/`PhotosController`'s hidden-files gates.
|
||||
Future<bool> passGate(bool gate, String reason) async {
|
||||
if (!_loginLockEnabled || !gate) return true;
|
||||
if (!gate) return true;
|
||||
return AppLockService.authenticate(reason);
|
||||
}
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
import 'package:flutter/foundation.dart';
|
||||
import 'package:local_auth/local_auth.dart';
|
||||
|
||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||
@@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart';
|
||||
class AppLockService {
|
||||
static final LocalAuthentication _auth = LocalAuthentication();
|
||||
|
||||
/// Replace the platform prompt in tests (null = the real one).
|
||||
@visibleForTesting
|
||||
static Future<bool> Function(String reason)? debugAuthenticate;
|
||||
|
||||
@visibleForTesting
|
||||
static Future<bool> Function()? debugIsDeviceSupported;
|
||||
|
||||
/// Whether this device can do *some* form of local auth - biometric
|
||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||
static Future<bool> isDeviceSupported() async {
|
||||
if (debugIsDeviceSupported != null) return debugIsDeviceSupported!();
|
||||
try {
|
||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||
if (canCheckBiometrics) return true;
|
||||
@@ -24,6 +33,7 @@ class AppLockService {
|
||||
/// throws) on cancellation, failure, or any platform error, so callers
|
||||
/// can treat every non-true result the same way: stay locked/blocked.
|
||||
static Future<bool> authenticate(String reason) async {
|
||||
if (debugAuthenticate != null) return debugAuthenticate!(reason);
|
||||
try {
|
||||
return await _auth.authenticate(
|
||||
localizedReason: reason,
|
||||
|
||||
@@ -332,7 +332,6 @@ class _OtherAccountRow extends StatelessWidget {
|
||||
),
|
||||
const SizedBox(width: 12),
|
||||
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
||||
const SizedBox(width: 44),
|
||||
],
|
||||
),
|
||||
),
|
||||
|
||||
@@ -26,26 +26,28 @@ String biometricLabel(TargetPlatform platform) {
|
||||
}
|
||||
}
|
||||
|
||||
/// Settings section 3: login lock, which gates opening the app, switching
|
||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
||||
/// Settings section 3: three independent locks - opening the app, switching
|
||||
/// accounts, and revealing hidden files - each behind the device's own PIN/
|
||||
/// biometric credential (see `AppLockService` - this app never stores or
|
||||
/// handles a PIN itself).
|
||||
/// handles a PIN itself). Turning one on or off asks for that credential, and
|
||||
/// none of them requires or implies another.
|
||||
class SettingsSecuritySection extends StatelessWidget {
|
||||
const SettingsSecuritySection({super.key});
|
||||
|
||||
Future<void> _handleLoginLockChanged(
|
||||
BuildContext context,
|
||||
SessionController session,
|
||||
bool value,
|
||||
) async {
|
||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
||||
Future<void> _handleLockChanged(
|
||||
BuildContext context, {
|
||||
required Future<bool> Function() change,
|
||||
required bool turningOn,
|
||||
required String name,
|
||||
}) async {
|
||||
final success = await change();
|
||||
if (!success && context.mounted) {
|
||||
ScaffoldMessenger.of(context).showSnackBar(
|
||||
SnackBar(
|
||||
content: Text(
|
||||
value
|
||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off login lock',
|
||||
turningOn
|
||||
? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||
: 'Could not turn off $name',
|
||||
),
|
||||
behavior: SnackBarBehavior.floating,
|
||||
),
|
||||
@@ -64,10 +66,17 @@ class SettingsSecuritySection extends StatelessWidget {
|
||||
NooSettingsRow(
|
||||
icon: LucideIcons.lock,
|
||||
label: Text(biometricLabel(platform)),
|
||||
subtitle: const Text("Require this device's PIN or biometric to open Noo"),
|
||||
subtitle: const Text(
|
||||
"Require this device's PIN or biometric to open Noo",
|
||||
),
|
||||
trailing: NooToggle(
|
||||
checked: session.loginLockEnabled,
|
||||
onChanged: (value) => _handleLoginLockChanged(context, session, value),
|
||||
onChanged: (value) => _handleLockChanged(
|
||||
context,
|
||||
change: value ? session.setupLoginLock : session.disableLoginLock,
|
||||
turningOn: value,
|
||||
name: 'login lock',
|
||||
),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
@@ -76,7 +85,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockAccountSwitching,
|
||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
||||
onChanged: (value) => _handleLockChanged(
|
||||
context,
|
||||
change: () => session.setLockAccountSwitching(value),
|
||||
turningOn: value,
|
||||
name: 'account switching lock',
|
||||
),
|
||||
),
|
||||
),
|
||||
NooSettingsRow(
|
||||
@@ -85,7 +99,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||
trailing: NooToggle(
|
||||
checked: session.lockHiddenFiles,
|
||||
onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null,
|
||||
onChanged: (value) => _handleLockChanged(
|
||||
context,
|
||||
change: () => session.setLockHiddenFiles(value),
|
||||
turningOn: value,
|
||||
name: 'hidden files lock',
|
||||
),
|
||||
),
|
||||
),
|
||||
],
|
||||
|
||||
Reference in New Issue
Block a user