diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt index efd51203..ef6cf138 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContext.kt @@ -1,13 +1,12 @@ package com.yubico.authenticator -import androidx.lifecycle.LiveData -import androidx.lifecycle.MutableLiveData import com.yubico.authenticator.logging.Log import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.CoroutineScope class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) { - private val channel = FlutterChannel(messenger, "android.state.appContext") + private val channel = MethodChannel(messenger, "android.state.appContext") init { channel.setHandler(coroutineScope) { method, args -> @@ -18,12 +17,11 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, pri } } - private suspend fun setContext(subPageIndex: Int): String { val appContext = OperationContext.getByValue(subPageIndex) - appViewModel.setContext(appContext) - Log.d(TAG, "App context is now ${appContext}") - return FlutterChannel.NULL + appViewModel.setAppContext(appContext) + Log.d(TAG, "App context is now $appContext") + return NULL } companion object { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt index 60b3e66f..7e46e850 100755 --- a/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/AppContextManager.kt @@ -2,6 +2,10 @@ package com.yubico.authenticator import com.yubico.yubikit.core.YubiKeyDevice +/** + * Provides behavior to run when a YubiKey is inserted/tapped for a specific view of the app. + */ interface AppContextManager { suspend fun processYubiKey(device: YubiKeyDevice) + fun dispose() } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt new file mode 100755 index 00000000..9128fa85 --- /dev/null +++ b/android/app/src/main/kotlin/com/yubico/authenticator/ChannelHelper.kt @@ -0,0 +1,105 @@ +package com.yubico.authenticator + +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.LiveData +import androidx.lifecycle.Observer +import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.EventChannel +import io.flutter.plugin.common.MethodChannel +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import kotlinx.serialization.encodeToString +import java.io.Closeable +import kotlin.coroutines.resume +import kotlin.coroutines.resumeWithException +import kotlin.coroutines.suspendCoroutine + +/** + * Observes a LiveData value, sending each change to Flutter via an EventChannel. + */ +inline fun LiveData.streamTo(lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, channelName: String): Closeable { + val channel = EventChannel(messenger, channelName) + var sink: EventChannel.EventSink? = null + + channel.setStreamHandler(object : EventChannel.StreamHandler { + override fun onListen(arguments: Any?, events: EventChannel.EventSink) { + sink = events + events.success(value?.let(jsonSerializer::encodeToString) ?: NULL) + } + + override fun onCancel(arguments: Any?) { + sink = null + } + }) + + val observer = Observer { + sink?.success(it?.let(jsonSerializer::encodeToString) ?: NULL) + } + observe(lifecycleOwner, observer) + + return Closeable { + removeObserver(observer) + channel.setStreamHandler(null) + } +} + +typealias MethodHandler = suspend (method: String, args: Map) -> String + +/** + * Coroutine-based handing of MethodChannel methods called from Flutter. + */ +fun MethodChannel.setHandler(scope: CoroutineScope, handler: MethodHandler) { + setMethodCallHandler { call, result -> + // N.B. Arguments from Flutter are passed as a Map of basic types. We may want to + // consider JSON encoding if we need to pass more complex structures. + // Return values are always JSON strings. + val args = call.arguments>() ?: mapOf() + scope.launch { + try { + val response = handler.invoke(call.method, args) + result.success(response) + } catch (notImplemented: NotImplementedError) { + result.notImplemented() + } catch (error: Throwable) { + result.error( + error.javaClass.simpleName, + error.toString(), + "Cause: " + error.cause + ", Stacktrace: " + android.util.Log.getStackTraceString( + error + ) + ) + } + } + } +} + +/** + * Coroutine-based method invocation to call a Flutter method and get a result. + */ +suspend fun MethodChannel.invoke(method: String, args: Any?): Any? = + withContext(Dispatchers.Main) { + suspendCoroutine { continuation -> + invokeMethod( + method, + args, + object : MethodChannel.Result { + override fun success(result: Any?) { + continuation.resume(result) + } + + override fun error( + errorCode: String, + errorMessage: String?, + errorDetails: Any? + ) { + continuation.resumeWithException(Exception("$errorCode: $errorMessage - $errorDetails")) + } + + override fun notImplemented() { + continuation.resumeWithException(NotImplementedError("Method not implemented: $method")) + } + }) + } + } \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt index c73862a9..fca0dbb2 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/DialogManager.kt @@ -1,6 +1,7 @@ package com.yubico.authenticator import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.* import kotlinx.serialization.encodeToString import kotlinx.serialization.json.Json @@ -15,12 +16,12 @@ enum class Icon(val value: String) { class DialogManager(messenger: BinaryMessenger, private val coroutineScope: CoroutineScope) { private val channel = - FlutterChannel(messenger, "com.yubico.authenticator.channel.dialog") + MethodChannel(messenger, "com.yubico.authenticator.channel.dialog") private var onCancelled: OnDialogCancelled? = null init { - channel.setHandler(coroutineScope) { method, args -> + channel.setHandler(coroutineScope) { method, _ -> when (method) { "cancel" -> dialogClosed() else -> throw NotImplementedError() @@ -31,7 +32,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro fun showDialog(icon: Icon, title: String, description: String, cancelled: OnDialogCancelled?) { onCancelled = cancelled coroutineScope.launch { - channel.call( + channel.invoke( "show", Json.encodeToString( mapOf( @@ -49,7 +50,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro title: String? = null, description: String? = null ) { - channel.call( + channel.invoke( "state", Json.encodeToString( mapOf( @@ -63,7 +64,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro fun closeDialog() { coroutineScope.launch { - channel.call("close") + channel.invoke("close", NULL) } } @@ -74,7 +75,7 @@ class DialogManager(messenger: BinaryMessenger, private val coroutineScope: Coro it.invoke() } } - return FlutterChannel.NULL + return NULL } companion object { diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/FlutterChannel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/FlutterChannel.kt deleted file mode 100755 index 40375ebf..00000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/FlutterChannel.kt +++ /dev/null @@ -1,76 +0,0 @@ -package com.yubico.authenticator - -import io.flutter.plugin.common.BinaryMessenger -import io.flutter.plugin.common.MethodChannel -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.launch -import kotlinx.coroutines.withContext -import kotlin.coroutines.resume -import kotlin.coroutines.resumeWithException -import kotlin.coroutines.suspendCoroutine - -class FlutterChannel( - messenger: BinaryMessenger, - channel: String, -) { - companion object { - const val NULL = "null" - } - - private val platform: MethodChannel = MethodChannel(messenger, channel) - - fun setHandler( - scope: CoroutineScope, - handler: suspend (method: String, args: Map) -> String - ) { - platform.setMethodCallHandler { call, result -> - // N.B. Arguments from Flutter are passed as a Map of basic types. We may want to - // consider JSON encoding if we need to pass more complex structures. - // Return values are always JSON strings. - val args = call.arguments>() ?: mapOf() - scope.launch { - try { - val response = handler.invoke(call.method, args) - result.success(response) - } catch (notImplemented: NotImplementedError) { - result.notImplemented() - } catch (error: Throwable) { - result.error( - error.javaClass.simpleName, - error.toString(), - "Cause: " + error.cause + ", Stacktrace: " + android.util.Log.getStackTraceString( - error - ) - ) - } - } - } - } - - suspend fun call(method: String, args: String = NULL): Any? = - withContext(Dispatchers.Main) { - suspendCoroutine { continuation -> - platform.invokeMethod( - method, - args, - object : MethodChannel.Result { - override fun success(result: Any?) { - continuation.resume(result) - } - - override fun error( - errorCode: String, - errorMessage: String?, - errorDetails: Any? - ) { - continuation.resumeWithException(Exception("$errorCode: $errorMessage - $errorDetails")) - } - - override fun notImplemented() { - continuation.resumeWithException(NotImplementedError("Method not implemented: $method")) - } - }) - } - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt b/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt index 2afdcc42..2b74e695 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/JsonSerializer.kt @@ -1,7 +1,9 @@ -package com.yubico.authenticator.oath +package com.yubico.authenticator import kotlinx.serialization.json.Json +const val NULL = "null" + val jsonSerializer = Json { // creates properties for default values encodeDefaults = true diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/LiveDataHelper.kt b/android/app/src/main/kotlin/com/yubico/authenticator/LiveDataHelper.kt deleted file mode 100755 index 0e3a7fba..00000000 --- a/android/app/src/main/kotlin/com/yubico/authenticator/LiveDataHelper.kt +++ /dev/null @@ -1,26 +0,0 @@ -package com.yubico.authenticator - -import androidx.lifecycle.LifecycleOwner -import androidx.lifecycle.LiveData -import com.yubico.authenticator.oath.jsonSerializer -import io.flutter.plugin.common.EventChannel -import kotlinx.serialization.encodeToString - -inline fun LiveData.streamTo(lifecycleOwner: LifecycleOwner, channel: EventChannel) { - var sink: EventChannel.EventSink? = null - - channel.setStreamHandler(object : EventChannel.StreamHandler { - override fun onListen(arguments: Any?, events: EventChannel.EventSink) { - sink = events - events.success(value?.let(jsonSerializer::encodeToString) ?: "null") - } - - override fun onCancel(arguments: Any?) { - sink = null - } - }) - - observe(lifecycleOwner) { - sink?.success(it?.let(jsonSerializer::encodeToString) ?: "null") - } -} \ No newline at end of file diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt index b0cb33ae..4625ea85 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainActivity.kt @@ -20,10 +20,12 @@ import com.yubico.yubikit.android.transport.nfc.NfcNotAvailable import com.yubico.yubikit.android.transport.nfc.NfcYubiKeyDevice import com.yubico.yubikit.android.transport.usb.UsbConfiguration import com.yubico.yubikit.core.Logger +import com.yubico.yubikit.core.YubiKeyDevice import io.flutter.embedding.android.FlutterFragmentActivity import io.flutter.embedding.engine.FlutterEngine import io.flutter.plugin.common.EventChannel import kotlinx.coroutines.launch +import java.io.Closeable import java.util.concurrent.Executors import kotlin.properties.Delegates @@ -67,11 +69,7 @@ class MainActivity : FlutterFragmentActivity() { Log.d(TAG, "Starting usb discovery") yubikit.startUsbDiscovery(UsbConfiguration()) { device -> viewModel.setConnectedYubiKey(device) - contextManager?.let { - lifecycleScope.launch { - it.processYubiKey(device) - } - } + processYubiKey(device) } hasNfc = startNfcDiscovery() } else { @@ -85,13 +83,7 @@ class MainActivity : FlutterFragmentActivity() { fun startNfcDiscovery(): Boolean = try { Log.d(TAG, "Starting nfc discovery") - yubikit.startNfcDiscovery(nfcConfiguration, this) { device -> - contextManager?.let { - lifecycleScope.launch { - it.processYubiKey(device) - } - } - } + yubikit.startNfcDiscovery(nfcConfiguration, this, ::processYubiKey) true } catch (e: NfcNotAvailable) { false @@ -145,10 +137,14 @@ class MainActivity : FlutterFragmentActivity() { val executor = Executors.newSingleThreadExecutor() val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor) lifecycleScope.launch { - contextManager?.processYubiKey(device) - device.remove { - executor.shutdown() - startNfcDiscovery() + try { + contextManager?.processYubiKey(device) + device.remove { + executor.shutdown() + startNfcDiscovery() + } + } catch (e: Throwable) { + Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString()) } } } else { @@ -156,11 +152,24 @@ class MainActivity : FlutterFragmentActivity() { } } - private lateinit var appContext: AppContext + private fun processYubiKey(device: YubiKeyDevice) { + contextManager?.let { + lifecycleScope.launch { + try { + it.processYubiKey(device) + } catch (e: Throwable) { + Log.e(TAG, "Error processing YubiKey in AppContextManager", e.toString()) + } + } + } + } + private var contextManager: AppContextManager? = null + private lateinit var appContext: AppContext private lateinit var dialogManager: DialogManager private lateinit var appPreferences: AppPreferences private lateinit var flutterLog: FlutterLog + private lateinit var flutterStreams: List override fun configureFlutterEngine(flutterEngine: FlutterEngine) { super.configureFlutterEngine(flutterEngine) @@ -172,19 +181,27 @@ class MainActivity : FlutterFragmentActivity() { dialogManager = DialogManager(messenger, this.lifecycleScope) appPreferences = AppPreferences(this) - viewModel.deviceInfo.streamTo(this, EventChannel(messenger, "android.devices.deviceInfo")) - - oathViewModel.sessionState.streamTo(this, EventChannel(messenger, "android.oath.sessionState")) - oathViewModel.credentials.streamTo(this, EventChannel(messenger, "android.oath.credentials")) + flutterStreams = listOf( + viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"), + oathViewModel.sessionState.streamTo(this, messenger, "android.oath.sessionState"), + oathViewModel.credentials.streamTo(this, messenger, "android.oath.credentials"), + ) viewModel.appContext.observe(this) { + contextManager?.dispose() contextManager = when(it) { - OperationContext.Oath -> OathManager(messenger, viewModel, oathViewModel, dialogManager, appPreferences) + OperationContext.Oath -> OathManager(this, messenger, viewModel, oathViewModel, dialogManager, appPreferences) else -> null } + viewModel.connectedYubiKey.value?.let(::processYubiKey) } } + override fun cleanUpFlutterEngine(flutterEngine: FlutterEngine) { + flutterStreams.forEach { it.close() } + super.cleanUpFlutterEngine(flutterEngine) + } + companion object { const val TAG = "MainActivity" } diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt index 98e31251..71af7b68 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/MainViewModel.kt @@ -20,7 +20,7 @@ class MainViewModel : ViewModel() { private var _appContext = MutableLiveData(OperationContext.Oath) val appContext: LiveData = _appContext - fun setContext(appContext: OperationContext) = _appContext.postValue(appContext) + fun setAppContext(appContext: OperationContext) = _appContext.postValue(appContext) private val _connectedYubiKey = MutableLiveData() val connectedYubiKey: LiveData = _connectedYubiKey diff --git a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt index 32334a9a..adcf5be5 100644 --- a/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt +++ b/android/app/src/main/kotlin/com/yubico/authenticator/oath/OathManager.kt @@ -1,5 +1,7 @@ package com.yubico.authenticator.oath +import androidx.lifecycle.LifecycleOwner +import androidx.lifecycle.Observer import com.yubico.authenticator.* import com.yubico.authenticator.logging.Log import com.yubico.authenticator.management.model @@ -16,6 +18,7 @@ import com.yubico.yubikit.core.util.Result import com.yubico.yubikit.oath.* import com.yubico.yubikit.support.DeviceUtil import io.flutter.plugin.common.BinaryMessenger +import io.flutter.plugin.common.MethodChannel import kotlinx.coroutines.* import kotlinx.serialization.encodeToString import java.net.URI @@ -25,6 +28,7 @@ import kotlin.coroutines.suspendCoroutine typealias OathAction = (Result) -> Unit class OathManager( + lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, private val appViewModel: MainViewModel, private val oathViewModel: OathViewModel, @@ -38,14 +42,45 @@ class OathManager( private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher() private val coroutineScope = CoroutineScope(SupervisorJob() + _dispatcher) - private val oathChannel = FlutterChannel(messenger, "android.oath.methods") + private val oathChannel = MethodChannel(messenger, "android.oath.methods") private val _memoryKeyProvider = ClearingMemProvider() private val _keyManager = KeyManager(KeyStoreProvider(), _memoryKeyProvider) private var pendingAction: OathAction? = null + private var refreshJob: Job? = null + + private val usbObserver = Observer { + refreshJob?.cancel() + if (it == null) { + appViewModel.setDeviceInfo(null) + oathViewModel.setSessionState(null) + } + } + + private val credentialObserver = Observer?> { codes -> + refreshJob?.cancel() + if(codes != null && appViewModel.connectedYubiKey.value != null) { + val expirations = codes + .filter { it.credential.oathType == Model.OathType.TOTP && !it.credential.touchRequired } + .mapNotNull { it.code?.validTo } + if(expirations.isNotEmpty()) { + val earliest = expirations.min() * 1000 + val now = System.currentTimeMillis() + refreshJob = coroutineScope.launch { + if(earliest > now) { + delay(earliest - now) + } + requestRefresh() + } + } + } + } init { + appViewModel.connectedYubiKey.observe(lifecycleOwner, usbObserver) + oathViewModel.credentials.observe(lifecycleOwner, credentialObserver) + // OATH methods callable from Flutter: oathChannel.setHandler(coroutineScope) { method, args -> when (method) { @@ -71,12 +106,18 @@ class OathManager( args["issuer"] as String? ) "deleteAccount" -> deleteAccount(args["credentialId"] as String) - "requestRefresh" -> requestRefresh() else -> throw NotImplementedError() } } } + override fun dispose() { + appViewModel.connectedYubiKey.removeObserver(usbObserver) + oathViewModel.credentials.removeObserver(credentialObserver) + oathChannel.setMethodCallHandler(null) + coroutineScope.cancel() + } + override suspend fun processYubiKey(device: YubiKeyDevice) { try { device.withConnection { @@ -147,8 +188,6 @@ class OathManager( } } - //private var _isUsbKey = false - private suspend fun reset(): String { useOathSession("Reset YubiKey") { // note, it is ok to reset locked session @@ -156,7 +195,7 @@ class OathManager( _keyManager.removeKey(it.deviceId) oathViewModel.setSessionState(it.model(false)) } - return FlutterChannel.NULL + return NULL } private suspend fun unlock(password: String, remember: Boolean): String = @@ -193,7 +232,7 @@ class OathManager( _keyManager.addKey(session.deviceId, accessKey, false) oathViewModel.setSessionState(session.model(false)) Log.d(TAG, "Successfully set password") - FlutterChannel.NULL + NULL } private suspend fun unsetPassword(currentPassword: String): String = @@ -205,7 +244,7 @@ class OathManager( _keyManager.removeKey(session.deviceId) oathViewModel.setSessionState(session.model(false)) Log.d(TAG, "Successfully unset password") - return@useOathSession FlutterChannel.NULL + return@useOathSession NULL } } throw Exception("Unset password failed") @@ -217,7 +256,7 @@ class OathManager( oathViewModel.sessionState.value?.let { oathViewModel.setSessionState(it.copy(isLocked = it.isAccessKeySet, isRemembered = false)) } - return FlutterChannel.NULL + return NULL } private suspend fun addAccount( @@ -261,19 +300,17 @@ class OathManager( val credential = getOathCredential(session, credentialId) session.deleteCredential(credential) oathViewModel.removeCredential(credential.model(session.deviceId)) - FlutterChannel.NULL + NULL } - private suspend fun requestRefresh(): String { + private suspend fun requestRefresh() { appViewModel.connectedYubiKey.value?.let { useOathSessionUsb(it) { oathViewModel.updateCredentials( calculateOathCodes(it).model(it.deviceId) ) } - return FlutterChannel.NULL - } - throw throw IllegalStateException("Cannot refresh for nfc key") + } ?: throw throw IllegalStateException("Cannot refresh for nfc key") } private suspend fun calculate(credentialId: String): String = diff --git a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt index a5976c93..210aa100 100644 --- a/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt +++ b/android/app/src/test/java/com/yubico/authenticator/oath/SerializationTest.kt @@ -1,6 +1,7 @@ package com.yubico.authenticator.oath import com.yubico.authenticator.device.Version +import com.yubico.authenticator.jsonSerializer import com.yubico.authenticator.oath.OathTestHelper.code import com.yubico.authenticator.oath.OathTestHelper.hotp import com.yubico.authenticator.oath.OathTestHelper.totp diff --git a/lib/android/oath/state.dart b/lib/android/oath/state.dart index 6ab5ef5c..2f81590d 100755 --- a/lib/android/oath/state.dart +++ b/lib/android/oath/state.dart @@ -1,6 +1,5 @@ import 'dart:async'; import 'dart:convert'; -import 'dart:math'; import 'package:flutter/material.dart'; import 'package:flutter/services.dart'; @@ -116,9 +115,6 @@ final androidCredentialListProvider = StateNotifierProvider.autoDispose ref.watch(withContextProvider), ref.watch(currentDeviceProvider)?.transport == Transport.usb, ); - ref.listen(windowStateProvider, (_, windowState) { - notifier._notifyWindowState(windowState); - }, fireImmediately: true); return notifier; }, ); @@ -128,7 +124,6 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier { final WithContext _withContext; final bool _isUsbAttached; late StreamSubscription _sub; - Timer? _timer; _AndroidCredentialListNotifier(this._withContext, this._isUsbAttached) : super() { @@ -138,22 +133,11 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier { ? List.unmodifiable( (json as List).map((e) => OathPair.fromJson(e)).toList()) : null; - _scheduleRefresh(); }); } - void _notifyWindowState(WindowState windowState) { - if (!_isUsbAttached) return; - if (windowState.active) { - _scheduleRefresh(); - } else { - _timer?.cancel(); - } - } - @override void dispose() { - _timer?.cancel(); _sub.cancel(); super.dispose(); } @@ -251,47 +235,4 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier { rethrow; } } - - _refresh() async { - if (!_isUsbAttached) return; - _log.debug('refreshing credentials...'); - - try { - await _methods.invokeMethod('requestRefresh'); - } catch (e) { - _log.debug('Failure refreshing codes: $e'); - } - } - - _scheduleRefresh() { - if (!_isUsbAttached) return; - - _timer?.cancel(); - if (state == null) { - _log.debug('No OATH state, refresh immediately'); - _refresh(); - } else if (mounted) { - final expirations = (state ?? []) - .where((pair) => - pair.credential.oathType == OathType.totp && - !pair.credential.touchRequired) - .map((e) => e.code) - .whereType() - .map((e) => e.validTo); - if (expirations.isEmpty) { - _log.debug('No expirations, no refresh'); - _timer = null; - } else { - final earliest = expirations.reduce(min) * 1000; - final now = DateTime.now().millisecondsSinceEpoch; - if (earliest < now) { - _log.debug('Already expired, refresh immediately'); - _refresh(); - } else { - _log.debug('Schedule refresh in ${earliest - now}ms'); - _timer = Timer(Duration(milliseconds: earliest - now), _refresh); - } - } - } - } }