From b516bc8a52c65150a271ffb13115eb8ef3880c2f Mon Sep 17 00:00:00 2001 From: Prateek Sunal Date: Fri, 26 Apr 2024 14:17:12 +0530 Subject: [PATCH] feat: add pinning --- auth/lib/l10n/arb/app_en.arb | 4 +- auth/lib/models/code.dart | 41 ++++++++++++-- auth/lib/models/code_display.dart | 54 +++++++++++++++++++ auth/lib/store/code_store.dart | 34 ++++++++---- auth/lib/ui/code_widget.dart | 33 ++++++++++++ auth/lib/ui/home_page.dart | 9 +++- auth/lib/ui/scanner_page.dart | 4 +- auth/lib/ui/settings/data/export_widget.dart | 10 ++-- .../ui/settings/data/import/aegis_import.dart | 4 +- .../data/import/bitwarden_import.dart | 2 +- .../data/import/encrypted_ente_import.dart | 2 +- .../data/import/google_auth_import.dart | 3 +- .../settings/data/import/lastpass_import.dart | 4 +- .../data/import/plain_text_import.dart | 35 ++++++++---- .../data/import/raivo_plain_text_import.dart | 8 +-- .../settings/data/import/two_fas_import.dart | 2 +- auth/test/models/code_test.dart | 12 ++--- 17 files changed, 212 insertions(+), 49 deletions(-) create mode 100644 auth/lib/models/code_display.dart diff --git a/auth/lib/l10n/arb/app_en.arb b/auth/lib/l10n/arb/app_en.arb index e16a39c79..c7c8cac06 100644 --- a/auth/lib/l10n/arb/app_en.arb +++ b/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" } \ No newline at end of file diff --git a/auth/lib/models/code.dart b/auth/lib/models/code.dart index 7853eb19d..2610b62da 100644 --- a/auth/lib/models/code.dart +++ b/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 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; } } diff --git a/auth/lib/models/code_display.dart b/auth/lib/models/code_display.dart new file mode 100644 index 000000000..11f69492a --- /dev/null +++ b/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? 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 toJson() { + return { + 'pinned': pinned, + 'trashed': trashed, + 'lastUsedAt': lastUsedAt, + 'tapCount': tapCount, + }; + } +} diff --git a/auth/lib/store/code_store.dart b/auth/lib/store/code_store.dart index 9b199f165..70c1219cc 100644 --- a/auth/lib/store/code_store.dart +++ b/auth/lib/store/code_store.dart @@ -29,10 +29,17 @@ class CodeStore { final List 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 importOfflineCodes() async { - if(_isOfflineImportRunning) { + if (_isOfflineImportRunning) { return; } _isOfflineImportRunning = true; diff --git a/auth/lib/ui/code_widget.dart b/auth/lib/ui/code_widget.dart index f97e865ec..b1a6a3f05 100644 --- a/auth/lib/ui/code_widget.dart +++ b/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 { 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 { 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 { ); } + Future _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( diff --git a/auth/lib/ui/home_page.dart b/auth/lib/ui/home_page.dart index c3397d79a..82ccdb6e8 100644 --- a/auth/lib/ui/home_page.dart +++ b/auth/lib/ui/home_page.dart @@ -99,6 +99,8 @@ class _HomePageState extends State { _codes = codes; _hasLoaded = true; _applyFilteringAndRefresh(); + }).onError((error, stackTrace) { + _logger.severe('Error while loading codes', error, stackTrace); }); } @@ -258,6 +260,11 @@ class _HomePageState extends State { 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 { } 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); diff --git a/auth/lib/ui/scanner_page.dart b/auth/lib/ui/scanner_page.dart index 6a7793631..a0f88b7c8 100644 --- a/auth/lib/ui/scanner_page.dart +++ b/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 { } 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); } }); } diff --git a/auth/lib/ui/settings/data/export_widget.dart b/auth/lib/ui/settings/data/export_widget.dart index ef438301c..ccf3b7df5 100644 --- a/auth/lib/ui/settings/data/export_widget.dart +++ b/auth/lib/ui/settings/data/export_widget.dart @@ -172,9 +172,13 @@ Future _exportCodes(BuildContext context, String fileContent) async { Future _getAuthDataForExport() async { final codes = await CodeStore.instance.getAllCodes(); - String data = ""; + List> items = []; for (final code in codes) { - data += "${code.rawData}\n"; + items.add(code.toExportJson()); } - return data; + final data = { + "items": items, + }; + + return jsonEncode(data); } diff --git a/auth/lib/ui/settings/data/import/aegis_import.dart b/auth/lib/ui/settings/data/import/aegis_import.dart index b801e64a5..f6dd87252 100644 --- a/auth/lib/ui/settings/data/import/aegis_import.dart +++ b/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 _processAegisExportFile( } else { throw Exception('Invalid OTP type'); } - parsedCodes.add(Code.fromRawData(otpUrl)); + parsedCodes.add(Code.fromOTPAuthUrl(otpUrl)); } for (final code in parsedCodes) { diff --git a/auth/lib/ui/settings/data/import/bitwarden_import.dart b/auth/lib/ui/settings/data/import/bitwarden_import.dart index 90e527dde..28b821e36 100644 --- a/auth/lib/ui/settings/data/import/bitwarden_import.dart +++ b/auth/lib/ui/settings/data/import/bitwarden_import.dart @@ -86,7 +86,7 @@ Future _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']; diff --git a/auth/lib/ui/settings/data/import/encrypted_ente_import.dart b/auth/lib/ui/settings/data/import/encrypted_ente_import.dart index 511c9bbf9..3d7896f88 100644 --- a/auth/lib/ui/settings/data/import/encrypted_ente_import.dart +++ b/auth/lib/ui/settings/data/import/encrypted_ente_import.dart @@ -110,7 +110,7 @@ Future _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); } diff --git a/auth/lib/ui/settings/data/import/google_auth_import.dart b/auth/lib/ui/settings/data/import/google_auth_import.dart index 12df41a14..c14752fa4 100644 --- a/auth/lib/ui/settings/data/import/google_auth_import.dart +++ b/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 parseGoogleAuth(String qrCodeData) { } else { throw Exception('Invalid OTP type'); } - codes.add(Code.fromRawData(otpUrl)); + codes.add(Code.fromOTPAuthUrl(otpUrl)); } return codes; } catch (e, s) { diff --git a/auth/lib/ui/settings/data/import/lastpass_import.dart b/auth/lib/ui/settings/data/import/lastpass_import.dart index 53f8b453d..8c36f0253 100644 --- a/auth/lib/ui/settings/data/import/lastpass_import.dart +++ b/auth/lib/ui/settings/data/import/lastpass_import.dart @@ -89,8 +89,8 @@ Future _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) { diff --git a/auth/lib/ui/settings/data/import/plain_text_import.dart b/auth/lib/ui/settings/data/import/plain_text_import.dart index 03bc50dce..4d6f5d8f9 100644 --- a/auth/lib/ui/settings/data/import/plain_text_import.dart +++ b/auth/lib/ui/settings/data/import/plain_text_import.dart @@ -101,20 +101,35 @@ Future _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 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 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 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); } diff --git a/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart b/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart index 48fc74888..3590a38b3 100644 --- a/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart +++ b/auth/lib/ui/settings/data/import/raivo_plain_text_import.dart @@ -57,7 +57,7 @@ Future _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 _pickRaivoJsonFile(BuildContext context) async { } } -Future _processRaivoExportFile(BuildContext context,String path) async { +Future _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 _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) { diff --git a/auth/lib/ui/settings/data/import/two_fas_import.dart b/auth/lib/ui/settings/data/import/two_fas_import.dart index ae5a05b0b..710d898d4 100644 --- a/auth/lib/ui/settings/data/import/two_fas_import.dart +++ b/auth/lib/ui/settings/data/import/two_fas_import.dart @@ -158,7 +158,7 @@ Future _process2FasExportFile( } else { throw Exception('Invalid OTP type'); } - parsedCodes.add(Code.fromRawData(otpUrl)); + parsedCodes.add(Code.fromOTPAuthUrl(otpUrl)); } for (final code in parsedCodes) { diff --git a/auth/test/models/code_test.dart b/auth/test/models/code_test.dart index 30ea23a4f..b2d2c28d6 100644 --- a/auth/test/models/code_test.dart +++ b/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'); });