Explorar el Código

feat: add pinning

Prateek Sunal hace 1 año
padre
commit
b516bc8a52

+ 3 - 1
auth/lib/l10n/arb/app_en.arb

@@ -157,6 +157,7 @@
       }
     }
   },
+  "invalidQRCode": "Invalid QR code",
   "noRecoveryKeyTitle": "No recovery key?",
   "enterEmailHint": "Enter your email address",
   "invalidEmailTitle": "Invalid email address",
@@ -421,5 +422,6 @@
   "invalidEndpoint": "Invalid endpoint",
   "invalidEndpointMessage": "Sorry, the endpoint you entered is invalid. Please enter a valid endpoint and try again.",
   "endpointUpdatedMessage": "Endpoint updated successfully",
-  "customEndpoint": "Connected to {endpoint}"
+  "customEndpoint": "Connected to {endpoint}",
+  "pinText": "Pin"
 }

+ 37 - 4
auth/lib/models/code.dart

@@ -1,4 +1,6 @@
+import 'package:ente_auth/models/code_display.dart';
 import 'package:ente_auth/utils/totp_util.dart';
+import 'package:flutter/foundation.dart';
 
 class Code {
   static const defaultDigits = 6;
@@ -12,10 +14,16 @@ class Code {
   final String secret;
   final Algorithm algorithm;
   final Type type;
+
+  /// otpauth url in the code
   final String rawData;
   final int counter;
   bool? hasSynced;
 
+  final CodeDisplay? display;
+
+  bool get isPinned => display?.pinned ?? false;
+
   Code(
     this.account,
     this.issuer,
@@ -27,6 +35,7 @@ class Code {
     this.counter,
     this.rawData, {
     this.generatedID,
+    this.display,
   });
 
   Code copyWith({
@@ -38,6 +47,7 @@ class Code {
     Algorithm? algorithm,
     Type? type,
     int? counter,
+    CodeDisplay? display,
   }) {
     final String updateAccount = account ?? this.account;
     final String updateIssuer = issuer ?? this.issuer;
@@ -47,6 +57,7 @@ class Code {
     final Algorithm updatedAlgo = algorithm ?? this.algorithm;
     final Type updatedType = type ?? this.type;
     final int updatedCounter = counter ?? this.counter;
+    final CodeDisplay? updatedDisplay = display ?? this.display;
 
     return Code(
       updateAccount,
@@ -59,6 +70,7 @@ class Code {
       updatedCounter,
       "otpauth://${updatedType.name}/$updateIssuer:$updateAccount?algorithm=${updatedAlgo.name}&digits=$updatedDigits&issuer=$updateIssuer&period=$updatePeriod&secret=$updatedSecret${updatedType == Type.hotp ? "&counter=$updatedCounter" : ""}",
       generatedID: generatedID,
+      display: updatedDisplay,
     );
   }
 
@@ -80,7 +92,7 @@ class Code {
     );
   }
 
-  static Code fromRawData(String rawData) {
+  static Code fromOTPAuthUrl(String rawData, {CodeDisplay? display}) {
     Uri uri = Uri.parse(rawData);
     try {
       return Code(
@@ -98,7 +110,7 @@ class Code {
       // if account name contains # without encoding,
       // rest of the url are treated as url fragment
       if (rawData.contains("#")) {
-        return Code.fromRawData(rawData.replaceAll("#", '%23'));
+        return Code.fromOTPAuthUrl(rawData.replaceAll("#", '%23'));
       } else {
         rethrow;
       }
@@ -122,6 +134,26 @@ class Code {
     }
   }
 
+  static Code fromExportJson(Map rawJson) {
+    try {
+      Code resultCode = Code.fromOTPAuthUrl(
+        rawJson['rawData'],
+        display: CodeDisplay.fromJson(rawJson['display']),
+      );
+      return resultCode;
+    } catch (e) {
+      debugPrint("Failed to parse code from export json $e");
+      rethrow;
+    }
+  }
+
+  Map<String, dynamic> toExportJson() {
+    return {
+      'rawData': rawData,
+      'display': display?.toJson(),
+    };
+  }
+
   static String _getIssuer(Uri uri) {
     try {
       if (uri.queryParameters.containsKey("issuer")) {
@@ -184,7 +216,7 @@ class Code {
   }
 
   static Type _getType(Uri uri) {
-    if (uri.host == "totp") {
+    if (uri.host == "totp" || uri.host == "steam") {
       return Type.totp;
     } else if (uri.host == "hotp") {
       return Type.hotp;
@@ -216,7 +248,8 @@ class Code {
         secret.hashCode ^
         type.hashCode ^
         counter.hashCode ^
-        rawData.hashCode;
+        rawData.hashCode ^
+        display.hashCode;
   }
 }
 

+ 54 - 0
auth/lib/models/code_display.dart

@@ -0,0 +1,54 @@
+/// Used to store the display settings of a code.
+class CodeDisplay {
+  final bool pinned;
+  final bool trashed;
+  final int lastUsedAt;
+  final int tapCount;
+
+  CodeDisplay({
+    this.pinned = false,
+    this.trashed = false,
+    this.lastUsedAt = 0,
+    this.tapCount = 0,
+  });
+
+  // copyWith
+  CodeDisplay copyWith({
+    bool? pinned,
+    bool? trashed,
+    int? lastUsedAt,
+    int? tapCount,
+  }) {
+    final bool updatedPinned = pinned ?? this.pinned;
+    final bool updatedTrashed = trashed ?? this.trashed;
+    final int updatedLastUsedAt = lastUsedAt ?? this.lastUsedAt;
+    final int updatedTapCount = tapCount ?? this.tapCount;
+    return CodeDisplay(
+      pinned: updatedPinned,
+      trashed: updatedTrashed,
+      lastUsedAt: updatedLastUsedAt,
+      tapCount: updatedTapCount,
+    );
+  }
+
+  factory CodeDisplay.fromJson(Map<String, dynamic>? json) {
+    if (json == null) {
+      return CodeDisplay();
+    }
+    return CodeDisplay(
+      pinned: json['pinned'] ?? false,
+      trashed: json['trashed'] ?? false,
+      lastUsedAt: json['lastUsedAt'] ?? 0,
+      tapCount: json['tapCount'] ?? 0,
+    );
+  }
+
+  Map<String, dynamic> toJson() {
+    return {
+      'pinned': pinned,
+      'trashed': trashed,
+      'lastUsedAt': lastUsedAt,
+      'tapCount': tapCount,
+    };
+  }
+}

+ 23 - 11
auth/lib/store/code_store.dart

@@ -29,10 +29,17 @@ class CodeStore {
     final List<Code> codes = [];
     for (final entity in entities) {
       final decodeJson = jsonDecode(entity.rawData);
-      final code = Code.fromRawData(decodeJson);
-      code.generatedID = entity.generatedID;
-      code.hasSynced = entity.hasSynced;
-      codes.add(code);
+      if (decodeJson is String && decodeJson.startsWith('otpauth://')) {
+        final code = Code.fromOTPAuthUrl(decodeJson);
+        code.generatedID = entity.generatedID;
+        code.hasSynced = entity.hasSynced;
+        codes.add(code);
+      } else {
+        final code = Code.fromExportJson(decodeJson);
+        code.generatedID = entity.generatedID;
+        code.hasSynced = entity.hasSynced;
+        codes.add(code);
+      }
     }
 
     // sort codes by issuer,account
@@ -54,28 +61,33 @@ class CodeStore {
     final mode = accountMode ?? _authenticatorService.getAccountMode();
     final codes = await getAllCodes(accountMode: mode);
     bool isExistingCode = false;
+    bool hasSameCode = false;
     for (final existingCode in codes) {
-      if (existingCode == code) {
-        _logger.info("Found duplicate code, skipping add");
-        return AddResult.duplicate;
-      } else if (existingCode.generatedID == code.generatedID) {
+      if (code.generatedID != null &&
+          existingCode.generatedID == code.generatedID) {
         isExistingCode = true;
         break;
       }
+      if (existingCode == code) {
+        hasSameCode = true;
+      }
+    }
+    if (!isExistingCode && hasSameCode) {
+      return AddResult.duplicate;
     }
     late AddResult result;
     if (isExistingCode) {
       result = AddResult.updateCode;
       await _authenticatorService.updateEntry(
         code.generatedID!,
-        jsonEncode(code.rawData),
+        jsonEncode(code.toExportJson()),
         shouldSync,
         mode,
       );
     } else {
       result = AddResult.newCode;
       code.generatedID = await _authenticatorService.addEntry(
-        jsonEncode(code.rawData),
+        jsonEncode(code.toExportJson()),
         shouldSync,
         mode,
       );
@@ -93,7 +105,7 @@ class CodeStore {
   bool _isOfflineImportRunning = false;
 
   Future<void> importOfflineCodes() async {
-    if(_isOfflineImportRunning) {
+    if (_isOfflineImportRunning) {
       return;
     }
     _isOfflineImportRunning = true;

+ 33 - 0
auth/lib/ui/code_widget.dart

@@ -6,6 +6,7 @@ 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/models/code_display.dart';
 import 'package:ente_auth/onboarding/view/setup_enter_secret_key_page.dart';
 import 'package:ente_auth/onboarding/view/view_qr_page.dart';
 import 'package:ente_auth/services/local_authentication_service.dart';
@@ -97,6 +98,13 @@ class _CodeWidgetState extends State<CodeWidget> {
                     icon: Icons.qr_code_2_outlined,
                     onSelected: () => _onShowQrPressed(null),
                   ),
+                  MenuItem(
+                    label: l10n.pinText,
+                    icon: widget.code.isPinned
+                        ? Icons.push_pin
+                        : Icons.push_pin_outlined,
+                    onSelected: () => _onShowQrPressed(null),
+                  ),
                   MenuItem(
                     label: l10n.edit,
                     icon: Icons.edit,
@@ -139,6 +147,22 @@ class _CodeWidgetState extends State<CodeWidget> {
                 const SizedBox(
                   width: 4,
                 ),
+                SlidableAction(
+                  onPressed: _onPinPressed,
+                  backgroundColor: Colors.grey.withOpacity(0.1),
+                  borderRadius: const BorderRadius.all(Radius.circular(12.0)),
+                  foregroundColor:
+                      Theme.of(context).colorScheme.inverseBackgroundColor,
+                  icon: widget.code.isPinned
+                      ? Icons.push_pin
+                      : Icons.push_pin_outlined,
+                  label: l10n.pinText,
+                  padding: const EdgeInsets.only(left: 4, right: 0),
+                  spacing: 8,
+                ),
+                const SizedBox(
+                  width: 4,
+                ),
                 SlidableAction(
                   onPressed: _onEditPressed,
                   backgroundColor: Colors.grey.withOpacity(0.1),
@@ -448,6 +472,15 @@ class _CodeWidgetState extends State<CodeWidget> {
     );
   }
 
+  Future<void> _onPinPressed(_) async {
+    bool currentlyPinned = widget.code.isPinned;
+    final display = widget.code.display ?? CodeDisplay();
+    final Code code = widget.code.copyWith(
+      display: display.copyWith(pinned: !currentlyPinned),
+    );
+    unawaited(CodeStore.instance.addCode(code));
+  }
+
   void _onDeletePressed(_) async {
     bool isAuthSuccessful =
         await LocalAuthenticationService.instance.requestLocalAuthentication(

+ 8 - 1
auth/lib/ui/home_page.dart

@@ -99,6 +99,8 @@ class _HomePageState extends State<HomePage> {
       _codes = codes;
       _hasLoaded = true;
       _applyFilteringAndRefresh();
+    }).onError((error, stackTrace) {
+      _logger.severe('Error while loading codes', error, stackTrace);
     });
   }
 
@@ -258,6 +260,11 @@ class _HomePageState extends State<HomePage> {
           onManuallySetupTap: _redirectToManualEntryPage,
         );
       } else {
+        _filteredCodes.sort((a, b) {
+          if (b.isPinned && !a.isPinned) return 1;
+          if (!b.isPinned && a.isPinned) return -1;
+          return 0;
+        });
         final list = AlignedGridView.count(
           crossAxisCount: (MediaQuery.sizeOf(context).width ~/ 400)
               .clamp(1, double.infinity)
@@ -360,7 +367,7 @@ class _HomePageState extends State<HomePage> {
     }
     if (mounted && link.toLowerCase().startsWith("otpauth://")) {
       try {
-        final newCode = Code.fromRawData(link);
+        final newCode = Code.fromOTPAuthUrl(link);
         getNextTotp(newCode);
         CodeStore.instance.addCode(newCode);
         _focusNewCode(newCode);

+ 3 - 1
auth/lib/ui/scanner_page.dart

@@ -2,6 +2,7 @@ import 'dart:io';
 
 import 'package:ente_auth/l10n/l10n.dart';
 import 'package:ente_auth/models/code.dart';
+import 'package:ente_auth/utils/toast_util.dart';
 import 'package:flutter/material.dart';
 import 'package:qr_code_scanner/qr_code_scanner.dart';
 
@@ -66,11 +67,12 @@ class ScannerPageState extends State<ScannerPage> {
     }
     controller.scannedDataStream.listen((scanData) {
       try {
-        final code = Code.fromRawData(scanData.code!);
+        final code = Code.fromOTPAuthUrl(scanData.code!);
         controller.dispose();
         Navigator.of(context).pop(code);
       } catch (e) {
         // Log
+        showToast(context, context.l10n.invalidQRCode);
       }
     });
   }

+ 7 - 3
auth/lib/ui/settings/data/export_widget.dart

@@ -172,9 +172,13 @@ Future<void> _exportCodes(BuildContext context, String fileContent) async {
 
 Future<String> _getAuthDataForExport() async {
   final codes = await CodeStore.instance.getAllCodes();
-  String data = "";
+  List<Map<String, dynamic>> items = [];
   for (final code in codes) {
-    data += "${code.rawData}\n";
+    items.add(code.toExportJson());
   }
-  return data;
+  final data = {
+    "items": items,
+  };
+
+  return jsonEncode(data);
 }

+ 2 - 2
auth/lib/ui/settings/data/import/aegis_import.dart

@@ -2,8 +2,8 @@ import 'dart:async';
 import 'dart:convert';
 import 'dart:io';
 import 'dart:typed_data';
-import 'package:convert/convert.dart';
 
+import 'package:convert/convert.dart';
 import 'package:ente_auth/l10n/l10n.dart';
 import 'package:ente_auth/models/code.dart';
 import 'package:ente_auth/services/authenticator_service.dart';
@@ -150,7 +150,7 @@ Future<int?> _processAegisExportFile(
     } else {
       throw Exception('Invalid OTP type');
     }
-    parsedCodes.add(Code.fromRawData(otpUrl));
+    parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
   }
 
   for (final code in parsedCodes) {

+ 1 - 1
auth/lib/ui/settings/data/import/bitwarden_import.dart

@@ -86,7 +86,7 @@ Future<int?> _processBitwardenExportFile(
       Code code;
 
       if (totp.contains("otpauth://")) {
-        code = Code.fromRawData(totp);
+        code = Code.fromOTPAuthUrl(totp);
       } else {
         var issuer = item['name'];
         var account = item['login']['username'];

+ 1 - 1
auth/lib/ui/settings/data/import/encrypted_ente_import.dart

@@ -110,7 +110,7 @@ Future<void> _decryptExportData(
           final parsedCodes = [];
           for (final code in splitCodes) {
             try {
-              parsedCodes.add(Code.fromRawData(code));
+              parsedCodes.add(Code.fromOTPAuthUrl(code));
             } catch (e) {
               Logger('EncryptedText').severe("Could not parse code", e);
             }

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

@@ -1,5 +1,6 @@
 import 'dart:async';
 import 'dart:convert';
+
 import 'package:base32/base32.dart';
 import 'package:ente_auth/l10n/l10n.dart';
 import 'package:ente_auth/models/code.dart';
@@ -124,7 +125,7 @@ List<Code> parseGoogleAuth(String qrCodeData) {
       } else {
         throw Exception('Invalid OTP type');
       }
-      codes.add(Code.fromRawData(otpUrl));
+      codes.add(Code.fromOTPAuthUrl(otpUrl));
     }
     return codes;
   } catch (e, s) {

+ 2 - 2
auth/lib/ui/settings/data/import/lastpass_import.dart

@@ -89,8 +89,8 @@ Future<int?> _processLastpassExportFile(
 
     // Build the OTP URL
     String otpUrl =
-          'otpauth://totp/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&period=$timer';
-    parsedCodes.add(Code.fromRawData(otpUrl));
+        'otpauth://totp/$issuer:$account?secret=$secret&issuer=$issuer&algorithm=$algorithm&digits=$digits&period=$timer';
+    parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
   }
 
   for (final code in parsedCodes) {

+ 25 - 10
auth/lib/ui/settings/data/import/plain_text_import.dart

@@ -101,20 +101,35 @@ Future<void> _pickImportFile(BuildContext context) async {
   final progressDialog = createProgressDialog(context, l10n.pleaseWait);
   await progressDialog.show();
   try {
+    final parsedCodes = [];
     File file = File(result.files.single.path!);
     final codes = await file.readAsString();
-    List<String> splitCodes = codes.split(",");
-    if (splitCodes.length == 1) {
-      splitCodes = const LineSplitter().convert(codes);
-    }
-    final parsedCodes = [];
-    for (final code in splitCodes) {
-      try {
-        parsedCodes.add(Code.fromRawData(code));
-      } catch (e) {
-        Logger('PlainText').severe("Could not parse code", e);
+
+    if (codes.startsWith('otpauth://')) {
+      List<String> splitCodes = codes.split(",");
+      if (splitCodes.length == 1) {
+        splitCodes = codes.split("\n");
+      }
+      for (final code in splitCodes) {
+        try {
+          parsedCodes.add(Code.fromOTPAuthUrl(code));
+        } catch (e) {
+          Logger('PlainText').severe("Could not parse code", e);
+        }
+      }
+    } else {
+      final decodedCodes = jsonDecode(codes);
+      List<Map> splitCodes = List.from(decodedCodes["items"]);
+
+      for (final code in splitCodes) {
+        try {
+          parsedCodes.add(Code.fromExportJson(code));
+        } catch (e) {
+          Logger('PlainText').severe("Could not parse code", e);
+        }
       }
     }
+
     for (final code in parsedCodes) {
       await CodeStore.instance.addCode(code, shouldSync: false);
     }

+ 4 - 4
auth/lib/ui/settings/data/import/raivo_plain_text_import.dart

@@ -57,7 +57,7 @@ Future<void> _pickRaivoJsonFile(BuildContext context) async {
     String path = result.files.single.path!;
     int? count = await _processRaivoExportFile(context, path);
     await progressDialog.hide();
-    if(count != null) {
+    if (count != null) {
       await importSuccessDialog(context, count);
     }
   } catch (e) {
@@ -70,9 +70,9 @@ Future<void> _pickRaivoJsonFile(BuildContext context) async {
   }
 }
 
-Future<int?> _processRaivoExportFile(BuildContext context,String path) async {
+Future<int?> _processRaivoExportFile(BuildContext context, String path) async {
   File file = File(path);
-  if(path.endsWith('.zip')) {
+  if (path.endsWith('.zip')) {
     await showErrorDialog(
       context,
       context.l10n.sorry,
@@ -105,7 +105,7 @@ Future<int?> _processRaivoExportFile(BuildContext context,String path) async {
     } else {
       throw Exception('Invalid OTP type');
     }
-    parsedCodes.add(Code.fromRawData(otpUrl));
+    parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
   }
 
   for (final code in parsedCodes) {

+ 1 - 1
auth/lib/ui/settings/data/import/two_fas_import.dart

@@ -158,7 +158,7 @@ Future<int?> _process2FasExportFile(
     } else {
       throw Exception('Invalid OTP type');
     }
-    parsedCodes.add(Code.fromRawData(otpUrl));
+    parsedCodes.add(Code.fromOTPAuthUrl(otpUrl));
   }
 
   for (final code in parsedCodes) {

+ 6 - 6
auth/test/models/code_test.dart

@@ -3,7 +3,7 @@ import 'package:flutter_test/flutter_test.dart';
 
 void main() {
   test("parseCodeFromRawData", () {
-    final code1 = Code.fromRawData(
+    final code1 = Code.fromOTPAuthUrl(
       "otpauth://totp/example%20finance%3Aee%40ff.gg?secret=ASKZNWOU6SVYAMVS",
     );
     expect(code1.issuer, "example finance", reason: "issuerMismatch");
@@ -12,7 +12,7 @@ void main() {
   });
 
   test("parseDocumentedFormat", () {
-    final code = Code.fromRawData(
+    final code = Code.fromOTPAuthUrl(
       "otpauth://totp/testdata@ente.io?secret=ASKZNWOU6SVYAMVS&issuer=GitHub",
     );
     expect(code.issuer, "GitHub", reason: "issuerMismatch");
@@ -21,7 +21,7 @@ void main() {
   });
 
   test("validateCount", () {
-    final code = Code.fromRawData(
+    final code = Code.fromOTPAuthUrl(
       "otpauth://hotp/testdata@ente.io?secret=ASKZNWOU6SVYAMVS&issuer=GitHub&counter=15",
     );
     expect(code.issuer, "GitHub", reason: "issuerMismatch");
@@ -32,7 +32,7 @@ void main() {
 //
 
   test("parseWithFunnyAccountName", () {
-    final code = Code.fromRawData(
+    final code = Code.fromOTPAuthUrl(
       "otpauth://totp/Mongo Atlas:Acc !@#444?algorithm=sha1&digits=6&issuer=Mongo Atlas&period=30&secret=NI4CTTFEV4G2JFE6",
     );
     expect(code.issuer, "Mongo Atlas", reason: "issuerMismatch");
@@ -43,11 +43,11 @@ void main() {
   test("parseAndUpdateInChinese", () {
     const String rubberDuckQr =
         'otpauth://totp/%E6%A9%A1%E7%9A%AE%E9%B8%AD?secret=2CWDCK4EOIN5DJDRMYUMYBBO4MKSR5AX&issuer=ente.io';
-    final code = Code.fromRawData(rubberDuckQr);
+    final code = Code.fromOTPAuthUrl(rubberDuckQr);
     expect(code.account, '橡皮鸭');
     final String updatedRawCode =
         code.copyWith(account: '伍迪', issuer: '鸭子').rawData;
-    final updateCode = Code.fromRawData(updatedRawCode);
+    final updateCode = Code.fromOTPAuthUrl(updatedRawCode);
     expect(updateCode.account, '伍迪', reason: 'updated accountMismatch');
     expect(updateCode.issuer, '鸭子', reason: 'updated issuerMismatch');
   });