mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-11-26 22:03:55 +03:00
Merge PR #1145.
This commit is contained in:
commit
142babd5d8
@ -50,6 +50,7 @@
|
||||
<category android:name="android.intent.category.BROWSABLE" />
|
||||
|
||||
<data android:scheme="otpauth" />
|
||||
<data android:scheme="otpauth-migration" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
|
@ -193,7 +193,10 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
|
||||
// Handle opening through otpauth:// link
|
||||
val intentData = intent.data
|
||||
if (intentData != null && intentData.scheme == "otpauth") {
|
||||
if (intentData != null &&
|
||||
(intentData.scheme == "otpauth" ||
|
||||
intentData.scheme == "otpauth-migration")
|
||||
) {
|
||||
intent.data = null
|
||||
appLinkMethodChannel.handleUri(intentData)
|
||||
}
|
||||
|
@ -27,7 +27,8 @@ enum class OathActionDescription(private val value: Int) {
|
||||
RenameAccount(5),
|
||||
DeleteAccount(6),
|
||||
CalculateCode(7),
|
||||
ActionFailure(8);
|
||||
ActionFailure(8),
|
||||
AddMultipleAccounts(9);
|
||||
|
||||
val id: Int
|
||||
get() = value + dialogDescriptionOathIndex
|
||||
|
@ -195,6 +195,7 @@ class OathManager(
|
||||
|
||||
// OATH methods callable from Flutter:
|
||||
oathChannel.setHandler(coroutineScope) { method, args ->
|
||||
@Suppress("UNCHECKED_CAST")
|
||||
when (method) {
|
||||
"reset" -> reset()
|
||||
"unlock" -> unlock(
|
||||
@ -227,6 +228,11 @@ class OathManager(
|
||||
args["requireTouch"] as Boolean
|
||||
)
|
||||
|
||||
"addAccountsToAny" -> addAccountsToAny(
|
||||
args["uris"] as List<String>,
|
||||
args["requireTouch"] as List<Boolean>
|
||||
)
|
||||
|
||||
else -> throw NotImplementedError()
|
||||
}
|
||||
}
|
||||
@ -383,6 +389,44 @@ class OathManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun addAccountsToAny(
|
||||
uris: List<String>,
|
||||
requireTouch: List<Boolean>,
|
||||
): String {
|
||||
logger.trace("Adding following accounts: {}", uris)
|
||||
|
||||
addToAny = true
|
||||
return useOathSessionNfc(OathActionDescription.AddMultipleAccounts) { session ->
|
||||
var successCount = 0
|
||||
for (index in uris.indices) {
|
||||
|
||||
val credentialData: CredentialData =
|
||||
CredentialData.parseUri(URI.create(uris[index]))
|
||||
|
||||
if (session.credentials.any { it.id.contentEquals(credentialData.id) }) {
|
||||
logger.info("A credential with this ID already exists, skipping")
|
||||
continue
|
||||
}
|
||||
|
||||
val credential = session.putCredential(credentialData, requireTouch[index])
|
||||
val code =
|
||||
if (credentialData.oathType == YubiKitOathType.TOTP && !requireTouch[index]) {
|
||||
// recalculate the code
|
||||
calculateCode(session, credential)
|
||||
} else null
|
||||
|
||||
oathViewModel.addCredential(
|
||||
Credential(credential, session.deviceId),
|
||||
Code.from(code)
|
||||
)
|
||||
|
||||
logger.trace("Added cred {}", credential)
|
||||
successCount++
|
||||
}
|
||||
jsonSerializer.encodeToString(mapOf("succeeded" to successCount))
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun reset(): String =
|
||||
useOathSession(OathActionDescription.Reset) {
|
||||
// note, it is ok to reset locked session
|
||||
|
@ -26,6 +26,7 @@ import android.os.Handler
|
||||
import android.os.Looper
|
||||
import android.provider.Settings
|
||||
import android.util.Log
|
||||
import android.util.Size
|
||||
import android.view.View
|
||||
import androidx.camera.core.*
|
||||
import androidx.camera.lifecycle.ProcessCameraProvider
|
||||
@ -81,8 +82,6 @@ internal class QRScannerView(
|
||||
private val stateChangeObserver = StateChangeObserver(context)
|
||||
private val uiThreadHandler = Handler(Looper.getMainLooper())
|
||||
|
||||
private var marginPct: Double? = null
|
||||
|
||||
companion object {
|
||||
const val TAG = "QRScannerView"
|
||||
|
||||
@ -93,9 +92,6 @@ internal class QRScannerView(
|
||||
Manifest.permission.CAMERA,
|
||||
).toTypedArray()
|
||||
|
||||
// view related
|
||||
private const val QR_SCANNER_ASPECT_RATIO = AspectRatio.RATIO_4_3
|
||||
|
||||
// communication channel
|
||||
private const val CHANNEL_NAME =
|
||||
"com.yubico.authenticator.flutter_plugins.qr_scanner_channel"
|
||||
@ -128,10 +124,20 @@ internal class QRScannerView(
|
||||
|
||||
private var imageAnalysis: ImageAnalysis? = null
|
||||
private var preview: Preview? = null
|
||||
private var barcodeAnalyzer : BarcodeAnalyzer = BarcodeAnalyzer(marginPct) { analyzeResult ->
|
||||
if (analyzeResult.isSuccess) {
|
||||
analyzeResult.getOrNull()?.let { result ->
|
||||
reportCodeFound(result)
|
||||
private val barcodeAnalyzer = with(creationParams) {
|
||||
var marginPct : Double? = null
|
||||
if (this?.get("margin") is Number) {
|
||||
val marginValue = this["margin"] as Number
|
||||
if (marginValue.toDouble() > 0.0 && marginValue.toDouble() < 45) {
|
||||
marginPct = marginValue.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
BarcodeAnalyzer(marginPct) { analyzeResult ->
|
||||
if (analyzeResult.isSuccess) {
|
||||
analyzeResult.getOrNull()?.let { result ->
|
||||
reportCodeFound(result)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -155,19 +161,11 @@ internal class QRScannerView(
|
||||
private val methodChannel: MethodChannel = MethodChannel(binaryMessenger, CHANNEL_NAME)
|
||||
private var permissionsGranted = false
|
||||
|
||||
private val screenSize = with(context.resources.displayMetrics) {
|
||||
Size(widthPixels, heightPixels)
|
||||
}
|
||||
|
||||
init {
|
||||
|
||||
// read margin parameter
|
||||
// only use it if it has reasonable value
|
||||
if (creationParams?.get("margin") is Number) {
|
||||
val marginValue = creationParams["margin"] as Number
|
||||
if (marginValue.toDouble() > 0.0 && marginValue.toDouble() < 45) {
|
||||
marginPct = marginValue.toDouble()
|
||||
}
|
||||
}
|
||||
|
||||
Log.v(TAG, "marginPct: $marginPct")
|
||||
|
||||
if (context is Activity) {
|
||||
permissionsGranted = allPermissionsGranted(context)
|
||||
|
||||
@ -259,14 +257,14 @@ internal class QRScannerView(
|
||||
|
||||
imageAnalysis = ImageAnalysis.Builder()
|
||||
.setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST)
|
||||
.setTargetAspectRatio(QR_SCANNER_ASPECT_RATIO)
|
||||
.setTargetResolution(Size(768,1024))
|
||||
.build()
|
||||
.also {
|
||||
it.setAnalyzer(cameraExecutor, barcodeAnalyzer)
|
||||
}
|
||||
|
||||
preview = Preview.Builder()
|
||||
.setTargetAspectRatio(QR_SCANNER_ASPECT_RATIO)
|
||||
.setTargetResolution(screenSize)
|
||||
.build()
|
||||
.also {
|
||||
it.setSurfaceProvider(previewView.surfaceProvider)
|
||||
@ -369,7 +367,7 @@ internal class QRScannerView(
|
||||
|
||||
val fullSize = BinaryBitmap(HybridBinarizer(luminanceSource))
|
||||
|
||||
val bitmapToProcess = if (marginPct != null) {
|
||||
val bitmapToProcess = if (marginPct != null && fullSize.isCropSupported) {
|
||||
val shorterDim = min(imageProxy.width, imageProxy.height)
|
||||
val cropMargin = marginPct * 0.01 * shorterDim
|
||||
val cropWH = shorterDim - 2.0 * cropMargin
|
||||
@ -395,6 +393,10 @@ internal class QRScannerView(
|
||||
}
|
||||
|
||||
val result: com.google.zxing.Result = multiFormatReader.decode(bitmapToProcess)
|
||||
if (analysisPaused) {
|
||||
return
|
||||
}
|
||||
|
||||
analysisPaused = true // pause
|
||||
Log.v(TAG, "Analysis result: ${result.text}")
|
||||
listener.invoke(Result.success(result.text))
|
||||
|
@ -18,10 +18,9 @@ import 'dart:convert';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter/services.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../oath/models.dart';
|
||||
import '../../oath/views/add_account_page.dart';
|
||||
import '../../oath/views/utils.dart';
|
||||
|
||||
const _appLinkMethodsChannel = MethodChannel('app.link.methods');
|
||||
|
||||
@ -30,24 +29,11 @@ void setupOtpAuthLinkHandler(BuildContext context) {
|
||||
final args = jsonDecode(call.arguments);
|
||||
switch (call.method) {
|
||||
case 'handleOtpAuthLink':
|
||||
{
|
||||
var url = args['link'];
|
||||
var otpauth = CredentialData.fromUri(Uri.parse(url));
|
||||
Navigator.popUntil(context, ModalRoute.withName('/'));
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
routeSettings: const RouteSettings(name: 'oath_add_account'),
|
||||
builder: (_) {
|
||||
return OathAddAccountPage(
|
||||
null,
|
||||
null,
|
||||
credentials: null,
|
||||
credentialData: otpauth,
|
||||
);
|
||||
},
|
||||
);
|
||||
break;
|
||||
}
|
||||
Navigator.popUntil(context, ModalRoute.withName('/'));
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final uri = args['link'];
|
||||
await handleUri(context, null, uri, null, null, l10n);
|
||||
break;
|
||||
default:
|
||||
throw PlatformException(
|
||||
code: 'NotImplemented',
|
||||
|
@ -22,6 +22,7 @@ import 'package:flutter/services.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:yubico_authenticator/exception/cancellation_exception.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/models.dart';
|
||||
@ -139,6 +140,35 @@ final addCredentialToAnyProvider =
|
||||
}
|
||||
});
|
||||
|
||||
final addCredentialsToAnyProvider = Provider(
|
||||
(ref) => (List<String> credentialUris, List<bool> touchRequired) async {
|
||||
try {
|
||||
_log.debug(
|
||||
'Calling android with ${credentialUris.length} credentials to be added');
|
||||
|
||||
String resultString = await _methods.invokeMethod(
|
||||
'addAccountsToAny',
|
||||
{
|
||||
'uris': credentialUris,
|
||||
'requireTouch': touchRequired,
|
||||
},
|
||||
);
|
||||
|
||||
_log.debug('Call result: $resultString');
|
||||
var result = jsonDecode(resultString);
|
||||
return result['succeeded'] == credentialUris.length;
|
||||
} on PlatformException catch (pe) {
|
||||
var decodedException = pe.decode();
|
||||
if (decodedException is CancellationException) {
|
||||
_log.debug('User cancelled adding multiple accounts');
|
||||
} else {
|
||||
_log.error('Failed to add multiple accounts.', pe);
|
||||
}
|
||||
|
||||
throw decodedException;
|
||||
}
|
||||
});
|
||||
|
||||
final androidCredentialListProvider = StateNotifierProvider.autoDispose
|
||||
.family<OathCredentialListNotifier, List<OathPair>?, DevicePath>(
|
||||
(ref, devicePath) {
|
||||
|
@ -38,15 +38,11 @@ GlobalKey<QRScannerZxingViewState> _zxingViewKey = GlobalKey();
|
||||
class _QrScannerViewState extends State<QrScannerView> {
|
||||
String? _scannedString;
|
||||
|
||||
// will be used later
|
||||
// ignore: unused_field
|
||||
CredentialData? _credentialData;
|
||||
ScanStatus _status = ScanStatus.scanning;
|
||||
bool _previewInitialized = false;
|
||||
bool _permissionsGranted = false;
|
||||
|
||||
void setError() {
|
||||
_credentialData = null;
|
||||
_scannedString = null;
|
||||
_status = ScanStatus.error;
|
||||
|
||||
@ -59,7 +55,6 @@ class _QrScannerViewState extends State<QrScannerView> {
|
||||
|
||||
void resetError() {
|
||||
setState(() {
|
||||
_credentialData = null;
|
||||
_scannedString = null;
|
||||
_status = ScanStatus.scanning;
|
||||
|
||||
@ -67,17 +62,17 @@ class _QrScannerViewState extends State<QrScannerView> {
|
||||
});
|
||||
}
|
||||
|
||||
void handleResult(String barCode) {
|
||||
void handleResult(String qrCodeData) {
|
||||
if (_status != ScanStatus.scanning) {
|
||||
// on success and error ignore reported codes
|
||||
return;
|
||||
}
|
||||
setState(() {
|
||||
if (barCode.isNotEmpty) {
|
||||
if (qrCodeData.isNotEmpty) {
|
||||
try {
|
||||
var parsedCredential = CredentialData.fromUri(Uri.parse(barCode));
|
||||
_credentialData = parsedCredential;
|
||||
_scannedString = barCode;
|
||||
CredentialData.fromUri(Uri.parse(
|
||||
qrCodeData)); // throws ArgumentError if validation fails
|
||||
_scannedString = qrCodeData;
|
||||
_status = ScanStatus.success;
|
||||
|
||||
final navigator = Navigator.of(context);
|
||||
@ -143,7 +138,7 @@ class _QrScannerViewState extends State<QrScannerView> {
|
||||
visible: _permissionsGranted,
|
||||
child: QRScannerZxingView(
|
||||
key: _zxingViewKey,
|
||||
marginPct: 50,
|
||||
marginPct: 10,
|
||||
onDetect: (scannedData) => handleResult(scannedData),
|
||||
onViewInitialized: (bool permissionsGranted) {
|
||||
Future.delayed(const Duration(milliseconds: 50), () {
|
||||
|
@ -72,6 +72,7 @@ enum _DDesc {
|
||||
oathDeleteAccount,
|
||||
oathCalculateCode,
|
||||
oathActionFailure,
|
||||
oathAddMultipleAccounts,
|
||||
invalid;
|
||||
|
||||
static const int dialogDescriptionOathIndex = 100;
|
||||
@ -86,7 +87,8 @@ enum _DDesc {
|
||||
dialogDescriptionOathIndex + 5: _DDesc.oathRenameAccount,
|
||||
dialogDescriptionOathIndex + 6: _DDesc.oathDeleteAccount,
|
||||
dialogDescriptionOathIndex + 7: _DDesc.oathCalculateCode,
|
||||
dialogDescriptionOathIndex + 8: _DDesc.oathActionFailure
|
||||
dialogDescriptionOathIndex + 8: _DDesc.oathActionFailure,
|
||||
dialogDescriptionOathIndex + 9: _DDesc.oathAddMultipleAccounts
|
||||
}[id] ??
|
||||
_DDesc.invalid;
|
||||
}
|
||||
@ -158,6 +160,7 @@ class _DialogProvider {
|
||||
_DDesc.oathDeleteAccount => l10n.s_nfc_dialog_oath_delete_account,
|
||||
_DDesc.oathCalculateCode => l10n.s_nfc_dialog_oath_calculate_code,
|
||||
_DDesc.oathActionFailure => l10n.s_nfc_dialog_oath_failure,
|
||||
_DDesc.oathAddMultipleAccounts => l10n.s_nfc_dialog_oath_add_multiple_accounts,
|
||||
_ => ''
|
||||
};
|
||||
}
|
||||
|
@ -17,18 +17,16 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../android/app_methods.dart';
|
||||
import '../../android/state.dart';
|
||||
import '../../exception/cancellation_exception.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../fido/views/fido_screen.dart';
|
||||
import '../../oath/models.dart';
|
||||
import '../../oath/views/add_account_page.dart';
|
||||
import '../../oath/state.dart';
|
||||
import '../../oath/views/oath_screen.dart';
|
||||
import '../../oath/views/utils.dart';
|
||||
import '../../piv/views/piv_screen.dart';
|
||||
import '../../widgets/custom_icons.dart';
|
||||
import '../message.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'device_error_screen.dart';
|
||||
@ -100,33 +98,19 @@ class MainPage extends ConsumerWidget {
|
||||
icon: const Icon(Icons.person_add_alt_1),
|
||||
tooltip: l10n.s_add_account,
|
||||
onPressed: () async {
|
||||
CredentialData? otpauth;
|
||||
final scanner = ref.read(qrScannerProvider);
|
||||
if (scanner != null) {
|
||||
try {
|
||||
final url = await scanner.scanQr();
|
||||
if (url != null) {
|
||||
otpauth = CredentialData.fromUri(Uri.parse(url));
|
||||
}
|
||||
final uri = await scanner.scanQr();
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
await withContext((context) =>
|
||||
handleUri(context, credentials, uri, null, null, l10n));
|
||||
} on CancellationException catch (_) {
|
||||
// ignored - user cancelled
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
await ref.read(withContextProvider)((context) => showBlurDialog(
|
||||
context: context,
|
||||
routeSettings:
|
||||
const RouteSettings(name: 'oath_add_account'),
|
||||
builder: (context) {
|
||||
return OathAddAccountPage(
|
||||
null,
|
||||
null,
|
||||
credentials: null,
|
||||
credentialData: otpauth,
|
||||
);
|
||||
},
|
||||
));
|
||||
},
|
||||
),
|
||||
);
|
||||
|
@ -285,6 +285,9 @@
|
||||
"s_accounts": "Accounts",
|
||||
"s_no_accounts": "No accounts",
|
||||
"s_add_account": "Add account",
|
||||
"s_add_accounts" : "Add account(s)",
|
||||
"p_add_description" : "To scan a QR code, make sure the full code is visible on screen and press the button below. You can also drag a saved image from a folder onto this dialog. If you have the account credential details in writing, use the manual entry instead.",
|
||||
"s_add_manually" : "Add manually",
|
||||
"s_account_added": "Account added",
|
||||
"l_account_add_failed": "Failed adding account: {message}",
|
||||
"@l_account_add_failed" : {
|
||||
@ -294,7 +297,9 @@
|
||||
},
|
||||
"l_account_name_required": "Your account must have a name",
|
||||
"l_name_already_exists": "This name already exists for the issuer",
|
||||
"l_account_already_exists": "This account already exists on the YubiKey",
|
||||
"l_invalid_character_issuer": "Invalid character: ':' is not allowed in issuer",
|
||||
"l_select_accounts" : "Select account(s) to add to the YubiKey",
|
||||
"s_pinned": "Pinned",
|
||||
"s_pin_account": "Pin account",
|
||||
"s_unpin_account": "Unpin account",
|
||||
@ -597,6 +602,7 @@
|
||||
"s_nfc_dialog_oath_delete_account": "Action: delete account",
|
||||
"s_nfc_dialog_oath_calculate_code": "Action: calculate OATH code",
|
||||
"s_nfc_dialog_oath_failure": "OATH operation failed",
|
||||
"s_nfc_dialog_oath_add_multiple_accounts": "Action: add multiple accounts",
|
||||
|
||||
"@_eof": {}
|
||||
}
|
@ -27,6 +27,7 @@ final searchAccountsField = GlobalKey();
|
||||
const setOrManagePasswordAction =
|
||||
Key('$_keyAction.action.set_or_manage_password');
|
||||
const addAccountAction = Key('$_keyAction.add_account');
|
||||
const migrateAccountAction = Key('$_keyAction.migrate_account');
|
||||
const resetAction = Key('$_keyAction.reset');
|
||||
const customIconsAction = Key('$_keyAction.custom_icons');
|
||||
|
||||
|
@ -14,6 +14,9 @@
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:typed_data';
|
||||
import 'dart:convert';
|
||||
import 'package:base32/base32.dart';
|
||||
import 'package:freezed_annotation/freezed_annotation.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
@ -120,10 +123,83 @@ class CredentialData with _$CredentialData {
|
||||
factory CredentialData.fromJson(Map<String, dynamic> json) =>
|
||||
_$CredentialDataFromJson(json);
|
||||
|
||||
factory CredentialData.fromUri(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() != 'otpauth') {
|
||||
throw ArgumentError('Invalid scheme, must be "otpauth://"');
|
||||
static List<CredentialData> fromUri(Uri uri) {
|
||||
if (uri.scheme.toLowerCase() == 'otpauth-migration') {
|
||||
return CredentialData.fromMigration(uri);
|
||||
} else if (uri.scheme.toLowerCase() == 'otpauth') {
|
||||
return [CredentialData.fromOtpauth(uri)];
|
||||
} else {
|
||||
throw ArgumentError('Invalid scheme');
|
||||
}
|
||||
}
|
||||
|
||||
static List<CredentialData> fromMigration(Uri uri) {
|
||||
// Parse single protobuf encoded integer
|
||||
(int value, Uint8List rem) protoInt(Uint8List data) {
|
||||
final extras = data.takeWhile((b) => b & 0x80 != 0).length;
|
||||
int value = 0;
|
||||
for (int i = extras; i >= 0; i--) {
|
||||
value = (value << 7) | (data[i] & 0x7F);
|
||||
}
|
||||
return (value, data.sublist(1 + extras));
|
||||
}
|
||||
|
||||
// Parse a single protobuf value from a buffer
|
||||
(int tag, dynamic value, Uint8List rem) protoValue(Uint8List data) {
|
||||
final first = data[0];
|
||||
final int len;
|
||||
(len, data) = protoInt(data.sublist(1));
|
||||
final index = first >> 3;
|
||||
switch (first & 0x07) {
|
||||
case 0:
|
||||
return (index, len, data);
|
||||
case 2:
|
||||
return (index, data.sublist(0, len), data.sublist(len));
|
||||
}
|
||||
throw ArgumentError('Unsupported value type!');
|
||||
}
|
||||
|
||||
// Parse a protobuf message into map of tags and values
|
||||
Map<int, dynamic> protoMap(Uint8List data) {
|
||||
Map<int, dynamic> values = {};
|
||||
while (data.isNotEmpty) {
|
||||
final (tag, value, rem) = protoValue(data);
|
||||
values[tag] = value;
|
||||
data = rem;
|
||||
}
|
||||
return values;
|
||||
}
|
||||
|
||||
// Parse encoded credentials from data (tag 1) ignoring trailing extra data
|
||||
Iterable<Map<int, dynamic>> splitCreds(Uint8List rem) sync* {
|
||||
Uint8List credrem;
|
||||
while (rem[0] == 0x0a) {
|
||||
(_, credrem, rem) = protoValue(rem);
|
||||
yield protoMap(credrem);
|
||||
}
|
||||
}
|
||||
|
||||
// Convert parsed credential values into CredentialData objects
|
||||
return splitCreds(base64.decode(uri.queryParameters['data']!))
|
||||
.map((values) => CredentialData(
|
||||
secret: base32.encode(values[1]),
|
||||
name: utf8.decode(values[2], allowMalformed: true),
|
||||
issuer: values[3] != null
|
||||
? utf8.decode(values[3], allowMalformed: true)
|
||||
: null,
|
||||
hashAlgorithm: switch (values[4]) {
|
||||
2 => HashAlgorithm.sha256,
|
||||
3 => HashAlgorithm.sha512,
|
||||
_ => HashAlgorithm.sha1,
|
||||
},
|
||||
digits: values[5] == 2 ? 8 : defaultDigits,
|
||||
oathType: values[6] == 1 ? OathType.hotp : OathType.totp,
|
||||
counter: values[7] ?? defaultCounter,
|
||||
))
|
||||
.toList();
|
||||
}
|
||||
|
||||
factory CredentialData.fromOtpauth(Uri uri) {
|
||||
final oathType = OathType.values.byName(uri.host.toLowerCase());
|
||||
final params = uri.queryParameters;
|
||||
String? issuer;
|
||||
|
119
lib/oath/views/add_account_dialog.dart
Normal file
119
lib/oath/views/add_account_dialog.dart
Normal file
@ -0,0 +1,119 @@
|
||||
/*
|
||||
* Copyright (C) 2023 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'dart:convert';
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:yubico_authenticator/app/message.dart';
|
||||
import 'package:yubico_authenticator/app/state.dart';
|
||||
import 'package:yubico_authenticator/widgets/responsive_dialog.dart';
|
||||
|
||||
import '../../app/models.dart';
|
||||
import '../../widgets/file_drop_target.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import 'add_account_page.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
class AddAccountDialog extends ConsumerStatefulWidget {
|
||||
final DevicePath? devicePath;
|
||||
final OathState? state;
|
||||
|
||||
const AddAccountDialog(this.devicePath, this.state, {super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_AddAccountDialogState();
|
||||
}
|
||||
|
||||
class _AddAccountDialogState extends ConsumerState<AddAccountDialog> {
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
|
||||
final qrScanner = ref.watch(qrScannerProvider);
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_add_account),
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: FileDropTarget(
|
||||
onFileDropped: (fileData) async {
|
||||
Navigator.of(context).pop();
|
||||
if (qrScanner != null) {
|
||||
final b64Image = base64Encode(fileData);
|
||||
final uri = await qrScanner.scanQr(b64Image);
|
||||
await withContext((context) => handleUri(context, credentials,
|
||||
uri, widget.devicePath, widget.state, l10n));
|
||||
}
|
||||
},
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
Text(l10n.p_add_description),
|
||||
const SizedBox(height: 4),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
runSpacing: 8.0,
|
||||
children: [
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.qr_code_scanner_outlined),
|
||||
label: Text(l10n.s_qr_scan),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
if (qrScanner != null) {
|
||||
final uri = await qrScanner.scanQr();
|
||||
await withContext((context) => handleUri(
|
||||
context,
|
||||
credentials,
|
||||
uri,
|
||||
widget.devicePath,
|
||||
widget.state,
|
||||
l10n));
|
||||
}
|
||||
},
|
||||
),
|
||||
ActionChip(
|
||||
avatar: const Icon(Icons.edit_outlined),
|
||||
label: Text(l10n.s_add_manually),
|
||||
onPressed: () async {
|
||||
Navigator.of(context).pop();
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
widget.devicePath,
|
||||
widget.state,
|
||||
credentials: credentials,
|
||||
),
|
||||
);
|
||||
});
|
||||
}),
|
||||
])
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
));
|
||||
}
|
||||
}
|
@ -51,8 +51,6 @@ final _log = Logger('oath.view.add_account_page');
|
||||
final _secretFormatterPattern =
|
||||
RegExp('[abcdefghijklmnopqrstuvwxyz234567 ]', caseSensitive: false);
|
||||
|
||||
enum _QrScanState { none, scanning, success, failed }
|
||||
|
||||
class OathAddAccountPage extends ConsumerStatefulWidget {
|
||||
final DevicePath? devicePath;
|
||||
final OathState? state;
|
||||
@ -82,8 +80,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
OathType _oathType = defaultOathType;
|
||||
HashAlgorithm _hashAlgorithm = defaultHashAlgorithm;
|
||||
int _digits = defaultDigits;
|
||||
int _counter = defaultCounter;
|
||||
bool _validateSecretLength = false;
|
||||
_QrScanState _qrState = _QrScanState.none;
|
||||
bool _dataLoaded = false;
|
||||
bool _isObscure = true;
|
||||
List<int> _periodValues = [20, 30, 45, 60];
|
||||
List<int> _digitsValues = [6, 8];
|
||||
@ -107,55 +106,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
}
|
||||
}
|
||||
|
||||
_scanQrCode(QrScanner qrScanner) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
try {
|
||||
setState(() {
|
||||
// If we have a previous scan result stored, clear it
|
||||
if (_qrState == _QrScanState.success) {
|
||||
_issuerController.text = '';
|
||||
_accountController.text = '';
|
||||
_secretController.text = '';
|
||||
_oathType = defaultOathType;
|
||||
_hashAlgorithm = defaultHashAlgorithm;
|
||||
_periodController.text = '$defaultPeriod';
|
||||
_digits = defaultDigits;
|
||||
}
|
||||
_qrState = _QrScanState.scanning;
|
||||
});
|
||||
final otpauth = await qrScanner.scanQr();
|
||||
if (otpauth == null) {
|
||||
if (!mounted) return;
|
||||
showMessage(context, l10n.l_qr_not_found);
|
||||
setState(() {
|
||||
_qrState = _QrScanState.failed;
|
||||
});
|
||||
} else {
|
||||
final data = CredentialData.fromUri(Uri.parse(otpauth));
|
||||
_loadCredentialData(data);
|
||||
}
|
||||
} catch (e) {
|
||||
final String errorMessage;
|
||||
// TODO: Make this cleaner than importing desktop specific RpcError.
|
||||
if (e is RpcError) {
|
||||
errorMessage = e.message;
|
||||
} else {
|
||||
errorMessage = e.toString();
|
||||
}
|
||||
|
||||
if (e is! CancellationException) {
|
||||
showMessage(
|
||||
context,
|
||||
l10n.l_qr_not_read(errorMessage),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
}
|
||||
setState(() {
|
||||
_qrState = _QrScanState.failed;
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
_loadCredentialData(CredentialData data) {
|
||||
setState(() {
|
||||
_issuerController.text = data.issuer?.trim() ?? '';
|
||||
@ -167,8 +117,9 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
_periodController.text = '${data.period}';
|
||||
_digitsValues = [data.digits];
|
||||
_digits = data.digits;
|
||||
_counter = data.counter;
|
||||
_isObscure = true;
|
||||
_qrState = _QrScanState.success;
|
||||
_dataLoaded = true;
|
||||
});
|
||||
}
|
||||
|
||||
@ -176,7 +127,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
{DevicePath? devicePath, required Uri credUri}) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
try {
|
||||
|
||||
FocusUtils.unfocus(context);
|
||||
|
||||
if (devicePath == null) {
|
||||
@ -304,8 +254,6 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
nameRemaining >= 0 &&
|
||||
period > 0;
|
||||
|
||||
final qrScanner = ref.watch(qrScannerProvider);
|
||||
|
||||
final hashAlgorithms = HashAlgorithm.values
|
||||
.where((alg) =>
|
||||
alg != HashAlgorithm.sha512 ||
|
||||
@ -330,6 +278,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
hashAlgorithm: _hashAlgorithm,
|
||||
digits: _digits,
|
||||
period: period,
|
||||
counter: _counter,
|
||||
);
|
||||
|
||||
final devicePath = deviceNode?.path;
|
||||
@ -368,6 +317,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
],
|
||||
child: FileDropTarget(
|
||||
onFileDropped: (fileData) async {
|
||||
final qrScanner = ref.read(qrScannerProvider);
|
||||
if (qrScanner != null) {
|
||||
final b64Image = base64Encode(fileData);
|
||||
final otpauth = await qrScanner.scanQr(b64Image);
|
||||
@ -375,8 +325,20 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
if (!mounted) return;
|
||||
showMessage(context, l10n.l_qr_not_found);
|
||||
} else {
|
||||
final data = CredentialData.fromUri(Uri.parse(otpauth));
|
||||
_loadCredentialData(data);
|
||||
try {
|
||||
final data = CredentialData.fromOtpauth(Uri.parse(otpauth));
|
||||
_loadCredentialData(data);
|
||||
} catch (e) {
|
||||
final String errorMessage;
|
||||
// TODO: Make this cleaner than importing desktop specific RpcError.
|
||||
if (e is RpcError) {
|
||||
errorMessage = e.message;
|
||||
} else {
|
||||
errorMessage = e.toString();
|
||||
}
|
||||
if (!mounted) return;
|
||||
showMessage(context, errorMessage);
|
||||
}
|
||||
}
|
||||
}
|
||||
},
|
||||
@ -483,7 +445,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
errorText: _validateSecretLength && !secretLengthValid
|
||||
? l10n.s_invalid_length
|
||||
: null),
|
||||
readOnly: _qrState == _QrScanState.success,
|
||||
readOnly: _dataLoaded,
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
@ -494,25 +456,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
if (isValid) submit();
|
||||
},
|
||||
),
|
||||
if (isDesktop && qrScanner != null)
|
||||
Padding(
|
||||
padding: const EdgeInsets.only(top: 16.0),
|
||||
child: ActionChip(
|
||||
avatar: _qrState != _QrScanState.scanning
|
||||
? (_qrState == _QrScanState.success
|
||||
? const Icon(Icons.qr_code)
|
||||
: const Icon(
|
||||
Icons.qr_code_scanner_outlined))
|
||||
: const CircularProgressIndicator(
|
||||
strokeWidth: 2.0),
|
||||
label: _qrState == _QrScanState.success
|
||||
? Text(l10n.l_qr_scanned)
|
||||
: Text(l10n.s_qr_scan),
|
||||
onPressed: () {
|
||||
_scanQrCode(qrScanner);
|
||||
}),
|
||||
),
|
||||
const Divider(),
|
||||
const SizedBox(height: 8),
|
||||
Wrap(
|
||||
crossAxisAlignment: WrapCrossAlignment.center,
|
||||
spacing: 4.0,
|
||||
@ -534,7 +478,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
selected: _oathType != defaultOathType,
|
||||
itemBuilder: (value) =>
|
||||
Text(value.getDisplayName(l10n)),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
onChanged: !_dataLoaded
|
||||
? (value) {
|
||||
setState(() {
|
||||
_oathType = value;
|
||||
@ -547,7 +491,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
value: _hashAlgorithm,
|
||||
selected: _hashAlgorithm != defaultHashAlgorithm,
|
||||
itemBuilder: (value) => Text(value.displayName),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
onChanged: !_dataLoaded
|
||||
? (value) {
|
||||
setState(() {
|
||||
_hashAlgorithm = value;
|
||||
@ -564,7 +508,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
defaultPeriod,
|
||||
itemBuilder: ((value) =>
|
||||
Text(l10n.s_num_sec(value))),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
onChanged: !_dataLoaded
|
||||
? (period) {
|
||||
setState(() {
|
||||
_periodController.text = '$period';
|
||||
@ -578,7 +522,7 @@ class _OathAddAccountPageState extends ConsumerState<OathAddAccountPage> {
|
||||
selected: _digits != defaultDigits,
|
||||
itemBuilder: (value) =>
|
||||
Text(l10n.s_num_digits(value)),
|
||||
onChanged: _qrState != _QrScanState.success
|
||||
onChanged: !_dataLoaded
|
||||
? (digits) {
|
||||
setState(() {
|
||||
_digits = digits;
|
||||
|
303
lib/oath/views/add_multi_account_page.dart
Normal file
303
lib/oath/views/add_multi_account_page.dart
Normal file
@ -0,0 +1,303 @@
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
import 'package:yubico_authenticator/app/logging.dart';
|
||||
import 'package:yubico_authenticator/exception/apdu_exception.dart';
|
||||
|
||||
import '../../android/oath/state.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../core/models.dart';
|
||||
import '../../desktop/models.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import '../models.dart';
|
||||
import '../../app/state.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../state.dart';
|
||||
import '../../app/message.dart';
|
||||
|
||||
import '../../exception/cancellation_exception.dart';
|
||||
import 'rename_list_account.dart';
|
||||
|
||||
final _log = Logger('oath.views.list_screen');
|
||||
|
||||
class OathAddMultiAccountPage extends ConsumerStatefulWidget {
|
||||
final DevicePath? devicePath;
|
||||
final OathState? state;
|
||||
final List<CredentialData>? credentialsFromUri;
|
||||
|
||||
const OathAddMultiAccountPage(
|
||||
this.devicePath, this.state, this.credentialsFromUri,
|
||||
{super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() =>
|
||||
_OathAddMultiAccountPageState();
|
||||
}
|
||||
|
||||
class _OathAddMultiAccountPageState
|
||||
extends ConsumerState<OathAddMultiAccountPage> {
|
||||
int? _numCreds;
|
||||
|
||||
late Map<CredentialData, (bool, bool, bool)> _credStates;
|
||||
List<OathCredential>? _credentials;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_credStates = Map.fromIterable(widget.credentialsFromUri!,
|
||||
value: (v) => (true, false, false));
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final colorScheme = Theme.of(context).colorScheme;
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
|
||||
if (widget.devicePath != null) {
|
||||
_credentials = ref
|
||||
.watch(credentialListProvider(widget.devicePath!))
|
||||
?.map((e) => e.credential)
|
||||
.toList();
|
||||
|
||||
_numCreds = ref.watch(credentialListProvider(widget.devicePath!)
|
||||
.select((value) => value?.length));
|
||||
}
|
||||
|
||||
// If the credential is not unique, make sure the checkbox is not checked
|
||||
checkForDuplicates();
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_add_accounts),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: isValid() ? submit : null,
|
||||
child: Text(l10n.s_save),
|
||||
)
|
||||
],
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
mainAxisSize: MainAxisSize.min,
|
||||
children: [
|
||||
Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Text(l10n.l_select_accounts)),
|
||||
...widget.credentialsFromUri!.map(
|
||||
(cred) {
|
||||
final (checked, touch, unique) = _credStates[cred]!;
|
||||
return CheckboxListTile(
|
||||
controlAffinity: ListTileControlAffinity.leading,
|
||||
secondary: Row(mainAxisSize: MainAxisSize.min, children: [
|
||||
if (isTouchSupported())
|
||||
IconButton(
|
||||
color: touch ? colorScheme.primary : null,
|
||||
onPressed: unique
|
||||
? () {
|
||||
setState(() {
|
||||
_credStates[cred] =
|
||||
(checked, !touch, unique);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
icon: const Icon(Icons.touch_app_outlined)),
|
||||
IconButton(
|
||||
onPressed: () async {
|
||||
final node = ref
|
||||
.read(currentDeviceDataProvider)
|
||||
.valueOrNull
|
||||
?.node;
|
||||
final withContext = ref.read(withContextProvider);
|
||||
CredentialData renamed = await withContext(
|
||||
(context) async => await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => RenameList(node!, cred,
|
||||
widget.credentialsFromUri, _credentials),
|
||||
));
|
||||
|
||||
setState(() {
|
||||
int index = widget.credentialsFromUri!.indexWhere(
|
||||
(element) =>
|
||||
element.name == cred.name &&
|
||||
(element.issuer == cred.issuer));
|
||||
widget.credentialsFromUri![index] = renamed;
|
||||
_credStates.remove(cred);
|
||||
_credStates[renamed] = (true, touch, true);
|
||||
});
|
||||
},
|
||||
icon: IconTheme(
|
||||
data: IconTheme.of(context),
|
||||
child: const Icon(Icons.edit_outlined)),
|
||||
),
|
||||
]),
|
||||
title: Text(cred.issuer ?? cred.name,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false),
|
||||
value: unique && checked,
|
||||
enabled: unique,
|
||||
subtitle: cred.issuer != null || !unique
|
||||
? Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.stretch,
|
||||
children: [
|
||||
if (cred.issuer != null)
|
||||
Text(cred.name,
|
||||
overflow: TextOverflow.fade,
|
||||
maxLines: 1,
|
||||
softWrap: false),
|
||||
if (!unique)
|
||||
Text(
|
||||
l10n.l_account_already_exists,
|
||||
style: TextStyle(
|
||||
color: colorScheme.error,
|
||||
fontSize: 12, // TODO: use Theme
|
||||
),
|
||||
)
|
||||
])
|
||||
: null,
|
||||
onChanged: (bool? value) {
|
||||
setState(() {
|
||||
_credStates[cred] = (value == true, touch, unique);
|
||||
});
|
||||
},
|
||||
);
|
||||
},
|
||||
)
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
));
|
||||
}
|
||||
|
||||
bool isTouchSupported() => widget.state?.version.isAtLeast(4, 2) ?? true;
|
||||
|
||||
void checkForDuplicates() {
|
||||
for (final item in _credStates.entries) {
|
||||
CredentialData cred = item.key;
|
||||
final (checked, touch, _) = item.value;
|
||||
final unique = isUnique(cred);
|
||||
_credStates[cred] = (checked && unique, touch, unique);
|
||||
}
|
||||
}
|
||||
|
||||
bool isUnique(CredentialData cred) {
|
||||
String nameText = cred.name;
|
||||
String? issuerText = cred.issuer ?? '';
|
||||
bool ans = _credentials
|
||||
?.where((element) =>
|
||||
element.name == nameText &&
|
||||
(element.issuer ?? '') == issuerText)
|
||||
.isEmpty ??
|
||||
true;
|
||||
|
||||
return ans;
|
||||
}
|
||||
|
||||
bool isValid() {
|
||||
if (widget.state != null) {
|
||||
final credsToAdd =
|
||||
_credStates.values.where((element) => element.$1).length;
|
||||
final capacity = widget.state!.version.isAtLeast(4) ? 32 : null;
|
||||
return (credsToAdd > 0) &&
|
||||
(capacity == null || (_numCreds! + credsToAdd <= capacity));
|
||||
} else {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
|
||||
void submit() async {
|
||||
final deviceNode = ref.watch(currentDeviceProvider);
|
||||
if (isAndroid &&
|
||||
(widget.devicePath == null || deviceNode?.transport == Transport.nfc)) {
|
||||
var uris = <String>[];
|
||||
var touchRequired = <bool>[];
|
||||
|
||||
// build list of uris and touch required flags for unique credentials
|
||||
for (final item in _credStates.entries) {
|
||||
CredentialData cred = item.key;
|
||||
final (checked, touch, _) = item.value;
|
||||
if (checked) {
|
||||
uris.add(cred.toUri().toString());
|
||||
touchRequired.add(touch);
|
||||
}
|
||||
}
|
||||
|
||||
await _addCredentials(uris: uris, touchRequired: touchRequired);
|
||||
} else {
|
||||
_credStates.forEach((cred, value) {
|
||||
if (value.$1) {
|
||||
accept(cred, value.$2);
|
||||
}
|
||||
});
|
||||
|
||||
Navigator.of(context).pop();
|
||||
}
|
||||
}
|
||||
|
||||
Future<void> _addCredentials(
|
||||
{required List<String> uris, required List<bool> touchRequired}) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
try {
|
||||
await ref.read(addCredentialsToAnyProvider).call(uris, touchRequired);
|
||||
if (!mounted) return;
|
||||
Navigator.of(context).pop();
|
||||
showMessage(context, l10n.s_account_added);
|
||||
} on CancellationException catch (_) {
|
||||
// ignored
|
||||
} catch (e) {
|
||||
_log.error('Failed to add multiple accounts', e.toString());
|
||||
final String errorMessage;
|
||||
if (e is ApduException) {
|
||||
errorMessage = e.message;
|
||||
} else {
|
||||
errorMessage = e.toString();
|
||||
}
|
||||
showMessage(
|
||||
context,
|
||||
l10n.l_account_add_failed(errorMessage),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
void accept(CredentialData cred, bool touch) async {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final devicePath = widget.devicePath;
|
||||
try {
|
||||
if (devicePath == null) {
|
||||
assert(isAndroid, 'devicePath is only optional for Android');
|
||||
await ref
|
||||
.read(addCredentialToAnyProvider)
|
||||
.call(cred.toUri(), requireTouch: touch);
|
||||
} else {
|
||||
await ref
|
||||
.read(credentialListProvider(devicePath).notifier)
|
||||
.addAccount(cred.toUri(), requireTouch: touch);
|
||||
}
|
||||
if (!mounted) return;
|
||||
//Navigator.of(context).pop();
|
||||
showMessage(context, l10n.s_account_added);
|
||||
} on CancellationException catch (_) {
|
||||
// ignored
|
||||
} catch (e) {
|
||||
_log.error('Failed to add account', e);
|
||||
final String errorMessage;
|
||||
// TODO: Make this cleaner than importing desktop specific RpcError.
|
||||
if (e is RpcError) {
|
||||
errorMessage = e.message;
|
||||
} else if (e is ApduException) {
|
||||
errorMessage = e.message;
|
||||
} else {
|
||||
errorMessage = e.toString();
|
||||
}
|
||||
showMessage(
|
||||
context,
|
||||
l10n.l_account_add_failed(errorMessage),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
@ -18,6 +18,7 @@ import 'package:flutter/material.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:yubico_authenticator/oath/icon_provider/icon_pack_dialog.dart';
|
||||
import 'package:yubico_authenticator/oath/views/add_account_dialog.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
@ -25,13 +26,12 @@ import '../../app/state.dart';
|
||||
import '../../app/views/fs_dialog.dart';
|
||||
import '../../app/views/action_list.dart';
|
||||
import '../../core/state.dart';
|
||||
import '../../exception/cancellation_exception.dart';
|
||||
import '../models.dart';
|
||||
import '../state.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import 'add_account_page.dart';
|
||||
import '../state.dart';
|
||||
import 'manage_password_dialog.dart';
|
||||
import 'reset_dialog.dart';
|
||||
import 'utils.dart';
|
||||
|
||||
Widget oathBuildActions(
|
||||
BuildContext context,
|
||||
@ -48,49 +48,35 @@ Widget oathBuildActions(
|
||||
children: [
|
||||
ActionListSection(l10n.s_setup, children: [
|
||||
ActionListItem(
|
||||
key: keys.addAccountAction,
|
||||
actionStyle: ActionStyle.primary,
|
||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||
title: l10n.s_add_account,
|
||||
subtitle: used == null
|
||||
? l10n.l_unlock_first
|
||||
: (capacity != null
|
||||
? l10n.l_accounts_used(used, capacity)
|
||||
: ''),
|
||||
onTap: used != null && (capacity == null || capacity > used)
|
||||
? (context) async {
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
Navigator.of(context).pop();
|
||||
CredentialData? otpauth;
|
||||
if (isAndroid) {
|
||||
final scanner = ref.read(qrScannerProvider);
|
||||
if (scanner != null) {
|
||||
try {
|
||||
final url = await scanner.scanQr();
|
||||
if (url != null) {
|
||||
otpauth = CredentialData.fromUri(Uri.parse(url));
|
||||
}
|
||||
} on CancellationException catch (_) {
|
||||
// ignored - user cancelled
|
||||
return;
|
||||
title: l10n.s_add_account,
|
||||
subtitle: used == null
|
||||
? l10n.l_unlock_first
|
||||
: (capacity != null
|
||||
? l10n.l_accounts_used(used, capacity)
|
||||
: ''),
|
||||
actionStyle: ActionStyle.primary,
|
||||
icon: const Icon(Icons.person_add_alt_1_outlined),
|
||||
onTap: used != null && (capacity == null || capacity > used)
|
||||
? (context) async {
|
||||
final credentials = ref.read(credentialsProvider);
|
||||
final withContext = ref.read(withContextProvider);
|
||||
Navigator.of(context).pop();
|
||||
if (isAndroid) {
|
||||
final qrScanner = ref.read(qrScannerProvider);
|
||||
if (qrScanner != null) {
|
||||
final uri = await qrScanner.scanQr();
|
||||
await withContext((context) => handleUri(context,
|
||||
credentials, uri, devicePath, oathState, l10n));
|
||||
}
|
||||
} else {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) =>
|
||||
AddAccountDialog(devicePath, oathState),
|
||||
);
|
||||
}
|
||||
}
|
||||
await withContext((context) async {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
devicePath,
|
||||
oathState,
|
||||
credentials: credentials,
|
||||
credentialData: otpauth,
|
||||
),
|
||||
);
|
||||
});
|
||||
}
|
||||
: null,
|
||||
),
|
||||
: null),
|
||||
]),
|
||||
ActionListSection(l10n.s_manage, children: [
|
||||
ActionListItem(
|
||||
|
207
lib/oath/views/rename_list_account.dart
Normal file
207
lib/oath/views/rename_list_account.dart
Normal file
@ -0,0 +1,207 @@
|
||||
/*
|
||||
* Copyright (C) 2022 Yubico.
|
||||
*
|
||||
* Licensed under the Apache License, Version 2.0 (the "License");
|
||||
* you may not use this file except in compliance with the License.
|
||||
* You may obtain a copy of the License at
|
||||
*
|
||||
* http://www.apache.org/licenses/LICENSE-2.0
|
||||
*
|
||||
* Unless required by applicable law or agreed to in writing, software
|
||||
* distributed under the License is distributed on an "AS IS" BASIS,
|
||||
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
|
||||
* See the License for the specific language governing permissions and
|
||||
* limitations under the License.
|
||||
*/
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
import 'package:flutter_riverpod/flutter_riverpod.dart';
|
||||
import 'package:logging/logging.dart';
|
||||
|
||||
import '../../app/logging.dart';
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../exception/cancellation_exception.dart';
|
||||
import '../../desktop/models.dart';
|
||||
import '../../widgets/responsive_dialog.dart';
|
||||
import '../../widgets/utf8_utils.dart';
|
||||
import '../models.dart';
|
||||
import '../keys.dart' as keys;
|
||||
import 'utils.dart';
|
||||
|
||||
final _log = Logger('oath.view.rename_account_dialog');
|
||||
|
||||
class RenameList extends ConsumerStatefulWidget {
|
||||
final DeviceNode device;
|
||||
final CredentialData credential;
|
||||
final List<CredentialData>? credentialsFromUri;
|
||||
final List<OathCredential>? credentials;
|
||||
|
||||
const RenameList(
|
||||
this.device, this.credential, this.credentialsFromUri, this.credentials,
|
||||
{super.key});
|
||||
|
||||
@override
|
||||
ConsumerState<ConsumerStatefulWidget> createState() => _RenameListState();
|
||||
}
|
||||
|
||||
class _RenameListState extends ConsumerState<RenameList> {
|
||||
late String _issuer;
|
||||
late String _account;
|
||||
|
||||
@override
|
||||
void initState() {
|
||||
super.initState();
|
||||
_issuer = widget.credential.issuer?.trim() ?? '';
|
||||
_account = widget.credential.name.trim();
|
||||
}
|
||||
|
||||
void _submit() {
|
||||
if (!mounted) return;
|
||||
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
try {
|
||||
// Rename credentials
|
||||
final credential = widget.credential.copyWith(
|
||||
issuer: _issuer == '' ? null : _issuer,
|
||||
name: _account,
|
||||
);
|
||||
|
||||
Navigator.of(context).pop(credential);
|
||||
showMessage(context, l10n.s_account_renamed);
|
||||
} on CancellationException catch (_) {
|
||||
// ignored
|
||||
} catch (e) {
|
||||
_log.error('Failed to add account', e);
|
||||
final String errorMessage;
|
||||
// TODO: Make this cleaner than importing desktop specific RpcError.
|
||||
if (e is RpcError) {
|
||||
errorMessage = e.message;
|
||||
} else {
|
||||
errorMessage = e.toString();
|
||||
}
|
||||
showMessage(
|
||||
context,
|
||||
l10n.l_account_add_failed(errorMessage),
|
||||
duration: const Duration(seconds: 4),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
@override
|
||||
Widget build(BuildContext context) {
|
||||
final l10n = AppLocalizations.of(context)!;
|
||||
final credential = widget.credential;
|
||||
|
||||
final (issuerRemaining, nameRemaining) = getRemainingKeySpace(
|
||||
oathType: credential.oathType,
|
||||
period: credential.period,
|
||||
issuer: _issuer,
|
||||
name: _account,
|
||||
);
|
||||
|
||||
// is this credential's name/issuer pair different from all other?
|
||||
final isUniqueFromUri = widget.credentialsFromUri
|
||||
?.where((element) =>
|
||||
element != credential &&
|
||||
element.name == _account &&
|
||||
(element.issuer ?? '') == _issuer)
|
||||
.isEmpty ??
|
||||
false;
|
||||
|
||||
final isUniqueFromDevice = widget.credentials
|
||||
?.where((element) =>
|
||||
element.name == _account && (element.issuer ?? '') == _issuer)
|
||||
.isEmpty ??
|
||||
false;
|
||||
|
||||
// is this credential's name/issuer of valid format
|
||||
final isValidFormat = _account.isNotEmpty;
|
||||
|
||||
// are the name/issuer values different from original
|
||||
final didChange = (widget.credential.issuer ?? '') != _issuer ||
|
||||
widget.credential.name != _account;
|
||||
|
||||
// can we rename with the new values
|
||||
final isValid = isUniqueFromUri && isUniqueFromDevice && isValidFormat;
|
||||
|
||||
return ResponsiveDialog(
|
||||
title: Text(l10n.s_rename_account),
|
||||
actions: [
|
||||
TextButton(
|
||||
onPressed: didChange && isValid ? _submit : null,
|
||||
key: keys.saveButton,
|
||||
child: Text(l10n.s_save),
|
||||
),
|
||||
],
|
||||
child: Padding(
|
||||
padding: const EdgeInsets.symmetric(horizontal: 18.0),
|
||||
child: Column(
|
||||
crossAxisAlignment: CrossAxisAlignment.start,
|
||||
children: [
|
||||
credential.issuer != null
|
||||
? Text(l10n.q_rename_target(
|
||||
'${credential.issuer} (${credential.name})'))
|
||||
: Text(l10n.q_rename_target(credential.name)),
|
||||
Text(l10n.p_rename_will_change_account_displayed),
|
||||
TextFormField(
|
||||
initialValue: _issuer,
|
||||
enabled: issuerRemaining > 0,
|
||||
maxLength: issuerRemaining > 0 ? issuerRemaining : null,
|
||||
buildCounter: buildByteCounterFor(_issuer),
|
||||
inputFormatters: [limitBytesLength(issuerRemaining)],
|
||||
key: keys.issuerField,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_issuer_optional,
|
||||
helperText: '', // Prevents dialog resizing when disabled
|
||||
prefixIcon: const Icon(Icons.business_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.next,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_issuer = value.trim();
|
||||
});
|
||||
},
|
||||
),
|
||||
TextFormField(
|
||||
initialValue: _account,
|
||||
maxLength: nameRemaining,
|
||||
inputFormatters: [limitBytesLength(nameRemaining)],
|
||||
buildCounter: buildByteCounterFor(_account),
|
||||
key: keys.nameField,
|
||||
decoration: InputDecoration(
|
||||
border: const OutlineInputBorder(),
|
||||
labelText: l10n.s_account_name,
|
||||
helperText: '', // Prevents dialog resizing when disabled
|
||||
errorText: !isValidFormat
|
||||
? l10n.l_account_name_required
|
||||
: (!isUniqueFromUri || !isUniqueFromDevice)
|
||||
? l10n.l_name_already_exists
|
||||
: null,
|
||||
prefixIcon: const Icon(Icons.people_alt_outlined),
|
||||
),
|
||||
textInputAction: TextInputAction.done,
|
||||
onChanged: (value) {
|
||||
setState(() {
|
||||
_account = value.trim();
|
||||
});
|
||||
},
|
||||
onFieldSubmitted: (_) {
|
||||
if (didChange && isValid) {
|
||||
_submit();
|
||||
}
|
||||
},
|
||||
),
|
||||
]
|
||||
.map((e) => Padding(
|
||||
padding: const EdgeInsets.symmetric(vertical: 8.0),
|
||||
child: e,
|
||||
))
|
||||
.toList(),
|
||||
),
|
||||
),
|
||||
);
|
||||
}
|
||||
}
|
@ -16,8 +16,16 @@
|
||||
|
||||
import 'dart:math';
|
||||
|
||||
import 'package:flutter/material.dart';
|
||||
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
|
||||
|
||||
import '../../app/message.dart';
|
||||
import '../../app/models.dart';
|
||||
import '../../widgets/utf8_utils.dart';
|
||||
import '../keys.dart';
|
||||
import '../models.dart';
|
||||
import 'add_account_page.dart';
|
||||
import 'add_multi_account_page.dart';
|
||||
|
||||
/// Calculates the available space for issuer and account name.
|
||||
///
|
||||
@ -53,3 +61,34 @@ String getTextName(OathCredential credential) {
|
||||
? '${credential.issuer} (${credential.name})'
|
||||
: credential.name;
|
||||
}
|
||||
|
||||
Future<void> handleUri(
|
||||
BuildContext context,
|
||||
List<OathCredential>? credentials,
|
||||
String? uri,
|
||||
DevicePath? devicePath,
|
||||
OathState? state,
|
||||
AppLocalizations l10n,
|
||||
) async {
|
||||
List<CredentialData> creds =
|
||||
uri != null ? CredentialData.fromUri(Uri.parse(uri)) : [];
|
||||
if (creds.isEmpty) {
|
||||
showMessage(context, l10n.l_qr_not_found);
|
||||
} else if (creds.length == 1) {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddAccountPage(
|
||||
devicePath,
|
||||
state,
|
||||
credentials: credentials,
|
||||
credentialData: creds[0],
|
||||
),
|
||||
);
|
||||
} else {
|
||||
await showBlurDialog(
|
||||
context: context,
|
||||
builder: (context) => OathAddMultiAccountPage(devicePath, state, creds,
|
||||
key: migrateAccountAction),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -40,6 +40,7 @@ class ResponsiveDialog extends StatefulWidget {
|
||||
class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
||||
final Key _childKey = GlobalKey();
|
||||
final _focus = FocusScopeNode();
|
||||
bool _hasLostFocus = false;
|
||||
|
||||
@override
|
||||
void dispose() {
|
||||
@ -101,8 +102,9 @@ class _ResponsiveDialogState extends State<ResponsiveDialog> {
|
||||
node: _focus,
|
||||
autofocus: true,
|
||||
onFocusChange: (focused) {
|
||||
if (!focused) {
|
||||
if (!focused && !_hasLostFocus) {
|
||||
_focus.requestFocus();
|
||||
_hasLostFocus = true;
|
||||
}
|
||||
},
|
||||
child: constraints.maxWidth < 540
|
||||
|
10
pubspec.lock
10
pubspec.lock
@ -41,6 +41,14 @@ packages:
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.11.0"
|
||||
base32:
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: base32
|
||||
sha256: ddad4ebfedf93d4500818ed8e61443b734ffe7cf8a45c668c9b34ef6adde02e2
|
||||
url: "https://pub.dev"
|
||||
source: hosted
|
||||
version: "2.1.3"
|
||||
boolean_selector:
|
||||
dependency: transitive
|
||||
description:
|
||||
@ -154,7 +162,7 @@ packages:
|
||||
source: hosted
|
||||
version: "1.17.1"
|
||||
convert:
|
||||
dependency: transitive
|
||||
dependency: "direct main"
|
||||
description:
|
||||
name: convert
|
||||
sha256: "0f08b14755d163f6e2134cb58222dd25ea2a2ee8a195e53983d57c075324d592"
|
||||
|
@ -64,6 +64,8 @@ dependencies:
|
||||
tray_manager: ^0.2.0
|
||||
local_notifier: ^0.1.5
|
||||
io: ^1.0.4
|
||||
base32: ^2.1.3
|
||||
convert: ^3.1.1
|
||||
|
||||
dev_dependencies:
|
||||
integration_test:
|
||||
|
Loading…
Reference in New Issue
Block a user