implement offline lock with PIN and biometric authentication

- Introduced `AppGateController` to manage initial routing (login, unlock, or home) based on session and connectivity state.
- Added `OfflineLockRepository` and `UnlockController` with support for PIN (PBKDF2 hashing) and biometric authentication.
- Created `UnlockScreen` for PIN entry and biometric prompts.
- Added `ConnectivityService` to detect online/offline status using `connectivity_plus`.
- Enhanced `AuthLocalDataSource` and `SessionRepository` to manage session markers and user IDs.
- Updated `AuthController` to handle session markers and navigation transitions.
- Modified `GoRouter` to use `AppGateState` for app-wide redirection logic.
- Integrated `local_auth` and `cryptography` packages.
- Added comprehensive unit tests for `AppGateController` and `UnlockController`.
This commit is contained in:
oskar 2026-01-13 14:35:57 +01:00
parent d096fc479d
commit 1315b95a72
38 changed files with 1319 additions and 8 deletions

View file

@ -43,6 +43,18 @@ When the spec is ready, generate a client and replace `ApiClient` usage:
2. Generate a Dart client (OpenAPI Generator or Swagger Codegen). 2. Generate a Dart client (OpenAPI Generator or Swagger Codegen).
3. Swap `AuthRemoteDataSource` or `TelemetryRemoteDataSource` to call the generated client. 3. Swap `AuthRemoteDataSource` or `TelemetryRemoteDataSource` to call the generated client.
## Clean and test
```sh
flutter clean
flutter test
```
## Clean and build
```sh
flutter clean
flutter build apk
```
## Running ## Running
```sh ```sh

View file

@ -1,4 +1,6 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE"/>
<uses-permission android:name="android.permission.INTERNET"/>
<application <application
android:label="mosenioring" android:label="mosenioring"
android:name="${applicationName}" android:name="${applicationName}"

View file

@ -4,9 +4,11 @@ import 'package:go_router/go_router.dart';
import '../di/providers.dart'; import '../di/providers.dart';
import '../features/auth/presentation/login_page.dart'; import '../features/auth/presentation/login_page.dart';
import '../features/home/presentation/home_page.dart'; import '../features/home/presentation/home_page.dart';
import '../features/offline_lock/presentation/unlock_screen.dart';
import '../features/app_gate/presentation/app_gate_state.dart';
final appRouterProvider = Provider<GoRouter>((ref) { final appRouterProvider = Provider<GoRouter>((ref) {
final authState = ref.watch(authControllerProvider); final appGateState = ref.watch(appGateControllerProvider);
return GoRouter( return GoRouter(
initialLocation: '/login', initialLocation: '/login',
@ -19,15 +21,26 @@ final appRouterProvider = Provider<GoRouter>((ref) {
path: '/', path: '/',
builder: (context, state) => const HomePage(), builder: (context, state) => const HomePage(),
), ),
GoRoute(
path: '/unlock',
builder: (context, state) => const UnlockScreen(),
),
], ],
redirect: (context, state) { redirect: (context, state) {
final isLoggedIn = authState.isAuthenticated; if (appGateState.isLoading) {
final isLoggingIn = state.matchedLocation == '/login'; return null;
}
final destination = appGateState.destination;
final location = state.matchedLocation;
if (!isLoggedIn && !isLoggingIn) { if (destination == AppGateDestination.login && location != '/login') {
return '/login'; return '/login';
} }
if (isLoggedIn && isLoggingIn) { if (destination == AppGateDestination.unlock && location != '/unlock') {
return '/unlock';
}
if (destination == AppGateDestination.home &&
(location == '/login' || location == '/unlock')) {
return '/'; return '/';
} }
return null; return null;

View file

@ -0,0 +1,18 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'connectivity_service.dart';
class ConnectivityPlusService implements ConnectivityService {
ConnectivityPlusService(this._connectivity);
final Connectivity _connectivity;
@override
Future<bool> isOnline() async {
final result = await _connectivity.checkConnectivity();
if (result.isEmpty) {
return false;
}
return !result.contains(ConnectivityResult.none);
}
}

View file

@ -0,0 +1,3 @@
abstract class ConnectivityService {
Future<bool> isOnline();
}

View file

@ -0,0 +1,23 @@
import 'dart:convert';
String? extractSubjectFromJwt(String token) {
final parts = token.split('.');
if (parts.length < 2) {
return null;
}
final payload = parts[1];
try {
final normalized = base64Url.normalize(payload);
final decoded = utf8.decode(base64Url.decode(normalized));
final data = jsonDecode(decoded);
if (data is Map<String, dynamic>) {
final sub = data['sub'];
if (sub is String && sub.isNotEmpty) {
return sub;
}
}
} catch (_) {
return null;
}
return null;
}

View file

@ -1,17 +1,34 @@
import 'package:connectivity_plus/connectivity_plus.dart';
import 'package:dio/dio.dart'; import 'package:dio/dio.dart';
import 'package:flutter_appauth/flutter_appauth.dart'; import 'package:flutter_appauth/flutter_appauth.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'package:local_auth/local_auth.dart';
import '../core/config/app_config.dart'; import '../core/config/app_config.dart';
import '../core/network/api_client.dart'; import '../core/network/api_client.dart';
import '../core/network/connectivity_plus_service.dart';
import '../core/network/connectivity_service.dart';
import '../core/network/http_client.dart'; import '../core/network/http_client.dart';
import '../features/app_gate/presentation/app_gate_controller.dart';
import '../features/app_gate/presentation/app_gate_state.dart';
import '../features/auth/data/auth_local_data_source.dart'; import '../features/auth/data/auth_local_data_source.dart';
import '../features/auth/data/auth_remote_data_source.dart'; import '../features/auth/data/auth_remote_data_source.dart';
import '../features/auth/data/auth_repository_impl.dart'; import '../features/auth/data/auth_repository_impl.dart';
import '../features/auth/domain/auth_repository.dart'; import '../features/auth/domain/auth_repository.dart';
import '../features/auth/presentation/auth_controller.dart'; import '../features/auth/presentation/auth_controller.dart';
import '../features/auth/presentation/auth_state.dart'; import '../features/auth/presentation/auth_state.dart';
import '../features/offline_lock/data/biometric_authenticator.dart';
import '../features/offline_lock/data/flutter_secure_storage_adapter.dart';
import '../features/offline_lock/data/local_auth_biometric_authenticator.dart';
import '../features/offline_lock/data/offline_lock_repository_impl.dart';
import '../features/offline_lock/data/pbkdf2_pin_hasher.dart';
import '../features/offline_lock/domain/offline_lock_repository.dart';
import '../features/offline_lock/domain/pin_hasher.dart';
import '../features/offline_lock/presentation/unlock_controller.dart';
import '../features/offline_lock/presentation/unlock_state.dart';
import '../features/session/data/session_repository_impl.dart';
import '../features/session/domain/session_repository.dart';
import '../features/telemetry/data/auth_token_provider.dart'; import '../features/telemetry/data/auth_token_provider.dart';
import '../features/telemetry/data/telemetry_remote_data_source.dart'; import '../features/telemetry/data/telemetry_remote_data_source.dart';
import '../features/telemetry/data/telemetry_service_impl.dart'; import '../features/telemetry/data/telemetry_service_impl.dart';
@ -28,6 +45,18 @@ final secureStorageProvider = Provider<FlutterSecureStorage>((ref) {
return const FlutterSecureStorage(); return const FlutterSecureStorage();
}); });
final connectivityProvider = Provider<Connectivity>((ref) {
return Connectivity();
});
final connectivityServiceProvider = Provider<ConnectivityService>((ref) {
return ConnectivityPlusService(ref.watch(connectivityProvider));
});
final localAuthProvider = Provider<LocalAuthentication>((ref) {
return LocalAuthentication();
});
final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) { final authLocalDataSourceProvider = Provider<AuthLocalDataSource>((ref) {
return AuthLocalDataSource(ref.watch(secureStorageProvider)); return AuthLocalDataSource(ref.watch(secureStorageProvider));
}); });
@ -118,10 +147,42 @@ final authRepositoryProvider = Provider<AuthRepository>((ref) {
); );
}); });
final sessionRepositoryProvider = Provider<SessionRepository>((ref) {
return SessionRepositoryImpl(ref.watch(authLocalDataSourceProvider));
});
final authControllerProvider = NotifierProvider<AuthController, AuthState>(() { final authControllerProvider = NotifierProvider<AuthController, AuthState>(() {
return AuthController(); return AuthController();
}); });
final appGateControllerProvider = NotifierProvider<AppGateController, AppGateState>(() {
return AppGateController();
});
final offlineLockStorageProvider = Provider<FlutterSecureStorageAdapter>((ref) {
return FlutterSecureStorageAdapter(ref.watch(secureStorageProvider));
});
final pinHasherProvider = Provider<PinHasher>((ref) {
return Pbkdf2PinHasher();
});
final biometricAuthenticatorProvider = Provider<BiometricAuthenticator>((ref) {
return LocalAuthBiometricAuthenticator(ref.watch(localAuthProvider));
});
final offlineLockRepositoryProvider = Provider<OfflineLockRepository>((ref) {
return OfflineLockRepositoryImpl(
storage: ref.watch(offlineLockStorageProvider),
hasher: ref.watch(pinHasherProvider),
biometricAuthenticator: ref.watch(biometricAuthenticatorProvider),
);
});
final unlockControllerProvider = NotifierProvider<UnlockController, UnlockState>(() {
return UnlockController();
});
final httpClientProvider = Provider<HttpClient>((ref) { final httpClientProvider = Provider<HttpClient>((ref) {
return ApiHttpClient(ref.watch(apiClientProvider)); return ApiHttpClient(ref.watch(apiClientProvider));
}); });

View file

@ -0,0 +1,98 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../di/providers.dart';
import '../../../core/security/jwt_utils.dart';
import '../../session/domain/models/session_marker.dart';
import 'app_gate_state.dart';
class AppGateController extends Notifier<AppGateState> {
@override
AppGateState build() {
Future.microtask(_bootstrap);
return const AppGateState(isLoading: true);
}
Future<void> _bootstrap() async {
state = state.copyWith(isLoading: true, errorMessage: null);
final sessionRepository = ref.read(sessionRepositoryProvider);
final connectivityService = ref.read(connectivityServiceProvider);
final authRepository = ref.read(authRepositoryProvider);
final token = await sessionRepository.readToken();
var marker = await sessionRepository.readSessionMarker();
final hasSession = marker != null || token != null;
if (!hasSession) {
state = const AppGateState(isLoading: false, destination: AppGateDestination.login);
return;
}
if (marker == null && token != null) {
final userId = extractSubjectFromJwt(token.accessToken) ?? 'unknown';
marker = SessionMarker(userId: userId, lastOnlineAt: DateTime.now());
await sessionRepository.saveSessionMarker(marker);
}
final isOnline = await connectivityService.isOnline();
if (!isOnline) {
state = const AppGateState(
isLoading: false,
destination: AppGateDestination.unlock,
isOffline: true,
);
return;
}
final refreshToken = token?.refreshToken;
if (refreshToken != null && refreshToken.isNotEmpty) {
try {
final refreshed = await authRepository.refreshToken(refreshToken: refreshToken);
final userId =
extractSubjectFromJwt(refreshed.accessToken) ?? marker?.userId ?? 'unknown';
await sessionRepository.saveSessionMarker(
SessionMarker(userId: userId, lastOnlineAt: DateTime.now()),
);
} catch (error) {
state = AppGateState(
isLoading: false,
destination: AppGateDestination.login,
errorMessage: error.toString(),
);
return;
}
}
state = const AppGateState(
isLoading: false,
destination: AppGateDestination.home,
isOffline: false,
);
}
void setOfflineUnlocked() {
state = state.copyWith(
isLoading: false,
destination: AppGateDestination.home,
isOffline: true,
errorMessage: null,
);
}
void setOnlineAuthenticated() {
state = state.copyWith(
isLoading: false,
destination: AppGateDestination.home,
isOffline: false,
errorMessage: null,
);
}
void resetToLogin() {
state = state.copyWith(
isLoading: false,
destination: AppGateDestination.login,
isOffline: false,
errorMessage: null,
);
}
}

View file

@ -0,0 +1,29 @@
enum AppGateDestination { login, unlock, home }
class AppGateState {
const AppGateState({
this.isLoading = false,
this.destination = AppGateDestination.login,
this.isOffline = false,
this.errorMessage,
});
final bool isLoading;
final AppGateDestination destination;
final bool isOffline;
final String? errorMessage;
AppGateState copyWith({
bool? isLoading,
AppGateDestination? destination,
bool? isOffline,
String? errorMessage,
}) {
return AppGateState(
isLoading: isLoading ?? this.isLoading,
destination: destination ?? this.destination,
isOffline: isOffline ?? this.isOffline,
errorMessage: errorMessage,
);
}
}

View file

@ -1,6 +1,7 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import '../domain/models/auth_token.dart'; import '../domain/models/auth_token.dart';
import '../../session/domain/models/session_marker.dart';
class AuthLocalDataSource { class AuthLocalDataSource {
AuthLocalDataSource(this._storage); AuthLocalDataSource(this._storage);
@ -12,6 +13,8 @@ class AuthLocalDataSource {
static const _localEmailKey = 'local_email'; static const _localEmailKey = 'local_email';
static const _localRolesKey = 'local_roles'; static const _localRolesKey = 'local_roles';
static const _localTenantKey = 'local_tenant'; static const _localTenantKey = 'local_tenant';
static const _sessionUserIdKey = 'session_user_id';
static const _sessionLastOnlineKey = 'session_last_online_at';
Future<AuthToken?> readToken() async { Future<AuthToken?> readToken() async {
final accessToken = await _storage.read(key: _accessTokenKey); final accessToken = await _storage.read(key: _accessTokenKey);
@ -24,8 +27,11 @@ class AuthLocalDataSource {
Future<void> saveToken(AuthToken token) async { Future<void> saveToken(AuthToken token) async {
await _storage.write(key: _accessTokenKey, value: token.accessToken); await _storage.write(key: _accessTokenKey, value: token.accessToken);
if (token.refreshToken != null) { final refreshToken = token.refreshToken;
if (refreshToken != null && refreshToken.isNotEmpty) {
await _storage.write(key: _refreshTokenKey, value: token.refreshToken); await _storage.write(key: _refreshTokenKey, value: token.refreshToken);
} else {
await _storage.delete(key: _refreshTokenKey);
} }
} }
@ -66,8 +72,35 @@ class AuthLocalDataSource {
_storage.delete(key: _localEmailKey), _storage.delete(key: _localEmailKey),
_storage.delete(key: _localRolesKey), _storage.delete(key: _localRolesKey),
_storage.delete(key: _localTenantKey), _storage.delete(key: _localTenantKey),
_storage.delete(key: _sessionUserIdKey),
_storage.delete(key: _sessionLastOnlineKey),
]); ]);
} }
Future<void> saveSessionMarker(SessionMarker marker) async {
await _storage.write(key: _sessionUserIdKey, value: marker.userId);
await _storage.write(
key: _sessionLastOnlineKey,
value: marker.lastOnlineAt.toIso8601String(),
);
}
Future<SessionMarker?> readSessionMarker() async {
final results = await Future.wait([
_storage.read(key: _sessionUserIdKey),
_storage.read(key: _sessionLastOnlineKey),
]);
final userId = results[0];
final lastOnlineRaw = results[1];
if (userId == null || userId.isEmpty || lastOnlineRaw == null) {
return null;
}
final parsed = DateTime.tryParse(lastOnlineRaw);
if (parsed == null) {
return null;
}
return SessionMarker(userId: userId, lastOnlineAt: parsed);
}
} }
class LocalAuthSession { class LocalAuthSession {

View file

@ -19,6 +19,13 @@ class AuthRepositoryImpl implements AuthRepository {
return token; return token;
} }
@override
Future<AuthToken> refreshToken({required String refreshToken}) async {
final token = await _remote.refreshToken(refreshToken: refreshToken);
await _local.saveToken(token);
return token;
}
@override @override
Future<AuthToken?> getSavedToken() { Future<AuthToken?> getSavedToken() {
return _local.readToken(); return _local.readToken();

View file

@ -6,6 +6,8 @@ abstract class AuthRepository {
required String password, required String password,
}); });
Future<AuthToken> refreshToken({required String refreshToken});
Future<AuthToken?> getSavedToken(); Future<AuthToken?> getSavedToken();
Future<void> persistToken(AuthToken token); Future<void> persistToken(AuthToken token);

View file

@ -1,9 +1,13 @@
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../di/providers.dart'; import '../../../di/providers.dart';
import '../../../core/security/jwt_utils.dart';
import '../data/auth_local_data_source.dart'; import '../data/auth_local_data_source.dart';
import '../domain/auth_repository.dart'; import '../domain/auth_repository.dart';
import '../domain/models/auth_token.dart'; import '../domain/models/auth_token.dart';
import '../../session/domain/models/session_marker.dart';
import '../../session/domain/session_repository.dart';
import '../../app_gate/presentation/app_gate_controller.dart';
import 'auth_state.dart'; import 'auth_state.dart';
class AuthController extends Notifier<AuthState> { class AuthController extends Notifier<AuthState> {
@ -15,6 +19,9 @@ class AuthController extends Notifier<AuthState> {
AuthRepository get _repository => ref.watch(authRepositoryProvider); AuthRepository get _repository => ref.watch(authRepositoryProvider);
AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider); AuthLocalDataSource get _localDataSource => ref.watch(authLocalDataSourceProvider);
SessionRepository get _sessionRepository => ref.watch(sessionRepositoryProvider);
AppGateController get _appGateController =>
ref.read(appGateControllerProvider.notifier);
Future<void> _bootstrap() async { Future<void> _bootstrap() async {
state = state.copyWith(isLoading: true, errorMessage: null); state = state.copyWith(isLoading: true, errorMessage: null);
@ -59,11 +66,22 @@ class AuthController extends Notifier<AuthState> {
); );
const token = AuthToken(accessToken: 'local'); const token = AuthToken(accessToken: 'local');
await _repository.persistToken(token); await _repository.persistToken(token);
await _sessionRepository.saveSessionMarker(
SessionMarker(userId: email, lastOnlineAt: DateTime.now()),
);
state = const AuthState(isLoading: false, token: token); state = const AuthState(isLoading: false, token: token);
_appGateController.setOnlineAuthenticated();
return; return;
} }
final token = await _repository.login(email: email, password: password); final token = await _repository.login(email: email, password: password);
await _sessionRepository.saveSessionMarker(
SessionMarker(
userId: extractSubjectFromJwt(token.accessToken) ?? email,
lastOnlineAt: DateTime.now(),
),
);
state = AuthState(isLoading: false, token: token); state = AuthState(isLoading: false, token: token);
_appGateController.setOnlineAuthenticated();
} catch (error) { } catch (error) {
state = state.copyWith( state = state.copyWith(
isLoading: false, isLoading: false,
@ -75,6 +93,7 @@ class AuthController extends Notifier<AuthState> {
Future<void> logout() async { Future<void> logout() async {
await _repository.logout(); await _repository.logout();
state = const AuthState(); state = const AuthState();
_appGateController.resetToLogin();
} }
String _friendlyError(Object error) { String _friendlyError(Object error) {

View file

@ -36,6 +36,7 @@ class _HomePageState extends ConsumerState<HomePage> {
final l10n = AppLocalizations.of(context)!; final l10n = AppLocalizations.of(context)!;
final telemetryState = ref.watch(telemetryControllerProvider); final telemetryState = ref.watch(telemetryControllerProvider);
final appGateState = ref.watch(appGateControllerProvider);
return Scaffold( return Scaffold(
appBar: AppBar( appBar: AppBar(
@ -57,6 +58,46 @@ class _HomePageState extends ConsumerState<HomePage> {
l10n.signedInMessage, l10n.signedInMessage,
style: Theme.of(context).textTheme.titleMedium, style: Theme.of(context).textTheme.titleMedium,
), ),
if (appGateState.isOffline) ...[
const SizedBox(height: 16),
Card(
child: Padding(
padding: const EdgeInsets.all(16),
child: Column(
crossAxisAlignment: CrossAxisAlignment.start,
children: [
const Text(
'Offline mode',
style: TextStyle(fontWeight: FontWeight.w600),
),
const SizedBox(height: 8),
const Text(
'You can continue working. Sync when you are back online.',
),
const SizedBox(height: 12),
ElevatedButton(
onPressed: () async {
final isOnline =
await ref.read(connectivityServiceProvider).isOnline();
if (!mounted) return;
final messenger = ScaffoldMessenger.of(context);
messenger.showSnackBar(
SnackBar(
content: Text(
isOnline
? 'Sync is not implemented yet.'
: 'Still offline. Try again later.',
),
),
);
},
child: const Text('Sync now'),
),
],
),
),
),
],
const SizedBox(height: 24), const SizedBox(height: 24),
Text( Text(
'Developer tools', 'Developer tools',

View file

@ -0,0 +1,4 @@
abstract class BiometricAuthenticator {
Future<bool> isAvailable();
Future<bool> authenticate({required String reason});
}

View file

@ -0,0 +1,18 @@
import 'package:flutter_secure_storage/flutter_secure_storage.dart';
import 'key_value_storage.dart';
class FlutterSecureStorageAdapter implements KeyValueStorage {
FlutterSecureStorageAdapter(this._storage);
final FlutterSecureStorage _storage;
@override
Future<void> delete(String key) => _storage.delete(key: key);
@override
Future<String?> read(String key) => _storage.read(key: key);
@override
Future<void> write(String key, String value) => _storage.write(key: key, value: value);
}

View file

@ -0,0 +1,5 @@
abstract class KeyValueStorage {
Future<String?> read(String key);
Future<void> write(String key, String value);
Future<void> delete(String key);
}

View file

@ -0,0 +1,33 @@
import 'package:local_auth/local_auth.dart';
import 'biometric_authenticator.dart';
class LocalAuthBiometricAuthenticator implements BiometricAuthenticator {
LocalAuthBiometricAuthenticator(this._localAuth);
final LocalAuthentication _localAuth;
@override
Future<bool> authenticate({required String reason}) async {
try {
return _localAuth.authenticate(
localizedReason: reason,
biometricOnly: true,
persistAcrossBackgrounding: true,
);
} catch (_) {
return false;
}
}
@override
Future<bool> isAvailable() async {
try {
final supported = await _localAuth.isDeviceSupported();
if (!supported) return false;
return _localAuth.canCheckBiometrics;
} catch (_) {
return false;
}
}
}

View file

@ -0,0 +1,73 @@
import '../domain/offline_lock_repository.dart';
import '../domain/pin_hash.dart';
import '../domain/pin_hasher.dart';
import 'biometric_authenticator.dart';
import 'key_value_storage.dart';
class OfflineLockRepositoryImpl implements OfflineLockRepository {
OfflineLockRepositoryImpl({
required KeyValueStorage storage,
required PinHasher hasher,
required BiometricAuthenticator biometricAuthenticator,
}) : _storage = storage,
_hasher = hasher,
_biometricAuthenticator = biometricAuthenticator;
final KeyValueStorage _storage;
final PinHasher _hasher;
final BiometricAuthenticator _biometricAuthenticator;
static const _pinHashKey = 'offline_pin_hash';
static const _pinSaltKey = 'offline_pin_salt';
static const _pinIterationsKey = 'offline_pin_iterations';
@override
Future<bool> canUseBiometrics() => _biometricAuthenticator.isAvailable();
@override
Future<bool> biometricUnlock() {
return _biometricAuthenticator.authenticate(reason: 'Unlock to continue');
}
@override
Future<bool> hasPin() async {
final hash = await _storage.read(_pinHashKey);
final salt = await _storage.read(_pinSaltKey);
return hash != null && salt != null && hash.isNotEmpty && salt.isNotEmpty;
}
@override
Future<void> setPin(String pin) async {
final hashed = await _hasher.hash(pin);
await _storage.write(_pinHashKey, hashed.hashBase64);
await _storage.write(_pinSaltKey, hashed.saltBase64);
await _storage.write(_pinIterationsKey, hashed.iterations.toString());
}
@override
Future<bool> verifyPin(String pin) async {
final stored = await _readPinHash();
if (stored == null) {
return false;
}
return _hasher.verify(pin: pin, stored: stored);
}
Future<PinHash?> _readPinHash() async {
final hash = await _storage.read(_pinHashKey);
final salt = await _storage.read(_pinSaltKey);
final iterationsRaw = await _storage.read(_pinIterationsKey);
if (hash == null || salt == null || iterationsRaw == null) {
return null;
}
final iterations = int.tryParse(iterationsRaw);
if (iterations == null) {
return null;
}
return PinHash(
saltBase64: salt,
hashBase64: hash,
iterations: iterations,
);
}
}

View file

@ -0,0 +1,62 @@
import 'dart:convert';
import 'dart:math';
import 'package:cryptography/cryptography.dart';
import '../domain/pin_hash.dart';
import '../domain/pin_hasher.dart';
class Pbkdf2PinHasher implements PinHasher {
Pbkdf2PinHasher({
this.iterations = 100000,
this.saltLength = 16,
});
final int iterations;
final int saltLength;
final Random _random = Random.secure();
@override
Future<PinHash> hash(String pin) async {
final salt = List<int>.generate(saltLength, (_) => _random.nextInt(256));
final hash = await _deriveHash(pin, salt, iterations);
return PinHash(
saltBase64: base64UrlEncode(salt),
hashBase64: base64UrlEncode(hash),
iterations: iterations,
);
}
@override
Future<bool> verify({
required String pin,
required PinHash stored,
}) async {
final salt = base64Url.decode(stored.saltBase64);
final derived = await _deriveHash(pin, salt, stored.iterations);
final storedHash = base64Url.decode(stored.hashBase64);
return _constantTimeEquals(derived, storedHash);
}
Future<List<int>> _deriveHash(String pin, List<int> salt, int iterations) async {
final pbkdf2 = Pbkdf2(
macAlgorithm: Hmac.sha256(),
iterations: iterations,
bits: 256,
);
final key = await pbkdf2.deriveKey(
secretKey: SecretKey(utf8.encode(pin)),
nonce: salt,
);
return (await key.extractBytes());
}
bool _constantTimeEquals(List<int> a, List<int> b) {
if (a.length != b.length) return false;
var diff = 0;
for (var i = 0; i < a.length; i++) {
diff |= a[i] ^ b[i];
}
return diff == 0;
}
}

View file

@ -0,0 +1,8 @@
abstract class OfflineLockRepository {
Future<bool> canUseBiometrics();
Future<bool> biometricUnlock();
Future<bool> hasPin();
Future<void> setPin(String pin);
Future<bool> verifyPin(String pin);
}

View file

@ -0,0 +1,11 @@
class PinHash {
const PinHash({
required this.saltBase64,
required this.hashBase64,
required this.iterations,
});
final String saltBase64;
final String hashBase64;
final int iterations;
}

View file

@ -0,0 +1,9 @@
import 'pin_hash.dart';
abstract class PinHasher {
Future<PinHash> hash(String pin);
Future<bool> verify({
required String pin,
required PinHash stored,
});
}

View file

@ -0,0 +1,78 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../di/providers.dart';
import '../../app_gate/presentation/app_gate_controller.dart';
import '../domain/offline_lock_repository.dart';
import 'unlock_state.dart';
class UnlockController extends Notifier<UnlockState> {
@override
UnlockState build() {
Future.microtask(_bootstrap);
return const UnlockState(isLoading: true);
}
OfflineLockRepository get _repository => ref.read(offlineLockRepositoryProvider);
AppGateController get _appGateController =>
ref.read(appGateControllerProvider.notifier);
Future<void> _bootstrap() async {
final biometricsAvailable = await _repository.canUseBiometrics();
final hasPin = await _repository.hasPin();
if (!ref.mounted) return;
state = state.copyWith(
isLoading: false,
biometricsAvailable: biometricsAvailable,
hasPin: hasPin,
showPinEntry: !biometricsAvailable,
);
}
void showPinEntry() {
state = state.copyWith(showPinEntry: true, errorMessage: null);
}
Future<void> unlockWithBiometrics() async {
state = state.copyWith(isLoading: true, errorMessage: null);
final success = await _repository.biometricUnlock();
if (!ref.mounted) return;
if (success) {
state = state.copyWith(isLoading: false, isUnlocked: true);
_appGateController.setOfflineUnlocked();
return;
}
state = state.copyWith(isLoading: false, errorMessage: 'Biometric unlock failed');
}
Future<void> submitPin(String pin) async {
final normalized = pin.trim();
if (normalized.length < 4 || normalized.length > 6) {
state = state.copyWith(
isLoading: false,
errorMessage: 'PIN must be 4-6 digits',
);
return;
}
state = state.copyWith(isLoading: true, errorMessage: null);
final hasPin = await _repository.hasPin();
if (!ref.mounted) return;
if (!hasPin) {
await _repository.setPin(normalized);
if (!ref.mounted) return;
state = state.copyWith(isLoading: false, hasPin: true, isUnlocked: true);
_appGateController.setOfflineUnlocked();
return;
}
final success = await _repository.verifyPin(normalized);
if (!ref.mounted) return;
if (success) {
state = state.copyWith(isLoading: false, isUnlocked: true);
_appGateController.setOfflineUnlocked();
return;
}
state = state.copyWith(isLoading: false, errorMessage: 'Invalid PIN');
}
}

View file

@ -0,0 +1,114 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../../../di/providers.dart';
class UnlockScreen extends ConsumerStatefulWidget {
const UnlockScreen({super.key});
@override
ConsumerState<UnlockScreen> createState() => _UnlockScreenState();
}
class _UnlockScreenState extends ConsumerState<UnlockScreen> {
final TextEditingController _pinController = TextEditingController();
@override
void dispose() {
_pinController.dispose();
super.dispose();
}
@override
Widget build(BuildContext context) {
final state = ref.watch(unlockControllerProvider);
final controller = ref.read(unlockControllerProvider.notifier);
return Scaffold(
appBar: AppBar(title: const Text('Unlock')),
body: Center(
child: ConstrainedBox(
constraints: const BoxConstraints(maxWidth: 420),
child: Padding(
padding: const EdgeInsets.all(24),
child: Column(
mainAxisSize: MainAxisSize.min,
children: [
const Icon(Icons.lock, size: 64),
const SizedBox(height: 16),
const Text(
'Unlock to continue',
style: TextStyle(fontSize: 22, fontWeight: FontWeight.w600),
),
const SizedBox(height: 24),
if (state.biometricsAvailable && !state.showPinEntry) ...[
SizedBox(
width: double.infinity,
child: FilledButton.icon(
onPressed: state.isLoading ? null : controller.unlockWithBiometrics,
icon: const Icon(Icons.fingerprint),
label: state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: const Text('Use biometrics'),
),
),
const SizedBox(height: 12),
TextButton(
onPressed: state.isLoading ? null : controller.showPinEntry,
child: const Text('Use PIN instead'),
),
],
if (state.showPinEntry || !state.biometricsAvailable) ...[
TextField(
controller: _pinController,
keyboardType: TextInputType.number,
obscureText: true,
maxLength: 6,
decoration: InputDecoration(
labelText: state.hasPin ? 'Enter PIN' : 'Create a PIN',
counterText: '',
),
inputFormatters: [
FilteringTextInputFormatter.digitsOnly,
LengthLimitingTextInputFormatter(6),
],
),
SizedBox(
width: double.infinity,
child: FilledButton(
onPressed: state.isLoading
? null
: () {
controller.submitPin(_pinController.text);
},
child: state.isLoading
? const SizedBox(
width: 20,
height: 20,
child: CircularProgressIndicator(strokeWidth: 2),
)
: Text(state.hasPin ? 'Unlock' : 'Set PIN'),
),
),
],
if (state.errorMessage != null) ...[
const SizedBox(height: 12),
Text(
state.errorMessage!,
textAlign: TextAlign.center,
style: TextStyle(color: Theme.of(context).colorScheme.error),
),
],
],
),
),
),
),
);
}
}

View file

@ -0,0 +1,35 @@
class UnlockState {
const UnlockState({
this.isLoading = false,
this.isUnlocked = false,
this.errorMessage,
this.biometricsAvailable = false,
this.showPinEntry = false,
this.hasPin = false,
});
final bool isLoading;
final bool isUnlocked;
final String? errorMessage;
final bool biometricsAvailable;
final bool showPinEntry;
final bool hasPin;
UnlockState copyWith({
bool? isLoading,
bool? isUnlocked,
String? errorMessage,
bool? biometricsAvailable,
bool? showPinEntry,
bool? hasPin,
}) {
return UnlockState(
isLoading: isLoading ?? this.isLoading,
isUnlocked: isUnlocked ?? this.isUnlocked,
errorMessage: errorMessage,
biometricsAvailable: biometricsAvailable ?? this.biometricsAvailable,
showPinEntry: showPinEntry ?? this.showPinEntry,
hasPin: hasPin ?? this.hasPin,
);
}
}

View file

@ -0,0 +1,27 @@
import '../../auth/data/auth_local_data_source.dart';
import '../../auth/domain/models/auth_token.dart';
import '../domain/models/session_marker.dart';
import '../domain/session_repository.dart';
class SessionRepositoryImpl implements SessionRepository {
SessionRepositoryImpl(this._localDataSource);
final AuthLocalDataSource _localDataSource;
@override
Future<void> clear() => _localDataSource.clear();
@override
Future<SessionMarker?> readSessionMarker() => _localDataSource.readSessionMarker();
@override
Future<AuthToken?> readToken() => _localDataSource.readToken();
@override
Future<void> saveSessionMarker(SessionMarker marker) {
return _localDataSource.saveSessionMarker(marker);
}
@override
Future<void> saveToken(AuthToken token) => _localDataSource.saveToken(token);
}

View file

@ -0,0 +1,9 @@
class SessionMarker {
const SessionMarker({
required this.userId,
required this.lastOnlineAt,
});
final String userId;
final DateTime lastOnlineAt;
}

View file

@ -0,0 +1,11 @@
import '../../auth/domain/models/auth_token.dart';
import 'models/session_marker.dart';
abstract class SessionRepository {
Future<AuthToken?> readToken();
Future<void> saveToken(AuthToken token);
Future<void> clear();
Future<SessionMarker?> readSessionMarker();
Future<void> saveSessionMarker(SessionMarker marker);
}

View file

@ -5,12 +5,16 @@
import FlutterMacOS import FlutterMacOS
import Foundation import Foundation
import connectivity_plus
import flutter_appauth import flutter_appauth
import flutter_secure_storage_darwin import flutter_secure_storage_darwin
import local_auth_darwin
import path_provider_foundation import path_provider_foundation
func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) { func RegisterGeneratedPlugins(registry: FlutterPluginRegistry) {
ConnectivityPlusPlugin.register(with: registry.registrar(forPlugin: "ConnectivityPlusPlugin"))
FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin")) FlutterAppauthPlugin.register(with: registry.registrar(forPlugin: "FlutterAppauthPlugin"))
FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin")) FlutterSecureStorageDarwinPlugin.register(with: registry.registrar(forPlugin: "FlutterSecureStorageDarwinPlugin"))
LocalAuthPlugin.register(with: registry.registrar(forPlugin: "LocalAuthPlugin"))
PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin")) PathProviderPlugin.register(with: registry.registrar(forPlugin: "PathProviderPlugin"))
} }

View file

@ -36,12 +36,15 @@ dependencies:
# The following adds the Cupertino Icons font to your application. # The following adds the Cupertino Icons font to your application.
# Use with the CupertinoIcons class for iOS style icons. # Use with the CupertinoIcons class for iOS style icons.
cupertino_icons: ^1.0.8 cupertino_icons: ^1.0.8
connectivity_plus: ^7.0.0
cryptography: ^2.7.0
dio: ^5.7.0 dio: ^5.7.0
flutter_appauth: ^11.0.0 flutter_appauth: ^11.0.0
flutter_riverpod: ^3.1.0 flutter_riverpod: ^3.1.0
flutter_secure_storage: ^10.0.0 flutter_secure_storage: ^10.0.0
go_router: ^17.0.1 go_router: ^17.0.1
intl: ^0.20.2 intl: ^0.20.2
local_auth: ^3.0.0
dev_dependencies: dev_dependencies:
flutter_test: flutter_test:
@ -53,7 +56,7 @@ dev_dependencies:
# package. See that file for information about deactivating specific lint # package. See that file for information about deactivating specific lint
# rules and activating additional ones. # rules and activating additional ones.
flutter_lints: ^6.0.0 flutter_lints: ^6.0.0
flutter_launcher_icons: ^0.13.1 flutter_launcher_icons: ^0.14.4
# For information on the generic Dart part of this file, see the # For information on the generic Dart part of this file, see the
# following page: https://dart.dev/tools/pub/pubspec # following page: https://dart.dev/tools/pub/pubspec

View file

@ -0,0 +1,149 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mosenioring/src/core/network/connectivity_service.dart';
import 'package:mosenioring/src/di/providers.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
import 'package:mosenioring/src/features/session/domain/models/session_marker.dart';
import 'package:mosenioring/src/features/session/domain/session_repository.dart';
class FakeSessionRepository implements SessionRepository {
AuthToken? token;
SessionMarker? marker;
@override
Future<void> clear() async {
token = null;
marker = null;
}
@override
Future<SessionMarker?> readSessionMarker() async => marker;
@override
Future<AuthToken?> readToken() async => token;
@override
Future<void> saveSessionMarker(SessionMarker marker) async {
this.marker = marker;
}
@override
Future<void> saveToken(AuthToken token) async {
this.token = token;
}
}
class FakeConnectivityService implements ConnectivityService {
FakeConnectivityService(this._online);
bool _online;
set online(bool value) => _online = value;
@override
Future<bool> isOnline() async => _online;
}
class FakeAuthRepository implements AuthRepository {
AuthToken? token;
@override
Future<AuthToken?> getSavedToken() async => token;
@override
Future<AuthToken> login({required String email, required String password}) async {
return const AuthToken(accessToken: 'token');
}
@override
Future<void> logout() async {}
@override
Future<void> persistToken(AuthToken token) async {
this.token = token;
}
@override
Future<AuthToken> refreshToken({required String refreshToken}) async {
return const AuthToken(accessToken: 'new_token', refreshToken: 'refresh');
}
}
void main() {
test('no session routes to login', () async {
final sessionRepository = FakeSessionRepository();
final connectivityService = FakeConnectivityService(true);
final authRepository = FakeAuthRepository();
final container = ProviderContainer(
overrides: [
sessionRepositoryProvider.overrideWithValue(sessionRepository),
connectivityServiceProvider.overrideWithValue(connectivityService),
authRepositoryProvider.overrideWithValue(authRepository),
],
);
addTearDown(container.dispose);
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration.zero);
if (!container.read(appGateControllerProvider).isLoading) break;
}
final state = container.read(appGateControllerProvider);
expect(state.destination, AppGateDestination.login);
});
test('session + offline routes to unlock', () async {
final sessionRepository = FakeSessionRepository()
..marker = SessionMarker(userId: 'user', lastOnlineAt: DateTime.now());
final connectivityService = FakeConnectivityService(false);
final authRepository = FakeAuthRepository();
final container = ProviderContainer(
overrides: [
sessionRepositoryProvider.overrideWithValue(sessionRepository),
connectivityServiceProvider.overrideWithValue(connectivityService),
authRepositoryProvider.overrideWithValue(authRepository),
],
);
addTearDown(container.dispose);
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration.zero);
if (!container.read(appGateControllerProvider).isLoading) break;
}
final state = container.read(appGateControllerProvider);
expect(state.destination, AppGateDestination.unlock);
expect(state.isOffline, isTrue);
});
test('session + online routes to home', () async {
final sessionRepository = FakeSessionRepository()
..marker = SessionMarker(userId: 'user', lastOnlineAt: DateTime.now())
..token = const AuthToken(accessToken: 'token', refreshToken: 'refresh');
final connectivityService = FakeConnectivityService(true);
final authRepository = FakeAuthRepository();
final container = ProviderContainer(
overrides: [
sessionRepositoryProvider.overrideWithValue(sessionRepository),
connectivityServiceProvider.overrideWithValue(connectivityService),
authRepositoryProvider.overrideWithValue(authRepository),
],
);
addTearDown(container.dispose);
for (var i = 0; i < 5; i++) {
await Future.delayed(Duration.zero);
if (!container.read(appGateControllerProvider).isLoading) break;
}
final state = container.read(appGateControllerProvider);
expect(state.destination, AppGateDestination.home);
expect(state.isOffline, isFalse);
});
}

View file

@ -5,6 +5,10 @@ import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart'; import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
import 'package:mosenioring/src/features/auth/data/auth_local_data_source.dart'; import 'package:mosenioring/src/features/auth/data/auth_local_data_source.dart';
import 'package:mosenioring/src/core/config/app_config.dart'; import 'package:mosenioring/src/core/config/app_config.dart';
import 'package:mosenioring/src/features/session/domain/models/session_marker.dart';
import 'package:mosenioring/src/features/session/domain/session_repository.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
class MockAuthRepository implements AuthRepository { class MockAuthRepository implements AuthRepository {
AuthToken? token; AuthToken? token;
@ -20,6 +24,11 @@ class MockAuthRepository implements AuthRepository {
return const AuthToken(accessToken: 'new_token'); return const AuthToken(accessToken: 'new_token');
} }
@override
Future<AuthToken> refreshToken({required String refreshToken}) async {
return const AuthToken(accessToken: 'refreshed_token');
}
@override @override
Future<void> logout() async { Future<void> logout() async {
logoutCalled = true; logoutCalled = true;
@ -34,11 +43,13 @@ class MockAuthRepository implements AuthRepository {
class MockAuthLocalDataSource implements AuthLocalDataSource { class MockAuthLocalDataSource implements AuthLocalDataSource {
LocalAuthSession? session; LocalAuthSession? session;
AuthToken? token; AuthToken? token;
SessionMarker? marker;
@override @override
Future<void> clear() async { Future<void> clear() async {
session = null; session = null;
token = null; token = null;
marker = null;
} }
@override @override
@ -57,17 +68,61 @@ class MockAuthLocalDataSource implements AuthLocalDataSource {
this.token = token; this.token = token;
} }
@override
Future<SessionMarker?> readSessionMarker() async => marker;
@override
Future<void> saveSessionMarker(SessionMarker marker) async {
this.marker = marker;
}
@override @override
dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation); dynamic noSuchMethod(Invocation invocation) => super.noSuchMethod(invocation);
} }
class FakeSessionRepository implements SessionRepository {
AuthToken? token;
SessionMarker? marker;
@override
Future<void> clear() async {
token = null;
marker = null;
}
@override
Future<SessionMarker?> readSessionMarker() async => marker;
@override
Future<AuthToken?> readToken() async => token;
@override
Future<void> saveSessionMarker(SessionMarker marker) async {
this.marker = marker;
}
@override
Future<void> saveToken(AuthToken token) async {
this.token = token;
}
}
class FakeAppGateController extends AppGateController {
@override
AppGateState build() => const AppGateState();
}
void main() { void main() {
group('AuthController', () { group('AuthController', () {
late MockAuthRepository mockRepository; late MockAuthRepository mockRepository;
late FakeSessionRepository fakeSessionRepository;
late FakeAppGateController fakeAppGateController;
late AppConfig testConfig; late AppConfig testConfig;
setUp(() { setUp(() {
mockRepository = MockAuthRepository(); mockRepository = MockAuthRepository();
fakeSessionRepository = FakeSessionRepository();
fakeAppGateController = FakeAppGateController();
testConfig = const AppConfig( testConfig = const AppConfig(
apiBaseUrl: 'https://api.example.com', apiBaseUrl: 'https://api.example.com',
useLocalAuth: false, useLocalAuth: false,
@ -84,6 +139,10 @@ void main() {
overrides: [ overrides: [
authRepositoryProvider.overrideWithValue(mockRepository), authRepositoryProvider.overrideWithValue(mockRepository),
appConfigProvider.overrideWithValue(config ?? testConfig), appConfigProvider.overrideWithValue(config ?? testConfig),
sessionRepositoryProvider.overrideWithValue(fakeSessionRepository),
appGateControllerProvider.overrideWith(
() => fakeAppGateController,
),
], ],
); );
addTearDown(container.dispose); addTearDown(container.dispose);

View file

@ -0,0 +1,66 @@
import 'dart:convert';
import 'package:flutter_test/flutter_test.dart';
import 'package:mosenioring/src/features/offline_lock/data/biometric_authenticator.dart';
import 'package:mosenioring/src/features/offline_lock/data/offline_lock_repository_impl.dart';
import 'package:mosenioring/src/features/offline_lock/data/key_value_storage.dart';
import 'package:mosenioring/src/features/offline_lock/domain/pin_hash.dart';
import 'package:mosenioring/src/features/offline_lock/domain/pin_hasher.dart';
class InMemoryStorage implements KeyValueStorage {
final Map<String, String> _data = {};
@override
Future<void> delete(String key) async {
_data.remove(key);
}
@override
Future<String?> read(String key) async => _data[key];
@override
Future<void> write(String key, String value) async {
_data[key] = value;
}
}
class FakeBiometricAuthenticator implements BiometricAuthenticator {
@override
Future<bool> authenticate({required String reason}) async => true;
@override
Future<bool> isAvailable() async => false;
}
class DeterministicPinHasher implements PinHasher {
@override
Future<PinHash> hash(String pin) async {
return PinHash(
saltBase64: base64UrlEncode(utf8.encode('salt')),
hashBase64: base64UrlEncode(utf8.encode('hash:$pin')),
iterations: 1,
);
}
@override
Future<bool> verify({required String pin, required PinHash stored}) async {
final expected = base64UrlEncode(utf8.encode('hash:$pin'));
return stored.hashBase64 == expected;
}
}
void main() {
test('verifyPin returns true for correct pin and false for incorrect pin', () async {
final storage = InMemoryStorage();
final repository = OfflineLockRepositoryImpl(
storage: storage,
hasher: DeterministicPinHasher(),
biometricAuthenticator: FakeBiometricAuthenticator(),
);
await repository.setPin('1234');
expect(await repository.verifyPin('1234'), isTrue);
expect(await repository.verifyPin('9876'), isFalse);
});
}

View file

@ -0,0 +1,90 @@
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:flutter_test/flutter_test.dart';
import 'package:mosenioring/src/di/providers.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
import 'package:mosenioring/src/features/offline_lock/domain/offline_lock_repository.dart';
import 'package:mosenioring/src/features/offline_lock/presentation/unlock_state.dart';
class FakeOfflineLockRepository implements OfflineLockRepository {
bool biometricsAvailable = false;
bool pinSet = true;
bool biometricResult = true;
bool verifyResult = true;
@override
Future<bool> biometricUnlock() async => biometricResult;
@override
Future<bool> canUseBiometrics() async => biometricsAvailable;
@override
Future<bool> hasPin() async => pinSet;
@override
Future<void> setPin(String pin) async {
pinSet = true;
}
@override
Future<bool> verifyPin(String pin) async => verifyResult;
}
class FakeAppGateController extends AppGateController {
@override
AppGateState build() => const AppGateState();
}
void main() {
test('submitPin transitions idle -> loading -> success', () async {
final repository = FakeOfflineLockRepository()
..biometricsAvailable = false
..pinSet = true
..verifyResult = true;
final appGateController = FakeAppGateController();
final states = <UnlockState>[];
final container = ProviderContainer(
overrides: [
offlineLockRepositoryProvider.overrideWithValue(repository),
appGateControllerProvider.overrideWith(() => appGateController),
],
);
addTearDown(container.dispose);
container.listen(unlockControllerProvider, (_, next) => states.add(next));
await Future.delayed(Duration.zero);
await container.read(unlockControllerProvider.notifier).submitPin('1234');
expect(states.any((state) => state.isLoading), isTrue);
expect(states.last.isUnlocked, isTrue);
expect(states.last.errorMessage, isNull);
});
test('submitPin transitions idle -> loading -> failure', () async {
final repository = FakeOfflineLockRepository()
..biometricsAvailable = false
..pinSet = true
..verifyResult = false;
final appGateController = FakeAppGateController();
final states = <UnlockState>[];
final container = ProviderContainer(
overrides: [
offlineLockRepositoryProvider.overrideWithValue(repository),
appGateControllerProvider.overrideWith(() => appGateController),
],
);
addTearDown(container.dispose);
container.listen(unlockControllerProvider, (_, next) => states.add(next));
await Future.delayed(Duration.zero);
await container.read(unlockControllerProvider.notifier).submitPin('1234');
expect(states.any((state) => state.isLoading), isTrue);
expect(states.last.isUnlocked, isFalse);
expect(states.last.errorMessage, 'Invalid PIN');
});
}

View file

@ -9,11 +9,85 @@ import 'package:flutter_test/flutter_test.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:mosenioring/src/app/app.dart'; import 'package:mosenioring/src/app/app.dart';
import 'package:mosenioring/src/core/config/app_config.dart';
import 'package:mosenioring/src/di/providers.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_controller.dart';
import 'package:mosenioring/src/features/app_gate/presentation/app_gate_state.dart';
import 'package:mosenioring/src/features/auth/domain/auth_repository.dart';
import 'package:mosenioring/src/features/auth/domain/models/auth_token.dart';
import 'package:mosenioring/src/features/session/domain/session_repository.dart';
import 'package:mosenioring/src/features/session/domain/models/session_marker.dart';
class FakeAuthRepository implements AuthRepository {
@override
Future<AuthToken?> getSavedToken() async => null;
@override
Future<AuthToken> login({required String email, required String password}) async {
return const AuthToken(accessToken: 'token');
}
@override
Future<void> logout() async {}
@override
Future<void> persistToken(AuthToken token) async {}
@override
Future<AuthToken> refreshToken({required String refreshToken}) async {
return const AuthToken(accessToken: 'token');
}
}
class FakeSessionRepository implements SessionRepository {
@override
Future<void> clear() async {}
@override
Future<SessionMarker?> readSessionMarker() async => null;
@override
Future<AuthToken?> readToken() async => null;
@override
Future<void> saveSessionMarker(SessionMarker marker) async {}
@override
Future<void> saveToken(AuthToken token) async {}
}
class FakeAppGateController extends AppGateController {
@override
AppGateState build() => const AppGateState(
isLoading: false,
destination: AppGateDestination.login,
);
}
void main() { void main() {
testWidgets('App smoke test', (WidgetTester tester) async { testWidgets('App smoke test', (WidgetTester tester) async {
// Build our app and trigger a frame. // Build our app and trigger a frame.
await tester.pumpWidget(const ProviderScope(child: App())); await tester.pumpWidget(
ProviderScope(
overrides: [
authRepositoryProvider.overrideWithValue(FakeAuthRepository()),
sessionRepositoryProvider.overrideWithValue(FakeSessionRepository()),
appConfigProvider.overrideWithValue(
const AppConfig(
apiBaseUrl: 'https://api.example.com',
useLocalAuth: false,
localTenantId: '123',
localRoles: 'USER',
keycloakIssuer: 'https://keycloak.com',
keycloakClientId: 'client',
keycloakRedirectUrl: 'app://callback',
),
),
appGateControllerProvider.overrideWith(() => FakeAppGateController()),
],
child: const App(),
),
);
await tester.pump(); // Start _bootstrap await tester.pump(); // Start _bootstrap
await tester.pump(const Duration(milliseconds: 100)); // Allow some time await tester.pump(const Duration(milliseconds: 100)); // Allow some time

View file

@ -6,9 +6,15 @@
#include "generated_plugin_registrant.h" #include "generated_plugin_registrant.h"
#include <connectivity_plus/connectivity_plus_windows_plugin.h>
#include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h> #include <flutter_secure_storage_windows/flutter_secure_storage_windows_plugin.h>
#include <local_auth_windows/local_auth_plugin.h>
void RegisterPlugins(flutter::PluginRegistry* registry) { void RegisterPlugins(flutter::PluginRegistry* registry) {
ConnectivityPlusWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("ConnectivityPlusWindowsPlugin"));
FlutterSecureStorageWindowsPluginRegisterWithRegistrar( FlutterSecureStorageWindowsPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin")); registry->GetRegistrarForPlugin("FlutterSecureStorageWindowsPlugin"));
LocalAuthPluginRegisterWithRegistrar(
registry->GetRegistrarForPlugin("LocalAuthPlugin"));
} }

View file

@ -3,7 +3,9 @@
# #
list(APPEND FLUTTER_PLUGIN_LIST list(APPEND FLUTTER_PLUGIN_LIST
connectivity_plus
flutter_secure_storage_windows flutter_secure_storage_windows
local_auth_windows
) )
list(APPEND FLUTTER_FFI_PLUGIN_LIST list(APPEND FLUTTER_FFI_PLUGIN_LIST