From 53c553db02376ab02eef8d75eeb5736c4268e65a Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 13:30:08 +0530 Subject: [PATCH 01/26] [auth] Move field label to left --- auth/lib/l10n/arb/app_en.arb | 2 + .../onboarding/view/common/field_label.dart | 25 ++ .../view/setup_enter_secret_key_page.dart | 324 ++++++++++-------- 3 files changed, 199 insertions(+), 152 deletions(-) create mode 100644 auth/lib/onboarding/view/common/field_label.dart diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 3b0f8a7139..6f5ea14088 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -19,6 +19,8 @@ "pleaseVerifyDetails": "Please verify the details and try again", "codeIssuerHint": "Issuer", "codeSecretKeyHint": "Secret Key", + "secret": "Secret", + "account": "Account", "codeAccountHint": "Account (you@domain.com)", "codeTagHint": "Tag", "accountKeyType": "Type of key", diff --git a/auth/lib/onboarding/view/common/field_label.dart b/auth/lib/onboarding/view/common/field_label.dart new file mode 100644 index 0000000000..2b4de42f93 --- /dev/null +++ b/auth/lib/onboarding/view/common/field_label.dart @@ -0,0 +1,25 @@ +import 'package:ente_auth/theme/ente_theme.dart'; +import 'package:flutter/material.dart'; + +class FieldLabel extends StatelessWidget { + final String label; + + const FieldLabel( + this.label, { + super.key, + }); + + @override + Widget build(BuildContext context) { + return Padding( + padding: const EdgeInsets.only(right: 12.0), + child: SizedBox( + width: 80, + child: Text( + label, + style: getEnteTextTheme(context).miniBoldMuted, + ), + ), + ); + } +} diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index b4ab8bfd0f..cfede00824 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -8,8 +8,10 @@ import 'package:ente_auth/models/code_display.dart'; import 'package:ente_auth/onboarding/model/tag_enums.dart'; import 'package:ente_auth/onboarding/view/common/add_chip.dart'; import 'package:ente_auth/onboarding/view/common/add_tag.dart'; +import 'package:ente_auth/onboarding/view/common/field_label.dart'; import 'package:ente_auth/onboarding/view/common/tag_chip.dart'; import 'package:ente_auth/store/code_display_store.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/utils/dialog_util.dart'; @@ -75,160 +77,177 @@ class _SetupEnterSecretKeyPageState extends State { appBar: AppBar( title: Text(l10n.importAccountPageTitle), ), - body: Center( - child: SingleChildScrollView( - child: Padding( - padding: const EdgeInsets.symmetric(vertical: 40.0, horizontal: 40), - child: Column( - crossAxisAlignment: CrossAxisAlignment.start, - children: [ - TextFormField( - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter some text"; - } - return null; - }, - decoration: InputDecoration( - hintText: l10n.codeIssuerHint, - floatingLabelBehavior: FloatingLabelBehavior.auto, - labelText: l10n.codeIssuerHint, - ), - controller: _issuerController, - autofocus: true, - ), - const SizedBox( - height: 20, - ), - TextFormField( - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter some text"; - } - return null; - }, - decoration: InputDecoration( - hintText: l10n.codeSecretKeyHint, - floatingLabelBehavior: FloatingLabelBehavior.auto, - labelText: l10n.codeSecretKeyHint, - suffixIcon: IconButton( - onPressed: () { - setState(() { - _secretKeyObscured = !_secretKeyObscured; - }); - }, - icon: _secretKeyObscured - ? const Icon(Icons.visibility_off_rounded) - : const Icon(Icons.visibility_rounded), - ), - ), - obscureText: _secretKeyObscured, - controller: _secretController, - ), - const SizedBox( - height: 20, - ), - TextFormField( - // The validator receives the text that the user has entered. - validator: (value) { - if (value == null || value.isEmpty) { - return "Please enter some text"; - } - return null; - }, - decoration: InputDecoration( - hintText: l10n.codeAccountHint, - floatingLabelBehavior: FloatingLabelBehavior.auto, - labelText: l10n.codeAccountHint, - ), - controller: _accountController, - ), - const SizedBox(height: 40), - const SizedBox( - height: 20, - ), - Text( - l10n.tags, - style: const TextStyle( - fontWeight: FontWeight.bold, - ), - ), - const SizedBox(height: 10), - Wrap( - spacing: 12, - alignment: WrapAlignment.start, - children: [ - ...allTags.map( - (e) => TagChip( - label: e, - action: TagChipAction.check, - state: tags.contains(e) - ? TagChipState.selected - : TagChipState.unselected, - onTap: () { - if (tags.contains(e)) { - tags.remove(e); - } else { - tags.add(e); - } - setState(() {}); - }, - ), - ), - AddChip( - onTap: () { - showDialog( - context: context, - builder: (BuildContext context) { - return AddTagDialog( - onTap: (tag) { - if (allTags.contains(tag) && - tags.contains(tag)) { - return; - } - allTags.add(tag); - tags.add(tag); - setState(() {}); - Navigator.pop(context); - }, - ); - }, - barrierColor: Colors.black.withOpacity(0.85), - barrierDismissible: false, - ); - }, - ), - ], - ), - const SizedBox( - height: 40, - ), - SizedBox( - width: 400, - child: OutlinedButton( - onPressed: () async { - if ((_accountController.text.trim().isEmpty && - _issuerController.text.trim().isEmpty) || - _secretController.text.trim().isEmpty) { - String message; - if (_secretController.text.trim().isEmpty) { - message = context.l10n.secretCanNotBeEmpty; - } else { - message = - context.l10n.bothIssuerAndAccountCanNotBeEmpty; + body: SingleChildScrollView( + child: Padding( + padding: const EdgeInsets.symmetric(vertical: 12.0, horizontal: 20), + child: Column( + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + Row( + children: [ + FieldLabel(l10n.codeIssuerHint), + Expanded( + child: TextFormField( + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter some text"; } - _showIncorrectDetailsDialog(context, message: message); - return; - } - await _saveCode(); - }, - child: Text(l10n.saveAction), + return null; + }, + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(vertical: 12.0), + ), + style: getEnteTextTheme(context).small, + controller: _issuerController, + autofocus: true, + ), ), + ], + ), + Row( + children: [ + FieldLabel(l10n.secret), + Expanded( + child: TextFormField( + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter some text"; + } + return null; + }, + style: getEnteTextTheme(context).small, + decoration: InputDecoration( + contentPadding: + const EdgeInsets.symmetric(vertical: 12.0), + suffixIcon: GestureDetector( + // padding: EdgeInsets.zero, + onTap: () { + setState(() { + _secretKeyObscured = !_secretKeyObscured; + }); + }, + child: _secretKeyObscured + ? const Icon( + Icons.visibility_off_rounded, + size: 18, + ) + : const Icon( + Icons.visibility_rounded, + size: 18, + ), + ), + ), + obscureText: _secretKeyObscured, + controller: _secretController, + ), + ), + ], + ), + Row( + children: [ + FieldLabel(l10n.account), + Expanded( + child: TextFormField( + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter some text"; + } + return null; + }, + decoration: const InputDecoration( + contentPadding: EdgeInsets.symmetric(vertical: 12.0), + ), + style: getEnteTextTheme(context).small, + controller: _accountController, + ), + ), + ], + ), + const SizedBox(height: 40), + const SizedBox( + height: 20, + ), + Text( + l10n.tags, + style: const TextStyle( + fontWeight: FontWeight.bold, ), - ], - ), + ), + const SizedBox(height: 10), + Wrap( + spacing: 12, + alignment: WrapAlignment.start, + children: [ + ...allTags.map( + (e) => TagChip( + label: e, + action: TagChipAction.check, + state: tags.contains(e) + ? TagChipState.selected + : TagChipState.unselected, + onTap: () { + if (tags.contains(e)) { + tags.remove(e); + } else { + tags.add(e); + } + setState(() {}); + }, + ), + ), + AddChip( + onTap: () { + showDialog( + context: context, + builder: (BuildContext context) { + return AddTagDialog( + onTap: (tag) { + if (allTags.contains(tag) && tags.contains(tag)) { + return; + } + allTags.add(tag); + tags.add(tag); + setState(() {}); + Navigator.pop(context); + }, + ); + }, + barrierColor: Colors.black.withOpacity(0.85), + barrierDismissible: false, + ); + }, + ), + ], + ), + const SizedBox( + height: 40, + ), + SizedBox( + width: 400, + child: OutlinedButton( + onPressed: () async { + if ((_accountController.text.trim().isEmpty && + _issuerController.text.trim().isEmpty) || + _secretController.text.trim().isEmpty) { + String message; + if (_secretController.text.trim().isEmpty) { + message = context.l10n.secretCanNotBeEmpty; + } else { + message = + context.l10n.bothIssuerAndAccountCanNotBeEmpty; + } + _showIncorrectDetailsDialog(context, message: message); + return; + } + await _saveCode(); + }, + child: Text(l10n.saveAction), + ), + ), + ], ), ), ), @@ -240,7 +259,8 @@ class _SetupEnterSecretKeyPageState extends State { final account = _accountController.text.trim(); final issuer = _issuerController.text.trim(); final secret = _secretController.text.trim().replaceAll(' ', ''); - final isStreamCode = issuer.toLowerCase() == "steam" || issuer.toLowerCase().contains('steampowered.com'); + final isStreamCode = issuer.toLowerCase() == "steam" || + issuer.toLowerCase().contains('steampowered.com'); if (widget.code != null && widget.code!.secret != secret) { ButtonResult? result = await showChoiceActionSheet( context, From 583163968d4fd300b30feb75770d4a279bb208d7 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:31:57 +0530 Subject: [PATCH 02/26] [auth] Limit text field length --- .../view/setup_enter_secret_key_page.dart | 22 +++++++++++++++++++ 1 file changed, 22 insertions(+) diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index cfede00824..df3c102750 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -29,6 +29,8 @@ class SetupEnterSecretKeyPage extends StatefulWidget { } class _SetupEnterSecretKeyPageState extends State { + final int _notesLimit = 500; + final int _otherTextLimit = 200; late TextEditingController _issuerController; late TextEditingController _accountController; late TextEditingController _secretController; @@ -54,9 +56,29 @@ class _SetupEnterSecretKeyPageState extends State { _streamSubscription = Bus.instance.on().listen((event) { _loadTags(); }); + + if (widget.code == null || + (widget.code!.issuer.length < _otherTextLimit && + widget.code!.account.length < _otherTextLimit && + widget.code!.secret.length < _otherTextLimit)) { + _limitTextLength(_issuerController, _otherTextLimit); + _limitTextLength(_accountController, _otherTextLimit); + _limitTextLength(_secretController, _otherTextLimit); + } super.initState(); } + void _limitTextLength(TextEditingController controller, int limit) { + controller.addListener(() { + if (controller.text.length > limit) { + controller.text = controller.text.substring(0, limit); + controller.selection = TextSelection.fromPosition( + TextPosition(offset: controller.text.length), + ); + } + }); + } + @override void dispose() { _streamSubscription?.cancel(); From 05200878f20a26801561bdf5f08c1847c20537c1 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:39:43 +0530 Subject: [PATCH 03/26] [auth] Add support for adding notes --- auth/lib/l10n/arb/app_en.arb | 13 ++++ auth/lib/models/code_display.dart | 9 +++ .../view/setup_enter_secret_key_page.dart | 73 ++++++++++++++++++- 3 files changed, 92 insertions(+), 3 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 6f5ea14088..10d2711adb 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -21,6 +21,19 @@ "codeSecretKeyHint": "Secret Key", "secret": "Secret", "account": "Account", + "advance": "Advance", + "notes": "Notes", + "notesLengthLimit": "Notes can be at most {count} characters long", + "@notesLengthLimit": { + "description": "Text to indicate the maximum number of characters allowed for notes", + "placeholders": { + "count": { + "description": "The maximum number of characters allowed for notes", + "type": "int", + "example": "100" + } + } + }, "codeAccountHint": "Account (you@domain.com)", "codeTagHint": "Tag", "accountKeyType": "Type of key", diff --git a/auth/lib/models/code_display.dart b/auth/lib/models/code_display.dart index 6bbf78f1fc..50c4767eaf 100644 --- a/auth/lib/models/code_display.dart +++ b/auth/lib/models/code_display.dart @@ -9,6 +9,7 @@ class CodeDisplay { final bool trashed; final int lastUsedAt; final int tapCount; + String note; final List tags; CodeDisplay({ @@ -17,6 +18,7 @@ class CodeDisplay { this.lastUsedAt = 0, this.tapCount = 0, this.tags = const [], + this.note = '', }); // copyWith @@ -26,12 +28,14 @@ class CodeDisplay { int? lastUsedAt, int? tapCount, List? tags, + String? note, }) { final bool updatedPinned = pinned ?? this.pinned; final bool updatedTrashed = trashed ?? this.trashed; final int updatedLastUsedAt = lastUsedAt ?? this.lastUsedAt; final int updatedTapCount = tapCount ?? this.tapCount; final List updatedTags = tags ?? this.tags; + final String updatedNote = note ?? this.note; return CodeDisplay( pinned: updatedPinned, @@ -39,6 +43,7 @@ class CodeDisplay { lastUsedAt: updatedLastUsedAt, tapCount: updatedTapCount, tags: updatedTags, + note: updatedNote, ); } @@ -52,6 +57,7 @@ class CodeDisplay { lastUsedAt: json['lastUsedAt'] ?? 0, tapCount: json['tapCount'] ?? 0, tags: List.from(json['tags'] ?? []), + note: json['note'] ?? '', ); } @@ -92,6 +98,7 @@ class CodeDisplay { 'lastUsedAt': lastUsedAt, 'tapCount': tapCount, 'tags': tags, + 'note': note, }; } @@ -104,6 +111,7 @@ class CodeDisplay { other.trashed == trashed && other.lastUsedAt == lastUsedAt && other.tapCount == tapCount && + other.note == note && listEquals(other.tags, tags); } @@ -113,6 +121,7 @@ class CodeDisplay { trashed.hashCode ^ lastUsedAt.hashCode ^ tapCount.hashCode ^ + note.hashCode ^ tags.hashCode; } } diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index df3c102750..4a7570d1ec 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -15,6 +15,7 @@ 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/utils/dialog_util.dart'; +import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_auth/utils/totp_util.dart'; import "package:flutter/material.dart"; @@ -34,6 +35,7 @@ class _SetupEnterSecretKeyPageState extends State { late TextEditingController _issuerController; late TextEditingController _accountController; late TextEditingController _secretController; + late TextEditingController _notesController; late bool _secretKeyObscured; late List tags = [...?widget.code?.display.tags]; List allTags = []; @@ -51,11 +53,23 @@ class _SetupEnterSecretKeyPageState extends State { _secretController = TextEditingController( text: widget.code?.secret, ); + _notesController = TextEditingController( + text: widget.code?.display.note, + ); _secretKeyObscured = widget.code != null; _loadTags(); _streamSubscription = Bus.instance.on().listen((event) { _loadTags(); }); + _notesController.addListener(() { + if (_notesController.text.length > _notesLimit) { + _notesController.text = _notesController.text.substring(0, _notesLimit); + _notesController.selection = TextSelection.fromPosition( + TextPosition(offset: _notesController.text.length), + ); + showToast(context, context.l10n.notesLengthLimit(_notesLimit)); + } + }); if (widget.code == null || (widget.code!.issuer.length < _otherTextLimit && @@ -82,6 +96,10 @@ class _SetupEnterSecretKeyPageState extends State { @override void dispose() { _streamSubscription?.cancel(); + _issuerController.dispose(); + _accountController.dispose(); + _accountController.dispose(); + _notesController.dispose(); super.dispose(); } @@ -188,10 +206,57 @@ class _SetupEnterSecretKeyPageState extends State { ), ], ), - const SizedBox(height: 40), - const SizedBox( - height: 20, + const SizedBox(height: 12), + Row( + children: [ + Expanded( + child: Theme( + data: Theme.of(context).copyWith( + dividerColor: Colors + .transparent, // Removes the default divider color + ), + child: ExpansionTile( + title: Text( + l10n.advance, + style: getEnteTextTheme(context).bodyMuted, + ), + children: [ + Row( + children: [ + FieldLabel(l10n.notes), + Expanded( + child: TextFormField( + // The validator receives the text that the user has entered. + validator: (value) { + if (value == null || value.isEmpty) { + return "Please enter some text"; + } + if (value.length > _notesLimit) { + return "Notes can't be more than 1000 characters"; + } + return null; + }, + maxLength: _notesLimit, + minLines: 1, + maxLines: 5, + decoration: const InputDecoration( + contentPadding: + EdgeInsets.symmetric(vertical: 12.0), + ), + style: getEnteTextTheme(context).small, + controller: _notesController, + ), + ), + ], + ), + const SizedBox(height: 12), + ], + ), + ), + ), + ], ), + const SizedBox(height: 24), Text( l10n.tags, style: const TextStyle( @@ -281,6 +346,7 @@ class _SetupEnterSecretKeyPageState extends State { final account = _accountController.text.trim(); final issuer = _issuerController.text.trim(); final secret = _secretController.text.trim().replaceAll(' ', ''); + final notes = _notesController.text.trim(); final isStreamCode = issuer.toLowerCase() == "steam" || issuer.toLowerCase().contains('steampowered.com'); if (widget.code != null && widget.code!.secret != secret) { @@ -299,6 +365,7 @@ class _SetupEnterSecretKeyPageState extends State { } final CodeDisplay display = widget.code?.display.copyWith(tags: tags) ?? CodeDisplay(tags: tags); + display.note = notes; final Code newCode = widget.code == null ? Code.fromAccountAndSecret( isStreamCode ? Type.steam : Type.totp, From f44f21c5ad59ccfecb10bf53476a9639da0375e6 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 14:50:56 +0530 Subject: [PATCH 04/26] [auth] Bypass auth in debugMode if recently authenticated --- auth/lib/services/local_authentication_service.dart | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/auth/lib/services/local_authentication_service.dart b/auth/lib/services/local_authentication_service.dart index a5a2042338..02c609591d 100644 --- a/auth/lib/services/local_authentication_service.dart +++ b/auth/lib/services/local_authentication_service.dart @@ -8,6 +8,7 @@ import 'package:ente_auth/utils/auth_util.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/lock_screen_settings.dart'; import 'package:ente_auth/utils/toast_util.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; import 'package:flutter_local_authentication/flutter_local_authentication.dart'; @@ -19,11 +20,19 @@ class LocalAuthenticationService { static final LocalAuthenticationService instance = LocalAuthenticationService._privateConstructor(); final logger = Logger((LocalAuthenticationService).toString()); + int lastAuthTime = 0; Future requestLocalAuthentication( BuildContext context, String infoMessage, ) async { + if (kDebugMode) { + // if last auth time is less than 60 seconds, don't ask for auth again + if (lastAuthTime != 0 && + DateTime.now().millisecondsSinceEpoch - lastAuthTime < 60000) { + return true; + } + } if (await isLocalAuthSupportedOnDevice() || LockScreenSettings.instance.getIsAppLockSet()) { AppLock.of(context)!.setEnabled(false); @@ -39,6 +48,7 @@ class LocalAuthenticationService { showToast(context, infoMessage); return false; } else { + lastAuthTime = DateTime.now().millisecondsSinceEpoch; return true; } } From f7e37c6b2c48f5fe99614022a07692b82966656f Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:35:27 +0530 Subject: [PATCH 05/26] [auth] Add support for trashing codes --- auth/lib/l10n/arb/app_en.arb | 3 ++ auth/lib/models/code.dart | 2 + auth/lib/ui/code_widget.dart | 72 +++++++++++++++++++++++++++++++++--- 3 files changed, 71 insertions(+), 6 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 10d2711adb..4229d3d9aa 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -49,6 +49,9 @@ "nextTotpTitle": "next", "deleteCodeTitle": "Delete code?", "deleteCodeMessage": "Are you sure you want to delete this code? This action is irreversible.", + "trashCode": "Trash code?", + "trashCodeMessage": "Are you sure you want to trash code for {account}?.", + "trash": "Trash", "viewLogsAction": "View logs", "sendLogsDescription": "This will send across logs to help us debug your issue. While we take precautions to ensure that sensitive information is not logged, we encourage you to view these logs before sharing them.", "preparingLogsTitle": "Preparing logs...", diff --git a/auth/lib/models/code.dart b/auth/lib/models/code.dart index 5f7cc0f135..a5e45ac5c0 100644 --- a/auth/lib/models/code.dart +++ b/auth/lib/models/code.dart @@ -27,6 +27,8 @@ class Code { bool get isPinned => display.pinned; + bool get isTrashed => display.trashed; + final Object? err; bool get hasError => err != null; diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 4c3e748e3a..3bc0245100 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -20,6 +20,7 @@ import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; import 'package:ente_auth/utils/toast_util.dart'; import 'package:ente_auth/utils/totp_util.dart'; +import 'package:flutter/foundation.dart'; import 'package:flutter/material.dart'; import 'package:flutter_context_menu/flutter_context_menu.dart'; import 'package:flutter_slidable/flutter_slidable.dart'; @@ -118,6 +119,16 @@ class _CodeWidgetState extends State { size: const Size(39, 39), ), ), + if (widget.code.isTrashed && kDebugMode) + Align( + alignment: Alignment.topLeft, + child: CustomPaint( + painter: PinBgPainter( + color: colorScheme.warning700, + ), + size: const Size(39, 39), + ), + ), Column( crossAxisAlignment: CrossAxisAlignment.start, mainAxisAlignment: MainAxisAlignment.spaceBetween, @@ -222,10 +233,14 @@ class _CodeWidgetState extends State { ), const MenuDivider(), MenuItem( - label: l10n.delete, + label: widget.code.isTrashed ? l10n.delete : l10n.trash, value: "Delete", - icon: Icons.delete, - onSelected: () => _onDeletePressed(null), + icon: widget.code.isTrashed + ? Icons.delete_forever + : Icons.delete, + onSelected: () => widget.code.isTrashed + ? _onDeletePressed(null) + : _onTrashPressed(null), ), ], padding: const EdgeInsets.all(8.0), @@ -308,12 +323,16 @@ class _CodeWidgetState extends State { width: 14, ), SlidableAction( - onPressed: _onDeletePressed, + onPressed: widget.code.isTrashed + ? _onDeletePressed + : _onTrashPressed, backgroundColor: Colors.grey.withOpacity(0.1), borderRadius: const BorderRadius.all(Radius.circular(8)), foregroundColor: colorScheme.deleteCodeTextColor, - icon: Icons.delete, - label: l10n.delete, + icon: widget.code.isTrashed + ? Icons.delete_forever + : Icons.delete, + label: widget.code.isTrashed ? l10n.delete : l10n.trash, padding: const EdgeInsets.only(left: 0, right: 0), spacing: 8, ), @@ -559,6 +578,10 @@ class _CodeWidgetState extends State { } void _onDeletePressed(_) async { + if (!widget.code.isTrashed) { + showToast(context, 'Code can only be deleted from trash'); + return; + } bool isAuthSuccessful = await LocalAuthenticationService.instance.requestLocalAuthentication( context, @@ -581,6 +604,43 @@ class _CodeWidgetState extends State { ); } + void _onTrashPressed(_) async { + if (widget.code.isTrashed) { + showToast(context, 'Code is already trashed'); + return; + } + bool isAuthSuccessful = + await LocalAuthenticationService.instance.requestLocalAuthentication( + context, + context.l10n.deleteCodeAuthMessage, + ); + if (!isAuthSuccessful) { + return; + } + FocusScope.of(context).requestFocus(); + final l10n = context.l10n; + await showChoiceActionSheet( + context, + title: l10n.trashCode, + body: l10n + .trashCodeMessage('${widget.code.issuer} (${widget.code.account})'), + firstButtonLabel: l10n.trash, + isCritical: true, + firstButtonOnTap: () async { + try { + final display = widget.code.display; + final Code code = widget.code.copyWith( + display: display.copyWith(trashed: true), + ); + await CodeStore.instance.addCode(code); + } catch (e) { + logger.severe('Failed to trash code: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ); + } + String _getCurrentOTP() { try { return getOTP(widget.code); From 42611085f4f5e46541081a64b1672e4b222f8f24 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:37:15 +0530 Subject: [PATCH 06/26] [auth] Ignore tags for trashed codes --- auth/lib/store/code_display_store.dart | 1 + 1 file changed, 1 insertion(+) diff --git a/auth/lib/store/code_display_store.dart b/auth/lib/store/code_display_store.dart index d6afd1b494..5e92723bbe 100644 --- a/auth/lib/store/code_display_store.dart +++ b/auth/lib/store/code_display_store.dart @@ -30,6 +30,7 @@ class CodeDisplayStore { final tags = {}; for (final code in codes) { if (code.hasError) continue; + if (code.isTrashed) continue; tags.addAll(code.display.tags); } return tags.toList()..sort(); From 72648286f23886234df00ee1f010c849cb567449 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 15:58:19 +0530 Subject: [PATCH 07/26] [auth] Show trashed icons in the end --- auth/lib/ui/home_page.dart | 41 ++++++++++++++++++++++++++++++++++---- 1 file changed, 37 insertions(+), 4 deletions(-) diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 44927d4af0..cc4dd18e8d 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -74,6 +74,8 @@ class _HomePageState extends State { StreamSubscription? _triggerLogoutEvent; StreamSubscription? _iconsChangedEvent; String selectedTag = ""; + bool _isTrashOpen = false; + bool hasTrashedCodes = false; @override void initState() { @@ -103,10 +105,15 @@ class _HomePageState extends State { void _loadCodes() { CodeStore.instance.getAllCodes().then((codes) { _allCodes = codes; + for (final c in _allCodes ?? []) { + if (c.isTrashed) { + hasTrashedCodes = true; + break; + } + } CodeDisplayStore.instance.getAllTags(allCodes: _allCodes).then((value) { tags = value; - if (mounted) { if (!tags.contains(selectedTag)) { selectedTag = ""; @@ -133,7 +140,8 @@ class _HomePageState extends State { for (final Code codeState in _allCodes!) { if (codeState.hasError || selectedTag != "" && - !codeState.display.tags.contains(selectedTag)) { + !codeState.display.tags.contains(selectedTag) || + (codeState.isTrashed != _isTrashOpen)) { continue; } @@ -145,11 +153,19 @@ class _HomePageState extends State { } _filteredCodes = issuerMatch; _filteredCodes.addAll(accountMatch); + } else if (_isTrashOpen) { + _filteredCodes = _allCodes + ?.where( + (element) => !element.hasError && element.isTrashed, + ) + .toList() ?? + []; } else { _filteredCodes = _allCodes ?.where( (element) => !element.hasError && + !element.isTrashed && (selectedTag == "" || element.display.tags.contains(selectedTag)), ) @@ -340,6 +356,7 @@ class _HomePageState extends State { final anyCodeHasError = _allCodes?.firstWhereOrNull((element) => element.hasError) != null; final indexOffset = anyCodeHasError ? 1 : 0; + final itemCount = tags.length + 1 + (hasTrashedCodes ? 1 : 0); final list = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -353,16 +370,31 @@ class _HomePageState extends State { const EdgeInsets.symmetric(horizontal: 16, vertical: 2), separatorBuilder: (context, index) => const SizedBox(width: 8), - itemCount: tags.length + 1, + itemCount: itemCount, itemBuilder: (context, index) { if (index == 0) { return TagChip( label: "All", - state: selectedTag == "" + state: selectedTag == "" && _isTrashOpen == false ? TagChipState.selected : TagChipState.unselected, onTap: () { selectedTag = ""; + _isTrashOpen = false; + setState(() {}); + _applyFilteringAndRefresh(); + }, + ); + } + if (index == itemCount - 1 && hasTrashedCodes) { + return TagChip( + label: "Trash", + state: _isTrashOpen + ? TagChipState.selected + : TagChipState.unselected, + onTap: () { + selectedTag = ""; + _isTrashOpen = !_isTrashOpen; setState(() {}); _applyFilteringAndRefresh(); }, @@ -375,6 +407,7 @@ class _HomePageState extends State { ? TagChipState.selected : TagChipState.unselected, onTap: () { + _isTrashOpen = false; if (selectedTag == tags[index - 1]) { selectedTag = ""; setState(() {}); From 35916af7bf2efc7e24c746a6ee366cf0bced046a Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:00:07 +0530 Subject: [PATCH 08/26] [auth] Show trashed icons in the end --- auth/lib/ui/home_page.dart | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index cc4dd18e8d..8e1134a951 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -388,7 +388,7 @@ class _HomePageState extends State { } if (index == itemCount - 1 && hasTrashedCodes) { return TagChip( - label: "Trash", + label: '🗑️ Trash', state: _isTrashOpen ? TagChipState.selected : TagChipState.unselected, From 0ad84be3ab11dcff980f798e3f852fcf17975bf6 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 16:28:16 +0530 Subject: [PATCH 09/26] [auth] Minor fix --- auth/lib/onboarding/view/setup_enter_secret_key_page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index 4a7570d1ec..1ba19d212a 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -98,7 +98,6 @@ class _SetupEnterSecretKeyPageState extends State { _streamSubscription?.cancel(); _issuerController.dispose(); _accountController.dispose(); - _accountController.dispose(); _notesController.dispose(); super.dispose(); } From 3bb9790229a7835d6ec92f796eb65749f956684c Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:27:26 +0530 Subject: [PATCH 10/26] [auth][mob] Add compact mode --- auth/lib/l10n/arb/app_en.arb | 1 + auth/lib/services/preference_service.dart | 9 +++ auth/lib/store/code_store.dart | 3 + auth/lib/ui/code_timer_progress.dart | 5 +- auth/lib/ui/code_widget.dart | 70 ++++++++++++------- auth/lib/ui/home_page.dart | 4 ++ .../ui/settings/general_section_widget.dart | 20 +++++- 7 files changed, 83 insertions(+), 29 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 4229d3d9aa..148f386d7f 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -364,6 +364,7 @@ "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.", "showLargeIcons": "Show large icons", + "compactMode": "Compact mode", "shouldHideCode": "Hide codes", "doubleTapToViewHiddenCode": "You can double tap on an entry to view code", "focusOnSearchBar": "Focus search on app start", diff --git a/auth/lib/services/preference_service.dart b/auth/lib/services/preference_service.dart index 66d96c7fdd..6da91465ea 100644 --- a/auth/lib/services/preference_service.dart +++ b/auth/lib/services/preference_service.dart @@ -14,6 +14,7 @@ class PreferenceService { static const kShouldHideCodesKey = "should_hide_codes"; static const kShouldAutoFocusOnSearchBar = "should_auto_focus_on_search_bar"; static const kShouldMinimizeOnCopy = "should_minimize_on_copy"; + static const kCompactMode = "vi.compactMode"; Future init() async { _prefs = await SharedPreferences.getInstance(); @@ -48,6 +49,14 @@ class PreferenceService { return _prefs.getBool(kShouldHideCodesKey) ?? false; } + bool isCompactMode() { + return _prefs.getBool(kCompactMode) ?? false; + } + + Future setCompactMode(bool value) async { + await _prefs.setBool(kCompactMode, value); + } + Future setHideCodes(bool value) async { await _prefs.setBool(kShouldHideCodesKey, value); Bus.instance.fire(IconsChangedEvent()); diff --git a/auth/lib/store/code_store.dart b/auth/lib/store/code_store.dart index 449bb93166..eb0906d976 100644 --- a/auth/lib/store/code_store.dart +++ b/auth/lib/store/code_store.dart @@ -48,6 +48,9 @@ class CodeStore { code.generatedID = entity.generatedID; code.hasSynced = entity.hasSynced; codes.add(code); + codes.add(code); + codes.add(code); + codes.add(code); } if (sortCodes) { diff --git a/auth/lib/ui/code_timer_progress.dart b/auth/lib/ui/code_timer_progress.dart index a825a6ca43..e594e15fb1 100644 --- a/auth/lib/ui/code_timer_progress.dart +++ b/auth/lib/ui/code_timer_progress.dart @@ -1,3 +1,4 @@ +import 'package:ente_auth/services/preference_service.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:flutter/material.dart'; import 'package:flutter/scheduler.dart'; @@ -30,6 +31,7 @@ class _CodeTimerProgressState extends State late final Ticker _ticker; late final ValueNotifier _progress; late final int _microSecondsInPeriod; + late bool _isCompactMode=false; @override void initState() { @@ -38,6 +40,7 @@ class _CodeTimerProgressState extends State _progress = ValueNotifier(0.0); _ticker = createTicker(_updateTimeRemaining); _ticker.start(); + _isCompactMode = PreferenceService.instance.isCompactMode(); _updateTimeRemaining(Duration.zero); } @@ -57,7 +60,7 @@ class _CodeTimerProgressState extends State @override Widget build(BuildContext context) { return SizedBox( - height: 3, + height: _isCompactMode ?1:3, child: ValueListenableBuilder( valueListenable: _progress, builder: (context, progress, _) { diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 3bc0245100..e9ac384889 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -30,10 +30,12 @@ import 'package:move_to_background/move_to_background.dart'; class CodeWidget extends StatefulWidget { final Code code; + final bool isCompactMode; const CodeWidget( this.code, { super.key, + required this.isCompactMode, }); @override @@ -50,12 +52,14 @@ class _CodeWidgetState extends State { late bool _shouldShowLargeIcon; late bool _hideCode; bool isMaskingEnabled = false; + bool isCompactMode = true; int _codeTimeStep = -1; @override void initState() { super.initState(); isMaskingEnabled = PreferenceService.instance.shouldHideCodes(); + _hideCode = isMaskingEnabled; _everySecondTimer = Timer.periodic(const Duration(milliseconds: 500), (Timer t) { @@ -116,7 +120,7 @@ class _CodeWidgetState extends State { painter: PinBgPainter( color: colorScheme.pinnedBgColor, ), - size: const Size(39, 39), + size: isCompactMode ? const Size(24, 24) : const Size(39, 39), ), ), if (widget.code.isTrashed && kDebugMode) @@ -137,7 +141,9 @@ class _CodeWidgetState extends State { CodeTimerProgressCache.getCachedWidget( widget.code.period, ), - const SizedBox(height: 28), + widget.isCompactMode + ? const SizedBox(height: 4) + : const SizedBox(height: 28), Row( children: [ _shouldShowLargeIcon ? _getIcon() : const SizedBox.shrink(), @@ -145,22 +151,32 @@ class _CodeWidgetState extends State { child: Column( children: [ _getTopRow(), - const SizedBox(height: 4), + widget.isCompactMode + ? const SizedBox.shrink() + : const SizedBox(height: 4), _getBottomRow(l10n), ], ), ), ], ), - const SizedBox(height: 32), + isCompactMode + ? const SizedBox(height: 4) + : const SizedBox(height: 32), ], ), if (widget.code.isPinned) ...[ Align( alignment: Alignment.topRight, child: Padding( - padding: const EdgeInsets.only(right: 6, top: 6), - child: SvgPicture.asset("assets/svg/pin-card.svg"), + padding: widget.isCompactMode + ? const EdgeInsets.only(right: 2, top: 2) + : const EdgeInsets.only(right: 6, top: 6), + child: SvgPicture.asset( + "assets/svg/pin-card.svg", + width: widget.isCompactMode ? 8 : null, + height: widget.isCompactMode ? 8 : null, + ), ), ), ], @@ -207,7 +223,9 @@ class _CodeWidgetState extends State { } return Container( - margin: const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8), + margin: widget.isCompactMode + ? const EdgeInsets.only(left: 16, right: 16, bottom: 6, top: 6) + : const EdgeInsets.only(left: 16, right: 16, bottom: 8, top: 8), child: Builder( builder: (context) { if (PlatformUtil.isDesktop()) { @@ -248,6 +266,7 @@ class _CodeWidgetState extends State { child: clippedCard(l10n), ); } + double slideSpace = isCompactMode ? 4 : 8; return Slidable( key: ValueKey(widget.code.hashCode), @@ -255,9 +274,7 @@ class _CodeWidgetState extends State { extentRatio: 0.90, motion: const ScrollMotion(), children: [ - const SizedBox( - width: 14, - ), + SizedBox(width: slideSpace), SlidableAction( onPressed: _onShowQrPressed, backgroundColor: Colors.grey.withOpacity(0.1), @@ -269,9 +286,7 @@ class _CodeWidgetState extends State { padding: const EdgeInsets.only(left: 4, right: 0), spacing: 8, ), - const SizedBox( - width: 14, - ), + SizedBox(width: slideSpace), CustomSlidableAction( onPressed: _onPinPressed, backgroundColor: Colors.grey.withOpacity(0.1), @@ -305,9 +320,7 @@ class _CodeWidgetState extends State { ), padding: const EdgeInsets.only(left: 4, right: 0), ), - const SizedBox( - width: 14, - ), + SizedBox(width: slideSpace), SlidableAction( onPressed: _onEditPressed, backgroundColor: Colors.grey.withOpacity(0.1), @@ -319,9 +332,7 @@ class _CodeWidgetState extends State { padding: const EdgeInsets.only(left: 4, right: 0), spacing: 8, ), - const SizedBox( - width: 14, - ), + SizedBox(width: slideSpace), SlidableAction( onPressed: widget.code.isTrashed ? _onDeletePressed @@ -362,7 +373,7 @@ class _CodeWidgetState extends State { type: MaterialType.transparency, child: AutoSizeText( _getFormattedCode(value), - style: const TextStyle(fontSize: 24), + style: TextStyle(fontSize: widget.isCompactMode ? 14 : 24), maxLines: 1, ), ); @@ -389,8 +400,8 @@ class _CodeWidgetState extends State { type: MaterialType.transparency, child: Text( _getFormattedCode(value), - style: const TextStyle( - fontSize: 18, + style: TextStyle( + fontSize: widget.isCompactMode ? 12 : 18, color: Colors.grey, ), ), @@ -423,6 +434,7 @@ class _CodeWidgetState extends State { } Widget _getTopRow() { + bool isCompactMode = widget.isCompactMode; return Padding( padding: const EdgeInsets.only(left: 16, right: 16), child: Row( @@ -434,13 +446,15 @@ class _CodeWidgetState extends State { children: [ Text( safeDecode(widget.code.issuer).trim(), - style: Theme.of(context).textTheme.titleLarge, + style: isCompactMode + ? Theme.of(context).textTheme.bodyMedium + : Theme.of(context).textTheme.titleLarge, ), - const SizedBox(height: 2), + if (!isCompactMode) const SizedBox(height: 2), Text( safeDecode(widget.code.account).trim(), style: Theme.of(context).textTheme.bodySmall?.copyWith( - fontSize: 12, + fontSize: isCompactMode ? 12 : 12, color: Colors.grey, ), ), @@ -471,12 +485,14 @@ class _CodeWidgetState extends State { Widget _getIcon() { return Padding( padding: _shouldShowLargeIcon - ? const EdgeInsets.only(left: 16) + ? EdgeInsets.only(left: widget.isCompactMode ? 12 : 16) : const EdgeInsets.all(0), child: IconUtils.instance.getIcon( context, safeDecode(widget.code.issuer).trim(), - width: _shouldShowLargeIcon ? 42 : 24, + width: widget.isCompactMode + ? (_shouldShowLargeIcon ? 32 : 24) + : (_shouldShowLargeIcon ? 42 : 24), ), ); } diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 8e1134a951..48e017e667 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -76,6 +76,7 @@ class _HomePageState extends State { String selectedTag = ""; bool _isTrashOpen = false; bool hasTrashedCodes = false; + bool isCompactMode = false; @override void initState() { @@ -244,6 +245,7 @@ class _HomePageState extends State { @override Widget build(BuildContext context) { final l10n = context.l10n; + isCompactMode = PreferenceService.instance.isCompactMode(); return PopScope( onPopInvokedWithResult: (_, result) async { @@ -446,6 +448,7 @@ class _HomePageState extends State { child: CodeWidget( key: ValueKey(code.hashCode), code, + isCompactMode: isCompactMode, ), ); }), @@ -476,6 +479,7 @@ class _HomePageState extends State { final codeState = _filteredCodes[index]; return CodeWidget( codeState, + isCompactMode: isCompactMode, ); }), itemCount: _filteredCodes.length, diff --git a/auth/lib/ui/settings/general_section_widget.dart b/auth/lib/ui/settings/general_section_widget.dart index 2b74cbf80d..2089b9f3ca 100644 --- a/auth/lib/ui/settings/general_section_widget.dart +++ b/auth/lib/ui/settings/general_section_widget.dart @@ -1,7 +1,9 @@ import 'dart:io'; import 'package:ente_auth/app/view/app.dart'; +import 'package:ente_auth/core/event_bus.dart'; import 'package:ente_auth/core/logging/super_logging.dart'; +import 'package:ente_auth/events/icons_changed_event.dart'; import 'package:ente_auth/l10n/l10n.dart'; import 'package:ente_auth/locale.dart'; import 'package:ente_auth/services/preference_service.dart'; @@ -78,6 +80,22 @@ class _AdvancedSectionWidgetState extends State { ), ), sectionOptionSpacing, + MenuItemWidget( + captionedTextWidget: CaptionedTextWidget( + title: l10n.compactMode, + ), + trailingWidget: ToggleSwitchWidget( + value: () => PreferenceService.instance.isCompactMode(), + onChanged: () async { + await PreferenceService.instance.setCompactMode( + !PreferenceService.instance.isCompactMode(), + ); + Bus.instance.fire(IconsChangedEvent()); + setState(() {}); + }, + ), + ), + sectionOptionSpacing, MenuItemWidget( captionedTextWidget: CaptionedTextWidget( title: l10n.shouldHideCode, @@ -88,7 +106,7 @@ class _AdvancedSectionWidgetState extends State { await PreferenceService.instance.setHideCodes( !PreferenceService.instance.shouldHideCodes(), ); - if(PreferenceService.instance.shouldHideCodes()) { + if (PreferenceService.instance.shouldHideCodes()) { showToast(context, context.l10n.doubleTapToViewHiddenCode); } setState(() {}); From 1ff0ab1adfc5d62831e381009b6f412d34856b9c Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Mon, 9 Sep 2024 17:35:40 +0530 Subject: [PATCH 11/26] [auth] Bump version --- auth/ios/Podfile.lock | 24 ++++++++++++------------ auth/lib/store/code_store.dart | 3 --- auth/pubspec.yaml | 2 +- 3 files changed, 13 insertions(+), 16 deletions(-) diff --git a/auth/ios/Podfile.lock b/auth/ios/Podfile.lock index 0f0fdac26e..f88450c0ac 100644 --- a/auth/ios/Podfile.lock +++ b/auth/ios/Podfile.lock @@ -82,9 +82,9 @@ PODS: - qr_code_scanner (0.2.0): - Flutter - MTBBarcodeScanner - - SDWebImage (5.19.2): - - SDWebImage/Core (= 5.19.2) - - SDWebImage/Core (5.19.2) + - SDWebImage (5.19.7): + - SDWebImage/Core (= 5.19.7) + - SDWebImage/Core (5.19.7) - Sentry/HybridSDK (8.33.0) - sentry_flutter (8.7.0): - Flutter @@ -100,16 +100,16 @@ PODS: - sqflite (0.0.3): - Flutter - FlutterMacOS - - "sqlite3 (3.46.0+1)": - - "sqlite3/common (= 3.46.0+1)" - - "sqlite3/common (3.46.0+1)" - - "sqlite3/dbstatvtab (3.46.0+1)": + - "sqlite3 (3.46.1+1)": + - "sqlite3/common (= 3.46.1+1)" + - "sqlite3/common (3.46.1+1)" + - "sqlite3/dbstatvtab (3.46.1+1)": - sqlite3/common - - "sqlite3/fts5 (3.46.0+1)": + - "sqlite3/fts5 (3.46.1+1)": - sqlite3/common - - "sqlite3/perf-threadsafe (3.46.0+1)": + - "sqlite3/perf-threadsafe (3.46.1+1)": - sqlite3/common - - "sqlite3/rtree (3.46.0+1)": + - "sqlite3/rtree (3.46.1+1)": - sqlite3/common - sqlite3_flutter_libs (0.0.1): - Flutter @@ -245,14 +245,14 @@ SPEC CHECKSUMS: path_provider_foundation: 2b6b4c569c0fb62ec74538f866245ac84301af46 privacy_screen: 1a131c052ceb3c3659934b003b0d397c2381a24e qr_code_scanner: bb67d64904c3b9658ada8c402e8b4d406d5d796e - SDWebImage: dfe95b2466a9823cf9f0c6d01217c06550d7b29a + SDWebImage: 8a6b7b160b4d710e2a22b6900e25301075c34cb3 Sentry: 8560050221424aef0bebc8e31eedf00af80f90a6 sentry_flutter: e26b861f744e5037a3faf9bf56603ec65d658a61 share_plus: 8875f4f2500512ea181eef553c3e27dba5135aad shared_preferences_foundation: fcdcbc04712aee1108ac7fda236f363274528f78 sodium_libs: 1faae17af662384acbd13e41867a0008cd2e2318 sqflite: 673a0e54cc04b7d6dba8d24fb8095b31c3a99eec - sqlite3: 292c3e1bfe89f64e51ea7fc7dab9182a017c8630 + sqlite3: 0bb0e6389d824e40296f531b858a2a0b71c0d2fb sqlite3_flutter_libs: c00457ebd31e59fa6bb830380ddba24d44fbcd3b SwiftyGif: 706c60cf65fa2bc5ee0313beece843c8eb8194d4 Toast: 1f5ea13423a1e6674c4abdac5be53587ae481c4e diff --git a/auth/lib/store/code_store.dart b/auth/lib/store/code_store.dart index eb0906d976..449bb93166 100644 --- a/auth/lib/store/code_store.dart +++ b/auth/lib/store/code_store.dart @@ -48,9 +48,6 @@ class CodeStore { code.generatedID = entity.generatedID; code.hasSynced = entity.hasSynced; codes.add(code); - codes.add(code); - codes.add(code); - codes.add(code); } if (sortCodes) { diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index 3b49002a09..9cfad4f7f5 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -1,6 +1,6 @@ name: ente_auth description: ente two-factor authenticator -version: 3.1.3+323 +version: 3.1.4+324 publish_to: none environment: From 9933e18ba548c51455f39f1f54da2080d4c2adac Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:23:55 +0530 Subject: [PATCH 12/26] [auth] Reduce extend ratio for compact mode --- auth/lib/ui/code_widget.dart | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index e9ac384889..f3249e9cf5 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -266,12 +266,13 @@ class _CodeWidgetState extends State { child: clippedCard(l10n), ); } - double slideSpace = isCompactMode ? 4 : 8; + final double slideSpace = isCompactMode ? 4 : 8; + final double extendRatio = isCompactMode ? 0.70 : 0.90; return Slidable( key: ValueKey(widget.code.hashCode), endActionPane: ActionPane( - extentRatio: 0.90, + extentRatio: extendRatio, motion: const ScrollMotion(), children: [ SizedBox(width: slideSpace), From 954afd6409a5fe68f9642daadfbf596a85b7fc80 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:28:36 +0530 Subject: [PATCH 13/26] [auth] Fix UX issue on deleting last code from trash --- auth/lib/l10n/arb/app_en.arb | 2 +- auth/lib/ui/home_page.dart | 4 ++++ 2 files changed, 5 insertions(+), 1 deletion(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 148f386d7f..da434bdc49 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -50,7 +50,7 @@ "deleteCodeTitle": "Delete code?", "deleteCodeMessage": "Are you sure you want to delete this code? This action is irreversible.", "trashCode": "Trash code?", - "trashCodeMessage": "Are you sure you want to trash code for {account}?.", + "trashCodeMessage": "Are you sure you want to trash code for {account}?", "trash": "Trash", "viewLogsAction": "View logs", "sendLogsDescription": "This will send across logs to help us debug your issue. While we take precautions to ensure that sensitive information is not logged, we encourage you to view these logs before sharing them.", diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 48e017e667..3b3636a888 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -106,12 +106,16 @@ class _HomePageState extends State { void _loadCodes() { CodeStore.instance.getAllCodes().then((codes) { _allCodes = codes; + hasTrashedCodes = false; for (final c in _allCodes ?? []) { if (c.isTrashed) { hasTrashedCodes = true; break; } } + if (!hasTrashedCodes) { + _isTrashOpen = false; + } CodeDisplayStore.instance.getAllTags(allCodes: _allCodes).then((value) { tags = value; From 823eb068f08b8e52ee0204893c701671f50463ae Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 10 Sep 2024 14:40:11 +0530 Subject: [PATCH 14/26] [auth] Add option to restore from trash --- auth/lib/l10n/arb/app_en.arb | 1 + auth/lib/ui/code_widget.dart | 200 ++++++++++++++++++++++------------- 2 files changed, 126 insertions(+), 75 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index da434bdc49..7e754f1ad3 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -211,6 +211,7 @@ "scanAQrCode": "Scan a QR code", "enterDetailsManually": "Enter details manually", "edit": "Edit", + "restore": "Restore", "copiedToClipboard": "Copied to clipboard", "copiedNextToClipboard": "Copied next code to clipboard", "error": "Error", diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index f3249e9cf5..02bc71bfbd 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -232,23 +232,33 @@ class _CodeWidgetState extends State { return ContextMenuRegion( contextMenu: ContextMenu( entries: [ - MenuItem( - label: 'QR', - icon: Icons.qr_code_2_outlined, - onSelected: () => _onShowQrPressed(null), - ), - MenuItem( - label: widget.code.isPinned ? l10n.unpinText : l10n.pinText, - icon: widget.code.isPinned - ? Icons.push_pin - : Icons.push_pin_outlined, - onSelected: () => _onPinPressed(null), - ), - MenuItem( - label: l10n.edit, - icon: Icons.edit, - onSelected: () => _onEditPressed(null), - ), + if (!widget.code.isTrashed) + MenuItem( + label: 'QR', + icon: Icons.qr_code_2_outlined, + onSelected: () => _onShowQrPressed(null), + ), + if (!widget.code.isTrashed) + MenuItem( + label: + widget.code.isPinned ? l10n.unpinText : l10n.pinText, + icon: widget.code.isPinned + ? Icons.push_pin + : Icons.push_pin_outlined, + onSelected: () => _onPinPressed(null), + ), + if (!widget.code.isTrashed) + MenuItem( + label: l10n.edit, + icon: Icons.edit, + onSelected: () => _onEditPressed(null), + ), + if (widget.code.isTrashed) + MenuItem( + label: l10n.restore, + icon: Icons.restore_outlined, + onSelected: () => _onRestoreClicked(null), + ), const MenuDivider(), MenuItem( label: widget.code.isTrashed ? l10n.delete : l10n.trash, @@ -267,7 +277,10 @@ class _CodeWidgetState extends State { ); } final double slideSpace = isCompactMode ? 4 : 8; - final double extendRatio = isCompactMode ? 0.70 : 0.90; + double extendRatio = isCompactMode ? 0.70 : 0.90; + if (widget.code.isTrashed) { + extendRatio = 0.50; + } return Slidable( key: ValueKey(widget.code.hashCode), @@ -275,64 +288,80 @@ class _CodeWidgetState extends State { extentRatio: extendRatio, motion: const ScrollMotion(), children: [ - SizedBox(width: slideSpace), - SlidableAction( - onPressed: _onShowQrPressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - icon: Icons.qr_code_2_outlined, - label: "QR", - padding: const EdgeInsets.only(left: 4, right: 0), - spacing: 8, - ), - SizedBox(width: slideSpace), - CustomSlidableAction( - onPressed: _onPinPressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - child: Column( - mainAxisAlignment: MainAxisAlignment.center, - children: [ - if (widget.code.isPinned) - SvgPicture.asset( - "assets/svg/pin-active.svg", - colorFilter: ui.ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), - ) - else - SvgPicture.asset( - "assets/svg/pin-inactive.svg", - colorFilter: ui.ColorFilter.mode( - Theme.of(context).colorScheme.primary, - BlendMode.srcIn, - ), - ), - const SizedBox(height: 8), - Text( - widget.code.isPinned ? l10n.unpinText : l10n.pinText, - ), - ], + if (!widget.code.isTrashed) SizedBox(width: slideSpace), + if (!widget.code.isTrashed) + SlidableAction( + onPressed: _onShowQrPressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + icon: Icons.qr_code_2_outlined, + label: "QR", + padding: const EdgeInsets.only(left: 4, right: 0), + spacing: 8, + ), + if (!widget.code.isTrashed) SizedBox(width: slideSpace), + if (!widget.code.isTrashed) + CustomSlidableAction( + onPressed: _onPinPressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + child: Column( + mainAxisAlignment: MainAxisAlignment.center, + children: [ + if (widget.code.isPinned) + SvgPicture.asset( + "assets/svg/pin-active.svg", + colorFilter: ui.ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ) + else + SvgPicture.asset( + "assets/svg/pin-inactive.svg", + colorFilter: ui.ColorFilter.mode( + Theme.of(context).colorScheme.primary, + BlendMode.srcIn, + ), + ), + const SizedBox(height: 8), + Text( + widget.code.isPinned ? l10n.unpinText : l10n.pinText, + ), + ], + ), + padding: const EdgeInsets.only(left: 4, right: 0), + ), + if (!widget.code.isTrashed) SizedBox(width: slideSpace), + if (!widget.code.isTrashed) + SlidableAction( + onPressed: _onEditPressed, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + icon: Icons.edit_outlined, + label: l10n.edit, + padding: const EdgeInsets.only(left: 4, right: 0), + spacing: 8, + ), + if (widget.code.isTrashed) SizedBox(width: slideSpace), + if (widget.code.isTrashed) + SlidableAction( + onPressed: _onRestoreClicked, + backgroundColor: Colors.grey.withOpacity(0.1), + borderRadius: const BorderRadius.all(Radius.circular(8)), + foregroundColor: + Theme.of(context).colorScheme.inverseBackgroundColor, + icon: Icons.restore_outlined, + label: l10n.restore, + padding: const EdgeInsets.only(left: 4, right: 0), + spacing: 8, ), - padding: const EdgeInsets.only(left: 4, right: 0), - ), - SizedBox(width: slideSpace), - SlidableAction( - onPressed: _onEditPressed, - backgroundColor: Colors.grey.withOpacity(0.1), - borderRadius: const BorderRadius.all(Radius.circular(8)), - foregroundColor: - Theme.of(context).colorScheme.inverseBackgroundColor, - icon: Icons.edit_outlined, - label: l10n.edit, - padding: const EdgeInsets.only(left: 4, right: 0), - spacing: 8, - ), SizedBox(width: slideSpace), SlidableAction( onPressed: widget.code.isTrashed @@ -658,6 +687,27 @@ class _CodeWidgetState extends State { ); } + void _onRestoreClicked(_) async { + if (!widget.code.isTrashed) { + showToast(context, 'Code is already restored'); + return; + } + FocusScope.of(context).requestFocus(); + + try { + final display = widget.code.display; + final Code code = widget.code.copyWith( + display: display.copyWith(trashed: false), + ); + await CodeStore.instance.addCode(code); + } catch (e) { + logger.severe('Failed to restore code: ${e.toString()}'); + if (mounted) { + showGenericErrorDialog(context: context, error: e).ignore(); + } + } + } + String _getCurrentOTP() { try { return getOTP(widget.code); From 02501caa71e0283f9a3d41ca2688959e3767036b Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Tue, 10 Sep 2024 15:17:56 +0530 Subject: [PATCH 15/26] [auth] Add viewQR code option on Edit screen --- .../view/setup_enter_secret_key_page.dart | 24 ++++++++++++++++--- 1 file changed, 21 insertions(+), 3 deletions(-) diff --git a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart index 1ba19d212a..83d4e92bc2 100644 --- a/auth/lib/onboarding/view/setup_enter_secret_key_page.dart +++ b/auth/lib/onboarding/view/setup_enter_secret_key_page.dart @@ -10,6 +10,7 @@ import 'package:ente_auth/onboarding/view/common/add_chip.dart'; import 'package:ente_auth/onboarding/view/common/add_tag.dart'; import 'package:ente_auth/onboarding/view/common/field_label.dart'; import 'package:ente_auth/onboarding/view/common/tag_chip.dart'; +import 'package:ente_auth/onboarding/view/view_qr_page.dart'; import 'package:ente_auth/store/code_display_store.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/components/buttons/button_widget.dart'; @@ -115,6 +116,22 @@ class _SetupEnterSecretKeyPageState extends State { return Scaffold( appBar: AppBar( title: Text(l10n.importAccountPageTitle), + actions: [ + if (widget.code != null) + IconButton( + icon: const Icon(Icons.qr_code_2_outlined), + enableFeedback: true, + onPressed: () { + Navigator.of(context).push( + MaterialPageRoute( + builder: (BuildContext context) { + return ViewQrPage(code: widget.code); + }, + ), + ).ignore(); + }, + ), + ], ), body: SingleChildScrollView( child: Padding( @@ -348,6 +365,9 @@ class _SetupEnterSecretKeyPageState extends State { final notes = _notesController.text.trim(); final isStreamCode = issuer.toLowerCase() == "steam" || issuer.toLowerCase().contains('steampowered.com'); + final CodeDisplay display = + widget.code?.display.copyWith(tags: tags) ?? CodeDisplay(tags: tags); + display.note = notes; if (widget.code != null && widget.code!.secret != secret) { ButtonResult? result = await showChoiceActionSheet( context, @@ -362,9 +382,7 @@ class _SetupEnterSecretKeyPageState extends State { return; } } - final CodeDisplay display = - widget.code?.display.copyWith(tags: tags) ?? CodeDisplay(tags: tags); - display.note = notes; + final Code newCode = widget.code == null ? Code.fromAccountAndSecret( isStreamCode ? Type.steam : Type.totp, From c47fcba5cc9d76bcfbb26ce23683cbed5e1a5261 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:37:21 +0530 Subject: [PATCH 16/26] [auth] Add method to generate future codes --- auth/lib/utils/totp_util.dart | 32 ++++++++++++++++++++++++++++++++ 1 file changed, 32 insertions(+) diff --git a/auth/lib/utils/totp_util.dart b/auth/lib/utils/totp_util.dart index f886215081..669f986cb5 100644 --- a/auth/lib/utils/totp_util.dart +++ b/auth/lib/utils/totp_util.dart @@ -52,6 +52,38 @@ String getNextTotp(Code code) { ); } +// generateFutureTotpCodes generates future TOTP codes based on the current time. +// It returns the start time and a list of future codes. +(int, List) generateFutureTotpCodes(Code code, int count) { + final int startTime = + ((DateTime.now().millisecondsSinceEpoch ~/ 1000) ~/ code.period) * + code.period * + 1000; + final String secret = getSanitizedSecret(code.secret); + final List codes = []; + if (code.type == Type.steam || code.issuer.toLowerCase() == 'steam') { + final SteamTOTP steamTotp = SteamTOTP(secret: code.secret); + for (int i = 1; i <= count; i++) { + int generatedTime = startTime + code.period * 1000 * i; + codes.add(steamTotp.generate(generatedTime ~/ 1000)); + } + } else { + for (int i = 1; i <= count; i++) { + int generatedTime = startTime + code.period * 1000 * i; + final genCode = otp.OTP.generateTOTPCodeString( + secret, + generatedTime, + length: code.digits, + interval: code.period, + algorithm: _getAlgorithm(code), + isGoogle: true, + ); + codes.add(genCode); + } + } + return (startTime, codes); +} + otp.Algorithm _getAlgorithm(Code code) { switch (code.algorithm) { case Algorithm.sha256: From af9e865745d0b509c1533f3f0518e128cbfd3bc9 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Wed, 11 Sep 2024 14:38:08 +0530 Subject: [PATCH 17/26] [auth] Add dropdown_button2 dep --- auth/pubspec.lock | 8 ++++++++ auth/pubspec.yaml | 1 + 2 files changed, 9 insertions(+) diff --git a/auth/pubspec.lock b/auth/pubspec.lock index d60f56eecf..bd9e708671 100644 --- a/auth/pubspec.lock +++ b/auth/pubspec.lock @@ -362,6 +362,14 @@ packages: url: "https://pub.dev" source: hosted version: "2.1.0" + dropdown_button2: + dependency: "direct main" + description: + name: dropdown_button2 + sha256: b0fe8d49a030315e9eef6c7ac84ca964250155a6224d491c1365061bc974a9e1 + url: "https://pub.dev" + source: hosted + version: "2.3.9" email_validator: dependency: "direct main" description: diff --git a/auth/pubspec.yaml b/auth/pubspec.yaml index 9cfad4f7f5..3a17c5cab3 100644 --- a/auth/pubspec.yaml +++ b/auth/pubspec.yaml @@ -27,6 +27,7 @@ dependencies: device_info_plus: ^9.1.1 dio: ^5.4.0 dotted_border: ^2.0.0+2 + dropdown_button2: ^2.3.9 email_validator: ^3.0.0 ente_crypto_dart: git: From 9ccb597e6e2e44ed393c76efbc4ca87a6ab3538d Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:37:49 +0530 Subject: [PATCH 18/26] [auth] Add hook to share code --- auth/lib/l10n/arb/app_en.arb | 2 + auth/lib/ui/code_widget.dart | 19 +++- auth/lib/ui/share/code_share.dart | 159 ++++++++++++++++++++++++++++++ auth/lib/utils/share_utils.dart | 51 ++++++++++ 4 files changed, 228 insertions(+), 3 deletions(-) create mode 100644 auth/lib/ui/share/code_share.dart diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 7e754f1ad3..1073924909 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -118,6 +118,7 @@ "emailVerificationToggle": "Email verification", "emailVerificationEnableWarning": "To avoid getting locked out of your account, be sure to store a copy of your email 2FA outside of Ente Auth before enabling email verification.", "authToChangeEmailVerificationSetting": "Please authenticate to change email verification", + "authenticateGeneric": "Please authenticate", "authToViewYourRecoveryKey": "Please authenticate to view your recovery key", "authToChangeYourEmail": "Please authenticate to change your email", "authToChangeYourPassword": "Please authenticate to change your password", @@ -211,6 +212,7 @@ "scanAQrCode": "Scan a QR code", "enterDetailsManually": "Enter details manually", "edit": "Edit", + "share": "Share", "restore": "Restore", "copiedToClipboard": "Copied to clipboard", "copiedNextToClipboard": "Copied next code to clipboard", diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 02bc71bfbd..7ab831c0c4 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -1,5 +1,7 @@ import 'dart:async'; +import 'dart:convert'; import 'dart:io'; +import 'dart:math'; import 'dart:ui' as ui; import 'package:auto_size_text/auto_size_text.dart'; @@ -15,6 +17,7 @@ import 'package:ente_auth/services/preference_service.dart'; import 'package:ente_auth/store/code_store.dart'; import 'package:ente_auth/theme/ente_theme.dart'; import 'package:ente_auth/ui/code_timer_progress.dart'; +import 'package:ente_auth/ui/share/code_share.dart'; import 'package:ente_auth/ui/utils/icon_utils.dart'; import 'package:ente_auth/utils/dialog_util.dart'; import 'package:ente_auth/utils/platform_util.dart'; @@ -291,13 +294,13 @@ class _CodeWidgetState extends State { if (!widget.code.isTrashed) SizedBox(width: slideSpace), if (!widget.code.isTrashed) SlidableAction( - onPressed: _onShowQrPressed, + onPressed: _onSharePressed, backgroundColor: Colors.grey.withOpacity(0.1), borderRadius: const BorderRadius.all(Radius.circular(8)), foregroundColor: Theme.of(context).colorScheme.inverseBackgroundColor, - icon: Icons.qr_code_2_outlined, - label: "QR", + icon: Icons.adaptive.share_outlined, + label: l10n.share, padding: const EdgeInsets.only(left: 4, right: 0), spacing: 8, ), @@ -605,6 +608,16 @@ class _CodeWidgetState extends State { ); } + Future _onSharePressed(_) async { + bool isAuthSuccessful = await LocalAuthenticationService.instance + .requestLocalAuthentication(context, context.l10n.authenticateGeneric); + await PlatformUtil.refocusWindows(); + if (!isAuthSuccessful) { + return; + } + showShareDialog(context, widget.code); + } + Future _onPinPressed(_) async { bool currentlyPinned = widget.code.isPinned; final display = widget.code.display; diff --git a/auth/lib/ui/share/code_share.dart b/auth/lib/ui/share/code_share.dart new file mode 100644 index 0000000000..3d3b8e7f65 --- /dev/null +++ b/auth/lib/ui/share/code_share.dart @@ -0,0 +1,159 @@ +import 'dart:convert'; +import 'dart:math'; +import 'dart:typed_data'; + +import 'package:dropdown_button2/dropdown_button2.dart'; +import 'package:ente_auth/l10n/l10n.dart'; +import 'package:ente_auth/models/code.dart'; +import 'package:ente_auth/ui/components/buttons/button_widget.dart'; +import 'package:ente_auth/ui/components/models/button_type.dart'; +import 'package:ente_auth/utils/dialog_util.dart'; +import 'package:ente_auth/utils/share_utils.dart'; +import 'package:ente_auth/utils/totp_util.dart'; +import 'package:ente_crypto_dart/ente_crypto_dart.dart'; +import 'package:flutter/material.dart'; +import 'package:logging/logging.dart'; + +class ShareCodeDialog extends StatefulWidget { + final Code code; + const ShareCodeDialog({super.key, required this.code}); + + @override + State createState() => _ShareCodeDialogState(); +} + +class _ShareCodeDialogState extends State { + final Logger logger = Logger('_ShareCodeDialogState'); + List items = [5, 15, 30, 60]; + + String getItemLabel(int min) { + if (min == 60) return '1 hour'; + if (min > 60) { + var hour = '${min ~/ 60}'; + if (min % 60 == 0) return '$hour hour'; + var minx = '${min % 60}'; + return '$hour hr $minx min'; + } + return '$min min'; + } + + int selectedValue = 15; + + @override + void initState() { + super.initState(); + } + + @override + Widget build(BuildContext context) { + return AlertDialog( + title: const Text('Share codes'), + content: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const Text( + 'Select the duration for which you want to share codes.', + ), + const SizedBox(height: 10), + DropdownButtonHideUnderline( + child: DropdownButton2( + hint: const Text('Select an option'), + items: items + .map( + (item) => DropdownMenuItem( + value: item, + child: Align( + alignment: Alignment.centerLeft, + child: Text(getItemLabel(item)), + ), + ), + ) + .toList(), + value: selectedValue, + onChanged: (value) { + setState(() { + selectedValue = value ?? 15; + }); + }, + ), + ), + ], + ), + actions: [ + ButtonWidget( + buttonType: ButtonType.primary, + buttonSize: ButtonSize.large, + labelText: context.l10n.share, + onTap: () async { + try { + await shareCode(); + + Navigator.of(context).pop(); + } catch (e) { + logger.warning('Failed to share code: ${e.toString()}'); + showGenericErrorDialog(context: context, error: e).ignore(); + } + }, + ), + const SizedBox(height: 8), + ButtonWidget( + buttonType: ButtonType.secondary, + buttonSize: ButtonSize.large, + labelText: context.l10n.cancel, + onTap: () async { + Navigator.of(context).pop(); + }, + ), + ], + ); + } + + Future shareCode() async { + final result = generateFutureTotpCodes(widget.code, 30); + // show toast with total time taken + Map data = { + 'startTime': result.$1, + 'step': widget.code.period, + 'codes': result.$2.join(","), + }; + try { + // generated 128 bit crypto secure random key + final Uint8List key = generate256BitKey(); + Uint8List input = utf8.encode(jsonEncode(data)); + final encResult = CryptoUtil.encryptSync(input, key); + String url = + 'https://auth.io/share?data=${uint8ListToUrlSafeBase64(encResult.encryptedData!)}&nonce=${uint8ListToUrlSafeBase64(encResult.nonce!)}#key=${uint8ListToUrlSafeBase64(key)}'; + logger.info('url: $url'); + shareText(url, context: context).ignore(); + } catch (e) { + logger.warning('Failed to encrypt data: ${e.toString()}'); + } + } + + String uint8ListToUrlSafeBase64(Uint8List data) { + String base64Str = base64UrlEncode(data); + return base64Str.replaceAll('=', ''); + } + + Uint8List generate256BitKey() { + final random = Random.secure(); + final bytes = Uint8List(32); // 32 bytes = 32 * 8 bits = 256 bits + for (int i = 0; i < bytes.length; i++) { + bytes[i] = random + .nextInt(256); // Generates a random number between 0 and 255 (1 byte) + } + return bytes; + } +} + +void showShareDialog(BuildContext context, Code code) { + showDialog( + context: context, + builder: (BuildContext context) { + return ShareCodeDialog( + code: code, + ); + }, + ); +} diff --git a/auth/lib/utils/share_utils.dart b/auth/lib/utils/share_utils.dart index 5bb8e08480..ca8b203678 100644 --- a/auth/lib/utils/share_utils.dart +++ b/auth/lib/utils/share_utils.dart @@ -5,6 +5,8 @@ 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:flutter/material.dart'; +import 'package:logging/logging.dart'; +import 'package:share_plus/share_plus.dart'; Future shareDialog( BuildContext context, @@ -49,3 +51,52 @@ Future shareDialog( ], ); } + +Rect _sharePosOrigin(BuildContext? context, GlobalKey? key) { + late final Rect rect; + if (context != null) { + rect = shareButtonRect(context, key); + } else { + rect = const Offset(20.0, 20.0) & const Size(10, 10); + } + return rect; +} + +/// Returns the rect of button if context and key are not null +/// If key is null, returned rect will be at the center of the screen +Rect shareButtonRect(BuildContext context, GlobalKey? shareButtonKey) { + Size size = MediaQuery.sizeOf(context); + final RenderObject? renderObject = + shareButtonKey?.currentContext?.findRenderObject(); + RenderBox? renderBox; + if (renderObject != null && renderObject is RenderBox) { + renderBox = renderObject; + } + if (renderBox == null) { + return Rect.fromLTWH(0, 0, size.width, size.height / 2); + } + size = renderBox.size; + final Offset position = renderBox.localToGlobal(Offset.zero); + return Rect.fromCenter( + center: position + Offset(size.width / 2, size.height / 2), + width: size.width, + height: size.height, + ); +} + +Future shareText( + String text, { + BuildContext? context, + GlobalKey? key, +}) async { + try { + final sharePosOrigin = _sharePosOrigin(context, key); + return Share.share( + text, + sharePositionOrigin: sharePosOrigin, + ); + } catch (e, s) { + Logger("ShareUtil").severe("failed to share text", e, s); + return ShareResult.unavailable; + } +} From bd18dc7a62f7c8361cf1ff2abd3323cdfe02fac5 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Thu, 12 Sep 2024 15:40:30 +0530 Subject: [PATCH 19/26] [auth] Disable sharing for HOTP codes --- auth/lib/ui/code_widget.dart | 12 ++++++++++-- 1 file changed, 10 insertions(+), 2 deletions(-) diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index 7ab831c0c4..bb5b3dc221 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -235,6 +235,13 @@ class _CodeWidgetState extends State { return ContextMenuRegion( contextMenu: ContextMenu( entries: [ + if (!widget.code.isTrashed && + widget.code.type.isTOTPCompatible) + MenuItem( + label: context.l10n.share, + icon: Icons.adaptive.share_outlined, + onSelected: () => _onSharePressed(null), + ), if (!widget.code.isTrashed) MenuItem( label: 'QR', @@ -291,8 +298,9 @@ class _CodeWidgetState extends State { extentRatio: extendRatio, motion: const ScrollMotion(), children: [ - if (!widget.code.isTrashed) SizedBox(width: slideSpace), - if (!widget.code.isTrashed) + if (!widget.code.isTrashed && widget.code.type.isTOTPCompatible) + SizedBox(width: slideSpace), + if (!widget.code.isTrashed && widget.code.type.isTOTPCompatible) SlidableAction( onPressed: _onSharePressed, backgroundColor: Colors.grey.withOpacity(0.1), From 710bb61f217e7636c0b55090d89bb8b36c8b49b0 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Thu, 12 Sep 2024 17:34:26 +0530 Subject: [PATCH 20/26] [auth] Fix bug in code generation --- auth/lib/utils/totp_util.dart | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/auth/lib/utils/totp_util.dart b/auth/lib/utils/totp_util.dart index 669f986cb5..6b7f53ae5b 100644 --- a/auth/lib/utils/totp_util.dart +++ b/auth/lib/utils/totp_util.dart @@ -63,12 +63,12 @@ String getNextTotp(Code code) { final List codes = []; if (code.type == Type.steam || code.issuer.toLowerCase() == 'steam') { final SteamTOTP steamTotp = SteamTOTP(secret: code.secret); - for (int i = 1; i <= count; i++) { + for (int i = 0; i < count; i++) { int generatedTime = startTime + code.period * 1000 * i; codes.add(steamTotp.generate(generatedTime ~/ 1000)); } } else { - for (int i = 1; i <= count; i++) { + for (int i = 0; i < count; i++) { int generatedTime = startTime + code.period * 1000 * i; final genCode = otp.OTP.generateTOTPCodeString( secret, From d77f4af04b8c2b37b5efea695d2b8064bbc1357b Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:17:49 +0530 Subject: [PATCH 21/26] [auth][mob] Allow sharing of codes --- auth/lib/ui/code_widget.dart | 2 -- auth/lib/ui/share/code_share.dart | 17 +++++++---------- 2 files changed, 7 insertions(+), 12 deletions(-) diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index bb5b3dc221..ffdcdbfb80 100644 --- a/auth/lib/ui/code_widget.dart +++ b/auth/lib/ui/code_widget.dart @@ -1,7 +1,5 @@ import 'dart:async'; -import 'dart:convert'; import 'dart:io'; -import 'dart:math'; import 'dart:ui' as ui; import 'package:auto_size_text/auto_size_text.dart'; diff --git a/auth/lib/ui/share/code_share.dart b/auth/lib/ui/share/code_share.dart index 3d3b8e7f65..2e6875b0cd 100644 --- a/auth/lib/ui/share/code_share.dart +++ b/auth/lib/ui/share/code_share.dart @@ -88,7 +88,6 @@ class _ShareCodeDialogState extends State { onTap: () async { try { await shareCode(); - Navigator.of(context).pop(); } catch (e) { logger.warning('Failed to share code: ${e.toString()}'); @@ -111,32 +110,30 @@ class _ShareCodeDialogState extends State { Future shareCode() async { final result = generateFutureTotpCodes(widget.code, 30); - // show toast with total time taken Map data = { 'startTime': result.$1, 'step': widget.code.period, 'codes': result.$2.join(","), }; try { - // generated 128 bit crypto secure random key - final Uint8List key = generate256BitKey(); + final Uint8List key = _generate256BitKey(); Uint8List input = utf8.encode(jsonEncode(data)); - final encResult = CryptoUtil.encryptSync(input, key); + final encResult = await CryptoUtil.encryptData(input, key); String url = - 'https://auth.io/share?data=${uint8ListToUrlSafeBase64(encResult.encryptedData!)}&nonce=${uint8ListToUrlSafeBase64(encResult.nonce!)}#key=${uint8ListToUrlSafeBase64(key)}'; - logger.info('url: $url'); + 'https://auth.ente.io/share?data=${_uint8ListToUrlSafeBase64(encResult.encryptedData!)}&header=${_uint8ListToUrlSafeBase64(encResult.header!)}#${_uint8ListToUrlSafeBase64(key)}'; shareText(url, context: context).ignore(); } catch (e) { - logger.warning('Failed to encrypt data: ${e.toString()}'); + logger.severe('Failed to encrypt data: ${e.toString()}'); + await showGenericErrorDialog(context: context, error: e); } } - String uint8ListToUrlSafeBase64(Uint8List data) { + String _uint8ListToUrlSafeBase64(Uint8List data) { String base64Str = base64UrlEncode(data); return base64Str.replaceAll('=', ''); } - Uint8List generate256BitKey() { + Uint8List _generate256BitKey() { final random = Random.secure(); final bytes = Uint8List(32); // 32 bytes = 32 * 8 bits = 256 bits for (int i = 0; i < bytes.length; i++) { From baeb47f94bc1d01da9c9f42683d39bfabbbc7101 Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:21:19 +0530 Subject: [PATCH 22/26] [auth] Ignore exception during os share --- auth/lib/ui/share/code_share.dart | 15 +++++++-------- 1 file changed, 7 insertions(+), 8 deletions(-) diff --git a/auth/lib/ui/share/code_share.dart b/auth/lib/ui/share/code_share.dart index 2e6875b0cd..43ecd34ff3 100644 --- a/auth/lib/ui/share/code_share.dart +++ b/auth/lib/ui/share/code_share.dart @@ -115,16 +115,15 @@ class _ShareCodeDialogState extends State { 'step': widget.code.period, 'codes': result.$2.join(","), }; + final Uint8List key = _generate256BitKey(); + Uint8List input = utf8.encode(jsonEncode(data)); + final encResult = await CryptoUtil.encryptData(input, key); + String url = + 'https://auth.ente.io/share?data=${_uint8ListToUrlSafeBase64(encResult.encryptedData!)}&header=${_uint8ListToUrlSafeBase64(encResult.header!)}#${_uint8ListToUrlSafeBase64(key)}'; try { - final Uint8List key = _generate256BitKey(); - Uint8List input = utf8.encode(jsonEncode(data)); - final encResult = await CryptoUtil.encryptData(input, key); - String url = - 'https://auth.ente.io/share?data=${_uint8ListToUrlSafeBase64(encResult.encryptedData!)}&header=${_uint8ListToUrlSafeBase64(encResult.header!)}#${_uint8ListToUrlSafeBase64(key)}'; - shareText(url, context: context).ignore(); + await shareText(url, context: context); } catch (e) { - logger.severe('Failed to encrypt data: ${e.toString()}'); - await showGenericErrorDialog(context: context, error: e); + logger.warning('Failed to share code: ${e.toString()}'); } } From 74dc15c38c8b6c77b5ac2bf16959b88d321ea17c Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:23:22 +0530 Subject: [PATCH 23/26] [auth] minor fixes --- auth/lib/ui/share/code_share.dart | 11 ++++++----- 1 file changed, 6 insertions(+), 5 deletions(-) diff --git a/auth/lib/ui/share/code_share.dart b/auth/lib/ui/share/code_share.dart index 43ecd34ff3..177e796a75 100644 --- a/auth/lib/ui/share/code_share.dart +++ b/auth/lib/ui/share/code_share.dart @@ -24,7 +24,7 @@ class ShareCodeDialog extends StatefulWidget { class _ShareCodeDialogState extends State { final Logger logger = Logger('_ShareCodeDialogState'); - List items = [5, 15, 30, 60]; + final List _durationInMins = [2, 5, 15, 30, 60]; String getItemLabel(int min) { if (min == 60) return '1 hour'; @@ -37,11 +37,12 @@ class _ShareCodeDialogState extends State { return '$min min'; } - int selectedValue = 15; + late final int selectedValue; @override void initState() { super.initState(); + selectedValue = _durationInMins[2]; } @override @@ -59,7 +60,7 @@ class _ShareCodeDialogState extends State { DropdownButtonHideUnderline( child: DropdownButton2( hint: const Text('Select an option'), - items: items + items: _durationInMins .map( (item) => DropdownMenuItem( value: item, @@ -89,8 +90,8 @@ class _ShareCodeDialogState extends State { try { await shareCode(); Navigator.of(context).pop(); - } catch (e) { - logger.warning('Failed to share code: ${e.toString()}'); + } catch (e, s) { + logger.severe('Failed to generate shared codes', e, s); showGenericErrorDialog(context: context, error: e).ignore(); } }, From 08a77a2defdc46f858007b803b19592528e2800e Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 13 Sep 2024 09:35:12 +0530 Subject: [PATCH 24/26] [auth] Fix codeCount for sharing --- auth/lib/l10n/arb/app_en.arb | 2 ++ auth/lib/ui/share/code_share.dart | 16 +++++++--------- 2 files changed, 9 insertions(+), 9 deletions(-) diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index 1073924909..4c70dd3880 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/auth/lib/l10n/arb/app_en.arb @@ -213,6 +213,8 @@ "enterDetailsManually": "Enter details manually", "edit": "Edit", "share": "Share", + "shareCodes": "Share codes", + "shareCodesDuration": "Select the duration for which you want to share codes.", "restore": "Restore", "copiedToClipboard": "Copied to clipboard", "copiedNextToClipboard": "Copied next code to clipboard", diff --git a/auth/lib/ui/share/code_share.dart b/auth/lib/ui/share/code_share.dart index 177e796a75..1a35601753 100644 --- a/auth/lib/ui/share/code_share.dart +++ b/auth/lib/ui/share/code_share.dart @@ -25,6 +25,7 @@ class ShareCodeDialog extends StatefulWidget { class _ShareCodeDialogState extends State { final Logger logger = Logger('_ShareCodeDialogState'); final List _durationInMins = [2, 5, 15, 30, 60]; + late int selectedValue; String getItemLabel(int min) { if (min == 60) return '1 hour'; @@ -37,8 +38,6 @@ class _ShareCodeDialogState extends State { return '$min min'; } - late final int selectedValue; - @override void initState() { super.initState(); @@ -48,14 +47,12 @@ class _ShareCodeDialogState extends State { @override Widget build(BuildContext context) { return AlertDialog( - title: const Text('Share codes'), + title: Text(context.l10n.shareCodes), content: Column( mainAxisSize: MainAxisSize.min, crossAxisAlignment: CrossAxisAlignment.start, children: [ - const Text( - 'Select the duration for which you want to share codes.', - ), + Text(context.l10n.shareCodesDuration), const SizedBox(height: 10), DropdownButtonHideUnderline( child: DropdownButton2( @@ -88,7 +85,7 @@ class _ShareCodeDialogState extends State { labelText: context.l10n.share, onTap: () async { try { - await shareCode(); + await shareCode(selectedValue); Navigator.of(context).pop(); } catch (e, s) { logger.severe('Failed to generate shared codes', e, s); @@ -109,8 +106,9 @@ class _ShareCodeDialogState extends State { ); } - Future shareCode() async { - final result = generateFutureTotpCodes(widget.code, 30); + Future shareCode(int durationInMin) async { + final int count = ((durationInMin * 60.0) / widget.code.period).ceil(); + final result = generateFutureTotpCodes(widget.code, count); Map data = { 'startTime': result.$1, 'step': widget.code.period, From b5f8964dc46e6c1f74db5a11f79771cb217e04fc Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 13 Sep 2024 10:35:11 +0530 Subject: [PATCH 25/26] [auth] Fix case when all codes are trashed --- auth/lib/ui/home_page.dart | 15 +++++++++++++-- 1 file changed, 13 insertions(+), 2 deletions(-) diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 3b3636a888..0d5cdbb37c 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -1,5 +1,6 @@ import 'dart:async'; import 'dart:io'; +import 'dart:math'; import 'package:app_links/app_links.dart'; import 'package:collection/collection.dart'; @@ -76,6 +77,7 @@ class _HomePageState extends State { String selectedTag = ""; bool _isTrashOpen = false; bool hasTrashedCodes = false; + bool hasNonTrashedCodes = false; bool isCompactMode = false; @override @@ -107,15 +109,23 @@ class _HomePageState extends State { CodeStore.instance.getAllCodes().then((codes) { _allCodes = codes; hasTrashedCodes = false; + hasNonTrashedCodes = false; for (final c in _allCodes ?? []) { if (c.isTrashed) { hasTrashedCodes = true; + } else { + hasNonTrashedCodes = true; + } + if (hasNonTrashedCodes && hasTrashedCodes) { break; } } if (!hasTrashedCodes) { _isTrashOpen = false; } + if (!hasNonTrashedCodes && hasTrashedCodes) { + _isTrashOpen = true; + } CodeDisplayStore.instance.getAllTags(allCodes: _allCodes).then((value) { tags = value; @@ -362,7 +372,8 @@ class _HomePageState extends State { final anyCodeHasError = _allCodes?.firstWhereOrNull((element) => element.hasError) != null; final indexOffset = anyCodeHasError ? 1 : 0; - final itemCount = tags.length + 1 + (hasTrashedCodes ? 1 : 0); + final itemCount = (hasNonTrashedCodes ? tags.length + 1 : 0) + + (hasTrashedCodes ? 1 : 0); final list = Column( crossAxisAlignment: CrossAxisAlignment.start, @@ -378,7 +389,7 @@ class _HomePageState extends State { const SizedBox(width: 8), itemCount: itemCount, itemBuilder: (context, index) { - if (index == 0) { + if (index == 0 && hasNonTrashedCodes) { return TagChip( label: "All", state: selectedTag == "" && _isTrashOpen == false From 0379d146401a1837da10fdb2a3fb582074e4c52e Mon Sep 17 00:00:00 2001 From: Neeraj Gupta <254676+ua741@users.noreply.github.com> Date: Fri, 13 Sep 2024 17:26:33 +0530 Subject: [PATCH 26/26] Lint fix --- auth/lib/ui/home_page.dart | 1 - 1 file changed, 1 deletion(-) diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index 0d5cdbb37c..7db6a3800f 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:io'; -import 'dart:math'; import 'package:app_links/app_links.dart'; import 'package:collection/collection.dart';