import 'dart:async'; import 'dart:convert'; import 'package:crypto/crypto.dart'; import 'package:firebase_messaging/firebase_messaging.dart'; import 'package:flutter_secure_storage/flutter_secure_storage.dart'; import 'package:shared_preferences/shared_preferences.dart'; import '../push/push_secure_storage.dart'; class AccountData { static const _usernameField = 'username'; static const _passwordField = 'password'; // App passwords live in the push-shared (group-scoped) keystore so the iOS // Notification Service Extension can authenticate Nextcloud calls too. // The talk password authenticates the second (apptype=talk) push // registration — Nextcloud binds each push subscription to its session // token, so two registrations need two app passwords. static const _appPasswordField = 'nextcloud_app_password'; static const _appPasswordTalkField = 'nextcloud_app_password_talk'; static const FlutterSecureStorage _secureStorage = FlutterSecureStorage(); static final AccountData _instance = AccountData._construct(); Completer _populated = Completer(); factory AccountData() => _instance; AccountData._construct() { _migrateAndLoad(); } String? _username; String? _password; String? _appPassword; String? _appPasswordTalk; String getUsername() { if (_username == null) throw Exception('Username not initialized'); return _username!; } String getPassword() { if (_password == null) throw Exception('Password not initialized'); return _password!; } String getUserSecret() => sha512 .convert(utf8.encode('${getUsername()}:${getPassword()}')) .toString(); Future getDeviceId() async => sha512 .convert( utf8.encode( '${getUserSecret()}@${await FirebaseMessaging.instance.getToken()}', ), ) .toString(); Future setData(String username, String password) async { await _secureStorage.write(key: _usernameField, value: username); await _secureStorage.write(key: _passwordField, value: password); _username = username; _password = password; if (!_populated.isCompleted) _populated.complete(); } Future removeData() async { _populated = Completer(); _username = null; _password = null; _appPassword = null; _appPasswordTalk = null; await _secureStorage.delete(key: _usernameField); await _secureStorage.delete(key: _passwordField); await _clearAppPasswordStorage(); await _clearAppPasswordTalkStorage(); } /// Persists a freshly minted Nextcloud app password. After this every /// [getBasicAuthHeader] call authenticates with the app password instead of /// the real password. Future setAppPassword(String appPassword) async { _appPassword = appPassword; try { await pushSecureStorage.write(key: _appPasswordField, value: appPassword); } on Object { // Group-scoped keystore may be unavailable (e.g. iOS entitlement not yet // provisioned). Keeping it in memory still lets this session use it. } } Future clearAppPassword() async { _appPassword = null; await _clearAppPasswordStorage(); } bool hasAppPassword() => _appPassword != null && _appPassword!.isNotEmpty; /// Persists the app password backing the Talk push registration. Future setAppPasswordTalk(String appPassword) async { _appPasswordTalk = appPassword; try { await pushSecureStorage.write( key: _appPasswordTalkField, value: appPassword, ); } on Object { // Group-scoped keystore may be unavailable — in-memory still works for // this session, matching setAppPassword. } } Future clearAppPasswordTalk() async { _appPasswordTalk = null; await _clearAppPasswordTalkStorage(); } bool hasAppPasswordTalk() => _appPasswordTalk != null && _appPasswordTalk!.isNotEmpty; Future _clearAppPasswordStorage() async { try { await pushSecureStorage.delete(key: _appPasswordField); } on Object { // ignore — nothing stored or keystore unavailable } } Future _clearAppPasswordTalkStorage() async { try { await pushSecureStorage.delete(key: _appPasswordTalkField); } on Object { // ignore — nothing stored or keystore unavailable } } Future _migrateAndLoad() async { await _migrateFromLegacyStorage(); _username = await _secureStorage.read(key: _usernameField); _password = await _secureStorage.read(key: _passwordField); try { _appPassword = await pushSecureStorage.read(key: _appPasswordField); _appPasswordTalk = await pushSecureStorage.read( key: _appPasswordTalkField, ); } on Object { _appPassword = null; _appPasswordTalk = null; } if (!_populated.isCompleted) _populated.complete(); } // Move credentials from the old SharedPreferences plain-text storage into the // platform's secure keystore. Run once per install and clear the legacy keys. Future _migrateFromLegacyStorage() async { final prefs = await SharedPreferences.getInstance(); final legacyUsername = prefs.getString(_usernameField); final legacyPassword = prefs.getString(_passwordField); if (legacyUsername == null || legacyPassword == null) return; final hasSecure = (await _secureStorage.read(key: _usernameField)) != null; if (!hasSecure) { await _secureStorage.write(key: _usernameField, value: legacyUsername); await _secureStorage.write(key: _passwordField, value: legacyPassword); } await prefs.remove(_usernameField); await prefs.remove(_passwordField); } Future waitForPopulation() async { await _populated.future; return isPopulated(); } bool isPopulated() => _username != null && _password != null; /// Returns the value for an HTTP `Authorization` header using HTTP Basic. /// Prefer this over embedding credentials in URLs — error logs and crash /// reports often capture the URL but not headers. String getBasicAuthHeader() { if (!isPopulated()) { throw Exception( 'AccountData (e.g. username or password) is not initialized!', ); } // Prefer the scoped app password once available; it survives real-password // rotation and is what the push-v2 registration is bound to. final secret = _appPassword ?? _password; return 'Basic ${base64Encode(utf8.encode('$_username:$secret'))}'; } /// Basic-auth header using the Talk app password — authenticates the /// apptype=talk push registration (and its unregister). Throws when the /// talk password has not been minted yet; callers treat that as a failed /// talk registration and retry on the next start. String getTalkBasicAuthHeader() { if (!isPopulated()) { throw Exception( 'AccountData (e.g. username or password) is not initialized!', ); } if (!hasAppPasswordTalk()) { throw StateError('Talk app password not available yet'); } return 'Basic ${base64Encode(utf8.encode('$_username:$_appPasswordTalk'))}'; } /// Basic-auth header that always uses the real password. Needed exactly once, /// to mint the app password via `core/getapppassword` (an app password cannot /// mint another). String getRealPasswordBasicAuthHeader() { if (!isPopulated()) { throw Exception( 'AccountData (e.g. username or password) is not initialized!', ); } return 'Basic ${base64Encode(utf8.encode('$_username:$_password'))}'; } /// Convenience wrapper around [getBasicAuthHeader] returning a single-entry /// header map ready to merge into HTTP request headers. Map authHeaders() => {'Authorization': getBasicAuthHeader()}; }