Worktree ios support #3
@@ -57,8 +57,8 @@ new provider instance):
|
|||||||
saved-accounts list, which one is active, `sessionGeneration` — see
|
saved-accounts list, which one is active, `sessionGeneration` — see
|
||||||
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
below), auth/login-flow state (`isLoggedIn`, `isRestoringSession`,
|
||||||
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
`loginFlowStatus`), the active `NextcloudService` instance, and login lock
|
||||||
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles`/
|
(`loginLockEnabled`/`lockAccountSwitching`/`lockHiddenFiles` - three
|
||||||
`needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
independent locks - plus `needsUnlock`/`passGate`). Exposes `addAccountClearedListener`/
|
||||||
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
`addAccountActivatedListener` (plain `List<VoidCallback>`) so sibling
|
||||||
controllers — constructed after `SessionController` and unable to hold a
|
controllers — constructed after `SessionController` and unable to hold a
|
||||||
forward reference to it — can react to login/logout/account-switch
|
forward reference to it — can react to login/logout/account-switch
|
||||||
|
|||||||
@@ -798,6 +798,19 @@ plugin's own manifest via merge, but kept explicit here too).
|
|||||||
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
own requirement) via `maxOf(24, flutter.minSdkVersion)` rather than trusting
|
||||||
Flutter's own default to already be high enough.
|
Flutter's own default to already be high enough.
|
||||||
|
|
||||||
|
**The three locks are independent** (Settings -> Security;
|
||||||
|
`SessionController`): `loginLockEnabled` (unlock to open the app - the only one
|
||||||
|
`needsUnlock`/the lock screen look at), `lockAccountSwitching` (unlock to
|
||||||
|
switch accounts) and `lockHiddenFiles` (unlock to turn on showing hidden
|
||||||
|
files). Each gate works with the others off - `passGate(gate, reason)`
|
||||||
|
prompts whenever its own `gate` is on, whether or not login lock is. Turning
|
||||||
|
any of them on **or off** needs a successful `AppLockService.authenticate`
|
||||||
|
(on also needs `isDeviceSupported`, so a gate nobody can pass can't be set;
|
||||||
|
off needs it so someone with a momentarily unlocked phone can't remove it),
|
||||||
|
and `disableLoginLock` leaves the other two untouched. The old master/sub-toggle
|
||||||
|
dependency is gone. Tests replace the prompt through
|
||||||
|
`AppLockService.debugAuthenticate`/`debugIsDeviceSupported`.
|
||||||
|
|
||||||
`isRestoringSession` still gates the splash screen until the above resolves
|
`isRestoringSession` still gates the splash screen until the above resolves
|
||||||
— see `standards.md` for why widget tests must mock both storage channels
|
— see `standards.md` for why widget tests must mock both storage channels
|
||||||
rather than relying on this async path throwing naturally.
|
rather than relying on this async path throwing naturally.
|
||||||
@@ -903,16 +916,16 @@ Reminders/Notes - the destination is picked *inside* the sheet:
|
|||||||
setting of their own. `hide` (default) drops dot-folders, `only` lists
|
setting of their own. `hide` (default) drops dot-folders, `only` lists
|
||||||
just them, `include` lists everything; a folder is hidden when it *or
|
just them, `include` lists everything; a folder is hidden when it *or
|
||||||
any ancestor* starts with a dot (`HiddenFilter`, same rule as the app).
|
any ancestor* starts with a dot (`HiddenFilter`, same rule as the app).
|
||||||
When the filter isn't `hide` and the app has login lock + "lock hidden
|
When the filter isn't `hide` and the app's "lock hidden files" is on,
|
||||||
files" on, the sheet asks for Face ID/passcode first (`DeviceAuth`, the
|
the sheet asks for Face ID/passcode first (`DeviceAuth`, the
|
||||||
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
|
`.deviceOwnerAuthentication` policy - biometrics with passcode fallback,
|
||||||
like `local_auth` with `biometricOnly: false`); cancelling falls back to
|
like `local_auth` with `biometricOnly: false`); cancelling falls back to
|
||||||
hiding them, with a note.
|
hiding them, with a note.
|
||||||
- **Account switching**: choosing any account other than the app's
|
- **Account switching**: choosing any account other than the app's
|
||||||
active one asks for the same unlock when login lock + "lock account
|
active one asks for the same unlock when "lock account switching" is on. One successful unlock covers the rest of that sheet.
|
||||||
switching" are on. One successful unlock covers the rest of that sheet.
|
|
||||||
- Opening the sheet itself is not gated - only these two actions are
|
- Opening the sheet itself is not gated - only these two actions are
|
||||||
(as in the app, where login lock guards launch and these toggles).
|
(as in the app, where login lock guards launch and these are separate
|
||||||
|
locks).
|
||||||
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
3. **Upload** calls `ShareUpload.enqueue`: one `PUT` per file on a *background*
|
||||||
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
`URLSession` (`dev.ayushya.noo.transfers.share`, with
|
||||||
`sharedContainerIdentifier`) that outlives the extension, and posts
|
`sharedContainerIdentifier`) that outlives the extension, and posts
|
||||||
|
|||||||
@@ -204,18 +204,17 @@ class RunnerTests: XCTestCase {
|
|||||||
XCTAssertEqual(shared.active?.id, "alice")
|
XCTAssertEqual(shared.active?.id, "alice")
|
||||||
}
|
}
|
||||||
|
|
||||||
func testUnlockRulesNeedTheMasterLockAndTheirOwnToggle() {
|
func testUnlockRulesAreIndependentOfLoginLock() {
|
||||||
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
func rules(master: Bool, switching: Bool, hidden: Bool) -> (Bool, Bool) {
|
||||||
let shared = SharedAccounts(
|
let shared = SharedAccounts(
|
||||||
accounts: [account("a")], activeId: "a",
|
accounts: [account("a")], activeId: "a",
|
||||||
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
loginLockEnabled: master, lockAccountSwitching: switching, lockHiddenFiles: hidden)
|
||||||
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
return (shared.needsUnlockToSwitchAccount, shared.needsUnlockForHidden)
|
||||||
}
|
}
|
||||||
XCTAssertTrue(rules(master: true, switching: true, hidden: false) == (true, false))
|
XCTAssertTrue(rules(master: false, switching: true, hidden: false) == (true, false))
|
||||||
XCTAssertTrue(rules(master: true, switching: false, hidden: true) == (false, true))
|
XCTAssertTrue(rules(master: false, switching: false, hidden: true) == (false, true))
|
||||||
XCTAssertTrue(
|
XCTAssertTrue(rules(master: true, switching: false, hidden: false) == (false, false))
|
||||||
rules(master: false, switching: true, hidden: true) == (false, false),
|
XCTAssertTrue(rules(master: true, switching: true, hidden: true) == (true, true))
|
||||||
"the sub-toggles mean nothing while the login lock is off, as in the app")
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// MARK: - TransferBatchStore
|
// MARK: - TransferBatchStore
|
||||||
|
|||||||
@@ -23,8 +23,9 @@ struct SharedAccount: Codable, Equatable, Identifiable {
|
|||||||
struct SharedAccounts: Codable, Equatable {
|
struct SharedAccounts: Codable, Equatable {
|
||||||
var accounts: [SharedAccount]
|
var accounts: [SharedAccount]
|
||||||
var activeId: String?
|
var activeId: String?
|
||||||
/// Settings -> Security: the master "login lock" and its two sub-toggles.
|
/// Settings -> Security: the three independent locks. `loginLockEnabled`
|
||||||
/// The sub-toggles only count while the master one is on (as in the app).
|
/// (unlock to open the app) is carried for completeness; the extension's own
|
||||||
|
/// gates are the other two.
|
||||||
var loginLockEnabled: Bool
|
var loginLockEnabled: Bool
|
||||||
var lockAccountSwitching: Bool
|
var lockAccountSwitching: Bool
|
||||||
var lockHiddenFiles: Bool
|
var lockHiddenFiles: Bool
|
||||||
@@ -36,11 +37,11 @@ struct SharedAccounts: Codable, Equatable {
|
|||||||
|
|
||||||
/// Uploading to an account other than the active one is "switching" in
|
/// Uploading to an account other than the active one is "switching" in
|
||||||
/// the app's terms, so it needs the same unlock.
|
/// the app's terms, so it needs the same unlock.
|
||||||
var needsUnlockToSwitchAccount: Bool { loginLockEnabled && lockAccountSwitching }
|
var needsUnlockToSwitchAccount: Bool { lockAccountSwitching }
|
||||||
|
|
||||||
/// Showing hidden folders needs the same unlock the app asks for when you
|
/// Showing hidden folders needs the same unlock the app asks for when you
|
||||||
/// turn hidden files on.
|
/// turn hidden files on.
|
||||||
var needsUnlockForHidden: Bool { loginLockEnabled && lockHiddenFiles }
|
var needsUnlockForHidden: Bool { lockHiddenFiles }
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
|
/// Keeps the [SharedAccounts] in a Keychain access group both the app and the
|
||||||
|
|||||||
@@ -516,7 +516,7 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
/// session-restore that failed, e.g. transient network trouble at cold
|
/// session-restore that failed, e.g. transient network trouble at cold
|
||||||
/// start), this still retries rather than no-op, since that's exactly the
|
/// start), this still retries rather than no-op, since that's exactly the
|
||||||
/// case LoginView's "Continue as" tile exists to recover from. Gated
|
/// 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
|
/// 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
|
/// tile) can surface a failure - e.g. a stored app password that no
|
||||||
/// longer works and needs the account removed/re-added.
|
/// longer works and needs the account removed/re-added.
|
||||||
@@ -640,10 +640,10 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Turns login lock off, along with both of its sub-toggles (meaningless
|
/// Turns login lock off. Requires a successful auth first, same as turning
|
||||||
/// once the base lock is gone). Requires a successful auth first, same as
|
/// it on - otherwise anyone with momentary access to an unlocked phone
|
||||||
/// turning it on - otherwise anyone with momentary access to an unlocked
|
/// could just switch it off. Only this lock: the account-switching and
|
||||||
/// phone could just switch it off.
|
/// hidden-files locks are independent and stay as they are.
|
||||||
Future<bool> disableLoginLock() async {
|
Future<bool> disableLoginLock() async {
|
||||||
if (!_loginLockEnabled) return true;
|
if (!_loginLockEnabled) return true;
|
||||||
final confirmed = await AppLockService.authenticate(
|
final confirmed = await AppLockService.authenticate(
|
||||||
@@ -651,30 +651,55 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
);
|
);
|
||||||
if (!confirmed) return false;
|
if (!confirmed) return false;
|
||||||
_loginLockEnabled = false;
|
_loginLockEnabled = false;
|
||||||
_lockAccountSwitching = false;
|
|
||||||
_lockHiddenFiles = false;
|
|
||||||
_isUnlocked = false;
|
_isUnlocked = false;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) {
|
prefsFuture.then((p) => p.setBool(_prefLoginLockEnabled, false));
|
||||||
p.setBool(_prefLoginLockEnabled, false);
|
|
||||||
p.setBool(_prefLockAccountSwitching, false);
|
|
||||||
p.setBool(_prefLockHiddenFiles, false);
|
|
||||||
});
|
|
||||||
return true;
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLockAccountSwitching(bool value) {
|
/// Turns the "unlock to switch accounts" gate on or off. Independent of
|
||||||
if (!_loginLockEnabled) return;
|
/// 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;
|
_lockAccountSwitching = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
prefsFuture.then((p) => p.setBool(_prefLockAccountSwitching, value));
|
||||||
|
return true;
|
||||||
}
|
}
|
||||||
|
|
||||||
void setLockHiddenFiles(bool value) {
|
/// Same as [setLockAccountSwitching], for revealing hidden files.
|
||||||
if (!_loginLockEnabled) return;
|
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;
|
_lockHiddenFiles = value;
|
||||||
notifyListeners();
|
notifyListeners();
|
||||||
prefsFuture.then((p) => p.setBool(_prefLockHiddenFiles, value));
|
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.
|
/// Called by the lock screen. Returns whether it actually unlocked.
|
||||||
@@ -687,12 +712,12 @@ class SessionController extends ChangeNotifier with WidgetsBindingObserver {
|
|||||||
return success;
|
return success;
|
||||||
}
|
}
|
||||||
|
|
||||||
/// Prompts for auth if [gate] is on and login lock is configured;
|
/// Prompts for auth if [gate] is on; returns true immediately (no prompt)
|
||||||
/// returns true immediately (no prompt) otherwise. Shared by the
|
/// otherwise. Each gate stands on its own - it doesn't depend on login lock
|
||||||
/// account-switching gate above and [FilesController]'s/`PhotosController`'s
|
/// being on. Shared by the account-switching gate above and
|
||||||
/// hidden-files gates.
|
/// [FilesController]'s/`PhotosController`'s hidden-files gates.
|
||||||
Future<bool> passGate(bool gate, String reason) async {
|
Future<bool> passGate(bool gate, String reason) async {
|
||||||
if (!_loginLockEnabled || !gate) return true;
|
if (!gate) return true;
|
||||||
return AppLockService.authenticate(reason);
|
return AppLockService.authenticate(reason);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import 'package:flutter/foundation.dart';
|
||||||
import 'package:local_auth/local_auth.dart';
|
import 'package:local_auth/local_auth.dart';
|
||||||
|
|
||||||
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
/// Thin wrapper around `local_auth`. Deliberately doesn't implement its own
|
||||||
@@ -8,9 +9,17 @@ import 'package:local_auth/local_auth.dart';
|
|||||||
class AppLockService {
|
class AppLockService {
|
||||||
static final LocalAuthentication _auth = LocalAuthentication();
|
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
|
/// Whether this device can do *some* form of local auth - biometric
|
||||||
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
/// enrolled, or at minimum a device PIN/pattern/password set up.
|
||||||
static Future<bool> isDeviceSupported() async {
|
static Future<bool> isDeviceSupported() async {
|
||||||
|
if (debugIsDeviceSupported != null) return debugIsDeviceSupported!();
|
||||||
try {
|
try {
|
||||||
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
final canCheckBiometrics = await _auth.canCheckBiometrics;
|
||||||
if (canCheckBiometrics) return true;
|
if (canCheckBiometrics) return true;
|
||||||
@@ -24,6 +33,7 @@ class AppLockService {
|
|||||||
/// throws) on cancellation, failure, or any platform error, so callers
|
/// throws) on cancellation, failure, or any platform error, so callers
|
||||||
/// can treat every non-true result the same way: stay locked/blocked.
|
/// can treat every non-true result the same way: stay locked/blocked.
|
||||||
static Future<bool> authenticate(String reason) async {
|
static Future<bool> authenticate(String reason) async {
|
||||||
|
if (debugAuthenticate != null) return debugAuthenticate!(reason);
|
||||||
try {
|
try {
|
||||||
return await _auth.authenticate(
|
return await _auth.authenticate(
|
||||||
localizedReason: reason,
|
localizedReason: reason,
|
||||||
|
|||||||
@@ -332,7 +332,6 @@ class _OtherAccountRow extends StatelessWidget {
|
|||||||
),
|
),
|
||||||
const SizedBox(width: 12),
|
const SizedBox(width: 12),
|
||||||
NooAvatar(initials: accountInitial(name), current: false, size: 40),
|
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
|
/// Settings section 3: three independent locks - opening the app, switching
|
||||||
/// accounts, and revealing hidden files behind the device's own PIN/
|
/// accounts, and revealing hidden files - each behind the device's own PIN/
|
||||||
/// biometric credential (see `AppLockService` - this app never stores or
|
/// 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 {
|
class SettingsSecuritySection extends StatelessWidget {
|
||||||
const SettingsSecuritySection({super.key});
|
const SettingsSecuritySection({super.key});
|
||||||
|
|
||||||
Future<void> _handleLoginLockChanged(
|
Future<void> _handleLockChanged(
|
||||||
BuildContext context,
|
BuildContext context, {
|
||||||
SessionController session,
|
required Future<bool> Function() change,
|
||||||
bool value,
|
required bool turningOn,
|
||||||
) async {
|
required String name,
|
||||||
final success = value ? await session.setupLoginLock() : await session.disableLoginLock();
|
}) async {
|
||||||
|
final success = await change();
|
||||||
if (!success && context.mounted) {
|
if (!success && context.mounted) {
|
||||||
ScaffoldMessenger.of(context).showSnackBar(
|
ScaffoldMessenger.of(context).showSnackBar(
|
||||||
SnackBar(
|
SnackBar(
|
||||||
content: Text(
|
content: Text(
|
||||||
value
|
turningOn
|
||||||
? "Could not set up login lock - make sure this device has a PIN, pattern, password, or biometric configured"
|
? "Could not turn on $name - make sure this device has a PIN, pattern, password, or biometric configured"
|
||||||
: 'Could not turn off login lock',
|
: 'Could not turn off $name',
|
||||||
),
|
),
|
||||||
behavior: SnackBarBehavior.floating,
|
behavior: SnackBarBehavior.floating,
|
||||||
),
|
),
|
||||||
@@ -64,10 +66,17 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
icon: LucideIcons.lock,
|
icon: LucideIcons.lock,
|
||||||
label: Text(biometricLabel(platform)),
|
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(
|
trailing: NooToggle(
|
||||||
checked: session.loginLockEnabled,
|
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(
|
NooSettingsRow(
|
||||||
@@ -76,7 +85,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
subtitle: const Text('Unlock to switch between saved accounts'),
|
subtitle: const Text('Unlock to switch between saved accounts'),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.lockAccountSwitching,
|
checked: session.lockAccountSwitching,
|
||||||
onChanged: session.loginLockEnabled ? session.setLockAccountSwitching : null,
|
onChanged: (value) => _handleLockChanged(
|
||||||
|
context,
|
||||||
|
change: () => session.setLockAccountSwitching(value),
|
||||||
|
turningOn: value,
|
||||||
|
name: 'account switching lock',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
NooSettingsRow(
|
NooSettingsRow(
|
||||||
@@ -85,7 +99,12 @@ class SettingsSecuritySection extends StatelessWidget {
|
|||||||
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
subtitle: const Text('Unlock to reveal hidden files and folders'),
|
||||||
trailing: NooToggle(
|
trailing: NooToggle(
|
||||||
checked: session.lockHiddenFiles,
|
checked: session.lockHiddenFiles,
|
||||||
onChanged: session.loginLockEnabled ? session.setLockHiddenFiles : null,
|
onChanged: (value) => _handleLockChanged(
|
||||||
|
context,
|
||||||
|
change: () => session.setLockHiddenFiles(value),
|
||||||
|
turningOn: value,
|
||||||
|
name: 'hidden files lock',
|
||||||
|
),
|
||||||
),
|
),
|
||||||
),
|
),
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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/sync_status_controller.dart';
|
||||||
import 'package:noo/providers/trash_controller.dart';
|
import 'package:noo/providers/trash_controller.dart';
|
||||||
import 'package:noo/theme/app_theme.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/app_top_bar.dart';
|
||||||
import 'package:noo/widgets/noo/nav/noo_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);
|
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