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