diff --git a/lib/app/view/app.dart b/lib/app/view/app.dart index 2f8e9f7ab..b723e54b9 100644 --- a/lib/app/view/app.dart +++ b/lib/app/view/app.dart @@ -1,4 +1,3 @@ - import 'dart:async'; import 'dart:io'; @@ -129,7 +128,8 @@ class _AppState extends State { Map get _getRoutes { return { - "/": (context) => Configuration.instance.hasConfiguredAccount() + "/": (context) => Configuration.instance.hasConfiguredAccount() || + Configuration.instance.hasOptedForOfflineMode() ? const HomePage() : const OnboardingPage(), }; diff --git a/lib/core/configuration.dart b/lib/core/configuration.dart index 9857e0b44..a39052e65 100644 --- a/lib/core/configuration.dart +++ b/lib/core/configuration.dart @@ -1,3 +1,4 @@ +import 'dart:async'; import 'dart:convert'; import 'dart:io' as io; import 'dart:typed_data'; @@ -29,15 +30,23 @@ class Configuration { ); static const emailKey = "email"; static const keyAttributesKey = "key_attributes"; - static const keyKey = "key"; + static const keyShouldShowLockScreen = "should_show_lock_screen"; static const lastTempFolderClearTimeKey = "last_temp_folder_clear_time"; + static const keyKey = "key"; static const secretKeyKey = "secret_key"; static const authSecretKeyKey = "auth_secret_key"; + static const offlineAuthSecretKey = "offline_auth_secret_key"; static const tokenKey = "token"; static const encryptedTokenKey = "encrypted_token"; static const userIDKey = "user_id"; static const hasMigratedSecureStorageKey = "has_migrated_secure_storage"; + static const hasOptedForOfflineModeKey = "has_opted_for_offline_mode"; + final List onlineSecureKeys = [ + keyKey, + secretKeyKey, + authSecretKeyKey + ]; final kTempFolderDeletionTimeBuffer = const Duration(days: 1).inMicroseconds; @@ -45,28 +54,20 @@ class Configuration { String? _cachedToken; late String _documentsDirectory; - String? _key; late SharedPreferences _preferences; + String? _key; String? _secretKey; String? _authSecretKey; + String? _offlineAuthKey; late FlutterSecureStorage _secureStorage; late String _tempDirectory; - late String _thumbnailCacheDirectory; - // 6th July 22: Remove this after 3 months. Hopefully, active users - // will migrate to newer version of the app, where shared media is stored - // on appSupport directory which OS won't clean up automatically - late String _sharedTempMediaDirectory; - - late String _sharedDocumentsMediaDirectory; String? _volatilePassword; final _secureStorageOptionsIOS = const IOSOptions( accessibility: KeychainAccessibility.first_unlock_this_device, ); - // const IOSOptions(accessibility: IOSAccessibility.first_unlock); - Future init() async { _preferences = await SharedPreferences.getInstance(); _secureStorage = const FlutterSecureStorage(); @@ -88,15 +89,27 @@ class Configuration { _logger.warning(e); } tempDirectory.createSync(recursive: true); - final tempDirectoryPath = (await getTemporaryDirectory()).path; - _thumbnailCacheDirectory = tempDirectoryPath + "/thumbnail-cache"; - io.Directory(_thumbnailCacheDirectory).createSync(recursive: true); - _sharedTempMediaDirectory = tempDirectoryPath + "/ente-shared-media"; - io.Directory(_sharedTempMediaDirectory).createSync(recursive: true); - _sharedDocumentsMediaDirectory = _documentsDirectory + "/ente-shared-media"; - io.Directory(_sharedDocumentsMediaDirectory).createSync(recursive: true); + await _initOnlineAccount(); + await _initOfflineAccount(); + } + + Future _initOfflineAccount() async { + _offlineAuthKey = await _secureStorage.read( + key: offlineAuthSecretKey, + iOptions: _secureStorageOptionsIOS, + ); + } + + Future _initOnlineAccount() async { if (!_preferences.containsKey(tokenKey)) { - await _secureStorage.deleteAll(iOptions: _secureStorageOptionsIOS); + for (final key in onlineSecureKeys) { + unawaited( + _secureStorage.delete( + key: key, + iOptions: _secureStorageOptionsIOS, + ), + ); + } } else { _key = await _secureStorage.read( key: keyKey, @@ -113,13 +126,17 @@ class Configuration { if (_key == null) { await logout(autoLogout: true); } - await _migrateSecurityStorageToFirstUnlock(); } } Future logout({bool autoLogout = false}) async { await _preferences.clear(); - await _secureStorage.deleteAll(iOptions: _secureStorageOptionsIOS); + for (String key in onlineSecureKeys) { + await _secureStorage.delete( + key: key, + iOptions: _secureStorageOptionsIOS, + ); + } await AuthenticatorDB.instance.clearTable(); _key = null; _cachedToken = null; @@ -179,8 +196,9 @@ class Configuration { return KeyGenResult(attributes, privateAttributes, loginKey); } - - Future> getAttributesForNewPassword(String password) async { + Future> getAttributesForNewPassword( + String password, + ) async { // Get master key final masterKey = getKey(); @@ -215,18 +233,16 @@ class Configuration { // SRP setup for existing users. Future decryptSecretsAndGetKeyEncKey( String password, - KeyAttributes attributes, - { + KeyAttributes attributes, { Uint8List? keyEncryptionKey, - } - ) async { + }) async { _logger.info('Start decryptAndSaveSecrets'); keyEncryptionKey ??= await CryptoUtil.deriveKey( - utf8.encode(password) as Uint8List, - Sodium.base642bin(attributes.kekSalt), - attributes.memLimit, - attributes.opsLimit, - ); + utf8.encode(password) as Uint8List, + Sodium.base642bin(attributes.kekSalt), + attributes.memLimit, + attributes.opsLimit, + ); _logger.info('user-key done'); Uint8List key; @@ -356,52 +372,31 @@ class Configuration { } } - Future setKey(String? key) async { + Future setKey(String key) async { _key = key; - if (key == null) { - await _secureStorage.delete( - key: keyKey, - iOptions: _secureStorageOptionsIOS, - ); - } else { - await _secureStorage.write( - key: keyKey, - value: key, - iOptions: _secureStorageOptionsIOS, - ); - } + await _secureStorage.write( + key: keyKey, + value: key, + iOptions: _secureStorageOptionsIOS, + ); } Future setSecretKey(String? secretKey) async { _secretKey = secretKey; - if (secretKey == null) { - await _secureStorage.delete( - key: secretKeyKey, - iOptions: _secureStorageOptionsIOS, - ); - } else { - await _secureStorage.write( - key: secretKeyKey, - value: secretKey, - iOptions: _secureStorageOptionsIOS, - ); - } + await _secureStorage.write( + key: secretKeyKey, + value: secretKey, + iOptions: _secureStorageOptionsIOS, + ); } Future setAuthSecretKey(String? authSecretKey) async { _authSecretKey = authSecretKey; - if (authSecretKey == null) { - await _secureStorage.delete( - key: authSecretKeyKey, - iOptions: _secureStorageOptionsIOS, - ); - } else { - await _secureStorage.write( - key: authSecretKeyKey, - value: authSecretKey, - iOptions: _secureStorageOptionsIOS, - ); - } + await _secureStorage.write( + key: authSecretKeyKey, + value: authSecretKey, + iOptions: _secureStorageOptionsIOS, + ); } Uint8List? getKey() { @@ -416,6 +411,10 @@ class Configuration { return _authSecretKey == null ? null : Sodium.base642bin(_authSecretKey!); } + Uint8List? getOfflineSecretKey() { + return _offlineAuthKey == null ? null : Sodium.base642bin(_offlineAuthKey!); + } + Uint8List getRecoveryKey() { final keyAttributes = getKeyAttributes()!; return CryptoUtil.decryptSync( @@ -430,22 +429,34 @@ class Configuration { return _tempDirectory; } - String getThumbnailCacheDirectory() { - return _thumbnailCacheDirectory; - } - - String getOldSharedMediaCacheDirectory() { - return _sharedTempMediaDirectory; - } - - String getSharedMediaDirectory() { - return _sharedDocumentsMediaDirectory; - } - bool hasConfiguredAccount() { return getToken() != null && _key != null; } + bool hasOptedForOfflineMode() { + return _preferences.getBool(hasOptedForOfflineModeKey) ?? false; + } + + Future optForOfflineMode() async { + if ((await _secureStorage.containsKey( + key: offlineAuthSecretKey, + iOptions: _secureStorageOptionsIOS, + ))) { + _offlineAuthKey = await _secureStorage.read( + key: offlineAuthSecretKey, + iOptions: _secureStorageOptionsIOS, + ); + } else { + _offlineAuthKey = Sodium.bin2base64(CryptoUtil.generateKey()); + await _secureStorage.write( + key: offlineAuthSecretKey, + value: _offlineAuthKey, + iOptions: _secureStorageOptionsIOS, + ); + } + await _preferences.setBool(hasOptedForOfflineModeKey, true); + } + bool shouldShowLockScreen() { if (_preferences.containsKey(keyShouldShowLockScreen)) { return _preferences.getBool(keyShouldShowLockScreen)!; @@ -465,27 +476,4 @@ class Configuration { String? getVolatilePassword() { return _volatilePassword; } - - Future _migrateSecurityStorageToFirstUnlock() async { - final hasMigratedSecureStorageToFirstUnlock = - _preferences.getBool(hasMigratedSecureStorageKey) ?? false; - if (!hasMigratedSecureStorageToFirstUnlock && - _key != null && - _secretKey != null) { - await _secureStorage.write( - key: keyKey, - value: _key, - iOptions: _secureStorageOptionsIOS, - ); - await _secureStorage.write( - key: secretKeyKey, - value: _secretKey, - iOptions: _secureStorageOptionsIOS, - ); - await _preferences.setBool( - hasMigratedSecureStorageKey, - true, - ); - } - } } diff --git a/lib/ente_theme_data.dart b/lib/ente_theme_data.dart index c9a29598f..bb2a994c4 100644 --- a/lib/ente_theme_data.dart +++ b/lib/ente_theme_data.dart @@ -370,7 +370,7 @@ extension CustomColorScheme on ColorScheme { ? const Color.fromRGBO(245, 245, 245, 1.0) : const Color.fromRGBO(30, 30, 30, 1.0); - Color get searchResultsCountTextColor => brightness == Brightness.light + Color get mutedTextColor => brightness == Brightness.light ? const Color.fromRGBO(80, 80, 80, 1) : const Color.fromRGBO(150, 150, 150, 1); diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 7a29ce467..6614a3221 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -5,7 +5,7 @@ "@counterAppBarTitle": { "description": "Text shown in the AppBar of the Counter Page" }, - "onBoardingBody": "Secure your 2FA codes", + "onBoardingBody": "Securely backup your 2FA codes", "onBoardingGetStarted": "Get Started", "setupFirstAccount": "Setup your first account", "importScanQrCode": "Scan a QR Code", @@ -320,5 +320,10 @@ "encrypted": "Encrypted", "plainText": "Plain text", "passwordToEncryptExport": "Password to encrypt export", - "export": "Export" + "export": "Export", + "useOffline": "Use without backups", + "signInToBackup": "Sign in to backup your codes", + "singIn": "Sign in", + "sigInBackupReminder": "Please export your codes to ensure that you have a backup you can restore from.", + "offlineModeWarning": "You have chosen to proceed without backups. Please take manual backups to make sure your codes are safe." } diff --git a/lib/onboarding/view/onboarding_page.dart b/lib/onboarding/view/onboarding_page.dart index 99726d39a..5d4e7e3e3 100644 --- a/lib/onboarding/view/onboarding_page.dart +++ b/lib/onboarding/view/onboarding_page.dart @@ -7,17 +7,23 @@ import 'package:ente_auth/ente_theme_data.dart'; import 'package:ente_auth/events/trigger_logout_event.dart'; import "package:ente_auth/l10n/l10n.dart"; import 'package:ente_auth/locale.dart'; +import 'package:ente_auth/theme/text_style.dart'; import 'package:ente_auth/ui/account/email_entry_page.dart'; import 'package:ente_auth/ui/account/login_page.dart'; import 'package:ente_auth/ui/account/logout_dialog.dart'; import 'package:ente_auth/ui/account/password_entry_page.dart'; import 'package:ente_auth/ui/account/password_reentry_page.dart'; import 'package:ente_auth/ui/common/gradient_button.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/models/button_result.dart'; import 'package:ente_auth/ui/home_page.dart'; import 'package:ente_auth/ui/settings/language_picker.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/navigation_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; import 'package:flutter/foundation.dart'; import "package:flutter/material.dart"; +import 'package:local_auth/local_auth.dart'; class OnboardingPage extends StatefulWidget { const OnboardingPage({Key? key}) : super(key: key); @@ -112,6 +118,9 @@ class _OnboardingPageState extends State { style: Theme.of(context).textTheme.titleLarge!.copyWith( color: Colors.white38, + // color: Theme.of(context) + // .colorScheme + // .mutedTextColor, ), ), ], @@ -128,7 +137,7 @@ class _OnboardingPageState extends State { const SizedBox(height: 4), Container( width: double.infinity, - padding: const EdgeInsets.fromLTRB(20, 12, 20, 28), + padding: const EdgeInsets.fromLTRB(20, 12, 20, 0), child: Hero( tag: "log_in", child: ElevatedButton( @@ -145,6 +154,23 @@ class _OnboardingPageState extends State { ), ), ), + const SizedBox(height: 4), + Container( + width: double.infinity, + padding: const EdgeInsets.only(top: 20, bottom: 20), + child: GestureDetector( + onTap: _optForOfflineMode, + child: Center( + child: Text( + l10n.useOffline, + style: body.copyWith( + color: + Theme.of(context).colorScheme.mutedTextColor, + ), + ), + ), + ), + ), ], ), ), @@ -155,6 +181,36 @@ class _OnboardingPageState extends State { ); } + Future _optForOfflineMode() async { + bool canCheckBio = await LocalAuthentication().canCheckBiometrics; + if(!canCheckBio) { + showToast(context, "Sorry, biometric authentication is not supported on this device."); + return; + } + final bool hasOptedBefore = Configuration.instance.hasOptedForOfflineMode(); + ButtonResult? result; + if(!hasOptedBefore) { + result = await showChoiceActionSheet( + context, + title: context.l10n.warning, + body: context.l10n.offlineModeWarning, + secondButtonLabel: context.l10n.cancel, + firstButtonLabel: context.l10n.ok, + ); + } + if (hasOptedBefore || result?.action == ButtonAction.first) { + await Configuration.instance.optForOfflineMode(); + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const HomePage(); + }, + ), + ); + } + + } + void _navigateToSignUpPage() { Widget page; if (Configuration.instance.getEncryptedToken() == null) { @@ -163,7 +219,9 @@ class _OnboardingPageState extends State { // No key if (Configuration.instance.getKeyAttributes() == null) { // Never had a key - page = const PasswordEntryPage(mode: PasswordEntryMode.set,); + page = const PasswordEntryPage( + mode: PasswordEntryMode.set, + ); } else if (Configuration.instance.getKey() == null) { // Yet to decrypt the key page = const PasswordReentryPage(); @@ -189,7 +247,9 @@ class _OnboardingPageState extends State { // No key if (Configuration.instance.getKeyAttributes() == null) { // Never had a key - page = const PasswordEntryPage(mode: PasswordEntryMode.set,); + page = const PasswordEntryPage( + mode: PasswordEntryMode.set, + ); } else if (Configuration.instance.getKey() == null) { // Yet to decrypt the key page = const PasswordReentryPage(); diff --git a/lib/services/authenticator_service.dart b/lib/services/authenticator_service.dart index 846638d3e..eb450d29a 100644 --- a/lib/services/authenticator_service.dart +++ b/lib/services/authenticator_service.dart @@ -15,18 +15,29 @@ import 'package:ente_auth/models/authenticator/auth_key.dart'; import 'package:ente_auth/models/authenticator/entity_result.dart'; import 'package:ente_auth/models/authenticator/local_auth_entity.dart'; import 'package:ente_auth/store/authenticator_db.dart'; +import 'package:ente_auth/store/offline_authenticator_db.dart'; import 'package:ente_auth/utils/crypto_util.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter_sodium/flutter_sodium.dart'; import 'package:logging/logging.dart'; import 'package:shared_preferences/shared_preferences.dart'; +enum AccountMode { + online, + offline, +} +extension on AccountMode { + bool get isOnline => this == AccountMode.online; + bool get isOffline => this == AccountMode.offline; +} + class AuthenticatorService { final _logger = Logger((AuthenticatorService).toString()); final _config = Configuration.instance; late SharedPreferences _prefs; late AuthenticatorGateway _gateway; late AuthenticatorDB _db; + late OfflineAuthenticatorDB _offlineDb; final String _lastEntitySyncTime = "lastEntitySyncTime"; AuthenticatorService._privateConstructor(); @@ -34,25 +45,34 @@ class AuthenticatorService { static final AuthenticatorService instance = AuthenticatorService._privateConstructor(); + AccountMode getAccountMode() { + return Configuration.instance.hasOptedForOfflineMode() && + !Configuration.instance.hasConfiguredAccount() + ? AccountMode.offline + : AccountMode.online; + } + Future init() async { _prefs = await SharedPreferences.getInstance(); _db = AuthenticatorDB.instance; + _offlineDb = OfflineAuthenticatorDB.instance; _gateway = AuthenticatorGateway(Network.instance.getDio(), _config); if (Configuration.instance.hasConfiguredAccount()) { - unawaited(sync()); + unawaited(onlineSync()); } Bus.instance.on().listen((event) { - unawaited(sync()); + unawaited(onlineSync()); }); } - Future> getEntities() async { - final List result = await _db.getAll(); + Future> getEntities(AccountMode mode) async { + final List result = + mode.isOnline ? await _db.getAll() : await _offlineDb.getAll(); final List entities = []; if (result.isEmpty) { return entities; } - final key = await getOrCreateAuthDataKey(); + final key = await getOrCreateAuthDataKey(mode); for (LocalAuthEntity e in result) { try { final decryptedValue = await CryptoUtil.decryptChaCha( @@ -75,17 +95,23 @@ class AuthenticatorService { return entities; } - Future addEntry(String plainText, bool shouldSync) async { - var key = await getOrCreateAuthDataKey(); + Future addEntry( + String plainText, + bool shouldSync, + AccountMode accountMode, + ) async { + var key = await getOrCreateAuthDataKey(accountMode); final encryptedKeyData = await CryptoUtil.encryptChaCha( utf8.encode(plainText) as Uint8List, key, ); String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!); String header = Sodium.bin2base64(encryptedKeyData.header!); - final insertedID = await _db.insert(encryptedData, header); + final insertedID = accountMode.isOnline + ? await _db.insert(encryptedData, header) + : await _offlineDb.insert(encryptedData, header); if (shouldSync) { - unawaited(sync()); + unawaited(onlineSync()); } return insertedID; } @@ -94,39 +120,53 @@ class AuthenticatorService { int generatedID, String plainText, bool shouldSync, + AccountMode accountMode, ) async { - var key = await getOrCreateAuthDataKey(); + var key = await getOrCreateAuthDataKey(accountMode); final encryptedKeyData = await CryptoUtil.encryptChaCha( utf8.encode(plainText) as Uint8List, key, ); String encryptedData = Sodium.bin2base64(encryptedKeyData.encryptedData!); String header = Sodium.bin2base64(encryptedKeyData.header!); - final int affectedRows = - await _db.updateEntry(generatedID, encryptedData, header); + final int affectedRows = accountMode.isOnline + ? await _db.updateEntry(generatedID, encryptedData, header) + : await _offlineDb.updateEntry(generatedID, encryptedData, header); assert( affectedRows == 1, "updateEntry should have updated exactly one row", ); if (shouldSync) { - unawaited(sync()); + unawaited(onlineSync()); } } - Future deleteEntry(int genID) async { - LocalAuthEntity? result = await _db.getEntryByID(genID); + Future deleteEntry(int genID, AccountMode accountMode) async { + LocalAuthEntity? result = accountMode.isOnline + ? await _db.getEntryByID(genID) + : await _offlineDb.getEntryByID(genID); if (result == null) { _logger.info("No entry found for given id"); return; } - if (result.id != null) { + if (result.id != null && accountMode.isOnline) { await _gateway.deleteEntity(result.id!); + } else { + debugPrint("Skipping delete since account mode is offline"); + } + if(accountMode.isOnline) { + await _db.deleteByIDs(generatedIDs: [genID]); + } else { + await _offlineDb.deleteByIDs(generatedIDs: [genID]); } - await _db.deleteByIDs(generatedIDs: [genID]); } - Future sync() async { + Future onlineSync() async { try { + if(getAccountMode().isOffline) { + debugPrint("Skipping sync since account mode is offline"); + return; + } _logger.info("Sync"); await _remoteToLocalSync(); _logger.info("remote fetch completed"); @@ -209,7 +249,10 @@ class AuthenticatorService { } } - Future getOrCreateAuthDataKey() async { + Future getOrCreateAuthDataKey(AccountMode mode) async { + if(mode.isOffline) { + return _config.getOfflineSecretKey()!; + } if (_config.getAuthSecretKey() != null) { return _config.getAuthSecretKey()!; } diff --git a/lib/store/code_store.dart b/lib/store/code_store.dart index 41585f542..fbc2d9d6f 100644 --- a/lib/store/code_store.dart +++ b/lib/store/code_store.dart @@ -1,11 +1,14 @@ import 'dart:convert'; +import 'dart:typed_data'; import 'package:collection/collection.dart'; +import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/core/event_bus.dart'; import 'package:ente_auth/events/codes_updated_event.dart'; import 'package:ente_auth/models/authenticator/entity_result.dart'; import 'package:ente_auth/models/code.dart'; import 'package:ente_auth/services/authenticator_service.dart'; +import 'package:ente_auth/store/offline_authenticator_db.dart'; import 'package:logging/logging.dart'; class CodeStore { @@ -20,9 +23,10 @@ class CodeStore { _authenticatorService = AuthenticatorService.instance; } - Future> getAllCodes() async { + Future> getAllCodes({AccountMode? accountMode}) async { + final mode = accountMode ?? _authenticatorService.getAccountMode(); final List entities = - await _authenticatorService.getEntities(); + await _authenticatorService.getEntities(mode); final List codes = []; for (final entity in entities) { final decodeJson = jsonDecode(entity.rawData); @@ -46,8 +50,10 @@ class CodeStore { Future addCode( Code code, { bool shouldSync = true, + AccountMode? accountMode, }) async { - final codes = await getAllCodes(); + final mode = accountMode ?? _authenticatorService.getAccountMode(); + final codes = await getAllCodes(accountMode: mode); bool isExistingCode = false; for (final existingCode in codes) { if (existingCode == code) { @@ -63,18 +69,53 @@ class CodeStore { code.generatedID!, jsonEncode(code.rawData), shouldSync, + mode, ); } else { code.generatedID = await _authenticatorService.addEntry( jsonEncode(code.rawData), shouldSync, + mode, ); } Bus.instance.fire(CodesUpdatedEvent()); } - Future removeCode(Code code) async { - await _authenticatorService.deleteEntry(code.generatedID!); + Future removeCode(Code code, {AccountMode? accountMode}) async { + final mode = accountMode ?? _authenticatorService.getAccountMode(); + await _authenticatorService.deleteEntry(code.generatedID!, mode); Bus.instance.fire(CodesUpdatedEvent()); } + + Future importOfflineCodes() async { + try { + Configuration config = Configuration.instance; + // Account isn't configured yet, so we can't import offline codes + if (!config.hasConfiguredAccount()) { + return; + } + // Never opted for offline mode, so we can't import offline codes + if (!config.hasOptedForOfflineMode()) { + return; + } + Uint8List? hasOfflineKey = config.getOfflineSecretKey(); + if (hasOfflineKey == null) { + // No offline key, so we can't import offline codes + return; + } + List offlineCodes = + await CodeStore.instance.getAllCodes(accountMode: AccountMode.offline); + for (Code eachCode in offlineCodes) { + await CodeStore.instance.addCode( + eachCode, + accountMode: AccountMode.online, + shouldSync: false, + ); + } + OfflineAuthenticatorDB.instance.clearTable(); + AuthenticatorService.instance.onlineSync().ignore(); + } catch (e, s) { + _logger.severe("error while importing offline codes", e, s); + } + } } diff --git a/lib/store/offline_authenticator_db.dart b/lib/store/offline_authenticator_db.dart new file mode 100644 index 000000000..569acd396 --- /dev/null +++ b/lib/store/offline_authenticator_db.dart @@ -0,0 +1,170 @@ +import 'dart:async'; +import 'dart:io'; + +import 'package:ente_auth/models/authenticator/auth_entity.dart'; +import 'package:ente_auth/models/authenticator/local_auth_entity.dart'; +import 'package:flutter/foundation.dart'; +import 'package:path/path.dart'; +import 'package:path_provider/path_provider.dart'; +import 'package:sqflite/sqflite.dart'; + +class OfflineAuthenticatorDB { + static const _databaseName = "ente.offline_authenticator.db"; + static const _databaseVersion = 1; + + static const entityTable = 'entities'; + + OfflineAuthenticatorDB._privateConstructor(); + static final OfflineAuthenticatorDB instance = OfflineAuthenticatorDB._privateConstructor(); + + static Future? _dbFuture; + + Future get database async { + _dbFuture ??= _initDatabase(); + return _dbFuture!; + } + + Future _initDatabase() async { + final Directory documentsDirectory = + await getApplicationDocumentsDirectory(); + final String path = join(documentsDirectory.path, _databaseName); + debugPrint(path); + return await openDatabase( + path, + version: _databaseVersion, + onCreate: _onCreate, + ); + } + + Future _onCreate(Database db, int version) async { + await db.execute( + ''' + CREATE TABLE $entityTable ( + _generatedID INTEGER PRIMARY KEY AUTOINCREMENT NOT NULL, + id TEXT, + encryptedData TEXT NOT NULL, + header TEXT NOT NULL, + createdAt INTEGER NOT NULL, + updatedAt INTEGER NOT NULL, + shouldSync INTEGER DEFAULT 0, + UNIQUE(id) + ); + ''', + ); + } + + Future insert(String encData, String header) async { + final db = await instance.database; + final int timeInMicroSeconds = DateTime.now().microsecondsSinceEpoch; + final insertedID = await db.insert( + entityTable, + { + "encryptedData": encData, + "header": header, + "shouldSync": 1, + "createdAt": timeInMicroSeconds, + "updatedAt": timeInMicroSeconds, + }, + ); + return insertedID; + } + + Future updateEntry( + int generatedID, + String encData, + String header, + ) async { + final db = await instance.database; + final int timeInMicroSeconds = DateTime.now().microsecondsSinceEpoch; + int affectedRows = await db.update( + entityTable, + { + "encryptedData": encData, + "header": header, + "shouldSync": 1, + "updatedAt": timeInMicroSeconds, + }, + where: '_generatedID = ?', + whereArgs: [generatedID], + ); + return affectedRows; + } + + Future insertOrReplace(List authEntities) async { + final db = await instance.database; + final batch = db.batch(); + for (AuthEntity authEntity in authEntities) { + final insertRow = authEntity.toMap(); + insertRow.remove('isDeleted'); + insertRow.putIfAbsent('shouldSync', () => 0); + batch.insert( + entityTable, + insertRow, + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + await batch.commit(noResult: true); + } + + Future updateLocalEntity(LocalAuthEntity localAuthEntity) async { + final db = await instance.database; + await db.update( + entityTable, + localAuthEntity.toMap(), + where: '_generatedID = ?', + whereArgs: [localAuthEntity.generatedID], + conflictAlgorithm: ConflictAlgorithm.replace, + ); + } + + Future getEntryByID(int genID) async { + final db = await instance.database; + final rows = await db + .query(entityTable, where: '_generatedID = ?', whereArgs: [genID]); + final listOfAuthEntities = _convertRows(rows); + if (listOfAuthEntities.isEmpty) { + return null; + } else { + return listOfAuthEntities.first; + } + } + + Future> getAll() async { + final db = await instance.database; + final rows = await db.rawQuery("SELECT * from $entityTable"); + return _convertRows(rows); + } + +// deleteByID will prefer generated id if both ids are passed during deletion + Future deleteByIDs({List? generatedIDs, List? ids}) async { + final db = await instance.database; + final batch = db.batch(); + const whereGenID = '_generatedID = ?'; + const whereID = 'id = ?'; + if (generatedIDs != null) { + for (int genId in generatedIDs) { + batch.delete(entityTable, where: whereGenID, whereArgs: [genId]); + } + } + if (ids != null) { + for (String id in ids) { + batch.delete(entityTable, where: whereID, whereArgs: [id]); + } + } + final result = await batch.commit(); + debugPrint("Done"); + } + + Future clearTable() async { + final db = await instance.database; + await db.delete(entityTable); + } + + List _convertRows(List> rows) { + final keys = []; + for (final row in rows) { + keys.add(LocalAuthEntity.fromMap(row)); + } + return keys; + } +} diff --git a/lib/ui/code_widget.dart b/lib/ui/code_widget.dart index e733b231c..393670f4a 100644 --- a/lib/ui/code_widget.dart +++ b/lib/ui/code_widget.dart @@ -1,6 +1,7 @@ import 'dart:async'; import 'package:clipboard/clipboard.dart'; +import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/ente_theme_data.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/code.dart'; @@ -31,6 +32,7 @@ class _CodeWidgetState extends State { final ValueNotifier _nextCode = ValueNotifier(""); final Logger logger = Logger("_CodeWidgetState"); bool _isInitialized = false; + late bool hasConfiguredAccount; @override void initState() { @@ -46,6 +48,7 @@ class _CodeWidgetState extends State { } } }); + hasConfiguredAccount = Configuration.instance.hasConfiguredAccount(); } @override @@ -174,9 +177,9 @@ class _CodeWidgetState extends State { Row( mainAxisAlignment: MainAxisAlignment.end, children: [ - widget.code.hasSynced != null && - widget.code.hasSynced! - ? Container() + (widget.code.hasSynced != null && + widget.code.hasSynced!) || !hasConfiguredAccount + ? const SizedBox.shrink() : const Icon( Icons.sync_disabled, size: 20, diff --git a/lib/ui/components/notification_warning_widget.dart b/lib/ui/components/notification_warning_widget.dart index dc15fd5d0..a799ffcd3 100644 --- a/lib/ui/components/notification_warning_widget.dart +++ b/lib/ui/components/notification_warning_widget.dart @@ -1,75 +1,120 @@ -import 'package:ente_auth/ente_theme_data.dart'; +import "package:ente_auth/ente_theme_data.dart"; import 'package:ente_auth/theme/colors.dart'; +import "package:ente_auth/theme/ente_theme.dart"; import 'package:ente_auth/theme/text_style.dart'; +import 'package:ente_auth/ui/components/buttons/icon_button_widget.dart'; import 'package:flutter/material.dart'; -class NotificationWarningWidget extends StatelessWidget { - final IconData warningIcon; +// CreateNotificationType enum +enum NotificationType { + warning, + banner, + notice, +} + +class NotificationWidget extends StatelessWidget { + final IconData startIcon; final IconData actionIcon; final String text; + final String? subText; final GestureTapCallback onTap; + final NotificationType type; - const NotificationWarningWidget({ + const NotificationWidget({ Key? key, - required this.warningIcon, + required this.startIcon, required this.actionIcon, required this.text, required this.onTap, + this.subText, + this.type = NotificationType.warning, }) : super(key: key); @override Widget build(BuildContext context) { + EnteColorScheme colorScheme = getEnteColorScheme(context); + EnteTextTheme textTheme = getEnteTextTheme(context); + TextStyle mainTextStyle = darkTextTheme.bodyBold; + TextStyle subTextStyle = darkTextTheme.miniMuted; + LinearGradient? backgroundGradient; + Color? backgroundColor; + EnteColorScheme strokeColorScheme = darkScheme; + List? boxShadow; + switch (type) { + case NotificationType.warning: + backgroundColor = warning500; + break; + case NotificationType.banner: + colorScheme = getEnteColorScheme(context); + textTheme = getEnteTextTheme(context); + backgroundColor = colorScheme.backgroundElevated2; + mainTextStyle = textTheme.bodyBold; + subTextStyle = textTheme.miniMuted; + strokeColorScheme = colorScheme; + boxShadow = [ + BoxShadow(color: Colors.black.withOpacity(0.25), blurRadius: 1), + ]; + break; + + case NotificationType.notice: + backgroundColor = colorScheme.backgroundElevated2; + mainTextStyle = textTheme.bodyBold; + subTextStyle = textTheme.miniMuted; + strokeColorScheme = colorScheme; + boxShadow = Theme.of(context).colorScheme.enteTheme.shadowMenu; + break; + } return Center( child: GestureDetector( onTap: onTap, - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 16.0, vertical: 12), - child: Container( - decoration: BoxDecoration( - borderRadius: const BorderRadius.all( - Radius.circular(8), - ), - boxShadow: Theme.of(context).colorScheme.enteTheme.shadowMenu, - color: warning500, + child: Container( + decoration: BoxDecoration( + borderRadius: const BorderRadius.all( + Radius.circular(8), ), - child: Padding( - padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 10), - child: Row( - mainAxisAlignment: MainAxisAlignment.spaceBetween, - children: [ - Icon( - warningIcon, - size: 36, - color: Colors.white, - ), - const SizedBox(width: 12), - Flexible( - child: Text( - text, - style: darkTextTheme.bodyBold, - textAlign: TextAlign.left, - ), - ), - const SizedBox(width: 12), - ClipOval( - child: Material( - color: fillFaintDark, - child: InkWell( - splashColor: Colors.red, // Splash color - onTap: onTap, - child: SizedBox( - width: 40, - height: 40, - child: Icon( - actionIcon, - color: Colors.white, - ), - ), + boxShadow: boxShadow, + color: backgroundColor, + gradient: backgroundGradient, + ), + child: Padding( + padding: const EdgeInsets.symmetric(horizontal: 12, vertical: 8), + child: Row( + mainAxisAlignment: MainAxisAlignment.spaceBetween, + children: [ + Icon( + startIcon, + size: 36, + color: strokeColorScheme.strokeBase, + ), + const SizedBox(width: 12), + Expanded( + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Text( + text, + style: mainTextStyle, + textAlign: TextAlign.left, ), - ), + subText != null + ? Text( + subText!, + style: subTextStyle, + ) + : const SizedBox.shrink(), + ], ), - ], - ), + ), + const SizedBox(width: 12), + IconButtonWidget( + icon: actionIcon, + iconButtonType: IconButtonType.rounded, + iconColor: strokeColorScheme.strokeBase, + defaultColor: strokeColorScheme.fillFaint, + pressedColor: strokeColorScheme.fillMuted, + onTap: onTap, + ), + ], ), ), ), diff --git a/lib/ui/home_page.dart b/lib/ui/home_page.dart index 3e9c64e9a..c6f3c73c9 100644 --- a/lib/ui/home_page.dart +++ b/lib/ui/home_page.dart @@ -65,6 +65,10 @@ class _HomePageState extends State { await autoLogoutAlert(context); }); _initDeepLinks(); + Future.delayed( + const Duration(seconds: 0), + () => CodeStore.instance.importOfflineCodes(), + ); } diff --git a/lib/ui/settings/data/data_section_widget.dart b/lib/ui/settings/data/data_section_widget.dart index a067bc024..bf494c99c 100644 --- a/lib/ui/settings/data/data_section_widget.dart +++ b/lib/ui/settings/data/data_section_widget.dart @@ -51,7 +51,7 @@ class DataSectionWidget extends StatelessWidget { trailingIcon: Icons.chevron_right_outlined, trailingIconIsMuted: true, onTap: () async { - handleExportClick(context); + await handleExportClick(context); }, ), sectionOptionSpacing, diff --git a/lib/ui/settings/data/import/aegis_import.dart b/lib/ui/settings/data/import/aegis_import.dart index 053f40f5a..5042d618f 100644 --- a/lib/ui/settings/data/import/aegis_import.dart +++ b/lib/ui/settings/data/import/aegis_import.dart @@ -156,7 +156,7 @@ Future _processAegisExportFile( for (final code in parsedCodes) { await CodeStore.instance.addCode(code, shouldSync: false); } - unawaited(AuthenticatorService.instance.sync()); + unawaited(AuthenticatorService.instance.onlineSync()); int count = parsedCodes.length; return count; } diff --git a/lib/ui/settings/data/import/encrypted_ente_import.dart b/lib/ui/settings/data/import/encrypted_ente_import.dart index 986d3f577..15f869206 100644 --- a/lib/ui/settings/data/import/encrypted_ente_import.dart +++ b/lib/ui/settings/data/import/encrypted_ente_import.dart @@ -93,7 +93,8 @@ Future _decryptExportData( derivedKey, Sodium.base642bin(enteAuthExport.encryptionNonce), ); - } catch (e) { + } catch (e,s) { + Logger("encryptedImport").warning('failed to decrypt',e,s); showToast(context, l10n.incorrectPasswordTitle); isPasswordIncorrect = true; } @@ -118,7 +119,7 @@ Future _decryptExportData( for (final code in parsedCodes) { await CodeStore.instance.addCode(code, shouldSync: false); } - unawaited(AuthenticatorService.instance.sync()); + unawaited(AuthenticatorService.instance.onlineSync()); importedCodeCount = parsedCodes.length; await progressDialog.hide(); } catch (e, s) { diff --git a/lib/ui/settings/data/import/google_auth_import.dart b/lib/ui/settings/data/import/google_auth_import.dart index 6c1604273..d883f365c 100644 --- a/lib/ui/settings/data/import/google_auth_import.dart +++ b/lib/ui/settings/data/import/google_auth_import.dart @@ -55,7 +55,7 @@ Future showGoogleAuthInstruction(BuildContext context) async { for (final code in codes) { await CodeStore.instance.addCode(code, shouldSync: false); } - unawaited(AuthenticatorService.instance.sync()); + unawaited(AuthenticatorService.instance.onlineSync()); importSuccessDialog(context, codes.length); } } diff --git a/lib/ui/settings/data/import/plain_text_import.dart b/lib/ui/settings/data/import/plain_text_import.dart index 3a0ff0b14..a8e64bb5f 100644 --- a/lib/ui/settings/data/import/plain_text_import.dart +++ b/lib/ui/settings/data/import/plain_text_import.dart @@ -121,7 +121,7 @@ Future _pickImportFile(BuildContext context) async { for (final code in parsedCodes) { await CodeStore.instance.addCode(code, shouldSync: false); } - unawaited(AuthenticatorService.instance.sync()); + unawaited(AuthenticatorService.instance.onlineSync()); await progressDialog.hide(); await importSuccessDialog(context, parsedCodes.length); } catch (e) { diff --git a/lib/ui/settings/data/import/raivo_plain_text_import.dart b/lib/ui/settings/data/import/raivo_plain_text_import.dart index ab5d01882..48fc74888 100644 --- a/lib/ui/settings/data/import/raivo_plain_text_import.dart +++ b/lib/ui/settings/data/import/raivo_plain_text_import.dart @@ -111,7 +111,7 @@ Future _processRaivoExportFile(BuildContext context,String path) async { for (final code in parsedCodes) { await CodeStore.instance.addCode(code, shouldSync: false); } - unawaited(AuthenticatorService.instance.sync()); + unawaited(AuthenticatorService.instance.onlineSync()); int count = parsedCodes.length; return count; } diff --git a/lib/ui/settings/security_section_widget.dart b/lib/ui/settings/security_section_widget.dart index 2ea3695f9..b228ef512 100644 --- a/lib/ui/settings/security_section_widget.dart +++ b/lib/ui/settings/security_section_widget.dart @@ -31,9 +31,11 @@ class SecuritySectionWidget extends StatefulWidget { class _SecuritySectionWidgetState extends State { final _config = Configuration.instance; + late bool _hasLoggedIn; @override void initState() { + _hasLoggedIn = _config.hasConfiguredAccount(); super.initState(); } @@ -53,49 +55,103 @@ class _SecuritySectionWidgetState extends State { } Widget _getSectionOptions(BuildContext context) { - final bool? canDisableMFA = UserService.instance.canDisableEmailMFA(); - if (canDisableMFA == null) { - // We don't know if the user can disable MFA yet, so we fetch the info - UserService.instance.getUserDetailsV2().ignore(); - } final l10n = context.l10n; final List children = []; - children.addAll([ - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.recoveryKey, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthentication( - context, - l10n.authToViewYourRecoveryKey, - ); - if (hasAuthenticated) { - String recoveryKey; - try { - recoveryKey = - Sodium.bin2hex(Configuration.instance.getRecoveryKey()); - } catch (e) { - showGenericErrorDialog(context: context); - return; - } - routeToPage( + if (_hasLoggedIn) { + final bool? canDisableMFA = UserService.instance.canDisableEmailMFA(); + if (canDisableMFA == null) { + // We don't know if the user can disable MFA yet, so we fetch the info + UserService.instance.getUserDetailsV2().ignore(); + } + children.addAll([ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.recoveryKey, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( context, - RecoveryKeyPage( - recoveryKey, - l10n.ok, - showAppBar: true, - onDone: () {}, - ), + l10n.authToViewYourRecoveryKey, ); - } - }, - ), + if (hasAuthenticated) { + String recoveryKey; + try { + recoveryKey = + Sodium.bin2hex(Configuration.instance.getRecoveryKey()); + } catch (e) { + showGenericErrorDialog(context: context); + return; + } + routeToPage( + context, + RecoveryKeyPage( + recoveryKey, + l10n.ok, + showAppBar: true, + onDone: () {}, + ), + ); + } + }, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.emailVerificationToggle, + ), + trailingWidget: ToggleSwitchWidget( + value: () => UserService.instance.hasEmailMFAEnabled(), + onChanged: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + l10n.authToChangeEmailVerificationSetting, + ); + final isEmailMFAEnabled = + UserService.instance.hasEmailMFAEnabled(); + if (hasAuthenticated) { + await updateEmailMFA(!isEmailMFAEnabled); + if (mounted) { + setState(() {}); + } + } + }, + ), + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: context.l10n.viewActiveSessions, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication( + context, + context.l10n.authToViewYourActiveSessions, + ); + if (hasAuthenticated) { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return const SessionsPage(); + }, + ), + ); + } + }, + ), + ]); + } else { + children.add(sectionOptionSpacing); + } + children.addAll([ MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: l10n.lockscreen, @@ -117,54 +173,6 @@ class _SecuritySectionWidgetState extends State { ), ), sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.emailVerificationToggle, - ), - trailingWidget: ToggleSwitchWidget( - value: () => UserService.instance.hasEmailMFAEnabled(), - onChanged: () async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthentication( - context, - l10n.authToChangeEmailVerificationSetting, - ); - final isEmailMFAEnabled = UserService.instance.hasEmailMFAEnabled(); - if (hasAuthenticated) { - await updateEmailMFA(!isEmailMFAEnabled); - if (mounted) { - setState(() {}); - } - } - }, - ), - ), - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: context.l10n.viewActiveSessions, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthentication( - context, - context.l10n.authToViewYourActiveSessions, - ); - if (hasAuthenticated) { - Navigator.of(context).push( - MaterialPageRoute( - builder: (BuildContext context) { - return const SessionsPage(); - }, - ), - ); - } - }, - ), - sectionOptionSpacing, ]); return Column( children: children, @@ -173,12 +181,19 @@ class _SecuritySectionWidgetState extends State { Future updateEmailMFA(bool isEnabled) async { try { - final UserDetails details = await UserService.instance.getUserDetailsV2(memoryCount: false); - if(details.profileData?.canDisableEmailMFA == false) { - await routeToPage(context, RequestPasswordVerificationPage(onPasswordVerified: (Uint8List keyEncryptionKey) async { - final Uint8List loginKey = await CryptoUtil.deriveLoginKey(keyEncryptionKey); - await UserService.instance.registerOrUpdateSrp(loginKey); - },),); + final UserDetails details = + await UserService.instance.getUserDetailsV2(memoryCount: false); + if (details.profileData?.canDisableEmailMFA == false) { + await routeToPage( + context, + RequestPasswordVerificationPage( + onPasswordVerified: (Uint8List keyEncryptionKey) async { + final Uint8List loginKey = + await CryptoUtil.deriveLoginKey(keyEncryptionKey); + await UserService.instance.registerOrUpdateSrp(loginKey); + }, + ), + ); } await UserService.instance.updateEmailMFA(isEnabled); } catch (e) { diff --git a/lib/ui/settings/support_dev_widget.dart b/lib/ui/settings/support_dev_widget.dart index a5be4da45..d0edd4e70 100644 --- a/lib/ui/settings/support_dev_widget.dart +++ b/lib/ui/settings/support_dev_widget.dart @@ -1,5 +1,6 @@ import 'dart:io'; +import 'package:ente_auth/core/configuration.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/models/subscription.dart'; import 'package:ente_auth/services/billing_service.dart'; @@ -18,13 +19,26 @@ class SupportDevWidget extends StatelessWidget { final l10n = context.l10n; // fetch - return FutureBuilder( - future: BillingService.instance.getSubscription(), - builder: (context, snapshot) { - if (snapshot.hasData) { - final subscription = snapshot.data; - if (subscription != null && subscription.productID == "free") { - return GestureDetector( + if (Configuration.instance.hasConfiguredAccount()) { + return FutureBuilder( + future: BillingService.instance.getSubscription(), + builder: (context, snapshot) { + if (snapshot.hasData) { + final subscription = snapshot.data; + if (subscription != null && subscription.productID == "free") { + return buildWidget(l10n, context); + } + } + return const SizedBox.shrink(); + }, + ); + } else { + return buildWidget(l10n, context); + } + } + + GestureDetector buildWidget(AppLocalizations l10n, BuildContext context) { + return GestureDetector( onTap: () { launchUrl(Uri.parse("https://ente.io")); }, @@ -35,6 +49,7 @@ class SupportDevWidget extends StatelessWidget { children: [ StyledText( text: l10n.supportDevs, + style: getEnteTextTheme(context).large, tags: { 'bold-green': StyledTextTag( style: TextStyle( @@ -45,23 +60,16 @@ class SupportDevWidget extends StatelessWidget { }, ), const Padding(padding: EdgeInsets.all(6)), - Platform.isAndroid - ? Text( + Text( l10n.supportDiscount, textAlign: TextAlign.center, style: const TextStyle( color: Colors.grey, ), ) - : const SizedBox.shrink(), ], ), ), ); - } - } - return const SizedBox.shrink(); - }, - ); } } diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 5bcb0b34b..ab5083da0 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -1,29 +1,42 @@ import 'dart:io'; +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/onboarding/view/onboarding_page.dart'; import 'package:ente_auth/services/user_service.dart'; import 'package:ente_auth/theme/colors.dart'; import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/models/button_result.dart'; +import 'package:ente_auth/ui/components/notification_warning_widget.dart'; import 'package:ente_auth/ui/settings/about_section_widget.dart'; import 'package:ente_auth/ui/settings/account_section_widget.dart'; import 'package:ente_auth/ui/settings/app_version_widget.dart'; import 'package:ente_auth/ui/settings/data/data_section_widget.dart'; +import 'package:ente_auth/ui/settings/data/export_widget.dart'; import 'package:ente_auth/ui/settings/security_section_widget.dart'; import 'package:ente_auth/ui/settings/social_section_widget.dart'; import 'package:ente_auth/ui/settings/support_dev_widget.dart'; import 'package:ente_auth/ui/settings/support_section_widget.dart'; import 'package:ente_auth/ui/settings/theme_switch_widget.dart'; import 'package:ente_auth/ui/settings/title_bar_widget.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/navigation_util.dart'; import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; + class SettingsPage extends StatelessWidget { final ValueNotifier emailNotifier; - const SettingsPage({Key? key, required this.emailNotifier}) : super(key: key); + SettingsPage({Key? key, required this.emailNotifier}) : super(key: key); @override Widget build(BuildContext context) { - UserService.instance.getUserDetailsV2().ignore(); + final _hasLoggedIn = Configuration.instance.hasConfiguredAccount(); + if (_hasLoggedIn) { + UserService.instance.getUserDetailsV2().ignore(); + } final enteColorScheme = getEnteColorScheme(context); return Scaffold( body: Container( @@ -34,34 +47,71 @@ class SettingsPage extends StatelessWidget { } Widget _getBody(BuildContext context, EnteColorScheme colorScheme) { + final _hasLoggedIn = Configuration.instance.hasConfiguredAccount(); final enteTextTheme = getEnteTextTheme(context); + const sectionSpacing = SizedBox(height: 8); final List contents = []; - contents.add( - Container( - padding: const EdgeInsets.symmetric(horizontal: 8), - child: Align( - alignment: Alignment.centerLeft, - child: AnimatedBuilder( - // [AnimatedBuilder] accepts any [Listenable] subtype. - animation: emailNotifier, - builder: (BuildContext context, Widget? child) { - return Text( - emailNotifier.value!, - style: enteTextTheme.body.copyWith( - color: colorScheme.textMuted, - overflow: TextOverflow.ellipsis, - ), - ); - }, + if (_hasLoggedIn) { + contents.add( + Container( + padding: const EdgeInsets.symmetric(horizontal: 8), + child: Align( + alignment: Alignment.centerLeft, + child: AnimatedBuilder( + // [AnimatedBuilder] accepts any [Listenable] subtype. + animation: emailNotifier, + builder: (BuildContext context, Widget? child) { + return Text( + emailNotifier.value!, + style: enteTextTheme.body.copyWith( + color: colorScheme.textMuted, + overflow: TextOverflow.ellipsis, + ), + ); + }, + ), ), ), - ), - ); - const sectionSpacing = SizedBox(height: 8); - contents.add(const SizedBox(height: 12)); + ); + contents.addAll([ + const SizedBox(height: 12), + AccountSectionWidget(), + sectionSpacing, + ]); + } else { + contents.addAll([ + NotificationWidget( + startIcon: Icons.account_box_outlined, + actionIcon: Icons.arrow_forward, + text: context.l10n.signInToBackup, + type: NotificationType.notice, + onTap: () async { + ButtonResult? result = await showChoiceActionSheet( + context, + title: context.l10n.warning, + body: context.l10n.sigInBackupReminder, + secondButtonLabel: context.l10n.singIn, + secondButtonAction: ButtonAction.second, + firstButtonLabel: context.l10n.exportCodes, + ); + if (result == null) return; + if (result.action == ButtonAction.first) { + await handleExportClick(context); + } else { + if (result.action == ButtonAction.second) { + await routeToPage( + context, + const OnboardingPage(), + ); + } + } + }, + ), + sectionSpacing, + sectionSpacing, + ]); + } contents.addAll([ - AccountSectionWidget(), - sectionSpacing, DataSectionWidget(), sectionSpacing, const SecuritySectionWidget(), diff --git a/macos/Podfile.lock b/macos/Podfile.lock index f73488907..678bce8da 100644 --- a/macos/Podfile.lock +++ b/macos/Podfile.lock @@ -88,14 +88,14 @@ SPEC CHECKSUMS: FlutterMacOS: 8f6f14fa908a6fb3fba0cd85dbd81ec4b251fb24 FMDB: 2ce00b547f966261cd18927a3ddb07cb6f3db82a package_info_plus_macos: f010621b07802a241d96d01876d6705f15e77c1c - path_provider_foundation: 37748e03f12783f9de2cb2c4eadfaa25fe6d4852 + path_provider_foundation: 29f094ae23ebbca9d3d0cec13889cd9060c0e943 Reachability: 33e18b67625424e47b6cde6d202dce689ad7af96 Sentry: 4c9babff9034785067c896fd580b1f7de44da020 - sentry_flutter: b10ae7a5ddcbc7f04648eeb2672b5747230172f1 + sentry_flutter: 1346a880b24c0240807b53b10cf50ddad40f504e share_plus_macos: 853ee48e7dce06b633998ca0735d482dd671ade4 - shared_preferences_foundation: 297b3ebca31b34ec92be11acd7fb0ba932c822ca + shared_preferences_foundation: 5b919d13b803cadd15ed2dc053125c68730e5126 sqflite: a5789cceda41d54d23f31d6de539d65bb14100ea - url_launcher_macos: c04e4fa86382d4f94f6b38f14625708be3ae52e2 + url_launcher_macos: d2691c7dd33ed713bf3544850a623080ec693d95 PODFILE CHECKSUM: 353c8bcc5d5b0994e508d035b5431cfe18c1dea7