feat: add pinning

This commit is contained in:
Prateek Sunal 2024-04-26 14:17:12 +05:30
parent c1103b656c
commit b516bc8a52
17 changed files with 212 additions and 49 deletions

View file

@ -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"
}

View file

@ -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;
}
}

View file

@ -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,
};
}
}

View file

@ -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;

View file

@ -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(

View file

@ -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);

View file

@ -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);
}
});
}

View file

@ -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);
}

View file

@ -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) {

View file

@ -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'];

View file

@ -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);
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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);
}

View file

@ -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) {

View file

@ -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) {

View file

@ -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');
});