handle features dependent on SDK version

This commit is contained in:
Adam Velebil 2022-09-21 15:29:34 +02:00
parent 2aa3fbb52b
commit 1d99ce3fe6
No known key found for this signature in database
GPG Key ID: AC6D6B9D715FC084
16 changed files with 224 additions and 51 deletions

View File

@ -31,7 +31,7 @@ apply plugin: 'com.google.android.gms.oss-licenses-plugin'
android {
compileSdkVersion 32
compileSdkVersion project.compileSdkVersion
compileOptions {
sourceCompatibility JavaVersion.VERSION_1_8

View File

@ -0,0 +1,36 @@
package com.yubico.authenticator
import android.content.ClipData
import android.content.ClipDescription
import android.content.ClipboardManager
import android.content.Context
import android.os.Build
import android.os.PersistableBundle
import com.yubico.authenticator.logging.Log
object ClipboardUtil {
private const val TAG = "ClipboardUtil"
fun setPrimaryClip(context: Context, toClipboard: String, isSensitive: Boolean) {
try {
val clipboardManager =
context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager
val clipData = ClipData.newPlainText(toClipboard, toClipboard)
clipData.apply {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
description.extras = PersistableBundle().apply {
putBoolean(ClipDescription.EXTRA_IS_SENSITIVE, isSensitive)
}
}
}
clipboardManager.setPrimaryClip(clipData)
} catch (e: Exception) {
Log.e(TAG, "Failed to set string to clipboard", e.stackTraceToString())
throw UnsupportedOperationException()
}
}
}

View File

@ -6,6 +6,7 @@ import android.content.Intent
import android.content.IntentFilter
import android.nfc.NfcAdapter
import android.nfc.Tag
import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import androidx.activity.viewModels
@ -130,7 +131,7 @@ class MainActivity : FlutterFragmentActivity() {
// Handle existing tag when launched from NDEF
val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
if(tag != null) {
if (tag != null) {
intent.removeExtra(NfcAdapter.EXTRA_TAG)
val executor = Executors.newSingleThreadExecutor()
@ -190,8 +191,15 @@ class MainActivity : FlutterFragmentActivity() {
viewModel.appContext.observe(this) {
contextManager?.dispose()
contextManager = when(it) {
OperationContext.Oath -> OathManager(this, messenger, viewModel, oathViewModel, dialogManager, appPreferences)
contextManager = when (it) {
OperationContext.Oath -> OathManager(
this,
messenger,
viewModel,
oathViewModel,
dialogManager,
appPreferences
)
else -> null
}
viewModel.connectedYubiKey.value?.let(::processYubiKey)
@ -234,6 +242,21 @@ class MainActivity : FlutterFragmentActivity() {
methodCall.arguments as Boolean,
)
)
"getAndroidSdkVersion" -> result.success(
Build.VERSION.SDK_INT
)
"setPrimaryClip" -> {
val toClipboard = methodCall.argument<String>("toClipboard")
val isSensitive = methodCall.argument<Boolean>("isSensitive")
if (toClipboard != null && isSensitive != null) {
ClipboardUtil.setPrimaryClip(
this@MainActivity,
toClipboard,
isSensitive
)
}
result.success(true)
}
else -> Log.w(TAG, "Unknown app method: ${methodCall.method}")
}
}

View File

@ -40,7 +40,7 @@ class NdefActivity : Activity() {
if (appPreferences.copyOtpOnNfcTap) {
try {
val otpSlotContent = parseOtpFromIntent()
setPrimaryClip(otpSlotContent.content)
ClipboardUtil.setPrimaryClip(this, otpSlotContent.content, true)
if (Build.VERSION.SDK_INT <= Build.VERSION_CODES.S_V2) {
showToast(
@ -95,16 +95,6 @@ class NdefActivity : Activity() {
throw IllegalArgumentException("Failed to parse OTP from the intent")
}
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"
}

View File

@ -23,7 +23,7 @@ allprojects {
minSdkVersion = 21
targetSdkVersion = 33
compileSdkVersion = 33
buildToolsVersion = "30.0.3"
buildToolsVersion = "33.0.0"
yubiKitVersion = "2.1.0"
junitVersion = "4.13.2"

View File

@ -2,7 +2,6 @@ import 'dart:convert';
import 'dart:io';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
@ -168,7 +167,7 @@ class AboutPage extends ConsumerWidget {
'dart': Platform.version,
});
final text = const JsonEncoder.withIndent(' ').convert(data);
await Clipboard.setData(ClipboardData(text: text));
await ref.read(clipboardProvider).setText(text);
await ref.read(withContextProvider)(
(context) async {
showMessage(
@ -236,13 +235,17 @@ class LoggingPanel extends ConsumerWidget {
onPressed: () async {
_log.info('Copying log to clipboard ($version)...');
final logs = await ref.read(logLevelProvider.notifier).getLogs();
await Clipboard.setData(ClipboardData(text: logs.join('\n')));
await ref.read(withContextProvider)(
(context) async {
showMessage(
context, AppLocalizations.of(context)!.general_log_copied);
},
);
var clipboard = ref.read(clipboardProvider);
await clipboard.setText(logs.join('\n'));
if (!clipboard.platformGivesFeedback()) {
await ref.read(withContextProvider)(
(context) async {
showMessage(
context,
AppLocalizations.of(context)!.general_log_copied);
},
);
}
},
),
],

View File

@ -0,0 +1,12 @@
import 'package:flutter/services.dart';
const appMethodsChannel = MethodChannel('app.methods');
Future<int> getAndroidSdkVersion() async {
return await appMethodsChannel.invokeMethod('getAndroidSdkVersion');
}
Future<void> setPrimaryClip(String toClipboard, bool isSensitive) async {
await appMethodsChannel.invokeMethod('setPrimaryClip',
{'toClipboard': toClipboard, 'isSensitive': isSensitive});
}

View File

@ -19,6 +19,7 @@ import '../app/views/main_page.dart';
import '../core/state.dart';
import '../management/state.dart';
import '../oath/state.dart';
import 'app_methods.dart';
import 'management/state.dart';
import 'oath/state.dart';
import 'qr_scanner/qr_scanner_provider.dart';
@ -34,6 +35,9 @@ Future<Widget> initialize() async {
_initLicenses();
//initialize sdkInt value
int androidSdkVersion = await getAndroidSdkVersion();
return ProviderScope(
overrides: [
supportedAppsProvider.overrideWithValue([
@ -51,7 +55,9 @@ Future<Widget> initialize() async {
managementStateProvider.overrideWithProvider(androidManagementState),
currentDeviceProvider.overrideWithProvider(androidCurrentDeviceProvider),
qrScannerProvider.overrideWithProvider(androidQrScannerProvider),
windowStateProvider.overrideWithProvider(androidWindowStateProvider)
windowStateProvider.overrideWithProvider(androidWindowStateProvider),
clipboardProvider.overrideWithProvider(androidClipboardProvider),
supportedThemesProvider.overrideWithProvider(androidSupportedThemesProvider)
],
child: DismissKeyboard(
child: YubicoAuthenticatorApp(page: Consumer(
@ -65,6 +71,9 @@ Future<Widget> initialize() async {
/// initializes global handler for dialogs
ref.read(androidDialogProvider);
/// set the platform version
ref.read(androidSdkVersionProvider).setVersion(androidSdkVersion);
/// if the beta dialog was not shown yet, this will show it
requestBetaDialog(ref);

View File

@ -1,12 +1,13 @@
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import '../app/models.dart';
import '../app/state.dart';
import 'app_methods.dart';
import 'devices.dart';
const _contextChannel = MethodChannel('android.state.appContext');
const _methodsChannel = MethodChannel('app.methods');
final androidAllowScreenshotsProvider =
StateNotifierProvider<AllowScreenshotsNotifier, bool>(
@ -18,13 +19,59 @@ class AllowScreenshotsNotifier extends StateNotifier<bool> {
void setAllowScreenshots(bool value) async {
final result =
await _methodsChannel.invokeMethod('allowScreenshots', value);
await appMethodsChannel.invokeMethod('allowScreenshots', value);
if (mounted) {
state = result;
}
}
}
final androidClipboardProvider = Provider<AppClipboard>(
(ref) => _AndroidClipboard(ref),
);
class _AndroidClipboard extends AppClipboard {
final ProviderRef<AppClipboard> _ref;
const _AndroidClipboard(this._ref);
@override
bool platformGivesFeedback() {
return _ref.read(androidSdkVersionProvider).getVersion() >= 33;
}
@override
Future<void> setText(String toClipboard, {bool isSensitive = false}) async {
await setPrimaryClip(toClipboard, isSensitive);
}
}
final androidSdkVersionProvider = StateProvider<_AndroidSdkVersion>(
(ref) => _AndroidSdkVersion(),
);
class _AndroidSdkVersion {
int _sdkVersion = -1;
int getVersion() {
return _sdkVersion;
}
void setVersion(int value) {
_sdkVersion = value;
}
}
final androidSupportedThemesProvider = StateProvider<List<ThemeMode>>((ref) {
if (ref.read(androidSdkVersionProvider).getVersion() < 29) {
/// the user can select from light or dark theme of the app
return [ThemeMode.light, ThemeMode.dark];
} else {
/// the user can also select system theme on newer Android versions
return ThemeMode.values;
}
});
final androidSubPageProvider =
StateNotifierProvider<CurrentAppNotifier, Application>((ref) {
return _AndroidSubPageNotifier(ref.watch(supportedAppsProvider));

View File

@ -153,7 +153,8 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
title: const Text('App theme'),
subtitle: Text(themeMode.displayName),
onTap: () async {
final newMode = await _selectAppearance(context, themeMode);
final newMode = await _selectAppearance(
ref.read(supportedThemesProvider), context, themeMode);
ref.read(themeModeProvider.notifier).setThemeMode(newMode);
},
),
@ -211,14 +212,14 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
}) ??
_defaultClipKbdLayout;
Future<ThemeMode> _selectAppearance(
Future<ThemeMode> _selectAppearance(List<ThemeMode> supportedThemes,
BuildContext context, ThemeMode themeMode) async =>
await showDialog<ThemeMode>(
context: context,
builder: (BuildContext context) {
return SimpleDialog(
title: const Text('Choose app theme'),
children: ThemeMode.values
children: supportedThemes
.map((e) => RadioListTile(
title: Text(e.displayName),
value: e,
@ -231,5 +232,5 @@ class _AndroidSettingsPageState extends ConsumerState<AndroidSettingsPage> {
.toList(),
);
}) ??
ThemeMode.system;
themeMode;
}

View File

@ -20,13 +20,23 @@ final windowStateProvider = Provider<WindowState>(
(ref) => WindowState(focused: true, visible: true, active: true),
);
final supportedThemesProvider =
StateProvider<List<ThemeMode>>((ref) => ThemeMode.values);
final themeModeProvider = StateNotifierProvider<ThemeModeNotifier, ThemeMode>(
(ref) => ThemeModeNotifier(ref.watch(prefProvider)));
(ref) => ThemeModeNotifier(
ref.watch(prefProvider),
/// theme on index 0 is the default
ref.read(supportedThemesProvider)[0]),
);
class ThemeModeNotifier extends StateNotifier<ThemeMode> {
static const String _key = 'APP_STATE_THEME';
final SharedPreferences _prefs;
ThemeModeNotifier(this._prefs) : super(_fromName(_prefs.getString(_key)));
ThemeModeNotifier(this._prefs, ThemeMode defaultTheme)
: super(_fromName(_prefs.getString(_key), defaultTheme));
void setThemeMode(ThemeMode mode) {
_log.debug('Set theme to $mode');
@ -34,14 +44,14 @@ class ThemeModeNotifier extends StateNotifier<ThemeMode> {
_prefs.setString(_key, mode.name);
}
static ThemeMode _fromName(String? name) {
static ThemeMode _fromName(String? name, ThemeMode defaultTheme) {
switch (name) {
case 'light':
return ThemeMode.light;
case 'dark':
return ThemeMode.dark;
default:
return ThemeMode.system;
return defaultTheme;
}
}
}
@ -71,6 +81,7 @@ final currentDeviceProvider =
abstract class CurrentDeviceNotifier extends StateNotifier<DeviceNode?> {
CurrentDeviceNotifier(super.state);
setCurrentDevice(DeviceNode? device);
}
@ -85,6 +96,7 @@ final currentAppProvider =
class CurrentAppNotifier extends StateNotifier<Application> {
final List<Application> _supportedApps;
CurrentAppNotifier(this._supportedApps) : super(_supportedApps.first);
void setCurrentApp(Application app) {
@ -137,6 +149,18 @@ class ContextConsumer extends StateNotifier<Function(BuildContext)?> {
}
}
abstract class AppClipboard {
const AppClipboard();
Future<void> setText(String toClipboard, {bool isSensitive = false});
bool platformGivesFeedback();
}
final clipboardProvider = Provider<AppClipboard>(
(ref) => throw UnimplementedError(),
);
/// A callback which will be invoked with a [BuildContext] that can be used to
/// open dialogs, show Snackbars, etc.
///

View File

@ -118,6 +118,7 @@ Future<Widget> initialize(List<String> argv) async {
fidoStateProvider.overrideWithProvider(desktopFidoState),
fingerprintProvider.overrideWithProvider(desktopFingerprintProvider),
credentialProvider.overrideWithProvider(desktopCredentialProvider),
clipboardProvider.overrideWithProvider(desktopClipboardProvider)
],
child: YubicoAuthenticatorApp(
page: Consumer(

View File

@ -1,15 +1,16 @@
import 'dart:async';
import 'dart:io';
import 'package:logging/logging.dart';
import 'package:flutter/services.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:shared_preferences/shared_preferences.dart';
import 'package:window_manager/window_manager.dart';
import 'package:yubico_authenticator/app/logging.dart';
import '../app/models.dart';
import '../app/state.dart';
import '../core/state.dart';
import '../app/models.dart';
import 'models.dart';
import 'rpc.dart';
@ -26,6 +27,7 @@ final rpcStateProvider = StateNotifierProvider<_RpcStateNotifier, RpcState>(
class _RpcStateNotifier extends StateNotifier<RpcState> {
final RpcSession rpc;
_RpcStateNotifier(this.rpc) : super(const RpcState('unknown', false)) {
_init();
}
@ -49,6 +51,7 @@ final desktopWindowStateProvider = Provider<WindowState>(
class _WindowStateNotifier extends StateNotifier<WindowState>
with WindowListener {
Timer? _idleTimer;
_WindowStateNotifier()
: super(WindowState(focused: true, visible: true, active: true)) {
_init();
@ -111,6 +114,22 @@ class _WindowStateNotifier extends StateNotifier<WindowState>
}
}
final desktopClipboardProvider = Provider<AppClipboard>(
(ref) => _DesktopClipboard(),
);
class _DesktopClipboard extends AppClipboard {
@override
bool platformGivesFeedback() {
return false;
}
@override
Future<void> setText(String toClipboard, {bool isSensitive = false}) async {
await Clipboard.setData(ClipboardData(text: toClipboard));
}
}
final desktopCurrentDeviceProvider =
StateNotifierProvider<CurrentDeviceNotifier, DeviceNode?>((ref) {
final provider = _DesktopCurrentDeviceNotifier(ref.watch(prefProvider));
@ -121,6 +140,7 @@ final desktopCurrentDeviceProvider =
class _DesktopCurrentDeviceNotifier extends CurrentDeviceNotifier {
static const String _lastDevice = 'APP_STATE_LAST_DEVICE';
final SharedPreferences _prefs;
_DesktopCurrentDeviceNotifier(this._prefs) : super(null);
_updateAttachedDevices(List<DeviceNode>? previous, List<DeviceNode> devices) {

View File

@ -14,6 +14,7 @@ import 'account_mixin.dart';
class AccountDialog extends ConsumerWidget with AccountMixin {
@override
final OathCredential credential;
const AccountDialog(this.credential, {super.key});
@override
@ -122,7 +123,8 @@ class AccountDialog extends ConsumerWidget with AccountMixin {
}
await ref.read(withContextProvider)(
(context) async {
copyToClipboard(context, getCode(ref));
copyToClipboard(
ref.watch(clipboardProvider), context, getCode(ref));
},
);
return null;

View File

@ -3,7 +3,6 @@ import 'dart:io';
import 'dart:ui';
import 'package:flutter/material.dart';
import 'package:flutter/services.dart';
import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
@ -68,11 +67,14 @@ mixin AccountMixin {
}
@protected
void copyToClipboard(BuildContext context, OathCode? code) {
void copyToClipboard(
AppClipboard clipboard, BuildContext context, OathCode? code) {
if (code != null) {
Clipboard.setData(ClipboardData(text: code.value));
showMessage(
context, AppLocalizations.of(context)!.oath_copied_to_clipboard);
clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
showMessage(
context, AppLocalizations.of(context)!.oath_copied_to_clipboard);
}
}
}
@ -117,11 +119,14 @@ mixin AccountMixin {
action: code == null || expired
? null
: (context) {
Clipboard.setData(ClipboardData(text: code.value));
showMessage(
context,
AppLocalizations.of(context)!
.oath_copied_to_clipboard);
var clipboard = ref.read(clipboardProvider);
clipboard.setText(code.value, isSensitive: true);
if (!clipboard.platformGivesFeedback()) {
showMessage(
context,
AppLocalizations.of(context)!
.oath_copied_to_clipboard);
}
},
),
if (manual)

View File

@ -80,8 +80,8 @@ class AccountView extends ConsumerWidget with AccountMixin {
ref,
)
: getCode(ref);
await withContext(
(context) async => copyToClipboard(context, code));
await withContext((context) async =>
copyToClipboard(ref.watch(clipboardProvider), context, code));
},
);
} on CancellationException catch (_) {