mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 12:07:28 +00:00
Merge pull request #6992 from element-hq/feature/bma/protectLinkNewDevice
Protect link new device
This commit is contained in:
+34
-3
@@ -13,8 +13,10 @@ import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.DisposableEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.composable.PermanentChild
|
||||
import com.bumble.appyx.core.lifecycle.subscribe
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
@@ -34,8 +36,10 @@ import io.element.android.features.linknewdevice.impl.screens.error.ErrorNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.error.ErrorScreenType
|
||||
import io.element.android.features.linknewdevice.impl.screens.number.EnterNumberNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.qrcode.ShowQrCodeNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.root.LinkDeviceType
|
||||
import io.element.android.features.linknewdevice.impl.screens.root.LinkNewDeviceRootNode
|
||||
import io.element.android.features.linknewdevice.impl.screens.scan.ScanQrCodeNode
|
||||
import io.element.android.features.lockscreen.api.DeviceUnlockEntryPoint
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
@@ -67,11 +71,16 @@ class LinkNewDeviceFlowNode(
|
||||
private val linkNewMobileHandler: LinkNewMobileHandler,
|
||||
private val linkNewDesktopHandler: LinkNewDesktopHandler,
|
||||
private val sessionEnterpriseService: SessionEnterpriseService,
|
||||
private val deviceUnlockEntryPoint: DeviceUnlockEntryPoint,
|
||||
) : BaseFlowNode<LinkNewDeviceFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
permanentNavModel = PermanentNavModel(
|
||||
navTargets = setOf(NavTarget.LockScreen),
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
@@ -124,6 +133,9 @@ class LinkNewDeviceFlowNode(
|
||||
@Parcelize
|
||||
data object DesktopScanQrCode : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object LockScreen : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class Error(
|
||||
val errorScreenType: ErrorScreenType,
|
||||
@@ -137,6 +149,9 @@ class LinkNewDeviceFlowNode(
|
||||
Timber.tag(tag.value).d("step: ${linkMobileStep::class.java.simpleName}")
|
||||
when (linkMobileStep) {
|
||||
LinkMobileStep.Uninitialized -> Unit
|
||||
LinkMobileStep.CreatingQrCode -> {
|
||||
// This step is handled in LinkNewDeviceRootPresenter
|
||||
}
|
||||
LinkMobileStep.Done -> {
|
||||
callback.onDone()
|
||||
}
|
||||
@@ -224,13 +239,28 @@ class LinkNewDeviceFlowNode(
|
||||
callback.onDone()
|
||||
}
|
||||
|
||||
override fun linkDesktopDevice() {
|
||||
linkNewDesktopHandler.reset()
|
||||
backstack.push(NavTarget.DesktopNotice)
|
||||
override fun onUnlockDevice(type: LinkDeviceType) {
|
||||
val callback = object : DeviceUnlockEntryPoint.Callback {
|
||||
override fun onCancel() = Unit
|
||||
override fun onUnlock() = when (type) {
|
||||
LinkDeviceType.Mobile -> {
|
||||
linkNewMobileHandler.reset()
|
||||
linkNewMobileHandler.createAndStartNewHandler()
|
||||
}
|
||||
LinkDeviceType.Desktop -> {
|
||||
linkNewDesktopHandler.reset()
|
||||
backstack.push(NavTarget.DesktopNotice)
|
||||
}
|
||||
}
|
||||
}
|
||||
deviceUnlockEntryPoint.requestUnlock(callback)
|
||||
}
|
||||
}
|
||||
createNode<LinkNewDeviceRootNode>(buildContext, listOf(callback))
|
||||
}
|
||||
is NavTarget.LockScreen -> {
|
||||
deviceUnlockEntryPoint.createNode(this, buildContext)
|
||||
}
|
||||
NavTarget.DesktopNotice -> {
|
||||
val callback = object : DesktopNoticeNode.Callback {
|
||||
override fun navigateBack() {
|
||||
@@ -324,5 +354,6 @@ class LinkNewDeviceFlowNode(
|
||||
}
|
||||
}
|
||||
BackstackView()
|
||||
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockScreen)
|
||||
}
|
||||
}
|
||||
|
||||
+5
-2
@@ -43,12 +43,15 @@ class LinkNewMobileHandler(
|
||||
val stepFlow: StateFlow<LinkMobileStep>
|
||||
get() = linkMobileStepFlow.asStateFlow()
|
||||
|
||||
fun createAndStartNewHandler() {
|
||||
fun createAndStartNewHandler(forRotating: Boolean = false) {
|
||||
Timber.tag(loggerTag.value).d("createAndStartNewHandler()")
|
||||
currentJob?.cancel()
|
||||
handler = matrixClient.createLinkMobileHandler().getOrNull()
|
||||
handler?.let { h ->
|
||||
currentJob = sessionScope.launch {
|
||||
if (!forRotating) {
|
||||
linkMobileStepFlow.emit(LinkMobileStep.CreatingQrCode)
|
||||
}
|
||||
h.linkMobileStep
|
||||
.onEach {
|
||||
linkMobileStepFlow.emit(it)
|
||||
@@ -68,7 +71,7 @@ class LinkNewMobileHandler(
|
||||
}
|
||||
|
||||
fun rotateQrCode() {
|
||||
createAndStartNewHandler()
|
||||
createAndStartNewHandler(forRotating = true)
|
||||
}
|
||||
|
||||
fun onTooManyRotation() {
|
||||
|
||||
+13
@@ -0,0 +1,13 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.linknewdevice.impl.screens.root
|
||||
|
||||
enum class LinkDeviceType {
|
||||
Mobile,
|
||||
Desktop,
|
||||
}
|
||||
-1
@@ -8,6 +8,5 @@
|
||||
package io.element.android.features.linknewdevice.impl.screens.root
|
||||
|
||||
sealed interface LinkNewDeviceRootEvent {
|
||||
data object LinkMobileDevice : LinkNewDeviceRootEvent
|
||||
data object CloseDialog : LinkNewDeviceRootEvent
|
||||
}
|
||||
|
||||
+2
-2
@@ -27,7 +27,7 @@ class LinkNewDeviceRootNode(
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
fun linkDesktopDevice()
|
||||
fun onUnlockDevice(type: LinkDeviceType)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
@@ -39,7 +39,7 @@ class LinkNewDeviceRootNode(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = callback::onDone,
|
||||
onLinkDesktopDeviceClick = callback::linkDesktopDevice,
|
||||
onUnlockDevice = callback::onUnlockDevice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-7
@@ -50,7 +50,13 @@ class LinkNewDeviceRootPresenter(
|
||||
LaunchedEffect(step) {
|
||||
when (val finalStep = step) {
|
||||
is LinkMobileStep.Uninitialized -> {
|
||||
qrCodeData = AsyncData.Uninitialized
|
||||
// Ignore this step when loading QrCode
|
||||
if (!qrCodeData.isLoading()) {
|
||||
qrCodeData = AsyncData.Uninitialized
|
||||
}
|
||||
}
|
||||
is LinkMobileStep.CreatingQrCode -> {
|
||||
qrCodeData = AsyncData.Loading()
|
||||
}
|
||||
is LinkMobileStep.QrReady -> {
|
||||
qrCodeData = AsyncData.Success(Unit)
|
||||
@@ -64,12 +70,6 @@ class LinkNewDeviceRootPresenter(
|
||||
|
||||
fun handleEvent(event: LinkNewDeviceRootEvent) {
|
||||
when (event) {
|
||||
LinkNewDeviceRootEvent.LinkMobileDevice -> coroutineScope.launch {
|
||||
qrCodeData = AsyncData.Loading()
|
||||
// Wait for the QrCode to be ready
|
||||
linkNewMobileHandler.reset()
|
||||
linkNewMobileHandler.createAndStartNewHandler()
|
||||
}
|
||||
LinkNewDeviceRootEvent.CloseDialog -> coroutineScope.launch {
|
||||
linkNewMobileHandler.reset()
|
||||
}
|
||||
|
||||
+34
-36
@@ -41,7 +41,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
fun LinkNewDeviceRootView(
|
||||
state: LinkNewDeviceRootState,
|
||||
onBackClick: () -> Unit,
|
||||
onLinkDesktopDeviceClick: () -> Unit,
|
||||
onUnlockDevice: (type: LinkDeviceType) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val (title, subtitle, iconStyle) = if (state.isSupported.dataOrNull() == false) {
|
||||
@@ -57,6 +57,7 @@ fun LinkNewDeviceRootView(
|
||||
BigIcon.Style.Default(CompoundIcons.Devices())
|
||||
)
|
||||
}
|
||||
|
||||
FlowStepPage(
|
||||
onBackClick = onBackClick,
|
||||
title = title,
|
||||
@@ -83,40 +84,37 @@ fun LinkNewDeviceRootView(
|
||||
}
|
||||
is AsyncData.Success -> {
|
||||
if (state.isSupported.data) {
|
||||
when (state.qrCodeData) {
|
||||
AsyncData.Uninitialized,
|
||||
is AsyncData.Failure -> {
|
||||
Button(
|
||||
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_mobile_device),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
||||
)
|
||||
Button(
|
||||
onClick = onLinkDesktopDeviceClick,
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||
)
|
||||
}
|
||||
is AsyncData.Loading,
|
||||
is AsyncData.Success -> {
|
||||
Button(
|
||||
onClick = { state.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice) },
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_loading_qr_code),
|
||||
showProgress = true,
|
||||
enabled = false,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
)
|
||||
Button(
|
||||
onClick = onLinkDesktopDeviceClick,
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
enabled = false,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||
)
|
||||
}
|
||||
}
|
||||
val canClick = state.qrCodeData is AsyncData.Uninitialized
|
||||
val isLoading = state.qrCodeData is AsyncData.Loading || state.qrCodeData is AsyncData.Success
|
||||
Button(
|
||||
onClick = {
|
||||
if (canClick) {
|
||||
onUnlockDevice(LinkDeviceType.Mobile)
|
||||
}
|
||||
},
|
||||
text = stringResource(
|
||||
id = if (isLoading) {
|
||||
R.string.screen_link_new_device_root_loading_qr_code
|
||||
} else {
|
||||
R.string.screen_link_new_device_root_mobile_device
|
||||
}
|
||||
),
|
||||
showProgress = isLoading,
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Mobile()),
|
||||
)
|
||||
Button(
|
||||
onClick = {
|
||||
if (canClick) {
|
||||
onUnlockDevice(LinkDeviceType.Desktop)
|
||||
}
|
||||
},
|
||||
text = stringResource(id = R.string.screen_link_new_device_root_desktop_computer),
|
||||
enabled = !isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Computer()),
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
onClick = onBackClick,
|
||||
@@ -147,6 +145,6 @@ internal fun LinkNewDeviceRootViewPreview(
|
||||
LinkNewDeviceRootView(
|
||||
state = state,
|
||||
onBackClick = { },
|
||||
onLinkDesktopDeviceClick = { },
|
||||
onUnlockDevice = { },
|
||||
)
|
||||
}
|
||||
|
||||
+2
@@ -13,6 +13,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.test.FakeSessionEnterpriseService
|
||||
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
|
||||
import io.element.android.features.lockscreen.test.FakeDeviceUnlockEntryPoint
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.node.TestParentNode
|
||||
@@ -39,6 +40,7 @@ class DefaultLinkNewDeviceEntryPointTest {
|
||||
linkNewMobileHandler = LinkNewMobileHandler(client),
|
||||
linkNewDesktopHandler = LinkNewDesktopHandler(client),
|
||||
sessionEnterpriseService = FakeSessionEnterpriseService(),
|
||||
deviceUnlockEntryPoint = FakeDeviceUnlockEntryPoint(),
|
||||
)
|
||||
}
|
||||
val callback: LinkNewDeviceEntryPoint.Callback = object : LinkNewDeviceEntryPoint.Callback {
|
||||
|
||||
+5
-2
@@ -88,6 +88,7 @@ class EnterNumberPresenterTest {
|
||||
navigator = navigator,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
linkMobileHandler.emitStep(
|
||||
LinkMobileStep.QrScanned(checkCodeSender)
|
||||
@@ -96,7 +97,7 @@ class EnterNumberPresenterTest {
|
||||
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.Continue)
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.sendingCode.isLoading()).isTrue()
|
||||
advanceUntilIdle()
|
||||
@@ -130,6 +131,7 @@ class EnterNumberPresenterTest {
|
||||
LinkMobileStep.QrScanned(checkCodeSender)
|
||||
)
|
||||
runCurrent()
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.Continue)
|
||||
@@ -163,6 +165,7 @@ class EnterNumberPresenterTest {
|
||||
createPresenter(
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
linkMobileHandler.emitStep(
|
||||
LinkMobileStep.QrScanned(checkCodeSender)
|
||||
@@ -171,7 +174,7 @@ class EnterNumberPresenterTest {
|
||||
initialState.eventSink(EnterNumberEvent.UpdateNumber("88"))
|
||||
skipItems(1)
|
||||
initialState.eventSink(EnterNumberEvent.Continue)
|
||||
skipItems(1)
|
||||
skipItems(2)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.sendingCode.isLoading()).isTrue()
|
||||
expectNoEvents()
|
||||
|
||||
+34
-1
@@ -75,15 +75,48 @@ class LinkNewDeviceRootPresenterTest {
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) }
|
||||
)
|
||||
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
|
||||
createPresenter(
|
||||
matrixClient = matrixClient,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.isSupported.dataOrNull()).isTrue()
|
||||
initialState.eventSink(LinkNewDeviceRootEvent.LinkMobileDevice)
|
||||
linkNewMobileHandler.createAndStartNewHandler()
|
||||
skipItems(1)
|
||||
val loadingState = awaitItem()
|
||||
assertThat(loadingState.qrCodeData.isLoading()).isTrue()
|
||||
skipItems(1)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - close dialog resets qrCodeData`() = runTest {
|
||||
val fakeLinkMobileHandler = FakeLinkMobileHandler(startResult = {})
|
||||
val matrixClient = FakeMatrixClient(
|
||||
canLinkNewDeviceResult = { Result.success(true) },
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
createLinkMobileHandlerResult = { Result.success(fakeLinkMobileHandler) }
|
||||
)
|
||||
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
|
||||
createPresenter(
|
||||
matrixClient = matrixClient,
|
||||
linkNewMobileHandler = linkNewMobileHandler,
|
||||
).test {
|
||||
skipItems(1)
|
||||
linkNewMobileHandler.onTooManyRotation()
|
||||
var errorState = awaitItem()
|
||||
while (!errorState.qrCodeData.isFailure()) {
|
||||
errorState = awaitItem()
|
||||
}
|
||||
assertThat(errorState.qrCodeData.isFailure()).isTrue()
|
||||
errorState.eventSink(LinkNewDeviceRootEvent.CloseDialog)
|
||||
var resetState = awaitItem()
|
||||
while (!resetState.qrCodeData.isUninitialized()) {
|
||||
resetState = awaitItem()
|
||||
}
|
||||
assertThat(resetState.qrCodeData.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+17
-13
@@ -17,9 +17,11 @@ import io.element.android.features.linknewdevice.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.ensureCalledOnceWithParam
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
@@ -42,29 +44,31 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `link desktop button clicked - calls the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
|
||||
ensureCalledOnce { callback ->
|
||||
ensureCalledOnceWithParam(LinkDeviceType.Desktop) { callback ->
|
||||
setLinkNewDeviceRootView(
|
||||
state = aLinkNewDeviceRootState(
|
||||
isSupported = AsyncData.Success(true),
|
||||
eventSink = eventRecorder,
|
||||
),
|
||||
onLinkDesktopDeviceClick = callback,
|
||||
onUnlockDevice = callback,
|
||||
)
|
||||
clickOn(R.string.screen_link_new_device_root_desktop_computer)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `link mobile button clicked - emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>()
|
||||
setLinkNewDeviceRootView(
|
||||
state = aLinkNewDeviceRootState(
|
||||
isSupported = AsyncData.Success(true),
|
||||
eventSink = eventRecorder,
|
||||
fun `link mobile button clicked - calls the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventRecorder = EventsRecorder<LinkNewDeviceRootEvent>(expectEvents = false)
|
||||
ensureCalledOnceWithParam(LinkDeviceType.Mobile) { callback ->
|
||||
setLinkNewDeviceRootView(
|
||||
state = aLinkNewDeviceRootState(
|
||||
isSupported = AsyncData.Success(true),
|
||||
eventSink = eventRecorder,
|
||||
),
|
||||
onUnlockDevice = callback,
|
||||
)
|
||||
)
|
||||
clickOn(R.string.screen_link_new_device_root_mobile_device)
|
||||
eventRecorder.assertSingle(LinkNewDeviceRootEvent.LinkMobileDevice)
|
||||
clickOn(R.string.screen_link_new_device_root_mobile_device)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -85,13 +89,13 @@ class LinkNewDeviceRootViewTest : RobolectricTest() {
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setLinkNewDeviceRootView(
|
||||
state: LinkNewDeviceRootState = aLinkNewDeviceRootState(),
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onLinkDesktopDeviceClick: () -> Unit = EnsureNeverCalled(),
|
||||
onUnlockDevice: (type: LinkDeviceType) -> Unit = EnsureNeverCalledWithParam(),
|
||||
) {
|
||||
setContent {
|
||||
LinkNewDeviceRootView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onLinkDesktopDeviceClick = onLinkDesktopDeviceClick,
|
||||
onUnlockDevice = onUnlockDevice,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user