diff --git a/lib/l10n/arb/app_en.arb b/lib/l10n/arb/app_en.arb index 0cd411f7f..196691d50 100644 --- a/lib/l10n/arb/app_en.arb +++ b/lib/l10n/arb/app_en.arb @@ -73,6 +73,11 @@ "changePassword": "Change password", "data" : "Data", "importCodes": "Import codes", + "importTypePlainText": "Plain text", + "importTypeEnteEncrypted": "ente Encrypted export", + "importFromApp": "Import codes from {appName}", + "importSelectJsonFile": "Select JSON file", + "importRaivoGuide": "Use the \"Export OTPs to Zip archive\" option in Raivo's Settings.\n\nExtract the zip file and import the JSON file.", "exportCodes": "Export codes", "importInstruction": "Please select a file that contains a list of your codes in the following format", "importCodeDelimiterInfo": "The codes can be separated by a comma or a new line", diff --git a/lib/models/export/ente.dart b/lib/models/export/ente.dart new file mode 100644 index 000000000..31cd08126 --- /dev/null +++ b/lib/models/export/ente.dart @@ -0,0 +1,66 @@ +/* +Version: 1.0 +KDF Algo: ARGON2ID +Decrypted Data Format: It contains code.rawData [1] separated by new line. +[1] otpauth://totp/provider.com:you@email.com?secret=YOUR_SECRET +*/ + +class EnteAuthExport { + final int version; + final KDFParams kdfParams; + final String encryptedData; + final String encryptionNonce; + + // Named constructor which can be used to specify each field individually + EnteAuthExport({ + required this.version, + required this.kdfParams, + required this.encryptedData, + required this.encryptionNonce, + }); + + // Convert EnteExport object to JSON + Map toJson() => { + 'version': version, + 'kdfParams': kdfParams.toJson(), + 'encryptedData': encryptedData, + 'encryptionNonce': encryptionNonce, + }; + + // Convert JSON to EnteExport object + static EnteAuthExport fromJson(Map json) => EnteAuthExport( + version: json['version'], + kdfParams: KDFParams.fromJson(json['kdfParams']), + encryptedData: json['encryptedData'], + encryptionNonce: json['encryptionNonce'], + ); +} + +// KDFParams is a class that holds the parameters for the KDF function. +// It is used to derive a key from a password. +class KDFParams { + final int memLimit; + final int opsLimit; + final String salt; + + // Named constructor which can be used to specify each field individually + KDFParams({ + required this.memLimit, + required this.opsLimit, + required this.salt, + }); + + // Convert KDFParams object to JSON + Map toJson() => { + 'memLimit': memLimit, + 'opsLimit': opsLimit, + 'salt': salt, + }; + + // Convert JSON to KDFParams object + static KDFParams fromJson(Map json) => KDFParams( + memLimit: json['memLimit'], + opsLimit: json['opsLimit'], + salt: json['salt'], + ); +} diff --git a/lib/ui/components/dialog_widget.dart b/lib/ui/components/dialog_widget.dart index 82c286531..02311fc53 100644 --- a/lib/ui/components/dialog_widget.dart +++ b/lib/ui/components/dialog_widget.dart @@ -73,19 +73,22 @@ class DialogWidget extends StatelessWidget { boxShadow: shadowFloatLight, borderRadius: const BorderRadius.all(Radius.circular(8)), ), - child: Padding( - padding: const EdgeInsets.all(16), - child: Column( - mainAxisSize: MainAxisSize.min, - children: [ - ContentContainer( - title: title, - body: body, - icon: icon, - ), - const SizedBox(height: 36), - Actions(buttons), - ], + child: Material( + color: Colors.transparent, + child: Padding( + padding: const EdgeInsets.all(16), + child: Column( + mainAxisSize: MainAxisSize.min, + children: [ + ContentContainer( + title: title, + body: body, + icon: icon, + ), + const SizedBox(height: 36), + Actions(buttons), + ], + ), ), ), ); diff --git a/lib/ui/settings/data/data_section_widget.dart b/lib/ui/settings/data/data_section_widget.dart new file mode 100644 index 000000000..1d3872f03 --- /dev/null +++ b/lib/ui/settings/data/data_section_widget.dart @@ -0,0 +1,64 @@ + +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/settings/common_settings.dart'; +import 'package:ente_auth/ui/settings/data/export_widget.dart'; +import 'package:ente_auth/ui/settings/data/import_page.dart'; +import 'package:ente_auth/utils/navigation_util.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class DataSectionWidget extends StatelessWidget { + final _logger = Logger("AccountSectionWidget"); + + DataSectionWidget({Key? key}) : super(key: key); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return ExpandableMenuItemWidget( + title: l10n.data, + selectionOptionsWidget: _getSectionOptions(context), + leadingIcon: Icons.key_outlined, + ); + } + + Column _getSectionOptions(BuildContext context) { + final l10n = context.l10n; + List children = []; + children.addAll([ + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.importCodes, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + routeToPage(context, ImportCodePage()); + // _showImportInstructionDialog(context); + }, + ), + sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.exportCodes, + ), + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + trailingIconIsMuted: true, + onTap: () async { + handleExportClick(context); + }, + ), + sectionOptionSpacing, + ]); + return Column( + children: children, + ); + } +} diff --git a/lib/ui/settings/data/export_widget.dart b/lib/ui/settings/data/export_widget.dart new file mode 100644 index 000000000..f07fccc14 --- /dev/null +++ b/lib/ui/settings/data/export_widget.dart @@ -0,0 +1,146 @@ +import 'dart:convert'; +import 'dart:io'; + +import 'package:ente_auth/core/configuration.dart'; +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/export/ente.dart'; +import 'package:ente_auth/services/local_authentication_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/dialog_widget.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/utils/crypto_util.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/foundation.dart'; +import 'package:flutter/material.dart'; +import 'package:flutter_sodium/flutter_sodium.dart'; +import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; + +Future handleExportClick(BuildContext context) async { + final result = await showDialogWidget( + context: context, + title: "Select export format", + body: "Encrypted exports will be protected by a password of your choice.", + buttons: [ + const ButtonWidget( + buttonType: ButtonType.primary, + labelText: "Encrypted", + isInAlert: true, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.first, + ), + const ButtonWidget( + buttonType: ButtonType.secondary, + labelText: "Plain text", + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.second, + ), + ], + ); + if (result?.action != null && result!.action != ButtonAction.cancel) { + if (result.action == ButtonAction.first) { + await _requestForEncryptionPassword(context); + } else { + await _showExportWarningDialog(context); + } + } +} + +Future _requestForEncryptionPassword(BuildContext context, + {String? password,}) async { + await showTextInputDialog( + context, + title: "Password to encrypt export", + submitButtonLabel: "Export", + hintText: "Enter password", + isPasswordInput: true, + alwaysShowSuccessState: false, + onSubmit: (String password) async { + if (password.isEmpty || password.length < 4) { + showToast(context, "Password must be at least 4 characters long."); + Future.delayed(const Duration(seconds: 0), () { + _requestForEncryptionPassword(context, password: password); + }); + return; + } + if (password.isNotEmpty) { + try { + final kekSalt = CryptoUtil.getSaltToDeriveKey(); + final derivedKeyResult = await CryptoUtil.deriveSensitiveKey( + utf8.encode(password) as Uint8List, + kekSalt, + ); + String exportPlainText = await _getAuthDataForExport(); + // Encrypt the key with this derived key + final encResult = await CryptoUtil.encryptChaCha( + utf8.encode(exportPlainText) as Uint8List, derivedKeyResult.key,); + final encContent = Sodium.bin2base64(encResult.encryptedData!); + final encNonce = Sodium.bin2base64(encResult.header!); + final EnteAuthExport data = EnteAuthExport( + version: 1, + encryptedData: encContent, + encryptionNonce: encNonce, + kdfParams: KDFParams( + memLimit: derivedKeyResult.memLimit, + opsLimit: derivedKeyResult.opsLimit, + salt: Sodium.bin2base64(kekSalt),), + ); + // get json value of data + _exportCodes(context, jsonEncode(data.toJson())); + } catch(e,s) { + Logger("ExportWidget").severe(e, s); + showToast(context, "Error while exporting codes."); + } + } + }, + ); +} + +Future _showExportWarningDialog(BuildContext context) async { + await showChoiceActionSheet( + context, + title: context.l10n.warning, + body: context.l10n.exportWarningDesc, + isCritical: true, + firstButtonOnTap: () async { + final data = await _getAuthDataForExport(); + await _exportCodes(context, data); + }, + secondButtonLabel: context.l10n.cancel, + firstButtonLabel: context.l10n.iUnderStand, + ); +} + +Future _exportCodes(BuildContext context, String fileContent) async { + final _codeFile = File( + Configuration.instance.getTempDirectory() + "ente-authenticator-codes.txt", + ); + final hasAuthenticated = await LocalAuthenticationService.instance + .requestLocalAuthentication(context, context.l10n.authToExportCodes); + if (!hasAuthenticated) { + return; + } + if (_codeFile.existsSync()) { + await _codeFile.delete(); + } + _codeFile.writeAsStringSync(fileContent); + await Share.shareFiles([_codeFile.path]); + Future.delayed(const Duration(seconds: 15), () async { + if (_codeFile.existsSync()) { + _codeFile.deleteSync(); + } + }); +} + +Future _getAuthDataForExport() async { + final codes = await CodeStore.instance.getAllCodes(); + String data = ""; + for (final code in codes) { + data += code.rawData + "\n"; + } + return data; +} diff --git a/lib/ui/settings/data/import/import_service.dart b/lib/ui/settings/data/import/import_service.dart new file mode 100644 index 000000000..53fede50e --- /dev/null +++ b/lib/ui/settings/data/import/import_service.dart @@ -0,0 +1,23 @@ + +import 'package:ente_auth/ui/settings/data/import/plain_text_import.dart'; +import 'package:ente_auth/ui/settings/data/import/ravio_plain_text_import.dart'; +import 'package:ente_auth/ui/settings/data/import_page.dart'; +import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/cupertino.dart'; + +class ImportService { + + static final ImportService _instance = ImportService._internal(); + factory ImportService() => _instance; + ImportService._internal(); + + Future initiateImport(BuildContext context,ImportType type) async { + if(type == ImportType.plainText) { + showImportInstructionDialog(context); + } else if(type == ImportType.ravio) { + showRaivoImportInstruction(context); + } else { + showToast(context, 'Coming soon!'); + } + } +} \ No newline at end of file diff --git a/lib/ui/settings/data/import/plain_text_import.dart b/lib/ui/settings/data/import/plain_text_import.dart new file mode 100644 index 000000000..2731df265 --- /dev/null +++ b/lib/ui/settings/data/import/plain_text_import.dart @@ -0,0 +1,148 @@ +import 'dart:async'; +import 'dart:io'; +import 'dart:ui'; + +import 'package:ente_auth/ente_theme_data.dart'; +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/authenticator_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/components/dialog_widget.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class PlainTextImport extends StatelessWidget { + const PlainTextImport({super.key}); + + @override + Widget build(BuildContext context) { + final l10n = context.l10n; + return Column( + children: [ + Text( + l10n.importInstruction, + ), + const SizedBox( + height: 20, + ), + Container( + color: Theme.of(context).colorScheme.gNavBackgroundColor, + child: Padding( + padding: const EdgeInsets.all(8), + child: Text( + "otpauth://totp/provider.com:you@email.com?secret=YOUR_SECRET", + style: TextStyle( + fontFeatures: const [FontFeature.tabularFigures()], + fontFamily: Platform.isIOS ? "Courier" : "monospace", + fontSize: 13, + ), + ), + ), + ), + const SizedBox( + height: 20, + ), + Text(l10n.importCodeDelimiterInfo), + ], + ); + } + +} + + +Future showImportInstructionDialog(BuildContext context) async { + final l10n = context.l10n; + final AlertDialog alert = AlertDialog( + shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), + title: Text( + l10n.importCodes, + style: Theme.of(context).textTheme.headline6, + ), + content: const SingleChildScrollView( + child: PlainTextImport(), + ), + actions: [ + TextButton( + child: Text( + l10n.cancel, + style: const TextStyle( + color: Colors.red, + ), + ), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + }, + ), + TextButton( + child: Text(l10n.selectFile), + onPressed: () { + Navigator.of(context, rootNavigator: true).pop('dialog'); + _pickImportFile(context); + }, + ), + ], + ); + + return showDialog( + context: context, + builder: (BuildContext context) { + return alert; + }, + barrierColor: Colors.black12, + ); +} + + +Future _pickImportFile(BuildContext context) async { + final l10n = context.l10n; + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if (result == null) { + return; + } + final progressDialog = createProgressDialog(context, l10n.pleaseWait); + await progressDialog.show(); + try { + File file = File(result.files.single.path!); + final codes = await file.readAsString(); + List splitCodes = codes.split(","); + if (splitCodes.length == 1) { + splitCodes = codes.split("\n"); + } + final parsedCodes = []; + for (final code in splitCodes) { + try { + parsedCodes.add(Code.fromRawData(code)); + } catch (e) { + Logger('PlainText').severe("Could not parse code", e); + } + } + for (final code in parsedCodes) { + await CodeStore.instance.addCode(code, shouldSync: false); + } + unawaited(AuthenticatorService.instance.sync()); + await progressDialog.hide(); + final DialogWidget dialog = choiceDialog( + title: context.l10n.importSuccessTitle, + body: context.l10n.importSuccessDesc(parsedCodes.length), + firstButtonLabel: l10n.ok, + firstButtonType: ButtonType.primary, + ); + await showConfettiDialog( + context: context, + dialogBuilder: (BuildContext context) { + return dialog; + }, + ); + } catch (e) { + await progressDialog.hide(); + await showErrorDialog( + context, + context.l10n.sorry, + context.l10n.importFailureDesc, + ); + } +} diff --git a/lib/ui/settings/data/import/ravio_plain_text_import.dart b/lib/ui/settings/data/import/ravio_plain_text_import.dart new file mode 100644 index 000000000..f425c89e0 --- /dev/null +++ b/lib/ui/settings/data/import/ravio_plain_text_import.dart @@ -0,0 +1,110 @@ +import 'dart:async'; +import 'dart:convert'; +import 'dart:io'; + +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/services/authenticator_service.dart'; +import 'package:ente_auth/store/code_store.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/dialog_widget.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:file_picker/file_picker.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +Future showRaivoImportInstruction(BuildContext context) async { + final l10n = context.l10n; + final result = await showDialogWidget( + context: context, + title: l10n.importFromApp("Raivo OTP"), + body: l10n.importRaivoGuide, + buttons: [ + ButtonWidget( + buttonType: ButtonType.primary, + labelText: l10n.importSelectJsonFile, + isInAlert: true, + buttonSize: ButtonSize.large, + buttonAction: ButtonAction.first, + ), + ButtonWidget( + buttonType: ButtonType.secondary, + labelText: context.l10n.cancel, + buttonSize: ButtonSize.large, + isInAlert: true, + buttonAction: ButtonAction.second, + ), + ], + ); + if (result?.action != null && result!.action != ButtonAction.cancel) { + if (result.action == ButtonAction.first) { + await _pickRaivoJsonFile(context); + } else {} + } +} + +Future _pickRaivoJsonFile(BuildContext context) async { + final l10n = context.l10n; + FilePickerResult? result = await FilePicker.platform.pickFiles(); + if (result == null) { + return; + } + final progressDialog = createProgressDialog(context, l10n.pleaseWait); + await progressDialog.show(); + try { + File file = File(result.files.single.path!); + final jsonString = await file.readAsString(); + List jsonArray = jsonDecode(jsonString); + final parsedCodes = []; + for (var item in jsonArray) { + var kind = item['kind']; + var algorithm = item['algorithm']; + var timer = item['timer']; + var digits = item['digits']; + var issuer = item['issuer']; + var secret = item['secret']; + var account = item['account']; + var counter = item['counter']; + + // Build the OTP URL + String otpUrl; + + if (kind.toLowerCase() == 'totp') { + otpUrl = + 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&period=$timer'; + } else if (kind.toLowerCase() == 'hotp') { + otpUrl = + 'otpauth://$kind/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&counter=$counter'; + } else { + throw Exception('Invalid OTP type'); + } + parsedCodes.add(Code.fromRawData(otpUrl)); + } + + for (final code in parsedCodes) { + await CodeStore.instance.addCode(code, shouldSync: false); + } + unawaited(AuthenticatorService.instance.sync()); + await progressDialog.hide(); + final DialogWidget dialog = choiceDialog( + title: context.l10n.importSuccessTitle, + body: context.l10n.importSuccessDesc(parsedCodes.length), + firstButtonLabel: l10n.ok, + firstButtonType: ButtonType.primary, + ); + await showConfettiDialog( + context: context, + dialogBuilder: (BuildContext context) { + return dialog; + }, + ); + } catch (e) { + await progressDialog.hide(); + await showErrorDialog( + context, + context.l10n.sorry, + context.l10n.importFailureDesc, + ); + } +} diff --git a/lib/ui/settings/data/import_page.dart b/lib/ui/settings/data/import_page.dart new file mode 100644 index 000000000..0b28feb55 --- /dev/null +++ b/lib/ui/settings/data/import_page.dart @@ -0,0 +1,106 @@ +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:ente_auth/ui/components/buttons/icon_button_widget.dart'; +import 'package:ente_auth/ui/components/captioned_text_widget.dart'; +import 'package:ente_auth/ui/components/divider_widget.dart'; +import 'package:ente_auth/ui/components/menu_item_widget.dart'; +import 'package:ente_auth/ui/components/title_bar_title_widget.dart'; +import 'package:ente_auth/ui/components/title_bar_widget.dart'; +import 'package:ente_auth/ui/settings/data/import/import_service.dart'; +import 'package:flutter/cupertino.dart'; +import 'package:flutter/material.dart'; + +enum ImportType { + plainText, + encrypted, + ravio, +} + +class ImportCodePage extends StatelessWidget { + late List importOptions = [ + ImportType.plainText, + ImportType.encrypted, + ImportType.ravio, + ]; + + ImportCodePage({super.key}); + + String getTitle(BuildContext context, ImportType type) { + switch (type) { + case ImportType.plainText: + return context.l10n.importTypePlainText; + case ImportType.encrypted: + return context.l10n.importTypeEnteEncrypted; + case ImportType.ravio: + return 'Ravio OTP'; + } + } + + @override + Widget build(BuildContext context) { + return Scaffold( + body: CustomScrollView( + primary: false, + slivers: [ + TitleBarWidget( + flexibleSpaceTitle: TitleBarTitleWidget( + title: context.l10n.importCodes, + ), + flexibleSpaceCaption: "Import source", + actionIcons: [ + IconButtonWidget( + icon: Icons.close_outlined, + iconButtonType: IconButtonType.secondary, + onTap: () { + Navigator.pop(context); + Navigator.pop(context); + }, + ), + ], + ), + SliverList( + delegate: SliverChildBuilderDelegate( + (delegateBuildContext, index) { + final type = importOptions[index]; + return Padding( + padding: const EdgeInsets.symmetric(horizontal: 16.0), + child: Column( + children: [ + if (index == 0) + const SizedBox( + height: 24, + ), + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: getTitle(context, type), + ), + alignCaptionedTextToLeft: true, + menuItemColor: getEnteColorScheme(context).fillFaint, + pressedColor: getEnteColorScheme(context).fillFaint, + trailingIcon: Icons.chevron_right_outlined, + isBottomBorderRadiusRemoved: + index != importOptions.length - 1, + isTopBorderRadiusRemoved: index != 0, + onTap: () async { + ImportService().initiateImport(context, type); + // routeToPage(context, ImportCodePage()); + // _showImportInstructionDialog(context); + }, + ), + if (index != importOptions.length - 1) + DividerWidget( + dividerType: DividerType.menu, + bgColor: getEnteColorScheme(context).fillFaint, + ), + ], + ), + ); + }, + childCount: importOptions.length, + ), + ), + ], + ), + ); + } +} diff --git a/lib/ui/settings/data_section_widget.dart b/lib/ui/settings/data_section_widget.dart deleted file mode 100644 index 79053c55d..000000000 --- a/lib/ui/settings/data_section_widget.dart +++ /dev/null @@ -1,267 +0,0 @@ -import 'dart:async'; -import 'dart:io'; -import 'dart:ui'; - -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'; -import 'package:ente_auth/services/authenticator_service.dart'; -import 'package:ente_auth/services/local_authentication_service.dart'; -import 'package:ente_auth/store/code_store.dart'; -import 'package:ente_auth/theme/ente_theme.dart'; -import 'package:ente_auth/ui/components/captioned_text_widget.dart'; -import 'package:ente_auth/ui/components/dialog_widget.dart'; -import 'package:ente_auth/ui/components/expandable_menu_item_widget.dart'; -import 'package:ente_auth/ui/components/menu_item_widget.dart'; -import 'package:ente_auth/ui/components/models/button_type.dart'; -import 'package:ente_auth/ui/settings/common_settings.dart'; -import 'package:ente_auth/utils/dialog_util.dart'; -import 'package:file_picker/file_picker.dart'; -import 'package:flutter/material.dart'; -import 'package:logging/logging.dart'; -import 'package:share_plus/share_plus.dart'; - -class DataSectionWidget extends StatelessWidget { - final _logger = Logger("AccountSectionWidget"); - - final _codeFile = File( - Configuration.instance.getTempDirectory() + "ente-authenticator-codes.txt", - ); - - DataSectionWidget({Key? key}) : super(key: key); - - @override - Widget build(BuildContext context) { - final l10n = context.l10n; - return ExpandableMenuItemWidget( - title: l10n.data, - selectionOptionsWidget: _getSectionOptions(context), - leadingIcon: Icons.key_outlined, - ); - } - - Column _getSectionOptions(BuildContext context) { - final l10n = context.l10n; - List children = []; - children.addAll([ - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.importCodes, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async { - _showImportInstructionDialog(context); - }, - ), - sectionOptionSpacing, - MenuItemWidget( - captionedTextWidget: CaptionedTextWidget( - title: l10n.exportCodes, - ), - pressedColor: getEnteColorScheme(context).fillFaint, - trailingIcon: Icons.chevron_right_outlined, - trailingIconIsMuted: true, - onTap: () async { - _showExportWarningDialog(context); - }, - ), - sectionOptionSpacing, - ]); - return Column( - children: children, - ); - } - - Future _showImportInstructionDialog(BuildContext context) async { - final l10n = context.l10n; - final AlertDialog alert = AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - title: Text( - l10n.importCodes, - style: Theme.of(context).textTheme.headline6, - ), - content: SingleChildScrollView( - child: Column( - children: [ - Text( - l10n.importInstruction, - ), - const SizedBox( - height: 20, - ), - Container( - color: Theme.of(context).colorScheme.gNavBackgroundColor, - child: Padding( - padding: const EdgeInsets.all(8), - child: Text( - "otpauth://totp/provider.com:you@email.com?secret=YOUR_SECRET", - style: TextStyle( - fontFeatures: const [FontFeature.tabularFigures()], - fontFamily: Platform.isIOS ? "Courier" : "monospace", - fontSize: 13, - ), - ), - ), - ), - const SizedBox( - height: 20, - ), - Text(l10n.importCodeDelimiterInfo), - ], - ), - ), - actions: [ - TextButton( - child: Text( - l10n.cancel, - style: const TextStyle( - color: Colors.red, - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop('dialog'); - }, - ), - TextButton( - child: Text(l10n.selectFile), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop('dialog'); - _pickImportFile(context); - }, - ), - ], - ); - - return showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - barrierColor: Colors.black12, - ); - } - - Future _showExportWarningDialog(BuildContext context) async { - final AlertDialog alert = AlertDialog( - shape: RoundedRectangleBorder(borderRadius: BorderRadius.circular(10)), - title: Text( - context.l10n.warning, - style: Theme.of(context).textTheme.headline6, - ), - content: Text( - context.l10n.exportWarningDesc, - ), - actions: [ - TextButton( - child: Text( - context.l10n.iUnderStand, - style: const TextStyle( - color: Colors.red, - ), - ), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop('dialog'); - _exportCodes(context); - }, - ), - TextButton( - child: Text( - context.l10n.cancel, - ), - onPressed: () { - Navigator.of(context, rootNavigator: true).pop('dialog'); - }, - ), - ], - ); - - return showDialog( - context: context, - builder: (BuildContext context) { - return alert; - }, - barrierColor: Colors.black12, - ); - } - - Future _exportCodes(BuildContext context) async { - final hasAuthenticated = await LocalAuthenticationService.instance - .requestLocalAuthentication(context, context.l10n.authToExportCodes); - if (!hasAuthenticated) { - return; - } - if (_codeFile.existsSync()) { - await _codeFile.delete(); - } - final codes = await CodeStore.instance.getAllCodes(); - String data = ""; - for (final code in codes) { - data += code.rawData + "\n"; - } - _codeFile.writeAsStringSync(data); - await Share.shareFiles([_codeFile.path]); - Future.delayed(const Duration(seconds: 15), () async { - if (_codeFile.existsSync()) { - _codeFile.deleteSync(); - } - }); - } - - Future _pickImportFile(BuildContext context) async { - final l10n = context.l10n; - FilePickerResult? result = await FilePicker.platform.pickFiles(); - if (result == null) { - return; - } - final dialog = createProgressDialog(context, l10n.pleaseWait); - await dialog.show(); - try { - File file = File(result.files.single.path!); - final codes = await file.readAsString(); - List splitCodes = codes.split(","); - if (splitCodes.length == 1) { - splitCodes = codes.split("\n"); - } - final parsedCodes = []; - for (final code in splitCodes) { - try { - parsedCodes.add(Code.fromRawData(code)); - } catch (e) { - _logger.severe("Could not parse code", e); - } - } - for (final code in parsedCodes) { - await CodeStore.instance.addCode(code, shouldSync: false); - } - unawaited(AuthenticatorService.instance.sync()); - - final DialogWidget dialog = choiceDialog( - title: context.l10n.importSuccessTitle, - body: context.l10n.importSuccessDesc(parsedCodes.length), - // body: "You have imported " + parsedCodes.length.toString() + " codes!", - firstButtonLabel: l10n.ok, - firstButtonOnTap: () async { - Navigator.of(context, rootNavigator: true).pop('dialog'); - }, - firstButtonType: ButtonType.primary, - ); - await showConfettiDialog( - context: context, - dialogBuilder: (BuildContext context) { - return dialog; - }, - ); - } catch (e) { - await dialog.hide(); - await showErrorDialog( - context, - context.l10n.sorry, - context.l10n.importFailureDesc, - ); - } - } -} diff --git a/lib/ui/settings_page.dart b/lib/ui/settings_page.dart index 43a6823ba..d979377f4 100644 --- a/lib/ui/settings_page.dart +++ b/lib/ui/settings_page.dart @@ -6,7 +6,7 @@ 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/danger_section_widget.dart'; -import 'package:ente_auth/ui/settings/data_section_widget.dart'; +import 'package:ente_auth/ui/settings/data/data_section_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';