Merge pull request #3298 from element-hq/feat/jme/3268-crypto-identity-reset
Feature: identity reset
This commit is contained in:
commit
b62348f39b
95 changed files with 2092 additions and 172 deletions
|
|
@ -42,6 +42,7 @@ dependencies {
|
|||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.deeplink)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.oidc.api)
|
||||
implementation(projects.libraries.preferences.api)
|
||||
implementation(projects.libraries.push.api)
|
||||
implementation(projects.libraries.pushproviders.api)
|
||||
|
|
@ -66,6 +67,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oidc.impl)
|
||||
testImplementation(projects.libraries.push.test)
|
||||
testImplementation(projects.libraries.pushproviders.test)
|
||||
testImplementation(projects.features.networkmonitor.test)
|
||||
|
|
|
|||
|
|
@ -42,8 +42,6 @@ import io.element.android.appnav.intent.ResolvedIntent
|
|||
import io.element.android.appnav.root.RootNavStateFlowFactory
|
||||
import io.element.android.appnav.root.RootPresenter
|
||||
import io.element.android.appnav.root.RootView
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
|
||||
import io.element.android.features.signedout.api.SignedOutEntryPoint
|
||||
import io.element.android.features.viewfolder.api.ViewFolderEntryPoint
|
||||
|
|
@ -58,6 +56,8 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
|||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
|
|
|
|||
|
|
@ -17,12 +17,12 @@
|
|||
package io.element.android.appnav.intent
|
||||
|
||||
import android.content.Intent
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcIntentResolver
|
||||
import timber.log.Timber
|
||||
import javax.inject.Inject
|
||||
|
||||
|
|
|
|||
|
|
@ -21,9 +21,6 @@ import android.content.Intent
|
|||
import android.net.Uri
|
||||
import androidx.core.net.toUri
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.impl.oidc.DefaultOidcIntentResolver
|
||||
import io.element.android.features.login.impl.oidc.OidcUrlParser
|
||||
import io.element.android.libraries.deeplink.DeepLinkCreator
|
||||
import io.element.android.libraries.deeplink.DeeplinkData
|
||||
import io.element.android.libraries.deeplink.DeeplinkParser
|
||||
|
|
@ -33,6 +30,9 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
|
|||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_THREAD_ID
|
||||
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.impl.DefaultOidcIntentResolver
|
||||
import io.element.android.libraries.oidc.impl.OidcUrlParser
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import org.junit.Assert.assertThrows
|
||||
import org.junit.Test
|
||||
|
|
|
|||
|
|
@ -58,6 +58,9 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object EnterRecoveryKey : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ResetIdentity : NavTarget
|
||||
}
|
||||
|
||||
interface Callback : Plugin {
|
||||
|
|
@ -85,6 +88,10 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
override fun onDone() {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
|
||||
override fun onResetKey() {
|
||||
backstack.push(NavTarget.ResetIdentity)
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
|
|
@ -94,6 +101,16 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
|
|||
.callback(secureBackupEntryPointCallback)
|
||||
.build()
|
||||
}
|
||||
is NavTarget.ResetIdentity -> {
|
||||
secureBackupEntryPoint.nodeBuilder(this, buildContext)
|
||||
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.ResetIdentity))
|
||||
.callback(object : SecureBackupEntryPoint.Callback {
|
||||
override fun onDone() {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
})
|
||||
.build()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -50,6 +50,7 @@ dependencies {
|
|||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.permissions.api)
|
||||
implementation(projects.libraries.qrcode)
|
||||
implementation(projects.libraries.oidc.api)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(platform(libs.network.retrofit.bom))
|
||||
implementation(libs.network.retrofit)
|
||||
|
|
@ -65,6 +66,7 @@ dependencies {
|
|||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.oidc.impl)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
|
||||
|
|
|
|||
|
|
@ -36,12 +36,7 @@ import dagger.assisted.AssistedInject
|
|||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.features.login.api.LoginFlowType
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.oidc.CustomTabAvailabilityChecker
|
||||
import io.element.android.features.login.impl.oidc.customtab.CustomTabHandler
|
||||
import io.element.android.features.login.impl.oidc.webview.OidcNode
|
||||
import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
|
||||
import io.element.android.features.login.impl.screens.changeaccountprovider.ChangeAccountProviderNode
|
||||
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
|
||||
|
|
@ -56,6 +51,9 @@ import io.element.android.libraries.architecture.createNode
|
|||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import io.element.android.libraries.oidc.api.OidcEntryPoint
|
||||
import kotlinx.coroutines.delay
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
|
@ -64,11 +62,10 @@ import kotlinx.parcelize.Parcelize
|
|||
class LoginFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||
private val customTabHandler: CustomTabHandler,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
private val oidcEntryPoint: OidcEntryPoint,
|
||||
) : BaseFlowNode<LoginFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = NavTarget.Root,
|
||||
|
|
@ -146,11 +143,11 @@ class LoginFlowNode @AssistedInject constructor(
|
|||
)
|
||||
val callback = object : ConfirmAccountProviderNode.Callback {
|
||||
override fun onOidcDetails(oidcDetails: OidcDetails) {
|
||||
if (customTabAvailabilityChecker.supportCustomTab()) {
|
||||
if (oidcEntryPoint.canUseCustomTab()) {
|
||||
// In this case open a Chrome Custom tab
|
||||
activity?.let {
|
||||
customChromeTabStarted = true
|
||||
customTabHandler.open(it, darkTheme, oidcDetails.url)
|
||||
oidcEntryPoint.openUrlInCustomTab(it, darkTheme, oidcDetails.url)
|
||||
}
|
||||
} else {
|
||||
// Fallback to WebView mode
|
||||
|
|
@ -201,8 +198,7 @@ class LoginFlowNode @AssistedInject constructor(
|
|||
createNode<LoginPasswordNode>(buildContext, plugins = listOf(callback))
|
||||
}
|
||||
is NavTarget.OidcView -> {
|
||||
val input = OidcNode.Inputs(navTarget.oidcDetails)
|
||||
createNode<OidcNode>(buildContext, plugins = listOf(input))
|
||||
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.oidcDetails.url)
|
||||
}
|
||||
is NavTarget.WaitList -> {
|
||||
val inputs = WaitListNode.Inputs(
|
||||
|
|
|
|||
|
|
@ -27,15 +27,15 @@ import androidx.compose.runtime.rememberCoroutineScope
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.error.ChangeServerError
|
||||
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
|
|
@ -43,7 +43,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
@Assisted private val params: Params,
|
||||
private val accountProviderDataSource: AccountProviderDataSource,
|
||||
private val authenticationService: MatrixAuthenticationService,
|
||||
private val defaultOidcActionFlow: DefaultOidcActionFlow,
|
||||
private val oidcActionFlow: OidcActionFlow,
|
||||
private val defaultLoginUserStory: DefaultLoginUserStory,
|
||||
) : Presenter<ConfirmAccountProviderState> {
|
||||
data class Params(
|
||||
|
|
@ -65,7 +65,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
}
|
||||
|
||||
LaunchedEffect(Unit) {
|
||||
defaultOidcActionFlow.collect { oidcAction ->
|
||||
oidcActionFlow.collect { oidcAction ->
|
||||
if (oidcAction != null) {
|
||||
onOidcAction(oidcAction, loginFlowAction)
|
||||
}
|
||||
|
|
@ -133,6 +133,6 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
|
|||
}
|
||||
}
|
||||
}
|
||||
defaultOidcActionFlow.reset()
|
||||
oidcActionFlow.reset()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,10 +20,8 @@ import app.cash.molecule.RecompositionMode
|
|||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.impl.DefaultLoginUserStory
|
||||
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
|
||||
import io.element.android.features.login.impl.oidc.customtab.DefaultOidcActionFlow
|
||||
import io.element.android.features.login.impl.util.defaultAccountProvider
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
|
|
@ -31,6 +29,8 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER
|
|||
import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.impl.customtab.DefaultOidcActionFlow
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.waitForPredicate
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -274,7 +274,7 @@ class ConfirmAccountProviderPresenterTest {
|
|||
params = params,
|
||||
accountProviderDataSource = accountProviderDataSource,
|
||||
authenticationService = matrixAuthenticationService,
|
||||
defaultOidcActionFlow = defaultOidcActionFlow,
|
||||
oidcActionFlow = defaultOidcActionFlow,
|
||||
defaultLoginUserStory = defaultLoginUserStory,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -34,6 +34,9 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
|
|||
|
||||
@Parcelize
|
||||
data object CreateNewRecoveryKey : InitialTarget
|
||||
|
||||
@Parcelize
|
||||
data object ResetIdentity : InitialTarget
|
||||
}
|
||||
|
||||
data class Params(val initialElement: InitialTarget) : NodeInputs
|
||||
|
|
|
|||
|
|
@ -45,6 +45,7 @@ dependencies {
|
|||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.matrixui)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.oidc.api)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(projects.libraries.testtags)
|
||||
api(libs.statemachine)
|
||||
|
|
|
|||
|
|
@ -34,6 +34,7 @@ import io.element.android.features.securebackup.impl.createkey.CreateNewRecovery
|
|||
import io.element.android.features.securebackup.impl.disable.SecureBackupDisableNode
|
||||
import io.element.android.features.securebackup.impl.enable.SecureBackupEnableNode
|
||||
import io.element.android.features.securebackup.impl.enter.SecureBackupEnterRecoveryKeyNode
|
||||
import io.element.android.features.securebackup.impl.reset.ResetIdentityFlowNode
|
||||
import io.element.android.features.securebackup.impl.root.SecureBackupRootNode
|
||||
import io.element.android.features.securebackup.impl.setup.SecureBackupSetupNode
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
|
|
@ -48,10 +49,11 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
|||
@Assisted plugins: List<Plugin>,
|
||||
) : BaseFlowNode<SecureBackupFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
initialElement = when (plugins.filterIsInstance(SecureBackupEntryPoint.Params::class.java).first().initialElement) {
|
||||
initialElement = when (plugins.filterIsInstance<SecureBackupEntryPoint.Params>().first().initialElement) {
|
||||
SecureBackupEntryPoint.InitialTarget.Root -> NavTarget.Root
|
||||
SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey -> NavTarget.EnterRecoveryKey
|
||||
SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey -> NavTarget.CreateNewRecoveryKey
|
||||
is SecureBackupEntryPoint.InitialTarget.ResetIdentity -> NavTarget.ResetIdentity
|
||||
},
|
||||
savedStateMap = buildContext.savedStateMap,
|
||||
),
|
||||
|
|
@ -79,6 +81,9 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
|||
|
||||
@Parcelize
|
||||
data object CreateNewRecoveryKey : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ResetIdentity : NavTarget
|
||||
}
|
||||
|
||||
private val callbacks = plugins<SecureBackupEntryPoint.Callback>()
|
||||
|
|
@ -146,6 +151,14 @@ class SecureBackupFlowNode @AssistedInject constructor(
|
|||
NavTarget.CreateNewRecoveryKey -> {
|
||||
createNode<CreateNewRecoveryKeyNode>(buildContext)
|
||||
}
|
||||
is NavTarget.ResetIdentity -> {
|
||||
val callback = object : ResetIdentityFlowNode.Callback {
|
||||
override fun onDone() {
|
||||
callbacks.forEach { it.onDone() }
|
||||
}
|
||||
}
|
||||
createNode<ResetIdentityFlowNode>(buildContext, listOf(callback))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -0,0 +1,79 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.filterIsInstance
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.launch
|
||||
import javax.inject.Inject
|
||||
|
||||
class ResetIdentityFlowManager @Inject constructor(
|
||||
private val matrixClient: MatrixClient,
|
||||
@SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope,
|
||||
private val sessionVerificationService: SessionVerificationService,
|
||||
) {
|
||||
private val resetHandleFlow: MutableStateFlow<AsyncData<IdentityResetHandle>> = MutableStateFlow(AsyncData.Uninitialized)
|
||||
val currentHandleFlow: StateFlow<AsyncData<IdentityResetHandle>> = resetHandleFlow
|
||||
private var whenResetIsDoneWaitingJob: Job? = null
|
||||
|
||||
fun whenResetIsDone(block: () -> Unit) {
|
||||
whenResetIsDoneWaitingJob = sessionCoroutineScope.launch {
|
||||
sessionVerificationService.sessionVerifiedStatus.filterIsInstance<SessionVerifiedStatus.Verified>().first()
|
||||
block()
|
||||
}
|
||||
}
|
||||
|
||||
fun getResetHandle(): StateFlow<AsyncData<IdentityResetHandle>> {
|
||||
return if (resetHandleFlow.value.isLoading() || resetHandleFlow.value.isSuccess()) {
|
||||
resetHandleFlow
|
||||
} else {
|
||||
resetHandleFlow.value = AsyncData.Loading()
|
||||
|
||||
sessionCoroutineScope.launch {
|
||||
matrixClient.encryptionService().startIdentityReset()
|
||||
.onSuccess { handle ->
|
||||
resetHandleFlow.value = if (handle != null) {
|
||||
AsyncData.Success(handle)
|
||||
} else {
|
||||
AsyncData.Failure(IllegalStateException("Could not get a reset identity handle"))
|
||||
}
|
||||
}
|
||||
.onFailure { resetHandleFlow.value = AsyncData.Failure(it) }
|
||||
}
|
||||
|
||||
resetHandleFlow
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun cancel() {
|
||||
currentHandleFlow.value.dataOrNull()?.cancel()
|
||||
resetHandleFlow.value = AsyncData.Uninitialized
|
||||
|
||||
whenResetIsDoneWaitingJob?.cancel()
|
||||
whenResetIsDoneWaitingJob = null
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,180 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset
|
||||
|
||||
import android.app.Activity
|
||||
import android.os.Parcelable
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalContext
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import androidx.lifecycle.DefaultLifecycleObserver
|
||||
import androidx.lifecycle.LifecycleOwner
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import com.bumble.appyx.core.plugin.plugins
|
||||
import com.bumble.appyx.navmodel.backstack.BackStack
|
||||
import com.bumble.appyx.navmodel.backstack.operation.push
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.features.securebackup.impl.reset.password.ResetIdentityPasswordNode
|
||||
import io.element.android.features.securebackup.impl.reset.root.ResetIdentityRootNode
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.BackstackView
|
||||
import io.element.android.libraries.architecture.BaseFlowNode
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
import io.element.android.libraries.oidc.api.OidcEntryPoint
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.collectLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.parcelize.Parcelize
|
||||
import timber.log.Timber
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class ResetIdentityFlowNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
private val resetIdentityFlowManager: ResetIdentityFlowManager,
|
||||
private val coroutineScope: CoroutineScope,
|
||||
private val oidcEntryPoint: OidcEntryPoint,
|
||||
) : BaseFlowNode<ResetIdentityFlowNode.NavTarget>(
|
||||
backstack = BackStack(initialElement = NavTarget.Root, savedStateMap = buildContext.savedStateMap),
|
||||
buildContext = buildContext,
|
||||
plugins = plugins,
|
||||
) {
|
||||
interface Callback : Plugin {
|
||||
fun onDone()
|
||||
}
|
||||
|
||||
sealed interface NavTarget : Parcelable {
|
||||
@Parcelize
|
||||
data object Root : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data object ResetPassword : NavTarget
|
||||
|
||||
@Parcelize
|
||||
data class ResetOidc(val url: String) : NavTarget
|
||||
}
|
||||
|
||||
private lateinit var activity: Activity
|
||||
private var resetJob: Job? = null
|
||||
|
||||
override fun onBuilt() {
|
||||
super.onBuilt()
|
||||
|
||||
resetIdentityFlowManager.whenResetIsDone {
|
||||
plugins<Callback>().forEach { it.onDone() }
|
||||
}
|
||||
|
||||
lifecycle.addObserver(object : DefaultLifecycleObserver {
|
||||
override fun onStart(owner: LifecycleOwner) {
|
||||
// If the custom tab was opened, we need to cancel the reset job
|
||||
// when we come back to the node if the reset wasn't successful
|
||||
cancelResetJob()
|
||||
}
|
||||
|
||||
override fun onDestroy(owner: LifecycleOwner) {
|
||||
// Make sure we cancel the reset job when the node is destroyed, just in case
|
||||
cancelResetJob()
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||
return when (navTarget) {
|
||||
is NavTarget.Root -> {
|
||||
val callback = object : ResetIdentityRootNode.Callback {
|
||||
override fun onContinue() {
|
||||
coroutineScope.startReset()
|
||||
}
|
||||
}
|
||||
createNode<ResetIdentityRootNode>(buildContext, listOf(callback))
|
||||
}
|
||||
is NavTarget.ResetPassword -> {
|
||||
val handle = resetIdentityFlowManager.currentHandleFlow.value.dataOrNull() as? IdentityPasswordResetHandle ?: error("No password handle found")
|
||||
createNode<ResetIdentityPasswordNode>(
|
||||
buildContext,
|
||||
listOf(ResetIdentityPasswordNode.Inputs(handle))
|
||||
)
|
||||
}
|
||||
is NavTarget.ResetOidc -> {
|
||||
oidcEntryPoint.createFallbackWebViewNode(this, buildContext, navTarget.url)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun CoroutineScope.startReset() = launch {
|
||||
resetIdentityFlowManager.getResetHandle()
|
||||
.collectLatest { state ->
|
||||
when (state) {
|
||||
is AsyncData.Failure -> {
|
||||
cancelResetJob()
|
||||
Timber.e(state.error, "Could not load the reset identity handle.")
|
||||
}
|
||||
is AsyncData.Success -> {
|
||||
when (val handle = state.data) {
|
||||
is IdentityOidcResetHandle -> {
|
||||
if (oidcEntryPoint.canUseCustomTab()) {
|
||||
activity.openUrlInChromeCustomTab(null, false, handle.url)
|
||||
} else {
|
||||
backstack.push(NavTarget.ResetOidc(handle.url))
|
||||
}
|
||||
resetJob = launch { handle.resetOidc() }
|
||||
}
|
||||
is IdentityPasswordResetHandle -> backstack.push(NavTarget.ResetPassword)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private fun cancelResetJob() {
|
||||
resetJob?.cancel()
|
||||
resetJob = null
|
||||
coroutineScope.launch { resetIdentityFlowManager.cancel() }
|
||||
}
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
// Workaround to get the current activity
|
||||
if (!this::activity.isInitialized) {
|
||||
activity = LocalContext.current as Activity
|
||||
}
|
||||
|
||||
val startResetState by resetIdentityFlowManager.currentHandleFlow.collectAsState()
|
||||
if (startResetState.isLoading()) {
|
||||
ProgressDialog(
|
||||
properties = DialogProperties(dismissOnBackPress = true, dismissOnClickOutside = true),
|
||||
onDismissRequest = { cancelResetJob() }
|
||||
)
|
||||
}
|
||||
|
||||
BackstackView(modifier)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
sealed interface ResetIdentityPasswordEvent {
|
||||
data class Reset(val password: String) : ResetIdentityPasswordEvent
|
||||
data object DismissError : ResetIdentityPasswordEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,54 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class ResetIdentityPasswordNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
coroutineDispatchers: CoroutineDispatchers,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
data class Inputs(val handle: IdentityPasswordResetHandle) : NodeInputs
|
||||
|
||||
private val presenter = ResetIdentityPasswordPresenter(
|
||||
identityPasswordResetHandle = inputs<Inputs>().handle,
|
||||
dispatchers = coroutineDispatchers
|
||||
)
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
ResetIdentityPasswordView(
|
||||
state = state,
|
||||
onBack = ::navigateUp
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,60 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.MutableState
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runCatchingUpdatingState
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class ResetIdentityPasswordPresenter(
|
||||
private val identityPasswordResetHandle: IdentityPasswordResetHandle,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<ResetIdentityPasswordState> {
|
||||
@Composable
|
||||
override fun present(): ResetIdentityPasswordState {
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
|
||||
val resetAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
|
||||
fun handleEvent(event: ResetIdentityPasswordEvent) {
|
||||
when (event) {
|
||||
is ResetIdentityPasswordEvent.Reset -> coroutineScope.reset(event.password, resetAction)
|
||||
ResetIdentityPasswordEvent.DismissError -> resetAction.value = AsyncAction.Uninitialized
|
||||
}
|
||||
}
|
||||
|
||||
return ResetIdentityPasswordState(
|
||||
resetAction = resetAction.value,
|
||||
eventSink = ::handleEvent
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.reset(password: String, action: MutableState<AsyncAction<Unit>>) = launch(dispatchers.io) {
|
||||
suspend {
|
||||
identityPasswordResetHandle.resetPassword(password).getOrThrow()
|
||||
}.runCatchingUpdatingState(action)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,24 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
data class ResetIdentityPasswordState(
|
||||
val resetAction: AsyncAction<Unit>,
|
||||
val eventSink: (ResetIdentityPasswordEvent) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,38 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
||||
class ResetIdentityPasswordStateProvider : PreviewParameterProvider<ResetIdentityPasswordState> {
|
||||
override val values: Sequence<ResetIdentityPasswordState>
|
||||
get() = sequenceOf(
|
||||
aResetIdentityPasswordState(),
|
||||
aResetIdentityPasswordState(resetAction = AsyncAction.Loading),
|
||||
aResetIdentityPasswordState(resetAction = AsyncAction.Success(Unit)),
|
||||
aResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("Failed"))),
|
||||
)
|
||||
}
|
||||
|
||||
private fun aResetIdentityPasswordState(
|
||||
resetAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
|
||||
eventSink: (ResetIdentityPasswordEvent) -> Unit = {},
|
||||
) = ResetIdentityPasswordState(
|
||||
resetAction = resetAction,
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
|
@ -0,0 +1,130 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalFocusManager
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.input.PasswordVisualTransformation
|
||||
import androidx.compose.ui.text.input.VisualTransformation
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.IconButton
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.onTabOrEnterKeyFocusNext
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
@Composable
|
||||
fun ResetIdentityPasswordView(
|
||||
state: ResetIdentityPasswordState,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val passwordState = textFieldState(stateValue = "")
|
||||
FlowStepPage(
|
||||
modifier = modifier,
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.LockSolid()),
|
||||
title = stringResource(R.string.screen_reset_encryption_password_title),
|
||||
subTitle = stringResource(R.string.screen_reset_encryption_password_subtitle),
|
||||
onBackClick = onBack,
|
||||
content = {
|
||||
Content(
|
||||
text = passwordState.value,
|
||||
onTextChange = { newText ->
|
||||
if (state.resetAction.isFailure()) {
|
||||
state.eventSink(ResetIdentityPasswordEvent.DismissError)
|
||||
}
|
||||
passwordState.value = newText
|
||||
},
|
||||
hasError = state.resetAction.isFailure(),
|
||||
)
|
||||
},
|
||||
buttons = {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_reset_identity),
|
||||
onClick = { state.eventSink(ResetIdentityPasswordEvent.Reset(passwordState.value)) },
|
||||
destructive = true,
|
||||
enabled = passwordState.value.isNotEmpty(),
|
||||
)
|
||||
}
|
||||
)
|
||||
|
||||
// On success we need to wait until the screen is automatically dismissed, so we keep the progress dialog
|
||||
if (state.resetAction.isLoading() || state.resetAction.isSuccess()) {
|
||||
ProgressDialog()
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content(text: String, onTextChange: (String) -> Unit, hasError: Boolean) {
|
||||
var showPassword by remember { mutableStateOf(false) }
|
||||
OutlinedTextField(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.onTabOrEnterKeyFocusNext(LocalFocusManager.current),
|
||||
value = text,
|
||||
onValueChange = onTextChange,
|
||||
label = { Text(stringResource(CommonStrings.common_password)) },
|
||||
placeholder = { Text(stringResource(R.string.screen_reset_encryption_password_placeholder)) },
|
||||
singleLine = true,
|
||||
visualTransformation = if (showPassword) VisualTransformation.None else PasswordVisualTransformation(),
|
||||
trailingIcon = {
|
||||
val image =
|
||||
if (showPassword) CompoundIcons.VisibilityOn() else CompoundIcons.VisibilityOff()
|
||||
val description =
|
||||
if (showPassword) stringResource(CommonStrings.a11y_hide_password) else stringResource(CommonStrings.a11y_show_password)
|
||||
|
||||
IconButton(onClick = { showPassword = !showPassword }) {
|
||||
Icon(imageVector = image, description)
|
||||
}
|
||||
},
|
||||
isError = hasError,
|
||||
supportingText = if (hasError) {
|
||||
{ Text(stringResource(R.string.screen_reset_encryption_password_error)) }
|
||||
} else {
|
||||
null
|
||||
}
|
||||
)
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ResetIdentityPasswordViewPreview(@PreviewParameter(ResetIdentityPasswordStateProvider::class) state: ResetIdentityPasswordState) {
|
||||
ElementPreview {
|
||||
ResetIdentityPasswordView(
|
||||
state = state,
|
||||
onBack = {}
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
sealed interface ResetIdentityRootEvent {
|
||||
data object Continue : ResetIdentityRootEvent
|
||||
data object DismissDialog : ResetIdentityRootEvent
|
||||
}
|
||||
|
|
@ -0,0 +1,51 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.bumble.appyx.core.plugin.Plugin
|
||||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.anvilannotations.ContributesNode
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
class ResetIdentityRootNode @AssistedInject constructor(
|
||||
@Assisted buildContext: BuildContext,
|
||||
@Assisted plugins: List<Plugin>,
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : Plugin {
|
||||
fun onContinue()
|
||||
}
|
||||
|
||||
private val presenter = ResetIdentityRootPresenter()
|
||||
private val callback: Callback = plugins.filterIsInstance<Callback>().first()
|
||||
|
||||
@Composable
|
||||
override fun View(modifier: Modifier) {
|
||||
val state = presenter.present()
|
||||
ResetIdentityRootView(
|
||||
modifier = modifier,
|
||||
state = state,
|
||||
onContinue = callback::onContinue,
|
||||
onBack = ::navigateUp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,43 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
|
||||
class ResetIdentityRootPresenter : Presenter<ResetIdentityRootState> {
|
||||
@Composable
|
||||
override fun present(): ResetIdentityRootState {
|
||||
var displayConfirmDialog by remember { mutableStateOf(false) }
|
||||
|
||||
fun handleEvent(event: ResetIdentityRootEvent) {
|
||||
displayConfirmDialog = when (event) {
|
||||
ResetIdentityRootEvent.Continue -> true
|
||||
ResetIdentityRootEvent.DismissDialog -> false
|
||||
}
|
||||
}
|
||||
|
||||
return ResetIdentityRootState(
|
||||
displayConfirmationDialog = displayConfirmDialog,
|
||||
eventSink = ::handleEvent
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,22 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
data class ResetIdentityRootState(
|
||||
val displayConfirmationDialog: Boolean,
|
||||
val eventSink: (ResetIdentityRootEvent) -> Unit,
|
||||
)
|
||||
|
|
@ -0,0 +1,33 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
|
||||
class ResetIdentityRootStateProvider : PreviewParameterProvider<ResetIdentityRootState> {
|
||||
override val values: Sequence<ResetIdentityRootState>
|
||||
get() = sequenceOf(
|
||||
ResetIdentityRootState(
|
||||
displayConfirmationDialog = false,
|
||||
eventSink = {}
|
||||
),
|
||||
ResetIdentityRootState(
|
||||
displayConfirmationDialog = true,
|
||||
eventSink = {}
|
||||
)
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,152 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.unit.dp
|
||||
import io.element.android.compound.theme.ElementTheme
|
||||
import io.element.android.compound.tokens.generated.CompoundIcons
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
|
||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
|
||||
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
|
||||
@Composable
|
||||
fun ResetIdentityRootView(
|
||||
state: ResetIdentityRootState,
|
||||
onContinue: () -> Unit,
|
||||
onBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
FlowStepPage(
|
||||
modifier = modifier,
|
||||
iconStyle = BigIcon.Style.AlertSolid,
|
||||
title = stringResource(R.string.screen_encryption_reset_title),
|
||||
subTitle = stringResource(R.string.screen_encryption_reset_subtitle),
|
||||
isScrollable = true,
|
||||
content = { Content() },
|
||||
buttons = {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(id = CommonStrings.action_continue),
|
||||
onClick = { state.eventSink(ResetIdentityRootEvent.Continue) },
|
||||
destructive = true,
|
||||
)
|
||||
},
|
||||
onBackClick = onBack,
|
||||
)
|
||||
|
||||
if (state.displayConfirmationDialog) {
|
||||
ConfirmationDialog(
|
||||
title = stringResource(R.string.screen_reset_encryption_confirmation_alert_title),
|
||||
content = stringResource(R.string.screen_reset_encryption_confirmation_alert_subtitle),
|
||||
submitText = stringResource(R.string.screen_reset_encryption_confirmation_alert_action),
|
||||
onSubmitClick = {
|
||||
state.eventSink(ResetIdentityRootEvent.DismissDialog)
|
||||
onContinue()
|
||||
},
|
||||
destructiveSubmit = true,
|
||||
onDismiss = { state.eventSink(ResetIdentityRootEvent.DismissDialog) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Content() {
|
||||
Column(
|
||||
modifier = Modifier.padding(top = 8.dp, bottom = 40.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
InfoListOrganism(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
items = persistentListOf(
|
||||
InfoListItem(
|
||||
message = stringResource(R.string.screen_encryption_reset_bullet_1),
|
||||
iconComposable = {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.Check(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconSuccessPrimary,
|
||||
)
|
||||
},
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(R.string.screen_encryption_reset_bullet_2),
|
||||
iconComposable = {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
)
|
||||
},
|
||||
),
|
||||
InfoListItem(
|
||||
message = stringResource(R.string.screen_encryption_reset_bullet_3),
|
||||
iconComposable = {
|
||||
Icon(
|
||||
modifier = Modifier.size(20.dp),
|
||||
imageVector = CompoundIcons.Close(),
|
||||
contentDescription = null,
|
||||
tint = ElementTheme.colors.iconCriticalPrimary,
|
||||
)
|
||||
},
|
||||
),
|
||||
),
|
||||
backgroundColor = ElementTheme.colors.bgActionSecondaryHovered,
|
||||
)
|
||||
|
||||
Text(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_encryption_reset_footer),
|
||||
style = ElementTheme.typography.fontBodyMdMedium,
|
||||
color = ElementTheme.colors.textActionPrimary,
|
||||
textAlign = TextAlign.Center,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@PreviewsDayNight
|
||||
@Composable
|
||||
internal fun ResetIdentityRootViewPreview(@PreviewParameter(ResetIdentityRootStateProvider::class) state: ResetIdentityRootState) {
|
||||
ElementPreview {
|
||||
ResetIdentityRootView(
|
||||
state = state,
|
||||
onContinue = {},
|
||||
onBack = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -16,6 +16,12 @@
|
|||
<string name="screen_create_new_recovery_key_list_item_4">"Follow the instructions to create a new recovery key"</string>
|
||||
<string name="screen_create_new_recovery_key_list_item_5">"Save your new recovery key in a password manager or encrypted note"</string>
|
||||
<string name="screen_create_new_recovery_key_title">"Reset the encryption for your account using another device"</string>
|
||||
<string name="screen_encryption_reset_bullet_1">"Your account details, contacts, preferences, and chat list will be kept"</string>
|
||||
<string name="screen_encryption_reset_bullet_2">"You will lose your existing message history"</string>
|
||||
<string name="screen_encryption_reset_bullet_3">"You will need to verify all your existing devices and contacts again"</string>
|
||||
<string name="screen_encryption_reset_footer">"Only reset your identity if you don’t have access to another signed-in device and you’ve lost your recovery key."</string>
|
||||
<string name="screen_encryption_reset_subtitle">"If you’re not signed in to any other devices and you’ve lost your recovery key, then you’ll need to reset your identity to continue using the app. "</string>
|
||||
<string name="screen_encryption_reset_title">"Reset your identity in case you can’t confirm another way"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_action_turn_off">"Turn off"</string>
|
||||
<string name="screen_key_backup_disable_confirmation_description">"You will lose your encrypted messages if you are signed out of all devices."</string>
|
||||
<string name="screen_key_backup_disable_confirmation_title">"Are you sure you want to turn off backup?"</string>
|
||||
|
|
@ -51,4 +57,11 @@
|
|||
<string name="screen_recovery_key_setup_generate_key_description">"Make sure you can store your recovery key somewhere safe"</string>
|
||||
<string name="screen_recovery_key_setup_success">"Recovery setup successful"</string>
|
||||
<string name="screen_recovery_key_setup_title">"Set up recovery"</string>
|
||||
<string name="screen_reset_encryption_confirmation_alert_action">"Yes, reset now"</string>
|
||||
<string name="screen_reset_encryption_confirmation_alert_subtitle">"This process is irreversible."</string>
|
||||
<string name="screen_reset_encryption_confirmation_alert_title">"Are you sure you want to reset your encryption?"</string>
|
||||
<string name="screen_reset_encryption_password_error">"An unknown error happened. Please check your account password is correct and try again."</string>
|
||||
<string name="screen_reset_encryption_password_placeholder">"Enter…"</string>
|
||||
<string name="screen_reset_encryption_password_subtitle">"Confirm that you want to reset your encryption."</string>
|
||||
<string name="screen_reset_encryption_password_title">"Enter your account password to continue"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -0,0 +1,148 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset
|
||||
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
|
||||
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceUntilIdle
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ResetIdentityFlowManagerTest {
|
||||
@Test
|
||||
fun `getResetHandle - emits a reset handle`() = runTest {
|
||||
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(FakeIdentityPasswordResetHandle()) }
|
||||
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
|
||||
val flowManager = createFlowManager(encryptionService = encryptionService)
|
||||
|
||||
flowManager.getResetHandle().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
assertThat(awaitItem().isSuccess()).isTrue()
|
||||
startResetLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getResetHandle - om successful handle retrieval returns that same handle`() = runTest {
|
||||
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(FakeIdentityPasswordResetHandle()) }
|
||||
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
|
||||
val flowManager = createFlowManager(encryptionService = encryptionService)
|
||||
|
||||
var result: AsyncData.Success<IdentityResetHandle>? = null
|
||||
flowManager.getResetHandle().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
result = awaitItem() as? AsyncData.Success<IdentityResetHandle>
|
||||
assertThat(result).isNotNull()
|
||||
}
|
||||
|
||||
flowManager.getResetHandle().test {
|
||||
assertThat(awaitItem()).isSameInstanceAs(result)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getResetHandle - will fail if it receives a null reset handle`() = runTest {
|
||||
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(null) }
|
||||
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
|
||||
val flowManager = createFlowManager(encryptionService = encryptionService)
|
||||
|
||||
flowManager.getResetHandle().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
assertThat(awaitItem().isFailure()).isTrue()
|
||||
startResetLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getResetHandle - fails gracefully when receiving an exception from the encryption service`() = runTest {
|
||||
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.failure(IllegalStateException("Failure")) }
|
||||
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
|
||||
val flowManager = createFlowManager(encryptionService = encryptionService)
|
||||
|
||||
flowManager.getResetHandle().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
assertThat(awaitItem().isFailure()).isTrue()
|
||||
startResetLambda.assertions().isCalledOnce()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `cancel - resets the state and calls cancel on the reset handle`() = runTest {
|
||||
val cancelLambda = lambdaRecorder<Unit> { }
|
||||
val resetHandle = FakeIdentityPasswordResetHandle(cancelLambda = cancelLambda)
|
||||
val startResetLambda = lambdaRecorder<Result<IdentityResetHandle?>> { Result.success(resetHandle) }
|
||||
val encryptionService = FakeEncryptionService(startIdentityResetLambda = startResetLambda)
|
||||
val flowManager = createFlowManager(encryptionService = encryptionService)
|
||||
|
||||
flowManager.getResetHandle().test {
|
||||
assertThat(awaitItem().isLoading()).isTrue()
|
||||
assertThat(awaitItem().isSuccess()).isTrue()
|
||||
|
||||
flowManager.cancel()
|
||||
cancelLambda.assertions().isCalledOnce()
|
||||
assertThat(awaitItem().isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `whenResetIsDone - will trigger the lambda when verification status is verified`() = runTest {
|
||||
val verificationService = FakeSessionVerificationService()
|
||||
val flowManager = createFlowManager(sessionVerificationService = verificationService)
|
||||
var isDone = false
|
||||
|
||||
flowManager.whenResetIsDone {
|
||||
isDone = true
|
||||
}
|
||||
|
||||
assertThat(isDone).isFalse()
|
||||
|
||||
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Unknown)
|
||||
advanceUntilIdle()
|
||||
assertThat(isDone).isFalse()
|
||||
|
||||
verificationService.emitVerifiedStatus(SessionVerifiedStatus.NotVerified)
|
||||
advanceUntilIdle()
|
||||
assertThat(isDone).isFalse()
|
||||
|
||||
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
|
||||
advanceUntilIdle()
|
||||
assertThat(isDone).isTrue()
|
||||
}
|
||||
|
||||
private fun TestScope.createFlowManager(
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
client: FakeMatrixClient = FakeMatrixClient(encryptionService = encryptionService),
|
||||
sessionCoroutineScope: CoroutineScope = this,
|
||||
sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(),
|
||||
) = ResetIdentityFlowManager(
|
||||
matrixClient = client,
|
||||
sessionCoroutineScope = sessionCoroutineScope,
|
||||
sessionVerificationService = sessionVerificationService,
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeIdentityPasswordResetHandle
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ResetIdentityPasswordPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = createPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.resetAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Reset event succeeds`() = runTest {
|
||||
val resetLambda = lambdaRecorder<String, Result<Unit>> { _ -> Result.success(Unit) }
|
||||
val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
|
||||
val presenter = createPresenter(identityResetHandle = resetHandle)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
|
||||
assertThat(awaitItem().resetAction.isLoading()).isTrue()
|
||||
assertThat(awaitItem().resetAction.isSuccess()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Reset event can fail gracefully`() = runTest {
|
||||
val resetLambda = lambdaRecorder<String, Result<Unit>> { _ -> Result.failure(IllegalStateException("Failed")) }
|
||||
val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
|
||||
val presenter = createPresenter(identityResetHandle = resetHandle)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
|
||||
assertThat(awaitItem().resetAction.isLoading()).isTrue()
|
||||
assertThat(awaitItem().resetAction.isFailure()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - DismissError event resets the state`() = runTest {
|
||||
val resetLambda = lambdaRecorder<String, Result<Unit>> { _ -> Result.failure(IllegalStateException("Failed")) }
|
||||
val resetHandle = FakeIdentityPasswordResetHandle(resetPasswordLambda = resetLambda)
|
||||
val presenter = createPresenter(identityResetHandle = resetHandle)
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ResetIdentityPasswordEvent.Reset("password"))
|
||||
assertThat(awaitItem().resetAction.isLoading()).isTrue()
|
||||
assertThat(awaitItem().resetAction.isFailure()).isTrue()
|
||||
|
||||
initialState.eventSink(ResetIdentityPasswordEvent.DismissError)
|
||||
assertThat(awaitItem().resetAction.isUninitialized()).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
private fun TestScope.createPresenter(
|
||||
identityResetHandle: FakeIdentityPasswordResetHandle = FakeIdentityPasswordResetHandle(),
|
||||
) = ResetIdentityPasswordPresenter(
|
||||
identityPasswordResetHandle = identityResetHandle,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
}
|
||||
|
|
@ -0,0 +1,97 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.password
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.compose.ui.test.onNodeWithText
|
||||
import androidx.compose.ui.test.performTextInput
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
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.pressBack
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ResetIdentityPasswordViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `pressing the back HW button invokes the expected callback`() {
|
||||
ensureCalledOnce {
|
||||
rule.setResetPasswordView(
|
||||
ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}),
|
||||
onBack = it,
|
||||
)
|
||||
rule.pressBackKey()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the back navigation button invokes the expected callback`() {
|
||||
ensureCalledOnce {
|
||||
rule.setResetPasswordView(
|
||||
ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = {}),
|
||||
onBack = it,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking 'Reset identity' confirms the reset`() {
|
||||
val eventsRecorder = EventsRecorder<ResetIdentityPasswordEvent>()
|
||||
rule.setResetPasswordView(
|
||||
ResetIdentityPasswordState(resetAction = AsyncAction.Uninitialized, eventSink = eventsRecorder),
|
||||
)
|
||||
rule.onNodeWithText("Password").performTextInput("A password")
|
||||
|
||||
rule.clickOn(CommonStrings.action_reset_identity)
|
||||
|
||||
eventsRecorder.assertSingle(ResetIdentityPasswordEvent.Reset("A password"))
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `modifying the password dismisses the error state`() {
|
||||
val eventsRecorder = EventsRecorder<ResetIdentityPasswordEvent>()
|
||||
rule.setResetPasswordView(
|
||||
ResetIdentityPasswordState(resetAction = AsyncAction.Failure(IllegalStateException("A failure")), eventSink = eventsRecorder),
|
||||
)
|
||||
rule.onNodeWithText("Password").performTextInput("A password")
|
||||
|
||||
eventsRecorder.assertSingle(ResetIdentityPasswordEvent.DismissError)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResetPasswordView(
|
||||
state: ResetIdentityPasswordState,
|
||||
onBack: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
ResetIdentityPasswordView(state = state, onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,65 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
|
||||
class ResetIdentityRootPresenterTest {
|
||||
@Test
|
||||
fun `present - initial state`() = runTest {
|
||||
val presenter = ResetIdentityRootPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
assertThat(initialState.displayConfirmationDialog).isFalse()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - Continue event displays the confirmation dialog`() = runTest {
|
||||
val presenter = ResetIdentityRootPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ResetIdentityRootEvent.Continue)
|
||||
|
||||
assertThat(awaitItem().displayConfirmationDialog).isTrue()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - DismissDialog event hides the confirmation dialog`() = runTest {
|
||||
val presenter = ResetIdentityRootPresenter()
|
||||
moleculeFlow(RecompositionMode.Immediate) {
|
||||
presenter.present()
|
||||
}.test {
|
||||
val initialState = awaitItem()
|
||||
initialState.eventSink(ResetIdentityRootEvent.Continue)
|
||||
assertThat(awaitItem().displayConfirmationDialog).isTrue()
|
||||
|
||||
initialState.eventSink(ResetIdentityRootEvent.DismissDialog)
|
||||
assertThat(awaitItem().displayConfirmationDialog).isFalse()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -0,0 +1,108 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.features.securebackup.impl.reset.root
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
|
||||
import androidx.compose.ui.test.junit4.createAndroidComposeRule
|
||||
import androidx.test.ext.junit.runners.AndroidJUnit4
|
||||
import io.element.android.features.securebackup.impl.R
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
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.pressBack
|
||||
import io.element.android.tests.testutils.pressBackKey
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import org.junit.rules.TestRule
|
||||
import org.junit.runner.RunWith
|
||||
import org.robolectric.annotation.Config
|
||||
|
||||
@RunWith(AndroidJUnit4::class)
|
||||
class ResetIdentityRootViewTest {
|
||||
@get:Rule
|
||||
val rule = createAndroidComposeRule<ComponentActivity>()
|
||||
|
||||
@Test
|
||||
fun `pressing the back HW button invokes the expected callback`() {
|
||||
ensureCalledOnce {
|
||||
rule.setResetRootView(
|
||||
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}),
|
||||
onBack = it,
|
||||
)
|
||||
rule.pressBackKey()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on the back navigation button invokes the expected callback`() {
|
||||
ensureCalledOnce {
|
||||
rule.setResetRootView(
|
||||
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = {}),
|
||||
onBack = it,
|
||||
)
|
||||
rule.pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@Config(qualifiers = "h720dp")
|
||||
fun `clicking Continue displays the confirmation dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ResetIdentityRootEvent>()
|
||||
rule.setResetRootView(
|
||||
ResetIdentityRootState(displayConfirmationDialog = false, eventSink = eventsRecorder),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_continue)
|
||||
|
||||
eventsRecorder.assertSingle(ResetIdentityRootEvent.Continue)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking 'Yes, reset now' confirms the reset`() {
|
||||
ensureCalledOnce {
|
||||
rule.setResetRootView(
|
||||
ResetIdentityRootState(displayConfirmationDialog = true, eventSink = {}),
|
||||
onContinue = it,
|
||||
)
|
||||
rule.clickOn(R.string.screen_reset_encryption_confirmation_alert_action)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking Cancel dismisses the dialog`() {
|
||||
val eventsRecorder = EventsRecorder<ResetIdentityRootEvent>()
|
||||
rule.setResetRootView(
|
||||
ResetIdentityRootState(displayConfirmationDialog = true, eventSink = eventsRecorder),
|
||||
)
|
||||
|
||||
rule.clickOn(CommonStrings.action_cancel)
|
||||
eventsRecorder.assertSingle(ResetIdentityRootEvent.DismissDialog)
|
||||
}
|
||||
}
|
||||
|
||||
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setResetRootView(
|
||||
state: ResetIdentityRootState,
|
||||
onBack: () -> Unit = EnsureNeverCalled(),
|
||||
onContinue: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
ResetIdentityRootView(state = state, onContinue = onContinue, onBack = onBack)
|
||||
}
|
||||
}
|
||||
|
|
@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
|
|||
|
||||
interface Callback : Plugin {
|
||||
fun onEnterRecoveryKey()
|
||||
fun onResetKey()
|
||||
fun onDone()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -43,6 +43,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
|
|||
state = state,
|
||||
modifier = modifier,
|
||||
onEnterRecoveryKey = callback::onEnterRecoveryKey,
|
||||
onResetKey = callback::onResetKey,
|
||||
onFinish = callback::onDone,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -20,6 +20,7 @@ import androidx.activity.compose.BackHandler
|
|||
import androidx.compose.foundation.Image
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.ColumnScope
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxHeight
|
||||
|
|
@ -53,6 +54,7 @@ import io.element.android.libraries.designsystem.components.PageTitle
|
|||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
|
|
@ -66,6 +68,7 @@ import io.element.android.features.verifysession.impl.VerifySelfSessionState.Ver
|
|||
fun VerifySelfSessionView(
|
||||
state: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onResetKey: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
|
|
@ -115,6 +118,7 @@ fun VerifySelfSessionView(
|
|||
goBack = ::resetFlow,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onFinish = onFinish,
|
||||
onResetKey = onResetKey,
|
||||
)
|
||||
}
|
||||
) {
|
||||
|
|
@ -226,6 +230,7 @@ private fun EmojiItemView(emoji: VerificationEmoji, modifier: Modifier = Modifie
|
|||
private fun BottomMenu(
|
||||
screenState: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit,
|
||||
onResetKey: () -> Unit,
|
||||
goBack: () -> Unit,
|
||||
onFinish: () -> Unit,
|
||||
) {
|
||||
|
|
@ -236,42 +241,72 @@ private fun BottomMenu(
|
|||
|
||||
when (verificationViewState) {
|
||||
is FlowStep.Initial -> {
|
||||
if (verificationViewState.isLastDevice) {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onPositiveButtonClick = onEnterRecoveryKey,
|
||||
)
|
||||
} else {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_identity_use_another_device),
|
||||
onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
negativeButtonTitle = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onNegativeButtonClick = onEnterRecoveryKey,
|
||||
BottomMenu {
|
||||
if (verificationViewState.isLastDevice) {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onClick = onEnterRecoveryKey,
|
||||
)
|
||||
} else {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_use_another_device),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
)
|
||||
OutlinedButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_enter_recovery_key),
|
||||
onClick = onEnterRecoveryKey,
|
||||
)
|
||||
}
|
||||
// This option should always be displayed
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_confirmation_cannot_confirm),
|
||||
onClick = onResetKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Canceled -> {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_session_verification_positive_button_canceled),
|
||||
onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
negativeButtonTitle = stringResource(CommonStrings.action_cancel),
|
||||
onNegativeButtonClick = goBack,
|
||||
)
|
||||
BottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_positive_button_canceled),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.RequestVerification) },
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = goBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Ready -> {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(CommonStrings.action_start),
|
||||
onPositiveButtonClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
|
||||
negativeButtonTitle = stringResource(CommonStrings.action_cancel),
|
||||
onNegativeButtonClick = goBack,
|
||||
)
|
||||
BottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_start),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.StartSasVerification) },
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_cancel),
|
||||
onClick = goBack,
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.AwaitingOtherDeviceResponse -> {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
onPositiveButtonClick = {},
|
||||
isLoading = true,
|
||||
)
|
||||
BottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_waiting_on_other_device),
|
||||
onClick = {},
|
||||
showProgress = true,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(40.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Verifying -> {
|
||||
val positiveButtonTitle = if (isVerifying) {
|
||||
|
|
@ -279,23 +314,34 @@ private fun BottomMenu(
|
|||
} else {
|
||||
stringResource(R.string.screen_session_verification_they_match)
|
||||
}
|
||||
BottomMenu(
|
||||
positiveButtonTitle = positiveButtonTitle,
|
||||
onPositiveButtonClick = {
|
||||
if (!isVerifying) {
|
||||
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
}
|
||||
},
|
||||
negativeButtonTitle = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||
onNegativeButtonClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
|
||||
isLoading = isVerifying,
|
||||
)
|
||||
BottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = positiveButtonTitle,
|
||||
showProgress = isVerifying,
|
||||
onClick = {
|
||||
if (!isVerifying) {
|
||||
eventSink(VerifySelfSessionViewEvents.ConfirmVerification)
|
||||
}
|
||||
},
|
||||
)
|
||||
TextButton(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_session_verification_they_dont_match),
|
||||
onClick = { eventSink(VerifySelfSessionViewEvents.DeclineVerification) },
|
||||
)
|
||||
}
|
||||
}
|
||||
is FlowStep.Completed -> {
|
||||
BottomMenu(
|
||||
positiveButtonTitle = stringResource(CommonStrings.action_continue),
|
||||
onPositiveButtonClick = onFinish,
|
||||
)
|
||||
BottomMenu {
|
||||
Button(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(CommonStrings.action_continue),
|
||||
onClick = onFinish,
|
||||
)
|
||||
// Placeholder so the 1st button keeps its vertical position
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
}
|
||||
is FlowStep.Skipped -> return
|
||||
}
|
||||
|
|
@ -303,35 +349,13 @@ private fun BottomMenu(
|
|||
|
||||
@Composable
|
||||
private fun BottomMenu(
|
||||
positiveButtonTitle: String?,
|
||||
onPositiveButtonClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
negativeButtonTitle: String? = null,
|
||||
negativeButtonEnabled: Boolean = negativeButtonTitle != null,
|
||||
onNegativeButtonClick: () -> Unit = {},
|
||||
isLoading: Boolean = false,
|
||||
buttons: @Composable ColumnScope.() -> Unit,
|
||||
) {
|
||||
ButtonColumnMolecule(
|
||||
modifier = modifier.padding(bottom = 16.dp)
|
||||
) {
|
||||
if (positiveButtonTitle != null) {
|
||||
Button(
|
||||
text = positiveButtonTitle,
|
||||
showProgress = isLoading,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onPositiveButtonClick,
|
||||
)
|
||||
}
|
||||
if (negativeButtonTitle != null) {
|
||||
TextButton(
|
||||
text = negativeButtonTitle,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
onClick = onNegativeButtonClick,
|
||||
enabled = negativeButtonEnabled,
|
||||
)
|
||||
} else {
|
||||
Spacer(modifier = Modifier.height(48.dp))
|
||||
}
|
||||
buttons()
|
||||
}
|
||||
}
|
||||
|
||||
|
|
@ -341,6 +365,7 @@ internal fun VerifySelfSessionViewPreview(@PreviewParameter(VerifySelfSessionSta
|
|||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onEnterRecoveryKey = {},
|
||||
onResetKey = {},
|
||||
onFinish = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -217,12 +217,14 @@ class VerifySelfSessionViewTest {
|
|||
state: VerifySelfSessionState,
|
||||
onEnterRecoveryKey: () -> Unit = EnsureNeverCalled(),
|
||||
onFinished: () -> Unit = EnsureNeverCalled(),
|
||||
onResetKey: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
VerifySelfSessionView(
|
||||
state = state,
|
||||
onEnterRecoveryKey = onEnterRecoveryKey,
|
||||
onFinish = onFinished,
|
||||
onResetKey = onResetKey,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -47,12 +47,19 @@ fun Activity.openUrlInChromeCustomTab(
|
|||
true -> CustomTabsIntent.COLOR_SCHEME_DARK
|
||||
}
|
||||
)
|
||||
.setShareIdentityEnabled(false)
|
||||
// Note: setting close button icon does not work
|
||||
// .setCloseButtonIcon(BitmapFactory.decodeResource(context.resources, R.drawable.ic_back_24dp))
|
||||
// .setStartAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||
// .setExitAnimations(context, R.anim.enter_fade_in, R.anim.exit_fade_out)
|
||||
.apply { session?.let { setSession(it) } }
|
||||
.build()
|
||||
.apply {
|
||||
// Disable download button
|
||||
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_DOWNLOAD_BUTTON", true)
|
||||
// Disable bookmark button
|
||||
intent.putExtra("org.chromium.chrome.browser.customtabs.EXTRA_DISABLE_START_BUTTON", true)
|
||||
}
|
||||
.launchUrl(this, Uri.parse(url))
|
||||
} catch (activityNotFoundException: ActivityNotFoundException) {
|
||||
// TODO context.toast(R.string.error_no_external_application_found)
|
||||
|
|
|
|||
|
|
@ -62,4 +62,52 @@ interface EncryptionService {
|
|||
* called the fingerprint of the device.
|
||||
*/
|
||||
suspend fun deviceEd25519(): String?
|
||||
|
||||
/**
|
||||
* Starts the identity reset process. This will return a handle that can be used to reset the identity.
|
||||
*/
|
||||
suspend fun startIdentityReset(): Result<IdentityResetHandle?>
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle to reset the user's identity.
|
||||
*/
|
||||
interface IdentityResetHandle {
|
||||
/**
|
||||
* Cancel the reset process and drops the existing handle in the SDK.
|
||||
*/
|
||||
suspend fun cancel()
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle to reset the user's identity with a password login type.
|
||||
*/
|
||||
interface IdentityPasswordResetHandle : IdentityResetHandle {
|
||||
/**
|
||||
* Reset the password of the user.
|
||||
*
|
||||
* This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is
|
||||
* called, or the identity is reset.
|
||||
*
|
||||
* @param password the current password, which will be validated before the process takes place.
|
||||
*/
|
||||
suspend fun resetPassword(password: String): Result<Unit>
|
||||
}
|
||||
|
||||
/**
|
||||
* A handle to reset the user's identity with an OIDC login type.
|
||||
*/
|
||||
interface IdentityOidcResetHandle : IdentityResetHandle {
|
||||
/**
|
||||
* The URL to open in a webview/custom tab to reset the identity.
|
||||
*/
|
||||
val url: String
|
||||
|
||||
/**
|
||||
* Reset the identity using the OIDC flow.
|
||||
*
|
||||
* This method will block the coroutine it's running on and keep polling indefinitely until either the coroutine is cancelled, the [cancel] method is
|
||||
* called, or the identity is reset.
|
||||
*/
|
||||
suspend fun resetOidc(): Result<Unit>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -18,10 +18,12 @@ package io.element.android.libraries.matrix.impl.encryption
|
|||
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupState
|
||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.impl.sync.RustSyncService
|
||||
|
|
@ -54,6 +56,7 @@ internal class RustEncryptionService(
|
|||
private val dispatchers: CoroutineDispatchers,
|
||||
) : EncryptionService {
|
||||
private val service: Encryption = client.encryption()
|
||||
private val sessionId = SessionId(client.session().userId)
|
||||
|
||||
private val enableRecoveryProgressMapper = EnableRecoveryProgressMapper()
|
||||
private val backupUploadStateMapper = BackupUploadStateMapper()
|
||||
|
|
@ -198,4 +201,12 @@ internal class RustEncryptionService(
|
|||
override suspend fun deviceEd25519(): String? {
|
||||
return service.ed25519Key()
|
||||
}
|
||||
|
||||
override suspend fun startIdentityReset(): Result<IdentityResetHandle?> {
|
||||
return runCatching {
|
||||
service.resetIdentity()?.let { handle ->
|
||||
RustIdentityResetHandleFactory.create(sessionId, handle)
|
||||
}?.getOrNull()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,71 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.impl.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import org.matrix.rustcomponents.sdk.AuthData
|
||||
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
|
||||
import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType
|
||||
|
||||
object RustIdentityResetHandleFactory {
|
||||
fun create(
|
||||
userId: UserId,
|
||||
identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle
|
||||
): Result<IdentityResetHandle> {
|
||||
return runCatching {
|
||||
when (val authType = identityResetHandle.authType()) {
|
||||
is CrossSigningResetAuthType.Oidc -> RustOidcIdentityResetHandle(identityResetHandle, authType.info.approvalUrl)
|
||||
// User interactive authentication (user + password)
|
||||
CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
class RustPasswordIdentityResetHandle(
|
||||
private val userId: UserId,
|
||||
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
|
||||
) : IdentityPasswordResetHandle {
|
||||
override suspend fun resetPassword(password: String): Result<Unit> {
|
||||
return runCatching { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) }
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
identityResetHandle.cancelAndDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
class RustOidcIdentityResetHandle(
|
||||
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
|
||||
override val url: String,
|
||||
) : IdentityOidcResetHandle {
|
||||
override suspend fun resetOidc(): Result<Unit> {
|
||||
return runCatching { identityResetHandle.reset(null) }
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
identityResetHandle.cancelAndDestroy()
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun org.matrix.rustcomponents.sdk.IdentityResetHandle.cancelAndDestroy() {
|
||||
cancel()
|
||||
destroy()
|
||||
}
|
||||
|
|
@ -20,13 +20,17 @@ import io.element.android.libraries.matrix.api.encryption.BackupState
|
|||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||
import io.element.android.libraries.matrix.api.encryption.EnableRecoveryProgress
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.tests.testutils.lambda.lambdaError
|
||||
import io.element.android.tests.testutils.simulateLongTask
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
|
||||
class FakeEncryptionService : EncryptionService {
|
||||
class FakeEncryptionService(
|
||||
var startIdentityResetLambda: () -> Result<IdentityResetHandle?> = { lambdaError() },
|
||||
) : EncryptionService {
|
||||
private var disableRecoveryFailure: Exception? = null
|
||||
override val backupStateStateFlow: MutableStateFlow<BackupState> = MutableStateFlow(BackupState.UNKNOWN)
|
||||
override val recoveryStateStateFlow: MutableStateFlow<RecoveryState> = MutableStateFlow(RecoveryState.UNKNOWN)
|
||||
|
|
@ -118,6 +122,10 @@ class FakeEncryptionService : EncryptionService {
|
|||
enableRecoveryProgressStateFlow.emit(state)
|
||||
}
|
||||
|
||||
override suspend fun startIdentityReset(): Result<IdentityResetHandle?> {
|
||||
return startIdentityResetLambda()
|
||||
}
|
||||
|
||||
companion object {
|
||||
const val FAKE_RECOVERY_KEY = "fake"
|
||||
}
|
||||
|
|
|
|||
|
|
@ -0,0 +1,47 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.matrix.test.encryption
|
||||
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityOidcResetHandle
|
||||
import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetHandle
|
||||
|
||||
class FakeIdentityOidcResetHandle(
|
||||
override val url: String = "",
|
||||
var resetOidcLambda: () -> Result<Unit> = { error("Not implemented") },
|
||||
var cancelLambda: () -> Unit = { error("Not implemented") },
|
||||
) : IdentityOidcResetHandle {
|
||||
override suspend fun resetOidc(): Result<Unit> {
|
||||
return resetOidcLambda()
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
cancelLambda()
|
||||
}
|
||||
}
|
||||
|
||||
class FakeIdentityPasswordResetHandle(
|
||||
var resetPasswordLambda: (String) -> Result<Unit> = { _ -> error("Not implemented") },
|
||||
var cancelLambda: () -> Unit = { error("Not implemented") },
|
||||
) : IdentityPasswordResetHandle {
|
||||
override suspend fun resetPassword(password: String): Result<Unit> {
|
||||
return resetPasswordLambda(password)
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
cancelLambda()
|
||||
}
|
||||
}
|
||||
27
libraries/oidc/api/build.gradle.kts
Normal file
27
libraries/oidc/api/build.gradle.kts
Normal file
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
plugins {
|
||||
id("io.element.android-library")
|
||||
id("kotlin-parcelize")
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.oidc.api"
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.api.oidc
|
||||
package io.element.android.libraries.oidc.api
|
||||
|
||||
sealed interface OidcAction {
|
||||
data object GoBack : OidcAction
|
||||
|
|
@ -14,8 +14,12 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.api.oidc
|
||||
package io.element.android.libraries.oidc.api
|
||||
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
|
||||
interface OidcActionFlow {
|
||||
fun post(oidcAction: OidcAction)
|
||||
suspend fun collect(collector: FlowCollector<OidcAction?>)
|
||||
fun reset()
|
||||
}
|
||||
|
|
@ -0,0 +1,27 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.api
|
||||
|
||||
import android.app.Activity
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
|
||||
interface OidcEntryPoint {
|
||||
fun canUseCustomTab(): Boolean
|
||||
fun openUrlInCustomTab(activity: Activity, darkTheme: Boolean, url: String)
|
||||
fun createFallbackWebViewNode(parentNode: Node, buildContext: BuildContext, url: String): Node
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.api.oidc
|
||||
package io.element.android.libraries.oidc.api
|
||||
|
||||
import android.content.Intent
|
||||
|
||||
63
libraries/oidc/impl/build.gradle.kts
Normal file
63
libraries/oidc/impl/build.gradle.kts
Normal file
|
|
@ -0,0 +1,63 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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.
|
||||
*/
|
||||
|
||||
plugins {
|
||||
id("io.element.android-compose-library")
|
||||
alias(libs.plugins.anvil)
|
||||
id("kotlin-parcelize")
|
||||
alias(libs.plugins.kotlin.serialization)
|
||||
}
|
||||
|
||||
android {
|
||||
namespace = "io.element.android.libraries.oidc.impl"
|
||||
|
||||
testOptions {
|
||||
unitTests {
|
||||
isIncludeAndroidResources = true
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
anvil {
|
||||
generateDaggerFactories.set(true)
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation(projects.anvilannotations)
|
||||
implementation(projects.appconfig)
|
||||
anvil(projects.anvilcodegen)
|
||||
implementation(projects.libraries.core)
|
||||
implementation(projects.libraries.androidutils)
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
implementation(projects.libraries.designsystem)
|
||||
implementation(projects.libraries.uiStrings)
|
||||
implementation(libs.androidx.browser)
|
||||
implementation(platform(libs.network.retrofit.bom))
|
||||
implementation(libs.network.retrofit)
|
||||
implementation(libs.serialization.json)
|
||||
api(projects.libraries.oidc.api)
|
||||
|
||||
testImplementation(libs.test.junit)
|
||||
testImplementation(libs.androidx.test.ext.junit)
|
||||
testImplementation(libs.coroutines.test)
|
||||
testImplementation(libs.molecule.runtime)
|
||||
testImplementation(libs.test.truth)
|
||||
testImplementation(libs.test.turbine)
|
||||
testImplementation(projects.libraries.matrix.test)
|
||||
testImplementation(projects.libraries.permissions.test)
|
||||
testImplementation(projects.tests.testutils)
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
package io.element.android.libraries.oidc.impl
|
||||
|
||||
import android.content.Context
|
||||
import androidx.browser.customtabs.CustomTabsClient
|
||||
|
|
@ -0,0 +1,49 @@
|
|||
/*
|
||||
* Copyright (c) 2024 New Vector Ltd
|
||||
*
|
||||
* 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
|
||||
*
|
||||
* https://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.
|
||||
*/
|
||||
|
||||
package io.element.android.libraries.oidc.impl
|
||||
|
||||
import android.app.Activity
|
||||
import com.bumble.appyx.core.modality.BuildContext
|
||||
import com.bumble.appyx.core.node.Node
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
|
||||
import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.oidc.api.OidcEntryPoint
|
||||
import io.element.android.libraries.oidc.impl.webview.OidcNode
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultOidcEntryPoint @Inject constructor(
|
||||
private val customTabAvailabilityChecker: CustomTabAvailabilityChecker,
|
||||
) : OidcEntryPoint {
|
||||
override fun canUseCustomTab(): Boolean {
|
||||
return customTabAvailabilityChecker.supportCustomTab()
|
||||
}
|
||||
|
||||
override fun openUrlInCustomTab(activity: Activity, darkTheme: Boolean, url: String) {
|
||||
assert(canUseCustomTab()) { "Custom tab is not supported in this device." }
|
||||
activity.openUrlInChromeCustomTab(null, darkTheme, url)
|
||||
}
|
||||
|
||||
override fun createFallbackWebViewNode(parentNode: Node, buildContext: BuildContext, url: String): Node {
|
||||
assert(!canUseCustomTab()) { "Custom tab should be used instead of the fallback node." }
|
||||
val inputs = OidcNode.Inputs(OidcDetails(url))
|
||||
return parentNode.createNode<OidcNode>(buildContext, listOf(inputs))
|
||||
}
|
||||
}
|
||||
|
|
@ -14,13 +14,13 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
package io.element.android.libraries.oidc.impl
|
||||
|
||||
import android.content.Intent
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcIntentResolver
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcIntentResolver
|
||||
import javax.inject.Inject
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
|
|
@ -14,10 +14,10 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
package io.element.android.libraries.oidc.impl
|
||||
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import javax.inject.Inject
|
||||
|
||||
/**
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.customtab
|
||||
package io.element.android.libraries.oidc.impl.customtab
|
||||
|
||||
import android.app.Activity
|
||||
import android.content.ComponentName
|
||||
|
|
@ -14,13 +14,13 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.customtab
|
||||
package io.element.android.libraries.oidc.impl.customtab
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.features.login.api.oidc.OidcActionFlow
|
||||
import io.element.android.libraries.di.AppScope
|
||||
import io.element.android.libraries.di.SingleIn
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcActionFlow
|
||||
import kotlinx.coroutines.flow.FlowCollector
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import javax.inject.Inject
|
||||
|
|
@ -34,11 +34,11 @@ class DefaultOidcActionFlow @Inject constructor() : OidcActionFlow {
|
|||
mutableStateFlow.value = oidcAction
|
||||
}
|
||||
|
||||
suspend fun collect(collector: FlowCollector<OidcAction?>) {
|
||||
override suspend fun collect(collector: FlowCollector<OidcAction?>) {
|
||||
mutableStateFlow.collect(collector)
|
||||
}
|
||||
|
||||
fun reset() {
|
||||
override fun reset() {
|
||||
mutableStateFlow.value = null
|
||||
}
|
||||
}
|
||||
|
|
@ -14,9 +14,9 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
|
||||
sealed interface OidcEvents {
|
||||
data object Cancel : OidcEvents
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Modifier
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
|
|
@ -25,11 +25,11 @@ import androidx.compose.runtime.setValue
|
|||
import dagger.assisted.Assisted
|
||||
import dagger.assisted.AssistedFactory
|
||||
import dagger.assisted.AssistedInject
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
class OidcPresenter @AssistedInject constructor(
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.api.auth.OidcDetails
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
|
|
@ -14,32 +14,39 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import android.annotation.SuppressLint
|
||||
import android.webkit.WebView
|
||||
import androidx.activity.compose.BackHandler
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.statusBarsPadding
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalInspectionMode
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||
import androidx.compose.ui.viewinterop.AndroidView
|
||||
import io.element.android.features.login.impl.oidc.OidcUrlParser
|
||||
import io.element.android.libraries.core.bool.orFalse
|
||||
import io.element.android.libraries.designsystem.components.async.AsyncActionView
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.oidc.impl.OidcUrlParser
|
||||
|
||||
@OptIn(ExperimentalMaterial3Api::class)
|
||||
@Composable
|
||||
fun OidcView(
|
||||
state: OidcState,
|
||||
onNavigateBack: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val isPreview = LocalInspectionMode.current
|
||||
val oidcUrlParser = remember { OidcUrlParser() }
|
||||
var webView by remember { mutableStateOf<WebView?>(null) }
|
||||
fun shouldOverrideUrl(url: String): Boolean {
|
||||
|
|
@ -55,7 +62,7 @@ fun OidcView(
|
|||
OidcWebViewClient(::shouldOverrideUrl)
|
||||
}
|
||||
|
||||
BackHandler {
|
||||
fun onBack() {
|
||||
if (webView?.canGoBack().orFalse()) {
|
||||
webView?.goBack()
|
||||
} else {
|
||||
|
|
@ -64,12 +71,35 @@ fun OidcView(
|
|||
}
|
||||
}
|
||||
|
||||
Box(modifier = modifier.statusBarsPadding()) {
|
||||
BackHandler { onBack() }
|
||||
|
||||
Scaffold(
|
||||
modifier = modifier,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = {
|
||||
BackButton(onClick = ::onBack)
|
||||
},
|
||||
)
|
||||
}
|
||||
) { contentPadding ->
|
||||
AndroidView(
|
||||
modifier = Modifier.padding(contentPadding),
|
||||
factory = { context ->
|
||||
WebView(context).apply {
|
||||
webViewClient = oidcWebViewClient
|
||||
loadUrl(state.oidcDetails.url)
|
||||
if (!isPreview) {
|
||||
webViewClient = oidcWebViewClient
|
||||
settings.apply {
|
||||
@SuppressLint("SetJavaScriptEnabled")
|
||||
javaScriptEnabled = true
|
||||
allowContentAccess = true
|
||||
allowFileAccess = true
|
||||
databaseEnabled = true
|
||||
domStorageEnabled = true
|
||||
}
|
||||
loadUrl(state.oidcDetails.url)
|
||||
}
|
||||
}.also {
|
||||
webView = it
|
||||
}
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import android.webkit.WebResourceRequest
|
||||
import android.webkit.WebView
|
||||
|
|
@ -14,7 +14,7 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
fun interface WebViewEventListener {
|
||||
/**
|
||||
|
|
@ -14,11 +14,11 @@
|
|||
* limitations under the License.
|
||||
*/
|
||||
|
||||
package io.element.android.features.login.impl.oidc
|
||||
package io.element.android.libraries.oidc.impl
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.matrix.api.auth.OidcConfig
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import org.junit.Assert
|
||||
import org.junit.Test
|
||||
|
||||
|
|
@ -16,17 +16,17 @@
|
|||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.features.login.impl.oidc.webview
|
||||
package io.element.android.libraries.oidc.impl.webview
|
||||
|
||||
import app.cash.molecule.RecompositionMode
|
||||
import app.cash.molecule.moleculeFlow
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.login.api.oidc.OidcAction
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.matrix.test.A_THROWABLE
|
||||
import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA
|
||||
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
|
||||
import io.element.android.libraries.oidc.api.OidcAction
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.runTest
|
||||
|
|
@ -268,12 +268,6 @@ Reason: %1$s."</string>
|
|||
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
|
||||
<string name="login_initial_device_name_android">"%1$s Android"</string>
|
||||
<string name="preference_rageshake">"Rageshake to report bug"</string>
|
||||
<string name="screen_encryption_reset_bullet_1">"Your account details, contacts, preferences, and chat list will be kept"</string>
|
||||
<string name="screen_encryption_reset_bullet_2">"You will lose your existing message history"</string>
|
||||
<string name="screen_encryption_reset_bullet_3">"You will need to verify all your existing devices and contacts again"</string>
|
||||
<string name="screen_encryption_reset_footer">"Only reset your identity if you don’t have access to another signed-in device and you’ve lost your recovery key."</string>
|
||||
<string name="screen_encryption_reset_subtitle">"If you’re not signed in to any other devices and you’ve lost your recovery key, then you’ll need to reset your identity to continue using the app. "</string>
|
||||
<string name="screen_encryption_reset_title">"Reset your identity in case you can’t confirm another way"</string>
|
||||
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
|
||||
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
|
||||
|
|
@ -284,12 +278,6 @@ Reason: %1$s."</string>
|
|||
<item quantity="other">"%1$d Pinned messages"</item>
|
||||
</plurals>
|
||||
<string name="screen_pinned_timeline_screen_title_empty">"Pinned messages"</string>
|
||||
<string name="screen_reset_encryption_confirmation_alert_action">"Yes, reset now"</string>
|
||||
<string name="screen_reset_encryption_confirmation_alert_subtitle">"This process is irreversible."</string>
|
||||
<string name="screen_reset_encryption_confirmation_alert_title">"Are you sure you want to reset your encryption?"</string>
|
||||
<string name="screen_reset_encryption_password_placeholder">"Enter…"</string>
|
||||
<string name="screen_reset_encryption_password_subtitle">"Confirm that you want to reset your encryption."</string>
|
||||
<string name="screen_reset_encryption_password_title">"Enter your account password to continue"</string>
|
||||
<string name="screen_room_details_pinned_events_row_title">"Pinned messages"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||
|
|
|
|||
|
|
@ -116,6 +116,7 @@ fun DependencyHandlerScope.allLibrariesImpl() {
|
|||
implementation(project(":libraries:mediaviewer:impl"))
|
||||
implementation(project(":libraries:troubleshoot:impl"))
|
||||
implementation(project(":libraries:fullscreenintent:impl"))
|
||||
implementation(project(":libraries:oidc:impl"))
|
||||
}
|
||||
|
||||
fun DependencyHandlerScope.allServicesImpl() {
|
||||
|
|
|
|||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:38b4c852a4bb957a10e24a72b70d7edd89a921983a9426592dc1fc53c1d54d99
|
||||
size 5450
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:9cdc07cc74bf427e7ae0244b97543f65aecb6b2f8fee7958d18906d333b868b8
|
||||
size 8848
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:a0c39aabac135aa3954ac881a43cc2bd79a2e357ec76f9aa46351b2985c17d73
|
||||
size 5437
|
||||
|
|
@ -1,3 +0,0 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c773553943c9728f1e80b6ca6491c9c8c50f9394f3bd1cb430abb24029a3f926
|
||||
size 7791
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e09769c04ba21aba09eb0de5865f659ad092ba2e46a8b3933f95b1170b09d303
|
||||
size 28540
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3702ebe296d7a14e20b3d683fb79f6b2455064a8986ce48c870b5cfd68dc5933
|
||||
size 27407
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:3702ebe296d7a14e20b3d683fb79f6b2455064a8986ce48c870b5cfd68dc5933
|
||||
size 27407
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b683ed9986e3fd139ee735e51a369322ba3652b2b8a578c05a61f26ee27898e0
|
||||
size 39996
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:14d4ed02bb2f949c4cbba84933bbbfb0c550ad261695a371ef12e74a2dbd8812
|
||||
size 27600
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:53f3dc9352250ba7495b6af312f3f370463291d29b76c40d4b3c340b77aa5712
|
||||
size 25455
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:53f3dc9352250ba7495b6af312f3f370463291d29b76c40d4b3c340b77aa5712
|
||||
size 25455
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b380335d847fa37e5319541f46f341c5fe8016f3af48765cfe7537c59dd1f58a
|
||||
size 38379
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:e9b7a1e2d20171a78da4ce4f590372e1d84f9624c4d56a011e74f91105c09f36
|
||||
size 79989
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:43f2eb2cb1f1b986b58d2614c892f2f7f15f1b696a656165ba1f8644f7759476
|
||||
size 62679
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:c3c38d62929774fe6e6e003f656efbeec1f675b5dbb9994a917c4978f64d61a9
|
||||
size 78280
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:08e5a9d4e1bc45e9411b74c7277f34fa0ba8d46fdd1556b65b9c50cc78f1468e
|
||||
size 60595
|
||||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:45548cd4afaac73a72ab4738c099506eb9dead138e8e0cf5668a75c4948b81f6
|
||||
size 26027
|
||||
oid sha256:ee9c90a91ef8703c4878ea9314121ad8343dd3392c051e7e3e3079198a32ef99
|
||||
size 30555
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:88f3de0736a207057ce0e71fea632e5197874d93045e87443a957ef0da64b8bd
|
||||
size 22760
|
||||
oid sha256:a4b44b23029dd1ff76a082a29c0b39584a1e20c92795eb4815d632608abd2858
|
||||
size 22702
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:146a2a2439de11035332340ce27e622ff20b661d600fc2977d1f82deb81d0f21
|
||||
size 24828
|
||||
oid sha256:ce48c81c355317abd6b9de4600e86492c3acde2807ca3572e064b61abc06239c
|
||||
size 29426
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:281e08f29d84da5c1e7c01d701bf5d8f415b6d80fb8a1298742d6e629314a7d8
|
||||
size 21186
|
||||
oid sha256:4d9810f3b326a05492c870ef22dbceec19112dba8beaa000066504cc96d1387a
|
||||
size 23913
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:bf610f5f758e65ae67836a2b40924e966a8add94fd00605c89ef75ddbd3bf22c
|
||||
size 25324
|
||||
oid sha256:453cab91aa056f3536c899354a2fb5de21519e7dce3d5675b1cbca702000e2c1
|
||||
size 29602
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:d2ed85817f1d95d1259e07aa566ede6d40360a688e29c0c463378770dd091540
|
||||
size 22003
|
||||
oid sha256:f468a4a9140c4dc23ab3d394049f59b86f0ca2f332bee50abf8eaa0acce166b5
|
||||
size 22030
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:354c1daceebd169b35a6db0173c954a3425c534156fdc662edfba4d710f51a49
|
||||
size 24155
|
||||
oid sha256:c21120a08c871c7f64952c20be2c90b28590901f8e52893702775b6b2f176892
|
||||
size 28524
|
||||
|
|
|
|||
|
|
@ -1,3 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:5d6ce5c3b44127d5d661ac450ea2cf48617e81e4ae40dfb6d9a5cf89f3591800
|
||||
size 20598
|
||||
oid sha256:1e46ffc3c3679eb5490d01c34d02314bae5611a7b10fb3c758331a28ae78f065
|
||||
size 23267
|
||||
|
|
|
|||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:154a56d848cc735efc25274052ce92b1a20bc8f80f75e91d0d4cfc7d488cd246
|
||||
size 5874
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:0915d4781155524bece1db737050ceb78ef76128da39b339fd67dd4a2a4dd79d
|
||||
size 9229
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:2dd3462935091cfe022f0e6fc71b082eed7dab4769def13398a81a90f871b61a
|
||||
size 5807
|
||||
|
|
@ -0,0 +1,3 @@
|
|||
version https://git-lfs.github.com/spec/v1
|
||||
oid sha256:b7cd23334c15b9039cbf0924a82703035b4498e67489418e5244cf2df576af9c
|
||||
size 8111
|
||||
|
|
@ -210,7 +210,10 @@
|
|||
"screen_chat_backup_.*",
|
||||
"screen_key_backup_disable_.*",
|
||||
"screen_recovery_key_.*",
|
||||
"screen_create_new_recovery_key_.*"
|
||||
"screen_create_new_recovery_key_.*",
|
||||
"screen_encryption_reset.*",
|
||||
"screen_reset_encryption.*",
|
||||
"screen\\.reset_encryption.*"
|
||||
]
|
||||
},
|
||||
{
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue