mirror of
https://github.com/Yubico/yubioath-flutter.git
synced 2024-12-23 10:11:52 +03:00
Android: Drop LiveData for YubiKey devices.
This commit is contained in:
parent
e0696da422
commit
1f0103b9b9
@ -6,18 +6,8 @@ import com.yubico.authenticator.logging.Log
|
||||
import io.flutter.plugin.common.BinaryMessenger
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
|
||||
enum class OperationContext(val value: Int) {
|
||||
Oath(0), Yubikey(1), Invalid(-1);
|
||||
|
||||
companion object {
|
||||
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid
|
||||
}
|
||||
}
|
||||
|
||||
class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope) {
|
||||
class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope, private val appViewModel: MainViewModel) {
|
||||
private val channel = FlutterChannel(messenger, "android.state.appContext")
|
||||
private var _appContext = MutableLiveData(OperationContext.Oath)
|
||||
val appContext: LiveData<OperationContext> = _appContext
|
||||
|
||||
init {
|
||||
channel.setHandler(coroutineScope) { method, args ->
|
||||
@ -30,8 +20,9 @@ class AppContext(messenger: BinaryMessenger, coroutineScope: CoroutineScope) {
|
||||
|
||||
|
||||
private suspend fun setContext(subPageIndex: Int): String {
|
||||
_appContext.value = OperationContext.getByValue(subPageIndex)
|
||||
Log.d(TAG, "App context is now ${_appContext.value}")
|
||||
val appContext = OperationContext.getByValue(subPageIndex)
|
||||
appViewModel.setContext(appContext)
|
||||
Log.d(TAG, "App context is now ${appContext}")
|
||||
return FlutterChannel.NULL
|
||||
}
|
||||
|
||||
|
@ -0,0 +1,7 @@
|
||||
package com.yubico.authenticator
|
||||
|
||||
import com.yubico.yubikit.core.YubiKeyDevice
|
||||
|
||||
interface AppContextManager {
|
||||
suspend fun processYubiKey(device: YubiKeyDevice)
|
||||
}
|
@ -9,7 +9,6 @@ import android.nfc.Tag
|
||||
import android.os.Bundle
|
||||
import android.view.WindowManager
|
||||
import androidx.activity.viewModels
|
||||
import androidx.lifecycle.Observer
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
import com.yubico.authenticator.logging.FlutterLog
|
||||
import com.yubico.authenticator.logging.Log
|
||||
@ -21,12 +20,9 @@ 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 com.yubico.yubikit.core.smartcard.SmartCardConnection
|
||||
import io.flutter.embedding.android.FlutterFragmentActivity
|
||||
import io.flutter.embedding.engine.FlutterEngine
|
||||
import io.flutter.plugin.common.EventChannel
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
import java.util.concurrent.Executors
|
||||
import kotlin.properties.Delegates
|
||||
@ -70,8 +66,12 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
if (it) {
|
||||
Log.d(TAG, "Starting usb discovery")
|
||||
yubikit.startUsbDiscovery(UsbConfiguration()) { device ->
|
||||
viewModel.yubiKeyDevice.postValue(device)
|
||||
device.setOnClosed { viewModel.yubiKeyDevice.postValue(null) }
|
||||
viewModel.setConnectedYubiKey(device)
|
||||
contextManager?.let {
|
||||
lifecycleScope.launch {
|
||||
it.processYubiKey(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
hasNfc = startNfcDiscovery()
|
||||
} else {
|
||||
@ -86,10 +86,9 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
try {
|
||||
Log.d(TAG, "Starting nfc discovery")
|
||||
yubikit.startNfcDiscovery(nfcConfiguration, this) { device ->
|
||||
viewModel.yubiKeyDevice.apply {
|
||||
lifecycleScope.launch(Dispatchers.Main) {
|
||||
value = device
|
||||
postValue(null)
|
||||
contextManager?.let {
|
||||
lifecycleScope.launch {
|
||||
it.processYubiKey(device)
|
||||
}
|
||||
}
|
||||
}
|
||||
@ -138,36 +137,27 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
|
||||
// Handle existing tag when launched from NDEF
|
||||
val tag = intent.getParcelableExtra<Tag>(NfcAdapter.EXTRA_TAG)
|
||||
if(tag != null) {
|
||||
intent.removeExtra(NfcAdapter.EXTRA_TAG)
|
||||
|
||||
val executor = Executors.newSingleThreadExecutor()
|
||||
val device = NfcYubiKeyDevice(tag, nfcConfiguration.timeout, executor)
|
||||
viewModel.yubiKeyDevice.value = device
|
||||
viewModel.yubiKeyDevice.observe(this, object: Observer<YubiKeyDevice?> {
|
||||
override fun onChanged(it: YubiKeyDevice?) {
|
||||
if(it == null) {
|
||||
viewModel.yubiKeyDevice.removeObserver(this)
|
||||
device.requestConnection(SmartCardConnection::class.java) {
|
||||
Log.d(TAG, "Await NFC removal...")
|
||||
device.remove {
|
||||
executor.shutdown()
|
||||
startNfcDiscovery()
|
||||
}
|
||||
}
|
||||
}
|
||||
lifecycleScope.launch {
|
||||
contextManager?.processYubiKey(device)
|
||||
device.remove {
|
||||
executor.shutdown()
|
||||
startNfcDiscovery()
|
||||
}
|
||||
|
||||
})
|
||||
viewModel.yubiKeyDevice.postValue(null)
|
||||
}
|
||||
} else {
|
||||
startNfcDiscovery()
|
||||
}
|
||||
}
|
||||
|
||||
private lateinit var appContext: AppContext
|
||||
private lateinit var oathManager: OathManager
|
||||
private var contextManager: AppContextManager? = null
|
||||
private lateinit var dialogManager: DialogManager
|
||||
private lateinit var appPreferences: AppPreferences
|
||||
private lateinit var flutterLog: FlutterLog
|
||||
@ -178,7 +168,7 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
val messenger = flutterEngine.dartExecutor.binaryMessenger
|
||||
|
||||
flutterLog = FlutterLog(messenger)
|
||||
appContext = AppContext(messenger, this.lifecycleScope)
|
||||
appContext = AppContext(messenger, this.lifecycleScope, viewModel)
|
||||
dialogManager = DialogManager(messenger, this.lifecycleScope)
|
||||
appPreferences = AppPreferences(this)
|
||||
|
||||
@ -187,7 +177,12 @@ class MainActivity : FlutterFragmentActivity() {
|
||||
oathViewModel.sessionState.streamTo(this, EventChannel(messenger, "android.oath.sessionState"))
|
||||
oathViewModel.credentials.streamTo(this, EventChannel(messenger, "android.oath.credentials"))
|
||||
|
||||
oathManager = OathManager(this, messenger, appContext, viewModel, oathViewModel, dialogManager, appPreferences)
|
||||
viewModel.appContext.observe(this) {
|
||||
contextManager = when(it) {
|
||||
OperationContext.Oath -> OathManager(messenger, viewModel, oathViewModel, dialogManager, appPreferences)
|
||||
else -> null
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
|
@ -4,13 +4,30 @@ import androidx.lifecycle.LiveData
|
||||
import androidx.lifecycle.MutableLiveData
|
||||
import androidx.lifecycle.ViewModel
|
||||
import com.yubico.authenticator.device.Info
|
||||
import com.yubico.yubikit.core.YubiKeyDevice
|
||||
import com.yubico.yubikit.android.transport.usb.UsbYubiKeyDevice
|
||||
|
||||
enum class OperationContext(val value: Int) {
|
||||
Oath(0), Yubikey(1), Invalid(-1);
|
||||
|
||||
companion object {
|
||||
fun getByValue(value: Int) = values().firstOrNull { it.value == value } ?: Invalid
|
||||
}
|
||||
}
|
||||
|
||||
class MainViewModel : ViewModel() {
|
||||
private val _handleYubiKey = MutableLiveData(true)
|
||||
val handleYubiKey: LiveData<Boolean> = _handleYubiKey
|
||||
|
||||
val yubiKeyDevice = MutableLiveData<YubiKeyDevice?>()
|
||||
private var _appContext = MutableLiveData(OperationContext.Oath)
|
||||
val appContext: LiveData<OperationContext> = _appContext
|
||||
fun setContext(appContext: OperationContext) = _appContext.postValue(appContext)
|
||||
|
||||
private val _connectedYubiKey = MutableLiveData<UsbYubiKeyDevice?>()
|
||||
val connectedYubiKey: LiveData<UsbYubiKeyDevice?> = _connectedYubiKey
|
||||
fun setConnectedYubiKey(device: UsbYubiKeyDevice) {
|
||||
_connectedYubiKey.postValue(device)
|
||||
device.setOnClosed { _connectedYubiKey.postValue(null) }
|
||||
}
|
||||
|
||||
private val _deviceInfo = MutableLiveData<Info?>()
|
||||
val deviceInfo: LiveData<Info?> = _deviceInfo
|
||||
|
@ -1,7 +1,5 @@
|
||||
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
|
||||
@ -27,14 +25,15 @@ import kotlin.coroutines.suspendCoroutine
|
||||
typealias OathAction = (Result<OathSession, Exception>) -> Unit
|
||||
|
||||
class OathManager(
|
||||
private val lifecycleOwner: LifecycleOwner,
|
||||
messenger: BinaryMessenger,
|
||||
appContext: AppContext,
|
||||
private val appViewModel: MainViewModel,
|
||||
private val oathViewModel: OathViewModel,
|
||||
private val dialogManager: DialogManager,
|
||||
private val appPreferences: AppPreferences,
|
||||
) {
|
||||
): AppContextManager {
|
||||
companion object {
|
||||
const val TAG = "OathManager"
|
||||
}
|
||||
|
||||
private val _dispatcher = Executors.newSingleThreadExecutor().asCoroutineDispatcher()
|
||||
private val coroutineScope = CoroutineScope(SupervisorJob() + _dispatcher)
|
||||
@ -47,14 +46,6 @@ class OathManager(
|
||||
private var pendingAction: OathAction? = null
|
||||
|
||||
init {
|
||||
appContext.appContext.observe(lifecycleOwner) {
|
||||
if (it == OperationContext.Oath) {
|
||||
installObservers()
|
||||
} else {
|
||||
uninstallObservers()
|
||||
}
|
||||
}
|
||||
|
||||
// OATH methods callable from Flutter:
|
||||
oathChannel.setHandler(coroutineScope) { method, args ->
|
||||
when (method) {
|
||||
@ -86,119 +77,78 @@ class OathManager(
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val TAG = "OathManager"
|
||||
}
|
||||
override suspend fun processYubiKey(device: YubiKeyDevice) {
|
||||
try {
|
||||
device.withConnection<SmartCardConnection, Unit> {
|
||||
val oath = OathSession(it)
|
||||
tryToUnlockOathSession(oath)
|
||||
|
||||
private val deviceObserver =
|
||||
Observer<YubiKeyDevice?> { yubiKeyDevice ->
|
||||
try {
|
||||
if (yubiKeyDevice != null) {
|
||||
yubikeyAttached(yubiKeyDevice)
|
||||
} else {
|
||||
yubikeyDetached()
|
||||
}
|
||||
} catch (e: Throwable) {
|
||||
Log.e(TAG, "Error in device observer", e.toString())
|
||||
}
|
||||
}
|
||||
val previousId = oathViewModel.sessionState.value?.deviceId
|
||||
if (oath.deviceId == previousId) {
|
||||
// Run any pending action
|
||||
pendingAction?.let { action ->
|
||||
action.invoke(Result.success(oath))
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
private fun installObservers() {
|
||||
Log.d(TAG, "Installed oath observers")
|
||||
appViewModel.yubiKeyDevice.observe(lifecycleOwner, deviceObserver)
|
||||
}
|
||||
|
||||
private fun uninstallObservers() {
|
||||
appViewModel.yubiKeyDevice.removeObserver(deviceObserver)
|
||||
Log.d(TAG, "Uninstalled oath observers")
|
||||
}
|
||||
|
||||
private var _isUsbKey = false
|
||||
|
||||
private fun yubikeyAttached(device: YubiKeyDevice) {
|
||||
_isUsbKey = device.transport == Transport.USB
|
||||
coroutineScope.launch {
|
||||
try {
|
||||
device.withConnection<SmartCardConnection, Unit> {
|
||||
val oath = OathSession(it)
|
||||
tryToUnlockOathSession(oath)
|
||||
|
||||
val previousId = oathViewModel.sessionState.value?.deviceId
|
||||
if (oath.deviceId == previousId) {
|
||||
// Run any pending action
|
||||
pendingAction?.let { action ->
|
||||
action.invoke(Result.success(oath))
|
||||
pendingAction = null
|
||||
}
|
||||
|
||||
// Refresh codes
|
||||
if (!oath.isLocked) {
|
||||
try {
|
||||
oathViewModel.updateCredentials(
|
||||
calculateOathCodes(oath).model(oath.deviceId)
|
||||
)
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to refresh codes", error.toString())
|
||||
}
|
||||
}
|
||||
} else {
|
||||
// Awaiting an action for a different device? Fail it and stop processing.
|
||||
pendingAction?.let { action ->
|
||||
action.invoke(Result.failure(IllegalStateException("Wrong deviceId")))
|
||||
pendingAction = null
|
||||
return@withConnection
|
||||
}
|
||||
|
||||
// Clear in-memory password for any previous device
|
||||
if (it.transport == Transport.NFC && previousId != null) {
|
||||
_memoryKeyProvider.removeKey(previousId)
|
||||
}
|
||||
|
||||
// Update the OATH state
|
||||
oathViewModel.setSessionState(oath.model(_keyManager.isRemembered(oath.deviceId)))
|
||||
if(!oath.isLocked) {
|
||||
// Refresh codes
|
||||
if (!oath.isLocked) {
|
||||
try {
|
||||
oathViewModel.updateCredentials(
|
||||
calculateOathCodes(oath).model(oath.deviceId)
|
||||
)
|
||||
} catch (error: Exception) {
|
||||
Log.e(TAG, "Failed to refresh codes", error.toString())
|
||||
}
|
||||
|
||||
// Update deviceInfo since the deviceId has changed
|
||||
val pid = (device as? UsbYubiKeyDevice)?.pid
|
||||
val deviceInfo = DeviceUtil.readInfo(it, pid)
|
||||
appViewModel.setDeviceInfo(deviceInfo.model(
|
||||
DeviceUtil.getName(deviceInfo, pid?.type),
|
||||
device.transport == Transport.NFC,
|
||||
pid?.value
|
||||
))
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Successfully read Oath session info (and credentials if unlocked) from connected key")
|
||||
} catch (e: Exception) {
|
||||
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
|
||||
Log.e(TAG, "Failed to connect to CCID", e.toString())
|
||||
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
|
||||
val deviceInfoData = getDeviceInfo(device)
|
||||
Log.d(TAG, "Sending device info: $deviceInfoData")
|
||||
appViewModel.setDeviceInfo(deviceInfoData)
|
||||
}
|
||||
} else {
|
||||
// Awaiting an action for a different device? Fail it and stop processing.
|
||||
pendingAction?.let { action ->
|
||||
action.invoke(Result.failure(IllegalStateException("Wrong deviceId")))
|
||||
pendingAction = null
|
||||
return@withConnection
|
||||
}
|
||||
|
||||
// Clear any cached OATH state
|
||||
oathViewModel.setSessionState(null)
|
||||
// Clear in-memory password for any previous device
|
||||
if (it.transport == Transport.NFC && previousId != null) {
|
||||
_memoryKeyProvider.removeKey(previousId)
|
||||
}
|
||||
|
||||
// Update the OATH state
|
||||
oathViewModel.setSessionState(oath.model(_keyManager.isRemembered(oath.deviceId)))
|
||||
if(!oath.isLocked) {
|
||||
oathViewModel.updateCredentials(
|
||||
calculateOathCodes(oath).model(oath.deviceId)
|
||||
)
|
||||
}
|
||||
|
||||
// Update deviceInfo since the deviceId has changed
|
||||
val pid = (device as? UsbYubiKeyDevice)?.pid
|
||||
val deviceInfo = DeviceUtil.readInfo(it, pid)
|
||||
appViewModel.setDeviceInfo(deviceInfo.model(
|
||||
DeviceUtil.getName(deviceInfo, pid?.type),
|
||||
device.transport == Transport.NFC,
|
||||
pid?.value
|
||||
))
|
||||
}
|
||||
}
|
||||
Log.d(TAG, "Successfully read Oath session info (and credentials if unlocked) from connected key")
|
||||
} catch (e: Exception) {
|
||||
// OATH not enabled/supported, try to get DeviceInfo over other USB interfaces
|
||||
Log.e(TAG, "Failed to connect to CCID", e.toString())
|
||||
if (device.transport == Transport.USB || e is ApplicationNotAvailableException) {
|
||||
val deviceInfoData = getDeviceInfo(device)
|
||||
Log.d(TAG, "Sending device info: $deviceInfoData")
|
||||
appViewModel.setDeviceInfo(deviceInfoData)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun yubikeyDetached() {
|
||||
if (_isUsbKey) {
|
||||
Log.d(TAG, "Device disconnected")
|
||||
// clear keys from memory
|
||||
_memoryKeyProvider.clearAll()
|
||||
pendingAction = null
|
||||
appViewModel.setDeviceInfo(null)
|
||||
// Clear any cached OATH state
|
||||
oathViewModel.setSessionState(null)
|
||||
}
|
||||
}
|
||||
|
||||
//private var _isUsbKey = false
|
||||
|
||||
private suspend fun reset(): String {
|
||||
useOathSession("Reset YubiKey") {
|
||||
// note, it is ok to reset locked session
|
||||
@ -209,7 +159,6 @@ class OathManager(
|
||||
return FlutterChannel.NULL
|
||||
}
|
||||
|
||||
|
||||
private suspend fun unlock(password: String, remember: Boolean): String =
|
||||
useOathSession("Unlocking") {
|
||||
val accessKey = it.deriveAccessKey(password.toCharArray())
|
||||
@ -316,16 +265,15 @@ class OathManager(
|
||||
}
|
||||
|
||||
private suspend fun requestRefresh(): String {
|
||||
if (!_isUsbKey) {
|
||||
throw IllegalStateException("Cannot refresh for nfc key")
|
||||
}
|
||||
|
||||
return useOathSession("Refresh codes") { session ->
|
||||
oathViewModel.updateCredentials(
|
||||
calculateOathCodes(session).model(session.deviceId)
|
||||
)
|
||||
FlutterChannel.NULL
|
||||
appViewModel.connectedYubiKey.value?.let {
|
||||
useOathSessionUsb(it) {
|
||||
oathViewModel.updateCredentials(
|
||||
calculateOathCodes(it).model(it.deviceId)
|
||||
)
|
||||
}
|
||||
return FlutterChannel.NULL
|
||||
}
|
||||
throw throw IllegalStateException("Cannot refresh for nfc key")
|
||||
}
|
||||
|
||||
private suspend fun calculate(credentialId: String): String =
|
||||
@ -388,12 +336,13 @@ class OathManager(
|
||||
}
|
||||
|
||||
private fun calculateOathCodes(session: OathSession): Map<Credential, Code> {
|
||||
val isUsbKey = appViewModel.connectedYubiKey.value != null
|
||||
var timestamp = System.currentTimeMillis()
|
||||
if (!_isUsbKey) {
|
||||
if (!isUsbKey) {
|
||||
// NFC, need to pad timer to avoid immediate expiration
|
||||
timestamp += 10000
|
||||
}
|
||||
val bypassTouch = appPreferences.bypassTouchOnNfcTap && !_isUsbKey
|
||||
val bypassTouch = appPreferences.bypassTouchOnNfcTap && !isUsbKey
|
||||
return session.calculateCodes(timestamp).map { (credential, code) ->
|
||||
Pair(
|
||||
credential, if (credential.isSteamCredential() && (!credential.isTouchRequired || bypassTouch)) {
|
||||
@ -407,19 +356,20 @@ class OathManager(
|
||||
}.toMap()
|
||||
}
|
||||
|
||||
private suspend fun <T> useOathSessionUsb(
|
||||
private suspend fun <T> useOathSession(
|
||||
title: String,
|
||||
action: (OathSession) -> T
|
||||
): T {
|
||||
appViewModel.yubiKeyDevice.value?.let { yubiKey ->
|
||||
Log.d(TAG, "Executing action on usb key: $title")
|
||||
return yubiKey.withConnection<SmartCardConnection, T> {
|
||||
action.invoke(OathSession(it))
|
||||
}
|
||||
}
|
||||
return appViewModel.connectedYubiKey.value?.let {
|
||||
useOathSessionUsb(it, action)
|
||||
} ?: useOathSessionNfc(title, action)
|
||||
}
|
||||
|
||||
Log.e(TAG, "USB Key not found for action: $title")
|
||||
throw IllegalStateException("USB Key not found for action: $title")
|
||||
private suspend fun <T> useOathSessionUsb(
|
||||
device: UsbYubiKeyDevice,
|
||||
block: (OathSession) -> T
|
||||
): T = device.withConnection<SmartCardConnection, T> {
|
||||
block(OathSession(it))
|
||||
}
|
||||
|
||||
private suspend fun <T> useOathSessionNfc(
|
||||
@ -462,19 +412,6 @@ class OathManager(
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun <T> useOathSession(
|
||||
title: String,
|
||||
action: (OathSession) -> T
|
||||
): T {
|
||||
return if (_isUsbKey) {
|
||||
// Uses the connected YubiKey directly
|
||||
useOathSessionUsb(title, action)
|
||||
} else {
|
||||
// Prompts for NFC tap
|
||||
useOathSessionNfc(title, action)
|
||||
}
|
||||
}
|
||||
|
||||
private fun getOathCredential(oathSession: OathSession, credentialId: String) =
|
||||
oathSession.credentials.firstOrNull { credential ->
|
||||
(credential != null) && credential.id.asString() == credentialId
|
||||
|
@ -35,7 +35,6 @@ class _AndroidOathStateNotifier extends OathStateNotifier {
|
||||
state = const AsyncValue.loading();
|
||||
} else {
|
||||
final oathState = OathState.fromJson(json);
|
||||
_log.debug('STATE: $oathState');
|
||||
state = AsyncValue.data(oathState);
|
||||
}
|
||||
}
|
||||
@ -139,8 +138,8 @@ class _AndroidCredentialListNotifier extends OathCredentialListNotifier {
|
||||
? List.unmodifiable(
|
||||
(json as List).map((e) => OathPair.fromJson(e)).toList())
|
||||
: null;
|
||||
_scheduleRefresh();
|
||||
});
|
||||
_scheduleRefresh();
|
||||
}
|
||||
|
||||
void _notifyWindowState(WindowState windowState) {
|
||||
|
Loading…
Reference in New Issue
Block a user