Browse Source

Add no account/offline mode (#227)

Neeraj Gupta 1 year ago
parent
commit
18d098b310

+ 2 - 2
lib/app/view/app.dart

@@ -1,4 +1,3 @@
-
 import 'dart:async';
 import 'dart:io';
 
@@ -129,7 +128,8 @@ class _AppState extends State<App> {
 
   Map<String, WidgetBuilder> get _getRoutes {
     return {
-      "/": (context) => Configuration.instance.hasConfiguredAccount()
+      "/": (context) => Configuration.instance.hasConfiguredAccount() ||
+              Configuration.instance.hasOptedForOfflineMode()
           ? const HomePage()
           : const OnboardingPage(),
     };

+ 90 - 102
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<String> 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<void> 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<void> _initOfflineAccount() async {
+    _offlineAuthKey = await _secureStorage.read(
+      key: offlineAuthSecretKey,
+      iOptions: _secureStorageOptionsIOS,
+    );
+  }
+
+  Future<void> _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<void> 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<Tuple2<KeyAttributes, Uint8List>> getAttributesForNewPassword(String password) async {
+  Future<Tuple2<KeyAttributes, Uint8List>> getAttributesForNewPassword(
+    String password,
+  ) async {
     // Get master key
     final masterKey = getKey();
 
@@ -215,18 +233,16 @@ class Configuration {
   // SRP setup for existing users.
   Future<Uint8List> 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<void> setKey(String? key) async {
+  Future<void> 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<void> 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<void> 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,20 +429,32 @@ class Configuration {
     return _tempDirectory;
   }
 
-  String getThumbnailCacheDirectory() {
-    return _thumbnailCacheDirectory;
-  }
-
-  String getOldSharedMediaCacheDirectory() {
-    return _sharedTempMediaDirectory;
+  bool hasConfiguredAccount() {
+    return getToken() != null && _key != null;
   }
 
-  String getSharedMediaDirectory() {
-    return _sharedDocumentsMediaDirectory;
+  bool hasOptedForOfflineMode() {
+    return _preferences.getBool(hasOptedForOfflineModeKey) ?? false;
   }
 
-  bool hasConfiguredAccount() {
-    return getToken() != null && _key != null;
+  Future<void> 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() {
@@ -465,27 +476,4 @@ class Configuration {
   String? getVolatilePassword() {
     return _volatilePassword;
   }
-
-  Future<void> _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,
-      );
-    }
-  }
 }

+ 1 - 1
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);
 

+ 7 - 2
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."
 }

+ 63 - 3
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<OnboardingPage> {
                           style:
                               Theme.of(context).textTheme.titleLarge!.copyWith(
                                     color: Colors.white38,
+                                    // color: Theme.of(context)
+                                    //                            .colorScheme
+                                    //                            .mutedTextColor,
                                   ),
                         ),
                       ],
@@ -128,7 +137,7 @@ class _OnboardingPageState extends State<OnboardingPage> {
                     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<OnboardingPage> {
                         ),
                       ),
                     ),
+                    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<OnboardingPage> {
     );
   }
 
+  Future<void> _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<OnboardingPage> {
       // 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<OnboardingPage> {
       // 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();

+ 62 - 19
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<void> 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<SignedInEvent>().listen((event) {
-      unawaited(sync());
+      unawaited(onlineSync());
     });
   }
 
-  Future<List<EntityResult>> getEntities() async {
-    final List<LocalAuthEntity> result = await _db.getAll();
+  Future<List<EntityResult>> getEntities(AccountMode mode) async {
+    final List<LocalAuthEntity> result =
+        mode.isOnline ? await _db.getAll() : await _offlineDb.getAll();
     final List<EntityResult> 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<int> addEntry(String plainText, bool shouldSync) async {
-    var key = await getOrCreateAuthDataKey();
+  Future<int> 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<void> deleteEntry(int genID) async {
-    LocalAuthEntity? result = await _db.getEntryByID(genID);
+  Future<void> 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<void> sync() async {
+  Future<void> 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<Uint8List> getOrCreateAuthDataKey() async {
+  Future<Uint8List> getOrCreateAuthDataKey(AccountMode mode) async {
+    if(mode.isOffline) {
+      return _config.getOfflineSecretKey()!;
+    }
     if (_config.getAuthSecretKey() != null) {
       return _config.getAuthSecretKey()!;
     }

+ 46 - 5
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<List<Code>> getAllCodes() async {
+  Future<List<Code>> getAllCodes({AccountMode? accountMode}) async {
+    final mode = accountMode ?? _authenticatorService.getAccountMode();
     final List<EntityResult> entities =
-        await _authenticatorService.getEntities();
+        await _authenticatorService.getEntities(mode);
     final List<Code> codes = [];
     for (final entity in entities) {
       final decodeJson = jsonDecode(entity.rawData);
@@ -46,8 +50,10 @@ class CodeStore {
   Future<void> 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<void> removeCode(Code code) async {
-    await _authenticatorService.deleteEntry(code.generatedID!);
+  Future<void> removeCode(Code code, {AccountMode? accountMode}) async {
+    final mode = accountMode ?? _authenticatorService.getAccountMode();
+    await _authenticatorService.deleteEntry(code.generatedID!, mode);
     Bus.instance.fire(CodesUpdatedEvent());
   }
+
+  Future<void> 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<Code> 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);
+    }
+  }
 }

+ 170 - 0
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<Database>? _dbFuture;
+
+  Future<Database> get database async {
+    _dbFuture ??= _initDatabase();
+    return _dbFuture!;
+  }
+
+  Future<Database> _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<int> 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<int> 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<void> insertOrReplace(List<AuthEntity> 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<void> updateLocalEntity(LocalAuthEntity localAuthEntity) async {
+    final db = await instance.database;
+    await db.update(
+      entityTable,
+      localAuthEntity.toMap(),
+      where: '_generatedID = ?',
+      whereArgs: [localAuthEntity.generatedID],
+      conflictAlgorithm: ConflictAlgorithm.replace,
+    );
+  }
+
+  Future<LocalAuthEntity?> 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<List<LocalAuthEntity>> 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<void> deleteByIDs({List<int>? generatedIDs, List<String>? 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<void> clearTable() async {
+    final db = await instance.database;
+    await db.delete(entityTable);
+  }
+
+  List<LocalAuthEntity> _convertRows(List<Map<String, dynamic>> rows) {
+    final keys = <LocalAuthEntity>[];
+    for (final row in rows) {
+      keys.add(LocalAuthEntity.fromMap(row));
+    }
+    return keys;
+  }
+}

+ 6 - 3
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<CodeWidget> {
   final ValueNotifier<String> _nextCode = ValueNotifier<String>("");
   final Logger logger = Logger("_CodeWidgetState");
   bool _isInitialized = false;
+  late bool hasConfiguredAccount;
 
   @override
   void initState() {
@@ -46,6 +48,7 @@ class _CodeWidgetState extends State<CodeWidget> {
         }
       }
     });
+    hasConfiguredAccount = Configuration.instance.hasConfiguredAccount();
   }
 
   @override
@@ -174,9 +177,9 @@ class _CodeWidgetState extends State<CodeWidget> {
                             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,

+ 95 - 50
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>? 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,
+                ),
+              ],
             ),
           ),
         ),

+ 4 - 0
lib/ui/home_page.dart

@@ -65,6 +65,10 @@ class _HomePageState extends State<HomePage> {
       await autoLogoutAlert(context);
     });
     _initDeepLinks();
+    Future.delayed(
+      const Duration(seconds: 0),
+          () => CodeStore.instance.importOfflineCodes(),
+    );
 
   }
 

+ 1 - 1
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,

+ 1 - 1
lib/ui/settings/data/import/aegis_import.dart

@@ -156,7 +156,7 @@ Future<int?> _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;
 }

+ 3 - 2
lib/ui/settings/data/import/encrypted_ente_import.dart

@@ -93,7 +93,8 @@ Future<void> _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<void> _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) {

+ 1 - 1
lib/ui/settings/data/import/google_auth_import.dart

@@ -55,7 +55,7 @@ Future<void> 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);
     }
   }

+ 1 - 1
lib/ui/settings/data/import/plain_text_import.dart

@@ -121,7 +121,7 @@ Future<void> _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) {

+ 1 - 1
lib/ui/settings/data/import/raivo_plain_text_import.dart

@@ -111,7 +111,7 @@ Future<int?> _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;
 }

+ 107 - 92
lib/ui/settings/security_section_widget.dart

@@ -31,9 +31,11 @@ class SecuritySectionWidget extends StatefulWidget {
 
 class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
   final _config = Configuration.instance;
+  late bool _hasLoggedIn;
 
   @override
   void initState() {
+    _hasLoggedIn = _config.hasConfiguredAccount();
     super.initState();
   }
 
@@ -53,118 +55,124 @@ class _SecuritySectionWidgetState extends State<SecuritySectionWidget> {
   }
 
   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<Widget> 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,
             );
-          }
-        },
-      ),
-      MenuItemWidget(
-        captionedTextWidget: CaptionedTextWidget(
-          title: l10n.lockscreen,
+            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: () {},
+                ),
+              );
+            }
+          },
         ),
-        trailingWidget: ToggleSwitchWidget(
-          value: () => _config.shouldShowLockScreen(),
-          onChanged: () async {
+        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
-                .requestLocalAuthForLockScreen(
+                .requestLocalAuthentication(
               context,
-              !_config.shouldShowLockScreen(),
-              context.l10n.authToChangeLockscreenSetting,
-              context.l10n.lockScreenEnablePreSteps,
+              context.l10n.authToViewYourActiveSessions,
             );
             if (hasAuthenticated) {
-              setState(() {});
+              Navigator.of(context).push(
+                MaterialPageRoute(
+                  builder: (BuildContext context) {
+                    return const SessionsPage();
+                  },
+                ),
+              );
             }
           },
         ),
-      ),
-      sectionOptionSpacing,
+      ]);
+    } else {
+      children.add(sectionOptionSpacing);
+    }
+    children.addAll([
       MenuItemWidget(
         captionedTextWidget: CaptionedTextWidget(
-          title: l10n.emailVerificationToggle,
+          title: l10n.lockscreen,
         ),
         trailingWidget: ToggleSwitchWidget(
-          value: () => UserService.instance.hasEmailMFAEnabled(),
+          value: () => _config.shouldShowLockScreen(),
           onChanged: () async {
             final hasAuthenticated = await LocalAuthenticationService.instance
-                .requestLocalAuthentication(
+                .requestLocalAuthForLockScreen(
               context,
-              l10n.authToChangeEmailVerificationSetting,
+              !_config.shouldShowLockScreen(),
+              context.l10n.authToChangeLockscreenSetting,
+              context.l10n.lockScreenEnablePreSteps,
             );
-            final isEmailMFAEnabled = UserService.instance.hasEmailMFAEnabled();
             if (hasAuthenticated) {
-              await updateEmailMFA(!isEmailMFAEnabled);
-              if (mounted) {
-                setState(() {});
-              }
+              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<SecuritySectionWidget> {
 
   Future<void> 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) {

+ 23 - 15
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<Subscription>(
-      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<Subscription>(
+        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();
-      },
-    );
   }
 }

+ 75 - 25
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<String?> 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<Widget> 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(),

+ 4 - 4
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