use ViewModelData interface

This commit is contained in:
Adam Velebil 2024-03-13 10:36:50 +01:00
parent ff8f2c8ce0
commit 9a19a9c608
No known key found for this signature in database
GPG Key ID: C9B1E4A3CBBD2E10
18 changed files with 112 additions and 153 deletions

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -36,18 +36,12 @@ interface JsonSerializable {
fun toJson() : String
}
sealed interface SessionState {
data object Empty : SessionState
data object Loading : SessionState
data class Value<T : JsonSerializable>(val data: T) : SessionState
sealed interface ViewModelData {
data object Empty : ViewModelData
data object Loading : ViewModelData
data class Value<T : JsonSerializable>(val data: T) : ViewModelData
}
data class ChannelData<T>(val data: T?, val isLoading: Boolean = false) {
companion object {
fun <T> loading() = ChannelData<T>(null, true)
fun <T> empty() = ChannelData<T>(null)
}
}
/**
* Observes a LiveData value, sending each change to Flutter via an EventChannel.
*/
@ -78,55 +72,20 @@ inline fun <reified T> LiveData<T>.streamTo(lifecycleOwner: LifecycleOwner, mess
}
/**
* Observes a Loadable LiveData value, sending each change to Flutter via an EventChannel.
* Observes a ViewModelData LiveData value, sending each change to Flutter via an EventChannel.
*/
inline fun <reified T> LiveData<ChannelData<T>>.streamData(lifecycleOwner: LifecycleOwner, messenger: BinaryMessenger, channelName: String): Closeable {
@JvmName("streamViewModelData")
inline fun <reified T : ViewModelData> LiveData<T>.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 {
if (it.isLoading) LOADING
else it.data?.let(jsonSerializer::encodeToString) ?: NULL
} ?: NULL
)
val get: (ViewModelData) -> String = {
when (it) {
is ViewModelData.Empty -> NULL
is ViewModelData.Loading -> LOADING
is ViewModelData.Value<*> -> it.data.toJson()
}
override fun onCancel(arguments: Any?) {
sink = null
}
})
val observer = Observer<ChannelData<T>> {
sink?.success(
if (it.isLoading) LOADING
else it.data?.let(jsonSerializer::encodeToString) ?: NULL
)
}
observe(lifecycleOwner, observer)
return Closeable {
removeObserver(observer)
channel.setStreamHandler(null)
}
}
fun get(state: SessionState) : String = when (state) {
is SessionState.Empty -> NULL
is SessionState.Loading -> LOADING
is SessionState.Value<*> -> state.data.toJson()
}
/**
* Observes a Loadable LiveData value, sending each change to Flutter via an EventChannel.
*/
inline fun <reified T : SessionState> LiveData<T>.streamState(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) {

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022 Yubico.
* Copyright (C) 2022,2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -20,7 +20,7 @@ import kotlinx.serialization.json.Json
const val NULL = "null"
const val LOADING = """{ "loading": true }"""
const val LOADING = "\"loading\""
val jsonSerializer = Json {
// creates properties for default values

View File

@ -320,10 +320,10 @@ class MainActivity : FlutterFragmentActivity() {
flutterStreams = listOf(
viewModel.deviceInfo.streamTo(this, messenger, "android.devices.deviceInfo"),
oathViewModel.sessionState.streamState(this, messenger, "android.oath.sessionState"),
oathViewModel.sessionState.streamTo(this, messenger, "android.oath.sessionState"),
oathViewModel.credentials.streamTo(this, messenger, "android.oath.credentials"),
fidoViewModel.sessionState.streamData(this, messenger, "android.fido.sessionState"),
fidoViewModel.credentials.streamData(this, messenger, "android.fido.credentials"),
fidoViewModel.sessionState.streamTo(this, messenger, "android.fido.sessionState"),
fidoViewModel.credentials.streamTo(this, messenger, "android.fido.credentials"),
fidoViewModel.resetState.streamTo(this, messenger, "android.fido.reset"),
)

View File

@ -136,14 +136,6 @@ class FidoManager(
else -> throw NotImplementedError()
}
}
if (!deviceManager.isUsbKeyConnected()) {
// for NFC connections require extra tap when switching context
if (fidoViewModel.sessionState.value == null) {
fidoViewModel.clearSessionState()
}
}
}
override fun dispose() {
@ -151,7 +143,7 @@ class FidoManager(
deviceManager.removeDeviceListener(this)
fidoChannel.setMethodCallHandler(null)
fidoViewModel.clearSessionState()
fidoViewModel.clearCredentials()
fidoViewModel.updateCredentials(emptyList())
coroutineScope.cancel()
}
@ -198,7 +190,7 @@ class FidoManager(
YubiKitFidoSession(connection as SmartCardConnection)
}
val previousSession = fidoViewModel.sessionState.value?.data?.info
val previousSession = fidoViewModel.currentSession()?.info
val currentSession = SessionInfo(fidoSession.cachedInfo)
logger.debug(
"Previous session: {}, current session: {}",
@ -286,7 +278,7 @@ class FidoManager(
ctapException.ctapError == CtapException.ERR_PIN_AUTH_BLOCKED
) {
pinStore.setPin(null)
fidoViewModel.clearCredentials()
fidoViewModel.updateCredentials(emptyList())
val pinRetriesResult = clientPin.pinRetries
JSONObject(
mapOf(
@ -356,9 +348,6 @@ class FidoManager(
pinUvAuthToken: ByteArray
): List<FidoCredential> =
try {
fidoViewModel.setCredentialsLoadingState()
val credMan = CredentialManagement(fidoSession, clientPin.pinUvAuth, pinUvAuthToken)
val rpIds = credMan.enumerateRps()
@ -394,7 +383,7 @@ class FidoManager(
val credMan = CredentialManagement(fidoSession, clientPin.pinUvAuth, token)
val credentialDescriptor =
fidoViewModel.credentials.value?.data?.firstOrNull {
fidoViewModel.credentials.value?.firstOrNull {
it.credentialId == credentialId && it.rpId == rpId
}?.publicKeyCredentialDescriptor

View File

@ -202,7 +202,7 @@ class FidoResetHelper(
logger.debug("Calling FIDO reset")
fidoSession.reset(resetCommandState)
fidoViewModel.setSessionState(Session(fidoSession.info, true))
fidoViewModel.clearCredentials()
fidoViewModel.updateCredentials(emptyList())
pinStore.setPin(null)
}

View File

@ -19,45 +19,39 @@ package com.yubico.authenticator.fido
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.yubico.authenticator.ChannelData
import com.yubico.authenticator.ViewModelData
import com.yubico.authenticator.fido.data.FidoCredential
import com.yubico.authenticator.fido.data.Session
class FidoViewModel : ViewModel() {
private val _sessionState = MutableLiveData<ChannelData<Session?>>()
val sessionState: LiveData<ChannelData<Session?>> = _sessionState
private val _sessionState = MutableLiveData<ViewModelData>()
val sessionState: LiveData<ViewModelData> = _sessionState
fun setSessionState(sessionState: Session?) {
_sessionState.postValue(ChannelData(sessionState))
fun currentSession() : Session? = (_sessionState.value as? ViewModelData.Value<*>)?.data as? Session?
fun setSessionState(sessionState: Session) {
_sessionState.postValue(ViewModelData.Value(sessionState))
}
fun clearSessionState() {
_sessionState.postValue(ChannelData.empty())
_sessionState.postValue(ViewModelData.Empty)
}
fun setSessionLoadingState() {
_sessionState.postValue(ChannelData.loading())
_sessionState.postValue(ViewModelData.Loading)
}
private val _credentials = MutableLiveData<ChannelData<List<FidoCredential>?>>()
val credentials: LiveData<ChannelData<List<FidoCredential>?>> = _credentials
fun setCredentialsLoadingState() {
_credentials.postValue(ChannelData.loading())
}
private val _credentials = MutableLiveData<List<FidoCredential>>()
val credentials: LiveData<List<FidoCredential>> = _credentials
fun updateCredentials(credentials: List<FidoCredential>) {
_credentials.postValue(ChannelData(credentials))
}
fun clearCredentials() {
_credentials.postValue(ChannelData.empty())
_credentials.postValue(credentials)
}
fun removeCredential(rpId: String, credentialId: String) {
_credentials.postValue(ChannelData(_credentials.value?.data?.filter {
_credentials.postValue(_credentials.value?.filter {
it.credentialId != credentialId || it.rpId != rpId
}))
})
}
private val _resetState = MutableLiveData(FidoResetState.Remove.value)

View File

@ -16,6 +16,8 @@
package com.yubico.authenticator.fido.data
import com.yubico.authenticator.JsonSerializable
import com.yubico.authenticator.jsonSerializer
import com.yubico.yubikit.fido.ctap.Ctap2Session.InfoData
import kotlinx.serialization.*
@ -87,8 +89,12 @@ data class Session(
@SerialName("info")
val info: SessionInfo,
val unlocked: Boolean
) {
) : JsonSerializable {
constructor(infoData: InfoData, unlocked: Boolean) : this(
SessionInfo(infoData), unlocked
)
override fun toJson(): String {
return jsonSerializer.encodeToString(this)
}
}

View File

@ -200,13 +200,6 @@ class OathManager(
else -> throw NotImplementedError()
}
}
if (!deviceManager.isUsbKeyConnected()) {
// for NFC connections require extra tap when switching context
if (oathViewModel.sessionState.value is SessionState.Empty) {
oathViewModel.setSessionState(null)
}
}
}
override fun dispose() {
@ -214,7 +207,7 @@ class OathManager(
deviceManager.removeDeviceListener(this)
oathViewModel.credentials.removeObserver(credentialObserver)
oathChannel.setMethodCallHandler(null)
oathViewModel.setSessionState(null)
oathViewModel.clearSession()
oathViewModel.updateCredentials(mapOf())
coroutineScope.cancel()
}
@ -325,7 +318,7 @@ class OathManager(
}
// Clear any cached OATH state
oathViewModel.setSessionState(null)
oathViewModel.clearSession()
}
}
@ -752,10 +745,10 @@ class OathManager(
override fun onDisconnected() {
refreshJob?.cancel()
oathViewModel.setSessionState(null)
oathViewModel.clearSession()
}
override fun onTimeout() {
oathViewModel.setSessionState(null)
oathViewModel.clearSession()
}
}

View File

@ -1,5 +1,5 @@
/*
* Copyright (C) 2022-2023 Yubico.
* Copyright (C) 2022-2024 Yubico.
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
@ -19,8 +19,7 @@ package com.yubico.authenticator.oath
import androidx.lifecycle.LiveData
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import com.yubico.authenticator.SessionState
import com.yubico.authenticator.JsonSerializable
import com.yubico.authenticator.ViewModelData
import com.yubico.authenticator.oath.data.Code
import com.yubico.authenticator.oath.data.Credential
import com.yubico.authenticator.oath.data.CredentialWithCode
@ -28,32 +27,31 @@ import com.yubico.authenticator.oath.data.Session
class OathViewModel: ViewModel() {
private val _sessionState = MutableLiveData<SessionState>()
val sessionState: LiveData<SessionState> = _sessionState
private val _sessionState = MutableLiveData<ViewModelData>()
val sessionState: LiveData<ViewModelData> = _sessionState
fun currentSession() : Session? = (sessionState.value as? SessionState.Value<*>)?.data as Session?
fun currentSession() : Session? = (_sessionState.value as? ViewModelData.Value<*>)?.data as? Session?
// Sets session and credentials after performing OATH reset
// Note: we cannot use [setSessionState] because resetting OATH changes deviceId
fun resetOathSession(sessionState: Session, credentials: Map<Credential, Code?>) {
_sessionState.postValue(SessionState.Value(sessionState))
_sessionState.postValue(ViewModelData.Value(sessionState))
updateCredentials(credentials)
}
fun setSessionState(sessionState: Session?) {
fun setSessionState(sessionState: Session) {
val oldDeviceId = currentSession()?.deviceId
if (sessionState == null) {
_sessionState.postValue(SessionState.Empty)
} else {
_sessionState.postValue(SessionState.Value(sessionState))
}
if(oldDeviceId != sessionState?.deviceId) {
_sessionState.postValue(ViewModelData.Value(sessionState))
if(oldDeviceId != sessionState.deviceId) {
_credentials.postValue(null)
}
}
fun clearSession() {
_sessionState.postValue(ViewModelData.Empty)
_credentials.postValue(null)
}
private val _credentials = MutableLiveData<List<CredentialWithCode>?>()
val credentials: LiveData<List<CredentialWithCode>?> = _credentials

View File

@ -24,6 +24,7 @@ import 'package:logging/logging.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../exception/no_data_exception.dart';
import '../../exception/platform_exception_decoder.dart';
import '../../fido/models.dart';
import '../../fido/state.dart';
@ -33,19 +34,19 @@ final _log = Logger('android.fido.state');
const _methods = MethodChannel('android.fido.methods');
final androidFidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState?, DevicePath>(_FidoStateNotifier.new);
.family<FidoStateNotifier, FidoState, DevicePath>(_FidoStateNotifier.new);
class _FidoStateNotifier extends FidoStateNotifier {
final _events = const EventChannel('android.fido.sessionState');
late StreamSubscription _sub;
@override
FutureOr<FidoState?> build(DevicePath devicePath) async {
FutureOr<FidoState> build(DevicePath devicePath) async {
_sub = _events.receiveBroadcastStream().listen((event) {
final json = jsonDecode(event);
if (json == null) {
state = const AsyncValue.data(null);
} else if (json.containsKey('loading') && json['loading'] == true) {
state = AsyncValue.error(const NoDataException(), StackTrace.current);
} else if (json == 'loading') {
state = const AsyncValue.loading();
} else {
final fidoState = FidoState.fromJson(json);
@ -57,7 +58,7 @@ class _FidoStateNotifier extends FidoStateNotifier {
ref.onDispose(_sub.cancel);
return Completer<FidoState?>().future;
return Completer<FidoState>().future;
}
@override
@ -191,8 +192,6 @@ class _FidoCredentialsNotifier extends FidoCredentialsNotifier {
_sub = _events.receiveBroadcastStream().listen((event) {
final json = jsonDecode(event);
if (json == null) {
state = const AsyncValue.data(null);
} else if (json[0] is Map && json[0]['loading'] == true) {
state = const AsyncValue.loading();
} else {
List<FidoCredential> newState = List.from(

View File

@ -24,13 +24,13 @@ import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:logging/logging.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../app/error_data_empty.dart';
import '../../app/logging.dart';
import '../../app/models.dart';
import '../../app/state.dart';
import '../../app/views/user_interaction.dart';
import '../../core/models.dart';
import '../../exception/cancellation_exception.dart';
import '../../exception/no_data_exception.dart';
import '../../exception/platform_exception_decoder.dart';
import '../../oath/models.dart';
import '../../oath/state.dart';
@ -52,8 +52,8 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
_sub = _events.receiveBroadcastStream().listen((event) {
final json = jsonDecode(event);
if (json == null) {
state = AsyncValue.error(const ErrorDataEmpty(), StackTrace.current);
} else if (json[0] is Map && json[0]['loading'] == true) {
state = AsyncValue.error(const NoDataException(), StackTrace.current);
} else if (json == 'loading') {
state = const AsyncValue.loading();
} else {
final oathState = OathState.fromJson(json);

View File

@ -1,3 +0,0 @@
class ErrorDataEmpty {
const ErrorDataEmpty();
}

View File

@ -47,14 +47,14 @@ final _sessionProvider =
);
final desktopFidoState = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState?, DevicePath>(
.family<FidoStateNotifier, FidoState, DevicePath>(
_DesktopFidoStateNotifier.new);
class _DesktopFidoStateNotifier extends FidoStateNotifier {
late RpcNodeSession _session;
late StateController<String?> _pinController;
FutureOr<FidoState?> _build(DevicePath devicePath) async {
FutureOr<FidoState> _build(DevicePath devicePath) async {
var result = await _session.command('get');
FidoState fidoState = FidoState.fromJson(result['data']);
if (fidoState.hasPin && !fidoState.unlocked) {
@ -71,7 +71,7 @@ class _DesktopFidoStateNotifier extends FidoStateNotifier {
}
@override
FutureOr<FidoState?> build(DevicePath devicePath) async {
FutureOr<FidoState> build(DevicePath devicePath) async {
_session = ref.watch(_sessionProvider(devicePath));
if (Platform.isWindows) {
// Make sure to rebuild if isAdmin changes

View File

@ -0,0 +1,19 @@
/*
* Copyright (C) 2024 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.
*/
class NoDataException implements Exception {
const NoDataException();
}

View File

@ -21,11 +21,11 @@ import '../core/state.dart';
import 'models.dart';
final fidoStateProvider = AsyncNotifierProvider.autoDispose
.family<FidoStateNotifier, FidoState?, DevicePath>(
.family<FidoStateNotifier, FidoState, DevicePath>(
() => throw UnimplementedError(),
);
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState?> {
abstract class FidoStateNotifier extends ApplicationStateNotifier<FidoState> {
Stream<InteractionEvent> reset();
Future<PinResult> setPin(String newPin, {String? oldPin});
Future<PinResult> unlock(String pin);

View File

@ -31,6 +31,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart';
import '../../exception/no_data_exception.dart';
import '../../management/models.dart';
import '../../widgets/list_title.dart';
import '../features.dart' as features;
@ -45,6 +46,7 @@ import 'pin_entry_form.dart';
class FingerprintsScreen extends ConsumerWidget {
final YubiKeyData deviceData;
const FingerprintsScreen(this.deviceData, {super.key});
@override
@ -57,6 +59,9 @@ class FingerprintsScreen extends ConsumerWidget {
builder: (context, _) => const CircularProgressIndicator(),
),
error: (error, _) {
if (error is NoDataException) {
return MessagePageNotInitialized(title: l10n.s_fingerprints);
}
final enabled = deviceData
.info.config.enabledCapabilities[deviceData.node.transport] ??
0;
@ -74,11 +79,9 @@ class FingerprintsScreen extends ConsumerWidget {
);
},
data: (fidoState) {
return fidoState == null
? MessagePageNotInitialized(title: l10n.s_fingerprints)
: fidoState.unlocked
? _FidoUnlockedPage(deviceData.node, fidoState)
: _FidoLockedPage(deviceData.node, fidoState);
return fidoState.unlocked
? _FidoUnlockedPage(deviceData.node, fidoState)
: _FidoLockedPage(deviceData.node, fidoState);
});
}
}

View File

@ -32,6 +32,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart';
import '../../exception/no_data_exception.dart';
import '../../management/models.dart';
import '../../widgets/list_title.dart';
import '../features.dart' as features;
@ -58,6 +59,9 @@ class PasskeysScreen extends ConsumerWidget {
builder: (context, _) => const CircularProgressIndicator(),
),
error: (error, _) {
if (error is NoDataException) {
return MessagePageNotInitialized(title: l10n.s_passkeys);
}
final enabled = deviceData
.info.config.enabledCapabilities[deviceData.node.transport] ??
0;
@ -76,11 +80,9 @@ class PasskeysScreen extends ConsumerWidget {
);
},
data: (fidoState) {
return fidoState == null
? MessagePageNotInitialized(title: l10n.s_passkeys)
: fidoState.unlocked
? _FidoUnlockedPage(deviceData.node, fidoState)
: _FidoLockedPage(deviceData.node, fidoState);
return fidoState.unlocked
? _FidoUnlockedPage(deviceData.node, fidoState)
: _FidoLockedPage(deviceData.node, fidoState);
});
}
}

View File

@ -23,7 +23,6 @@ import 'package:flutter_gen/gen_l10n/app_localizations.dart';
import 'package:flutter_riverpod/flutter_riverpod.dart';
import 'package:material_symbols_icons/symbols.dart';
import '../../app/error_data_empty.dart';
import '../../app/message.dart';
import '../../app/models.dart';
import '../../app/shortcuts.dart';
@ -34,6 +33,7 @@ import '../../app/views/app_page.dart';
import '../../app/views/message_page.dart';
import '../../app/views/message_page_not_initialized.dart';
import '../../core/state.dart';
import '../../exception/no_data_exception.dart';
import '../../management/models.dart';
import '../../widgets/app_input_decoration.dart';
import '../../widgets/app_text_form_field.dart';
@ -65,7 +65,7 @@ class OathScreen extends ConsumerWidget {
graphic: CircularProgressIndicator(),
delayedContent: true,
),
error: (error, _) => error is ErrorDataEmpty
error: (error, _) => error is NoDataException
? MessagePageNotInitialized(title: l10n.s_accounts)
: AppFailurePage(
cause: error,