Refactor common code between add and rename accounts.

This commit is contained in:
Dain Nilsson 2022-01-31 11:02:34 +01:00
parent a5f73f7d94
commit 40335e66da
No known key found for this signature in database
GPG Key ID: F04367096FBA95E8
7 changed files with 240 additions and 68 deletions

View File

@ -18,3 +18,8 @@ class Version with _$Version {
return '$major.$minor.$patch'; return '$major.$minor.$patch';
} }
} }
@freezed
class Pair<T1, T2> with _$Pair<T1, T2> {
factory Pair(T1 first, T2 second) = _Pair<T1, T2>;
}

View File

@ -168,3 +168,147 @@ abstract class _Version extends Version {
_$VersionCopyWith<_Version> get copyWith => _$VersionCopyWith<_Version> get copyWith =>
throw _privateConstructorUsedError; throw _privateConstructorUsedError;
} }
/// @nodoc
class _$PairTearOff {
const _$PairTearOff();
_Pair<T1, T2> call<T1, T2>(T1 first, T2 second) {
return _Pair<T1, T2>(
first,
second,
);
}
}
/// @nodoc
const $Pair = _$PairTearOff();
/// @nodoc
mixin _$Pair<T1, T2> {
T1 get first => throw _privateConstructorUsedError;
T2 get second => throw _privateConstructorUsedError;
@JsonKey(ignore: true)
$PairCopyWith<T1, T2, Pair<T1, T2>> get copyWith =>
throw _privateConstructorUsedError;
}
/// @nodoc
abstract class $PairCopyWith<T1, T2, $Res> {
factory $PairCopyWith(Pair<T1, T2> value, $Res Function(Pair<T1, T2>) then) =
_$PairCopyWithImpl<T1, T2, $Res>;
$Res call({T1 first, T2 second});
}
/// @nodoc
class _$PairCopyWithImpl<T1, T2, $Res> implements $PairCopyWith<T1, T2, $Res> {
_$PairCopyWithImpl(this._value, this._then);
final Pair<T1, T2> _value;
// ignore: unused_field
final $Res Function(Pair<T1, T2>) _then;
@override
$Res call({
Object? first = freezed,
Object? second = freezed,
}) {
return _then(_value.copyWith(
first: first == freezed
? _value.first
: first // ignore: cast_nullable_to_non_nullable
as T1,
second: second == freezed
? _value.second
: second // ignore: cast_nullable_to_non_nullable
as T2,
));
}
}
/// @nodoc
abstract class _$PairCopyWith<T1, T2, $Res>
implements $PairCopyWith<T1, T2, $Res> {
factory _$PairCopyWith(
_Pair<T1, T2> value, $Res Function(_Pair<T1, T2>) then) =
__$PairCopyWithImpl<T1, T2, $Res>;
@override
$Res call({T1 first, T2 second});
}
/// @nodoc
class __$PairCopyWithImpl<T1, T2, $Res> extends _$PairCopyWithImpl<T1, T2, $Res>
implements _$PairCopyWith<T1, T2, $Res> {
__$PairCopyWithImpl(_Pair<T1, T2> _value, $Res Function(_Pair<T1, T2>) _then)
: super(_value, (v) => _then(v as _Pair<T1, T2>));
@override
_Pair<T1, T2> get _value => super._value as _Pair<T1, T2>;
@override
$Res call({
Object? first = freezed,
Object? second = freezed,
}) {
return _then(_Pair<T1, T2>(
first == freezed
? _value.first
: first // ignore: cast_nullable_to_non_nullable
as T1,
second == freezed
? _value.second
: second // ignore: cast_nullable_to_non_nullable
as T2,
));
}
}
/// @nodoc
class _$_Pair<T1, T2> implements _Pair<T1, T2> {
_$_Pair(this.first, this.second);
@override
final T1 first;
@override
final T2 second;
@override
String toString() {
return 'Pair<$T1, $T2>(first: $first, second: $second)';
}
@override
bool operator ==(dynamic other) {
return identical(this, other) ||
(other.runtimeType == runtimeType &&
other is _Pair<T1, T2> &&
const DeepCollectionEquality().equals(other.first, first) &&
const DeepCollectionEquality().equals(other.second, second));
}
@override
int get hashCode => Object.hash(
runtimeType,
const DeepCollectionEquality().hash(first),
const DeepCollectionEquality().hash(second));
@JsonKey(ignore: true)
@override
_$PairCopyWith<T1, T2, _Pair<T1, T2>> get copyWith =>
__$PairCopyWithImpl<T1, T2, _Pair<T1, T2>>(this, _$identity);
}
abstract class _Pair<T1, T2> implements Pair<T1, T2> {
factory _Pair(T1 first, T2 second) = _$_Pair<T1, T2>;
@override
T1 get first;
@override
T2 get second;
@override
@JsonKey(ignore: true)
_$PairCopyWith<T1, T2, _Pair<T1, T2>> get copyWith =>
throw _privateConstructorUsedError;
}

View File

@ -837,11 +837,11 @@ class _$CredentialDataTearOff {
{String? issuer, {String? issuer,
required String name, required String name,
required String secret, required String secret,
OathType oathType = OathType.totp, OathType oathType = defaultOathType,
HashAlgorithm hashAlgorithm = HashAlgorithm.sha1, HashAlgorithm hashAlgorithm = defaultHashAlgorithm,
int digits = 6, int digits = defaultDigits,
int period = 30, int period = defaultPeriod,
int counter = 0}) { int counter = defaultCounter}) {
return _CredentialData( return _CredentialData(
issuer: issuer, issuer: issuer,
name: name, name: name,
@ -1036,11 +1036,11 @@ class _$_CredentialData extends _CredentialData {
{this.issuer, {this.issuer,
required this.name, required this.name,
required this.secret, required this.secret,
this.oathType = OathType.totp, this.oathType = defaultOathType,
this.hashAlgorithm = HashAlgorithm.sha1, this.hashAlgorithm = defaultHashAlgorithm,
this.digits = 6, this.digits = defaultDigits,
this.period = 30, this.period = defaultPeriod,
this.counter = 0}) this.counter = defaultCounter})
: super._(); : super._();
factory _$_CredentialData.fromJson(Map<String, dynamic> json) => factory _$_CredentialData.fromJson(Map<String, dynamic> json) =>

View File

@ -65,13 +65,13 @@ _$_CredentialData _$$_CredentialDataFromJson(Map<String, dynamic> json) =>
name: json['name'] as String, name: json['name'] as String,
secret: json['secret'] as String, secret: json['secret'] as String,
oathType: $enumDecodeNullable(_$OathTypeEnumMap, json['oath_type']) ?? oathType: $enumDecodeNullable(_$OathTypeEnumMap, json['oath_type']) ??
OathType.totp, defaultOathType,
hashAlgorithm: hashAlgorithm:
$enumDecodeNullable(_$HashAlgorithmEnumMap, json['hash_algorithm']) ?? $enumDecodeNullable(_$HashAlgorithmEnumMap, json['hash_algorithm']) ??
HashAlgorithm.sha1, defaultHashAlgorithm,
digits: json['digits'] as int? ?? 6, digits: json['digits'] as int? ?? defaultDigits,
period: json['period'] as int? ?? 30, period: json['period'] as int? ?? defaultPeriod,
counter: json['counter'] as int? ?? 0, counter: json['counter'] as int? ?? defaultCounter,
); );
Map<String, dynamic> _$$_CredentialDataToJson(_$_CredentialData instance) => Map<String, dynamic> _$$_CredentialDataToJson(_$_CredentialData instance) =>

View File

@ -8,6 +8,7 @@ import 'package:yubico_authenticator/oath/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../state.dart'; import '../state.dart';
import 'utils.dart';
final _secretFormatterPattern = final _secretFormatterPattern =
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false); RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
@ -33,17 +34,14 @@ class _AddAccountFormState extends State<AddAccountForm> {
@override @override
Widget build(BuildContext context) { Widget build(BuildContext context) {
int remaining = 64; // 64 bytes are shared between issuer and name. final remaining = getRemainingKeySpace(
if (_oathType == OathType.totp && _period != defaultPeriod) { oathType: _oathType,
// Non-standard periods are stored as part of this data, as a "D/"- prefix. period: _period,
remaining -= '$_period/'.length; issuer: _issuer,
} name: _account,
if (_issuer.isNotEmpty) { );
// Issuer is separated from name with a ":", if present. final issuerRemaining = remaining.first;
remaining -= 1; final nameRemaining = remaining.second;
}
final issuerRemaining = remaining - max<int>(_account.length, 1);
final nameRemaining = remaining - _issuer.length;
final secretValid = _secret.length * 5 % 8 < 5; final secretValid = _secret.length * 5 % 8 < 5;
final isValid = final isValid =
@ -57,7 +55,7 @@ class _AddAccountFormState extends State<AddAccountForm> {
children: [ children: [
TextField( TextField(
enabled: issuerRemaining > 0, enabled: issuerRemaining > 0,
maxLength: issuerRemaining > 0 ? issuerRemaining : null, maxLength: max(issuerRemaining, 1),
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Issuer (optional)', labelText: 'Issuer (optional)',
helperText: helperText:
@ -65,13 +63,12 @@ class _AddAccountFormState extends State<AddAccountForm> {
), ),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_issuer = value; _issuer = value.trim();
}); });
}, },
), ),
TextFormField( TextField(
enabled: nameRemaining > 0, maxLength: nameRemaining,
maxLength: nameRemaining > 0 ? nameRemaining : null,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Account name', labelText: 'Account name',
helperText: helperText:
@ -79,11 +76,11 @@ class _AddAccountFormState extends State<AddAccountForm> {
), ),
onChanged: (value) { onChanged: (value) {
setState(() { setState(() {
_account = value; _account = value.trim();
}); });
}, },
), ),
TextFormField( TextField(
inputFormatters: <TextInputFormatter>[ inputFormatters: <TextInputFormatter>[
FilteringTextInputFormatter.allow(_secretFormatterPattern) FilteringTextInputFormatter.allow(_secretFormatterPattern)
], ],

View File

@ -1,5 +1,3 @@
import 'dart:math';
import 'package:flutter/material.dart'; import 'package:flutter/material.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -7,6 +5,7 @@ import '../models.dart';
import '../state.dart'; import '../state.dart';
import '../../app/models.dart'; import '../../app/models.dart';
import '../../app/state.dart'; import '../../app/state.dart';
import 'utils.dart';
class RenameAccountDialog extends ConsumerStatefulWidget { class RenameAccountDialog extends ConsumerStatefulWidget {
final DeviceNode device; final DeviceNode device;
@ -20,16 +19,15 @@ class RenameAccountDialog extends ConsumerStatefulWidget {
} }
class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> { class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
late TextEditingController _issuerController; late String _issuer;
late TextEditingController _nameController; late String _account;
_RenameAccountDialogState(); _RenameAccountDialogState();
@override @override
void initState() { void initState() {
super.initState(); super.initState();
_issuerController = _issuer = widget.credential.issuer ?? '';
TextEditingController(text: widget.credential.issuer ?? ''); _account = widget.credential.name;
_nameController = TextEditingController(text: widget.credential.name);
} }
@override @override
@ -45,20 +43,15 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
? '${credential.issuer} (${credential.name})' ? '${credential.issuer} (${credential.name})'
: credential.name; : credential.name;
int remaining = 64; // 64 bytes are shared between issuer and name. final remaining = getRemainingKeySpace(
if (credential.oathType == OathType.totp && oathType: credential.oathType,
credential.period != defaultPeriod) { period: credential.period,
// Non-standard periods are stored as part of this data, as a "D/"- prefix. issuer: _issuer,
remaining -= '${credential.period}/'.length; name: _account,
} );
if (_issuerController.text.isNotEmpty) { final issuerRemaining = remaining.first;
// Issuer is separated from name with a ":", if present. final nameRemaining = remaining.second;
remaining -= 1; final isValid = _account.isNotEmpty;
}
final issuerRemaining =
remaining - max<int>(_nameController.text.length, 1);
final nameRemaining = remaining - _issuerController.text.length;
final isValid = _nameController.text.trim().isNotEmpty;
return AlertDialog( return AlertDialog(
title: Text('Rename $label?'), title: Text('Rename $label?'),
@ -67,29 +60,32 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
children: [ children: [
const Text( const Text(
'This will change how the account is displayed in the list.'), 'This will change how the account is displayed in the list.'),
TextField( TextFormField(
controller: _issuerController, initialValue: _issuer,
enabled: issuerRemaining > 0, enabled: issuerRemaining > 0,
maxLength: issuerRemaining > 0 ? issuerRemaining : null, maxLength: issuerRemaining > 0 ? issuerRemaining : null,
decoration: const InputDecoration( decoration: const InputDecoration(
labelText: 'Issuer', labelText: 'Issuer (optional)',
helperText: '', // Prevents dialog resizing when enabled = false helperText: '', // Prevents dialog resizing when enabled = false
), ),
onChanged: (value) { onChanged: (value) {
setState(() {}); // Update maxLength setState(() {
_issuer = value.trim();
});
}, },
), ),
TextField( TextFormField(
controller: _nameController, initialValue: _account,
enabled: nameRemaining > 0, maxLength: nameRemaining,
maxLength: nameRemaining > 0 ? nameRemaining : null,
decoration: InputDecoration( decoration: InputDecoration(
labelText: 'Account name *', labelText: 'Account name',
helperText: '', // Prevents dialog resizing when enabled = false helperText: '', // Prevents dialog resizing when enabled = false
errorText: isValid ? null : 'Your account must have a name', errorText: isValid ? null : 'Your account must have a name',
), ),
onChanged: (value) { onChanged: (value) {
setState(() {}); // Update maxLength, isValid setState(() {
_account = value.trim();
});
}, },
), ),
], ],
@ -104,12 +100,10 @@ class _RenameAccountDialogState extends ConsumerState<RenameAccountDialog> {
ElevatedButton( ElevatedButton(
onPressed: isValid onPressed: isValid
? () async { ? () async {
final issuer = _issuerController.text.trim();
final name = _nameController.text.trim();
await ref await ref
.read(credentialListProvider(widget.device.path).notifier) .read(credentialListProvider(widget.device.path).notifier)
.renameAccount( .renameAccount(credential,
credential, issuer.isNotEmpty ? issuer : null, name); _issuer.isNotEmpty ? _issuer : null, _account);
Navigator.of(context).pop(); Navigator.of(context).pop();
ScaffoldMessenger.of(context).showSnackBar( ScaffoldMessenger.of(context).showSnackBar(
const SnackBar( const SnackBar(

32
lib/oath/views/utils.dart Executable file
View File

@ -0,0 +1,32 @@
import 'dart:math';
import '../models.dart';
import '../../core/models.dart';
/// Calculates the available space for issuer and account name.
///
/// Returns a [Pair] of the space available for the issuer and account name,
/// respectively, based on the current state of the credential.
Pair<int, int> getRemainingKeySpace(
{required OathType oathType,
required int period,
required String issuer,
required String name}) {
int remaining = 64; // The field is 64 bytes in total.
if (oathType == OathType.totp && period != defaultPeriod) {
// Non-standard TOTP periods are stored as part of this data, as a "D/"- prefix.
remaining -= '$period/'.length;
}
int issuerSpace = issuer.length;
if (issuer.isNotEmpty) {
// Issuer is separated from name with a ":", if present.
issuerSpace += 1;
}
return Pair(
// Always reserve at least one character for name
remaining - 1 - max(name.length, 1),
remaining - issuerSpace,
);
}