Merge pull request #3298 from element-hq/feat/jme/3268-crypto-identity-reset

Feature: identity reset
This commit is contained in:
Benoit Marty
2024-08-16 09:11:15 +02:00
committed by GitHub
95 changed files with 2092 additions and 172 deletions

View File

@@ -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)

View File

@@ -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

View File

@@ -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

View File

@@ -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

View File

@@ -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()
}
}
}

View File

@@ -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)

View File

@@ -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(

View File

@@ -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()
}
}

View File

@@ -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,
)
}

View File

@@ -34,6 +34,9 @@ interface SecureBackupEntryPoint : FeatureEntryPoint {
@Parcelize
data object CreateNewRecoveryKey : InitialTarget
@Parcelize
data object ResetIdentity : InitialTarget
}
data class Params(val initialElement: InitialTarget) : NodeInputs

View File

@@ -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)

View File

@@ -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))
}
}
}

View File

@@ -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
}
}

View File

@@ -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)
}
}

View File

@@ -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
}

View File

@@ -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
)
}
}

View File

@@ -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)
}
}

View File

@@ -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,
)

View File

@@ -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,
)

View File

@@ -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 = {}
)
}
}

View File

@@ -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
}

View File

@@ -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,
)
}
}

View File

@@ -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
)
}
}

View File

@@ -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,
)

View File

@@ -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 = {}
)
)
}

View File

@@ -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 = {},
)
}
}

View File

@@ -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 dont have access to another signed-in device and youve lost your recovery key."</string>
<string name="screen_encryption_reset_subtitle">"If youre not signed in to any other devices and youve lost your recovery key, then youll need to reset your identity to continue using the app. "</string>
<string name="screen_encryption_reset_title">"Reset your identity in case you cant 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>

View File

@@ -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,
)
}

View File

@@ -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(),
)
}

View File

@@ -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)
}
}

View File

@@ -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()
}
}
}

View File

@@ -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)
}
}

View File

@@ -31,6 +31,7 @@ interface VerifySessionEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onEnterRecoveryKey()
fun onResetKey()
fun onDone()
}
}

View File

@@ -43,6 +43,7 @@ class VerifySelfSessionNode @AssistedInject constructor(
state = state,
modifier = modifier,
onEnterRecoveryKey = callback::onEnterRecoveryKey,
onResetKey = callback::onResetKey,
onFinish = callback::onDone,
)
}

View File

@@ -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 = {},
)
}

View File

@@ -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,
)
}
}

View File

@@ -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)

View File

@@ -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>
}

View File

@@ -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()
}
}
}

View File

@@ -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()
}

View File

@@ -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"
}

View File

@@ -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()
}
}

View 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)
}

View File

@@ -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

View File

@@ -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()
}

View 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
*
* 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
}

View File

@@ -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

View 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)
}

View File

@@ -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

View File

@@ -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))
}
}

View File

@@ -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)

View File

@@ -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
/**

View File

@@ -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

View File

@@ -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
}
}

View File

@@ -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

View File

@@ -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

View File

@@ -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(

View File

@@ -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

View File

@@ -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

View File

@@ -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
}

View File

@@ -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

View File

@@ -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 {
/**

View File

@@ -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

View File

@@ -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

View File

@@ -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 dont have access to another signed-in device and youve lost your recovery key."</string>
<string name="screen_encryption_reset_subtitle">"If youre not signed in to any other devices and youve lost your recovery key, then youll need to reset your identity to continue using the app. "</string>
<string name="screen_encryption_reset_title">"Reset your identity in case you cant 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>

View File

@@ -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() {

View File

@@ -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.*"
]
},
{