fixed possible lockups on app start

This commit is contained in:
2026-09-11 12:04:32 +02:00
parent 43dfc52bc7
commit 3734d7ff2c
7 changed files with 452 additions and 173 deletions
+59 -3
View File
@@ -1,11 +1,14 @@
import 'dart:async';
import 'dart:convert';
import 'dart:developer';
import 'dart:io';
import 'package:crypto/crypto.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:shared_preferences/shared_preferences.dart';
import '../push/push_secure_storage.dart';
import '../utils/exponential_backoff.dart';
class AccountData {
static const _usernameField = 'username';
@@ -26,7 +29,22 @@ class AccountData {
// Keeps isPopulated()/getPassword() valid; demo mode never uses a real one.
static const _demoPasswordPlaceholder = 'demo';
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage();
// `first_unlock` so a background launch on a locked device (silent push,
// BGAppRefresh) can still read the session. Items written by older versions
// carry the plugin default `unlocked` and are invisible to this instance
// until _migrateKeychainAccessibility moved them over.
static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.first_unlock),
);
static const FlutterSecureStorage _legacySecureStorage = FlutterSecureStorage(
iOptions: IOSOptions(accessibility: KeychainAccessibility.unlocked),
);
static const List<String> _sessionFields = [
_usernameField,
_passwordField,
_demoField,
_loginFlowField,
];
static final AccountData _instance = AccountData._construct();
Completer<void> _populated = Completer();
@@ -34,7 +52,7 @@ class AccountData {
factory AccountData() => _instance;
AccountData._construct() {
_migrateAndLoad();
unawaited(_loadWithRetry());
}
String? _username;
@@ -175,12 +193,36 @@ class AccountData {
}
}
/// iOS keychain reads fail while protected data is unavailable (app launch
/// racing the unlock, background wake on a locked device). Without a retry
/// the completer never resolved and the app stayed on the launch screen.
Future<void> _loadWithRetry() async {
for (var attempt = 1; !_populated.isCompleted; attempt++) {
try {
await _migrateAndLoad();
return;
} catch (e, s) {
log('AccountData load failed (attempt $attempt): $e', stackTrace: s);
await Future<void>.delayed(exponentialBackoff(attempt));
}
}
}
/// Stops waiting for the stored session; the app then behaves as logged
/// out. The keychain entries stay untouched so a later start can still
/// restore the session.
void abandonLoad() {
if (!_populated.isCompleted) _populated.complete();
}
Future<void> _migrateAndLoad() async {
await _migrateFromLegacyStorage();
await _migrateKeychainAccessibility();
_username = await _secureStorage.read(key: _usernameField);
_password = await _secureStorage.read(key: _passwordField);
_isDemo = (await _secureStorage.read(key: _demoField)) == 'true';
_usesLoginFlow = (await _secureStorage.read(key: _loginFlowField)) == 'true';
_usesLoginFlow =
(await _secureStorage.read(key: _loginFlowField)) == 'true';
try {
_appPassword = await pushSecureStorage.read(key: _appPasswordField);
_appPasswordTalk = await pushSecureStorage.read(
@@ -210,11 +252,25 @@ class AccountData {
await prefs.remove(_passwordField);
}
Future<void> _migrateKeychainAccessibility() async {
if (!Platform.isIOS) return;
for (final field in _sessionFields) {
final value = await _legacySecureStorage.read(key: field);
if (value == null) continue;
// Same account+service: the legacy item has to go before the re-add.
await _legacySecureStorage.delete(key: field);
await _secureStorage.write(key: field, value: value);
}
}
Future<bool> waitForPopulation() async {
await _populated.future;
return isPopulated();
}
/// True once the stored session has been read (or given up on).
bool get isLoaded => _populated.isCompleted;
bool isPopulated() => _username != null && _password != null;
/// Returns the value for an HTTP `Authorization` header using HTTP Basic.