diff --git a/android/app/build.gradle b/android/app/build.gradle index 44deedb9..f53c271f 100644 --- a/android/app/build.gradle +++ b/android/app/build.gradle @@ -30,7 +30,7 @@ apply from: "$flutterRoot/packages/flutter_tools/gradle/flutter.gradle" android { - compileSdkVersion flutter.compileSdkVersion + compileSdkVersion 32 compileOptions { sourceCompatibility JavaVersion.VERSION_1_8 @@ -83,10 +83,11 @@ dependencies { implementation 'org.jetbrains.kotlinx:kotlinx-serialization-json:1.3.2' // Lifecycle - implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.4.1' + implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.5.1' implementation 'androidx.lifecycle:lifecycle-extensions:2.2.0' - implementation 'androidx.fragment:fragment-ktx:1.4.1' + implementation 'androidx.fragment:fragment-ktx:1.5.1' + implementation 'androidx.preference:preference-ktx:1.2.0' // testing dependencies testImplementation "junit:junit:$project.junitVersion" diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 08d7f5c3..09a89177 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -1,3 +1,4 @@ + @@ -15,16 +16,18 @@ android:name="${applicationName}" android:icon="@mipmap/ic_launcher" android:label="@string/app_label"> + + - + + \ No newline at end of file diff --git a/android/app/src/main/java/com/yubico/authenticator/YOTPActivity.kt b/android/app/src/main/java/com/yubico/authenticator/YOTPActivity.kt new file mode 100644 index 00000000..c7237df8 --- /dev/null +++ b/android/app/src/main/java/com/yubico/authenticator/YOTPActivity.kt @@ -0,0 +1,105 @@ +package com.yubico.authenticator + +import android.app.Activity +import android.content.* +import android.net.Uri +import android.os.Build +import android.os.Bundle +import android.widget.Toast + +import com.yubico.authenticator.logging.Log + +typealias ResourceId = Int + +class YOTPActivity : Activity() { + + private var openAppOnNfcTap: Boolean = false + private var copyOtpOnNfcTap: Boolean = false + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + val prefs: SharedPreferences = getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + openAppOnNfcTap = prefs.getBoolean(PREF_NFC_OPEN_APP, false) + copyOtpOnNfcTap = prefs.getBoolean(PREF_NFC_COPY_OTP, false) + + handleIntent(intent) + } + + override fun onNewIntent(intent: Intent) { + super.onNewIntent(intent) + handleIntent(intent) + } + + override fun onPause() { + super.onPause() + overridePendingTransition(0, 0) + } + + private fun handleIntent(intent: Intent) { + val intentData: Uri? = intent.data + if (intentData != null) { + + var otp: String? = null + if (copyOtpOnNfcTap) { + try { + otp = parseOtpFromUri(intentData) + setPrimaryClip(otp) + + if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) { + showToast(R.string.otp_success, Toast.LENGTH_SHORT) + } + } catch (_: IllegalArgumentException) { + showToast(R.string.otp_parse_failure, Toast.LENGTH_LONG) + } catch (_: UnsupportedOperationException) { + showToast(R.string.otp_set_clip_failure, Toast.LENGTH_LONG) + } + + } + + if (openAppOnNfcTap) { + val mainAppIntent = Intent(this, MainActivity::class.java).apply { + if (otp != null) { + putExtra("OTP", otp) + } + } + startActivity(mainAppIntent) + } + + finishAndRemoveTask() + } + } + + private fun showToast(value: ResourceId, length: Int) { + Toast.makeText(this, value, length).show() + } + + private fun parseOtpFromUri(uri: Uri): String { + uri.fragment?.let { + if (it.length == 44) { + return it + } + } + + Log.e(TAG, "Failed to parse OTP from provided otp uri string") + Log.t(TAG, "Uri was $uri") + throw IllegalArgumentException() + } + + private fun setPrimaryClip(otp: String) { + try { + val clipboardManager = getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + clipboardManager.setPrimaryClip(ClipData.newPlainText(otp, otp)) + } catch (e: Exception) { + Log.e(TAG, "Failed to copy otp string to clipboard", e.stackTraceToString()) + throw UnsupportedOperationException() + } + } + + companion object { + const val TAG = "YubicoAuthenticatorOTPActivity" + const val PREFS_FILE = "FlutterSharedPreferences" + const val PREF_NFC_OPEN_APP = "flutter.prefNfcOpenApp" + const val PREF_NFC_COPY_OTP = "flutter.prefNfcCopyOtp" + } + +} \ No newline at end of file diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 34dde4bb..371388bd 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -1,4 +1,7 @@ Yubico Authenticator + Successfully copied OTP code from YubiKey to clipboard. + Failed to parse OTP code from YubiKey. + Failed to access clipboard when trying to copy OTP code from YubiKey. \ No newline at end of file diff --git a/android/app/src/main/res/values/styles.xml b/android/app/src/main/res/values/styles.xml index d460d1e9..530675de 100644 --- a/android/app/src/main/res/values/styles.xml +++ b/android/app/src/main/res/values/styles.xml @@ -15,4 +15,15 @@ + + diff --git a/android/build.gradle b/android/build.gradle index 6cb94736..cc1156db 100644 --- a/android/build.gradle +++ b/android/build.gradle @@ -1,5 +1,5 @@ buildscript { - ext.kotlin_version = '1.6.21' + ext.kotlin_version = '1.7.0' repositories { google() mavenCentral() diff --git a/lib/android/views/android_settings_page.dart b/lib/android/views/android_settings_page.dart new file mode 100755 index 00000000..545e3526 --- /dev/null +++ b/lib/android/views/android_settings_page.dart @@ -0,0 +1,95 @@ +import 'package:flutter/material.dart'; +import 'package:flutter_riverpod/flutter_riverpod.dart'; +import 'package:logging/logging.dart'; +import 'package:yubico_authenticator/core/state.dart'; + +import '../../app/logging.dart'; +import '../../app/state.dart'; +import '../../widgets/list_title.dart'; +import '../../widgets/responsive_dialog.dart'; + +final _log = Logger('android_settings'); + +class AndroidSettingsPage extends ConsumerStatefulWidget { + const AndroidSettingsPage({super.key}); + + @override + ConsumerState createState() => + _AndroidSettingsPageState(); +} + +class _AndroidSettingsPageState extends ConsumerState { + static const String prefNfcOpenApp = 'prefNfcOpenApp'; + static const String prefNfcCopyOtp = 'prefNfcCopyOtp'; + + bool nfcOpenApp = false; + bool nfcCopyOtp = false; + + @override + void initState() { + super.initState(); + nfcOpenApp = ref.read(prefProvider).getBool(prefNfcOpenApp) ?? false; + nfcCopyOtp = ref.read(prefProvider).getBool(prefNfcCopyOtp) ?? false; + } + + @override + Widget build(BuildContext context) { + final themeMode = ref.watch(themeModeProvider); + return ResponsiveDialog( + title: const Text('Settings'), + child: Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.start, + children: [ + const ListTitle('NFC tap options'), + SwitchListTile( + title: const Text('Open authenticator'), + value: nfcOpenApp, + onChanged: (value) { + ref.read(prefProvider).setBool(prefNfcOpenApp, value); + setState(() { + nfcOpenApp = value; + }); + }), + SwitchListTile( + title: const Text('Copy OTP to clipboard'), + value: nfcCopyOtp, + onChanged: (value) { + ref.read(prefProvider).setBool(prefNfcCopyOtp, value); + setState(() { + nfcCopyOtp = value; + }); + }), + const ListTitle('Appearance'), + RadioListTile( + title: const Text('System default'), + value: ThemeMode.system, + groupValue: themeMode, + onChanged: (mode) { + ref.read(themeModeProvider.notifier).setThemeMode(mode!); + _log.debug('Set theme mode to $mode'); + }, + ), + RadioListTile( + title: const Text('Light mode'), + value: ThemeMode.light, + groupValue: themeMode, + onChanged: (mode) { + ref.read(themeModeProvider.notifier).setThemeMode(mode!); + _log.debug('Set theme mode to $mode'); + }, + ), + RadioListTile( + title: const Text('Dark mode'), + value: ThemeMode.dark, + groupValue: themeMode, + onChanged: (mode) { + ref.read(themeModeProvider.notifier).setThemeMode(mode!); + _log.debug('Set theme mode to $mode'); + }, + ), + ], + ), + ); + } +} diff --git a/lib/app/views/main_drawer.dart b/lib/app/views/main_drawer.dart index e6984156..b805b879 100755 --- a/lib/app/views/main_drawer.dart +++ b/lib/app/views/main_drawer.dart @@ -1,9 +1,12 @@ +import 'dart:io'; + import 'package:flutter/material.dart'; import 'package:flutter_gen/gen_l10n/app_localizations.dart'; import 'package:flutter_riverpod/flutter_riverpod.dart'; -import '../../management/views/management_screen.dart'; import '../../about_page.dart'; +import '../../android/views/android_settings_page.dart'; +import '../../management/views/management_screen.dart'; import '../../settings_page.dart'; import '../message.dart'; import '../models.dart'; @@ -98,7 +101,9 @@ class MainPageDrawer extends ConsumerWidget { if (shouldPop) nav.pop(); showBlurDialog( context: context, - builder: (context) => const SettingsPage(), + builder: (context) => Platform.isAndroid + ? const AndroidSettingsPage() + : const SettingsPage(), routeSettings: const RouteSettings(name: 'settings'), ); },