diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml
index c3aa70ef23..7afe540d22 100644
--- a/.idea/dictionaries/shared.xml
+++ b/.idea/dictionaries/shared.xml
@@ -10,6 +10,7 @@
onboarding
placeables
posthog
+ securebackup
showkase
snackbar
swipeable
diff --git a/.maestro/tests/account/logout.yaml b/.maestro/tests/account/logout.yaml
index a06ac25e2d..c2f75e0977 100644
--- a/.maestro/tests/account/logout.yaml
+++ b/.maestro/tests/account/logout.yaml
@@ -3,11 +3,12 @@ appId: ${APP_ID}
- tapOn:
id: "home_screen-settings"
- tapOn: "Sign out"
-- takeScreenshot: build/maestro/900-SignOutDialg
+- takeScreenshot: build/maestro/900-SignOutScreen
+- back
+- tapOn: "Sign out"
+- tapOn: "Sign out"
# Ensure cancel cancels
- tapOn: "Cancel"
- tapOn: "Sign out"
-- tapOn:
- text: "Sign out"
- index: 1
+- tapOn: "Sign out anyway"
- runFlow: ../assertions/assertInitDisplayed.yaml
diff --git a/.maestro/tests/roomList/searchRoomList.yaml b/.maestro/tests/roomList/searchRoomList.yaml
index 6c31acd4db..b4f44c8b27 100644
--- a/.maestro/tests/roomList/searchRoomList.yaml
+++ b/.maestro/tests/roomList/searchRoomList.yaml
@@ -8,8 +8,6 @@ appId: ${APP_ID}
# Back from timeline
- back
- assertVisible: "MyR"
-# Close keyboard
-- hideKeyboard
# Back from search
- back
- runFlow: ../assertions/assertHomeDisplayed.yaml
diff --git a/CHANGES.md b/CHANGES.md
index 796c0d783c..16e33de234 100644
--- a/CHANGES.md
+++ b/CHANGES.md
@@ -1,3 +1,28 @@
+Changes in Element X v0.3.0 (2023-10-31)
+========================================
+
+Features ✨
+----------
+ - Element Call: change the 'join call' button in a chat room when there's an active call. ([#1158](https://github.com/vector-im/element-x-android/issues/1158))
+ - Mentions: add mentions suggestion view in RTE ([#1452](https://github.com/vector-im/element-x-android/issues/1452))
+ - Record and send voice messages ([#1596](https://github.com/vector-im/element-x-android/issues/1596))
+ - Enable voice messages for all users ([#1669](https://github.com/vector-im/element-x-android/issues/1669))
+ - Receive and play a voice message ([#2084](https://github.com/vector-im/element-x-android/issues/2084))
+ - Enable Element Call integration in rooms by default, fix several issues when creating or joining calls.
+
+Bugfixes 🐛
+----------
+ - Group fallback notification to avoid having plenty of them displayed. ([#994](https://github.com/vector-im/element-x-android/issues/994))
+ - Hide keyboard when exiting the chat room screen. ([#1375](https://github.com/vector-im/element-x-android/issues/1375))
+ - Always register the pusher when application starts ([#1481](https://github.com/vector-im/element-x-android/issues/1481))
+ - Ensure screen does not turn off when playing a video ([#1519](https://github.com/vector-im/element-x-android/issues/1519))
+ - Fix issue where text is cleared when cancelling a reply ([#1617](https://github.com/vector-im/element-x-android/issues/1617))
+
+Other changes
+-------------
+ - Remove usage of blocking methods. ([#1563](https://github.com/vector-im/element-x-android/issues/1563))
+
+
Changes in Element X v0.2.4 (2023-10-12)
========================================
diff --git a/app/build.gradle.kts b/app/build.gradle.kts
index 5286bde045..c8e11927d1 100644
--- a/app/build.gradle.kts
+++ b/app/build.gradle.kts
@@ -27,7 +27,7 @@ plugins {
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
alias(libs.plugins.kapt)
- id("com.google.firebase.appdistribution") version "4.0.0"
+ id("com.google.firebase.appdistribution") version "4.0.1"
id("org.jetbrains.kotlinx.knit") version "0.4.0"
id("kotlin-parcelize")
// To be able to update the firebase.xml files, uncomment and build the project
@@ -201,9 +201,9 @@ dependencies {
implementation(projects.features.call)
implementation(projects.anvilannotations)
implementation(projects.appnav)
+ implementation(projects.appconfig)
anvil(projects.anvilcodegen)
- coreLibraryDesugaring(libs.android.desugar)
implementation(libs.appyx.core)
implementation(libs.androidx.splash)
implementation(libs.androidx.core)
@@ -230,7 +230,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
- testImplementation(libs.test.konsist)
ksp(libs.showkase.processor)
}
diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
index 3b35a0ebf4..7109743052 100644
--- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt
+++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt
@@ -30,7 +30,7 @@ import androidx.compose.ui.platform.LocalUriHandler
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost
-import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
+import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
@@ -42,7 +42,7 @@ import timber.log.Timber
private val loggerTag = LoggerTag("MainActivity")
-class MainActivity : NodeComponentActivity() {
+class MainActivity : NodeActivity() {
private lateinit var mainNode: MainNode
diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
index 17ba415762..037cec1e71 100644
--- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
+++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt
@@ -31,6 +31,7 @@ import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.DefaultPreferences
import io.element.android.libraries.di.SingleIn
import io.element.android.x.BuildConfig
@@ -51,6 +52,12 @@ object AppModule {
return File(context.filesDir, "sessions")
}
+ @Provides
+ @CacheDirectory
+ fun providesCacheDirectory(@ApplicationContext context: Context): File {
+ return context.cacheDir
+ }
+
@Provides
fun providesResources(@ApplicationContext context: Context): Resources {
return context.resources
diff --git a/app/src/main/res/values-night/themes.xml b/app/src/main/res/values-night/themes.xml
index a6572451d7..fad45b6def 100644
--- a/app/src/main/res/values-night/themes.xml
+++ b/app/src/main/res/values-night/themes.xml
@@ -22,5 +22,5 @@
- @style/Theme.ElementX
-
+
diff --git a/app/src/main/res/values/themes.xml b/app/src/main/res/values/themes.xml
index 530821a92b..e7bafa39fe 100644
--- a/app/src/main/res/values/themes.xml
+++ b/app/src/main/res/values/themes.xml
@@ -21,5 +21,5 @@
- @drawable/transparent
- @style/Theme.ElementX
-
+
diff --git a/app/src/test/kotlin/io/element/android/app/KonsistTest.kt b/app/src/test/kotlin/io/element/android/app/KonsistTest.kt
deleted file mode 100644
index 25367e8c5a..0000000000
--- a/app/src/test/kotlin/io/element/android/app/KonsistTest.kt
+++ /dev/null
@@ -1,139 +0,0 @@
-/*
- * Copyright (c) 2023 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.
- */
-
-package io.element.android.app
-
-import androidx.compose.runtime.Composable
-import com.lemonappdev.konsist.api.KoModifier
-import com.lemonappdev.konsist.api.Konsist
-import com.lemonappdev.konsist.api.ext.list.constructors
-import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutModifier
-import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutOverrideModifier
-import com.lemonappdev.konsist.api.ext.list.parameters
-import com.lemonappdev.konsist.api.ext.list.properties
-import com.lemonappdev.konsist.api.ext.list.withAllAnnotationsOf
-import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
-import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
-import com.lemonappdev.konsist.api.ext.list.withReturnType
-import com.lemonappdev.konsist.api.ext.list.withTopLevel
-import com.lemonappdev.konsist.api.ext.list.withoutName
-import com.lemonappdev.konsist.api.ext.list.withoutNameEndingWith
-import com.lemonappdev.konsist.api.verify.assertFalse
-import com.lemonappdev.konsist.api.verify.assertTrue
-import io.element.android.libraries.architecture.Presenter
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import org.junit.Test
-
-class KonsistTest {
-
- @Test
- fun `Classes extending 'Presenter' should have 'Presenter' suffix`() {
- Konsist.scopeFromProject()
- .classes()
- .withAllParentsOf(Presenter::class)
- .assertTrue {
- it.name.endsWith("Presenter")
- }
- }
-
- @Test
- fun `Functions with '@PreviewsDayNight' annotation should have 'Preview' suffix`() {
- Konsist
- .scopeFromProject()
- .functions()
- .withAllAnnotationsOf(PreviewsDayNight::class)
- .assertTrue {
- it.hasNameEndingWith("Preview") &&
- it.hasNameEndingWith("LightPreview").not() &&
- it.hasNameEndingWith("DarkPreview").not()
- }
- }
-
- @Test
- fun `Top level function with '@Composable' annotation starting with a upper case should be placed in a file with the same name`() {
- Konsist
- .scopeFromProject()
- .functions()
- .withTopLevel()
- .withoutModifier(KoModifier.PRIVATE)
- .withoutNameEndingWith("Preview")
- .withAllAnnotationsOf(Composable::class)
- .withoutName(
- // Add some exceptions...
- "OutlinedButton",
- "TextButton",
- "SimpleAlertDialogContent",
- )
- .assertTrue(
- additionalMessage =
- """
- Please check the filename. It should match the top level Composable function. If the filename is correct:
- - consider making the Composable private or moving it to its own file
- - at last resort, you can add an exception in the Konsist test
- """.trimIndent()
- ) {
- if (it.name.first().isLowerCase()) {
- true
- } else {
- val fileName = it.containingFile.name.removeSuffix(".kt")
- fileName == it.name
- }
- }
- }
-
- @Test
- fun `Data class state MUST not have default value`() {
- Konsist
- .scopeFromProject()
- .classes()
- .withNameEndingWith("State")
- .withoutName(
- "CameraPositionState",
- )
- .constructors
- .parameters
- .assertTrue { parameterDeclaration ->
- parameterDeclaration.defaultValue == null &&
- // Using parameterDeclaration.defaultValue == null is not enough apparently,
- // Also check that the text does not contain an equal sign
- parameterDeclaration.text.contains("=").not()
- }
- }
-
- @Test
- fun `Function which creates Presenter in test MUST be named 'createPresenterName'`() {
- Konsist
- .scopeFromTest()
- .functions()
- .withReturnType { it.name.endsWith("Presenter") }
- .withoutOverrideModifier()
- .assertTrue { functionDeclaration ->
- functionDeclaration.name == "create${functionDeclaration.returnType?.name}"
- }
- }
-
- @Test
- fun `no field should have 'm' prefix`() {
- Konsist
- .scopeFromProject()
- .classes()
- .properties()
- .assertFalse {
- val secondCharacterIsUppercase = it.name.getOrNull(1)?.isUpperCase() ?: false
- it.name.startsWith('m') && secondCharacterIsUppercase
- }
- }
-}
diff --git a/appconfig/build.gradle.kts b/appconfig/build.gradle.kts
new file mode 100644
index 0000000000..3f9275d383
--- /dev/null
+++ b/appconfig/build.gradle.kts
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2022 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("java-library")
+ alias(libs.plugins.kotlin.jvm)
+ alias(libs.plugins.anvil)
+ alias(libs.plugins.ksp)
+}
+
+java {
+ sourceCompatibility = JavaVersion.VERSION_17
+ targetCompatibility = JavaVersion.VERSION_17
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ implementation(libs.dagger)
+ implementation(projects.libraries.di)
+}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt
new file mode 100644
index 0000000000..186b84f8f0
--- /dev/null
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/AuthenticationConfig.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.appconfig
+
+object AuthenticationConfig {
+ const val MATRIX_ORG_URL = "https://matrix.org"
+
+ const val DEFAULT_HOMESERVER_URL = MATRIX_ORG_URL
+ const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
+}
diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
similarity index 81%
rename from features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt
rename to appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
index 50dad213fd..4182c32a42 100644
--- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceEvents.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/ElementCallConfig.kt
@@ -14,8 +14,8 @@
* limitations under the License.
*/
-package io.element.android.features.logout.api
+package io.element.android.appconfig
-sealed interface LogoutPreferenceEvents {
- data object Logout : LogoutPreferenceEvents
+object ElementCallConfig {
+ const val DEFAULT_BASE_URL = "https://call.element.dev"
}
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt
new file mode 100644
index 0000000000..04446072b6
--- /dev/null
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/LockScreenConfig.kt
@@ -0,0 +1,79 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.appconfig
+
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.Provides
+import io.element.android.libraries.di.AppScope
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+/**
+ * Configuration for the lock screen feature.
+ */
+data class LockScreenConfig(
+ /**
+ * Whether the PIN is mandatory or not.
+ */
+ val isPinMandatory: Boolean,
+
+ /**
+ * Some PINs are blacklisted.
+ */
+ val pinBlacklist: Set,
+
+ /**
+ * The size of the PIN.
+ */
+ val pinSize: Int,
+
+ /**
+ * Number of attempts before the user is logged out.
+ */
+ val maxPinCodeAttemptsBeforeLogout: Int,
+
+ /**
+ * Time period before locking the app once backgrounded.
+ */
+ val gracePeriod: Duration,
+
+ /**
+ * Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
+ */
+ val isStrongBiometricsEnabled: Boolean,
+ /**
+ * Authentication with weak methods (most face/iris unlock implementations) is supported.
+ */
+ val isWeakBiometricsEnabled: Boolean,
+)
+
+@ContributesTo(AppScope::class)
+@Module
+object LockScreenConfigModule {
+
+ @Provides
+ fun providesLockScreenConfig(): LockScreenConfig = LockScreenConfig(
+ isPinMandatory = false,
+ pinBlacklist = setOf("0000", "1234"),
+ pinSize = 4,
+ maxPinCodeAttemptsBeforeLogout = 3,
+ gracePeriod = 90.seconds,
+ isStrongBiometricsEnabled = true,
+ isWeakBiometricsEnabled = true,
+ )
+}
diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt
similarity index 93%
rename from libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt
rename to appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt
index ddce776627..e4d6ee7ca2 100644
--- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/config/MatrixConfiguration.kt
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MatrixConfiguration.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.libraries.matrix.api.config
+package io.element.android.appconfig
object MatrixConfiguration {
const val matrixToPermalinkBaseUrl: String = "https://matrix.to/#/"
diff --git a/appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt
new file mode 100644
index 0000000000..61f015d239
--- /dev/null
+++ b/appconfig/src/main/kotlin/io/element/android/appconfig/SecureBackupConfig.kt
@@ -0,0 +1,21 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.appconfig
+
+object SecureBackupConfig {
+ const val LearnMoreUrl: String = "https://element.io/help#encryption5"
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
index 72fd2461d2..ed286c71b9 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt
@@ -48,10 +48,14 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invitelist.api.InviteListEntryPoint
+import io.element.android.features.lockscreen.api.LockScreenEntryPoint
+import io.element.android.features.lockscreen.api.LockScreenLockState
+import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint
+import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.verifysession.api.VerifySessionEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
@@ -84,12 +88,15 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val verifySessionEntryPoint: VerifySessionEntryPoint,
+ private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint,
private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueState: FtueState,
+ private val lockScreenEntryPoint: LockScreenEntryPoint,
+ private val lockScreenStateService: LockScreenService,
private val matrixClient: MatrixClient,
snackbarDispatcher: SnackbarDispatcher,
) : BackstackNode(
@@ -98,7 +105,7 @@ class LoggedInFlowNode @AssistedInject constructor(
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
- NavTarget.Permanent,
+ navTargets = setOf(NavTarget.LoggedInPermanent, NavTarget.LockPermanent),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@@ -130,8 +137,8 @@ class LoggedInFlowNode @AssistedInject constructor(
}
},
onStop = {
- //Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
coroutineScope.launch {
+ //Counterpart startSync is done in observeSyncStateAndNetworkStatus method.
syncService.stopSync()
}
},
@@ -167,7 +174,10 @@ class LoggedInFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable {
@Parcelize
- data object Permanent : NavTarget
+ data object LoggedInPermanent : NavTarget
+
+ @Parcelize
+ data object LockPermanent : NavTarget
@Parcelize
data object RoomList : NavTarget
@@ -179,7 +189,9 @@ class LoggedInFlowNode @AssistedInject constructor(
) : NavTarget
@Parcelize
- data object Settings : NavTarget
+ data class Settings(
+ val initialElement: PreferencesEntryPoint.InitialTarget = PreferencesEntryPoint.InitialTarget.Root
+ ) : NavTarget
@Parcelize
data object CreateRoom : NavTarget
@@ -187,6 +199,9 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize
data object VerifySession : NavTarget
+ @Parcelize
+ data object SecureBackup : NavTarget
+
@Parcelize
data object InviteList : NavTarget
@@ -196,9 +211,14 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
- NavTarget.Permanent -> {
+ NavTarget.LoggedInPermanent -> {
createNode(buildContext)
}
+ NavTarget.LockPermanent -> {
+ lockScreenEntryPoint.nodeBuilder(this, buildContext)
+ .target(LockScreenEntryPoint.Target.Unlock)
+ .build()
+ }
NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback {
override fun onRoomClicked(roomId: RoomId) {
@@ -206,7 +226,7 @@ class LoggedInFlowNode @AssistedInject constructor(
}
override fun onSettingsClicked() {
- backstack.push(NavTarget.Settings)
+ backstack.push(NavTarget.Settings())
}
override fun onCreateRoomClicked() {
@@ -239,11 +259,15 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) }
}
+
+ override fun onOpenGlobalNotificationSettings() {
+ backstack.push(NavTarget.Settings(PreferencesEntryPoint.InitialTarget.NotificationSettings))
+ }
}
val inputs = RoomFlowNode.Inputs(roomId = navTarget.roomId, initialElement = navTarget.initialElement)
createNode(buildContext, plugins = listOf(inputs, callback))
}
- NavTarget.Settings -> {
+ is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback {
override fun onOpenBugReport() {
plugins().forEach { it.onOpenBugReport() }
@@ -252,8 +276,18 @@ class LoggedInFlowNode @AssistedInject constructor(
override fun onVerifyClicked() {
backstack.push(NavTarget.VerifySession)
}
+
+ override fun onSecureBackupClicked() {
+ backstack.push(NavTarget.SecureBackup)
+ }
+
+ override fun onOpenRoomNotificationSettings(roomId: RoomId) {
+ backstack.push(NavTarget.Room(roomId, initialElement = RoomLoadedFlowNode.NavTarget.RoomNotificationSettings))
+ }
}
- preferencesEntryPoint.nodeBuilder(this, buildContext)
+ val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
+ return preferencesEntryPoint.nodeBuilder(this, buildContext)
+ .params(inputs)
.callback(callback)
.build()
}
@@ -272,6 +306,9 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.VerifySession -> {
verifySessionEntryPoint.createNode(this, buildContext)
}
+ NavTarget.SecureBackup -> {
+ secureBackupEntryPoint.createNode(this, buildContext)
+ }
NavTarget.InviteList -> {
val callback = object : InviteListEntryPoint.Callback {
override fun onBackClicked() {
@@ -324,17 +361,24 @@ class LoggedInFlowNode @AssistedInject constructor(
@Composable
override fun View(modifier: Modifier) {
Box(modifier = modifier) {
- Children(
- navModel = backstack,
- modifier = Modifier,
- // Animate navigation to settings and to a room
- transitionHandler = rememberDefaultTransitionHandler(),
- )
-
- val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
-
- if (!isFtueDisplayed) {
- PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.Permanent)
+ val lockScreenState by lockScreenStateService.lockState.collectAsState()
+ when (lockScreenState) {
+ LockScreenLockState.Unlocked -> {
+ Children(
+ navModel = backstack,
+ modifier = Modifier,
+ // Animate navigation to settings and to a room
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
+ if (!isFtueDisplayed) {
+ PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
+ }
+ }
+ LockScreenLockState.Locked -> {
+ MoveActivityToBackgroundBackHandler()
+ PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent)
+ }
}
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/MoveActivityToBackgroundBackHandler.kt b/appnav/src/main/kotlin/io/element/android/appnav/MoveActivityToBackgroundBackHandler.kt
new file mode 100644
index 0000000000..5d959f9464
--- /dev/null
+++ b/appnav/src/main/kotlin/io/element/android/appnav/MoveActivityToBackgroundBackHandler.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.appnav
+
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.activity.ComponentActivity
+import androidx.activity.compose.BackHandler
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.platform.LocalContext
+
+@Composable
+fun MoveActivityToBackgroundBackHandler(enabled: Boolean = true) {
+
+ fun Context.findActivity(): ComponentActivity? = when (this) {
+ is ComponentActivity -> this
+ is ContextWrapper -> baseContext.findActivity()
+ else -> null
+ }
+
+ val context = LocalContext.current
+ BackHandler(enabled = enabled) {
+ context.findActivity()?.moveTaskToBack(false)
+ }
+}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
index 3e36e7d692..f75725b1bb 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/di/MatrixClientsHolder.kt
@@ -49,7 +49,7 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
sessionIdsToMatrixClient.remove(sessionId)
}
- fun getOrNull(sessionId: SessionId): MatrixClient? {
+ override fun getOrNull(sessionId: SessionId): MatrixClient? {
return sessionIdsToMatrixClient[sessionId]
}
@@ -92,7 +92,7 @@ class MatrixClientsHolder @Inject constructor(private val authenticationService:
sessionIdsToMatrixClient[matrixClient.sessionId] = matrixClient
}
.onFailure {
- Timber.e("Fail to restore session")
+ Timber.e(it, "Fail to restore session")
}
}
}
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt
index 6950b9b699..5ddbb164d8 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInNode.kt
@@ -31,7 +31,10 @@ class LoggedInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
private val loggedInPresenter: LoggedInPresenter,
-) : Node(buildContext, plugins = plugins) {
+) : Node(
+ buildContext = buildContext,
+ plugins = plugins
+) {
@Composable
override fun View(modifier: Modifier) {
diff --git a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
index 8ad9beba61..613ed650c8 100644
--- a/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
+++ b/appnav/src/main/kotlin/io/element/android/appnav/room/RoomLoadedFlowNode.kt
@@ -75,6 +75,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
interface Callback : Plugin {
fun onForwardedToSingleRoom(roomId: RoomId)
+ fun onOpenGlobalNotificationSettings()
}
data class Inputs(
@@ -128,6 +129,18 @@ class RoomLoadedFlowNode @AssistedInject constructor(
}
}
+ private fun createRoomDetailsNode(buildContext: BuildContext, initialTarget: RoomDetailsEntryPoint.InitialTarget): Node {
+ val callback = object : RoomDetailsEntryPoint.Callback {
+ override fun onOpenGlobalNotificationSettings() {
+ callbacks.forEach { it.onOpenGlobalNotificationSettings() }
+ }
+ }
+ return roomDetailsEntryPoint.nodeBuilder(this, buildContext)
+ .params(RoomDetailsEntryPoint.Params(initialTarget))
+ .callback(callback)
+ .build()
+ }
+
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Messages -> {
@@ -147,12 +160,13 @@ class RoomLoadedFlowNode @AssistedInject constructor(
messagesEntryPoint.createNode(this, buildContext, callback)
}
NavTarget.RoomDetails -> {
- val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomDetails)
- roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
+ createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
}
is NavTarget.RoomMemberDetails -> {
- val inputs = RoomDetailsEntryPoint.Inputs(RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
- roomDetailsEntryPoint.createNode(this, buildContext, inputs, emptyList())
+ createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomMemberDetails(navTarget.userId))
+ }
+ NavTarget.RoomNotificationSettings -> {
+ createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomNotificationSettings)
}
}
}
@@ -166,6 +180,9 @@ class RoomLoadedFlowNode @AssistedInject constructor(
@Parcelize
data class RoomMemberDetails(val userId: UserId) : NavTarget
+
+ @Parcelize
+ data object RoomNotificationSettings : NavTarget
}
@Composable
diff --git a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
index a25e4d8134..fd5de85b1d 100644
--- a/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
+++ b/appnav/src/test/kotlin/io/element/android/appnav/RoomFlowNodeTest.kt
@@ -71,14 +71,22 @@ class RoomFlowNodeTest {
var nodeId: String? = null
- override fun createNode(
- parentNode: Node,
- buildContext: BuildContext,
- inputs: RoomDetailsEntryPoint.Inputs,
- plugins: List
- ): Node {
- return node(buildContext) {}.also {
- nodeId = it.id
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): RoomDetailsEntryPoint.NodeBuilder {
+ return object : RoomDetailsEntryPoint.NodeBuilder {
+
+ override fun params(params: RoomDetailsEntryPoint.Params): RoomDetailsEntryPoint.NodeBuilder {
+ return this
+ }
+
+ override fun callback(callback: RoomDetailsEntryPoint.Callback): RoomDetailsEntryPoint.NodeBuilder {
+ return this
+ }
+
+ override fun build(): Node {
+ return node(buildContext) {}.also {
+ nodeId = it.id
+ }
+ }
}
}
}
diff --git a/build.gradle.kts b/build.gradle.kts
index f08c023b1d..598d4aab43 100644
--- a/build.gradle.kts
+++ b/build.gradle.kts
@@ -198,6 +198,11 @@ koverMerged {
// We do not cover Nodes (normally covered by maestro, but code coverage is not computed with maestro)
"*Node",
"*Node$*",
+ // Exclude `:libraries:matrix:impl` module, it contains only wrappers to access the Rust Matrix SDK api, so it is not really relevant to unit test it: there is no logic to test.
+ "io.element.android.libraries.matrix.impl.*",
+ "*Presenter\$present\$*",
+ // Forked from compose
+ "io.element.android.libraries.designsystem.theme.components.bottomsheet.*",
)
)
}
@@ -250,6 +255,7 @@ koverMerged {
excludes += "io.element.android.appnav.loggedin.LoggedInPresenter$*"
// Some options can't be tested at the moment
excludes += "io.element.android.features.preferences.impl.developer.DeveloperSettingsPresenter$*"
+ excludes += "*Presenter\$present\$*"
}
bound {
minValue = 85
diff --git a/docs/_developer_onboarding.md b/docs/_developer_onboarding.md
index 7af9207365..a100a2beba 100644
--- a/docs/_developer_onboarding.md
+++ b/docs/_developer_onboarding.md
@@ -117,6 +117,10 @@ You can also have access to the aars through the [release](https://github.com/ma
#### Build the SDK locally
+Easiest way: run the script [./tools/sdk/build_rust_sdk.sh](./tools/sdk/build_rust_sdk.sh) and just answer the questions.
+
+Legacy way:
+
If you need to locally build the sdk-android you can use
the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script.
@@ -147,15 +151,6 @@ Troubleshooting:
- If you get the error `thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', .cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-ndk-2.11.0/src/cli.rs:345:18` try updating your Cargo NDK version. In this case, 2.11.0 is too old so `cargo install cargo-ndk` to install a newer version.
- If you get the error `Unsupported class file major version 64` try changing your JVM version. In this case, Java 20 is not supported in Gradle yet, so downgrade to an earlier version (Java 17 worked in this case).
-Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`:
-
-```groovy
-dependencies {
- api(projects.libraries.rustsdk) // <- use the local version of the sdk. Uncomment this line.
- //implementation(libs.matrix.sdk) // <- use the released version. Comment this line.
-}
-```
-
You are good to test your local rust development now!
### The Android project
diff --git a/fastlane/metadata/android/en-US/changelogs/40003000.txt b/fastlane/metadata/android/en-US/changelogs/40003000.txt
new file mode 100644
index 0000000000..ec8dc0aaf1
--- /dev/null
+++ b/fastlane/metadata/android/en-US/changelogs/40003000.txt
@@ -0,0 +1,2 @@
+Main changes in this version: TODO.
+Full changelog: https://github.com/vector-im/element-x-android/releases
\ No newline at end of file
diff --git a/features/call/build.gradle.kts b/features/call/build.gradle.kts
index 69046e33b4..c59f1ea855 100644
--- a/features/call/build.gradle.kts
+++ b/features/call/build.gradle.kts
@@ -18,20 +18,44 @@ plugins {
id("io.element.android-compose-library")
alias(libs.plugins.anvil)
alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+ alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "io.element.android.features.call"
+
+ buildFeatures {
+ buildConfig = true
+ }
+}
+
+anvil {
+ generateDaggerFactories.set(true)
}
dependencies {
+ implementation(projects.appnav)
+ implementation(projects.appconfig)
+ implementation(projects.anvilannotations)
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.core)
implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.matrix.impl)
implementation(projects.libraries.network)
+ implementation(projects.libraries.preferences.api)
+ implementation(projects.services.toolbox.api)
implementation(libs.androidx.webkit)
+ implementation(libs.serialization.json)
ksp(libs.showkase.processor)
- testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
testImplementation(libs.test.robolectric)
+ testImplementation(projects.libraries.featureflag.test)
+ testImplementation(projects.libraries.preferences.test)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
}
diff --git a/features/call/src/main/AndroidManifest.xml b/features/call/src/main/AndroidManifest.xml
index 877b7fb0a8..c7db9cc38f 100644
--- a/features/call/src/main/AndroidManifest.xml
+++ b/features/call/src/main/AndroidManifest.xml
@@ -27,7 +27,7 @@
{
+
+ @AssistedFactory
+ interface Factory {
+ fun create(callType: CallType, navigator: CallScreenNavigator): CallScreenPresenter
+ }
+
+ private val isInWidgetMode = callType is CallType.RoomCall
+ private val userAgent = userAgentProvider.provide()
+
+ @Composable
+ override fun present(): CallScreenState {
+ val coroutineScope = rememberCoroutineScope()
+ val urlState = remember { mutableStateOf>(Async.Uninitialized) }
+ val callWidgetDriver = remember { mutableStateOf(null) }
+ val messageInterceptor = remember { mutableStateOf(null) }
+ var isJoinedCall by rememberSaveable { mutableStateOf(false) }
+
+ LaunchedEffect(Unit) {
+ loadUrl(callType, urlState, callWidgetDriver)
+ }
+
+ HandleMatrixClientSyncState()
+
+ callWidgetDriver.value?.let { driver ->
+ LaunchedEffect(Unit) {
+ driver.incomingMessages
+ .onEach {
+ // Relay message to the WebView
+ messageInterceptor.value?.sendMessage(it)
+ }
+ .launchIn(this)
+
+ driver.run()
+ }
+ }
+
+ messageInterceptor.value?.let { interceptor ->
+ LaunchedEffect(Unit) {
+ interceptor.interceptedMessages
+ .onEach {
+ // Relay message to Widget Driver
+ callWidgetDriver.value?.send(it)
+
+ val parsedMessage = parseMessage(it)
+ if (parsedMessage?.direction == WidgetMessage.Direction.FromWidget) {
+ if (parsedMessage.action == WidgetMessage.Action.HangUp) {
+ close(callWidgetDriver.value, navigator)
+ } else if (parsedMessage.action == WidgetMessage.Action.SendEvent) {
+ // This event is received when a member joins the call, the first one will be the current one
+ val type = parsedMessage.data?.jsonObject?.get("type")?.jsonPrimitive?.contentOrNull
+ if (type == "org.matrix.msc3401.call.member") {
+ isJoinedCall = true
+ }
+ }
+ }
+ }
+ .launchIn(this)
+ }
+ }
+
+ fun handleEvents(event: CallScreenEvents) {
+ when (event) {
+ is CallScreenEvents.Hangup -> {
+ val widgetId = callWidgetDriver.value?.id
+ val interceptor = messageInterceptor.value
+ if (widgetId != null && interceptor != null && isJoinedCall) {
+ // If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
+ sendHangupMessage(widgetId, interceptor)
+ isJoinedCall = false
+ } else {
+ coroutineScope.launch {
+ close(callWidgetDriver.value, navigator)
+ }
+ }
+ }
+ is CallScreenEvents.SetupMessageChannels -> {
+ messageInterceptor.value = event.widgetMessageInterceptor
+ }
+ }
+ }
+
+ return CallScreenState(
+ urlState = urlState.value,
+ userAgent = userAgent,
+ isInWidgetMode = isInWidgetMode,
+ eventSink = ::handleEvents,
+ )
+ }
+
+ private fun CoroutineScope.loadUrl(
+ inputs: CallType,
+ urlState: MutableState>,
+ callWidgetDriver: MutableState,
+ ) = launch {
+ urlState.runCatchingUpdatingState {
+ when (inputs) {
+ is CallType.ExternalUrl -> {
+ inputs.url
+ }
+ is CallType.RoomCall -> {
+ val (driver, url) = callWidgetProvider.getWidget(
+ sessionId = inputs.sessionId,
+ roomId = inputs.roomId,
+ clientId = UUID.randomUUID().toString(),
+ ).getOrThrow()
+ callWidgetDriver.value = driver
+ url
+ }
+ }
+ }
+ }
+
+ @Composable
+ private fun HandleMatrixClientSyncState() {
+ val coroutineScope = rememberCoroutineScope()
+ DisposableEffect(Unit) {
+ val client = (callType as? CallType.RoomCall)?.sessionId?.let {
+ matrixClientsProvider.getOrNull(it)
+ } ?: return@DisposableEffect onDispose { }
+
+ coroutineScope.launch {
+ client.syncService().syncState
+ .onEach { state ->
+ if (state != SyncState.Running) {
+ client.syncService().startSync()
+ }
+ }
+ .collect()
+ }
+ onDispose {
+ // We can't use the local coroutine scope here because it will be disposed before this effect
+ appCoroutineScope.launch {
+ client.syncService().run {
+ if (syncState.value == SyncState.Running) {
+ stopSync()
+ }
+ }
+ }
+ }
+ }
+ }
+
+ private fun parseMessage(message: String): WidgetMessage? {
+ return WidgetMessageSerializer.deserialize(message).getOrNull()
+ }
+
+ private fun sendHangupMessage(widgetId: String, messageInterceptor: WidgetMessageInterceptor) {
+ val message = WidgetMessage(
+ direction = WidgetMessage.Direction.ToWidget,
+ widgetId = widgetId,
+ requestId = "widgetapi-${clock.epochMillis()}",
+ action = WidgetMessage.Action.HangUp,
+ data = null,
+ )
+ messageInterceptor.sendMessage(WidgetMessageSerializer.serialize(message))
+ }
+
+ private fun CoroutineScope.close(widgetDriver: MatrixWidgetDriver?, navigator: CallScreenNavigator) = launch(dispatchers.io) {
+ navigator.close()
+ widgetDriver?.close()
+ }
+
+}
+
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt
new file mode 100644
index 0000000000..12cd7612ae
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenState.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.ui
+
+import io.element.android.libraries.architecture.Async
+
+data class CallScreenState(
+ val urlState: Async,
+ val userAgent: String,
+ val isInWidgetMode: Boolean,
+ val eventSink: (CallScreenEvents) -> Unit,
+)
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt
similarity index 53%
rename from features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt
rename to features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt
index 0f5b90cbc8..acc01e6149 100644
--- a/features/call/src/main/kotlin/io/element/android/features/call/CallScreenView.kt
+++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/CallScreenView.kt
@@ -14,106 +14,124 @@
* limitations under the License.
*/
-package io.element.android.features.call
+package io.element.android.features.call.ui
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.PermissionRequest
import android.webkit.WebChromeClient
import android.webkit.WebView
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.viewinterop.AndroidView
+import io.element.android.features.call.R
+import io.element.android.features.call.utils.WebViewWidgetMessageInterceptor
+import io.element.android.libraries.architecture.Async
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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.utils.CommonDrawables
-import io.element.android.libraries.theme.ElementTheme
typealias RequestPermissionCallback = (Array) -> Unit
+interface CallScreenNavigator {
+ fun close()
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun CallScreenView(
- url: String?,
- userAgent: String,
+ state: CallScreenState,
requestPermissions: (Array, RequestPermissionCallback) -> Unit,
- onClose: () -> Unit,
modifier: Modifier = Modifier,
) {
- ElementTheme {
- Scaffold(
- modifier = modifier,
- topBar = {
- TopAppBar(
- title = { Text(stringResource(R.string.element_call)) },
- navigationIcon = {
- BackButton(
- resourceId = CommonDrawables.ic_compound_close,
- onClick = onClose
- )
- }
- )
- }
- ) { padding ->
- CallWebView(
- modifier = Modifier
- .padding(padding)
- .consumeWindowInsets(padding)
- .fillMaxSize(),
- url = url,
- userAgent = userAgent,
- onPermissionsRequested = { request ->
- val androidPermissions = mapWebkitPermissions(request.resources)
- val callback: RequestPermissionCallback = { request.grant(it) }
- requestPermissions(androidPermissions.toTypedArray(), callback)
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ title = { Text(stringResource(R.string.element_call)) },
+ navigationIcon = {
+ BackButton(
+ resourceId = CommonDrawables.ic_compound_close,
+ onClick = { state.eventSink(CallScreenEvents.Hangup) }
+ )
}
)
}
+ ) { padding ->
+ BackHandler {
+ state.eventSink(CallScreenEvents.Hangup)
+ }
+ CallWebView(
+ modifier = Modifier
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .fillMaxSize(),
+ url = state.urlState,
+ userAgent = state.userAgent,
+ onPermissionsRequested = { request ->
+ val androidPermissions = mapWebkitPermissions(request.resources)
+ val callback: RequestPermissionCallback = { request.grant(it) }
+ requestPermissions(androidPermissions.toTypedArray(), callback)
+ },
+ onWebViewCreated = { webView ->
+ val interceptor = WebViewWidgetMessageInterceptor(webView)
+ state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
+ }
+ )
}
}
@Composable
private fun CallWebView(
- url: String?,
+ url: Async,
userAgent: String,
onPermissionsRequested: (PermissionRequest) -> Unit,
+ onWebViewCreated: (WebView) -> Unit,
modifier: Modifier = Modifier,
) {
- val isInpectionMode = LocalInspectionMode.current
- AndroidView(
- modifier = modifier,
- factory = { context ->
- WebView(context).apply {
- if (!isInpectionMode) {
- setup(userAgent, onPermissionsRequested)
- if (url != null) {
- loadUrl(url)
- }
- }
- }
- },
- update = { webView ->
- if (!isInpectionMode && url != null) {
- webView.loadUrl(url)
- }
- },
- onRelease = { webView ->
- webView.destroy()
+ if (LocalInspectionMode.current) {
+ Box(modifier = modifier, contentAlignment = Alignment.Center) {
+ Text("WebView - can't be previewed")
}
- )
+ } else {
+ AndroidView(
+ modifier = modifier,
+ factory = { context ->
+ WebView(context).apply {
+ onWebViewCreated(this)
+ setup(userAgent, onPermissionsRequested)
+ }
+ },
+ update = { webView ->
+ if (url is Async.Success && webView.url != url.data) {
+ webView.loadUrl(url.data)
+ }
+ },
+ onRelease = { webView ->
+ webView.destroy()
+ }
+ )
+ }
}
@SuppressLint("SetJavaScriptEnabled")
-private fun WebView.setup(userAgent: String, onPermissionsRequested: (PermissionRequest) -> Unit) {
+private fun WebView.setup(
+ userAgent: String,
+ onPermissionsRequested: (PermissionRequest) -> Unit,
+) {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
@@ -140,12 +158,15 @@ private fun WebView.setup(userAgent: String, onPermissionsRequested: (Permission
@PreviewsDayNight
@Composable
internal fun CallScreenViewPreview() {
- ElementTheme {
+ ElementPreview {
CallScreenView(
- url = "https://call.element.io/some-actual-call?with=parameters",
- userAgent = "",
+ state = CallScreenState(
+ urlState = Async.Success("https://call.element.io/some-actual-call?with=parameters"),
+ isInWidgetMode = false,
+ userAgent = "",
+ eventSink = {},
+ ),
requestPermissions = { _, _ -> },
- onClose = { },
)
}
}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt
similarity index 72%
rename from features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt
rename to features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt
index 481634a4ca..651a2176f3 100644
--- a/features/call/src/main/kotlin/io/element/android/features/call/ElementCallActivity.kt
+++ b/features/call/src/main/kotlin/io/element/android/features/call/ui/ElementCallActivity.kt
@@ -14,10 +14,12 @@
* limitations under the License.
*/
-package io.element.android.features.call
+package io.element.android.features.call.ui
import android.Manifest
+import android.content.Context
import android.content.Intent
+import android.content.Intent.FLAG_ACTIVITY_NEW_TASK
import android.content.res.Configuration
import android.media.AudioAttributes
import android.media.AudioFocusRequest
@@ -26,20 +28,40 @@ import android.os.Build
import android.os.Bundle
import android.view.WindowManager
import android.webkit.PermissionRequest
-import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.result.ActivityResultLauncher
import androidx.activity.result.contract.ActivityResultContracts
import androidx.compose.runtime.mutableStateOf
+import androidx.core.content.IntentCompat
+import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
+import io.element.android.features.call.CallForegroundService
+import io.element.android.features.call.CallType
import io.element.android.features.call.di.CallBindings
+import io.element.android.features.call.utils.CallIntentDataParser
import io.element.android.libraries.architecture.bindings
-import io.element.android.libraries.network.useragent.UserAgentProvider
+import io.element.android.libraries.theme.ElementTheme
import javax.inject.Inject
-class ElementCallActivity : ComponentActivity() {
+class ElementCallActivity : NodeComponentActivity(), CallScreenNavigator {
+ companion object {
+ private const val EXTRA_CALL_WIDGET_SETTINGS = "EXTRA_CALL_WIDGET_SETTINGS"
+
+ fun start(
+ context: Context,
+ callInputs: CallType,
+ ) {
+ val intent = Intent(context, ElementCallActivity::class.java).apply {
+ putExtra(EXTRA_CALL_WIDGET_SETTINGS, callInputs)
+ addFlags(FLAG_ACTIVITY_NEW_TASK)
+ }
+ context.startActivity(intent)
+ }
+ }
- @Inject lateinit var userAgentProvider: UserAgentProvider
@Inject lateinit var callIntentDataParser: CallIntentDataParser
+ @Inject lateinit var presenterFactory: CallScreenPresenter.Factory
+
+ private lateinit var presenter: CallScreenPresenter
private lateinit var audioManager: AudioManager
@@ -51,7 +73,7 @@ class ElementCallActivity : ComponentActivity() {
private val requestPermissionsLauncher = registerPermissionResultLauncher()
private var isDarkMode = false
- private val urlState = mutableStateOf(null)
+ private val webViewTarget = mutableStateOf(null)
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -60,10 +82,7 @@ class ElementCallActivity : ComponentActivity() {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
- urlState.value = intent?.dataString?.let(::parseUrl) ?: run {
- finish()
- return
- }
+ setCallType(intent)
if (savedInstanceState == null) {
updateUiMode(resources.configuration)
@@ -72,18 +91,17 @@ class ElementCallActivity : ComponentActivity() {
audioManager = getSystemService(AUDIO_SERVICE) as AudioManager
requestAudioFocus()
- val userAgent = userAgentProvider.provide()
-
setContent {
- CallScreenView(
- url = urlState.value,
- userAgent = userAgent,
- onClose = this::finish,
- requestPermissions = { permissions, callback ->
- requestPermissionCallback = callback
- requestPermissionsLauncher.launch(permissions)
- }
- )
+ val state = presenter.present()
+ ElementTheme {
+ CallScreenView(
+ state = state,
+ requestPermissions = { permissions, callback ->
+ requestPermissionCallback = callback
+ requestPermissionsLauncher.launch(permissions)
+ }
+ )
+ }
}
}
@@ -96,15 +114,7 @@ class ElementCallActivity : ComponentActivity() {
override fun onNewIntent(intent: Intent?) {
super.onNewIntent(intent)
- val intentUrl = intent?.dataString?.let(::parseUrl)
- when {
- // New URL, update it and reload the webview
- intentUrl != null -> urlState.value = intentUrl
- // Re-opened the activity but we have no url to load or a cached one, finish the activity
- intent?.dataString == null && urlState.value == null -> finish()
- // Coming back from notification, do nothing
- else -> return
- }
+ setCallType(intent)
}
override fun onStart() {
@@ -130,6 +140,32 @@ class ElementCallActivity : ComponentActivity() {
finishAndRemoveTask()
}
+ override fun close() {
+ finish()
+ }
+
+ private fun setCallType(intent: Intent?) {
+ val inputs = intent?.let {
+ IntentCompat.getParcelableExtra(it, EXTRA_CALL_WIDGET_SETTINGS, CallType::class.java)
+ }
+ val intentUrl = intent?.dataString?.let(::parseUrl)
+ when {
+ // Re-opened the activity but we have no url to load or a cached one, finish the activity
+ intent?.dataString == null && inputs == null && webViewTarget.value == null -> finish()
+ inputs != null -> {
+ webViewTarget.value = inputs
+ presenter = presenterFactory.create(inputs, this)
+ }
+ intentUrl != null -> {
+ val fallbackInputs = CallType.ExternalUrl(intentUrl)
+ webViewTarget.value = fallbackInputs
+ presenter = presenterFactory.create(fallbackInputs, this)
+ }
+ // Coming back from notification, do nothing
+ else -> return
+ }
+ }
+
private fun parseUrl(url: String?): String? = callIntentDataParser.parse(url)
private fun registerPermissionResultLauncher(): ActivityResultLauncher> {
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt
similarity index 98%
rename from features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt
rename to features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt
index b903b437d8..0814216745 100644
--- a/features/call/src/main/kotlin/io/element/android/features/call/CallIntentDataParser.kt
+++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallIntentDataParser.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.features.call
+package io.element.android.features.call.utils
import android.net.Uri
import javax.inject.Inject
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt
new file mode 100644
index 0000000000..b65298854d
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/CallWidgetProvider.kt
@@ -0,0 +1,31 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
+
+interface CallWidgetProvider {
+ suspend fun getWidget(
+ sessionId: SessionId,
+ roomId: RoomId,
+ clientId: String,
+ languageTag: String? = null,
+ theme: String? = null,
+ ): Result>
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt
new file mode 100644
index 0000000000..f3cb9cbcd5
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProvider.kt
@@ -0,0 +1,50 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.ElementCallConfig
+import io.element.android.features.preferences.api.store.PreferencesStore
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
+import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
+import kotlinx.coroutines.flow.firstOrNull
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultCallWidgetProvider @Inject constructor(
+ private val matrixClientsProvider: MatrixClientProvider,
+ private val preferencesStore: PreferencesStore,
+ private val callWidgetSettingsProvider: CallWidgetSettingsProvider,
+) : CallWidgetProvider {
+ override suspend fun getWidget(
+ sessionId: SessionId,
+ roomId: RoomId,
+ clientId: String,
+ languageTag: String?,
+ theme: String?,
+ ): Result> = runCatching {
+ val room = matrixClientsProvider.getOrRestore(sessionId).getOrThrow().getRoom(roomId) ?: error("Room not found")
+ val baseUrl = preferencesStore.getCustomElementCallBaseUrlFlow().firstOrNull() ?: ElementCallConfig.DEFAULT_BASE_URL
+ val widgetSettings = callWidgetSettingsProvider.provide(baseUrl)
+ val callUrl = room.generateWidgetWebViewUrl(widgetSettings, clientId, languageTag, theme).getOrThrow()
+ room.getWidgetDriver(widgetSettings).getOrThrow() to callUrl
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt
new file mode 100644
index 0000000000..e11529f068
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WebViewWidgetMessageInterceptor.kt
@@ -0,0 +1,101 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import android.graphics.Bitmap
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.webkit.WebViewCompat
+import androidx.webkit.WebViewFeature
+import io.element.android.features.call.BuildConfig
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+class WebViewWidgetMessageInterceptor(
+ private val webView: WebView,
+) : WidgetMessageInterceptor {
+
+ companion object {
+ // We call both the WebMessageListener and the JavascriptInterface objects in JS with this
+ // 'listenerName' so they can both receive the data from the WebView when
+ // `${LISTENER_NAME}.postMessage(...)` is called
+ const val LISTENER_NAME = "elementX"
+ }
+
+ // It's important to have extra capacity here to make sure we don't drop any messages
+ override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 10)
+
+ init {
+ webView.webViewClient = object : WebViewClient() {
+ override fun onPageStarted(view: WebView?, url: String?, favicon: Bitmap?) {
+ super.onPageStarted(view, url, favicon)
+
+ // We inject this JS code when the page starts loading to attach a message listener to the window.
+ // This listener will receive both messages:
+ // - EC widget API -> Element X (message.data.api == "fromWidget")
+ // - Element X -> EC widget API (message.data.api == "toWidget"), we should ignore these
+ view?.evaluateJavascript(
+ """
+ window.addEventListener('message', function(event) {
+ let message = {data: event.data, origin: event.origin}
+ if (message.data.response && message.data.api == "toWidget"
+ || !message.data.response && message.data.api == "fromWidget") {
+ let json = JSON.stringify(event.data)
+ ${"console.log('message sent: ' + json);".takeIf { BuildConfig.DEBUG } }
+ ${LISTENER_NAME}.postMessage(json);
+ } else {
+ ${"console.log('message received (ignored): ' + JSON.stringify(event.data));".takeIf { BuildConfig.DEBUG } }
+ }
+ });
+ """.trimIndent(),
+ null
+ )
+ }
+ }
+
+ // Create a WebMessageListener, which will receive messages from the WebView and reply to them
+ val webMessageListener = WebViewCompat.WebMessageListener { _, message, _, _, _ ->
+ onMessageReceived(message.data)
+ }
+
+ // Use WebMessageListener if supported, otherwise use JavascriptInterface
+ if (WebViewFeature.isFeatureSupported(WebViewFeature.WEB_MESSAGE_LISTENER)) {
+ WebViewCompat.addWebMessageListener(
+ webView,
+ LISTENER_NAME,
+ setOf("*"),
+ webMessageListener
+ )
+ } else {
+ webView.addJavascriptInterface(object {
+ @JavascriptInterface
+ fun postMessage(json: String?) {
+ onMessageReceived(json)
+ }
+ }, LISTENER_NAME)
+ }
+ }
+
+ override fun sendMessage(message: String) {
+ webView.evaluateJavascript("postMessage($message, '*')", null)
+ }
+
+ private fun onMessageReceived(json: String?) {
+ // Here is where we would handle the messages from the WebView, passing them to the Rust SDK
+ json?.let { interceptedMessages.tryEmit(it) }
+ }
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt
new file mode 100644
index 0000000000..fa5c3bea67
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageInterceptor.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import kotlinx.coroutines.flow.Flow
+
+interface WidgetMessageInterceptor {
+ val interceptedMessages: Flow
+ fun sendMessage(message: String)
+}
diff --git a/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt
new file mode 100644
index 0000000000..5ed9db028c
--- /dev/null
+++ b/features/call/src/main/kotlin/io/element/android/features/call/utils/WidgetMessageSerializer.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import io.element.android.features.call.data.WidgetMessage
+import kotlinx.serialization.json.Json
+
+object WidgetMessageSerializer {
+
+ private val coder = Json { ignoreUnknownKeys = true }
+
+ fun deserialize(message: String): Result {
+ return runCatching { coder.decodeFromString(WidgetMessage.serializer(), message) }
+ }
+
+ fun serialize(message: WidgetMessage): String {
+ return coder.encodeToString(WidgetMessage.serializer(), message)
+ }
+}
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
index f82e31c068..55b5f16771 100644
--- a/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
+++ b/features/call/src/test/kotlin/io/element/android/features/call/MapWebkitPermissionsTest.kt
@@ -19,6 +19,7 @@ package io.element.android.features.call
import android.Manifest
import android.webkit.PermissionRequest
import com.google.common.truth.Truth.assertThat
+import io.element.android.features.call.ui.mapWebkitPermissions
import org.junit.Test
class MapWebkitPermissionsTest {
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
new file mode 100644
index 0000000000..77f83de209
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt
@@ -0,0 +1,255 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.ui
+
+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.call.CallType
+import io.element.android.features.call.utils.FakeCallWidgetProvider
+import io.element.android.features.call.utils.FakeWidgetMessageInterceptor
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.matrix.api.sync.SyncState
+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.FakeMatrixClient
+import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
+import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
+import io.element.android.libraries.network.useragent.UserAgentProvider
+import io.element.android.services.toolbox.api.systemclock.SystemClock
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.consumeItemsUntilTimeout
+import io.element.android.tests.testutils.testCoroutineDispatchers
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancelAndJoin
+import kotlinx.coroutines.launch
+import kotlinx.coroutines.sync.Mutex
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runCurrent
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class CallScreenPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - with CallType ExternalUrl just loads the URL`() = runTest {
+ val presenter = createCallScreenPresenter(CallType.ExternalUrl("https://call.element.io"))
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ // Wait until the URL is loaded
+ skipItems(1)
+
+ val initialState = awaitItem()
+ assertThat(initialState.urlState).isEqualTo(Async.Success("https://call.element.io"))
+ assertThat(initialState.isInWidgetMode).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest {
+ val widgetDriver = FakeWidgetDriver()
+ val widgetProvider = FakeCallWidgetProvider(widgetDriver)
+ val presenter = createCallScreenPresenter(
+ callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
+ widgetDriver = widgetDriver,
+ widgetProvider = widgetProvider,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ // Wait until the URL is loaded
+ skipItems(1)
+
+ val initialState = awaitItem()
+ assertThat(initialState.urlState).isInstanceOf(Async.Success::class.java)
+ assertThat(initialState.isInWidgetMode).isTrue()
+ assertThat(widgetProvider.getWidgetCalled).isTrue()
+ assertThat(widgetDriver.runCalledCount).isEqualTo(1)
+ }
+ }
+
+ @Test
+ fun `present - set message interceptor, send and receive messages`() = runTest {
+ val widgetDriver = FakeWidgetDriver()
+ val presenter = createCallScreenPresenter(
+ callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
+ widgetDriver = widgetDriver,
+ )
+ val messageInterceptor = FakeWidgetMessageInterceptor()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+
+ // And incoming message from the Widget Driver is passed to the WebView
+ widgetDriver.givenIncomingMessage("A message")
+ assertThat(messageInterceptor.sentMessages).containsExactly("A message")
+
+ // And incoming message from the WebView is passed to the Widget Driver
+ messageInterceptor.givenInterceptedMessage("A reply")
+ assertThat(widgetDriver.sentMessages).containsExactly("A reply")
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
+ val navigator = FakeCallScreenNavigator()
+ val widgetDriver = FakeWidgetDriver()
+ val presenter = createCallScreenPresenter(
+ callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
+ widgetDriver = widgetDriver,
+ navigator = navigator,
+ dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
+ )
+ val messageInterceptor = FakeWidgetMessageInterceptor()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+
+ initialState.eventSink(CallScreenEvents.Hangup)
+
+ // Let background coroutines run
+ runCurrent()
+
+ assertThat(navigator.closeCalled).isTrue()
+ assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) {
+ val navigator = FakeCallScreenNavigator()
+ val widgetDriver = FakeWidgetDriver()
+ val presenter = createCallScreenPresenter(
+ callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
+ widgetDriver = widgetDriver,
+ navigator = navigator,
+ dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
+ )
+ val messageInterceptor = FakeWidgetMessageInterceptor()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(CallScreenEvents.SetupMessageChannels(messageInterceptor))
+
+ messageInterceptor.givenInterceptedMessage("""{"action":"im.vector.hangup","api":"fromWidget","widgetId":"1","requestId":"1"}""")
+
+ // Let background coroutines run
+ runCurrent()
+
+ assertThat(navigator.closeCalled).isTrue()
+ assertThat(widgetDriver.closeCalledCount).isEqualTo(1)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest {
+ val navigator = FakeCallScreenNavigator()
+ val widgetDriver = FakeWidgetDriver()
+ val matrixClient = FakeMatrixClient()
+ val presenter = createCallScreenPresenter(
+ callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
+ widgetDriver = widgetDriver,
+ navigator = navigator,
+ dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
+ matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ consumeItemsUntilTimeout()
+
+ assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Running)
+
+ cancelAndIgnoreRemainingEvents()
+ }
+ }
+
+ @Test
+ fun `present - automatically stops the Matrix client sync on dispose`() = runTest {
+ val navigator = FakeCallScreenNavigator()
+ val widgetDriver = FakeWidgetDriver()
+ val matrixClient = FakeMatrixClient()
+ val presenter = createCallScreenPresenter(
+ callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID),
+ widgetDriver = widgetDriver,
+ navigator = navigator,
+ dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
+ matrixClientsProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) })
+ )
+ val hasRun = Mutex(true)
+ val job = launch {
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.collect {
+ hasRun.unlock()
+ }
+ }
+
+ hasRun.lock()
+
+ job.cancelAndJoin()
+
+ assertThat(matrixClient.syncService().syncState.value).isEqualTo(SyncState.Terminated)
+ }
+
+ private fun TestScope.createCallScreenPresenter(
+ callType: CallType,
+ navigator: CallScreenNavigator = FakeCallScreenNavigator(),
+ widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
+ widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver),
+ dispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
+ matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(),
+ ): CallScreenPresenter {
+ val userAgentProvider = object : UserAgentProvider {
+ override fun provide(): String {
+ return "Test"
+ }
+ }
+ val clock = SystemClock { 0 }
+ return CallScreenPresenter(
+ callType,
+ navigator,
+ widgetProvider,
+ userAgentProvider,
+ clock,
+ dispatchers,
+ matrixClientsProvider,
+ this,
+ )
+ }
+}
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt
new file mode 100644
index 0000000000..498503cb15
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/FakeCallScreenNavigator.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.ui
+
+class FakeCallScreenNavigator : CallScreenNavigator {
+ var closeCalled = false
+ private set
+
+ override fun close() {
+ closeCalled = true
+ }
+ }
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
similarity index 98%
rename from features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt
rename to features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
index f23a5fb43c..eb8e756182 100644
--- a/features/call/src/test/kotlin/io/element/android/features/call/CallIntentDataParserTests.kt
+++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/CallIntentDataParserTest.kt
@@ -14,7 +14,7 @@
* limitations under the License.
*/
-package io.element.android.features.call
+package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat
import org.junit.Test
@@ -23,7 +23,7 @@ import org.robolectric.RobolectricTestRunner
import java.net.URLEncoder
@RunWith(RobolectricTestRunner::class)
-class CallIntentDataParserTests {
+class CallIntentDataParserTest {
private val callIntentDataParser = CallIntentDataParser()
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
new file mode 100644
index 0000000000..f7f17d794d
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt
@@ -0,0 +1,121 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.preferences.api.store.PreferencesStore
+import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
+import io.element.android.libraries.matrix.api.MatrixClientProvider
+import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
+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.FakeMatrixClient
+import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider
+import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultCallWidgetProviderTest {
+
+ @Test
+ fun `getWidget - fails if the session does not exist`() = runTest {
+ val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.failure(Exception("Session not found")) })
+ assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
+ }
+
+ @Test
+ fun `getWidget - fails if the room does not exist`() = runTest {
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, null)
+ }
+ val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
+ assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
+ }
+
+ @Test
+ fun `getWidget - fails if it can't generate the URL for the widget`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenGenerateWidgetWebViewUrlResult(Result.failure(Exception("Can't generate URL for widget")))
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
+ assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
+ }
+
+ @Test
+ fun `getWidget - fails if it can't get the widget driver`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenGenerateWidgetWebViewUrlResult(Result.success("url"))
+ givenGetWidgetDriverResult(Result.failure(Exception("Can't get a widget driver")))
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
+ assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").isFailure).isTrue()
+ }
+
+ @Test
+ fun `getWidget - returns a widget driver when all steps are successful`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenGenerateWidgetWebViewUrlResult(Result.success("url"))
+ givenGetWidgetDriverResult(Result.success(FakeWidgetDriver()))
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ val provider = createProvider(matrixClientProvider = FakeMatrixClientProvider { Result.success(client) })
+ assertThat(provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme").getOrNull()).isNotNull()
+ }
+
+ @Test
+ fun `getWidget - will use a custom base url if it exists`() = runTest {
+ val room = FakeMatrixRoom().apply {
+ givenGenerateWidgetWebViewUrlResult(Result.success("url"))
+ givenGetWidgetDriverResult(Result.success(FakeWidgetDriver()))
+ }
+ val client = FakeMatrixClient().apply {
+ givenGetRoomResult(A_ROOM_ID, room)
+ }
+ val preferencesStore = InMemoryPreferencesStore().apply {
+ setCustomElementCallBaseUrl("https://custom.element.io")
+ }
+ val settingsProvider = FakeCallWidgetSettingsProvider()
+ val provider = createProvider(
+ matrixClientProvider = FakeMatrixClientProvider { Result.success(client) },
+ callWidgetSettingsProvider = settingsProvider,
+ preferencesStore = preferencesStore,
+ )
+ provider.getWidget(A_SESSION_ID, A_ROOM_ID, "clientId", "languageTag", "theme")
+
+ assertThat(settingsProvider.providedBaseUrls).containsExactly("https://custom.element.io")
+ }
+
+ private fun createProvider(
+ matrixClientProvider: MatrixClientProvider = FakeMatrixClientProvider(),
+ preferencesStore: PreferencesStore = InMemoryPreferencesStore(),
+ callWidgetSettingsProvider: CallWidgetSettingsProvider = FakeCallWidgetSettingsProvider()
+ ) = DefaultCallWidgetProvider(
+ matrixClientProvider,
+ preferencesStore,
+ callWidgetSettingsProvider,
+ )
+}
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt
new file mode 100644
index 0000000000..69ae340648
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt
@@ -0,0 +1,42 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.SessionId
+import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver
+import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
+
+class FakeCallWidgetProvider(
+ private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(),
+ private val url: String = "https://call.element.io",
+ ) : CallWidgetProvider {
+
+ var getWidgetCalled = false
+ private set
+
+ override suspend fun getWidget(
+ sessionId: SessionId,
+ roomId: RoomId,
+ clientId: String,
+ languageTag: String?,
+ theme: String?
+ ): Result> {
+ getWidgetCalled = true
+ return Result.success(widgetDriver to url)
+ }
+ }
diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt
new file mode 100644
index 0000000000..6e36dfff81
--- /dev/null
+++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeWidgetMessageInterceptor.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.call.utils
+
+import kotlinx.coroutines.flow.MutableSharedFlow
+
+class FakeWidgetMessageInterceptor : WidgetMessageInterceptor {
+ val sentMessages = mutableListOf()
+
+ override val interceptedMessages = MutableSharedFlow(extraBufferCapacity = 1)
+
+ override fun sendMessage(message: String) {
+ sentMessages += message
+ }
+
+ fun givenInterceptedMessage(message: String) {
+ interceptedMessages.tryEmit(message)
+ }
+ }
diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts
index 42fe8dade5..1719aecbe1 100644
--- a/features/ftue/impl/build.gradle.kts
+++ b/features/ftue/impl/build.gradle.kts
@@ -43,6 +43,7 @@ dependencies {
implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api)
implementation(projects.services.analytics.api)
+ implementation(projects.features.lockscreen.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.permissions.noop)
implementation(projects.services.toolbox.api)
@@ -57,6 +58,7 @@ dependencies {
testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test)
+ testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils)
ksp(libs.showkase.processor)
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
index ab6bf94a69..ab5d163e60 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt
@@ -39,6 +39,7 @@ import io.element.android.features.ftue.impl.notifications.NotificationsOptInNod
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.WelcomeNode
+import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
@@ -60,11 +61,12 @@ class FtueFlowNode @AssistedInject constructor(
private val ftueState: DefaultFtueState,
private val analyticsEntryPoint: AnalyticsEntryPoint,
private val analyticsService: AnalyticsService,
+ private val lockScreenEntryPoint: LockScreenEntryPoint,
) : BackstackNode(
backstack = BackStack(
initialElement = NavTarget.Placeholder,
savedStateMap = buildContext.savedStateMap,
- backPressHandler = NoOpBackstackHandlerStrategy(),
+ backPressHandler = NoOpBackstackHandlerStrategy(),
),
buildContext = buildContext,
plugins = plugins,
@@ -85,6 +87,9 @@ class FtueFlowNode @AssistedInject constructor(
@Parcelize
data object AnalyticsOptIn : NavTarget
+
+ @Parcelize
+ data object LockScreenSetup : NavTarget
}
private val callback = plugins.filterIsInstance().firstOrNull()
@@ -139,6 +144,17 @@ class FtueFlowNode @AssistedInject constructor(
NavTarget.AnalyticsOptIn -> {
analyticsEntryPoint.createNode(this, buildContext)
}
+ NavTarget.LockScreenSetup -> {
+ val callback = object : LockScreenEntryPoint.Callback {
+ override fun onSetupCompleted() {
+ lifecycleScope.launch { moveToNextStep() }
+ }
+ }
+ lockScreenEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callback)
+ .target(LockScreenEntryPoint.Target.Setup)
+ .build()
+ }
}
}
@@ -156,6 +172,9 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.AnalyticsOptIn -> {
backstack.replace(NavTarget.AnalyticsOptIn)
}
+ FtueStep.LockscreenSetup -> {
+ backstack.newRoot(NavTarget.LockScreenSetup)
+ }
null -> callback?.onFtueFlowFinished()
}
}
diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
index 3247d7faf8..7d80fd7413 100644
--- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
+++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/state/DefaultFtueState.kt
@@ -23,6 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.welcome.state.WelcomeScreenState
+import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.permissions.api.PermissionStateProvider
@@ -44,6 +45,7 @@ class DefaultFtueState @Inject constructor(
private val welcomeScreenState: WelcomeScreenState,
private val migrationScreenStore: MigrationScreenStore,
private val permissionStateProvider: PermissionStateProvider,
+ private val lockScreenService: LockScreenService,
private val matrixClient: MatrixClient,
) : FtueState {
@@ -72,10 +74,13 @@ class DefaultFtueState @Inject constructor(
FtueStep.MigrationScreen -> if (shouldDisplayWelcomeScreen()) FtueStep.WelcomeScreen else getNextStep(
FtueStep.WelcomeScreen
)
- FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
+ FtueStep.WelcomeScreen -> if (shouldAskNotificationPermissions()) FtueStep.NotificationsOptIn else getNextStep(
FtueStep.NotificationsOptIn
)
- FtueStep.NotificationsOptIn -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
+ FtueStep.NotificationsOptIn -> if (shouldDisplayLockscreenSetup()) FtueStep.LockscreenSetup else getNextStep(
+ FtueStep.LockscreenSetup
+ )
+ FtueStep.LockscreenSetup -> if (needsAnalyticsOptIn()) FtueStep.AnalyticsOptIn else getNextStep(
FtueStep.AnalyticsOptIn
)
FtueStep.AnalyticsOptIn -> null
@@ -83,11 +88,12 @@ class DefaultFtueState @Inject constructor(
private fun isAnyStepIncomplete(): Boolean {
return listOf(
- shouldDisplayMigrationScreen(),
- shouldDisplayWelcomeScreen(),
- shouldAskNotificationPermissions(),
- needsAnalyticsOptIn()
- ).any { it }
+ { shouldDisplayMigrationScreen() },
+ { shouldDisplayWelcomeScreen() },
+ { shouldAskNotificationPermissions() },
+ { needsAnalyticsOptIn() },
+ { shouldDisplayLockscreenSetup() },
+ ).any { it() }
}
private fun shouldDisplayMigrationScreen(): Boolean {
@@ -112,6 +118,12 @@ class DefaultFtueState @Inject constructor(
} else false
}
+ private fun shouldDisplayLockscreenSetup(): Boolean {
+ return runBlocking {
+ lockScreenService.isSetupRequired()
+ }
+ }
+
fun setWelcomeScreenShown() {
welcomeScreenState.setWelcomeScreenShown()
updateState()
@@ -128,4 +140,5 @@ sealed interface FtueStep {
data object WelcomeScreen : FtueStep
data object NotificationsOptIn : FtueStep
data object AnalyticsOptIn : FtueStep
+ data object LockscreenSetup : FtueStep
}
diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml
index aa76053cea..c7eb1f83be 100644
--- a/features/ftue/impl/src/main/res/values-sk/translations.xml
+++ b/features/ftue/impl/src/main/res/values-sk/translations.xml
@@ -8,6 +8,6 @@
"História správ pre zašifrované miestnosti nebude v tejto aktualizácii k dispozícii."
"Radi by sme od vás počuli, dajte nám vedieť, čo si myslíte, prostredníctvom stránky nastavení."
"Poďme na to!"
- "Tu je to, čo potrebujete vedieť:"
+ "Toto by ste mali vedieť:"
"Vitajte v %1$s!"
diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
index 1388eb8fc1..b84bd93d3b 100644
--- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
+++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/DefaultFtueStateTests.kt
@@ -23,6 +23,8 @@ import io.element.android.features.ftue.impl.migration.MigrationScreenStore
import io.element.android.features.ftue.impl.state.DefaultFtueState
import io.element.android.features.ftue.impl.state.FtueStep
import io.element.android.features.ftue.impl.welcome.state.FakeWelcomeState
+import io.element.android.features.lockscreen.api.LockScreenService
+import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -186,6 +188,7 @@ class DefaultFtueStateTests {
migrationScreenStore: MigrationScreenStore = InMemoryMigrationScreenStore(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
matrixClient: MatrixClient = FakeMatrixClient(),
+ lockScreenService: LockScreenService = FakeLockScreenService(),
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, // First version where notification permission is required
) = DefaultFtueState(
sdkVersionProvider = FakeBuildVersionSdkIntProvider(sdkIntVersion),
@@ -194,6 +197,7 @@ class DefaultFtueStateTests {
welcomeScreenState = welcomeState,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
+ lockScreenService = lockScreenService,
matrixClient = matrixClient,
)
}
diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
index 2a2a3995fb..f13349f0d8 100644
--- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
+++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapPlaceholder.kt
@@ -30,15 +30,15 @@ import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.res.painterResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
-import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import io.element.android.features.location.api.R
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.utils.BooleanProvider
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
@@ -81,7 +81,7 @@ internal fun StaticMapPlaceholder(
@PreviewsDayNight
@Composable
internal fun StaticMapPlaceholderPreview(
- @PreviewParameter(BooleanParameterProvider::class) values: Boolean
+ @PreviewParameter(BooleanProvider::class) values: Boolean
) = ElementPreview {
StaticMapPlaceholder(
showProgress = values,
@@ -91,8 +91,3 @@ internal fun StaticMapPlaceholderPreview(
onLoadMapClick = {},
)
}
-
-internal class BooleanParameterProvider : PreviewParameterProvider {
- override val values: Sequence
- get() = sequenceOf(true, false)
-}
diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt
index e70fe79fdf..124f30e9f4 100644
--- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt
+++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/send/SendLocationView.kt
@@ -33,8 +33,6 @@ import androidx.compose.material.icons.filled.MyLocation
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ListItem
import androidx.compose.material3.SheetValue
-import androidx.compose.material3.rememberBottomSheetScaffoldState
-import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
@@ -47,19 +45,21 @@ import com.mapbox.mapboxsdk.camera.CameraPosition
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl
-import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.R
+import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.PermissionDeniedDialog
import io.element.android.features.location.impl.common.PermissionRationaleDialog
import io.element.android.libraries.designsystem.components.button.BackButton
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.FloatingActionButton
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
+import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.maplibre.compose.CameraMode
import io.element.android.libraries.maplibre.compose.CameraMoveStartedReason
diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
index 1bb99f48ce..b6f0dc8260 100644
--- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
+++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt
@@ -33,7 +33,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.SendLocationInvocation
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.delay
diff --git a/features/lockscreen/api/build.gradle.kts b/features/lockscreen/api/build.gradle.kts
new file mode 100644
index 0000000000..97f472517c
--- /dev/null
+++ b/features/lockscreen/api/build.gradle.kts
@@ -0,0 +1,27 @@
+/*
+ * Copyright (c) 2023 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")
+}
+
+android {
+ namespace = "io.element.android.features.lockscreen.api"
+}
+
+dependencies {
+ implementation(projects.libraries.architecture)
+}
diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt
new file mode 100644
index 0000000000..f63757717e
--- /dev/null
+++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface LockScreenEntryPoint : FeatureEntryPoint {
+
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun target(target: Target): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback: Plugin {
+ fun onSetupCompleted()
+ }
+
+ enum class Target {
+ Settings,
+ Setup,
+ Unlock
+ }
+}
diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenLockState.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenLockState.kt
new file mode 100644
index 0000000000..e107729454
--- /dev/null
+++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenLockState.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.api
+
+sealed interface LockScreenLockState {
+ data object Unlocked : LockScreenLockState
+ data object Locked : LockScreenLockState
+}
diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt
new file mode 100644
index 0000000000..c6fe444119
--- /dev/null
+++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenService.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.api
+
+import kotlinx.coroutines.flow.StateFlow
+
+interface LockScreenService {
+ /**
+ * The current lock state of the app.
+ */
+ val lockState: StateFlow
+
+ /**
+ * Check if setting up the lock screen is required.
+ * @return true if the lock screen is mandatory and not setup yet, false otherwise.
+ */
+ suspend fun isSetupRequired(): Boolean
+}
diff --git a/features/lockscreen/impl/build.gradle.kts b/features/lockscreen/impl/build.gradle.kts
new file mode 100644
index 0000000000..168e72ba3d
--- /dev/null
+++ b/features/lockscreen/impl/build.gradle.kts
@@ -0,0 +1,63 @@
+/*
+ * Copyright (c) 2023 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)
+ alias(libs.plugins.ksp)
+ id("kotlin-parcelize")
+}
+
+android {
+ namespace = "io.element.android.features.lockscreen.impl"
+}
+
+anvil {
+ generateDaggerFactories.set(true)
+}
+
+dependencies {
+ ksp(libs.showkase.processor)
+ implementation(projects.anvilannotations)
+ anvil(projects.anvilcodegen)
+ api(projects.features.lockscreen.api)
+ implementation(projects.appconfig)
+ implementation(projects.libraries.core)
+ implementation(projects.libraries.architecture)
+ implementation(projects.libraries.matrix.api)
+ implementation(projects.libraries.matrixui)
+ implementation(projects.libraries.designsystem)
+ implementation(projects.libraries.featureflag.api)
+ implementation(projects.libraries.cryptography.api)
+ implementation(projects.libraries.uiStrings)
+ implementation(projects.libraries.sessionStorage.api)
+ implementation(projects.services.appnavstate.api)
+ implementation(libs.androidx.datastore.preferences)
+ implementation(libs.androidx.biometric)
+
+ testImplementation(libs.test.junit)
+ testImplementation(libs.coroutines.test)
+ testImplementation(libs.molecule.runtime)
+ testImplementation(libs.test.truth)
+ testImplementation(libs.test.turbine)
+ testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.tests.testutils)
+ testImplementation(projects.libraries.cryptography.test)
+ testImplementation(projects.libraries.cryptography.impl)
+ testImplementation(projects.libraries.featureflag.test)
+ implementation(projects.libraries.sessionStorage.test)
+ implementation(projects.services.appnavstate.test)
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt
new file mode 100644
index 0000000000..67182e4fff
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt
@@ -0,0 +1,60 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.lockscreen.api.LockScreenEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint {
+
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LockScreenEntryPoint.NodeBuilder {
+
+ var innerTarget: LockScreenEntryPoint.Target = LockScreenEntryPoint.Target.Unlock
+ val callbacks = mutableListOf()
+
+ return object : LockScreenEntryPoint.NodeBuilder {
+
+ override fun callback(callback: LockScreenEntryPoint.Callback): LockScreenEntryPoint.NodeBuilder {
+ callbacks += callback
+ return this
+ }
+
+ override fun target(target: LockScreenEntryPoint.Target): LockScreenEntryPoint.NodeBuilder {
+ innerTarget = target
+ return this
+ }
+
+ override fun build(): Node {
+ val inputs = LockScreenFlowNode.Inputs(
+ when (innerTarget) {
+ LockScreenEntryPoint.Target.Unlock -> LockScreenFlowNode.NavTarget.Unlock
+ LockScreenEntryPoint.Target.Setup -> LockScreenFlowNode.NavTarget.Setup
+ LockScreenEntryPoint.Target.Settings -> LockScreenFlowNode.NavTarget.Settings
+ }
+ )
+ val plugins = listOf(inputs) + callbacks
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
new file mode 100644
index 0000000000..f4cc699907
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenService.kt
@@ -0,0 +1,128 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.api.LockScreenLockState
+import io.element.android.features.lockscreen.api.LockScreenService
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.sessionstorage.api.observer.SessionListener
+import io.element.android.libraries.sessionstorage.api.observer.SessionObserver
+import io.element.android.services.appnavstate.api.AppForegroundStateService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+import kotlin.time.Duration
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class DefaultLockScreenService @Inject constructor(
+ private val lockScreenConfig: LockScreenConfig,
+ private val featureFlagService: FeatureFlagService,
+ private val lockScreenStore: LockScreenStore,
+ private val pinCodeManager: PinCodeManager,
+ private val coroutineScope: CoroutineScope,
+ private val sessionObserver: SessionObserver,
+ private val appForegroundStateService: AppForegroundStateService,
+ private val biometricUnlockManager: BiometricUnlockManager,
+) : LockScreenService {
+
+ private val _lockScreenState = MutableStateFlow(LockScreenLockState.Unlocked)
+ override val lockState: StateFlow = _lockScreenState
+
+ private var lockJob: Job? = null
+
+ init {
+ pinCodeManager.addCallback(object : DefaultPinCodeManagerCallback() {
+ override fun onPinCodeVerified() {
+ _lockScreenState.value = LockScreenLockState.Unlocked
+ }
+
+ override fun onPinCodeRemoved() {
+ _lockScreenState.value = LockScreenLockState.Unlocked
+ }
+ })
+ biometricUnlockManager.addCallback(object : DefaultBiometricUnlockCallback() {
+ override fun onBiometricUnlockSuccess() {
+ _lockScreenState.value = LockScreenLockState.Unlocked
+ coroutineScope.launch {
+ lockScreenStore.resetCounter()
+ }
+ }
+ })
+ coroutineScope.lockIfNeeded()
+ observeAppForegroundState()
+ observeSessionsState()
+ }
+
+ /**
+ * Makes sure to delete the pin code when the session is deleted.
+ */
+ private fun observeSessionsState() {
+ sessionObserver.addListener(object : SessionListener {
+
+ override suspend fun onSessionCreated(userId: String) = Unit
+
+ override suspend fun onSessionDeleted(userId: String) {
+ //TODO handle multi session at some point
+ pinCodeManager.deletePinCode()
+ }
+ })
+ }
+
+ /**
+ * Makes sure to lock the app if it goes in background for a certain amount of time.
+ */
+ private fun observeAppForegroundState() {
+ coroutineScope.launch {
+ appForegroundStateService.start()
+ appForegroundStateService.isInForeground.collect { isInForeground ->
+ if (isInForeground) {
+ lockJob?.cancel()
+ } else {
+ lockJob = lockIfNeeded(gracePeriod = lockScreenConfig.gracePeriod)
+ }
+ }
+ }
+ }
+
+ override suspend fun isSetupRequired(): Boolean {
+ return lockScreenConfig.isPinMandatory
+ && featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
+ && !pinCodeManager.isPinCodeAvailable()
+ }
+
+ private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
+ if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
+ delay(gracePeriod)
+ _lockScreenState.value = LockScreenLockState.Locked
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt
new file mode 100644
index 0000000000..48bfa465ee
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt
@@ -0,0 +1,117 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.lockscreen.api.LockScreenEntryPoint
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
+import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
+import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class LockScreenFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val pinCodeManager: PinCodeManager,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+
+ data class Inputs(
+ val initialNavTarget: NavTarget = NavTarget.Unlock,
+ ) : NodeInputs
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Unlock : NavTarget
+
+ @Parcelize
+ data object Setup : NavTarget
+
+ @Parcelize
+ data object Settings : NavTarget
+ }
+
+ private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
+ override fun onPinCodeCreated() {
+ plugins().forEach {
+ it.onSetupCompleted()
+ }
+ }
+ }
+
+ override fun onBuilt() {
+ super.onBuilt()
+ lifecycle.subscribe(
+ onCreate = {
+ pinCodeManager.addCallback(pinCodeManagerCallback)
+ },
+ onDestroy = {
+ pinCodeManager.removeCallback(pinCodeManagerCallback)
+ }
+ )
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Unlock -> {
+ val inputs = PinUnlockNode.Inputs(isInAppUnlock = false)
+ createNode(buildContext, plugins = listOf(inputs))
+ }
+ NavTarget.Setup -> {
+ createNode(buildContext)
+ }
+ NavTarget.Settings -> {
+ createNode(buildContext)
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlock.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlock.kt
new file mode 100644
index 0000000000..e21b8e235c
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlock.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.biometric
+
+import android.security.keystore.KeyPermanentlyInvalidatedException
+import androidx.biometric.BiometricPrompt
+import androidx.biometric.BiometricPrompt.CryptoObject
+import androidx.biometric.BiometricPrompt.PromptInfo
+import androidx.core.content.ContextCompat
+import androidx.fragment.app.FragmentActivity
+import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
+import io.element.android.libraries.cryptography.api.SecretKeyRepository
+import kotlinx.coroutines.CompletableDeferred
+import timber.log.Timber
+import java.security.InvalidKeyException
+import javax.crypto.Cipher
+
+interface BiometricUnlock {
+
+ interface Callback {
+ fun onBiometricSetupError()
+ fun onBiometricUnlockSuccess()
+ fun onBiometricUnlockFailed(error: Exception?)
+ }
+
+ sealed interface AuthenticationResult {
+ data object Success : AuthenticationResult
+ data class Failure(val error: Exception? = null) : AuthenticationResult
+ }
+
+ val isActive: Boolean
+ fun setup()
+ suspend fun authenticate(): AuthenticationResult
+}
+
+class NoopBiometricUnlock : BiometricUnlock {
+ override val isActive: Boolean = false
+ override fun setup() = Unit
+ override suspend fun authenticate() = BiometricUnlock.AuthenticationResult.Failure()
+}
+
+class DefaultBiometricUnlock(
+ private val activity: FragmentActivity,
+ private val promptInfo: PromptInfo,
+ private val secretKeyRepository: SecretKeyRepository,
+ private val encryptionDecryptionService: EncryptionDecryptionService,
+ private val keyAlias: String,
+ private val callbacks: List
+) : BiometricUnlock {
+
+ override val isActive: Boolean = true
+
+ private lateinit var cryptoObject: CryptoObject
+
+ override fun setup() {
+ try {
+ val secretKey = ensureKey()
+ val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
+ cryptoObject = CryptoObject(cipher)
+ } catch (e: InvalidKeyException) {
+ callbacks.forEach { it.onBiometricSetupError() }
+ Timber.e(e, "Invalid biometric key")
+ }
+ }
+
+ override suspend fun authenticate(): BiometricUnlock.AuthenticationResult {
+ if (!this::cryptoObject.isInitialized) {
+ return BiometricUnlock.AuthenticationResult.Failure()
+ }
+ val deferredAuthenticationResult = CompletableDeferred()
+ val executor = ContextCompat.getMainExecutor(activity.baseContext)
+ val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
+ val prompt = BiometricPrompt(activity, executor, callback)
+ prompt.authenticate(promptInfo, cryptoObject)
+ return deferredAuthenticationResult.await()
+ }
+
+ @Throws(KeyPermanentlyInvalidatedException::class)
+ private fun ensureKey() = secretKeyRepository.getOrCreateKey(keyAlias, true).also {
+ encryptionDecryptionService.createEncryptionCipher(it)
+ }
+}
+
+private class AuthenticationCallback(
+ private val callbacks: List,
+ private val deferredAuthenticationResult: CompletableDeferred,
+) : BiometricPrompt.AuthenticationCallback() {
+
+ override fun onAuthenticationError(errorCode: Int, errString: CharSequence) {
+ super.onAuthenticationError(errorCode, errString)
+ val biometricUnlockError = BiometricUnlockError(errorCode, errString.toString())
+ callbacks.forEach { it.onBiometricUnlockFailed(biometricUnlockError) }
+ deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(biometricUnlockError))
+ }
+
+ override fun onAuthenticationFailed() {
+ super.onAuthenticationFailed()
+ callbacks.forEach { it.onBiometricUnlockFailed(null) }
+ deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
+ }
+
+ override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
+ super.onAuthenticationSucceeded(result)
+ if (result.cryptoObject?.cipher.isValid()) {
+ callbacks.forEach { it.onBiometricUnlockSuccess() }
+ deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Success)
+ } else {
+ val error = IllegalStateException("Invalid cipher")
+ callbacks.forEach { it.onBiometricUnlockFailed(error) }
+ deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure())
+ }
+ }
+
+ private fun Cipher?.isValid(): Boolean {
+ if (this == null) return false
+ return runCatching {
+ doFinal("biometric_challenge".toByteArray())
+ }.isSuccess
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockError.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockError.kt
new file mode 100644
index 0000000000..37cc3dc1a9
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockError.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2022 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.
+ */
+
+package io.element.android.features.lockscreen.impl.biometric
+
+import androidx.biometric.BiometricPrompt
+
+/**
+ * Wrapper for [BiometricPrompt.AuthenticationCallback] errors.
+ */
+class BiometricUnlockError(val code: Int, message: String) : Exception(message) {
+ /**
+ * This error disables Biometric authentication, either temporarily or permanently.
+ */
+ val isAuthDisabledError: Boolean get() = code in LOCKOUT_ERROR_CODES
+
+ /**
+ * This error permanently disables Biometric authentication.
+ */
+ val isAuthPermanentlyDisabledError: Boolean get() = code == BiometricPrompt.ERROR_LOCKOUT_PERMANENT
+
+ companion object {
+ private val LOCKOUT_ERROR_CODES = arrayOf(BiometricPrompt.ERROR_LOCKOUT, BiometricPrompt.ERROR_LOCKOUT_PERMANENT)
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockManager.kt
new file mode 100644
index 0000000000..f7fe416f23
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/BiometricUnlockManager.kt
@@ -0,0 +1,38 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.biometric
+
+import androidx.compose.runtime.Composable
+
+interface BiometricUnlockManager {
+
+ /**
+ * If the device is secured for example with a pin, pattern or password.
+ */
+ val isDeviceSecured: Boolean
+
+ /**
+ * If the device has biometric hardware and if the user has enrolled at least one biometric.
+ */
+ val hasAvailableAuthenticator: Boolean
+
+ fun addCallback(callback: BiometricUnlock.Callback)
+ fun removeCallback(callback: BiometricUnlock.Callback)
+
+ @Composable
+ fun rememberBiometricUnlock(): BiometricUnlock
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt
new file mode 100644
index 0000000000..34ed45b464
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockCallback.kt
@@ -0,0 +1,23 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.biometric
+
+open class DefaultBiometricUnlockCallback : BiometricUnlock.Callback {
+ override fun onBiometricSetupError() = Unit
+ override fun onBiometricUnlockSuccess() = Unit
+ override fun onBiometricUnlockFailed(error: Exception?) = Unit
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
new file mode 100644
index 0000000000..2d3dd7146b
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/biometric/DefaultBiometricUnlockManager.kt
@@ -0,0 +1,148 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.biometric
+
+import android.app.KeyguardManager
+import android.content.Context
+import android.content.ContextWrapper
+import androidx.biometric.BiometricManager
+import androidx.biometric.BiometricPrompt
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.remember
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.platform.LocalLifecycleOwner
+import androidx.compose.ui.res.stringResource
+import androidx.core.content.getSystemService
+import androidx.fragment.app.FragmentActivity
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.R
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
+import io.element.android.libraries.cryptography.api.SecretKeyRepository
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import java.util.concurrent.CopyOnWriteArrayList
+import javax.inject.Inject
+
+private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_BIOMETRIC"
+
+@ContributesBinding(AppScope::class)
+@SingleIn(AppScope::class)
+class DefaultBiometricUnlockManager @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val lockScreenStore: LockScreenStore,
+ private val lockScreenConfig: LockScreenConfig,
+ private val encryptionDecryptionService: EncryptionDecryptionService,
+ private val secretKeyRepository: SecretKeyRepository,
+ private val coroutineScope: CoroutineScope,
+) : BiometricUnlockManager {
+
+ private val callbacks = CopyOnWriteArrayList()
+ private val biometricManager = BiometricManager.from(context)
+ private val keyguardManager: KeyguardManager = context.getSystemService()!!
+
+ /**
+ * Returns true if a weak biometric method (i.e.: some face or iris unlock implementations) can be used.
+ */
+ private val canUseWeakBiometricAuth: Boolean
+ get() = lockScreenConfig.isWeakBiometricsEnabled
+ && biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_WEAK) == BiometricManager.BIOMETRIC_SUCCESS
+
+ /**
+ * Returns true if a strong biometric method (i.e.: fingerprint, some face or iris unlock implementations) can be used.
+ */
+ private val canUseStrongBiometricAuth: Boolean
+ get() = lockScreenConfig.isStrongBiometricsEnabled
+ && biometricManager.canAuthenticate(BiometricManager.Authenticators.BIOMETRIC_STRONG) == BiometricManager.BIOMETRIC_SUCCESS
+
+ /**
+ * Returns true if any biometric method (weak or strong) can be used.
+ */
+ override val hasAvailableAuthenticator: Boolean
+ get() = canUseWeakBiometricAuth || canUseStrongBiometricAuth
+
+ override val isDeviceSecured: Boolean
+ get() = keyguardManager.isDeviceSecure
+
+ private val internalCallback = object : DefaultBiometricUnlockCallback() {
+ override fun onBiometricSetupError() {
+ coroutineScope.launch {
+ lockScreenStore.setIsBiometricUnlockAllowed(false)
+ secretKeyRepository.deleteKey(SECRET_KEY_ALIAS)
+ }
+ }
+ }
+
+ @Composable
+ override fun rememberBiometricUnlock(): BiometricUnlock {
+ val isBiometricAllowed by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
+ val lifecycleState by LocalLifecycleOwner.current.lifecycle.currentStateFlow.collectAsState()
+ val isAvailable by remember(lifecycleState) {
+ derivedStateOf {
+ isBiometricAllowed && hasAvailableAuthenticator
+ }
+ }
+ val promptTitle = stringResource(id = R.string.screen_app_lock_biometric_unlock_title_android)
+ val promptNegative = stringResource(id = R.string.screen_app_lock_use_pin_android)
+ val activity = LocalContext.current.findFragmentActivity()
+ return remember(isAvailable) {
+ if (isAvailable && activity != null) {
+ val authenticators = when {
+ canUseStrongBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_STRONG
+ canUseWeakBiometricAuth -> BiometricManager.Authenticators.BIOMETRIC_WEAK
+ else -> 0
+ }
+ val promptInfo = BiometricPrompt.PromptInfo.Builder().apply {
+ setTitle(promptTitle)
+ setNegativeButtonText(promptNegative)
+ setAllowedAuthenticators(authenticators)
+ }.build()
+ DefaultBiometricUnlock(
+ activity = activity,
+ promptInfo = promptInfo,
+ secretKeyRepository = secretKeyRepository,
+ encryptionDecryptionService = encryptionDecryptionService,
+ keyAlias = SECRET_KEY_ALIAS,
+ callbacks = callbacks + internalCallback
+ )
+ } else {
+ NoopBiometricUnlock()
+ }
+ }
+ }
+
+ override fun addCallback(callback: BiometricUnlock.Callback) {
+ callbacks.add(callback)
+ }
+
+ override fun removeCallback(callback: BiometricUnlock.Callback) {
+ callbacks.remove(callback)
+ }
+
+ private fun Context.findFragmentActivity(): FragmentActivity? = when (this) {
+ is FragmentActivity -> this
+ is ContextWrapper -> baseContext.findFragmentActivity()
+ else -> null
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt
new file mode 100644
index 0000000000..019236aba7
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/components/PinEntryTextField.kt
@@ -0,0 +1,138 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.components
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.border
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.ExperimentalLayoutApi
+import androidx.compose.foundation.layout.FlowRow
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.foundation.text.BasicTextField
+import androidx.compose.foundation.text.KeyboardOptions
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.text.input.KeyboardType
+import androidx.compose.ui.unit.dp
+import io.element.android.features.lockscreen.impl.pin.model.PinDigit
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.pinDigitBg
+import io.element.android.libraries.theme.ElementTheme
+
+@Composable
+fun PinEntryTextField(
+ pinEntry: PinEntry,
+ isSecured: Boolean,
+ onValueChange: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ BasicTextField(
+ modifier = modifier,
+ value = pinEntry.toText(),
+ onValueChange = {
+ onValueChange(it)
+ },
+ keyboardOptions = KeyboardOptions(keyboardType = KeyboardType.NumberPassword),
+ decorationBox = {
+ PinEntryRow(pinEntry = pinEntry, isSecured = isSecured)
+ }
+ )
+}
+
+@OptIn(ExperimentalLayoutApi::class)
+@Composable
+private fun PinEntryRow(
+ pinEntry: PinEntry,
+ isSecured: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ FlowRow(
+ modifier = modifier,
+ horizontalArrangement = Arrangement.spacedBy(8.dp, alignment = Alignment.CenterHorizontally),
+ verticalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ for (digit in pinEntry.digits) {
+ PinDigitView(digit = digit, isSecured = isSecured)
+ }
+ }
+}
+
+@Composable
+private fun PinDigitView(
+ digit: PinDigit,
+ isSecured: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val shape = RoundedCornerShape(8.dp)
+ val appearanceModifier = when (digit) {
+ PinDigit.Empty -> {
+ Modifier.border(1.dp, ElementTheme.colors.iconPrimary, shape)
+ }
+ is PinDigit.Filled -> {
+ Modifier.background(ElementTheme.colors.pinDigitBg, shape)
+ }
+ }
+ Box(
+ modifier = modifier
+ .size(48.dp)
+ .then(appearanceModifier),
+ contentAlignment = Alignment.Center,
+
+ ) {
+ if (digit is PinDigit.Filled) {
+ val text = if (isSecured) {
+ "•"
+ } else {
+ digit.value.toString()
+ }
+ Text(
+ text = text,
+ style = ElementTheme.typography.fontHeadingMdBold
+ )
+ }
+
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun PinEntryTextFieldPreview() {
+ ElementPreview {
+ val pinEntry = PinEntry.createEmpty(4).fillWith("12")
+ Column {
+ PinEntryTextField(
+ pinEntry = pinEntry,
+ isSecured = true,
+ onValueChange = {},
+ )
+ Spacer(modifier = Modifier.size(16.dp))
+ PinEntryTextField(
+ pinEntry = pinEntry,
+ isSecured = false,
+ onValueChange = {},
+ )
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
new file mode 100644
index 0000000000..a256562c43
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManager.kt
@@ -0,0 +1,97 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
+import io.element.android.libraries.cryptography.api.EncryptionResult
+import io.element.android.libraries.cryptography.api.SecretKeyRepository
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.SingleIn
+import java.util.concurrent.CopyOnWriteArrayList
+import javax.inject.Inject
+
+private const val SECRET_KEY_ALIAS = "elementx.SECRET_KEY_ALIAS_PIN_CODE"
+
+@ContributesBinding(AppScope::class)
+@SingleIn(AppScope::class)
+class DefaultPinCodeManager @Inject constructor(
+ private val secretKeyRepository: SecretKeyRepository,
+ private val encryptionDecryptionService: EncryptionDecryptionService,
+ private val lockScreenStore: LockScreenStore,
+) : PinCodeManager {
+
+ private val callbacks = CopyOnWriteArrayList()
+
+ override fun addCallback(callback: PinCodeManager.Callback) {
+ callbacks.add(callback)
+ }
+
+ override fun removeCallback(callback: PinCodeManager.Callback) {
+ callbacks.remove(callback)
+ }
+
+ override suspend fun isPinCodeAvailable(): Boolean {
+ return lockScreenStore.hasPinCode()
+ }
+
+ override suspend fun getPinCodeSize(): Int {
+ val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return 0
+ val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
+ val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
+ return decryptedPinCode.size
+ }
+
+ override suspend fun createPinCode(pinCode: String) {
+ val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
+ val encryptedPinCode = encryptionDecryptionService.encrypt(secretKey, pinCode.toByteArray()).toBase64()
+ lockScreenStore.saveEncryptedPinCode(encryptedPinCode)
+ callbacks.forEach { it.onPinCodeCreated() }
+ }
+
+ override suspend fun verifyPinCode(pinCode: String): Boolean {
+ val encryptedPinCode = lockScreenStore.getEncryptedCode() ?: return false
+ return try {
+ val secretKey = secretKeyRepository.getOrCreateKey(SECRET_KEY_ALIAS, false)
+ val decryptedPinCode = encryptionDecryptionService.decrypt(secretKey, EncryptionResult.fromBase64(encryptedPinCode))
+ val pinCodeToCheck = pinCode.toByteArray()
+ decryptedPinCode.contentEquals(pinCodeToCheck).also { isPinCodeCorrect ->
+ if (isPinCodeCorrect) {
+ lockScreenStore.resetCounter()
+ callbacks.forEach { callback ->
+ callback.onPinCodeVerified()
+ }
+ } else {
+ lockScreenStore.onWrongPin()
+ }
+ }
+ } catch (failure: Throwable) {
+ false
+ }
+ }
+
+ override suspend fun deletePinCode() {
+ lockScreenStore.deleteEncryptedPinCode()
+ lockScreenStore.resetCounter()
+ callbacks.forEach { it.onPinCodeRemoved() }
+ }
+
+ override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
+ return lockScreenStore.getRemainingPinCodeAttemptsNumber()
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerCallback.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerCallback.kt
new file mode 100644
index 0000000000..3ce8565cd2
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerCallback.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin
+
+open class DefaultPinCodeManagerCallback : PinCodeManager.Callback {
+ override fun onPinCodeVerified() = Unit
+
+ override fun onPinCodeCreated() = Unit
+
+ override fun onPinCodeRemoved() = Unit
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt
new file mode 100644
index 0000000000..21e7281dc8
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/PinCodeManager.kt
@@ -0,0 +1,85 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin
+
+/**
+ * This interface is the main interface to manage the pin code.
+ * Implementation should take care of encrypting the pin code and storing it.
+ */
+interface PinCodeManager {
+
+ /**
+ * Callbacks for pin code management events.
+ */
+ interface Callback {
+ /**
+ * Called when the pin code is verified.
+ */
+ fun onPinCodeVerified()
+
+ /**
+ * Called when the pin code is created.
+ */
+ fun onPinCodeCreated()
+
+ /**
+ * Called when the pin code is removed.
+ */
+ fun onPinCodeRemoved()
+ }
+
+ /**
+ * Register a callback to be notified of pin code management events.
+ */
+ fun addCallback(callback: Callback)
+
+ /**
+ * Unregister callback to be notified of pin code management events.
+ */
+ fun removeCallback(callback: Callback)
+
+ /**
+ * @return true if a pin code is available.
+ */
+ suspend fun isPinCodeAvailable(): Boolean
+
+ /**
+ * @return the size of the saved pin code.
+ */
+ suspend fun getPinCodeSize(): Int
+
+ /**
+ * Creates a new encrypted pin code.
+ * @param pinCode the clear pin code to create
+ */
+ suspend fun createPinCode(pinCode: String)
+
+ /**
+ * @return true if the pin code is correct.
+ */
+ suspend fun verifyPinCode(pinCode: String): Boolean
+
+ /**
+ * Deletes the previously created pin code.
+ */
+ suspend fun deletePinCode()
+
+ /**
+ * @return the number of remaining attempts before the pin code is blocked.
+ */
+ suspend fun getRemainingPinCodeAttemptsNumber(): Int
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt
new file mode 100644
index 0000000000..aa3c45e02e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinDigit.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin.model
+
+sealed interface PinDigit {
+ data object Empty : PinDigit
+ data class Filled(val value: Char) : PinDigit
+
+ fun toText(): String {
+ return when (this) {
+ is Empty -> ""
+ is Filled -> value.toString()
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt
new file mode 100644
index 0000000000..96c3bec3ad
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntry.kt
@@ -0,0 +1,88 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin.model
+
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toPersistentList
+
+data class PinEntry(
+ val digits: ImmutableList,
+) {
+
+ companion object {
+ fun createEmpty(size: Int): PinEntry {
+ val digits = List(size) { PinDigit.Empty }
+ return PinEntry(
+ digits = digits.toPersistentList()
+ )
+ }
+ }
+
+ val size = digits.size
+
+ /**
+ * Fill the first digits with the given text.
+ * Can't be more than the size of the PinEntry
+ * Keep the Empty digits at the end
+ * @return the new PinEntry
+ */
+ fun fillWith(text: String): PinEntry {
+ val newDigits = MutableList(size) { PinDigit.Empty }
+ text.forEachIndexed { index, char ->
+ if (index < size && char.isDigit()) {
+ newDigits[index] = PinDigit.Filled(char)
+ }
+ }
+ return copy(digits = newDigits.toPersistentList())
+ }
+
+ fun deleteLast(): PinEntry {
+ if (isEmpty()) return this
+ val newDigits = digits.toMutableList()
+ newDigits.indexOfLast { it is PinDigit.Filled }.also { lastFilled ->
+ newDigits[lastFilled] = PinDigit.Empty
+ }
+ return copy(digits = newDigits.toPersistentList())
+ }
+
+ fun addDigit(digit: Char): PinEntry {
+ if (isComplete()) return this
+ val newDigits = digits.toMutableList()
+ newDigits.indexOfFirst { it is PinDigit.Empty }.also { firstEmpty ->
+ newDigits[firstEmpty] = PinDigit.Filled(digit)
+ }
+ return copy(digits = newDigits.toPersistentList())
+ }
+
+ fun clear(): PinEntry {
+ return createEmpty(size)
+ }
+
+ fun isComplete(): Boolean {
+ return digits.all { it is PinDigit.Filled }
+ }
+
+ fun isEmpty(): Boolean {
+ return digits.all { it is PinDigit.Empty }
+ }
+
+ fun toText(): String {
+ return digits.joinToString("") {
+ it.toText()
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt
new file mode 100644
index 0000000000..6cb41bfefc
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsEvents.kt
@@ -0,0 +1,24 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+sealed interface LockScreenSettingsEvents {
+ data object OnRemovePin : LockScreenSettingsEvents
+ data object ConfirmRemovePin : LockScreenSettingsEvents
+ data object CancelRemovePin : LockScreenSettingsEvents
+ data object ToggleBiometricAllowed : LockScreenSettingsEvents
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt
new file mode 100644
index 0000000000..82f5cafbe7
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt
@@ -0,0 +1,147 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.lifecycle.lifecycleScope
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.node.node
+import com.bumble.appyx.core.plugin.Plugin
+import com.bumble.appyx.navmodel.backstack.BackStack
+import com.bumble.appyx.navmodel.backstack.operation.newRoot
+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.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
+import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+import kotlinx.coroutines.launch
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class LockScreenSettingsFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val pinCodeManager: PinCodeManager,
+ private val biometricUnlockManager: BiometricUnlockManager,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Unknown,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Unknown : NavTarget
+
+ @Parcelize
+ data object Unlock : NavTarget
+
+ @Parcelize
+ data object Setup : NavTarget
+
+ @Parcelize
+ data object Settings : NavTarget
+ }
+
+ private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
+ override fun onPinCodeVerified() {
+ backstack.newRoot(NavTarget.Settings)
+ }
+
+ override fun onPinCodeRemoved() {
+ navigateUp()
+ }
+ }
+
+ private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
+ override fun onBiometricUnlockSuccess() {
+ backstack.newRoot(NavTarget.Settings)
+ }
+ }
+
+ init {
+ lifecycleScope.launch {
+ if (pinCodeManager.isPinCodeAvailable()) {
+ backstack.newRoot(NavTarget.Unlock)
+ } else {
+ backstack.newRoot(NavTarget.Setup)
+ }
+ }
+ lifecycle.subscribe(
+ onCreate = {
+ pinCodeManager.addCallback(pinCodeManagerCallback)
+ biometricUnlockManager.addCallback(biometricUnlockCallback)
+ },
+ onDestroy = {
+ pinCodeManager.removeCallback(pinCodeManagerCallback)
+ biometricUnlockManager.removeCallback(biometricUnlockCallback)
+ }
+ )
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Unlock -> {
+ val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
+ createNode(buildContext, plugins = listOf(inputs))
+ }
+ NavTarget.Setup -> {
+ val callback = object : LockScreenSetupFlowNode.Callback {
+ override fun onSetupDone() {
+ backstack.newRoot(NavTarget.Settings)
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ NavTarget.Settings -> {
+ val callback = object : LockScreenSettingsNode.Callback {
+ override fun onChangePinClicked() {
+ backstack.push(NavTarget.Setup)
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ NavTarget.Unknown -> node(buildContext) { }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt
new file mode 100644
index 0000000000..96f5393483
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsNode.kt
@@ -0,0 +1,55 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+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 com.bumble.appyx.core.plugin.plugins
+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 LockScreenSettingsNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: LockScreenSettingsPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun onChangePinClicked()
+ }
+
+ private fun onChangePinClicked() {
+ plugins().forEach { it.onChangePinClicked() }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ LockScreenSettingsView(
+ state = state,
+ onBackPressed = this::navigateUp,
+ onChangePinClicked = this::onChangePinClicked,
+ modifier = modifier,
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
new file mode 100644
index 0000000000..2c086ea92a
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenter.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.architecture.Presenter
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class LockScreenSettingsPresenter @Inject constructor(
+ private val lockScreenConfig: LockScreenConfig,
+ private val pinCodeManager: PinCodeManager,
+ private val lockScreenStore: LockScreenStore,
+ private val biometricUnlockManager: BiometricUnlockManager,
+ private val coroutineScope: CoroutineScope,
+) : Presenter {
+
+ @Composable
+ override fun present(): LockScreenSettingsState {
+ var triggerComputation by remember {
+ mutableIntStateOf(0)
+ }
+ var showRemovePinOption by remember {
+ mutableStateOf(false)
+ }
+ var showToggleBiometric by remember {
+ mutableStateOf(false)
+ }
+ val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
+ var showRemovePinConfirmation by remember {
+ mutableStateOf(false)
+ }
+ LaunchedEffect(triggerComputation) {
+ showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
+ showToggleBiometric = biometricUnlockManager.isDeviceSecured
+ }
+
+ fun handleEvents(event: LockScreenSettingsEvents) {
+ when (event) {
+ LockScreenSettingsEvents.CancelRemovePin -> showRemovePinConfirmation = false
+ LockScreenSettingsEvents.ConfirmRemovePin -> {
+ coroutineScope.launch {
+ if (showRemovePinConfirmation) {
+ showRemovePinConfirmation = false
+ pinCodeManager.deletePinCode()
+ triggerComputation++
+ }
+ }
+ }
+ LockScreenSettingsEvents.OnRemovePin -> showRemovePinConfirmation = true
+ LockScreenSettingsEvents.ToggleBiometricAllowed -> {
+ coroutineScope.launch {
+ lockScreenStore.setIsBiometricUnlockAllowed(!isBiometricEnabled)
+ }
+ }
+ }
+ }
+
+ return LockScreenSettingsState(
+ showRemovePinOption = showRemovePinOption,
+ isBiometricEnabled = isBiometricEnabled,
+ showRemovePinConfirmation = showRemovePinConfirmation,
+ showToggleBiometric = showToggleBiometric,
+ eventSink = ::handleEvents
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt
new file mode 100644
index 0000000000..856899eb47
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsState.kt
@@ -0,0 +1,25 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+data class LockScreenSettingsState(
+ val showRemovePinOption: Boolean,
+ val isBiometricEnabled: Boolean,
+ val showRemovePinConfirmation: Boolean,
+ val showToggleBiometric: Boolean,
+ val eventSink: (LockScreenSettingsEvents) -> Unit
+)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt
new file mode 100644
index 0000000000..2db2d62103
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsStateProvider.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class LockScreenSettingsStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLockScreenSettingsState(),
+ aLockScreenSettingsState(isLockMandatory = true),
+ aLockScreenSettingsState(showRemovePinConfirmation = true),
+ )
+}
+
+fun aLockScreenSettingsState(
+ isLockMandatory: Boolean = false,
+ isBiometricEnabled: Boolean = false,
+ showRemovePinConfirmation: Boolean = false,
+ showToggleBiometric: Boolean = true,
+) = LockScreenSettingsState(
+ showRemovePinOption = isLockMandatory,
+ isBiometricEnabled = isBiometricEnabled,
+ showRemovePinConfirmation = showRemovePinConfirmation,
+ showToggleBiometric = showToggleBiometric,
+ eventSink = {}
+)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
new file mode 100644
index 0000000000..15254eb728
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt
@@ -0,0 +1,98 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.features.lockscreen.impl.R
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.components.preferences.PreferenceDivider
+import io.element.android.libraries.designsystem.components.preferences.PreferencePage
+import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
+import io.element.android.libraries.designsystem.components.preferences.PreferenceText
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.theme.ElementTheme
+
+@Composable
+fun LockScreenSettingsView(
+ state: LockScreenSettingsState,
+ onChangePinClicked: () -> Unit,
+ onBackPressed: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ PreferencePage(
+ title = stringResource(id = io.element.android.libraries.ui.strings.R.string.common_screen_lock),
+ onBackPressed = onBackPressed,
+ modifier = modifier
+ ) {
+ PreferenceCategory(showDivider = false) {
+ PreferenceText(
+ title = stringResource(id = R.string.screen_app_lock_settings_change_pin),
+ onClick = onChangePinClicked
+ )
+ PreferenceDivider()
+ if (state.showRemovePinOption) {
+ PreferenceText(
+ title = stringResource(id = R.string.screen_app_lock_settings_remove_pin),
+ tintColor = ElementTheme.colors.textCriticalPrimary,
+ onClick = {
+ state.eventSink(LockScreenSettingsEvents.OnRemovePin)
+ }
+ )
+ }
+ if (state.showToggleBiometric) {
+ PreferenceDivider()
+ PreferenceSwitch(
+ title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
+ isChecked = state.isBiometricEnabled,
+ onCheckedChange = {
+ state.eventSink(LockScreenSettingsEvents.ToggleBiometricAllowed)
+ }
+ )
+ }
+ }
+ }
+ if (state.showRemovePinConfirmation) {
+ ConfirmationDialog(
+ title = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_title),
+ content = stringResource(id = R.string.screen_app_lock_settings_remove_pin_alert_message),
+ onSubmitClicked = {
+ state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
+ },
+ onDismiss = {
+ state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
+ })
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LockScreenSettingsViewPreview(
+ @PreviewParameter(LockScreenSettingsStateProvider::class) state: LockScreenSettingsState,
+) {
+ ElementPreview {
+ LockScreenSettingsView(
+ state = state,
+ onChangePinClicked = {},
+ onBackPressed = {},
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt
new file mode 100644
index 0000000000..f612f3b82e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/LockScreenSetupFlowNode.kt
@@ -0,0 +1,115 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup
+
+import android.os.Parcelable
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import com.bumble.appyx.core.composable.Children
+import com.bumble.appyx.core.lifecycle.subscribe
+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.newRoot
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.setup.biometric.SetupBiometricNode
+import io.element.android.features.lockscreen.impl.setup.pin.SetupPinNode
+import io.element.android.libraries.architecture.BackstackNode
+import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.SessionScope
+import kotlinx.parcelize.Parcelize
+
+@ContributesNode(SessionScope::class)
+class LockScreenSetupFlowNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val pinCodeManager: PinCodeManager,
+) : BackstackNode(
+ backstack = BackStack(
+ initialElement = NavTarget.Pin,
+ savedStateMap = buildContext.savedStateMap,
+ ),
+ buildContext = buildContext,
+ plugins = plugins,
+) {
+
+ interface Callback : Plugin {
+ fun onSetupDone()
+ }
+
+ private fun onSetupDone() {
+ plugins().forEach { it.onSetupDone() }
+ }
+
+ sealed interface NavTarget : Parcelable {
+ @Parcelize
+ data object Pin : NavTarget
+
+ @Parcelize
+ data object Biometric : NavTarget
+ }
+
+ private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
+
+ override fun onPinCodeCreated() {
+ backstack.newRoot(NavTarget.Biometric)
+ }
+ }
+
+ init {
+ lifecycle.subscribe(
+ onCreate = {
+ pinCodeManager.addCallback(pinCodeManagerCallback)
+ },
+ onDestroy = {
+ pinCodeManager.removeCallback(pinCodeManagerCallback)
+ }
+ )
+ }
+
+ override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
+ return when (navTarget) {
+ NavTarget.Pin -> {
+ createNode(buildContext)
+ }
+ NavTarget.Biometric -> {
+ val callback = object : SetupBiometricNode.Callback {
+ override fun onBiometricSetupDone() {
+ onSetupDone()
+ }
+ }
+ createNode(buildContext, plugins = listOf(callback))
+ }
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ Children(
+ navModel = backstack,
+ modifier = modifier,
+ transitionHandler = rememberDefaultTransitionHandler(),
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt
new file mode 100644
index 0000000000..d4d9e76d96
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+sealed interface SetupBiometricEvents {
+ data object AllowBiometric : SetupBiometricEvents
+ data object UsePin : SetupBiometricEvents
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricNode.kt
new file mode 100644
index 0000000000..ae99730a0e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricNode.kt
@@ -0,0 +1,59 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+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 com.bumble.appyx.core.plugin.plugins
+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 SetupBiometricNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: SetupBiometricPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ interface Callback : Plugin {
+ fun onBiometricSetupDone()
+ }
+
+ private fun onSetupDone() {
+ plugins().forEach { it.onBiometricSetupDone() }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ LaunchedEffect(state.isBiometricSetupDone) {
+ if (state.isBiometricSetupDone) {
+ onSetupDone()
+ }
+ }
+ SetupBiometricView(
+ state = state,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
new file mode 100644
index 0000000000..ff65a2c7aa
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenter.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.architecture.Presenter
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class SetupBiometricPresenter @Inject constructor(
+ private val lockScreenStore: LockScreenStore,
+) : Presenter {
+
+ @Composable
+ override fun present(): SetupBiometricState {
+
+ var isBiometricSetupDone by remember {
+ mutableStateOf(false)
+ }
+
+ val coroutineScope = rememberCoroutineScope()
+
+ fun handleEvents(event: SetupBiometricEvents) {
+ when (event) {
+ SetupBiometricEvents.AllowBiometric -> coroutineScope.launch {
+ lockScreenStore.setIsBiometricUnlockAllowed(true)
+ isBiometricSetupDone = true
+ }
+ SetupBiometricEvents.UsePin -> coroutineScope.launch {
+ lockScreenStore.setIsBiometricUnlockAllowed(false)
+ isBiometricSetupDone = true
+ }
+ }
+ }
+
+ return SetupBiometricState(
+ isBiometricSetupDone = isBiometricSetupDone,
+ eventSink = ::handleEvents
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt
new file mode 100644
index 0000000000..2f352f5d89
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricState.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+data class SetupBiometricState(
+ val isBiometricSetupDone: Boolean,
+ val eventSink: (SetupBiometricEvents) -> Unit
+)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt
new file mode 100644
index 0000000000..baecb23c96
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricStateProvider.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class SetupBiometricStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aSetupBiometricState(),
+ )
+}
+
+fun aSetupBiometricState(
+ isBiometricSetupDone: Boolean = false,
+) = SetupBiometricState(
+ isBiometricSetupDone = isBiometricSetupDone,
+ eventSink = {}
+)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
new file mode 100644
index 0000000000..e1bdabb114
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricView.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+import androidx.activity.compose.BackHandler
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Fingerprint
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.lockscreen.impl.R
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+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.TextButton
+
+@Composable
+fun SetupBiometricView(
+ state: SetupBiometricState,
+ modifier: Modifier = Modifier,
+) {
+ BackHandler(true) {
+ state.eventSink(SetupBiometricEvents.UsePin)
+ }
+ HeaderFooterPage(
+ modifier = modifier.padding(top = 80.dp),
+ header = {
+ SetupBiometricHeader()
+ },
+ footer = {
+ SetupBiometricFooter(
+ onAllowClicked = { state.eventSink(SetupBiometricEvents.AllowBiometric) },
+ onSkipClicked = { state.eventSink(SetupBiometricEvents.UsePin) }
+ )
+ },
+ )
+}
+
+@Composable
+private fun SetupBiometricHeader(modifier: Modifier = Modifier) {
+ val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
+ IconTitleSubtitleMolecule(
+ iconImageVector = Icons.Default.Fingerprint,
+ title = stringResource(id = R.string.screen_app_lock_settings_enable_biometric_unlock),
+ subTitle = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_subtitle, biometricAuth),
+ modifier = modifier
+ )
+}
+
+@Composable
+private fun SetupBiometricFooter(
+ onAllowClicked: () -> Unit,
+ onSkipClicked: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ Column(
+ modifier = modifier.fillMaxWidth(),
+ verticalArrangement = spacedBy(16.dp),
+ horizontalAlignment = Alignment.CenterHorizontally
+ ) {
+ val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
+ Button(
+ text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_allow_title, biometricAuth),
+ onClick = onAllowClicked
+ )
+ TextButton(
+ text = stringResource(id = R.string.screen_app_lock_setup_biometric_unlock_skip),
+ onClick = onSkipClicked
+ )
+ }
+}
+
+@Composable
+@PreviewsDayNight
+internal fun SetupBiometricViewPreview(@PreviewParameter(SetupBiometricStateProvider::class) state: SetupBiometricState) {
+ ElementPreview {
+ SetupBiometricView(
+ state = state,
+ )
+ }
+}
+
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt
new file mode 100644
index 0000000000..d0105b1a21
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+sealed interface SetupPinEvents {
+ data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents
+ data object ClearFailure : SetupPinEvents
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt
new file mode 100644
index 0000000000..c159c4cc58
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinNode.kt
@@ -0,0 +1,45 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+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 SetupPinNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: SetupPinPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ SetupPinView(
+ state = state,
+ onBackClicked = this::navigateUp,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
new file mode 100644
index 0000000000..33bf5ccba2
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenter.kt
@@ -0,0 +1,116 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.setValue
+import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
+import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.core.meta.BuildMeta
+import kotlinx.coroutines.delay
+import javax.inject.Inject
+
+class SetupPinPresenter @Inject constructor(
+ private val lockScreenConfig: LockScreenConfig,
+ private val pinValidator: PinValidator,
+ private val buildMeta: BuildMeta,
+ private val pinCodeManager: PinCodeManager,
+) : Presenter {
+
+ @Composable
+ override fun present(): SetupPinState {
+ var choosePinEntry by remember {
+ mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize))
+ }
+ var confirmPinEntry by remember {
+ mutableStateOf(PinEntry.createEmpty(lockScreenConfig.pinSize))
+ }
+ var isConfirmationStep by remember {
+ mutableStateOf(false)
+ }
+ var setupPinFailure by remember {
+ mutableStateOf(null)
+ }
+ LaunchedEffect(choosePinEntry) {
+ if (choosePinEntry.isComplete()) {
+ when (val pinValidationResult = pinValidator.isPinValid(choosePinEntry)) {
+ is PinValidator.Result.Invalid -> {
+ setupPinFailure = pinValidationResult.failure
+ }
+ PinValidator.Result.Valid -> {
+ // Leave some time for the ui to refresh before showing confirmation
+ delay(150)
+ isConfirmationStep = true
+ }
+ }
+ }
+ }
+
+ LaunchedEffect(confirmPinEntry) {
+ if (confirmPinEntry.isComplete()) {
+ if (confirmPinEntry == choosePinEntry) {
+ pinCodeManager.createPinCode(confirmPinEntry.toText())
+ } else {
+ setupPinFailure = SetupPinFailure.PinsDontMatch
+ }
+ }
+ }
+
+ fun handleEvents(event: SetupPinEvents) {
+ when (event) {
+ is SetupPinEvents.OnPinEntryChanged -> {
+ if (isConfirmationStep) {
+ confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
+ } else {
+ choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
+ }
+ }
+ SetupPinEvents.ClearFailure -> {
+ when (setupPinFailure) {
+ is SetupPinFailure.PinsDontMatch -> {
+ choosePinEntry = choosePinEntry.clear()
+ confirmPinEntry = confirmPinEntry.clear()
+ }
+ is SetupPinFailure.PinBlacklisted -> {
+ choosePinEntry = choosePinEntry.clear()
+ }
+ null -> Unit
+ }
+ isConfirmationStep = false
+ setupPinFailure = null
+ }
+ }
+ }
+
+ return SetupPinState(
+ choosePinEntry = choosePinEntry,
+ confirmPinEntry = confirmPinEntry,
+ isConfirmationStep = isConfirmationStep,
+ setupPinFailure = setupPinFailure,
+ appName = buildMeta.applicationName,
+ eventSink = ::handleEvents
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt
new file mode 100644
index 0000000000..4c9b68178f
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinState.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
+
+data class SetupPinState(
+ val choosePinEntry: PinEntry,
+ val confirmPinEntry: PinEntry,
+ val isConfirmationStep: Boolean,
+ val setupPinFailure: SetupPinFailure?,
+ val appName: String,
+ val eventSink: (SetupPinEvents) -> Unit
+) {
+ val activePinEntry = if (isConfirmationStep) {
+ confirmPinEntry
+ } else {
+ choosePinEntry
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
new file mode 100644
index 0000000000..582c27b5fe
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinStateProvider.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
+
+open class SetupPinStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aSetupPinState(),
+ aSetupPinState(
+ choosePinEntry = PinEntry.createEmpty(4).fillWith("12")
+ ),
+ aSetupPinState(
+ choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
+ isConfirmationStep = true,
+ ),
+ aSetupPinState(
+ choosePinEntry = PinEntry.createEmpty(4).fillWith("1789"),
+ confirmPinEntry = PinEntry.createEmpty(4).fillWith("1788"),
+ isConfirmationStep = true,
+ creationFailure = SetupPinFailure.PinsDontMatch
+ ),
+ aSetupPinState(
+ choosePinEntry = PinEntry.createEmpty(4).fillWith("1111"),
+ creationFailure = SetupPinFailure.PinBlacklisted
+ ),
+
+ )
+}
+
+fun aSetupPinState(
+ choosePinEntry: PinEntry = PinEntry.createEmpty(4),
+ confirmPinEntry: PinEntry = PinEntry.createEmpty(4),
+ isConfirmationStep: Boolean = false,
+ creationFailure: SetupPinFailure? = null,
+) = SetupPinState(
+ choosePinEntry = choosePinEntry,
+ confirmPinEntry = confirmPinEntry,
+ isConfirmationStep = isConfirmationStep,
+ setupPinFailure = creationFailure,
+ appName = "Element",
+ eventSink = {}
+)
+
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
new file mode 100644
index 0000000000..3d41d1ba1d
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinView.kt
@@ -0,0 +1,164 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+@file:OptIn(ExperimentalMaterial3Api::class)
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.consumeWindowInsets
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.rememberScrollState
+import androidx.compose.foundation.verticalScroll
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.features.lockscreen.impl.R
+import io.element.android.features.lockscreen.impl.components.PinEntryTextField
+import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.components.button.BackButton
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+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
+
+@Composable
+fun SetupPinView(
+ state: SetupPinState,
+ onBackClicked: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Scaffold(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ navigationIcon = {
+ BackButton(onClick = onBackClicked)
+ },
+ title = {}
+ )
+ },
+ content = { padding ->
+ val scrollState = rememberScrollState()
+ Column(
+ modifier = Modifier
+ .imePadding()
+ .padding(padding)
+ .consumeWindowInsets(padding)
+ .verticalScroll(state = scrollState)
+ .padding(vertical = 16.dp, horizontal = 20.dp),
+ ) {
+ SetupPinHeader(state.isConfirmationStep, state.appName)
+ SetupPinContent(state)
+ }
+ }
+ )
+}
+
+@Composable
+private fun SetupPinHeader(
+ isValidationStep: Boolean,
+ appName: String,
+ modifier: Modifier = Modifier,
+) {
+ Column(
+ modifier = modifier,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ ) {
+ IconTitleSubtitleMolecule(
+ title = if (isValidationStep) {
+ stringResource(id = R.string.screen_app_lock_setup_confirm_pin)
+ } else {
+ stringResource(id = R.string.screen_app_lock_setup_choose_pin)
+ },
+ subTitle = stringResource(id = R.string.screen_app_lock_setup_pin_context, appName),
+ iconImageVector = Icons.Filled.Lock,
+ )
+ }
+}
+
+@Composable
+private fun SetupPinContent(
+ state: SetupPinState,
+ modifier: Modifier = Modifier,
+) {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ PinEntryTextField(
+ pinEntry = state.activePinEntry,
+ isSecured = true,
+ onValueChange = {
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(it))
+ },
+ modifier = modifier
+ .focusRequester(focusRequester)
+ .padding(top = 36.dp)
+ .fillMaxWidth()
+ )
+ if (state.setupPinFailure != null) {
+ ErrorDialog(
+ modifier = modifier,
+ title = state.setupPinFailure.title(),
+ content = state.setupPinFailure.content(),
+ onDismiss = {
+ state.eventSink(SetupPinEvents.ClearFailure)
+ }
+ )
+ }
+}
+
+@Composable
+private fun SetupPinFailure.content(): String {
+ return when (this) {
+ SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_content)
+ SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_content)
+ }
+}
+
+@Composable
+private fun SetupPinFailure.title(): String {
+ return when (this) {
+ SetupPinFailure.PinBlacklisted -> stringResource(id = R.string.screen_app_lock_setup_pin_blacklisted_dialog_title)
+ SetupPinFailure.PinsDontMatch -> stringResource(id = R.string.screen_app_lock_setup_pin_mismatch_dialog_title)
+ }
+}
+
+@Composable
+@PreviewsDayNight
+internal fun SetupPinViewPreview(@PreviewParameter(SetupPinStateProvider::class) state: SetupPinState) {
+ ElementPreview {
+ SetupPinView(
+ state = state,
+ onBackClicked = {},
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt
new file mode 100644
index 0000000000..ca01aab61f
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/PinValidator.kt
@@ -0,0 +1,39 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin.validation
+
+import io.element.android.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import javax.inject.Inject
+
+class PinValidator @Inject constructor(private val lockScreenConfig: LockScreenConfig) {
+
+ sealed interface Result {
+ data object Valid : Result
+ data class Invalid(val failure: SetupPinFailure) : Result
+ }
+
+ fun isPinValid(pinEntry: PinEntry): Result {
+ val pinAsText = pinEntry.toText()
+ val isBlacklisted = lockScreenConfig.pinBlacklist.any { it == pinAsText }
+ return if (isBlacklisted) {
+ Result.Invalid(SetupPinFailure.PinBlacklisted)
+ } else {
+ Result.Valid
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt
new file mode 100644
index 0000000000..271dcc2f2c
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/setup/pin/validation/SetupPinFailure.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin.validation
+
+sealed interface SetupPinFailure {
+ data object PinBlacklisted : SetupPinFailure
+ data object PinsDontMatch : SetupPinFailure
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt
new file mode 100644
index 0000000000..7f19346cec
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/EncryptedPinCodeStorage.kt
@@ -0,0 +1,43 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.storage
+
+/**
+ * Should be implemented by any class that provides access to the encrypted PIN code.
+ * All methods are suspending in case there are async IO operations involved.
+ */
+interface EncryptedPinCodeStorage {
+ /**
+ * Returns the encrypted PIN code.
+ */
+ suspend fun getEncryptedCode(): String?
+
+ /**
+ * Saves the encrypted PIN code to some persistable storage.
+ */
+ suspend fun saveEncryptedPinCode(pinCode: String)
+
+ /**
+ * Deletes the PIN code from some persistable storage.
+ */
+ suspend fun deleteEncryptedPinCode()
+
+ /**
+ * Returns whether the PIN code is stored or not.
+ */
+ suspend fun hasPinCode(): Boolean
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/LockScreenStore.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/LockScreenStore.kt
new file mode 100644
index 0000000000..77ce61d190
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/LockScreenStore.kt
@@ -0,0 +1,47 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.storage
+
+import kotlinx.coroutines.flow.Flow
+
+interface LockScreenStore : EncryptedPinCodeStorage {
+
+ /**
+ * Returns the remaining PIN code attempts. When this reaches 0 the PIN code access won't be available for some time.
+ */
+ suspend fun getRemainingPinCodeAttemptsNumber(): Int
+
+ /**
+ * Should decrement the number of remaining PIN code attempts.
+ */
+ suspend fun onWrongPin()
+
+ /**
+ * Resets the counter of attempts for PIN code and biometric access.
+ */
+ suspend fun resetCounter()
+
+ /**
+ * Returns whether the biometric unlock is allowed or not.
+ */
+ fun isBiometricUnlockAllowed(): Flow
+
+ /**
+ * Sets whether the biometric unlock is allowed or not.
+ */
+ suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean)
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
new file mode 100644
index 0000000000..4b01b6e62a
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/storage/PreferencesLockScreenStore.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.storage
+
+import android.content.Context
+import androidx.datastore.core.DataStore
+import androidx.datastore.preferences.core.Preferences
+import androidx.datastore.preferences.core.booleanPreferencesKey
+import androidx.datastore.preferences.core.edit
+import androidx.datastore.preferences.core.intPreferencesKey
+import androidx.datastore.preferences.core.stringPreferencesKey
+import androidx.datastore.preferences.preferencesDataStore
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.appconfig.LockScreenConfig
+import io.element.android.libraries.di.AppScope
+import io.element.android.libraries.di.ApplicationContext
+import io.element.android.libraries.di.SingleIn
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+private val Context.dataStore: DataStore by preferencesDataStore(name = "pin_code_store")
+
+@SingleIn(AppScope::class)
+@ContributesBinding(AppScope::class)
+class PreferencesLockScreenStore @Inject constructor(
+ @ApplicationContext private val context: Context,
+ private val lockScreenConfig: LockScreenConfig,
+) : LockScreenStore {
+
+ private val pinCodeKey = stringPreferencesKey("encoded_pin_code")
+ private val remainingAttemptsKey = intPreferencesKey("remaining_pin_code_attempts")
+ private val biometricUnlockKey = booleanPreferencesKey("biometric_unlock_enabled")
+
+ override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
+ return context.dataStore.data.map { preferences ->
+ preferences.getRemainingPinCodeAttemptsNumber()
+ }.first()
+ }
+
+ override suspend fun onWrongPin() {
+ context.dataStore.edit { preferences ->
+ val current = preferences.getRemainingPinCodeAttemptsNumber()
+ val remaining = (current - 1).coerceAtLeast(0)
+ preferences[remainingAttemptsKey] = remaining
+ }
+ }
+
+ override suspend fun resetCounter() {
+ context.dataStore.edit { preferences ->
+ preferences[remainingAttemptsKey] = lockScreenConfig.maxPinCodeAttemptsBeforeLogout
+ }
+ }
+
+ override suspend fun getEncryptedCode(): String? {
+ return context.dataStore.data.map { preferences ->
+ preferences[pinCodeKey]
+ }.first()
+ }
+
+ override suspend fun saveEncryptedPinCode(pinCode: String) {
+ context.dataStore.edit { preferences ->
+ preferences[pinCodeKey] = pinCode
+ }
+ }
+
+ override suspend fun deleteEncryptedPinCode() {
+ context.dataStore.edit { preferences ->
+ preferences.remove(pinCodeKey)
+ }
+ }
+
+ override suspend fun hasPinCode(): Boolean {
+ return context.dataStore.data.map { preferences ->
+ preferences[pinCodeKey] != null
+ }.first()
+ }
+
+ override fun isBiometricUnlockAllowed(): Flow {
+ return context.dataStore.data.map { preferences ->
+ preferences[biometricUnlockKey] ?: false
+ }
+ }
+
+ override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
+ context.dataStore.edit { preferences ->
+ preferences[biometricUnlockKey] = isAllowed
+ }
+ }
+
+ private fun Preferences.getRemainingPinCodeAttemptsNumber() = this[remainingAttemptsKey] ?: lockScreenConfig.maxPinCodeAttemptsBeforeLogout
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt
new file mode 100644
index 0000000000..4aded6f47c
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockEvents.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock
+
+import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
+
+sealed interface PinUnlockEvents {
+ data class OnPinKeypadPressed(val pinKeypadModel: PinKeypadModel) : PinUnlockEvents
+ data class OnPinEntryChanged(val entryAsText: String) : PinUnlockEvents
+ data object OnForgetPin : PinUnlockEvents
+ data object ClearSignOutPrompt : PinUnlockEvents
+ data object SignOut : PinUnlockEvents
+ data object OnUseBiometric : PinUnlockEvents
+ data object ClearBiometricError : PinUnlockEvents
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt
new file mode 100644
index 0000000000..a0818716b6
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock
+
+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.di.SessionScope
+
+@ContributesNode(SessionScope::class)
+class PinUnlockNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: PinUnlockPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ data class Inputs(
+ val isInAppUnlock: Boolean
+ ) : NodeInputs
+
+ private val inputs: Inputs = inputs()
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ PinUnlockView(
+ state = state,
+ isInAppUnlock = inputs.isInAppUnlock,
+ modifier = modifier
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
new file mode 100644
index 0000000000..872547dfdc
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt
@@ -0,0 +1,180 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.saveable.rememberSaveable
+import androidx.compose.runtime.setValue
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.core.bool.orFalse
+import io.element.android.libraries.matrix.api.MatrixClient
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class PinUnlockPresenter @Inject constructor(
+ private val pinCodeManager: PinCodeManager,
+ private val biometricUnlockManager: BiometricUnlockManager,
+ private val matrixClient: MatrixClient,
+ private val coroutineScope: CoroutineScope,
+) : Presenter {
+
+ @Composable
+ override fun present(): PinUnlockState {
+ val pinEntryState = remember {
+ mutableStateOf>(Async.Uninitialized)
+ }
+ val pinEntry by pinEntryState
+ var remainingAttempts by remember {
+ mutableStateOf>(Async.Uninitialized)
+ }
+ var showWrongPinTitle by rememberSaveable {
+ mutableStateOf(false)
+ }
+ var showSignOutPrompt by rememberSaveable {
+ mutableStateOf(false)
+ }
+ val signOutAction = remember {
+ mutableStateOf>(Async.Uninitialized)
+ }
+ var biometricUnlockResult by remember {
+ mutableStateOf(null)
+ }
+
+ val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
+
+ LaunchedEffect(Unit) {
+ suspend {
+ val pinCodeSize = pinCodeManager.getPinCodeSize()
+ PinEntry.createEmpty(pinCodeSize)
+ }.runCatchingUpdatingState(pinEntryState)
+ }
+ LaunchedEffect(biometricUnlock) {
+ biometricUnlock.setup()
+ biometricUnlock.authenticate()
+ }
+
+ LaunchedEffect(pinEntry) {
+ if (pinEntry.isComplete()) {
+ val isVerified = pinCodeManager.verifyPinCode(pinEntry.toText())
+ if (!isVerified) {
+ pinEntryState.value = pinEntry.clear()
+ showWrongPinTitle = true
+ }
+ }
+ val remainingAttemptsNumber = pinCodeManager.getRemainingPinCodeAttemptsNumber()
+ remainingAttempts = Async.Success(remainingAttemptsNumber)
+ if (remainingAttemptsNumber == 0) {
+ showSignOutPrompt = true
+ }
+ }
+
+ fun handleEvents(event: PinUnlockEvents) {
+ when (event) {
+ is PinUnlockEvents.OnPinKeypadPressed -> {
+ pinEntryState.value = pinEntry.process(event.pinKeypadModel)
+ }
+ PinUnlockEvents.OnForgetPin -> showSignOutPrompt = true
+ PinUnlockEvents.ClearSignOutPrompt -> showSignOutPrompt = false
+ PinUnlockEvents.SignOut -> {
+ if (showSignOutPrompt) {
+ showSignOutPrompt = false
+ coroutineScope.signOut(signOutAction)
+ }
+ }
+ PinUnlockEvents.OnUseBiometric -> {
+ coroutineScope.launch {
+ biometricUnlockResult = biometricUnlock.authenticate()
+ }
+ }
+ PinUnlockEvents.ClearBiometricError -> {
+ biometricUnlockResult = null
+ }
+ is PinUnlockEvents.OnPinEntryChanged -> {
+ pinEntryState.value = pinEntry.process(event.entryAsText)
+ }
+ }
+ }
+ return PinUnlockState(
+ pinEntry = pinEntry,
+ showWrongPinTitle = showWrongPinTitle,
+ remainingAttempts = remainingAttempts,
+ showSignOutPrompt = showSignOutPrompt,
+ signOutAction = signOutAction.value,
+ showBiometricUnlock = biometricUnlock.isActive,
+ biometricUnlockResult = biometricUnlockResult,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun Async.isComplete(): Boolean {
+ return dataOrNull()?.isComplete().orFalse()
+ }
+
+ private fun Async.toText(): String {
+ return dataOrNull()?.toText() ?: ""
+ }
+
+ private fun Async.clear(): Async {
+ return when (this) {
+ is Async.Success -> Async.Success(data.clear())
+ else -> this
+ }
+ }
+
+ private fun Async.process(pinKeypadModel: PinKeypadModel): Async {
+ return when (this) {
+ is Async.Success -> {
+ val pinEntry = when (pinKeypadModel) {
+ PinKeypadModel.Back -> data.deleteLast()
+ is PinKeypadModel.Number -> data.addDigit(pinKeypadModel.number)
+ PinKeypadModel.Empty -> data
+ }
+ Async.Success(pinEntry)
+ }
+ else -> this
+ }
+ }
+
+ private fun Async.process(pinEntryAsText: String): Async {
+ return when (this) {
+ is Async.Success -> {
+ val pinEntry = data.fillWith(pinEntryAsText)
+ Async.Success(pinEntry)
+ }
+ else -> this
+ }
+ }
+
+ private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch {
+ suspend {
+ matrixClient.logout(ignoreSdkError = true)
+ }.runCatchingUpdatingState(signOutAction)
+ }
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
new file mode 100644
index 0000000000..667f7a825a
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockState.kt
@@ -0,0 +1,48 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock
+
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockError
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.libraries.architecture.Async
+
+data class PinUnlockState(
+ val pinEntry: Async,
+ val showWrongPinTitle: Boolean,
+ val remainingAttempts: Async,
+ val showSignOutPrompt: Boolean,
+ val signOutAction: Async,
+ val showBiometricUnlock: Boolean,
+ val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
+ val eventSink: (PinUnlockEvents) -> Unit
+) {
+ val isSignOutPromptCancellable = when (remainingAttempts) {
+ is Async.Success -> remainingAttempts.data > 0
+ else -> true
+ }
+
+ val biometricUnlockErrorMessage = when {
+ biometricUnlockResult is BiometricUnlock.AuthenticationResult.Failure
+ && biometricUnlockResult.error is BiometricUnlockError
+ && biometricUnlockResult.error.isAuthDisabledError -> {
+ biometricUnlockResult.error.message
+ }
+ else -> null
+ }
+ val showBiometricUnlockError = biometricUnlockErrorMessage != null
+}
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
new file mode 100644
index 0000000000..cbf6b2dee2
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockStateProvider.kt
@@ -0,0 +1,54 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.features.lockscreen.impl.biometric.BiometricUnlock
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.libraries.architecture.Async
+
+open class PinUnlockStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aPinUnlockState(),
+ aPinUnlockState(pinEntry = PinEntry.createEmpty(4).fillWith("12")),
+ aPinUnlockState(showWrongPinTitle = true),
+ aPinUnlockState(showSignOutPrompt = true),
+ aPinUnlockState(showBiometricUnlock = false),
+ aPinUnlockState(showSignOutPrompt = true, remainingAttempts = 0),
+ aPinUnlockState(signOutAction = Async.Loading()),
+ )
+}
+
+fun aPinUnlockState(
+ pinEntry: PinEntry = PinEntry.createEmpty(4),
+ remainingAttempts: Int = 3,
+ showWrongPinTitle: Boolean = false,
+ showSignOutPrompt: Boolean = false,
+ showBiometricUnlock: Boolean = true,
+ biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
+ signOutAction: Async = Async.Uninitialized,
+) = PinUnlockState(
+ pinEntry = Async.Success(pinEntry),
+ showWrongPinTitle = showWrongPinTitle,
+ remainingAttempts = Async.Success(remainingAttempts),
+ showSignOutPrompt = showSignOutPrompt,
+ showBiometricUnlock = showBiometricUnlock,
+ signOutAction = signOutAction,
+ biometricUnlockResult = biometricUnlockResult,
+ eventSink = {}
+)
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
new file mode 100644
index 0000000000..fcca4985f5
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockView.kt
@@ -0,0 +1,381 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+
+package io.element.android.features.lockscreen.impl.unlock
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.BoxWithConstraintsScope
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxHeight
+import androidx.compose.foundation.layout.fillMaxSize
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.imePadding
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.systemBarsPadding
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.filled.Lock
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.focus.FocusRequester
+import androidx.compose.ui.focus.focusRequester
+import androidx.compose.ui.res.pluralStringResource
+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 androidx.lifecycle.Lifecycle
+import io.element.android.features.lockscreen.impl.R
+import io.element.android.features.lockscreen.impl.components.PinEntryTextField
+import io.element.android.features.lockscreen.impl.pin.model.PinDigit
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypad
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.designsystem.theme.components.Surface
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.theme.components.TextButton
+import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun PinUnlockView(
+ state: PinUnlockState,
+ isInAppUnlock: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ OnLifecycleEvent { _, event ->
+ when (event) {
+ Lifecycle.Event.ON_RESUME -> state.eventSink.invoke(PinUnlockEvents.OnUseBiometric)
+ else -> Unit
+ }
+ }
+ Surface(modifier) {
+ PinUnlockPage(state = state, isInAppUnlock = isInAppUnlock)
+ if (state.showSignOutPrompt) {
+ SignOutPrompt(
+ isCancellable = state.isSignOutPromptCancellable,
+ onSignOut = { state.eventSink(PinUnlockEvents.SignOut) },
+ onDismiss = { state.eventSink(PinUnlockEvents.ClearSignOutPrompt) },
+ )
+ }
+ if (state.signOutAction is Async.Loading) {
+ ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
+ }
+ if (state.showBiometricUnlockError) {
+ ErrorDialog(
+ content = state.biometricUnlockErrorMessage ?: "",
+ onDismiss = { state.eventSink(PinUnlockEvents.ClearBiometricError) }
+ )
+ }
+ }
+}
+
+@Composable
+private fun PinUnlockPage(
+ state: PinUnlockState,
+ isInAppUnlock: Boolean,
+ modifier: Modifier = Modifier
+) {
+ BoxWithConstraints {
+ val commonModifier = modifier
+ .fillMaxSize()
+ .systemBarsPadding()
+ .imePadding()
+ .padding(all = 20.dp)
+
+ val header = @Composable {
+ PinUnlockHeader(
+ state = state,
+ isInAppUnlock = isInAppUnlock,
+ modifier = Modifier.padding(top = 60.dp)
+ )
+ }
+ val footer = @Composable {
+ PinUnlockFooter(
+ modifier = Modifier.padding(top = 24.dp),
+ showBiometricUnlock = state.showBiometricUnlock,
+ onUseBiometric = {
+ state.eventSink(PinUnlockEvents.OnUseBiometric)
+ },
+ onForgotPin = {
+ state.eventSink(PinUnlockEvents.OnForgetPin)
+ },
+ )
+ }
+ val content = @Composable { constraints: BoxWithConstraintsScope ->
+ if (isInAppUnlock) {
+ val pinEntry = state.pinEntry.dataOrNull()
+ if (pinEntry != null) {
+ val focusRequester = remember { FocusRequester() }
+ LaunchedEffect(Unit) {
+ focusRequester.requestFocus()
+ }
+ PinEntryTextField(
+ pinEntry = pinEntry,
+ isSecured = true,
+ onValueChange = {
+ state.eventSink(PinUnlockEvents.OnPinEntryChanged(it))
+ },
+ modifier = Modifier
+ .focusRequester(focusRequester)
+ .fillMaxWidth()
+ )
+ }
+ } else {
+ PinKeypad(
+ onClick = {
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(it))
+ },
+ maxWidth = constraints.maxWidth,
+ maxHeight = constraints.maxHeight,
+ horizontalAlignment = Alignment.CenterHorizontally,
+ )
+ }
+ }
+ if (maxHeight < 600.dp) {
+ PinUnlockCompactView(
+ header = header,
+ footer = footer,
+ content = content,
+ modifier = commonModifier,
+ )
+ } else {
+ PinUnlockExpandedView(
+ header = header,
+ footer = footer,
+ content = content,
+ modifier = commonModifier,
+ )
+ }
+ }
+}
+
+@Composable
+private fun SignOutPrompt(
+ isCancellable: Boolean,
+ onSignOut: () -> Unit,
+ onDismiss: () -> Unit,
+ modifier: Modifier = Modifier
+) {
+ if (isCancellable) {
+ ConfirmationDialog(
+ title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
+ content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
+ onSubmitClicked = onSignOut,
+ onDismiss = onDismiss,
+ modifier = modifier,
+ )
+ } else {
+ ErrorDialog(
+ title = stringResource(id = R.string.screen_app_lock_signout_alert_title),
+ content = stringResource(id = R.string.screen_app_lock_signout_alert_message),
+ onDismiss = onSignOut,
+ modifier = modifier,
+ )
+ }
+}
+
+@Composable
+private fun PinUnlockCompactView(
+ header: @Composable () -> Unit,
+ footer: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxWithConstraintsScope.() -> Unit,
+) {
+ Row(modifier = modifier) {
+ Column(Modifier.weight(1f)) {
+ header()
+ Spacer(modifier = Modifier.height(24.dp))
+ footer()
+ }
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxHeight(),
+ contentAlignment = Alignment.Center,
+ ) {
+ content()
+ }
+ }
+}
+
+@Composable
+private fun PinUnlockExpandedView(
+ header: @Composable () -> Unit,
+ footer: @Composable () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxWithConstraintsScope.() -> Unit,
+) {
+ Column(
+ modifier = modifier,
+ ) {
+ header()
+ BoxWithConstraints(
+ modifier = Modifier
+ .weight(1f)
+ .fillMaxWidth()
+ .padding(top = 40.dp),
+ ) {
+ content()
+ }
+ footer()
+ }
+}
+
+@Composable
+private fun PinDotsRow(
+ pinEntry: PinEntry,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier, horizontalArrangement = spacedBy(8.dp), verticalAlignment = Alignment.CenterVertically) {
+ for (digit in pinEntry.digits) {
+ PinDot(isFilled = digit is PinDigit.Filled)
+ }
+ }
+}
+
+@Composable
+private fun PinDot(
+ isFilled: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ val backgroundColor = if (isFilled) {
+ ElementTheme.colors.iconPrimary
+ } else {
+ ElementTheme.colors.bgSubtlePrimary
+ }
+ Box(
+ modifier = modifier
+ .size(14.dp)
+ .background(backgroundColor, CircleShape)
+ )
+}
+
+@Composable
+private fun PinUnlockHeader(
+ state: PinUnlockState,
+ isInAppUnlock: Boolean,
+ modifier: Modifier = Modifier,
+) {
+ Column(modifier, horizontalAlignment = Alignment.CenterHorizontally) {
+ if (isInAppUnlock) {
+ RoundedIconAtom(imageVector = Icons.Filled.Lock)
+ } else {
+ Icon(
+ modifier = Modifier
+ .size(32.dp),
+ tint = ElementTheme.colors.iconPrimary,
+ imageVector = Icons.Filled.Lock,
+ contentDescription = "",
+ )
+ }
+ Spacer(modifier = Modifier.height(16.dp))
+ Text(
+ text = stringResource(id = CommonStrings.common_enter_your_pin),
+ modifier = Modifier
+ .fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontHeadingMdBold,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ Spacer(Modifier.height(8.dp))
+ val remainingAttempts = state.remainingAttempts.dataOrNull()
+ val subtitle = if (remainingAttempts != null) {
+ if (state.showWrongPinTitle) {
+ pluralStringResource(id = R.plurals.screen_app_lock_subtitle_wrong_pin, count = remainingAttempts, remainingAttempts)
+ } else {
+ pluralStringResource(id = R.plurals.screen_app_lock_subtitle, count = remainingAttempts, remainingAttempts)
+ }
+ } else {
+ ""
+ }
+ val subtitleColor = if (state.showWrongPinTitle) {
+ MaterialTheme.colorScheme.error
+ } else {
+ MaterialTheme.colorScheme.secondary
+ }
+ Text(
+ text = subtitle,
+ modifier = Modifier.fillMaxWidth(),
+ textAlign = TextAlign.Center,
+ style = ElementTheme.typography.fontBodyMdRegular,
+ color = subtitleColor,
+ )
+ if (!isInAppUnlock && state.pinEntry is Async.Success) {
+ Spacer(Modifier.height(24.dp))
+ PinDotsRow(state.pinEntry.data)
+ }
+ }
+}
+
+@Composable
+private fun PinUnlockFooter(
+ showBiometricUnlock: Boolean,
+ onUseBiometric: () -> Unit,
+ onForgotPin: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier.fillMaxWidth(), horizontalArrangement = Arrangement.SpaceAround) {
+ if (showBiometricUnlock) {
+ TextButton(text = stringResource(id = R.string.screen_app_lock_use_biometric_android), onClick = onUseBiometric)
+ }
+ TextButton(text = stringResource(id = R.string.screen_app_lock_forgot_pin), onClick = onForgotPin)
+ }
+}
+
+@Composable
+@PreviewsDayNight
+internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
+ ElementPreview {
+ PinUnlockView(
+ state = state,
+ isInAppUnlock = true,
+ )
+ }
+}
+
+@Composable
+@PreviewsDayNight
+internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
+ ElementPreview {
+ PinUnlockView(
+ state = state,
+ isInAppUnlock = false,
+ )
+ }
+}
+
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
new file mode 100644
index 0000000000..9db5cfe11a
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypad.kt
@@ -0,0 +1,214 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock.keypad
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Arrangement.spacedBy
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.BoxScope
+import androidx.compose.foundation.layout.BoxWithConstraints
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.Backspace
+import androidx.compose.material3.Text
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.unit.Dp
+import androidx.compose.ui.unit.coerceAtMost
+import androidx.compose.ui.unit.dp
+import androidx.compose.ui.unit.sp
+import androidx.compose.ui.unit.times
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.text.toSp
+import io.element.android.libraries.designsystem.theme.components.Icon
+import io.element.android.libraries.theme.ElementTheme
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+private val spaceBetweenPinKey = 16.dp
+private val maxSizePinKey = 80.dp
+
+@Composable
+fun PinKeypad(
+ onClick: (PinKeypadModel) -> Unit,
+ maxWidth: Dp,
+ maxHeight: Dp,
+ modifier: Modifier = Modifier,
+ verticalAlignment: Alignment.Vertical = Alignment.Top,
+ horizontalAlignment: Alignment.Horizontal = Alignment.Start,
+) {
+ val pinKeyMaxWidth = ((maxWidth - 2 * spaceBetweenPinKey) / 3).coerceAtMost(maxSizePinKey)
+ val pinKeyMaxHeight = ((maxHeight - 3 * spaceBetweenPinKey) / 4).coerceAtMost(maxSizePinKey)
+ val pinKeySize = if (pinKeyMaxWidth < pinKeyMaxHeight) pinKeyMaxWidth else pinKeyMaxHeight
+
+ val horizontalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterHorizontally)
+ val verticalArrangement = spacedBy(spaceBetweenPinKey, Alignment.CenterVertically)
+ Column(
+ modifier = modifier,
+ verticalArrangement = verticalArrangement,
+ horizontalAlignment = horizontalAlignment,
+ ) {
+ PinKeypadRow(
+ pinKeySize = pinKeySize,
+ verticalAlignment = verticalAlignment,
+ horizontalArrangement = horizontalArrangement,
+ models = persistentListOf(PinKeypadModel.Number('1'), PinKeypadModel.Number('2'), PinKeypadModel.Number('3')),
+ onClick = onClick,
+ )
+ PinKeypadRow(
+ pinKeySize = pinKeySize,
+ verticalAlignment = verticalAlignment,
+ horizontalArrangement = horizontalArrangement,
+ models = persistentListOf(PinKeypadModel.Number('4'), PinKeypadModel.Number('5'), PinKeypadModel.Number('6')),
+ onClick = onClick,
+ )
+ PinKeypadRow(
+ pinKeySize = pinKeySize,
+ verticalAlignment = verticalAlignment,
+ horizontalArrangement = horizontalArrangement,
+ models = persistentListOf(PinKeypadModel.Number('7'), PinKeypadModel.Number('8'), PinKeypadModel.Number('9')),
+ onClick = onClick,
+ )
+ PinKeypadRow(
+ pinKeySize = pinKeySize,
+ verticalAlignment = verticalAlignment,
+ horizontalArrangement = horizontalArrangement,
+ models = persistentListOf(PinKeypadModel.Empty, PinKeypadModel.Number('0'), PinKeypadModel.Back),
+ onClick = onClick,
+ )
+ }
+}
+
+@Composable
+private fun PinKeypadRow(
+ models: ImmutableList,
+ onClick: (PinKeypadModel) -> Unit,
+ pinKeySize: Dp,
+ modifier: Modifier = Modifier,
+ horizontalArrangement: Arrangement.Horizontal = Arrangement.Start,
+ verticalAlignment: Alignment.Vertical = Alignment.Top,
+) {
+ Row(
+ horizontalArrangement = horizontalArrangement,
+ verticalAlignment = verticalAlignment,
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ val commonModifier = Modifier.size(pinKeySize)
+ for (model in models) {
+ when (model) {
+ is PinKeypadModel.Empty -> {
+ Spacer(modifier = commonModifier)
+ }
+ is PinKeypadModel.Back -> {
+ PinKeypadBackButton(
+ modifier = commonModifier,
+ onClick = { onClick(model) },
+ )
+ }
+ is PinKeypadModel.Number -> {
+ PinKeyBadDigitButton(
+ size = pinKeySize,
+ modifier = commonModifier,
+ digit = model.number.toString(),
+ onClick = { onClick(model) },
+ )
+ }
+ }
+ }
+ }
+}
+
+@Composable
+private fun PinKeypadButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+ content: @Composable BoxScope.() -> Unit,
+) {
+ Box(
+ contentAlignment = Alignment.Center,
+ modifier = modifier
+ .clip(CircleShape)
+ .background(color = ElementTheme.colors.bgSubtlePrimary)
+ .clickable(onClick = onClick),
+ content = content
+ )
+}
+
+@Composable
+private fun PinKeyBadDigitButton(
+ digit: String,
+ size: Dp,
+ onClick: (String) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ PinKeypadButton(
+ modifier = modifier,
+ onClick = { onClick(digit) }
+ ) {
+ val fontSize = size.toSp() / 2
+ val originalFont = ElementTheme.typography.fontHeadingXlBold
+ val ratio = fontSize.value / originalFont.fontSize.value
+ val lineHeight = originalFont.lineHeight * ratio
+ Text(
+ text = digit,
+ color = ElementTheme.colors.textPrimary,
+ style = originalFont.copy(fontSize = fontSize, lineHeight = lineHeight, letterSpacing = 0.sp),
+ )
+ }
+}
+
+@Composable
+private fun PinKeypadBackButton(
+ onClick: () -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ PinKeypadButton(
+ modifier = modifier,
+ onClick = onClick,
+ ) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.Backspace,
+ contentDescription = null,
+ )
+ }
+}
+
+@Composable
+@PreviewsDayNight
+internal fun PinKeypadPreview() {
+ ElementPreview {
+ BoxWithConstraints {
+ PinKeypad(
+ maxWidth = maxWidth,
+ maxHeight = maxHeight,
+ onClick = {}
+ )
+ }
+ }
+}
+
+
diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt
new file mode 100644
index 0000000000..8d232cb21b
--- /dev/null
+++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/keypad/PinKeypadModel.kt
@@ -0,0 +1,26 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock.keypad
+
+import androidx.compose.runtime.Immutable
+
+@Immutable
+sealed interface PinKeypadModel {
+ data object Empty : PinKeypadModel
+ data object Back : PinKeypadModel
+ data class Number(val number: Char) : PinKeypadModel
+}
diff --git a/features/lockscreen/impl/src/main/res/values-cs/translations.xml b/features/lockscreen/impl/src/main/res/values-cs/translations.xml
new file mode 100644
index 0000000000..0f7f3decfb
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-cs/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Odhlašování…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-de/translations.xml b/features/lockscreen/impl/src/main/res/values-de/translations.xml
new file mode 100644
index 0000000000..9491e2e1a0
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-de/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Abmelden…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-es/translations.xml b/features/lockscreen/impl/src/main/res/values-es/translations.xml
new file mode 100644
index 0000000000..5f7393df00
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-es/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Cerrando sesión…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-fr/translations.xml b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..5a56f1b6f2
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,32 @@
+
+
+
+ - "Il reste %1$d tentative pour déverrouiller"
+ - "Il reste %1$d tentatives pour déverrouiller"
+
+
+ - "Code PIN incorrect. Il reste %1$d tentative"
+ - "Code PIN incorrect. Il reste %1$d tentatives"
+
+ "Authentification biométrique"
+ "Déverrouillage biométrique"
+ "Code PIN oublié?"
+ "Modifier le code PIN"
+ "Autoriser le déverrouillage biométrique"
+ "Supprimer le code PIN"
+ "Êtes-vous certain de vouloir supprimer le code PIN?"
+ "Supprimer le code PIN?"
+ "Autoriser %1$s"
+ "Je préfère utiliser le code PIN"
+ "Gagnez du temps en utilisant %1$s pour déverrouiller l’application à chaque fois."
+ "Choisissez un code PIN"
+ "Confirmer le code PIN"
+ "Vous ne pouvez pas choisir ce code PIN pour des raisons de sécurité"
+ "Choisissez un code PIN différent"
+ "Verrouillez %1$s pour ajouter une sécurité supplémentaire à vos discussions. Choisissez un code facile à retenir. Si vous oubliez le code PIN, vous serez déconnecté."
+ "Veuillez saisir le même code PIN deux fois"
+ "Les codes PIN ne correspondent pas"
+ "Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."
+ "Vous êtes en train de vous déconnecter"
+ "Déconnexion…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-it/translations.xml b/features/lockscreen/impl/src/main/res/values-it/translations.xml
new file mode 100644
index 0000000000..579346ed6e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-it/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Uscita in corso…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-ro/translations.xml b/features/lockscreen/impl/src/main/res/values-ro/translations.xml
new file mode 100644
index 0000000000..7cbd3ca512
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-ro/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Deconectare în curs…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-ru/translations.xml b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
new file mode 100644
index 0000000000..3ce820fc0c
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-ru/translations.xml
@@ -0,0 +1,4 @@
+
+
+ "Выполняется выход…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-sk/translations.xml b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..0977768f81
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,31 @@
+
+
+
+ - "Nesprávny PIN kód. Máte ešte %1$d pokus"
+ - "Nesprávny PIN kód. Máte ešte %1$d pokusy"
+ - "Nesprávny PIN kód. Máte ešte %1$d pokusov"
+
+ "biometrické overenie"
+ "biometrické odomknutie"
+ "Zabudli ste PIN?"
+ "Zmeniť PIN kód"
+ "Povoliť biometrické odomknutie"
+ "Odstrániť PIN"
+ "Ste si istí, že chcete odstrániť PIN?"
+ "Odstrániť PIN?"
+ "Povoliť %1$s"
+ "Radšej použijem PIN"
+ "Ušetrite si čas a použite zakaždým %1$s na odomknutie aplikácie"
+ "Vyberte PIN"
+ "Potvrdiť PIN"
+ "Z bezpečnostných dôvodov si nemôžete toto zvoliť ako svoj PIN kód."
+ "Vyberte iný PIN"
+ "Uzamknite %1$s, aby ste zvýšili bezpečnosť svojich konverzácií.
+
+Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplikácie odhlásení."
+ "Zadajte prosím ten istý PIN dvakrát"
+ "PIN kódy sa nezhodujú"
+ "Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."
+ "Prebieha odhlasovanie"
+ "Prebieha odhlasovanie…"
+
diff --git a/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
new file mode 100644
index 0000000000..d01b56268e
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values-zh-rTW/translations.xml
@@ -0,0 +1,23 @@
+
+
+
+ - "PIN 碼錯誤。您還有 %1$d 次機會"
+
+ "生物辨識認證"
+ "生物辨識解鎖"
+ "忘記 PIN 碼?"
+ "變更 PIN 碼"
+ "允許生物辨識解鎖"
+ "移除 PIN 碼"
+ "您確定要移除 PIN 碼嗎?"
+ "移除 PIN 碼"
+ "允許 %1$s"
+ "選擇 PIN 碼"
+ "確認 PIN 碼"
+ "基於安全性的考量,您選的 PIN 碼無法使用"
+ "選擇一個不一樣的 PIN 碼"
+ "請輸入相同的 PIN 碼兩次"
+ "PIN 碼不一樣"
+ "您即將登出"
+ "正在登出…"
+
diff --git a/features/lockscreen/impl/src/main/res/values/localazy.xml b/features/lockscreen/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..865102ea5b
--- /dev/null
+++ b/features/lockscreen/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,37 @@
+
+
+
+ - "You have %1$d attempt to unlock"
+ - "You have %1$d attempts to unlock"
+
+
+ - "Wrong PIN. You have %1$d more chance"
+ - "Wrong PIN. You have %1$d more chances"
+
+ "biometric authentication"
+ "biometric unlock"
+ "Unlock with biometric"
+ "Forgot PIN?"
+ "Change PIN code"
+ "Allow biometric unlock"
+ "Remove PIN"
+ "Are you sure you want to remove PIN?"
+ "Remove PIN?"
+ "Allow %1$s"
+ "I’d rather use PIN"
+ "Save yourself some time and use %1$s to unlock the app each time"
+ "Choose PIN"
+ "Confirm PIN"
+ "You cannot choose this as your PIN code for security reasons"
+ "Choose a different PIN"
+ "Lock %1$s to add extra security to your chats.
+
+Choose something memorable. If you forget this PIN, you will be logged out of the app."
+ "Please enter the same PIN twice"
+ "PINs don\'t match"
+ "You’ll need to re-login and create a new PIN to proceed"
+ "You are being signed out"
+ "Use biometric"
+ "Use PIN"
+ "Signing out…"
+
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricUnlockManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricUnlockManager.kt
new file mode 100644
index 0000000000..d26a90d305
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/biometric/FakeBiometricUnlockManager.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.biometric
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+
+class FakeBiometricUnlockManager : BiometricUnlockManager {
+
+ override var isDeviceSecured: Boolean = true
+ override var hasAvailableAuthenticator: Boolean = false
+
+ override fun addCallback(callback: BiometricUnlock.Callback) {
+ // no-op
+ }
+
+ override fun removeCallback(callback: BiometricUnlock.Callback) {
+ // no-op
+ }
+
+ @Composable
+ override fun rememberBiometricUnlock(): BiometricUnlock {
+ return remember {
+ NoopBiometricUnlock()
+ }
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt
new file mode 100644
index 0000000000..aa575eabd4
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/LockScreenConfig.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.fixtures
+
+import io.element.android.appconfig.LockScreenConfig
+import kotlin.time.Duration
+import kotlin.time.Duration.Companion.seconds
+
+internal fun aLockScreenConfig(
+ isPinMandatory: Boolean = false,
+ pinBlacklist: Set = emptySet(),
+ pinSize: Int = 4,
+ maxPinCodeAttemptsBeforeLogout: Int = 3,
+ gracePeriod: Duration = 3.seconds,
+ isStrongBiometricsEnabled: Boolean = true,
+ isWeakBiometricsEnabled: Boolean = true,
+): LockScreenConfig {
+ return LockScreenConfig(
+ isPinMandatory = isPinMandatory,
+ pinBlacklist = pinBlacklist,
+ pinSize = pinSize,
+ maxPinCodeAttemptsBeforeLogout = maxPinCodeAttemptsBeforeLogout,
+ gracePeriod = gracePeriod,
+ isStrongBiometricsEnabled = isStrongBiometricsEnabled,
+ isWeakBiometricsEnabled = isWeakBiometricsEnabled,
+ )
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/PinCodeManager.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/PinCodeManager.kt
new file mode 100644
index 0000000000..b6bd73141f
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/fixtures/PinCodeManager.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.fixtures
+
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManager
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
+import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
+import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
+
+internal fun aPinCodeManager(
+ lockScreenStore: LockScreenStore = InMemoryLockScreenStore(),
+ secretKeyRepository: SimpleSecretKeyRepository = SimpleSecretKeyRepository(),
+ encryptionDecryptionService: EncryptionDecryptionService = AESEncryptionDecryptionService(),
+): PinCodeManager {
+ return DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerTest.kt
new file mode 100644
index 0000000000..3c65620084
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/DefaultPinCodeManagerTest.kt
@@ -0,0 +1,53 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin
+
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
+import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
+import io.element.android.libraries.cryptography.test.SimpleSecretKeyRepository
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultPinCodeManagerTest {
+
+ private val lockScreenStore = InMemoryLockScreenStore()
+ private val secretKeyRepository = SimpleSecretKeyRepository()
+ private val encryptionDecryptionService = AESEncryptionDecryptionService()
+ private val pinCodeManager = DefaultPinCodeManager(secretKeyRepository, encryptionDecryptionService, lockScreenStore)
+
+ @Test
+ fun `given a pin code when create and delete assert no pin code left`() = runTest {
+ pinCodeManager.createPinCode("1234")
+ assertThat(pinCodeManager.isPinCodeAvailable()).isTrue()
+ pinCodeManager.deletePinCode()
+ assertThat(pinCodeManager.isPinCodeAvailable()).isFalse()
+ }
+
+ @Test
+ fun `given a pin code when create and verify with the same pin succeed`() = runTest {
+ val pinCode = "1234"
+ pinCodeManager.createPinCode(pinCode)
+ assertThat(pinCodeManager.verifyPinCode(pinCode)).isTrue()
+ }
+
+ @Test
+ fun `given a pin code when create and verify with a different pin fails`() = runTest {
+ pinCodeManager.createPinCode("1234")
+ assertThat(pinCodeManager.verifyPinCode("1235")).isFalse()
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt
new file mode 100644
index 0000000000..37d54677a1
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryAssertions.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin.model
+
+import com.google.common.truth.Truth.assertThat
+
+fun PinEntry.assertText(text: String) {
+ assertThat(toText()).isEqualTo(text)
+}
+
+fun PinEntry.assertEmpty() {
+ val isEmpty = digits.all { it is PinDigit.Empty }
+ assertThat(isEmpty).isTrue()
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryTest.kt
new file mode 100644
index 0000000000..38abb2d295
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/model/PinEntryTest.kt
@@ -0,0 +1,81 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin.model
+
+import com.google.common.truth.Truth.assertThat
+import org.junit.Test
+
+class PinEntryTest {
+
+ @Test
+ fun `when using fillWith with empty string ensure pin is empty`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry.fillWith("")
+ assertThat(newPinEntry.isEmpty()).isTrue()
+ }
+
+ @Test
+ fun `when using fillWith with bigger string than size ensure pin is complete`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry.fillWith("12345")
+ assertThat(newPinEntry.isComplete()).isTrue()
+ newPinEntry.assertText("1234")
+ }
+
+ @Test
+ fun `when using fillWith with non digit string ensure pin is filtering`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry.fillWith("12aa")
+ newPinEntry.assertText("12")
+ }
+
+ @Test
+ fun `when using clear ensure pin is empty`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry.clear()
+ assertThat(newPinEntry.isEmpty()).isTrue()
+ assertThat(newPinEntry.isComplete()).isFalse()
+ newPinEntry.assertText("")
+ }
+
+ @Test
+ fun `when using deleteLast ensure pin correct`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry.fillWith("1234").deleteLast()
+ newPinEntry.assertText("123")
+ }
+
+ @Test
+ fun `when using deleteLast with empty pin ensure pin is empty`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry.deleteLast()
+ assertThat(newPinEntry.isEmpty()).isTrue()
+ }
+
+ @Test
+ fun `when using addDigit with complete pin ensure pin is complete`() {
+ val pinEntry = PinEntry.createEmpty(4)
+ val newPinEntry = pinEntry
+ .addDigit('1')
+ .addDigit('2')
+ .addDigit('3')
+ .addDigit('4')
+ .addDigit('5')
+ assertThat(newPinEntry.isComplete()).isTrue()
+ newPinEntry.assertText("1234")
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt
new file mode 100644
index 0000000000..5d1af46ae5
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/pin/storage/InMemoryLockScreenStore.kt
@@ -0,0 +1,66 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.pin.storage
+
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.MutableStateFlow
+
+private const val DEFAULT_REMAINING_ATTEMPTS = 3
+
+class InMemoryLockScreenStore : LockScreenStore {
+
+ private var pinCode: String? = null
+ private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
+ private var isBiometricUnlockAllowed = MutableStateFlow(false)
+
+ override suspend fun getRemainingPinCodeAttemptsNumber(): Int {
+ return remainingAttempts
+ }
+
+ override suspend fun onWrongPin() {
+ remainingAttempts--
+ }
+
+ override suspend fun resetCounter() {
+ remainingAttempts = DEFAULT_REMAINING_ATTEMPTS
+ }
+
+ override suspend fun getEncryptedCode(): String? {
+ return pinCode
+ }
+
+ override suspend fun saveEncryptedPinCode(pinCode: String) {
+ this.pinCode = pinCode
+ }
+
+ override suspend fun deleteEncryptedPinCode() {
+ pinCode = null
+ }
+
+ override suspend fun hasPinCode(): Boolean {
+ return pinCode != null
+ }
+
+ override fun isBiometricUnlockAllowed(): Flow {
+ return isBiometricUnlockAllowed
+ }
+
+ override suspend fun setIsBiometricUnlockAllowed(isAllowed: Boolean) {
+ isBiometricUnlockAllowed.value = isAllowed
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
new file mode 100644
index 0000000000..bd99c09bbe
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsPresenterTest.kt
@@ -0,0 +1,84 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.settings
+
+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.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
+import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
+import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
+import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class LockScreenSettingsPresenterTest {
+
+ @Test
+ fun `present - remove pin flow`() = runTest {
+ val presenter = createLockScreenSettingsPresenter(this)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ consumeItemsUntilPredicate { state ->
+ state.showRemovePinOption
+ }.last().also { state ->
+ state.eventSink(LockScreenSettingsEvents.OnRemovePin)
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.showRemovePinConfirmation).isTrue()
+ state.eventSink(LockScreenSettingsEvents.CancelRemovePin)
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.showRemovePinConfirmation).isFalse()
+ state.eventSink(LockScreenSettingsEvents.OnRemovePin)
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.showRemovePinConfirmation).isTrue()
+ state.eventSink(LockScreenSettingsEvents.ConfirmRemovePin)
+ }
+ consumeItemsUntilPredicate {
+ it.showRemovePinOption.not()
+ }.last().also { state ->
+ assertThat(state.showRemovePinConfirmation).isFalse()
+ assertThat(state.showRemovePinOption).isFalse()
+ }
+ }
+ }
+
+ private suspend fun createLockScreenSettingsPresenter(
+ coroutineScope: CoroutineScope,
+ lockScreenConfig: LockScreenConfig = aLockScreenConfig(),
+ ): LockScreenSettingsPresenter {
+ val lockScreenStore = InMemoryLockScreenStore()
+ val pinCodeManager = aPinCodeManager(lockScreenStore = lockScreenStore).apply {
+ createPinCode("1234")
+ }
+ return LockScreenSettingsPresenter(
+ lockScreenStore = lockScreenStore,
+ pinCodeManager = pinCodeManager,
+ coroutineScope = coroutineScope,
+ lockScreenConfig = lockScreenConfig,
+ biometricUnlockManager = FakeBiometricUnlockManager(),
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
new file mode 100644
index 0000000000..3db9d246f9
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/biometric/SetupBiometricPresenterTest.kt
@@ -0,0 +1,74 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.biometric
+
+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.lockscreen.impl.pin.storage.InMemoryLockScreenStore
+import io.element.android.features.lockscreen.impl.storage.LockScreenStore
+import kotlinx.coroutines.flow.first
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SetupBiometricPresenterTest {
+
+ @Test
+ fun `present - allow flow`() = runTest {
+ val lockScreenStore = InMemoryLockScreenStore()
+ val presenter = createSetupBiometricPresenter(lockScreenStore)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().also { state ->
+ assertThat(state.isBiometricSetupDone).isFalse()
+ state.eventSink(SetupBiometricEvents.AllowBiometric)
+ }
+ awaitItem().also { state ->
+ assertThat(state.isBiometricSetupDone).isTrue()
+ }
+ }
+ assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isTrue()
+ }
+
+ @Test
+ fun `present - skip flow`() = runTest {
+ val lockScreenStore = InMemoryLockScreenStore()
+ val presenter = createSetupBiometricPresenter(lockScreenStore)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().also { state ->
+ assertThat(state.isBiometricSetupDone).isFalse()
+ state.eventSink(SetupBiometricEvents.UsePin)
+ }
+ awaitItem().also { state ->
+ assertThat(state.isBiometricSetupDone).isTrue()
+ }
+ }
+ assertThat(lockScreenStore.isBiometricUnlockAllowed().first()).isFalse()
+ }
+
+ private fun createSetupBiometricPresenter(
+ lockScreenStore: LockScreenStore = InMemoryLockScreenStore()
+ ): SetupBiometricPresenter {
+ return SetupBiometricPresenter(
+ lockScreenStore = lockScreenStore,
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
new file mode 100644
index 0000000000..7f3373bd4a
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/setup/pin/SetupPinPresenterTest.kt
@@ -0,0 +1,134 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.setup.pin
+
+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.appconfig.LockScreenConfig
+import io.element.android.features.lockscreen.impl.fixtures.aLockScreenConfig
+import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.pin.model.assertEmpty
+import io.element.android.features.lockscreen.impl.pin.model.assertText
+import io.element.android.features.lockscreen.impl.setup.pin.validation.PinValidator
+import io.element.android.features.lockscreen.impl.setup.pin.validation.SetupPinFailure
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class SetupPinPresenterTest {
+
+ private val blacklistedPin = "1234"
+ private val halfCompletePin = "12"
+ private val completePin = "1235"
+ private val mismatchedPin = "1236"
+
+ @Test
+ fun `present - complete flow`() = runTest {
+ val pinCodeCreated = CompletableDeferred()
+ val callback = object : DefaultPinCodeManagerCallback() {
+ override fun onPinCodeCreated() {
+ pinCodeCreated.complete(Unit)
+ }
+ }
+ val presenter = createSetupPinPresenter(callback)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().also { state ->
+ state.choosePinEntry.assertEmpty()
+ state.confirmPinEntry.assertEmpty()
+ assertThat(state.setupPinFailure).isNull()
+ assertThat(state.isConfirmationStep).isFalse()
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
+ }
+ awaitItem().also { state ->
+ state.choosePinEntry.assertText(halfCompletePin)
+ state.confirmPinEntry.assertEmpty()
+ assertThat(state.setupPinFailure).isNull()
+ assertThat(state.isConfirmationStep).isFalse()
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
+ }
+ awaitLastSequentialItem().also { state ->
+ state.choosePinEntry.assertText(blacklistedPin)
+ assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinBlacklisted)
+ state.eventSink(SetupPinEvents.ClearFailure)
+ }
+ awaitLastSequentialItem().also { state ->
+ state.choosePinEntry.assertEmpty()
+ assertThat(state.setupPinFailure).isNull()
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
+ }
+ consumeItemsUntilPredicate {
+ it.isConfirmationStep
+ }.last().also { state ->
+ state.choosePinEntry.assertText(completePin)
+ state.confirmPinEntry.assertEmpty()
+ assertThat(state.isConfirmationStep).isTrue()
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
+ }
+ awaitLastSequentialItem().also { state ->
+ state.choosePinEntry.assertText(completePin)
+ state.confirmPinEntry.assertText(mismatchedPin)
+ assertThat(state.setupPinFailure).isEqualTo(SetupPinFailure.PinsDontMatch)
+ state.eventSink(SetupPinEvents.ClearFailure)
+ }
+ awaitLastSequentialItem().also { state ->
+ state.choosePinEntry.assertEmpty()
+ state.confirmPinEntry.assertEmpty()
+ assertThat(state.isConfirmationStep).isFalse()
+ assertThat(state.setupPinFailure).isNull()
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
+ }
+ consumeItemsUntilPredicate {
+ it.isConfirmationStep
+ }.last().also { state ->
+ state.choosePinEntry.assertText(completePin)
+ state.confirmPinEntry.assertEmpty()
+ assertThat(state.isConfirmationStep).isTrue()
+ state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
+ }
+ awaitItem().also { state ->
+ state.choosePinEntry.assertText(completePin)
+ state.confirmPinEntry.assertText(completePin)
+ }
+ pinCodeCreated.await()
+ }
+ }
+
+ private fun createSetupPinPresenter(
+ callback: PinCodeManager.Callback,
+ lockScreenConfig: LockScreenConfig = aLockScreenConfig(
+ pinBlacklist = setOf(blacklistedPin)
+ ),
+ ): SetupPinPresenter {
+ val pinCodeManager = aPinCodeManager()
+ pinCodeManager.addCallback(callback)
+ return SetupPinPresenter(
+ lockScreenConfig = lockScreenConfig,
+ pinValidator = PinValidator(lockScreenConfig),
+ buildMeta = aBuildMeta(),
+ pinCodeManager = pinCodeManager
+ )
+ }
+}
diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
new file mode 100644
index 0000000000..95ded7617c
--- /dev/null
+++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt
@@ -0,0 +1,165 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.impl.unlock
+
+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.lockscreen.impl.biometric.BiometricUnlockManager
+import io.element.android.features.lockscreen.impl.biometric.FakeBiometricUnlockManager
+import io.element.android.features.lockscreen.impl.fixtures.aPinCodeManager
+import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
+import io.element.android.features.lockscreen.impl.pin.PinCodeManager
+import io.element.android.features.lockscreen.impl.pin.model.PinEntry
+import io.element.android.features.lockscreen.impl.pin.model.assertText
+import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import io.element.android.tests.testutils.consumeItemsUntilPredicate
+import kotlinx.coroutines.CompletableDeferred
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class PinUnlockPresenterTest {
+
+ private val halfCompletePin = "12"
+ private val completePin = "1235"
+
+ @Test
+ fun `present - success verify flow`() = runTest {
+ val pinCodeVerified = CompletableDeferred()
+ val callback = object : DefaultPinCodeManagerCallback() {
+ override fun onPinCodeCreated() {
+ pinCodeVerified.complete(Unit)
+ }
+ }
+ val presenter = createPinUnlockPresenter(this, callback = callback)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().also { state ->
+ assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
+ assertThat(state.showWrongPinTitle).isFalse()
+ assertThat(state.showSignOutPrompt).isFalse()
+ assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
+ assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
+ }
+ consumeItemsUntilPredicate {
+ it.pinEntry is Async.Success && it.remainingAttempts is Async.Success
+ }.last().also { state ->
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
+ }
+ awaitLastSequentialItem().also { state ->
+ state.pinEntry.assertText(halfCompletePin)
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Back))
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Empty))
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
+ state.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('5')))
+ }
+ awaitLastSequentialItem().also { state ->
+ state.pinEntry.assertText(completePin)
+ }
+ pinCodeVerified.await()
+ }
+ }
+
+ @Test
+ fun `present - failure verify flow`() = runTest {
+ val pinCodeVerified = CompletableDeferred()
+ val callback = object : DefaultPinCodeManagerCallback() {
+ override fun onPinCodeCreated() {
+ pinCodeVerified.complete(Unit)
+ }
+ }
+ val presenter = createPinUnlockPresenter(this, callback = callback)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = consumeItemsUntilPredicate {
+ it.pinEntry is Async.Success && it.remainingAttempts is Async.Success
+ }.last()
+ val numberOfAttempts = initialState.remainingAttempts.dataOrNull() ?: 0
+ repeat(numberOfAttempts) {
+ initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('1')))
+ initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('2')))
+ initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('3')))
+ initialState.eventSink(PinUnlockEvents.OnPinKeypadPressed(PinKeypadModel.Number('4')))
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.remainingAttempts.dataOrNull()).isEqualTo(0)
+ assertThat(state.showSignOutPrompt).isEqualTo(true)
+ assertThat(state.isSignOutPromptCancellable).isEqualTo(false)
+ }
+ }
+ }
+
+ @Test
+ fun `present - forgot pin flow`() = runTest {
+ val presenter = createPinUnlockPresenter(this)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ consumeItemsUntilPredicate {
+ it.pinEntry is Async.Success && it.remainingAttempts is Async.Success
+ }.last().also { state ->
+ state.eventSink(PinUnlockEvents.OnForgetPin)
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.showSignOutPrompt).isEqualTo(true)
+ assertThat(state.isSignOutPromptCancellable).isEqualTo(true)
+ state.eventSink(PinUnlockEvents.ClearSignOutPrompt)
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.showSignOutPrompt).isEqualTo(false)
+ state.eventSink(PinUnlockEvents.OnForgetPin)
+ }
+ awaitLastSequentialItem().also { state ->
+ assertThat(state.showSignOutPrompt).isEqualTo(true)
+ state.eventSink(PinUnlockEvents.SignOut)
+ }
+ consumeItemsUntilPredicate { state ->
+ state.signOutAction is Async.Success
+ }
+ }
+ }
+
+ private fun Async.assertText(text: String) {
+ dataOrNull()?.assertText(text)
+ }
+
+ private suspend fun createPinUnlockPresenter(
+ scope: CoroutineScope,
+ biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(),
+ callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(),
+ ): PinUnlockPresenter {
+ val pinCodeManager = aPinCodeManager().apply {
+ addCallback(callback)
+ createPinCode(completePin)
+ }
+ return PinUnlockPresenter(
+ pinCodeManager = pinCodeManager,
+ biometricUnlockManager = biometricUnlockManager,
+ matrixClient = FakeMatrixClient(),
+ coroutineScope = scope,
+ )
+ }
+}
diff --git a/features/lockscreen/test/build.gradle.kts b/features/lockscreen/test/build.gradle.kts
new file mode 100644
index 0000000000..083b54b88b
--- /dev/null
+++ b/features/lockscreen/test/build.gradle.kts
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2023 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")
+}
+
+android {
+ namespace = "io.element.android.features.lockscreen.test"
+}
+
+dependencies {
+ implementation(libs.coroutines.core)
+ api(projects.features.lockscreen.api)
+}
diff --git a/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenService.kt b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenService.kt
new file mode 100644
index 0000000000..012c8e9a5c
--- /dev/null
+++ b/features/lockscreen/test/src/main/kotlin/io/element/android/features/lockscreen/test/FakeLockScreenService.kt
@@ -0,0 +1,41 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.lockscreen.test
+
+import io.element.android.features.lockscreen.api.LockScreenLockState
+import io.element.android.features.lockscreen.api.LockScreenService
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.StateFlow
+
+class FakeLockScreenService : LockScreenService {
+
+ private var isSetupRequired: Boolean = false
+ private val _lockState: MutableStateFlow = MutableStateFlow(LockScreenLockState.Locked)
+ override val lockState: StateFlow = _lockState
+
+ override suspend fun isSetupRequired(): Boolean {
+ return isSetupRequired
+ }
+
+ fun setIsSetupRequired(isSetupRequired: Boolean) {
+ this.isSetupRequired = isSetupRequired
+ }
+
+ fun setLockState(lockState: LockScreenLockState) {
+ _lockState.value = lockState
+ }
+}
diff --git a/features/login/impl/build.gradle.kts b/features/login/impl/build.gradle.kts
index ae13197f05..6f4e959499 100644
--- a/features/login/impl/build.gradle.kts
+++ b/features/login/impl/build.gradle.kts
@@ -38,6 +38,7 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
+ implementation(projects.appconfig)
anvil(projects.anvilcodegen)
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
index 35fd7246f2..0d3b9c5dc3 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/accountprovider/AccountProviderProvider.kt
@@ -17,7 +17,7 @@
package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.features.login.impl.util.LoginConstants
+import io.element.android.appconfig.AuthenticationConfig
open class AccountProviderProvider : PreviewParameterProvider {
override val values: Sequence
@@ -32,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider {
}
fun anAccountProvider() = AccountProvider(
- url = LoginConstants.MATRIX_ORG_URL,
+ url = AuthenticationConfig.MATRIX_ORG_URL,
subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true,
isMatrixOrg = true,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
index 786d8aaeae..96fc115cfa 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenter.kt
@@ -17,9 +17,9 @@
package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable
+import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
-import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject
@@ -34,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
// Just matrix.org by default for now
accountProviders = listOf(
AccountProvider(
- url = LoginConstants.MATRIX_ORG_URL,
+ url = AuthenticationConfig.MATRIX_ORG_URL,
subtitle = null,
isPublic = true,
isMatrixOrg = true,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
index 8dce1bd78e..50b24b3964 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderStateProvider.kt
@@ -17,9 +17,9 @@
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
open class SearchAccountProviderStateProvider : PreviewParameterProvider {
@@ -50,7 +50,7 @@ fun aHomeserverDataList(): List {
}
fun aHomeserverData(
- homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
+ homeserverUrl: String = AuthenticationConfig.MATRIX_ORG_URL,
isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true,
): HomeserverData {
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
index 29781acff1..47dfe248b7 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderView.kt
@@ -14,12 +14,11 @@
* limitations under the License.
*/
-@file:OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
+@file:OptIn(ExperimentalMaterial3Api::class)
package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
@@ -48,13 +47,13 @@ import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.input.KeyboardType
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
+import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.R
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderView
import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData
-import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
@@ -196,7 +195,7 @@ fun SearchAccountProviderView(
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
- val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
+ val isMatrixOrg = homeserverUrl == AuthenticationConfig.MATRIX_ORG_URL
return AccountProvider(
url = homeserverUrl,
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
index 98fd62d7b0..91c19e4052 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/LoginConstants.kt
@@ -16,18 +16,12 @@
package io.element.android.features.login.impl.util
+import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.login.impl.accountprovider.AccountProvider
-object LoginConstants {
- const val MATRIX_ORG_URL = "https://matrix.org"
-
- const val DEFAULT_HOMESERVER_URL = "https://matrix.org"
- const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
-}
-
val defaultAccountProvider = AccountProvider(
- url = LoginConstants.DEFAULT_HOMESERVER_URL,
+ url = AuthenticationConfig.DEFAULT_HOMESERVER_URL,
subtitle = null,
- isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
- isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
+ isPublic = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
+ isMatrixOrg = AuthenticationConfig.DEFAULT_HOMESERVER_URL == AuthenticationConfig.MATRIX_ORG_URL,
)
diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt
index 261b02c1b8..6726105bce 100644
--- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt
+++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/util/Util.kt
@@ -19,9 +19,10 @@ package io.element.android.features.login.impl.util
import android.content.Context
import android.content.Intent
import android.net.Uri
+import io.element.android.appconfig.AuthenticationConfig
import io.element.android.libraries.core.data.tryOrNull
fun openLearnMorePage(context: Context) {
- val intent = Intent(Intent.ACTION_VIEW, Uri.parse(LoginConstants.SLIDING_SYNC_READ_MORE_URL))
+ val intent = Intent(Intent.ACTION_VIEW, Uri.parse(AuthenticationConfig.SLIDING_SYNC_READ_MORE_URL))
tryOrNull { context.startActivity(intent) }
}
diff --git a/features/login/impl/src/main/res/values-cs/translations.xml b/features/login/impl/src/main/res/values-cs/translations.xml
index 5d697bf34a..0094479b12 100644
--- a/features/login/impl/src/main/res/values-cs/translations.xml
+++ b/features/login/impl/src/main/res/values-cs/translations.xml
@@ -18,6 +18,7 @@
"Adresa URL domovského serveru"
"Můžete se připojit pouze k serveru, který podporuje klouzavou synchronizaci. Správce vašeho domovského serveru jej bude muset nakonfigurovat. %1$s"
"Jaká je adresa vašeho serveru?"
+ "Vyberte váš server"
"Tento účet byl deaktivován."
"Nesprávné uživatelské jméno nebo heslo"
"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"
@@ -34,14 +35,8 @@
"Na %2$s je momentálně vysoká poptávka po %1$s. Vraťte se do aplikace za pár dní a zkuste to znovu.
Díky za trpělivost!"
- "Vítá vás %1$s"
"Jste v pořadníku!"
"Jdete do toho!"
- "Pokračovat"
- "Pokračovat"
- "Vyberte svůj server"
- "Heslo"
- "Pokračovat"
"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."
- "Uživatelské jméno"
+ "Vítá vás %1$s!"
diff --git a/features/login/impl/src/main/res/values-de/translations.xml b/features/login/impl/src/main/res/values-de/translations.xml
index a7511c0f09..0e757190a9 100644
--- a/features/login/impl/src/main/res/values-de/translations.xml
+++ b/features/login/impl/src/main/res/values-de/translations.xml
@@ -34,14 +34,8 @@
"Derzeit besteht eine hohe Nachfrage nach %1$s auf %2$s. Kehre in ein paar Tagen zur App zurück und versuche es erneut.
Danke für deine Geduld!"
- "Willkommen bei %1$s!"
"Du bist fast am Ziel."
"Du bist dabei."
- "Weiter"
- "Weiter"
- "Wähle deinen Server aus"
- "Passwort"
- "Weiter"
"Matrix ist ein offenes Netzwerk für eine sichere, dezentrale Kommunikation."
- "Benutzername"
+ "Willkommen bei %1$s!"
diff --git a/features/login/impl/src/main/res/values-es/translations.xml b/features/login/impl/src/main/res/values-es/translations.xml
index 0735fb7fd2..6f023f6373 100644
--- a/features/login/impl/src/main/res/values-es/translations.xml
+++ b/features/login/impl/src/main/res/values-es/translations.xml
@@ -11,10 +11,4 @@
"El servidor seleccionado no admite contraseñas ni inicio de sesión OIDC. Póngase en contacto con su administrador o elija otro homeserver."
"Introduce tus datos"
"¡Hola de nuevo!"
- "Continuar"
- "Continuar"
- "Selecciona tu servidor"
- "Contraseña"
- "Continuar"
- "Usuario"
diff --git a/features/login/impl/src/main/res/values-fr/translations.xml b/features/login/impl/src/main/res/values-fr/translations.xml
index a566198a45..eedba864da 100644
--- a/features/login/impl/src/main/res/values-fr/translations.xml
+++ b/features/login/impl/src/main/res/values-fr/translations.xml
@@ -18,6 +18,7 @@
"URL du serveur d’accueil"
"Vous ne pouvez vous connecter qu’à un serveur existant qui prend en charge le sliding sync. L’administrateur de votre serveur d’accueil devra le configurer. %1$s"
"Quelle est l’adresse de votre serveur ?"
+ "Choisissez votre serveur"
"Ce compte a été désactivé."
"Nom d’utilisateur et/ou mot de passe incorrects"
"Il ne s’agit pas d’un identifiant utilisateur valide. Format attendu : « @user:homeserver.org »"
@@ -34,14 +35,8 @@
"Il y a une forte demande pour %1$s sur %2$s à l’heure actuelle. Revenez sur l’application dans quelques jours et réessayez.
Merci pour votre patience !"
- "Bienvenue dans %1$s !"
"Vous y êtes presque."
"Vous y êtes."
- "Continuer"
- "Continuer"
- "Sélectionnez votre serveur"
- "Mot de passe"
- "Continuer"
"Matrix est un réseau ouvert pour une communication sécurisée et décentralisée."
- "Nom d’utilisateur"
+ "Bienvenue dans %1$s !"
diff --git a/features/login/impl/src/main/res/values-it/translations.xml b/features/login/impl/src/main/res/values-it/translations.xml
index f7321f1d52..227b85bed7 100644
--- a/features/login/impl/src/main/res/values-it/translations.xml
+++ b/features/login/impl/src/main/res/values-it/translations.xml
@@ -11,10 +11,4 @@
"L\'homeserver selezionato non supporta la password o l\'accesso OIDC. Contatta il tuo amministratore o scegli un altro homeserver."
"Inserisci i tuoi dati"
"Bentornato!"
- "Continua"
- "Continua"
- "Seleziona il tuo server"
- "Password"
- "Continua"
- "Nome utente"
diff --git a/features/login/impl/src/main/res/values-ro/translations.xml b/features/login/impl/src/main/res/values-ro/translations.xml
index e780269a39..49e701558a 100644
--- a/features/login/impl/src/main/res/values-ro/translations.xml
+++ b/features/login/impl/src/main/res/values-ro/translations.xml
@@ -34,14 +34,8 @@
"Există o cerere mare pentru %1$s pentru %2$s în acest moment. Reveniți la aplicație în câteva zile și încercați din nou.
Vă mulțumim pentru răbdare!"
- "Bun venit la %1$s"
"Sunteți pe lista de așteptare"
"Sunteți conectat!"
- "Continuați"
- "Continuați"
- "Selectați serverul"
- "Parola"
- "Continuați"
"Matrix este o rețea deschisă pentru o comunicare sigură și descentralizată."
- "Utilizator"
+ "Bun venit la%1$s!"
diff --git a/features/login/impl/src/main/res/values-ru/translations.xml b/features/login/impl/src/main/res/values-ru/translations.xml
index 641db1e0d4..1751364597 100644
--- a/features/login/impl/src/main/res/values-ru/translations.xml
+++ b/features/login/impl/src/main/res/values-ru/translations.xml
@@ -18,6 +18,7 @@
"URL-адрес домашнего сервера"
"Вы можете подключиться только к существующему серверу, поддерживающему sliding sync. Администратору домашнего сервера потребуется настроить его. %1$s"
"Какой адрес у вашего сервера?"
+ "Выберите свой сервер"
"Данная учетная запись была деактивирована."
"Неверное имя пользователя и/или пароль"
"Это не корректный идентификатор пользователя. Ожидаемый формат: \'@user:homeserver.org\'"
@@ -34,14 +35,8 @@
"В настоящее время существует высокий спрос на %1$s на %2$s. Вернитесь в приложение через несколько дней и попробуйте снова.
Спасибо за терпение!"
- "Добро пожаловать в %1$s!"
"Почти готово!"
"Вы зарегистрированы!"
- "Продолжить"
- "Продолжить"
- "Выберите свой сервер"
- "Пароль"
- "Продолжить"
"Matrix — это открытая сеть для безопасной децентрализованной связи."
- "Имя пользователя"
+ "Добро пожаловать в %1$s!"
diff --git a/features/login/impl/src/main/res/values-sk/translations.xml b/features/login/impl/src/main/res/values-sk/translations.xml
index e94990554f..cf49504755 100644
--- a/features/login/impl/src/main/res/values-sk/translations.xml
+++ b/features/login/impl/src/main/res/values-sk/translations.xml
@@ -14,10 +14,9 @@
"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."
"Zmeniť poskytovateľa účtu"
"Nemohli sme sa spojiť s týmto domovským serverom. Skontrolujte prosím, či ste zadali URL adresu domovského servera správne. Ak je adresa URL správna, kontaktujte svoj domovský server pre ďalšiu pomoc."
- "Tento server momentálne nepodporuje kĺzavú synchronizáciu."
"Adresa URL domovského servera"
- "Pripojiť sa môžete len k existujúcemu serveru, ktorý podporuje kĺzavú synchronizáciu. Váš správca domovského servera ju bude musieť nakonfigurovať. %1$s"
"Aká je adresa vášho servera?"
+ "Vyberte svoj server"
"Tento účet bol deaktivovaný."
"Nesprávne používateľské meno a/alebo heslo"
"Toto nie je platný identifikátor používateľa. Očakávaný formát: \'@pouzivatel:homeserver.sk\'"
@@ -34,14 +33,8 @@
"Momentálne je veľký dopyt po %1$s na %2$s. Vráťte sa do aplikácie za pár dní a skúste to znova.
Ďakujeme za trpezlivosť!"
- "Vitajte v %1$s"
"Ste na čakanej listine!"
"Ste dnu!"
- "Pokračovať"
- "Pokračovať"
- "Vyberte svoj server"
- "Heslo"
- "Pokračovať"
"Matrix je otvorená sieť pre bezpečnú a decentralizovanú komunikáciu."
- "Používateľské meno"
+ "Vitajte v %1$s!"
diff --git a/features/login/impl/src/main/res/values-zh-rTW/translations.xml b/features/login/impl/src/main/res/values-zh-rTW/translations.xml
index 789de2b30e..1c3e524b2b 100644
--- a/features/login/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/login/impl/src/main/res/values-zh-rTW/translations.xml
@@ -3,7 +3,7 @@
"更改帳號提供者"
"家伺服器位址"
"輸入關鍵字或網域名稱。"
- "搜尋公司、社群、私有伺服器"
+ "搜尋公司、社群、私有伺服器。"
"尋找帳號提供者"
"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"
"您即將登入 %s"
@@ -21,16 +21,10 @@
"歡迎回來!"
"登入 %1$s"
"更改帳號提供者"
- "Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"
+ "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。"
"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"
"您即將登入 %1$s"
"您即將在 %1$s 建立帳號"
+ "Matrix 是一個開放網路,為了安全且去中心化的通訊而生。"
"歡迎使用 %1$s!"
- "繼續"
- "繼續"
- "選擇您的伺服器"
- "密碼"
- "繼續"
- "Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"
- "使用者名稱"
diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml
index e09f4fe693..2149318670 100644
--- a/features/login/impl/src/main/res/values/localazy.xml
+++ b/features/login/impl/src/main/res/values/localazy.xml
@@ -18,6 +18,7 @@
"Homeserver URL"
"You can only connect to an existing server that supports sliding sync. Your homeserver admin will need to configure it. %1$s"
"What is the address of your server?"
+ "Select your server"
"This account has been deactivated."
"Incorrect username and/or password"
"This is not a valid user identifier. Expected format: ‘@user:homeserver.org’"
@@ -34,14 +35,8 @@
"There\'s a high demand for %1$s on %2$s at the moment. Come back to the app in a few days and try again.
Thanks for your patience!"
- "Welcome to %1$s!"
"You’re almost there."
"You\'re in."
- "Continue"
- "Continue"
- "Select your server"
- "Password"
- "Continue"
"Matrix is an open network for secure, decentralised communication."
- "Username"
+ "Welcome to %1$s!"
diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTests.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTest.kt
similarity index 98%
rename from features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTests.kt
rename to features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTest.kt
index c1d7e5bb6c..17c6ce3e4a 100644
--- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTests.kt
+++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/error/ErrorFormatterTest.kt
@@ -22,7 +22,7 @@ import io.element.android.libraries.matrix.api.auth.AuthenticationException
import io.element.android.libraries.ui.strings.CommonStrings
import org.junit.Test
-class ErrorFormatterTests {
+class ErrorFormatterTest {
// region loginError
@Test
diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt
new file mode 100644
index 0000000000..b750a47e6d
--- /dev/null
+++ b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutEntryPoint.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.api
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import io.element.android.libraries.architecture.FeatureEntryPoint
+
+interface LogoutEntryPoint : FeatureEntryPoint {
+ fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
+
+ interface NodeBuilder {
+ fun callback(callback: Callback): NodeBuilder
+ fun build(): Node
+ }
+
+ interface Callback : Plugin {
+ fun onChangeRecoveryKeyClicked()
+ }
+}
diff --git a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceView.kt b/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceView.kt
deleted file mode 100644
index 24b7c8a266..0000000000
--- a/features/logout/api/src/main/kotlin/io/element/android/features/logout/api/LogoutPreferenceView.kt
+++ /dev/null
@@ -1,94 +0,0 @@
-/*
- * Copyright (c) 2022 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.
- */
-
-package io.element.android.features.logout.api
-
-import androidx.compose.runtime.Composable
-import androidx.compose.runtime.LaunchedEffect
-import androidx.compose.runtime.mutableStateOf
-import androidx.compose.runtime.remember
-import androidx.compose.ui.res.stringResource
-import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.designsystem.components.ProgressDialog
-import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
-import io.element.android.libraries.designsystem.components.preferences.PreferenceText
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
-import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.utils.CommonDrawables
-
-@Composable
-fun LogoutPreferenceView(
- state: LogoutPreferenceState,
- onSuccessLogout: (logoutUrlResult: String?) -> Unit
-) {
- val eventSink = state.eventSink
- if (state.logoutAction is Async.Success) {
- LaunchedEffect(state.logoutAction) {
- onSuccessLogout(state.logoutAction.data)
- }
- return
- }
- val openDialog = remember { mutableStateOf(false) }
-
- LogoutPreferenceContent(
- onClick = {
- openDialog.value = true
- }
- )
-
- // Log out confirmation dialog
- if (openDialog.value) {
- ConfirmationDialog(
- title = stringResource(id = R.string.screen_signout_confirmation_dialog_title),
- content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
- submitText = stringResource(id = R.string.screen_signout_confirmation_dialog_submit),
- onCancelClicked = {
- openDialog.value = false
- },
- onSubmitClicked = {
- openDialog.value = false
- eventSink(LogoutPreferenceEvents.Logout)
- },
- onDismiss = {
- openDialog.value = false
- }
- )
- }
-
- if (state.logoutAction is Async.Loading) {
- ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
- }
-}
-
-@Composable
-private fun LogoutPreferenceContent(
- onClick: () -> Unit = {},
-) {
- PreferenceText(
- title = stringResource(id = R.string.screen_signout_preference_item),
- iconResourceId = CommonDrawables.ic_compound_leave,
- onClick = onClick
- )
-}
-
-@PreviewsDayNight
-@Composable
-internal fun LogoutPreferenceViewPreview() = ElementPreview {
- LogoutPreferenceView(
- aLogoutPreferenceState(),
- onSuccessLogout = {}
- )
-}
diff --git a/features/logout/api/src/main/res/values-fr/translations.xml b/features/logout/api/src/main/res/values-fr/translations.xml
deleted file mode 100644
index 16c9d3717e..0000000000
--- a/features/logout/api/src/main/res/values-fr/translations.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
- "Êtes-vous sûr de vouloir vous déconnecter ?"
- "Se déconnecter"
- "Déconnexion…"
- "Se déconnecter"
- "Se déconnecter"
-
diff --git a/features/logout/api/src/main/res/values-sk/translations.xml b/features/logout/api/src/main/res/values-sk/translations.xml
deleted file mode 100644
index 212f11ccbc..0000000000
--- a/features/logout/api/src/main/res/values-sk/translations.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
- "Ste si istí, že sa chcete odhlásiť?"
- "Odhlásiť sa"
- "Prebieha odhlasovanie…"
- "Odhlásiť sa"
- "Odhlásiť sa"
-
diff --git a/features/logout/api/src/main/res/values/localazy.xml b/features/logout/api/src/main/res/values/localazy.xml
deleted file mode 100644
index 9ea4bb77fd..0000000000
--- a/features/logout/api/src/main/res/values/localazy.xml
+++ /dev/null
@@ -1,8 +0,0 @@
-
-
- "Are you sure you want to sign out?"
- "Sign out"
- "Signing out…"
- "Sign out"
- "Sign out"
-
diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts
index f5ee8dd951..68f7b389b3 100644
--- a/features/logout/impl/build.gradle.kts
+++ b/features/logout/impl/build.gradle.kts
@@ -31,8 +31,10 @@ anvil {
dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
+ implementation(projects.libraries.androidutils)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
+ implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.testtags)
@@ -47,5 +49,6 @@ dependencies {
testImplementation(libs.test.truth)
testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test)
+ testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.tests.testutils)
}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt
new file mode 100644
index 0000000000..4928850245
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutEntryPoint.kt
@@ -0,0 +1,46 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+import com.bumble.appyx.core.modality.BuildContext
+import com.bumble.appyx.core.node.Node
+import com.bumble.appyx.core.plugin.Plugin
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.features.logout.api.LogoutEntryPoint
+import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.AppScope
+import javax.inject.Inject
+
+@ContributesBinding(AppScope::class)
+class DefaultLogoutEntryPoint @Inject constructor() : LogoutEntryPoint {
+ override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LogoutEntryPoint.NodeBuilder {
+ val plugins = ArrayList()
+
+ return object : LogoutEntryPoint.NodeBuilder {
+
+ override fun callback(callback: LogoutEntryPoint.Callback): LogoutEntryPoint.NodeBuilder {
+ plugins += callback
+ return this
+ }
+
+ override fun build(): Node {
+ return parentNode.createNode(buildContext, plugins)
+ }
+ }
+ }
+}
+
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt
deleted file mode 100644
index 2fece4449b..0000000000
--- a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/DefaultLogoutPreferencePresenter.kt
+++ /dev/null
@@ -1,64 +0,0 @@
-/*
- * Copyright (c) 2023 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.
- */
-
-package io.element.android.features.logout.impl
-
-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 com.squareup.anvil.annotations.ContributesBinding
-import io.element.android.features.logout.api.LogoutPreferenceEvents
-import io.element.android.features.logout.api.LogoutPreferencePresenter
-import io.element.android.features.logout.api.LogoutPreferenceState
-import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.architecture.runCatchingUpdatingState
-import io.element.android.libraries.di.SessionScope
-import io.element.android.libraries.matrix.api.MatrixClient
-import kotlinx.coroutines.CoroutineScope
-import kotlinx.coroutines.launch
-import javax.inject.Inject
-
-@ContributesBinding(SessionScope::class)
-class DefaultLogoutPreferencePresenter @Inject constructor(private val matrixClient: MatrixClient) :
- LogoutPreferencePresenter {
-
- @Composable
- override fun present(): LogoutPreferenceState {
- val localCoroutineScope = rememberCoroutineScope()
- val logoutAction: MutableState> = remember {
- mutableStateOf(Async.Uninitialized)
- }
-
- fun handleEvents(event: LogoutPreferenceEvents) {
- when (event) {
- LogoutPreferenceEvents.Logout -> localCoroutineScope.logout(logoutAction)
- }
- }
-
- return LogoutPreferenceState(
- logoutAction = logoutAction.value,
- eventSink = ::handleEvents
- )
- }
-
- private fun CoroutineScope.logout(logoutAction: MutableState>) = launch {
- suspend {
- matrixClient.logout()
- }.runCatchingUpdatingState(logoutAction)
- }
-}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt
new file mode 100644
index 0000000000..2a8ee322a1
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+sealed interface LogoutEvents {
+ data class Logout(val ignoreSdkError: Boolean) : LogoutEvents
+ data object CloseDialogs : LogoutEvents
+}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt
new file mode 100644
index 0000000000..ccb00b958f
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutNode.kt
@@ -0,0 +1,65 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+import android.app.Activity
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+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 dagger.assisted.Assisted
+import dagger.assisted.AssistedInject
+import io.element.android.anvilannotations.ContributesNode
+import io.element.android.features.logout.api.LogoutEntryPoint
+import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
+import io.element.android.libraries.di.SessionScope
+import timber.log.Timber
+
+@ContributesNode(SessionScope::class)
+class LogoutNode @AssistedInject constructor(
+ @Assisted buildContext: BuildContext,
+ @Assisted plugins: List,
+ private val presenter: LogoutPresenter,
+) : Node(buildContext, plugins = plugins) {
+
+ private fun onChangeRecoveryKeyClicked() {
+ plugins().forEach { it.onChangeRecoveryKeyClicked() }
+ }
+
+ private fun onSuccessLogout(activity: Activity, url: String?) {
+ Timber.d("Success logout with result url: $url")
+ url?.let {
+ activity.openUrlInChromeCustomTab(null, false, it)
+ }
+ }
+
+ @Composable
+ override fun View(modifier: Modifier) {
+ val state = presenter.present()
+ val activity = LocalContext.current as Activity
+ LogoutView(
+ state = state,
+ onChangeRecoveryKeyClicked = ::onChangeRecoveryKeyClicked,
+ onSuccessLogout = { onSuccessLogout(activity, it) },
+ onBackClicked = ::navigateUp,
+ modifier = modifier,
+ )
+ }
+}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
new file mode 100644
index 0000000000..ff76bde776
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutPresenter.kt
@@ -0,0 +1,107 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.runtime.MutableState
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.encryption.BackupUploadState
+import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.flow.emptyFlow
+import kotlinx.coroutines.flow.flowOf
+import kotlinx.coroutines.launch
+import javax.inject.Inject
+
+class LogoutPresenter @Inject constructor(
+ private val matrixClient: MatrixClient,
+ private val encryptionService: EncryptionService,
+ private val featureFlagService: FeatureFlagService,
+) : Presenter {
+
+ @Composable
+ override fun present(): LogoutState {
+ val localCoroutineScope = rememberCoroutineScope()
+ val logoutAction: MutableState> = remember {
+ mutableStateOf(Async.Uninitialized)
+ }
+
+ val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage)
+ .collectAsState(initial = null)
+
+ val backupUploadState: BackupUploadState by remember(secureStorageFlag) {
+ when (secureStorageFlag) {
+ true -> encryptionService.waitForBackupUploadSteadyState()
+ false -> flowOf(BackupUploadState.Done)
+ else -> emptyFlow()
+ }
+ }
+ .collectAsState(initial = BackupUploadState.Unknown)
+
+ var showLogoutDialog by remember { mutableStateOf(false) }
+ var isLastSession by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ isLastSession = encryptionService.isLastDevice().getOrNull() ?: false
+ }
+
+ fun handleEvents(event: LogoutEvents) {
+ when (event) {
+ is LogoutEvents.Logout -> {
+ if (showLogoutDialog || event.ignoreSdkError) {
+ showLogoutDialog = false
+ localCoroutineScope.logout(logoutAction, event.ignoreSdkError)
+ } else {
+ showLogoutDialog = true
+ }
+ }
+ LogoutEvents.CloseDialogs -> {
+ logoutAction.value = Async.Uninitialized
+ showLogoutDialog = false
+ }
+ }
+ }
+
+ return LogoutState(
+ isLastSession = isLastSession,
+ backupUploadState = backupUploadState,
+ showConfirmationDialog = showLogoutDialog,
+ logoutAction = logoutAction.value,
+ eventSink = ::handleEvents
+ )
+ }
+
+ private fun CoroutineScope.logout(
+ logoutAction: MutableState>,
+ ignoreSdkError: Boolean,
+ ) = launch {
+ suspend {
+ matrixClient.logout(ignoreSdkError)
+ }.runCatchingUpdatingState(logoutAction)
+ }
+}
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
new file mode 100644
index 0000000000..1672640ab7
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutState.kt
@@ -0,0 +1,28 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.encryption.BackupUploadState
+
+data class LogoutState(
+ val isLastSession: Boolean,
+ val backupUploadState: BackupUploadState,
+ val showConfirmationDialog: Boolean,
+ val logoutAction: Async,
+ val eventSink: (LogoutEvents) -> Unit,
+)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
new file mode 100644
index 0000000000..ddf0f30340
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutStateProvider.kt
@@ -0,0 +1,49 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.encryption.BackupUploadState
+import io.element.android.libraries.matrix.api.encryption.SteadyStateException
+
+open class LogoutStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aLogoutState(),
+ aLogoutState(isLastSession = true),
+ aLogoutState(isLastSession = false, backupUploadState = BackupUploadState.Uploading(66, 200)),
+ aLogoutState(isLastSession = true, backupUploadState = BackupUploadState.Done),
+ aLogoutState(showConfirmationDialog = true),
+ aLogoutState(logoutAction = Async.Loading()),
+ aLogoutState(logoutAction = Async.Failure(Exception("Failed to logout"))),
+ aLogoutState(backupUploadState = BackupUploadState.SteadyException(SteadyStateException.Connection("No network"))),
+ )
+}
+
+fun aLogoutState(
+ isLastSession: Boolean = false,
+ backupUploadState: BackupUploadState = BackupUploadState.Unknown,
+ showConfirmationDialog: Boolean = false,
+ logoutAction: Async = Async.Uninitialized,
+) = LogoutState(
+ isLastSession = isLastSession,
+ backupUploadState = backupUploadState,
+ showConfirmationDialog = showConfirmationDialog,
+ logoutAction = logoutAction,
+ eventSink = {}
+)
diff --git a/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
new file mode 100644
index 0000000000..465eb7eb35
--- /dev/null
+++ b/features/logout/impl/src/main/kotlin/io/element/android/features/logout/impl/LogoutView.kt
@@ -0,0 +1,239 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+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.material3.ExperimentalMaterial3Api
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.unit.dp
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
+import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
+import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.button.BackButton
+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.LinearProgressIndicator
+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.TopAppBar
+import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
+import io.element.android.libraries.designsystem.utils.CommonDrawables
+import io.element.android.libraries.matrix.api.encryption.BackupUploadState
+import io.element.android.libraries.matrix.api.encryption.SteadyStateException
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LogoutView(
+ state: LogoutState,
+ onChangeRecoveryKeyClicked: () -> Unit,
+ onBackClicked: () -> Unit,
+ onSuccessLogout: (logoutUrlResult: String?) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val eventSink = state.eventSink
+
+ HeaderFooterPage(
+ modifier = modifier,
+ topBar = {
+ TopAppBar(
+ navigationIcon = { BackButton(onClick = onBackClicked) },
+ title = {},
+ )
+ },
+ header = {
+ HeaderContent(state = state)
+ },
+ footer = {
+ BottomMenu(
+ state = state,
+ onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
+ onLogoutClicked = {
+ eventSink(LogoutEvents.Logout(ignoreSdkError = false))
+ },
+ )
+ }
+ ) {
+ Content(state = state)
+ }
+
+ // Log out confirmation dialog
+ if (state.showConfirmationDialog) {
+ ConfirmationDialog(
+ title = stringResource(id = CommonStrings.action_signout),
+ content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
+ submitText = stringResource(id = CommonStrings.action_signout),
+ onCancelClicked = {
+ eventSink(LogoutEvents.CloseDialogs)
+ },
+ onSubmitClicked = {
+ eventSink(LogoutEvents.Logout(ignoreSdkError = false))
+ },
+ onDismiss = {
+ eventSink(LogoutEvents.CloseDialogs)
+ }
+ )
+ }
+
+ when (state.logoutAction) {
+ is Async.Loading ->
+ ProgressDialog(text = stringResource(id = R.string.screen_signout_in_progress_dialog_content))
+ is Async.Failure ->
+ ConfirmationDialog(
+ title = stringResource(id = CommonStrings.dialog_title_error),
+ content = stringResource(id = CommonStrings.error_unknown),
+ submitText = stringResource(id = CommonStrings.action_signout_anyway),
+ onCancelClicked = {
+ eventSink(LogoutEvents.CloseDialogs)
+ },
+ onSubmitClicked = {
+ eventSink(LogoutEvents.Logout(ignoreSdkError = true))
+ },
+ onDismiss = {
+ eventSink(LogoutEvents.CloseDialogs)
+ }
+ )
+ Async.Uninitialized ->
+ Unit
+ is Async.Success ->
+ LaunchedEffect(state.logoutAction) {
+ onSuccessLogout(state.logoutAction.data)
+ }
+ }
+}
+
+@Composable
+private fun HeaderContent(
+ state: LogoutState,
+ modifier: Modifier = Modifier,
+) {
+ val title = when {
+ state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title)
+ state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title)
+ else -> stringResource(CommonStrings.action_signout)
+ }
+ val subtitle = when {
+ (state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection ->
+ stringResource(id = R.string.screen_signout_key_backup_offline_subtitle)
+ state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle)
+ state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle)
+ else -> null
+ }
+
+ val paddingTop = 0.dp
+ IconTitleSubtitleMolecule(
+ modifier = modifier.padding(top = paddingTop),
+ iconResourceId = CommonDrawables.ic_key,
+ title = title,
+ subTitle = subtitle,
+ )
+}
+
+private fun BackupUploadState.isBackingUp(): Boolean {
+ return when (this) {
+ BackupUploadState.Unknown,
+ BackupUploadState.Waiting,
+ is BackupUploadState.Uploading,
+ is BackupUploadState.CheckingIfUploadNeeded -> true
+ is BackupUploadState.SteadyException -> exception is SteadyStateException.Connection
+ BackupUploadState.Done,
+ BackupUploadState.Error -> false
+ }
+}
+
+@Composable
+private fun BottomMenu(
+ state: LogoutState,
+ onLogoutClicked: () -> Unit,
+ onChangeRecoveryKeyClicked: () -> Unit,
+) {
+ val logoutAction = state.logoutAction
+ ButtonColumnMolecule(
+ modifier = Modifier.padding(bottom = 20.dp)
+ ) {
+ if (state.isLastSession) {
+ OutlinedButton(
+ text = stringResource(id = CommonStrings.common_settings),
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onChangeRecoveryKeyClicked,
+ )
+ }
+ val signOutSubmitRes = when {
+ logoutAction is Async.Loading -> R.string.screen_signout_in_progress_dialog_content
+ state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway
+ else -> CommonStrings.action_signout
+ }
+ Button(
+ text = stringResource(id = signOutSubmitRes),
+ showProgress = logoutAction is Async.Loading,
+ destructive = true,
+ modifier = Modifier.fillMaxWidth(),
+ onClick = onLogoutClicked,
+ )
+ }
+}
+
+@Composable
+private fun Content(
+ state: LogoutState,
+) {
+ if (state.backupUploadState is BackupUploadState.Uploading) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(top = 60.dp, start = 20.dp, end = 20.dp),
+ verticalArrangement = Arrangement.spacedBy(8.dp)
+ ) {
+ LinearProgressIndicator(
+ modifier = Modifier.fillMaxWidth(),
+ progress = state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat(),
+ trackColor = ElementTheme.colors.progressIndicatorTrackColor,
+ )
+ Text(
+ modifier = Modifier.align(Alignment.End),
+ text = "${state.backupUploadState.backedUpCount} / ${state.backupUploadState.totalCount}",
+ style = ElementTheme.typography.fontBodySmRegular,
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun LogoutViewPreview(
+ @PreviewParameter(LogoutStateProvider::class) state: LogoutState,
+) = ElementPreview {
+ LogoutView(
+ state,
+ onChangeRecoveryKeyClicked = {},
+ onSuccessLogout = {},
+ onBackClicked = {},
+ )
+}
diff --git a/features/logout/api/src/main/res/values-cs/translations.xml b/features/logout/impl/src/main/res/values-cs/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-cs/translations.xml
rename to features/logout/impl/src/main/res/values-cs/translations.xml
diff --git a/features/logout/api/src/main/res/values-de/translations.xml b/features/logout/impl/src/main/res/values-de/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-de/translations.xml
rename to features/logout/impl/src/main/res/values-de/translations.xml
diff --git a/features/logout/api/src/main/res/values-es/translations.xml b/features/logout/impl/src/main/res/values-es/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-es/translations.xml
rename to features/logout/impl/src/main/res/values-es/translations.xml
diff --git a/features/logout/impl/src/main/res/values-fr/translations.xml b/features/logout/impl/src/main/res/values-fr/translations.xml
new file mode 100644
index 0000000000..c309c3166f
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-fr/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Êtes-vous sûr de vouloir vous déconnecter ?"
+ "Se déconnecter"
+ "Déconnexion…"
+ "Vous êtes en train de vous déconnecter de votre dernière session. Si vous vous déconnectez maintenant, vous perdrez l’accès à l’historique de vos discussions chiffrées."
+ "Vous avez désactivé la sauvegarde"
+ "Vos clés étaient en cours de sauvegarde lorsque vous avez perdu la connexion au réseau. Il faudrait rétablir cette connexion afin de pouvoir terminer la sauvegarde avant de vous déconnecter."
+ "Vos clés sont en cours de sauvegarde"
+ "Veuillez attendre que cela se termine avant de vous déconnecter."
+ "Vos clés sont en cours de sauvegarde"
+ "Vous êtes sur le point de vous déconnecter de votre dernier appareil. Si vous le faites maintenant, vous perdrez l’accès à l’historique de vos messages."
+ "La récupération n’est pas configurée."
+ "Vous êtes sur le point de vous déconnecter de votre dernière session. Si vous le faites maintenant, vous perdrez l’accès à l’historique de vos discussions chiffrées."
+ "Avez-vous sauvegardé votre clé de récupération?"
+ "Se déconnecter"
+ "Se déconnecter"
+
diff --git a/features/logout/api/src/main/res/values-it/translations.xml b/features/logout/impl/src/main/res/values-it/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-it/translations.xml
rename to features/logout/impl/src/main/res/values-it/translations.xml
diff --git a/features/logout/api/src/main/res/values-ro/translations.xml b/features/logout/impl/src/main/res/values-ro/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-ro/translations.xml
rename to features/logout/impl/src/main/res/values-ro/translations.xml
diff --git a/features/logout/api/src/main/res/values-ru/translations.xml b/features/logout/impl/src/main/res/values-ru/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-ru/translations.xml
rename to features/logout/impl/src/main/res/values-ru/translations.xml
diff --git a/features/logout/impl/src/main/res/values-sk/translations.xml b/features/logout/impl/src/main/res/values-sk/translations.xml
new file mode 100644
index 0000000000..fcd78f2022
--- /dev/null
+++ b/features/logout/impl/src/main/res/values-sk/translations.xml
@@ -0,0 +1,18 @@
+
+
+ "Ste si istí, že sa chcete odhlásiť?"
+ "Odhlásiť sa"
+ "Prebieha odhlasovanie…"
+ "Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."
+ "Vypli ste zálohovanie"
+ "Keď ste sa odpojili od internetu, vaše kľúče sa ešte stále zálohovali. Pripojte sa znova k internetu, aby sa vaše kľúče mohli zálohovať pred odhlásením."
+ "Vaše kľúče sa ešte stále zálohujú"
+ "Pred odhlásením počkajte, kým sa to dokončí."
+ "Vaše kľúče sa ešte stále zálohujú"
+ "Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, stratíte prístup k svojim šifrovaným správam."
+ "Obnovenie nie je nastavené"
+ "Chystáte sa odhlásiť z vašej poslednej relácie. Ak sa teraz odhlásite, môžete stratiť prístup k svojim šifrovaným správam."
+ "Uložili ste si kľúč na obnovenie?"
+ "Odhlásiť sa"
+ "Odhlásiť sa"
+
diff --git a/features/logout/api/src/main/res/values-zh-rTW/translations.xml b/features/logout/impl/src/main/res/values-zh-rTW/translations.xml
similarity index 100%
rename from features/logout/api/src/main/res/values-zh-rTW/translations.xml
rename to features/logout/impl/src/main/res/values-zh-rTW/translations.xml
diff --git a/features/logout/impl/src/main/res/values/localazy.xml b/features/logout/impl/src/main/res/values/localazy.xml
new file mode 100644
index 0000000000..9296381c87
--- /dev/null
+++ b/features/logout/impl/src/main/res/values/localazy.xml
@@ -0,0 +1,18 @@
+
+
+ "Are you sure you want to sign out?"
+ "Sign out"
+ "Signing out…"
+ "You are about to sign out of your last session. If you sign out now, you will lose access to your encrypted messages."
+ "You have turned off backup"
+ "Your keys were still being backed up when you went offline. Reconnect so that your keys can be backed up before signing out."
+ "Your keys are still being backed up"
+ "Please wait for this to complete before signing out."
+ "Your keys are still being backed up"
+ "You are about to sign out of your last session. If you sign out now, you\'ll lose access to your encrypted messages."
+ "Recovery not set up"
+ "You are about to sign out of your last session. If you sign out now, you might lose access to your encrypted messages."
+ "Have you saved your recovery key?"
+ "Sign out"
+ "Sign out"
+
diff --git a/features/logout/impl/src/main/res/values/tmp.xml b/features/logout/impl/src/main/res/values/tmp.xml
new file mode 100644
index 0000000000..e9e1e376c6
--- /dev/null
+++ b/features/logout/impl/src/main/res/values/tmp.xml
@@ -0,0 +1,6 @@
+
+
+
+
+
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
deleted file mode 100644
index 52e673cba6..0000000000
--- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPreferencePresenterTest.kt
+++ /dev/null
@@ -1,87 +0,0 @@
-/*
- * Copyright (c) 2023 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.
- */
-
-package io.element.android.features.logout.impl
-
-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.logout.api.LogoutPreferenceEvents
-import io.element.android.features.logout.api.LogoutPreferenceState
-import io.element.android.libraries.architecture.Async
-import io.element.android.libraries.matrix.test.A_THROWABLE
-import io.element.android.libraries.matrix.test.FakeMatrixClient
-import io.element.android.tests.testutils.WarmUpRule
-import kotlinx.coroutines.test.runTest
-import org.junit.Rule
-import org.junit.Test
-
-class LogoutPreferencePresenterTest {
-
- @get:Rule
- val warmUpRule = WarmUpRule()
-
- @Test
- fun `present - initial state`() = runTest {
- val presenter = DefaultLogoutPreferencePresenter(
- FakeMatrixClient(),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
- }
- }
-
- @Test
- fun `present - logout`() = runTest {
- val presenter = DefaultLogoutPreferencePresenter(
- FakeMatrixClient(),
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
- val loadingState = awaitItem()
- assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
- }
- }
-
- @Test
- fun `present - logout with error`() = runTest {
- val matrixClient = FakeMatrixClient()
- val presenter = DefaultLogoutPreferencePresenter(
- matrixClient,
- )
- moleculeFlow(RecompositionMode.Immediate) {
- presenter.present()
- }.test {
- val initialState = awaitItem()
- matrixClient.givenLogoutError(A_THROWABLE)
- initialState.eventSink.invoke(LogoutPreferenceEvents.Logout)
- val loadingState = awaitItem()
- assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
- val successState = awaitItem()
- assertThat(successState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE))
- }
- }
-}
-
diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
new file mode 100644
index 0000000000..9b06e71ba3
--- /dev/null
+++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt
@@ -0,0 +1,215 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.logout.impl
+
+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.architecture.Async
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.featureflag.test.FakeFeatureFlagService
+import io.element.android.libraries.matrix.api.MatrixClient
+import io.element.android.libraries.matrix.api.encryption.BackupUploadState
+import io.element.android.libraries.matrix.api.encryption.EncryptionService
+import io.element.android.libraries.matrix.test.A_THROWABLE
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
+import io.element.android.tests.testutils.WarmUpRule
+import io.element.android.tests.testutils.awaitLastSequentialItem
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.flow.flow
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+
+class LogoutPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createLogoutPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitLastSequentialItem()
+ assertThat(initialState.isLastSession).isFalse()
+ assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ assertThat(initialState.showConfirmationDialog).isFalse()
+ assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - initial state - last session`() = runTest {
+ val presenter = createLogoutPresenter(
+ encryptionService = FakeEncryptionService().apply {
+ givenIsLastDevice(true)
+ }
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isLastSession).isTrue()
+ assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ assertThat(initialState.showConfirmationDialog).isFalse()
+ assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - initial state - backing up`() = runTest {
+ val encryptionService = FakeEncryptionService()
+ encryptionService.givenWaitForBackupUploadSteadyStateFlow(
+ flow {
+ emit(BackupUploadState.Waiting)
+ delay(1)
+ emit(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
+ delay(1)
+ emit(BackupUploadState.Done)
+ }
+ )
+ val presenter = createLogoutPresenter(
+ encryptionService = encryptionService
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+ assertThat(initialState.isLastSession).isFalse()
+ assertThat(initialState.backupUploadState).isEqualTo(BackupUploadState.Unknown)
+ assertThat(initialState.showConfirmationDialog).isFalse()
+ assertThat(initialState.logoutAction).isEqualTo(Async.Uninitialized)
+ val waitingState = awaitItem()
+ assertThat(waitingState.backupUploadState).isEqualTo(BackupUploadState.Waiting)
+ val uploadingState = awaitItem()
+ assertThat(uploadingState.backupUploadState).isEqualTo(BackupUploadState.Uploading(backedUpCount = 1, totalCount = 2))
+ val doneState = awaitItem()
+ assertThat(doneState.backupUploadState).isEqualTo(BackupUploadState.Done)
+ }
+ }
+
+ @Test
+ fun `present - logout then cancel`() = runTest {
+ val presenter = createLogoutPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitLastSequentialItem()
+ initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.showConfirmationDialog).isTrue()
+ initialState.eventSink.invoke(LogoutEvents.CloseDialogs)
+ val finalState = awaitItem()
+ assertThat(finalState.showConfirmationDialog).isFalse()
+ }
+ }
+
+ @Test
+ fun `present - logout then confirm`() = runTest {
+ val presenter = createLogoutPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitLastSequentialItem()
+ initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.showConfirmationDialog).isTrue()
+ confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.showConfirmationDialog).isFalse()
+ assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
+ }
+ }
+
+ @Test
+ fun `present - logout with error then cancel`() = runTest {
+ val matrixClient = FakeMatrixClient().apply {
+ givenLogoutError(A_THROWABLE)
+ }
+ val presenter = createLogoutPresenter(
+ matrixClient,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitLastSequentialItem()
+ initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.showConfirmationDialog).isTrue()
+ confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.showConfirmationDialog).isFalse()
+ assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
+ val errorState = awaitItem()
+ assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE))
+ errorState.eventSink.invoke(LogoutEvents.CloseDialogs)
+ val finalState = awaitItem()
+ assertThat(finalState.logoutAction).isEqualTo(Async.Uninitialized)
+ }
+ }
+
+ @Test
+ fun `present - logout with error then force`() = runTest {
+ val matrixClient = FakeMatrixClient().apply {
+ givenLogoutError(A_THROWABLE)
+ }
+ val presenter = createLogoutPresenter(
+ matrixClient,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitLastSequentialItem()
+ initialState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ val confirmationState = awaitItem()
+ assertThat(confirmationState.showConfirmationDialog).isTrue()
+ confirmationState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = false))
+ skipItems(1)
+ val loadingState = awaitItem()
+ assertThat(loadingState.showConfirmationDialog).isFalse()
+ assertThat(loadingState.logoutAction).isInstanceOf(Async.Loading::class.java)
+ val errorState = awaitItem()
+ assertThat(errorState.logoutAction).isEqualTo(Async.Failure(A_THROWABLE))
+ errorState.eventSink.invoke(LogoutEvents.Logout(ignoreSdkError = true))
+ val loadingState2 = awaitItem()
+ assertThat(loadingState2.showConfirmationDialog).isFalse()
+ assertThat(loadingState2.logoutAction).isInstanceOf(Async.Loading::class.java)
+ val successState = awaitItem()
+ assertThat(successState.logoutAction).isInstanceOf(Async.Success::class.java)
+ }
+ }
+
+ private fun createLogoutPresenter(
+ matrixClient: MatrixClient = FakeMatrixClient(),
+ encryptionService: EncryptionService = FakeEncryptionService(),
+ ): LogoutPresenter = LogoutPresenter(
+ matrixClient = matrixClient,
+ encryptionService = encryptionService,
+ featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.SecureStorage.key to true)),
+ )
+}
+
diff --git a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
index 5a0596c7bb..d9a5e50e11 100644
--- a/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
+++ b/features/messages/api/src/main/kotlin/io/element/android/features/messages/api/MessageComposerContext.kt
@@ -16,7 +16,7 @@
package io.element.android.features.messages.api
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
/**
* Hoist-able state of the message composer.
diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts
index e886a3aeaa..ca3051351f 100644
--- a/features/messages/impl/build.gradle.kts
+++ b/features/messages/impl/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
api(projects.features.messages.api)
+ implementation(projects.features.call)
implementation(projects.features.location.api)
implementation(projects.features.poll.api)
implementation(projects.libraries.androidutils)
@@ -50,6 +51,9 @@ dependencies {
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.libraries.preferences.api)
+ implementation(projects.libraries.voicerecorder.api)
+ implementation(projects.libraries.mediaplayer.api)
+ implementation(projects.libraries.uiUtils)
implementation(projects.features.networkmonitor.api)
implementation(projects.services.analytics.api)
implementation(libs.coil.compose)
@@ -79,6 +83,8 @@ dependencies {
testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.textcomposer.test)
+ testImplementation(projects.libraries.voicerecorder.test)
+ testImplementation(projects.libraries.mediaplayer.test)
testImplementation(libs.test.mockk)
ksp(libs.showkase.processor)
diff --git a/features/messages/impl/src/main/AndroidManifest.xml b/features/messages/impl/src/main/AndroidManifest.xml
new file mode 100644
index 0000000000..a00e8e1873
--- /dev/null
+++ b/features/messages/impl/src/main/AndroidManifest.xml
@@ -0,0 +1,20 @@
+
+
+
+
+
+
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt
index 8b28a216b0..215559b334 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/ExpandableBottomSheetScaffold.kt
@@ -20,12 +20,8 @@ package io.element.android.features.messages.impl
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxHeight
-import androidx.compose.material3.BottomSheetScaffold
import androidx.compose.material3.ExperimentalMaterial3Api
-import androidx.compose.material3.SheetState
import androidx.compose.material3.SheetValue
-import androidx.compose.material3.rememberBottomSheetScaffoldState
-import androidx.compose.material3.rememberStandardBottomSheetState
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
@@ -42,6 +38,10 @@ import androidx.compose.ui.unit.Constraints
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.min
+import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
+import io.element.android.libraries.designsystem.theme.components.bottomsheet.CustomSheetState
+import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberBottomSheetScaffoldState
+import io.element.android.libraries.designsystem.theme.components.bottomsheet.rememberStandardBottomSheetState
import kotlin.math.roundToInt
/**
@@ -58,6 +58,7 @@ import kotlin.math.roundToInt
* @param modifier The modifier for the layout.
* @param sheetContentKey The key for the sheet content. If the key changes, the sheet will be remeasured.
*/
+@OptIn(ExperimentalMaterial3Api::class)
@Composable
internal fun ExpandableBottomSheetScaffold(
content: @Composable (padding: PaddingValues) -> Unit,
@@ -139,7 +140,7 @@ internal fun ExpandableBottomSheetScaffold(
modifier = Modifier.fillMaxHeight(),
measurePolicy = { measurables, constraints ->
val constraintHeight = constraints.maxHeight
- val offset = scaffoldState.bottomSheetState.getOffset() ?: 0
+ val offset = scaffoldState.bottomSheetState.getIntOffset() ?: 0
val height = Integer.max(0, constraintHeight - offset)
val top = measurables[0].measure(
constraints.copy(
@@ -163,7 +164,7 @@ internal fun ExpandableBottomSheetScaffold(
})
}
-private fun SheetState.getOffset(): Int? = try {
+private fun CustomSheetState.getIntOffset(): Int? = try {
requireOffset().roundToInt()
} catch (e: IllegalStateException) {
null
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
index fcb2e7e5e8..128c531374 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt
@@ -16,6 +16,7 @@
package io.element.android.features.messages.impl
+import android.content.Context
import android.os.Parcelable
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
@@ -29,6 +30,8 @@ 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.call.CallType
+import io.element.android.features.call.ui.ElementCallActivity
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.SendLocationEntryPoint
import io.element.android.features.location.api.ShowLocationEntryPoint
@@ -50,7 +53,9 @@ import io.element.android.features.poll.api.create.CreatePollEntryPoint
import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
+import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@@ -63,6 +68,8 @@ import kotlinx.parcelize.Parcelize
class MessagesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
+ @ApplicationContext private val context: Context,
+ private val matrixClient: MatrixClient,
private val sendLocationEntryPoint: SendLocationEntryPoint,
private val showLocationEntryPoint: ShowLocationEntryPoint,
private val createPollEntryPoint: CreatePollEntryPoint,
@@ -149,6 +156,14 @@ class MessagesFlowNode @AssistedInject constructor(
override fun onCreatePollClicked() {
backstack.push(NavTarget.CreatePoll)
}
+
+ override fun onJoinCallClicked(roomId: RoomId) {
+ val inputs = CallType.RoomCall(
+ sessionId = matrixClient.sessionId,
+ roomId = roomId,
+ )
+ ElementCallActivity.start(context, inputs)
+ }
}
createNode(buildContext, listOf(callback))
}
@@ -238,7 +253,7 @@ class MessagesFlowNode @AssistedInject constructor(
backstack.push(navTarget)
}
is TimelineItemAudioContent -> {
- val mediaSource = event.content.audioSource
+ val mediaSource = event.content.mediaSource
val navTarget = NavTarget.MediaViewer(
mediaInfo = MediaInfo(
name = event.content.body,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
index 6a3cf502d7..636569a24b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesNode.kt
@@ -17,6 +17,7 @@
package io.element.android.features.messages.impl
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext
@@ -27,9 +28,13 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
+import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.libraries.mediaplayer.api.MediaPlayer
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.item.TimelineItemDebugInfo
@@ -44,6 +49,8 @@ class MessagesNode @AssistedInject constructor(
private val room: MatrixRoom,
private val analyticsService: AnalyticsService,
private val presenterFactory: MessagesPresenter.Factory,
+ private val timelineItemPresenterFactories: TimelineItemPresenterFactories,
+ private val mediaPlayer: MediaPlayer,
) : Node(buildContext, plugins = plugins), MessagesNavigator {
private val presenter = presenterFactory.create(this)
@@ -59,12 +66,16 @@ class MessagesNode @AssistedInject constructor(
fun onReportMessage(eventId: EventId, senderId: UserId)
fun onSendLocationClicked()
fun onCreatePollClicked()
+ fun onJoinCallClicked(roomId: RoomId)
}
init {
lifecycle.subscribe(
onCreate = {
analyticsService.capture(room.toAnalyticsViewRoom())
+ },
+ onDestroy = {
+ mediaPlayer.close()
}
)
}
@@ -104,19 +115,28 @@ class MessagesNode @AssistedInject constructor(
callback?.onCreatePollClicked()
}
+ private fun onJoinCallClicked() {
+ callback?.onJoinCallClicked(room.roomId)
+ }
+
@Composable
override fun View(modifier: Modifier) {
- val state = presenter.present()
- MessagesView(
- state = state,
- onBackPressed = this::navigateUp,
- onRoomDetailsClicked = this::onRoomDetailsClicked,
- onEventClicked = this::onEventClicked,
- onPreviewAttachments = this::onPreviewAttachments,
- onUserDataClicked = this::onUserDataClicked,
- onSendLocationClicked = this::onSendLocationClicked,
- onCreatePollClicked = this::onCreatePollClicked,
- modifier = modifier,
- )
+ CompositionLocalProvider(
+ LocalTimelineItemPresenterFactories provides timelineItemPresenterFactories,
+ ) {
+ val state = presenter.present()
+ MessagesView(
+ state = state,
+ onBackPressed = this::navigateUp,
+ onRoomDetailsClicked = this::onRoomDetailsClicked,
+ onEventClicked = this::onEventClicked,
+ onPreviewAttachments = this::onPreviewAttachments,
+ onUserDataClicked = this::onUserDataClicked,
+ onSendLocationClicked = this::onSendLocationClicked,
+ onCreatePollClicked = this::onCreatePollClicked,
+ onJoinCallClicked = this::onJoinCallClicked,
+ modifier = modifier,
+ )
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
index 20dc17eabc..fdfd1484d5 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt
@@ -21,6 +21,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
@@ -54,7 +55,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatter
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.preferences.api.store.PreferencesStore
@@ -62,20 +65,24 @@ import io.element.android.libraries.androidutils.clipboard.ClipboardHelper
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
+import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.room.canRedactAsState
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
@@ -84,6 +91,7 @@ import timber.log.Timber
class MessagesPresenter @AssistedInject constructor(
private val room: MatrixRoom,
private val composerPresenter: MessageComposerPresenter,
+ private val voiceMessageComposerPresenter: VoiceMessageComposerPresenter,
private val timelinePresenter: TimelinePresenter,
private val actionListPresenter: ActionListPresenter,
private val customReactionPresenter: CustomReactionPresenter,
@@ -95,7 +103,9 @@ class MessagesPresenter @AssistedInject constructor(
private val dispatchers: CoroutineDispatchers,
private val clipboardHelper: ClipboardHelper,
private val preferencesStore: PreferencesStore,
+ private val featureFlagsService: FeatureFlagService,
@Assisted private val navigator: MessagesNavigator,
+ private val buildMeta: BuildMeta,
) : Presenter {
@AssistedFactory
@@ -105,8 +115,10 @@ class MessagesPresenter @AssistedInject constructor(
@Composable
override fun present(): MessagesState {
+ val roomInfo by room.roomInfoFlow.collectAsState(null)
val localCoroutineScope = rememberCoroutineScope()
val composerState = composerPresenter.present()
+ val voiceMessageComposerState = voiceMessageComposerPresenter.present()
val timelineState = timelinePresenter.present()
val actionListState = actionListPresenter.present()
val customReactionState = customReactionPresenter.present()
@@ -116,14 +128,13 @@ class MessagesPresenter @AssistedInject constructor(
val syncUpdateFlow = room.syncUpdateFlow.collectAsState()
val userHasPermissionToSendMessage by room.canSendMessageAsState(type = MessageEventType.ROOM_MESSAGE, updateKey = syncUpdateFlow.value)
val userHasPermissionToRedact by room.canRedactAsState(updateKey = syncUpdateFlow.value)
- var roomName: Async by remember { mutableStateOf(Async.Uninitialized) }
- var roomAvatar: Async by remember { mutableStateOf(Async.Uninitialized) }
- LaunchedEffect(syncUpdateFlow.value) {
- withContext(dispatchers.io) {
- roomName = Async.Success(room.displayName)
- roomAvatar = Async.Success(room.avatarData())
- }
+ val roomName: Async by remember {
+ derivedStateOf { roomInfo?.name?.let { Async.Success(it) } ?: Async.Uninitialized }
}
+ val roomAvatar: Async by remember {
+ derivedStateOf { roomInfo?.avatarData()?.let { Async.Success(it) } ?: Async.Uninitialized }
+ }
+
var hasDismissedInviteDialog by rememberSaveable {
mutableStateOf(false)
}
@@ -145,6 +156,13 @@ class MessagesPresenter @AssistedInject constructor(
val enableTextFormatting by preferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true)
+ var enableVoiceMessages by remember { mutableStateOf(false) }
+ var enableInRoomCalls by remember { mutableStateOf(false) }
+ LaunchedEffect(featureFlagsService) {
+ enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages)
+ enableInRoomCalls = featureFlagsService.isFeatureEnabled(FeatureFlags.InRoomCalls)
+ }
+
fun handleEvents(event: MessagesEvents) {
when (event) {
is MessagesEvents.HandleAction -> {
@@ -177,6 +195,7 @@ class MessagesPresenter @AssistedInject constructor(
userHasPermissionToSendMessage = userHasPermissionToSendMessage,
userHasPermissionToRedact = userHasPermissionToRedact,
composerState = composerState,
+ voiceMessageComposerState = voiceMessageComposerState,
timelineState = timelineState,
actionListState = actionListState,
customReactionState = customReactionState,
@@ -187,14 +206,18 @@ class MessagesPresenter @AssistedInject constructor(
showReinvitePrompt = showReinvitePrompt,
inviteProgress = inviteProgress.value,
enableTextFormatting = enableTextFormatting,
+ enableVoiceMessages = enableVoiceMessages,
+ enableInRoomCalls = enableInRoomCalls,
+ appName = buildMeta.applicationName,
+ isCallOngoing = roomInfo?.hasRoomCall ?: false,
eventSink = { handleEvents(it) }
)
}
- private fun MatrixRoom.avatarData(): AvatarData {
+ private fun MatrixRoomInfo.avatarData(): AvatarData {
return AvatarData(
- id = roomId.value,
- name = displayName,
+ id = id,
+ name = name,
url = avatarUrl,
size = AvatarSize.TimelineRoom
)
@@ -308,6 +331,10 @@ class MessagesPresenter @AssistedInject constructor(
textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Audio,
)
+ is TimelineItemVoiceContent -> AttachmentThumbnailInfo(
+ textContent = textContent,
+ type = AttachmentThumbnailType.Voice,
+ )
is TimelineItemLocationContent -> AttachmentThumbnailInfo(
type = AttachmentThumbnailType.Location,
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
index 31a66acd8c..5bce4f19a1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesState.kt
@@ -23,6 +23,7 @@ import io.element.android.features.messages.impl.timeline.TimelineState
import io.element.android.features.messages.impl.timeline.components.customreaction.CustomReactionState
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -36,6 +37,7 @@ data class MessagesState(
val userHasPermissionToSendMessage: Boolean,
val userHasPermissionToRedact: Boolean,
val composerState: MessageComposerState,
+ val voiceMessageComposerState: VoiceMessageComposerState,
val timelineState: TimelineState,
val actionListState: ActionListState,
val customReactionState: CustomReactionState,
@@ -46,5 +48,9 @@ data class MessagesState(
val inviteProgress: Async,
val showReinvitePrompt: Boolean,
val enableTextFormatting: Boolean,
+ val enableVoiceMessages: Boolean,
+ val enableInRoomCalls: Boolean,
+ val isCallOngoing: Boolean,
+ val appName: String,
val eventSink: (MessagesEvents) -> Unit
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
index 5642bd4d2b..275b324862 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.anActionListState
+import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.aMessageComposerState
import io.element.android.features.messages.impl.timeline.aTimelineItemList
import io.element.android.features.messages.impl.timeline.aTimelineState
@@ -25,12 +26,14 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.features.messages.impl.timeline.components.reactionsummary.ReactionSummaryState
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuState
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
+import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
+import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentSetOf
open class MessagesStateProvider : PreviewParameterProvider {
@@ -46,6 +49,23 @@ open class MessagesStateProvider : PreviewParameterProvider {
roomAvatar = Async.Uninitialized,
),
aMessagesState().copy(composerState = aMessageComposerState().copy(showTextFormatting = true)),
+ aMessagesState().copy(
+ enableVoiceMessages = true,
+ voiceMessageComposerState = aVoiceMessageComposerState(showPermissionRationaleDialog = true),
+ ),
+ aMessagesState().copy(
+ composerState = aMessageComposerState().copy(
+ attachmentsState = AttachmentsState.Sending.Processing(persistentListOf())
+ ),
+ ),
+ aMessagesState().copy(
+ composerState = aMessageComposerState().copy(
+ attachmentsState = AttachmentsState.Sending.Uploading(0.33f)
+ ),
+ ),
+ aMessagesState().copy(
+ isCallOngoing = true,
+ )
)
}
@@ -58,8 +78,9 @@ fun aMessagesState() = MessagesState(
composerState = aMessageComposerState().copy(
richTextEditorState = RichTextEditorState("Hello", initialFocus = true),
isFullScreen = false,
- mode = MessageComposerMode.Normal("Hello"),
+ mode = MessageComposerMode.Normal,
),
+ voiceMessageComposerState = aVoiceMessageComposerState(),
timelineState = aTimelineState().copy(
timelineItems = aTimelineItemList(aTimelineItemTextContent()),
),
@@ -82,5 +103,9 @@ fun aMessagesState() = MessagesState(
inviteProgress = Async.Uninitialized,
showReinvitePrompt = false,
enableTextFormatting = true,
+ enableVoiceMessages = true,
+ enableInRoomCalls = true,
+ isCallOngoing = false,
+ appName = "Element",
eventSink = {}
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
index e112a451ad..cc89ba3658 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt
@@ -21,24 +21,33 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBars
import androidx.compose.foundation.layout.width
+import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
+import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.RectangleShape
+import androidx.compose.ui.input.nestedscroll.NestedScrollConnection
+import androidx.compose.ui.input.nestedscroll.NestedScrollSource
+import androidx.compose.ui.input.nestedscroll.nestedScroll
import androidx.compose.ui.platform.LocalView
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
@@ -50,6 +59,7 @@ import io.element.android.features.messages.impl.actionlist.ActionListEvents
import io.element.android.features.messages.impl.actionlist.ActionListView
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.attachments.Attachment
+import io.element.android.features.messages.impl.mentions.MentionSuggestionsPickerView
import io.element.android.features.messages.impl.messagecomposer.AttachmentsBottomSheet
import io.element.android.features.messages.impl.messagecomposer.AttachmentsState
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
@@ -62,6 +72,8 @@ import io.element.android.features.messages.impl.timeline.components.reactionsum
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMenuEvents
import io.element.android.features.messages.impl.timeline.components.retrysendmenu.RetrySendMessageMenu
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessagePermissionRationaleDialog
import io.element.android.features.networkmonitor.api.ui.ConnectivityIndicatorView
import io.element.android.libraries.androidutils.ui.hideKeyboard
import io.element.android.libraries.designsystem.atomic.molecules.IconTitlePlaceholdersRowMolecule
@@ -75,10 +87,15 @@ import io.element.android.libraries.designsystem.components.dialogs.Confirmation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.BottomSheetDragHandle
+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.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
+import io.element.android.libraries.designsystem.utils.CommonDrawables
+import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.LogCompositions
+import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.UserId
@@ -87,6 +104,7 @@ import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
+import androidx.compose.material3.Button as Material3Button
@Composable
fun MessagesView(
@@ -98,10 +116,17 @@ fun MessagesView(
onPreviewAttachments: (ImmutableList) -> Unit,
onSendLocationClicked: () -> Unit,
onCreatePollClicked: () -> Unit,
+ onJoinCallClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
LogCompositions(tag = "MessagesScreen", msg = "Root")
+ OnLifecycleEvent { _, event ->
+ state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.LifecycleEvent(event))
+ }
+
+ KeepScreenOn(state.voiceMessageComposerState.keepScreenOn)
+
AttachmentStateView(
state = state.composerState.attachmentsState,
onPreviewAttachments = onPreviewAttachments,
@@ -159,8 +184,11 @@ fun MessagesView(
MessagesViewTopBar(
roomName = state.roomName.dataOrNull(),
roomAvatar = state.roomAvatar.dataOrNull(),
+ inRoomCallsEnabled = state.enableInRoomCalls,
onBackPressed = onBackPressed,
onRoomDetailsClicked = onRoomDetailsClicked,
+ isCallOngoing = state.isCallOngoing,
+ onJoinCallClicked = onJoinCallClicked,
)
}
},
@@ -222,6 +250,14 @@ fun MessagesView(
ReinviteDialog(
state = state
)
+
+ // Since the textfield is now based on an Android view, this is no longer done automatically.
+ // We need to hide the keyboard automatically when navigating out of this screen.
+ DisposableEffect(Unit) {
+ onDispose {
+ localView.hideKeyboard()
+ }
+ }
}
@Composable
@@ -291,6 +327,18 @@ private fun MessagesViewContent(
enableTextFormatting = state.enableTextFormatting,
)
+ if (state.enableVoiceMessages && state.voiceMessageComposerState.showPermissionRationaleDialog) {
+ VoiceMessagePermissionRationaleDialog(
+ onContinue = {
+ state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
+ },
+ onDismiss = {
+ state.voiceMessageComposerState.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
+ },
+ appName = state.appName
+ )
+ }
+
ExpandableBottomSheetScaffold(
sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() }
@@ -298,7 +346,11 @@ private fun MessagesViewContent(
@Composable {}
},
sheetSwipeEnabled = state.composerState.showTextFormatting,
- sheetShape = if (state.composerState.showTextFormatting) MaterialTheme.shapes.large else RectangleShape,
+ sheetShape = if (state.composerState.showTextFormatting || state.composerState.memberSuggestions.isNotEmpty()) {
+ MaterialTheme.shapes.large
+ } else {
+ RectangleShape
+ },
content = { paddingValues ->
TimelineView(
modifier = Modifier.padding(paddingValues),
@@ -314,32 +366,66 @@ private fun MessagesViewContent(
)
},
sheetContent = { subcomposing: Boolean ->
- if (state.userHasPermissionToSendMessage) {
- MessageComposerView(
- state = state.composerState,
- subcomposing = subcomposing,
- enableTextFormatting = state.enableTextFormatting,
- modifier = Modifier
- .fillMaxWidth(),
- )
- } else {
- CantSendMessageBanner()
- }
+ MessagesViewComposerBottomSheetContents(
+ subcomposing = subcomposing,
+ state = state,
+ )
},
- sheetContentKey = state.composerState.richTextEditorState.lineCount,
+ sheetContentKey = state.composerState.richTextEditorState.lineCount + state.composerState.memberSuggestions.size,
sheetTonalElevation = 0.dp,
- sheetShadowElevation = 0.dp,
+ sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
)
}
}
+@Composable
+private fun MessagesViewComposerBottomSheetContents(
+ subcomposing: Boolean,
+ state: MessagesState,
+ modifier: Modifier = Modifier,
+) {
+ if (state.userHasPermissionToSendMessage) {
+ Column(modifier = modifier.fillMaxWidth()) {
+ MentionSuggestionsPickerView(
+ modifier = Modifier.heightIn(max = 230.dp)
+ // Consume all scrolling, preventing the bottom sheet from being dragged when interacting with the list of suggestions
+ .nestedScroll(object : NestedScrollConnection {
+ override fun onPostScroll(consumed: Offset, available: Offset, source: NestedScrollSource): Offset {
+ return available
+ }
+ }),
+ roomId = state.roomId,
+ roomName = state.roomName.dataOrNull(),
+ roomAvatarData = state.roomAvatar.dataOrNull(),
+ memberSuggestions = state.composerState.memberSuggestions,
+ onSuggestionSelected = {
+ // TODO pass the selected suggestion to the RTE so it can be inserted as a pill
+ }
+ )
+ MessageComposerView(
+ state = state.composerState,
+ voiceMessageState = state.voiceMessageComposerState,
+ subcomposing = subcomposing,
+ enableTextFormatting = state.enableTextFormatting,
+ enableVoiceMessages = state.enableVoiceMessages,
+ modifier = Modifier.fillMaxWidth(),
+ )
+ }
+ } else {
+ CantSendMessageBanner(modifier = modifier)
+ }
+}
+
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun MessagesViewTopBar(
roomName: String?,
roomAvatar: AvatarData?,
+ inRoomCallsEnabled: Boolean,
+ isCallOngoing: Boolean,
modifier: Modifier = Modifier,
onRoomDetailsClicked: () -> Unit = {},
+ onJoinCallClicked: () -> Unit = {},
onBackPressed: () -> Unit = {},
) {
TopAppBar(
@@ -362,10 +448,50 @@ private fun MessagesViewTopBar(
)
}
},
+ actions = {
+ if (inRoomCallsEnabled) {
+ if (isCallOngoing) {
+ JoinCallMenuItem(onJoinCallClicked = onJoinCallClicked)
+ } else {
+ IconButton(onClick = onJoinCallClicked) {
+ Icon(CommonDrawables.ic_compound_video_call, contentDescription = stringResource(CommonStrings.a11y_start_call))
+ }
+ }
+ }
+ Spacer(Modifier.width(8.dp))
+ },
windowInsets = WindowInsets(0.dp)
)
}
+@Composable
+private fun JoinCallMenuItem(
+ modifier: Modifier = Modifier,
+ onJoinCallClicked: () -> Unit,
+) {
+ Material3Button(
+ onClick = onJoinCallClicked,
+ colors = ButtonDefaults.buttonColors(
+ contentColor = ElementTheme.colors.bgCanvasDefault,
+ containerColor = ElementTheme.colors.iconAccentTertiary
+ ),
+ contentPadding = PaddingValues(horizontal = 10.dp, vertical = 0.dp),
+ modifier = modifier.heightIn(min = 36.dp),
+ ) {
+ Icon(
+ modifier = Modifier.size(20.dp),
+ resourceId = CommonDrawables.ic_compound_video_call,
+ contentDescription = null
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = stringResource(CommonStrings.action_join),
+ style = ElementTheme.typography.fontBodyMdMedium
+ )
+ Spacer(Modifier.width(8.dp))
+ }
+}
+
@Composable
private fun RoomAvatarAndNameRow(
roomName: String,
@@ -421,5 +547,6 @@ internal fun MessagesViewPreview(@PreviewParameter(MessagesStateProvider::class)
onUserDataClicked = {},
onSendLocationClicked = {},
onCreatePollClicked = {},
+ onJoinCallClicked = {},
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
index c9e485b88a..cd11aa875b 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListPresenter.kt
@@ -29,6 +29,7 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemRedactedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.model.event.canBeCopied
import io.element.android.features.messages.impl.timeline.model.event.canReact
import io.element.android.features.preferences.api.store.PreferencesStore
@@ -131,6 +132,23 @@ class ActionListPresenter @Inject constructor(
}
}
}
+ is TimelineItemVoiceContent -> {
+ buildList {
+ if (timelineItem.isRemote) {
+ add(TimelineItemAction.Reply)
+ add(TimelineItemAction.Forward)
+ }
+ if (isDeveloperModeEnabled) {
+ add(TimelineItemAction.ViewSource)
+ }
+ if (!timelineItem.isMine) {
+ add(TimelineItemAction.ReportContent)
+ }
+ if (timelineItem.isMine || userCanRedact) {
+ add(TimelineItemAction.Redact)
+ }
+ }
+ }
else -> buildList {
if (timelineItem.isRemote) {
// Can only reply or forward messages already uploaded to the server
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
index 44736bc076..3ea99aaede 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListStateProvider.kt
@@ -20,11 +20,13 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.messages.impl.actionlist.model.TimelineItemAction
import io.element.android.features.messages.impl.timeline.aTimelineItemEvent
import io.element.android.features.messages.impl.timeline.aTimelineItemReactions
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemFileContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemLocationContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@@ -67,6 +69,22 @@ open class ActionListStateProvider : PreviewParameterProvider {
actions = aTimelineItemActionList(),
)
),
+ anActionListState().copy(
+ target = ActionListState.Target.Success(
+ event = aTimelineItemEvent(content = aTimelineItemAudioContent()).copy(
+ reactionsState = reactionsState
+ ),
+ actions = aTimelineItemActionList(),
+ )
+ ),
+ anActionListState().copy(
+ target = ActionListState.Target.Success(
+ event = aTimelineItemEvent(content = aTimelineItemVoiceContent()).copy(
+ reactionsState = reactionsState
+ ),
+ actions = aTimelineItemActionList(),
+ )
+ ),
anActionListState().copy(
target = ActionListState.Target.Success(
event = aTimelineItemEvent(content = aTimelineItemLocationContent()).copy(
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
index 05db97d3be..c8bdba9e9d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/actionlist/ActionListView.kt
@@ -66,6 +66,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.utils.messagesummary.MessageSummaryFormatterImpl
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
@@ -307,6 +308,18 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
}
content = { ContentForBody(event.content.body) }
}
+ is TimelineItemVoiceContent -> {
+ icon = {
+ AttachmentThumbnail(
+ modifier = imageModifier,
+ info = AttachmentThumbnailInfo(
+ textContent = textContent,
+ type = AttachmentThumbnailType.Voice,
+ )
+ )
+ }
+ content = { ContentForBody(textContent) }
+ }
}
Row(modifier = modifier) {
icon()
@@ -348,7 +361,7 @@ private fun EmojiReactionsRow(
) {
// TODO use most recently used emojis here when available from the Rust SDK
val defaultEmojis = sequenceOf(
- "👍", "👎", "🔥", "❤️", "👏"
+ "👍️", "👎️", "🔥", "❤️", "👏"
)
for (emoji in defaultEmojis) {
val isHighlighted = highlightedEmojis.contains(emoji)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt
index 3ca534c310..4348b83a95 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaView.kt
@@ -66,6 +66,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes.isMimeTypeVideo
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
+import io.element.android.libraries.designsystem.utils.KeepScreenOn
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.theme.ElementTheme
import me.saket.telephoto.zoomable.ZoomSpec
@@ -152,6 +153,10 @@ private fun MediaVideoView(
override fun onRenderedFirstFrame() {
localMediaViewState.isReady = true
}
+
+ override fun onIsPlayingChanged(isPlaying: Boolean) {
+ localMediaViewState.isPlaying = isPlaying
+ }
}
val exoPlayer = remember {
ExoPlayerWrapper.create(context)
@@ -168,6 +173,7 @@ private fun MediaVideoView(
} else {
exoPlayer.setMediaItems(emptyList())
}
+ KeepScreenOn(localMediaViewState.isPlaying)
AndroidView(
factory = {
PlayerView(context).apply {
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt
index e009c3f6cc..d5af7a78e1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/media/local/LocalMediaViewState.kt
@@ -26,6 +26,7 @@ import androidx.compose.runtime.setValue
@Stable
class LocalMediaViewState {
var isReady: Boolean by mutableStateOf(false)
+ var isPlaying: Boolean by mutableStateOf(false)
}
@Composable
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
new file mode 100644
index 0000000000..6b937a872e
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt
@@ -0,0 +1,169 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.mentions
+
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import io.element.android.features.messages.impl.R
+import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.theme.ElementTheme
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
+
+@Composable
+fun MentionSuggestionsPickerView(
+ roomId: RoomId,
+ roomName: String?,
+ roomAvatarData: AvatarData?,
+ memberSuggestions: ImmutableList,
+ onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ LazyColumn(
+ modifier = modifier.fillMaxWidth(),
+ ) {
+ items(
+ memberSuggestions,
+ key = { suggestion ->
+ when (suggestion) {
+ is RoomMemberSuggestion.Room -> "@room"
+ is RoomMemberSuggestion.Member -> suggestion.roomMember.userId.value
+ }
+ }
+ ) {
+ Column(modifier = Modifier.fillParentMaxWidth()) {
+ RoomMemberSuggestionItemView(
+ memberSuggestion = it,
+ roomId = roomId.value,
+ roomName = roomName,
+ roomAvatar = roomAvatarData,
+ onSuggestionSelected = onSuggestionSelected,
+ modifier = Modifier.fillMaxWidth()
+ )
+ HorizontalDivider(modifier = Modifier.fillMaxWidth())
+ }
+ }
+ }
+}
+
+@Composable
+private fun RoomMemberSuggestionItemView(
+ memberSuggestion: RoomMemberSuggestion,
+ roomId: String,
+ roomName: String?,
+ roomAvatar: AvatarData?,
+ onSuggestionSelected: (RoomMemberSuggestion) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) {
+ val avatarSize = AvatarSize.TimelineRoom
+ val avatarData = when (memberSuggestion) {
+ is RoomMemberSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize)
+ is RoomMemberSuggestion.Member -> AvatarData(
+ memberSuggestion.roomMember.userId.value,
+ memberSuggestion.roomMember.displayName,
+ memberSuggestion.roomMember.avatarUrl,
+ avatarSize,
+ )
+ }
+ val title = when (memberSuggestion) {
+ is RoomMemberSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title)
+ is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.displayName
+ }
+
+ val subtitle = when (memberSuggestion) {
+ is RoomMemberSuggestion.Room -> "@room"
+ is RoomMemberSuggestion.Member -> memberSuggestion.roomMember.userId.value
+ }
+
+ Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp))
+
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(end = 16.dp, top = 8.dp, bottom = 8.dp)
+ .align(Alignment.CenterVertically),
+ ) {
+ title?.let {
+ Text(
+ text = it,
+ style = ElementTheme.typography.fontBodyLgRegular,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ Text(
+ text = subtitle,
+ style = ElementTheme.typography.fontBodySmRegular,
+ color = ElementTheme.colors.textSecondary,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun MentionSuggestionsPickerView_Preview() {
+ ElementPreview {
+ val roomMember = RoomMember(
+ userId = UserId("@alice:server.org"),
+ displayName = null,
+ avatarUrl = null,
+ membership = RoomMembershipState.JOIN,
+ isNameAmbiguous = false,
+ powerLevel = 0L,
+ normalizedPowerLevel = 0L,
+ isIgnored = false,
+ )
+ MentionSuggestionsPickerView(
+ roomId = RoomId("!room:matrix.org"),
+ roomName = "Room",
+ roomAvatarData = null,
+ memberSuggestions = persistentListOf(
+ RoomMemberSuggestion.Room,
+ RoomMemberSuggestion.Member(roomMember),
+ RoomMemberSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")),
+ ),
+ onSuggestionSelected = {}
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
new file mode 100644
index 0000000000..1f4e2b5376
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt
@@ -0,0 +1,111 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.mentions
+
+import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
+import io.element.android.libraries.matrix.api.core.UserId
+import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.room.roomMembers
+import io.element.android.libraries.textcomposer.model.Suggestion
+import io.element.android.libraries.textcomposer.model.SuggestionType
+
+/**
+ * This class is responsible for processing mention suggestions when `@`, `/` or `#` are type in the composer.
+ */
+object MentionSuggestionsProcessor {
+
+ // We don't want to retrieve thousands of members
+ private const val MAX_BATCH_ITEMS = 100
+
+ /**
+ * Process the mention suggestions.
+ * @param suggestion The current suggestion input
+ * @param roomMembersState The room members state, it contains the current users in the room
+ * @param currentUserId The current user id
+ * @param canSendRoomMention Should return true if the current user can send room mentions
+ * @return The list of mentions to display
+ */
+ suspend fun process(
+ suggestion: Suggestion?,
+ roomMembersState: MatrixRoomMembersState,
+ currentUserId: UserId,
+ canSendRoomMention: suspend () -> Boolean,
+ ): List {
+ val members = roomMembersState.roomMembers()
+ // Take the first MAX_BATCH_ITEMS only
+ ?.take(MAX_BATCH_ITEMS)
+ return when {
+ members.isNullOrEmpty() || suggestion == null -> {
+ // Clear suggestions
+ emptyList()
+ }
+ else -> {
+ when (suggestion.type) {
+ SuggestionType.Mention -> {
+ // Replace suggestions
+ val matchingMembers = getMemberSuggestions(
+ query = suggestion.text,
+ roomMembers = roomMembersState.roomMembers(),
+ currentUserId = currentUserId,
+ canSendRoomMention = canSendRoomMention()
+ )
+ matchingMembers
+ }
+ else -> {
+ // Clear suggestions
+ emptyList()
+ }
+ }
+ }
+ }
+ }
+
+ private fun getMemberSuggestions(
+ query: String,
+ roomMembers: List?,
+ currentUserId: UserId,
+ canSendRoomMention: Boolean,
+ ): List {
+ return if (roomMembers.isNullOrEmpty()) {
+ emptyList()
+ } else {
+ fun isJoinedMemberAndNotSelf(member: RoomMember): Boolean {
+ return member.membership == RoomMembershipState.JOIN && currentUserId != member.userId
+ }
+
+ fun memberMatchesQuery(member: RoomMember, query: String): Boolean {
+ return member.userId.value.contains(query, ignoreCase = true)
+ || member.displayName?.contains(query, ignoreCase = true) == true
+ }
+
+ val matchingMembers = roomMembers
+ // Search only in joined members, exclude the current user
+ .filter { member ->
+ isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query)
+ }
+ .map(RoomMemberSuggestion::Member)
+
+ if ("room".contains(query) && canSendRoomMention) {
+ listOf(RoomMemberSuggestion.Room) + matchingMembers
+ } else {
+ matchingMembers
+ }
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt
index 73481cd617..2353285499 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerContextImpl.kt
@@ -23,12 +23,12 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.di.SingleIn
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
import javax.inject.Inject
@SingleIn(RoomScope::class)
@ContributesBinding(RoomScope::class)
class MessageComposerContextImpl @Inject constructor() : MessageComposerContext {
- override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal(""))
+ override var composerMode: MessageComposerMode by mutableStateOf(MessageComposerMode.Normal)
internal set
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
index 92b180f326..97c2e7015d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt
@@ -17,8 +17,9 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
-import io.element.android.libraries.textcomposer.Message
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.Message
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.Suggestion
@Immutable
sealed interface MessageComposerEvents {
@@ -39,4 +40,5 @@ sealed interface MessageComposerEvents {
data class ToggleTextFormatting(val enabled: Boolean) : MessageComposerEvents
data object CancelSendAttachment : MessageComposerEvents
data class Error(val error: Throwable) : MessageComposerEvents
+ data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
index a45056ac67..b66decd81e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt
@@ -23,6 +23,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateListOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
@@ -34,6 +35,7 @@ import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.attachments.Attachment
import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError
import io.element.android.features.messages.impl.media.local.LocalMediaFactory
+import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
@@ -43,22 +45,32 @@ import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.matrix.api.core.ProgressCallback
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.RoomMember
+import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.permissions.api.PermissionsEvents
import io.element.android.libraries.permissions.api.PermissionsPresenter
-import io.element.android.libraries.textcomposer.Message
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.Message
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.Suggestion
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.Job
+import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.flow.collect
+import kotlinx.coroutines.flow.combine
+import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch
import javax.inject.Inject
import kotlin.coroutines.coroutineContext
+import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@SingleIn(RoomScope::class)
@@ -73,17 +85,26 @@ class MessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val messageComposerContext: MessageComposerContextImpl,
private val richTextEditorStateFactory: RichTextEditorStateFactory,
+ private val currentSessionIdHolder: CurrentSessionIdHolder,
permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter {
private val cameraPermissionPresenter = permissionsPresenterFactory.create(Manifest.permission.CAMERA)
private var pendingEvent: MessageComposerEvents? = null
+ private val suggestionSearchTrigger = MutableStateFlow(null)
+
+ @OptIn(FlowPreview::class)
@SuppressLint("UnsafeOptInUsageError")
@Composable
override fun present(): MessageComposerState {
val localCoroutineScope = rememberCoroutineScope()
+ var isMentionsEnabled by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions)
+ }
+
val cameraPermissionState = cameraPermissionPresenter.present()
val attachmentsState = remember {
mutableStateOf(AttachmentsState.None)
@@ -151,14 +172,44 @@ class MessageComposerPresenter @Inject constructor(
}
}
+ val memberSuggestions = remember { mutableStateListOf() }
+ LaunchedEffect(isMentionsEnabled) {
+ if (!isMentionsEnabled) return@LaunchedEffect
+ val currentUserId = currentSessionIdHolder.current
+
+ suspend fun canSendRoomMention(): Boolean {
+ val roomIsDm = room.isDirect && room.isOneToOne
+ val userCanSendAtRoom = room.canUserTriggerRoomNotification(currentUserId).getOrDefault(false)
+ return !roomIsDm && userCanSendAtRoom
+ }
+
+ suggestionSearchTrigger
+ .debounce(0.5.seconds)
+ .combine(room.membersStateFlow) { suggestion, roomMembersState ->
+ memberSuggestions.clear()
+ val result = MentionSuggestionsProcessor.process(
+ suggestion = suggestion,
+ roomMembersState = roomMembersState,
+ currentUserId = currentUserId,
+ canSendRoomMention = ::canSendRoomMention,
+ )
+ if (result.isNotEmpty()) {
+ memberSuggestions.addAll(result)
+ }
+ }
+ .collect()
+ }
+
fun handleEvents(event: MessageComposerEvents) {
when (event) {
MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value
MessageComposerEvents.CloseSpecialMode -> {
- localCoroutineScope.launch {
- richTextEditorState.setHtml("")
+ if (messageComposerContext.composerMode is MessageComposerMode.Edit) {
+ localCoroutineScope.launch {
+ richTextEditorState.setHtml("")
+ }
}
- messageComposerContext.composerMode = MessageComposerMode.Normal("")
+ messageComposerContext.composerMode = MessageComposerMode.Normal
}
is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage(
message = event.message,
@@ -229,6 +280,9 @@ class MessageComposerPresenter @Inject constructor(
is MessageComposerEvents.Error -> {
analyticsService.trackError(event.error)
}
+ is MessageComposerEvents.SuggestionReceived -> {
+ suggestionSearchTrigger.value = event.suggestion
+ }
}
}
@@ -241,6 +295,7 @@ class MessageComposerPresenter @Inject constructor(
canShareLocation = canShareLocation.value,
canCreatePoll = canCreatePoll.value,
attachmentsState = attachmentsState.value,
+ memberSuggestions = memberSuggestions.toPersistentList(),
eventSink = { handleEvents(it) }
)
}
@@ -253,7 +308,7 @@ class MessageComposerPresenter @Inject constructor(
val capturedMode = messageComposerContext.composerMode
// Reset composer right away
richTextEditorState.setHtml("")
- updateComposerMode(MessageComposerMode.Normal(""))
+ updateComposerMode(MessageComposerMode.Normal)
when (capturedMode) {
is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html)
is MessageComposerMode.Edit -> {
@@ -353,3 +408,8 @@ class MessageComposerPresenter @Inject constructor(
}
}
}
+
+sealed interface RoomMemberSuggestion {
+ data object Room : RoomMemberSuggestion
+ data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
index ff74a81abe..6a9d963d18 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt
@@ -19,7 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.Stable
import io.element.android.features.messages.impl.attachments.Attachment
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
import kotlinx.collections.immutable.ImmutableList
@@ -33,6 +33,7 @@ data class MessageComposerState(
val canShareLocation: Boolean,
val canCreatePoll: Boolean,
val attachmentsState: AttachmentsState,
+ val memberSuggestions: ImmutableList,
val eventSink: (MessageComposerEvents) -> Unit,
) {
val hasFocus: Boolean = richTextEditorState.hasFocus
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
index 0483d945af..ac936b2118 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt
@@ -17,8 +17,10 @@
package io.element.android.features.messages.impl.messagecomposer
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.wysiwyg.compose.RichTextEditorState
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.persistentListOf
open class MessageComposerStateProvider : PreviewParameterProvider {
override val values: Sequence
@@ -30,12 +32,13 @@ open class MessageComposerStateProvider : PreviewParameterProvider = persistentListOf(),
) = MessageComposerState(
richTextEditorState = composerState,
isFullScreen = isFullScreen,
@@ -45,5 +48,6 @@ fun aMessageComposerState(
canShareLocation = canShareLocation,
canCreatePoll = canCreatePoll,
attachmentsState = attachmentsState,
+ memberSuggestions = memberSuggestions,
eventSink = {},
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
index 938da1dcaf..e34a22eedc 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt
@@ -24,17 +24,26 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerStateProvider
+import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMessageComposerState
import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.textcomposer.Message
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.textcomposer.TextComposer
+import io.element.android.libraries.textcomposer.model.Message
+import io.element.android.libraries.textcomposer.model.PressEvent
+import io.element.android.libraries.textcomposer.model.Suggestion
+import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import kotlinx.coroutines.launch
@Composable
-fun MessageComposerView(
+internal fun MessageComposerView(
state: MessageComposerState,
+ voiceMessageState: VoiceMessageComposerState,
subcomposing: Boolean,
enableTextFormatting: Boolean,
+ enableVoiceMessages: Boolean,
modifier: Modifier = Modifier,
) {
fun sendMessage(message: Message) {
@@ -53,6 +62,10 @@ fun MessageComposerView(
state.eventSink(MessageComposerEvents.ToggleTextFormatting(enabled = false))
}
+ fun onSuggestionReceived(suggestion: Suggestion?) {
+ state.eventSink(MessageComposerEvents.SuggestionReceived(suggestion))
+ }
+
fun onError(error: Throwable) {
state.eventSink(MessageComposerEvents.Error(error))
}
@@ -64,9 +77,26 @@ fun MessageComposerView(
}
}
+ val onVoiceRecordButtonEvent = { press: PressEvent ->
+ voiceMessageState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(press))
+ }
+
+ val onSendVoiceMessage = {
+ voiceMessageState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ }
+
+ val onDeleteVoiceMessage = {
+ voiceMessageState.eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
+ }
+
+ val onVoicePlayerEvent = { event: VoiceMessagePlayerEvent ->
+ voiceMessageState.eventSink(VoiceMessageComposerEvents.PlayerEvent(event))
+ }
+
TextComposer(
modifier = modifier,
state = state.richTextEditorState,
+ voiceMessageState = voiceMessageState.voiceMessageState,
subcomposing = subcomposing,
onRequestFocus = ::onRequestFocus,
onSendMessage = ::sendMessage,
@@ -76,24 +106,53 @@ fun MessageComposerView(
onAddAttachment = ::onAddAttachment,
onDismissTextFormatting = ::onDismissTextFormatting,
enableTextFormatting = enableTextFormatting,
+ enableVoiceMessages = enableVoiceMessages,
+ onVoiceRecordButtonEvent = onVoiceRecordButtonEvent,
+ onVoicePlayerEvent = onVoicePlayerEvent,
+ onSendVoiceMessage = onSendVoiceMessage,
+ onDeleteVoiceMessage = onDeleteVoiceMessage,
+ onSuggestionReceived = ::onSuggestionReceived,
onError = ::onError,
)
}
@PreviewsDayNight
@Composable
-internal fun MessageComposerViewPreview(@PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState) = ElementPreview {
+internal fun MessageComposerViewPreview(
+ @PreviewParameter(MessageComposerStateProvider::class) state: MessageComposerState,
+) = ElementPreview {
Column {
MessageComposerView(
modifier = Modifier.height(IntrinsicSize.Min),
state = state,
+ voiceMessageState = aVoiceMessageComposerState(),
enableTextFormatting = true,
+ enableVoiceMessages = true,
subcomposing = false,
)
MessageComposerView(
modifier = Modifier.height(200.dp),
state = state,
+ voiceMessageState = aVoiceMessageComposerState(),
enableTextFormatting = true,
+ enableVoiceMessages = true,
+ subcomposing = false,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun MessageComposerViewVoicePreview(
+ @PreviewParameter(VoiceMessageComposerStateProvider::class) state: VoiceMessageComposerState,
+) = ElementPreview {
+ Column {
+ MessageComposerView(
+ modifier = Modifier.height(IntrinsicSize.Min),
+ state = aMessageComposerState(),
+ voiceMessageState = state,
+ enableTextFormatting = true,
+ enableVoiceMessages = true,
subcomposing = false,
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
index 0d0fafd17d..0a0feedf65 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelinePresenter.kt
@@ -20,6 +20,7 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
@@ -30,12 +31,17 @@ import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.encryption.BackupState
+import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.timeline.item.event.TimelineItemEventOrigin
+import io.element.android.libraries.matrix.api.verification.SessionVerificationService
+import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.ui.room.canSendMessageAsState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.ImmutableList
@@ -55,6 +61,8 @@ class TimelinePresenter @Inject constructor(
private val dispatchers: CoroutineDispatchers,
private val appScope: CoroutineScope,
private val analyticsService: AnalyticsService,
+ private val verificationService: SessionVerificationService,
+ private val encryptionService: EncryptionService,
) : Presenter {
private val timeline = room.timeline
@@ -77,6 +85,18 @@ class TimelinePresenter @Inject constructor(
val prevMostRecentItemId = rememberSaveable { mutableStateOf(null) }
val hasNewItems = remember { mutableStateOf(false) }
+ val sessionVerifiedStatus by verificationService.sessionVerifiedStatus.collectAsState()
+ val keyBackupState by encryptionService.backupStateStateFlow.collectAsState()
+
+ val sessionState by remember {
+ derivedStateOf {
+ SessionState(
+ isSessionVerified = sessionVerifiedStatus == SessionVerifiedStatus.Verified,
+ isKeyBackupEnabled = keyBackupState == BackupState.ENABLED
+ )
+ }
+ }
+
fun handleEvents(event: TimelineEvents) {
when (event) {
TimelineEvents.LoadMore -> localScope.paginateBackwards()
@@ -131,6 +151,7 @@ class TimelinePresenter @Inject constructor(
paginationState = paginationState,
timelineItems = timelineItems,
hasNewItems = hasNewItems.value,
+ sessionState = sessionState,
eventSink = ::handleEvents
)
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
index 1c7ff1b87c..173e33b9c9 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineState.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline
import androidx.compose.runtime.Immutable
import io.element.android.features.messages.impl.timeline.model.TimelineItem
+import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import kotlinx.collections.immutable.ImmutableList
@@ -29,5 +30,6 @@ data class TimelineState(
val userHasPermissionToSendMessage: Boolean,
val paginationState: MatrixTimeline.PaginationState,
val hasNewItems: Boolean,
+ val sessionState: SessionState,
val eventSink: (TimelineEvents) -> Unit
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
index 1374f4aef4..0e1795117f 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineStateProvider.kt
@@ -24,6 +24,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.virtual.aTimelineItemDaySeparatorModel
+import io.element.android.features.messages.impl.timeline.session.aSessionState
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.EventId
@@ -46,6 +47,10 @@ fun aTimelineState(timelineItems: ImmutableList = persistentListOf
highlightedEventId = null,
userHasPermissionToSendMessage = true,
hasNewItems = false,
+ sessionState = aSessionState(
+ isSessionVerified = true,
+ isKeyBackupEnabled = true,
+ ),
eventSink = {},
)
@@ -139,7 +144,7 @@ fun aTimelineItemReactions(
count: Int = 1,
isHighlighted: Boolean = false,
): TimelineItemReactions {
- val emojis = arrayOf("👍", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
+ val emojis = arrayOf("👍️", "😀️", "😁️", "😆️", "😅️", "🤣️", "🥰️", "😇️", "😊️", "😉️", "🙃️", "🙂️", "😍️", "🤗️", "🤭️")
return TimelineItemReactions(
reactions = buildList {
repeat(count) { index ->
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
index fdb4b309eb..6d1929f825 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/TimelineView.kt
@@ -38,6 +38,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.FloatingActionButtonDefaults
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -59,11 +60,14 @@ import io.element.android.features.messages.impl.timeline.components.TimelineIte
import io.element.android.features.messages.impl.timeline.components.TimelineItemVirtualRow
import io.element.android.features.messages.impl.timeline.components.group.GroupHeaderView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineLoadingMoreIndicator
+import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
+import io.element.android.features.messages.impl.timeline.di.aFakeTimelineItemPresenterFactories
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContentProvider
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.canBeRepliedTo
+import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.libraries.designsystem.animation.alphaAnimation
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@@ -130,6 +134,7 @@ fun TimelineView(
onReactionLongClick = onReactionLongClicked,
onMoreReactionsClick = onMoreReactionsClicked,
onTimestampClicked = onTimestampClicked,
+ sessionState = state.sessionState,
eventSink = state.eventSink,
onSwipeToReply = onSwipeToReply,
)
@@ -159,6 +164,7 @@ private fun TimelineItemRow(
timelineItem: TimelineItem,
highlightedItem: String?,
userHasPermissionToSendMessage: Boolean,
+ sessionState: SessionState,
onUserDataClick: (UserId) -> Unit,
onClick: (TimelineItem.Event) -> Unit,
onLongClick: (TimelineItem.Event) -> Unit,
@@ -175,6 +181,7 @@ private fun TimelineItemRow(
is TimelineItem.Virtual -> {
TimelineItemVirtualRow(
virtual = timelineItem,
+ sessionState = sessionState,
modifier = modifier,
)
}
@@ -231,6 +238,7 @@ private fun TimelineItemRow(
TimelineItemRow(
timelineItem = subGroupEvent,
highlightedItem = highlightedItem,
+ sessionState = sessionState,
userHasPermissionToSendMessage = false,
onClick = onClick,
onLongClick = onLongClick,
@@ -333,15 +341,19 @@ internal fun TimelineViewPreview(
@PreviewParameter(TimelineItemEventContentProvider::class) content: TimelineItemEventContent
) = ElementPreview {
val timelineItems = aTimelineItemList(content)
- TimelineView(
- state = aTimelineState(timelineItems),
- onMessageClicked = {},
- onTimestampClicked = {},
- onUserDataClicked = {},
- onMessageLongClicked = {},
- onReactionClicked = { _, _ -> },
- onReactionLongClicked = { _, _ -> },
- onMoreReactionsClicked = {},
- onSwipeToReply = {},
- )
+ CompositionLocalProvider(
+ LocalTimelineItemPresenterFactories provides aFakeTimelineItemPresenterFactories(),
+ ) {
+ TimelineView(
+ state = aTimelineState(timelineItems),
+ onMessageClicked = {},
+ onTimestampClicked = {},
+ onUserDataClicked = {},
+ onMessageLongClicked = {},
+ onReactionClicked = { _, _ -> },
+ onReactionLongClicked = { _, _ -> },
+ onMoreReactionsClicked = {},
+ onSwipeToReply = {},
+ )
+ }
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
index 0ef1c9bc67..f11e149073 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemEventRow.kt
@@ -100,6 +100,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessa
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
@@ -616,6 +617,9 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
textContent = messageContent.body,
type = AttachmentThumbnailType.Audio,
)
+ is VoiceMessageType -> AttachmentThumbnailInfo(
+ type = AttachmentThumbnailType.Voice,
+ )
else -> null
}
}
@@ -625,6 +629,7 @@ private fun textForInReplyTo(inReplyTo: InReplyTo.Ready): String {
val messageContent = inReplyTo.content as? MessageContent ?: return ""
return when (messageContent.type) {
is LocationMessageType -> stringResource(CommonStrings.common_shared_location)
+ is VoiceMessageType -> stringResource(CommonStrings.common_voice_message)
else -> messageContent.body
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
index d6b1c06f54..7a476cb8a6 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/TimelineItemVirtualRow.kt
@@ -18,6 +18,7 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
+import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineEncryptedHistoryBannerView
import io.element.android.features.messages.impl.timeline.components.virtual.TimelineItemDaySeparatorView
import io.element.android.features.messages.impl.timeline.model.TimelineItem
@@ -28,12 +29,13 @@ import io.element.android.features.messages.impl.timeline.model.virtual.Timeline
@Composable
fun TimelineItemVirtualRow(
virtual: TimelineItem.Virtual,
+ sessionState: SessionState,
modifier: Modifier = Modifier
) {
when (virtual.model) {
is TimelineItemDaySeparatorModel -> TimelineItemDaySeparatorView(virtual.model, modifier)
TimelineItemReadMarkerModel -> return
- is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(modifier)
+ is TimelineItemEncryptedHistoryBannerVirtualModel -> TimelineEncryptedHistoryBannerView(sessionState, modifier)
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt
new file mode 100644
index 0000000000..3de0734b61
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiItem.kt
@@ -0,0 +1,93 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.components.customreaction
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.interaction.MutableInteractionSource
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material.ripple.rememberRipple
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.unit.dp
+import io.element.android.emojibasebindings.Emoji
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.theme.ElementTheme
+
+@Composable
+fun EmojiItem(
+ item: Emoji,
+ isSelected: Boolean,
+ onEmojiSelected: (Emoji) -> Unit,
+ modifier: Modifier = Modifier,
+) {
+ val backgroundColor = if (isSelected) {
+ ElementTheme.colors.bgActionPrimaryRest
+ } else {
+ Color.Transparent
+ }
+
+ Box(
+ modifier = modifier
+ .size(40.dp)
+ .background(backgroundColor, CircleShape)
+ .clickable(
+ enabled = true,
+ onClick = { onEmojiSelected(item) },
+ indication = rememberRipple(bounded = false, radius = 20.dp),
+ interactionSource = remember { MutableInteractionSource() }
+ ),
+ contentAlignment = Alignment.Center
+ ) {
+ Text(
+ text = item.unicode,
+ style = ElementTheme.typography.fontHeadingSmRegular,
+ )
+ }
+}
+
+@PreviewsDayNight
+@Composable
+internal fun EmojiItemPreview() = ElementPreview {
+ Row(
+ horizontalArrangement = Arrangement.spacedBy(8.dp),
+ ) {
+ for (isSelected in listOf(true, false)) {
+ EmojiItem(
+ item = Emoji(
+ hexcode = "",
+ label = "",
+ tags = null,
+ shortcodes = emptyList(),
+ unicode = "👍",
+ skins = null
+ ),
+ isSelected = isSelected,
+ onEmojiSelected = {},
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt
index ee9d4c819d..29d6b14e59 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/customreaction/EmojiPicker.kt
@@ -17,31 +17,22 @@
package io.element.android.features.messages.impl.timeline.components.customreaction
import androidx.compose.foundation.ExperimentalFoundationApi
-import androidx.compose.foundation.background
-import androidx.compose.foundation.clickable
-import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
-import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.grid.GridCells
import androidx.compose.foundation.lazy.grid.LazyVerticalGrid
import androidx.compose.foundation.lazy.grid.items
import androidx.compose.foundation.pager.HorizontalPager
import androidx.compose.foundation.pager.rememberPagerState
-import androidx.compose.foundation.shape.CircleShape
-import androidx.compose.material.ripple.rememberRipple
import androidx.compose.material3.SecondaryTabRow
import androidx.compose.material3.Tab
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
-import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
-import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.unit.dp
@@ -52,8 +43,6 @@ import io.element.android.emojibasebindings.EmojibaseStore
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
-import io.element.android.libraries.designsystem.theme.components.Text
-import io.element.android.libraries.theme.ElementTheme
import kotlinx.collections.immutable.ImmutableSet
import kotlinx.collections.immutable.persistentSetOf
import kotlinx.coroutines.launch
@@ -101,31 +90,12 @@ fun EmojiPicker(
contentPadding = PaddingValues(vertical = 10.dp, horizontal = 16.dp),
horizontalArrangement = Arrangement.spacedBy(8.dp),
) {
-
items(emojis, key = { it.unicode }) { item ->
- val backgroundColor = if (selectedEmojis.contains(item.unicode)) {
- ElementTheme.colors.bgActionPrimaryRest
- } else {
- Color.Transparent
- }
-
- Box(
- modifier = Modifier
- .size(40.dp)
- .background(backgroundColor, CircleShape)
- .clickable(
- enabled = true,
- onClick = { onEmojiSelected(item) },
- indication = rememberRipple(bounded = false, radius = 20.dp),
- interactionSource = remember { MutableInteractionSource() }
- ),
- contentAlignment = Alignment.Center
- ) {
- Text(
- text = item.unicode,
- style = ElementTheme.typography.fontHeadingSmRegular,
- )
- }
+ EmojiItem(
+ item = item,
+ isSelected = selectedEmojis.contains(item.unicode),
+ onEmojiSelected = onEmojiSelected
+ )
}
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
index dccc020e49..22d1d9cacd 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemEventContentView.kt
@@ -20,6 +20,8 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.features.messages.impl.timeline.TimelineEvents
+import io.element.android.features.messages.impl.timeline.di.LocalTimelineItemPresenterFactories
+import io.element.android.features.messages.impl.timeline.di.rememberPresenter
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemAudioContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
@@ -32,6 +34,9 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
+import io.element.android.libraries.architecture.Presenter
@Composable
fun TimelineItemEventContentView(
@@ -44,6 +49,7 @@ fun TimelineItemEventContentView(
eventSink: (TimelineEvents) -> Unit,
modifier: Modifier = Modifier
) {
+ val presenterFactories = LocalTimelineItemPresenterFactories.current
when (content) {
is TimelineItemEncryptedContent -> TimelineItemEncryptedView(
content = content,
@@ -100,5 +106,14 @@ fun TimelineItemEventContentView(
eventSink = eventSink,
modifier = modifier,
)
+ is TimelineItemVoiceContent -> {
+ val presenter: Presenter = presenterFactories.rememberPresenter(content)
+ TimelineItemVoiceView(
+ state = presenter.present(),
+ content = content,
+ extraPadding = extraPadding,
+ modifier = modifier
+ )
+ }
}
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
new file mode 100644
index 0000000000..e7762fe278
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/event/TimelineItemVoiceView.kt
@@ -0,0 +1,230 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.components.event
+
+import androidx.compose.foundation.background
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.shape.CircleShape
+import androidx.compose.material3.IconButtonDefaults
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.platform.LocalContext
+import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.semantics.contentDescription
+import androidx.compose.ui.semantics.semantics
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import androidx.compose.ui.unit.dp
+import io.element.android.features.messages.impl.R
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContentProvider
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageStateProvider
+import io.element.android.libraries.androidutils.accessibility.isScreenReaderEnabled
+import io.element.android.libraries.designsystem.components.media.WaveformPlaybackView
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
+import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
+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.Text
+import io.element.android.libraries.theme.ElementTheme
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+fun TimelineItemVoiceView(
+ state: VoiceMessageState,
+ content: TimelineItemVoiceContent,
+ extraPadding: ExtraPadding,
+ modifier: Modifier = Modifier,
+) {
+ fun playPause() {
+ state.eventSink(VoiceMessageEvents.PlayPause)
+ }
+
+ val a11y = stringResource(CommonStrings.common_voice_message)
+ Row(
+ modifier = modifier.semantics {
+ contentDescription = a11y
+ },
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ when (state.button) {
+ VoiceMessageState.Button.Play -> PlayButton(onClick = ::playPause)
+ VoiceMessageState.Button.Pause -> PauseButton(onClick = ::playPause)
+ VoiceMessageState.Button.Downloading -> ProgressButton()
+ VoiceMessageState.Button.Retry -> RetryButton(onClick = ::playPause)
+ VoiceMessageState.Button.Disabled -> PlayButton(onClick = {}, enabled = false)
+ }
+ Spacer(Modifier.width(8.dp))
+ Text(
+ text = state.time,
+ color = ElementTheme.materialColors.secondary,
+ style = ElementTheme.typography.fontBodySmMedium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Spacer(Modifier.width(8.dp))
+ val context = LocalContext.current
+ WaveformPlaybackView(
+ showCursor = state.button == VoiceMessageState.Button.Pause,
+ playbackProgress = state.progress,
+ waveform = content.waveform,
+ modifier = Modifier
+ .height(34.dp)
+ .weight(1f),
+ seekEnabled = !context.isScreenReaderEnabled(),
+ onSeek = { state.eventSink(VoiceMessageEvents.Seek(it)) },
+ )
+ Spacer(Modifier.width(extraPadding.getDpSize()))
+ }
+}
+
+@Composable
+private fun PlayButton(
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+) {
+ CustomIconButton(
+ onClick = onClick,
+ enabled = enabled,
+ ) {
+ Icon(
+ resourceId = R.drawable.play,
+ contentDescription = stringResource(id = CommonStrings.a11y_play),
+ )
+ }
+}
+
+@Composable
+private fun PauseButton(
+ onClick: () -> Unit,
+) {
+ CustomIconButton(
+ onClick = onClick,
+ ) {
+ Icon(
+ resourceId = R.drawable.pause,
+ contentDescription = stringResource(id = CommonStrings.a11y_play),
+ )
+ }
+}
+
+@Composable
+private fun RetryButton(
+ onClick: () -> Unit,
+) {
+ CustomIconButton(
+ onClick = onClick,
+ ) {
+ Icon(
+ resourceId = R.drawable.retry,
+ contentDescription = stringResource(id = CommonStrings.action_retry),
+ )
+ }
+}
+
+@Composable
+private fun ProgressButton() {
+ CustomIconButton(
+ onClick = {},
+ enabled = false,
+ ) {
+ CircularProgressIndicator(
+ modifier = Modifier
+ .padding(2.dp)
+ .size(16.dp),
+ color = ElementTheme.colors.iconSecondary,
+ strokeWidth = 2.dp,
+ )
+ }
+}
+
+@Composable
+private fun CustomIconButton(
+ onClick: () -> Unit,
+ enabled: Boolean = true,
+ content: @Composable () -> Unit,
+) {
+ IconButton(
+ onClick = onClick,
+ modifier = Modifier
+ .background(color = ElementTheme.colors.bgCanvasDefault, shape = CircleShape)
+ .size(36.dp),
+ enabled = enabled,
+ colors = IconButtonDefaults.iconButtonColors(
+ contentColor = ElementTheme.colors.iconSecondary,
+ disabledContentColor = ElementTheme.colors.iconDisabled,
+ ),
+ content = content,
+ )
+}
+
+open class TimelineItemVoiceViewParametersProvider : PreviewParameterProvider {
+ private val voiceMessageStateProvider = VoiceMessageStateProvider()
+ private val timelineItemVoiceContentProvider = TimelineItemVoiceContentProvider()
+ override val values: Sequence
+ get() = timelineItemVoiceContentProvider.values.flatMap { content ->
+ voiceMessageStateProvider.values.map { state ->
+ TimelineItemVoiceViewParameters(
+ state = state,
+ content = content,
+ )
+ }
+ }
+}
+
+data class TimelineItemVoiceViewParameters(
+ val state: VoiceMessageState,
+ val content: TimelineItemVoiceContent,
+)
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineItemVoiceViewPreview(
+ @PreviewParameter(TimelineItemVoiceViewParametersProvider::class) timelineItemVoiceViewParameters: TimelineItemVoiceViewParameters,
+) = ElementPreview {
+ TimelineItemVoiceView(
+ state = timelineItemVoiceViewParameters.state,
+ content = timelineItemVoiceViewParameters.content,
+ extraPadding = noExtraPadding,
+ )
+}
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineItemVoiceViewUnifiedPreview() = ElementPreview {
+ val timelineItemVoiceViewParametersProvider = TimelineItemVoiceViewParametersProvider()
+ Column {
+ timelineItemVoiceViewParametersProvider.values.forEach {
+ TimelineItemVoiceView(
+ state = it.state,
+ content = it.content,
+ extraPadding = noExtraPadding,
+ )
+ }
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
index 0317a68a00..f00791296d 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/components/virtual/TimelineEncryptedHistoryBannerView.kt
@@ -16,26 +16,35 @@
package io.element.android.features.messages.impl.timeline.components.virtual
+import androidx.annotation.StringRes
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.messages.impl.R
+import io.element.android.features.messages.impl.timeline.session.SessionState
+import io.element.android.features.messages.impl.timeline.session.SessionStateProvider
+import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.theme.ElementTheme
@Composable
-fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
+fun TimelineEncryptedHistoryBannerView(
+ sessionState: SessionState,
+ modifier: Modifier = Modifier,
+) {
Row(
modifier = modifier
.padding(start = 16.dp, end = 16.dp, top = 24.dp, bottom = 32.dp)
@@ -43,25 +52,35 @@ fun TimelineEncryptedHistoryBannerView(modifier: Modifier = Modifier) {
.border(1.dp, ElementTheme.colors.borderInfoSubtle, MaterialTheme.shapes.small)
.background(ElementTheme.colors.bgInfoSubtle)
.padding(16.dp),
- horizontalArrangement = Arrangement.spacedBy(16.dp)
+ horizontalArrangement = Arrangement.spacedBy(16.dp),
) {
Icon(
+ modifier = Modifier.size(20.dp),
resourceId = CommonDrawables.ic_compound_info,
contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary
)
Text(
- text = stringResource(R.string.screen_room_encrypted_history_banner),
+ text = stringResource(sessionState.toStringResId()),
style = ElementTheme.typography.fontBodyMdMedium,
color = ElementTheme.colors.textInfoPrimary
)
}
}
-@PreviewsDayNight
-@Composable
-internal fun TimelineEncryptedHistoryBannerViewPreview() {
- ElementTheme {
- TimelineEncryptedHistoryBannerView()
+@StringRes
+private fun SessionState.toStringResId(): Int {
+ return when {
+ isSessionVerified.not() -> R.string.screen_room_encrypted_history_banner_unverified
+ isKeyBackupEnabled.not() -> R.string.screen_room_encrypted_history_banner
+ else -> R.string.screen_room_encrypted_history_banner // TODO strings need to be updated
}
}
+
+@PreviewsDayNight
+@Composable
+internal fun TimelineEncryptedHistoryBannerViewPreview(
+ @PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
+) = ElementPreview {
+ TimelineEncryptedHistoryBannerView(sessionState = sessionState)
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt
new file mode 100644
index 0000000000..2823011dbd
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/FakeTimelineItemPresenterFactories.kt
@@ -0,0 +1,34 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.di
+
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
+import io.element.android.features.messages.impl.voicemessages.timeline.aVoiceMessageState
+import io.element.android.libraries.architecture.Presenter
+
+/**
+ * A fake [TimelineItemPresenterFactories] for screenshot tests.
+ */
+fun aFakeTimelineItemPresenterFactories() = TimelineItemPresenterFactories(
+ mapOf(
+ Pair(
+ TimelineItemVoiceContent::class.java,
+ TimelineItemPresenterFactory { Presenter { aVoiceMessageState() } },
+ ),
+ )
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt
new file mode 100644
index 0000000000..9cb046a054
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemEventContentKey.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.di
+
+import dagger.MapKey
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
+import kotlin.reflect.KClass
+
+/**
+ * Annotation to add a factory of type [TimelineItemPresenterFactory] to a
+ * Dagger map multi binding keyed with a subclass of [TimelineItemEventContent].
+ */
+@Retention(AnnotationRetention.RUNTIME)
+@MapKey
+annotation class TimelineItemEventContentKey(val value: KClass)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt
new file mode 100644
index 0000000000..0574f7e903
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactories.kt
@@ -0,0 +1,77 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.di
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.staticCompositionLocalOf
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Module
+import dagger.multibindings.Multibinds
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.RoomScope
+import javax.inject.Inject
+
+/**
+ * Dagger module that declares the [TimelineItemPresenterFactory] map multi binding.
+ *
+ * Its sole purpose is to support the case of an empty map multibinding.
+ */
+@Module
+@ContributesTo(RoomScope::class)
+interface TimelineItemPresenterFactoriesModule {
+ @Multibinds
+ fun multiBindTimelineItemPresenterFactories(): @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>
+}
+
+/**
+ * Wrapper around the [TimelineItemPresenterFactory] map multi binding.
+ *
+ * Its only purpose is to provide a nicer type name than:
+ * `@JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>`.
+ *
+ * A typealias would have been better but typealiases on Dagger types which use @JvmSuppressWildcards
+ * currently make Dagger crash.
+ *
+ * Request this type from Dagger to access the [TimelineItemPresenterFactory] map multibinding.
+ */
+data class TimelineItemPresenterFactories @Inject constructor(
+ val factories: @JvmSuppressWildcards Map, TimelineItemPresenterFactory<*, *>>,
+)
+
+/**
+ * Provides a [TimelineItemPresenterFactories] to the composition.
+ */
+val LocalTimelineItemPresenterFactories = staticCompositionLocalOf {
+ TimelineItemPresenterFactories(emptyMap())
+}
+
+/**
+ * Creates and remembers a presenter for the given content.
+ *
+ * Will throw if the presenter is not found in the [TimelineItemPresenterFactory] map multi binding.
+ */
+@Composable
+inline fun TimelineItemPresenterFactories.rememberPresenter(
+ content: C
+): Presenter = remember(content) {
+ factories.getValue(C::class.java).let {
+ @Suppress("UNCHECKED_CAST")
+ (it as TimelineItemPresenterFactory).create(content)
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt
new file mode 100644
index 0000000000..f79d606f60
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/di/TimelineItemPresenterFactory.kt
@@ -0,0 +1,33 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.di
+
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEventContent
+import io.element.android.libraries.architecture.Presenter
+
+/**
+ * A factory for a [Presenter] associated with a timeline item.
+ *
+ * Implementations should be annotated with [AssistedFactory] to be created by Dagger.
+ *
+ * @param C The timeline item's [TimelineItemEventContent] subtype.
+ * @param S The [Presenter]'s state class.
+ * @return A [Presenter] that produces a state of type [S] for the given content of type [C].
+ */
+fun interface TimelineItemPresenterFactory {
+ fun create(content: C): Presenter
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
index 55e889f496..b5a3c9545e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentFactory.kt
@@ -52,7 +52,7 @@ class TimelineItemContentFactory @Inject constructor(
is FailedToParseStateContent -> failedToParseStateFactory.create(itemContent)
is MessageContent -> {
val senderDisplayName = (eventTimelineItem.senderProfile as? ProfileTimelineDetails.Ready)?.displayName ?: eventTimelineItem.sender.value
- messageFactory.create(itemContent, senderDisplayName)
+ messageFactory.create(itemContent, senderDisplayName, eventTimelineItem.eventId)
}
is ProfileChangeContent -> profileChangeFactory.create(eventTimelineItem)
is RedactedContent -> redactedMessageFactory.create(itemContent)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
index ae2ea4f350..d15a07526c 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt
@@ -26,10 +26,14 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemNoticeContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.util.FileExtensionExtractor
import io.element.android.features.messages.impl.timeline.util.toHtmlDocument
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
@@ -37,17 +41,23 @@ import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.MessageContent
import io.element.android.libraries.matrix.api.timeline.item.event.NoticeMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.OtherMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.UnknownMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageType
+import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
+import kotlinx.collections.immutable.persistentListOf
+import kotlinx.collections.immutable.toImmutableList
+import java.time.Duration
import javax.inject.Inject
class TimelineItemContentMessageFactory @Inject constructor(
private val fileSizeFormatter: FileSizeFormatter,
private val fileExtensionExtractor: FileExtensionExtractor,
+ private val featureFlagService: FeatureFlagService,
) {
- fun create(content: MessageContent, senderDisplayName: String): TimelineItemEventContent {
+ suspend fun create(content: MessageContent, senderDisplayName: String, eventId: EventId?): TimelineItemEventContent {
return when (val messageType = content.type) {
is EmoteMessageType -> TimelineItemEmoteContent(
body = "* $senderDisplayName ${messageType.body}",
@@ -101,14 +111,40 @@ class TimelineItemContentMessageFactory @Inject constructor(
fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
)
}
- is AudioMessageType -> TimelineItemAudioContent(
- body = messageType.body,
- audioSource = messageType.source,
- duration = messageType.info?.duration?.toMillis() ?: 0L,
- mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
- formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
- fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
- )
+ is AudioMessageType -> {
+ TimelineItemAudioContent(
+ body = messageType.body,
+ mediaSource = messageType.source,
+ duration = messageType.info?.duration ?: Duration.ZERO,
+ mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
+ formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
+ fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
+ )
+ }
+ is VoiceMessageType -> {
+ when (featureFlagService.isFeatureEnabled(FeatureFlags.VoiceMessages)) {
+ true -> {
+ TimelineItemVoiceContent(
+ eventId = eventId,
+ body = messageType.body,
+ mediaSource = messageType.source,
+ duration = messageType.info?.duration ?: Duration.ZERO,
+ mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
+ waveform = messageType.details?.waveform?.toImmutableList() ?: persistentListOf(),
+ )
+ }
+ false -> {
+ TimelineItemAudioContent(
+ body = messageType.body,
+ mediaSource = messageType.source,
+ duration = messageType.info?.duration ?: Duration.ZERO,
+ mimeType = messageType.info?.mimetype ?: MimeTypes.OctetStream,
+ formattedFileSize = fileSizeFormatter.format(messageType.info?.size ?: 0),
+ fileExtension = fileExtensionExtractor.extractFromName(messageType.body),
+ )
+ }
+ }
+ }
is FileMessageType -> {
val fileExtension = fileExtensionExtractor.extractFromName(messageType.body)
TimelineItemFileContent(
@@ -130,8 +166,14 @@ class TimelineItemContentMessageFactory @Inject constructor(
htmlDocument = messageType.formatted?.toHtmlDocument(),
isEdited = content.isEdited,
)
+ is OtherMessageType -> TimelineItemTextContent(
+ body = messageType.body,
+ htmlDocument = null,
+ isEdited = content.isEdited,
+ )
UnknownMessageType -> TimelineItemTextContent(
- // Display the body as a fallback
+ // Display the body as a fallback, but should not happen anymore
+ // (we have `OtherMessageType` now)
body = content.body,
htmlDocument = null,
isEdited = content.isEdited,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt
index 844942002a..51ab200c48 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/groups/Groupability.kt
@@ -30,6 +30,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@@ -58,6 +59,7 @@ internal fun TimelineItem.Event.canBeGrouped(): Boolean {
is TimelineItemAudioContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
+ is TimelineItemVoiceContent,
TimelineItemRedactedContent,
TimelineItemUnknownContent -> false
is TimelineItemProfileChangeContent,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
index 485b863170..9d9a41e0e3 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContent.kt
@@ -18,11 +18,12 @@ package io.element.android.features.messages.impl.timeline.model.event
import io.element.android.features.messages.impl.media.helper.formatFileExtensionAndSize
import io.element.android.libraries.matrix.api.media.MediaSource
+import java.time.Duration
data class TimelineItemAudioContent(
val body: String,
- val duration: Long,
- val audioSource: MediaSource,
+ val duration: Duration,
+ val mediaSource: MediaSource,
val mimeType: String,
val formattedFileSize: String,
val fileExtension: String,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
index ed424781f8..06cb53b6fe 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemAudioContentProvider.kt
@@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource
+import java.time.Duration
open class TimelineItemAudioContentProvider : PreviewParameterProvider {
override val values: Sequence
@@ -34,6 +35,6 @@ fun aTimelineItemAudioContent(fileName: String = "A sound.mp3") = TimelineItemAu
mimeType = MimeTypes.Pdf,
formattedFileSize = "100kB",
fileExtension = "mp3",
- duration = 100,
- audioSource = MediaSource(""),
+ duration = Duration.ofMillis(100),
+ mediaSource = MediaSource(""),
)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
index ef31d6249c..56c0b63b0e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContent.kt
@@ -58,6 +58,7 @@ fun TimelineItemEventContent.canReact(): Boolean =
is TimelineItemImageContent,
is TimelineItemLocationContent,
is TimelineItemPollContent,
+ is TimelineItemVoiceContent,
is TimelineItemVideoContent -> true
is TimelineItemStateContent,
is TimelineItemRedactedContent,
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
index 4c25bdfb23..bbbd8748a2 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemEventContentProvider.kt
@@ -28,8 +28,12 @@ class TimelineItemEventContentProvider : PreviewParameterProvider,
+) : TimelineItemEventContent {
+ override val type: String = "TimelineItemAudioContent"
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
new file mode 100644
index 0000000000..830255f06e
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/event/TimelineItemVoiceContentProvider.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.model.event
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.core.mimetype.MimeTypes
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import kotlinx.collections.immutable.toPersistentList
+import java.time.Duration
+
+open class TimelineItemVoiceContentProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aTimelineItemVoiceContent(
+ durationMs = 1,
+ waveform = listOf(),
+ ),
+ aTimelineItemVoiceContent(
+ durationMs = 10_000,
+ waveform = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
+ ),
+ aTimelineItemVoiceContent(
+ durationMs = 1_800_000, // 30 minutes
+ waveform = List(1024) { it / 1024f },
+ ),
+ )
+}
+
+fun aTimelineItemVoiceContent(
+ eventId: String? = "\$anEventId",
+ body: String = "body doesn't really matter for a voice message",
+ durationMs: Long = 61_000,
+ contentUri: String = "mxc://matrix.org/1234567890abcdefg",
+ mimeType: String = MimeTypes.Ogg,
+ waveform: List = listOf(0f, 1f, 2f, 3f, 4f, 5f, 6f, 7f, 8f, 9f, 8f, 7f, 6f, 5f, 4f, 3f, 2f, 1f, 0f),
+) = TimelineItemVoiceContent(
+ eventId = eventId?.let { EventId(it) },
+ body = body,
+ duration = Duration.ofMillis(durationMs),
+ mediaSource = MediaSource(contentUri),
+ mimeType = mimeType,
+ waveform = waveform.toPersistentList(),
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt
index 442aed5734..d2bcc18533 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemEncryptedHistoryBannerVirtualModel.kt
@@ -16,6 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual
-object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
+data object TimelineItemEncryptedHistoryBannerVirtualModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemEncryptedHistoryBannerVirtualModel"
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt
index 0b8e3fc0e5..80d1156dd1 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/model/virtual/TimelineItemReadMarkerModel.kt
@@ -16,6 +16,6 @@
package io.element.android.features.messages.impl.timeline.model.virtual
-object TimelineItemReadMarkerModel : TimelineItemVirtualModel {
+data object TimelineItemReadMarkerModel : TimelineItemVirtualModel {
override val type: String = "TimelineItemReadMarkerModel"
}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionState.kt
new file mode 100644
index 0000000000..715b4c4962
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionState.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.session
+
+data class SessionState(
+ val isSessionVerified: Boolean,
+ val isKeyBackupEnabled: Boolean,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionStateProvider.kt
new file mode 100644
index 0000000000..2dd27d5456
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/session/SessionStateProvider.kt
@@ -0,0 +1,36 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.timeline.session
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class SessionStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aSessionState(isSessionVerified = false, isKeyBackupEnabled = false),
+ aSessionState(isSessionVerified = true, isKeyBackupEnabled = false),
+ aSessionState(isSessionVerified = true, isKeyBackupEnabled = true),
+ )
+}
+
+internal fun aSessionState(
+ isSessionVerified: Boolean = false,
+ isKeyBackupEnabled: Boolean = false,
+) = SessionState(
+ isSessionVerified = isSessionVerified,
+ isKeyBackupEnabled = isKeyBackupEnabled,
+)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt
index 03a2063b36..6b3ed3d65e 100644
--- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/utils/messagesummary/MessageSummaryFormatterImpl.kt
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextBasedContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemUnknownContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.ui.strings.CommonStrings
@@ -49,6 +50,7 @@ class MessageSummaryFormatterImpl @Inject constructor(
is TimelineItemEncryptedContent -> context.getString(CommonStrings.common_unable_to_decrypt)
is TimelineItemRedactedContent -> context.getString(CommonStrings.common_message_removed)
is TimelineItemPollContent -> event.content.question
+ is TimelineItemVoiceContent -> context.getString(CommonStrings.common_voice_message)
is TimelineItemUnknownContent -> context.getString(CommonStrings.common_unsupported_event)
is TimelineItemImageContent -> context.getString(CommonStrings.common_image)
is TimelineItemVideoContent -> context.getString(CommonStrings.common_video)
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt
new file mode 100644
index 0000000000..7ca95ad9a7
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/VoiceMessageException.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages
+
+internal sealed class VoiceMessageException : Exception() {
+ data class FileException(
+ override val message: String?, override val cause: Throwable? = null
+ ) : VoiceMessageException()
+ data class PermissionMissing(
+ override val message: String?, override val cause: Throwable?
+ ) : VoiceMessageException()
+ data class PlayMessageError(
+ override val message: String?, override val cause: Throwable?
+ ) : VoiceMessageException()
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt
new file mode 100644
index 0000000000..f80ee15d95
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerEvents.kt
@@ -0,0 +1,35 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.composer
+
+import androidx.lifecycle.Lifecycle
+import io.element.android.libraries.textcomposer.model.PressEvent
+import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
+
+sealed interface VoiceMessageComposerEvents {
+ data class RecordButtonEvent(
+ val pressEvent: PressEvent
+ ): VoiceMessageComposerEvents
+ data class PlayerEvent(
+ val playerEvent: VoiceMessagePlayerEvent,
+ ): VoiceMessageComposerEvents
+ data object SendVoiceMessage: VoiceMessageComposerEvents
+ data object DeleteVoiceMessage: VoiceMessageComposerEvents
+ data object AcceptPermissionRationale: VoiceMessageComposerEvents
+ data object DismissPermissionsRationale: VoiceMessageComposerEvents
+ data class LifecycleEvent(val event: Lifecycle.Event): VoiceMessageComposerEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt
new file mode 100644
index 0000000000..b166dcd78a
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPlayer.kt
@@ -0,0 +1,105 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.composer
+
+import io.element.android.libraries.mediaplayer.api.MediaPlayer
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+/**
+ * A media player for the voice message composer.
+ *
+ * @param mediaPlayer The [MediaPlayer] to use.
+ */
+class VoiceMessageComposerPlayer @Inject constructor(
+ private val mediaPlayer: MediaPlayer,
+) {
+ private var lastPlayedMediaPath: String? = null
+ private val curPlayingMediaId
+ get() = mediaPlayer.state.value.mediaId
+
+ val state: Flow = mediaPlayer.state.map { state ->
+ if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
+ return@map State.NotPlaying
+ }
+
+ State(
+ isPlaying = state.isPlaying,
+ currentPosition = state.currentPosition,
+ duration = state.duration,
+ )
+ }.distinctUntilChanged()
+
+ /**
+ * Start playing from the current position.
+ *
+ * @param mediaPath The path to the media to be played.
+ * @param mimeType The mime type of the media file.
+ */
+ fun play(mediaPath: String, mimeType: String) {
+ if (mediaPath == curPlayingMediaId) {
+ mediaPlayer.play()
+ } else {
+ lastPlayedMediaPath = mediaPath
+ mediaPlayer.acquireControlAndPlay(
+ uri = mediaPath,
+ mediaId = mediaPath,
+ mimeType = mimeType,
+ )
+ }
+ }
+
+ /**
+ * Pause playback.
+ */
+ fun pause() {
+ if (lastPlayedMediaPath == curPlayingMediaId) {
+ mediaPlayer.pause()
+ }
+ }
+
+ data class State(
+ /**
+ * Whether this player is currently playing.
+ */
+ val isPlaying: Boolean,
+ /**
+ * The elapsed time of this player in milliseconds.
+ */
+ val currentPosition: Long,
+ /**
+ * The duration of this player in milliseconds.
+ */
+ val duration: Long,
+ ) {
+ companion object {
+ val NotPlaying = State(
+ isPlaying = false,
+ currentPosition = 0L,
+ duration = 0L,
+ )
+ }
+
+ /**
+ * The progress of this player between 0 and 1.
+ */
+ val progress: Float =
+ if (duration <= currentPosition) 0f else currentPosition.toFloat() / duration.toFloat()
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
new file mode 100644
index 0000000000..7cde5739ba
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerPresenter.kt
@@ -0,0 +1,245 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.composer
+
+import android.Manifest
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import androidx.core.net.toUri
+import androidx.lifecycle.Lifecycle
+import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.di.SingleIn
+import io.element.android.libraries.mediaupload.api.MediaSender
+import io.element.android.libraries.permissions.api.PermissionsEvents
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.textcomposer.model.PressEvent
+import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
+import io.element.android.libraries.textcomposer.model.VoiceMessageState
+import io.element.android.libraries.voicerecorder.api.VoiceRecorder
+import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.ImmutableList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.launch
+import timber.log.Timber
+import java.io.File
+import javax.inject.Inject
+
+@SingleIn(RoomScope::class)
+class VoiceMessageComposerPresenter @Inject constructor(
+ private val appCoroutineScope: CoroutineScope,
+ private val voiceRecorder: VoiceRecorder,
+ private val analyticsService: AnalyticsService,
+ private val mediaSender: MediaSender,
+ private val player: VoiceMessageComposerPlayer,
+ permissionsPresenterFactory: PermissionsPresenter.Factory
+) : Presenter {
+ private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
+
+ @Composable
+ override fun present(): VoiceMessageComposerState {
+ val localCoroutineScope = rememberCoroutineScope()
+ val recorderState by voiceRecorder.state.collectAsState(initial = VoiceRecorderState.Idle)
+ val keepScreenOn by remember { derivedStateOf { recorderState is VoiceRecorderState.Recording } }
+
+ val permissionState = permissionsPresenter.present()
+ var isSending by remember { mutableStateOf(false) }
+ val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying)
+ val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } }
+ val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
+
+ val onLifecycleEvent = { event: Lifecycle.Event ->
+ when (event) {
+ Lifecycle.Event.ON_PAUSE -> {
+ appCoroutineScope.finishRecording()
+ player.pause()
+ }
+ Lifecycle.Event.ON_DESTROY -> {
+ appCoroutineScope.cancelRecording()
+ }
+ else -> {}
+ }
+ }
+
+ val onRecordButtonPress = { event: VoiceMessageComposerEvents.RecordButtonEvent ->
+ val permissionGranted = permissionState.permissionGranted
+ when (event.pressEvent) {
+ PressEvent.PressStart -> {
+ Timber.v("Voice message record button pressed")
+ when {
+ permissionGranted -> {
+ localCoroutineScope.startRecording()
+ }
+ else -> {
+ Timber.i("Voice message permission needed")
+ permissionState.eventSink(PermissionsEvents.RequestPermissions)
+ }
+ }
+ }
+ PressEvent.LongPressEnd -> {
+ Timber.v("Voice message record button released")
+ localCoroutineScope.finishRecording()
+ }
+ PressEvent.Tapped -> {
+ Timber.v("Voice message record button tapped")
+ localCoroutineScope.cancelRecording()
+ }
+ }
+ }
+ val onPlayerEvent = { event: VoiceMessagePlayerEvent ->
+ when (event) {
+ VoiceMessagePlayerEvent.Play ->
+ when (val recording = recorderState) {
+ is VoiceRecorderState.Finished ->
+ player.play(
+ mediaPath = recording.file.path,
+ mimeType = recording.mimeType,
+ )
+ else -> Timber.e("Voice message player event received but no file to play")
+ }
+ VoiceMessagePlayerEvent.Pause -> {
+ player.pause()
+ }
+ is VoiceMessagePlayerEvent.Seek -> {
+ // TODO implement seeking
+ }
+ }
+ }
+
+ val onAcceptPermissionsRationale = {
+ permissionState.eventSink(PermissionsEvents.OpenSystemSettingAndCloseDialog)
+ }
+
+ val onDismissPermissionsRationale = {
+ permissionState.eventSink(PermissionsEvents.CloseDialog)
+ }
+
+ val onSendButtonPress = lambda@{
+ val finishedState = recorderState as? VoiceRecorderState.Finished
+ if (finishedState == null) {
+ val exception = VoiceMessageException.FileException("No file to send")
+ analyticsService.trackError(exception)
+ Timber.e(exception)
+ return@lambda
+ }
+ if (isSending) {
+ return@lambda
+ }
+ isSending = true
+ player.pause()
+ appCoroutineScope.sendMessage(
+ file = finishedState.file,
+ mimeType = finishedState.mimeType,
+ waveform = finishedState.waveform,
+ ).invokeOnCompletion {
+ isSending = false
+ }
+ }
+
+ val handleEvents: (VoiceMessageComposerEvents) -> Unit = { event ->
+ when (event) {
+ is VoiceMessageComposerEvents.RecordButtonEvent -> onRecordButtonPress(event)
+ is VoiceMessageComposerEvents.PlayerEvent -> onPlayerEvent(event.playerEvent)
+ is VoiceMessageComposerEvents.SendVoiceMessage -> localCoroutineScope.launch {
+ onSendButtonPress()
+ }
+ VoiceMessageComposerEvents.DeleteVoiceMessage -> {
+ player.pause()
+ localCoroutineScope.deleteRecording()
+ }
+ VoiceMessageComposerEvents.DismissPermissionsRationale -> onDismissPermissionsRationale()
+ VoiceMessageComposerEvents.AcceptPermissionRationale -> onAcceptPermissionsRationale()
+ is VoiceMessageComposerEvents.LifecycleEvent -> onLifecycleEvent(event.event)
+ }
+ }
+
+ return VoiceMessageComposerState(
+ voiceMessageState = when (val state = recorderState) {
+ is VoiceRecorderState.Recording -> VoiceMessageState.Recording(
+ duration = state.elapsedTime,
+ levels = state.levels.toPersistentList()
+ )
+ is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
+ isSending = isSending,
+ isPlaying = isPlaying,
+ playbackProgress = playerState.progress,
+ waveform = waveform,
+ )
+ else -> VoiceMessageState.Idle
+ },
+ showPermissionRationaleDialog = permissionState.showDialog,
+ keepScreenOn = keepScreenOn,
+ eventSink = handleEvents,
+ )
+ }
+
+ private fun CoroutineScope.startRecording() = launch {
+ try {
+ voiceRecorder.startRecord()
+ } catch (e: SecurityException) {
+ Timber.e(e, "Voice message error")
+ analyticsService.trackError(VoiceMessageException.PermissionMissing("Expected permission to record but none", e))
+ }
+ }
+
+ private fun CoroutineScope.finishRecording() = launch {
+ voiceRecorder.stopRecord()
+ }
+
+ private fun CoroutineScope.cancelRecording() = launch {
+ voiceRecorder.stopRecord(cancelled = true)
+ }
+
+ private fun CoroutineScope.deleteRecording() = launch {
+ voiceRecorder.deleteRecording()
+ }
+
+ private fun CoroutineScope.sendMessage(
+ file: File,
+ mimeType: String,
+ waveform: List
+ ) = launch {
+ val result = mediaSender.sendVoiceMessage(
+ uri = file.toUri(),
+ mimeType = mimeType,
+ waveForm = waveform,
+ )
+
+ if (result.isFailure) {
+ Timber.e(result.exceptionOrNull(), "Voice message error")
+ return@launch
+ }
+
+ voiceRecorder.deleteRecording()
+ }
+}
+
+private fun VoiceRecorderState.finishedWaveform(): ImmutableList =
+ (this as? VoiceRecorderState.Finished)
+ ?.waveform
+ .orEmpty()
+ .toImmutableList()
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt
new file mode 100644
index 0000000000..055fa28177
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerState.kt
@@ -0,0 +1,29 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.composer
+
+import androidx.compose.runtime.Stable
+import io.element.android.libraries.textcomposer.model.VoiceMessageState
+
+@Stable
+data class VoiceMessageComposerState(
+ val voiceMessageState: VoiceMessageState,
+ val showPermissionRationaleDialog: Boolean,
+ val keepScreenOn: Boolean,
+ val eventSink: (VoiceMessageComposerEvents) -> Unit,
+)
+
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt
new file mode 100644
index 0000000000..f9856005bb
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessageComposerStateProvider.kt
@@ -0,0 +1,44 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.composer
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.textcomposer.model.VoiceMessageState
+import kotlinx.collections.immutable.toPersistentList
+import kotlin.time.Duration.Companion.seconds
+
+internal open class VoiceMessageComposerStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aVoiceMessageComposerState(voiceMessageState = VoiceMessageState.Recording(duration = 61.seconds, levels = aWaveformLevels)),
+ )
+}
+
+internal fun aVoiceMessageComposerState(
+ voiceMessageState: VoiceMessageState = VoiceMessageState.Idle,
+ keepScreenOn: Boolean = false,
+ showPermissionRationaleDialog: Boolean = false,
+) = VoiceMessageComposerState(
+ voiceMessageState = voiceMessageState,
+ showPermissionRationaleDialog = showPermissionRationaleDialog,
+ keepScreenOn = keepScreenOn,
+ eventSink = {},
+)
+
+internal var aWaveformLevels = List(100) { it.toFloat() / 100 }.toPersistentList()
+
+
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt
new file mode 100644
index 0000000000..9898aba95a
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/composer/VoiceMessagePermissionRationaleDialog.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.composer
+
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.res.stringResource
+import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
+import io.element.android.libraries.ui.strings.CommonStrings
+
+@Composable
+internal fun VoiceMessagePermissionRationaleDialog(
+ onContinue: () -> Unit,
+ onDismiss: () -> Unit,
+ appName: String,
+) {
+ ConfirmationDialog(
+ content = stringResource(CommonStrings.error_missing_microphone_voice_rationale_android, appName),
+ onSubmitClicked = onContinue,
+ onDismiss = onDismiss,
+ submitText = stringResource(CommonStrings.action_continue),
+ cancelText = stringResource(CommonStrings.action_cancel),
+ )
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt
new file mode 100644
index 0000000000..3f2aa70f5d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageEvents.kt
@@ -0,0 +1,22 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.timeline
+
+sealed interface VoiceMessageEvents {
+ data object PlayPause : VoiceMessageEvents
+ data class Seek(val percentage: Float) : VoiceMessageEvents
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt
new file mode 100644
index 0000000000..a559183261
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageMediaRepo.kt
@@ -0,0 +1,137 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.timeline
+
+import com.squareup.anvil.annotations.ContributesBinding
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import io.element.android.libraries.di.CacheDirectory
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.api.media.toFile
+import java.io.File
+
+/**
+ * Fetches the media file for a voice message.
+ *
+ * Media is downloaded from the rust sdk and stored in the application's cache directory.
+ * Media files are indexed by their Matrix Content (mxc://) URI and considered immutable.
+ * Whenever a given mxc is found in the cache, it is returned immediately.
+ */
+interface VoiceMessageMediaRepo {
+
+ /**
+ * Factory for [VoiceMessageMediaRepo].
+ */
+ fun interface Factory {
+ /**
+ * Creates a [VoiceMessageMediaRepo].
+ *
+ * @param mediaSource the media source of the voice message.
+ * @param mimeType the mime type of the voice message.
+ * @param body the body of the voice message.
+ */
+ fun create(
+ mediaSource: MediaSource,
+ mimeType: String?,
+ body: String?,
+ ): VoiceMessageMediaRepo
+ }
+
+ /**
+ * Returns the voice message media file.
+ *
+ * In case of a cache hit the file is returned immediately.
+ * In case of a cache miss the file is downloaded and then returned.
+ *
+ * @return A [Result] holding either the media [File] from the cache directory or an [Exception].
+ */
+ suspend fun getMediaFile(): Result
+}
+
+class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
+ @CacheDirectory private val cacheDir: File,
+ private val matrixMediaLoader: MatrixMediaLoader,
+ @Assisted private val mediaSource: MediaSource,
+ @Assisted("mimeType") private val mimeType: String?,
+ @Assisted("body") private val body: String?,
+) : VoiceMessageMediaRepo {
+
+ @ContributesBinding(RoomScope::class)
+ @AssistedFactory
+ fun interface Factory : VoiceMessageMediaRepo.Factory {
+ override fun create(
+ mediaSource: MediaSource,
+ @Assisted("mimeType") mimeType: String?,
+ @Assisted("body") body: String?,
+ ): DefaultVoiceMessageMediaRepo
+ }
+
+ override suspend fun getMediaFile(): Result = if (!isInCache()) {
+ matrixMediaLoader.downloadMediaFile(
+ source = mediaSource,
+ mimeType = mimeType,
+ body = body,
+ ).mapCatching {
+ val dest = cachedFilePath.apply { parentFile?.mkdirs() }
+ // TODO By not closing the MediaFile we're leaking the rust file handle here.
+ // Not that big of a deal but better to avoid it someday.
+ if (it.toFile().renameTo(dest)) {
+ dest
+ } else {
+ error("Failed to move file to cache.")
+ }
+ }
+ } else {
+ Result.success(cachedFilePath)
+ }
+
+ private val cachedFilePath: File = File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mediaSource.url)}")
+
+ private fun isInCache(): Boolean = cachedFilePath.exists()
+}
+
+/**
+ * Subdirectory of the application's cache directory where voice messages are stored.
+ */
+private const val CACHE_VOICE_SUBDIR = "temp/voice"
+
+/**
+ * Regex to match a Matrix Content (mxc://) URI.
+ *
+ * See: https://spec.matrix.org/v1.8/client-server-api/#matrix-content-mxc-uris
+ */
+private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
+
+/**
+ * Sanitizes an mxcUri to be used as a relative file path.
+ *
+ * @param mxcUri the Matrix Content (mxc://) URI of the voice message.
+ * @return the relative file path as "/".
+ * @throws IllegalStateException if the mxcUri is invalid.
+ */
+private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
+ "mxcUri2FilePath: Invalid mxcUri: $mxcUri"
+}.let { match ->
+ buildString {
+ append(match.groupValues[1])
+ append("/")
+ append(match.groupValues[2])
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt
new file mode 100644
index 0000000000..93b336095f
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePlayer.kt
@@ -0,0 +1,184 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.timeline
+
+import com.squareup.anvil.annotations.ContributesBinding
+import io.element.android.libraries.mediaplayer.api.MediaPlayer
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.distinctUntilChanged
+import kotlinx.coroutines.flow.map
+import javax.inject.Inject
+
+/**
+ * A media player specialized in playing a single voice message.
+ */
+interface VoiceMessagePlayer {
+
+ fun interface Factory {
+
+ /**
+ * Creates a [VoiceMessagePlayer].
+ *
+ * NB: Different voice messages can use the same content uri (e.g. in case of
+ * a forward of a voice message),
+ * therefore the mxc:// uri in [mediaSource] is not enough to uniquely identify
+ * a voice message. This is why we must provide the eventId as well.
+ *
+ * @param eventId The eventId of the voice message event.
+ * @param mediaSource The media source of the voice message.
+ * @param mimeType The mime type of the voice message.
+ * @param body The body of the voice message.
+ */
+ fun create(
+ eventId: EventId?,
+ mediaSource: MediaSource,
+ mimeType: String?,
+ body: String?,
+ ): VoiceMessagePlayer
+ }
+
+ /**
+ * The current state of this player.
+ */
+ val state: Flow
+
+ /**
+ * Starts playing from the beginning
+ * acquiring control of the underlying [MediaPlayer].
+ * If already in control of the underlying [MediaPlayer], starts playing from the
+ * current position.
+ *
+ * Will suspend whilst the media file is being downloaded.
+ */
+ suspend fun play(): Result
+
+ /**
+ * Pause playback.
+ */
+ fun pause()
+
+ /**
+ * Seek to a specific position.
+ *
+ * @param positionMs The position in milliseconds.
+ */
+ fun seekTo(positionMs: Long)
+
+ data class State(
+ /**
+ * Whether this player is currently playing.
+ */
+ val isPlaying: Boolean,
+ /**
+ * Whether this player has control of the underlying [MediaPlayer].
+ */
+ val isMyMedia: Boolean,
+ /**
+ * The elapsed time of this player in milliseconds.
+ */
+ val currentPosition: Long,
+ )
+}
+
+/**
+ * An implementation of [VoiceMessagePlayer] which is backed by a
+ * [VoiceMessageMediaRepo] to fetch and cache the media file and
+ * which uses a global [MediaPlayer] instance to play the media.
+ */
+class DefaultVoiceMessagePlayer(
+ private val mediaPlayer: MediaPlayer,
+ voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
+ private val eventId: EventId?,
+ mediaSource: MediaSource,
+ mimeType: String?,
+ body: String?,
+) : VoiceMessagePlayer {
+
+ @ContributesBinding(RoomScope::class) // Scoped types can't use @AssistedInject.
+ class Factory @Inject constructor(
+ private val mediaPlayer: MediaPlayer,
+ private val voiceMessageMediaRepoFactory: VoiceMessageMediaRepo.Factory,
+ ) : VoiceMessagePlayer.Factory {
+ override fun create(
+ eventId: EventId?,
+ mediaSource: MediaSource,
+ mimeType: String?,
+ body: String?,
+ ): DefaultVoiceMessagePlayer = DefaultVoiceMessagePlayer(
+ mediaPlayer = mediaPlayer,
+ voiceMessageMediaRepoFactory = voiceMessageMediaRepoFactory,
+ eventId = eventId,
+ mediaSource = mediaSource,
+ mimeType = mimeType,
+ body = body,
+ )
+ }
+
+ private val repo = voiceMessageMediaRepoFactory.create(
+ mediaSource = mediaSource,
+ mimeType = mimeType,
+ body = body
+ )
+
+ override val state: Flow = mediaPlayer.state.map { state ->
+ VoiceMessagePlayer.State(
+ isPlaying = state.mediaId.isMyTrack() && state.isPlaying,
+ isMyMedia = state.mediaId.isMyTrack(),
+ currentPosition = if (state.mediaId.isMyTrack()) state.currentPosition else 0L
+ )
+ }.distinctUntilChanged()
+
+ override suspend fun play(): Result = if (inControl()) {
+ mediaPlayer.play()
+ Result.success(Unit)
+ } else {
+ if (eventId != null) {
+ repo.getMediaFile().mapCatching { mediaFile ->
+ mediaPlayer.acquireControlAndPlay(
+ uri = mediaFile.path,
+ mediaId = eventId.value,
+ mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
+ )
+ }
+ } else {
+ Result.failure(IllegalStateException("Cannot play a voice message with no eventId"))
+ }
+ }
+
+ override fun pause() {
+ ifInControl {
+ mediaPlayer.pause()
+ }
+ }
+
+ override fun seekTo(positionMs: Long) {
+ ifInControl {
+ mediaPlayer.seekTo(positionMs)
+ }
+ }
+
+ private fun String?.isMyTrack(): Boolean = if (eventId == null) false else this == eventId.value
+
+ private inline fun ifInControl(block: () -> Unit) {
+ if (inControl()) block()
+ }
+
+ private fun inControl(): Boolean = mediaPlayer.state.value.mediaId.isMyTrack()
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt
new file mode 100644
index 0000000000..1bbdedc22d
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessagePresenter.kt
@@ -0,0 +1,135 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.timeline
+
+import androidx.compose.runtime.Composable
+import androidx.compose.runtime.collectAsState
+import androidx.compose.runtime.derivedStateOf
+import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
+import androidx.compose.runtime.rememberCoroutineScope
+import com.squareup.anvil.annotations.ContributesTo
+import dagger.Binds
+import dagger.Module
+import dagger.assisted.Assisted
+import dagger.assisted.AssistedFactory
+import dagger.assisted.AssistedInject
+import dagger.multibindings.IntoMap
+import io.element.android.features.messages.impl.timeline.di.TimelineItemEventContentKey
+import io.element.android.features.messages.impl.timeline.di.TimelineItemPresenterFactory
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runUpdatingState
+import io.element.android.libraries.di.RoomScope
+import io.element.android.libraries.ui.utils.time.formatShort
+import io.element.android.services.analytics.api.AnalyticsService
+import kotlinx.coroutines.launch
+import kotlin.time.Duration.Companion.milliseconds
+
+@Module
+@ContributesTo(RoomScope::class)
+interface VoiceMessagePresenterModule {
+ @Binds
+ @IntoMap
+ @TimelineItemEventContentKey(TimelineItemVoiceContent::class)
+ fun bindVoiceMessagePresenterFactory(factory: VoiceMessagePresenter.Factory): TimelineItemPresenterFactory<*, *>
+}
+
+class VoiceMessagePresenter @AssistedInject constructor(
+ voiceMessagePlayerFactory: VoiceMessagePlayer.Factory,
+ private val analyticsService: AnalyticsService,
+ @Assisted private val content: TimelineItemVoiceContent,
+) : Presenter {
+
+ @AssistedFactory
+ fun interface Factory : TimelineItemPresenterFactory {
+ override fun create(content: TimelineItemVoiceContent): VoiceMessagePresenter
+ }
+
+ private val player = voiceMessagePlayerFactory.create(
+ eventId = content.eventId,
+ mediaSource = content.mediaSource,
+ mimeType = content.mimeType,
+ body = content.body,
+ )
+
+ @Composable
+ override fun present(): VoiceMessageState {
+
+ val scope = rememberCoroutineScope()
+
+ val playerState by player.state.collectAsState(VoiceMessagePlayer.State(isPlaying = false, isMyMedia = false, currentPosition = 0L))
+ val play = remember { mutableStateOf>(Async.Uninitialized) }
+
+ val button by remember {
+ derivedStateOf {
+ when {
+ content.eventId == null -> VoiceMessageState.Button.Disabled
+ playerState.isPlaying -> VoiceMessageState.Button.Pause
+ play.value is Async.Loading -> VoiceMessageState.Button.Downloading
+ play.value is Async.Failure -> VoiceMessageState.Button.Retry
+ else -> VoiceMessageState.Button.Play
+ }
+ }
+ }
+ val progress by remember {
+ derivedStateOf { if (playerState.isMyMedia) playerState.currentPosition / content.duration.toMillis().toFloat() else 0f }
+ }
+ val time by remember {
+ derivedStateOf {
+ val time = if (playerState.isMyMedia) playerState.currentPosition else content.duration.toMillis()
+ time.milliseconds.formatShort()
+ }
+ }
+
+ fun eventSink(event: VoiceMessageEvents) {
+ when (event) {
+ is VoiceMessageEvents.PlayPause -> {
+ if (playerState.isPlaying) {
+ player.pause()
+ } else {
+ scope.launch {
+ play.runUpdatingState(
+ errorTransform = {
+ analyticsService.trackError(
+ VoiceMessageException.PlayMessageError("Error while trying to play voice message", it)
+ )
+ it
+ },
+ ) {
+ player.play()
+ }
+ }
+ }
+ }
+ is VoiceMessageEvents.Seek -> {
+ player.seekTo((event.percentage * content.duration.toMillis()).toLong())
+ }
+ }
+ }
+
+ return VoiceMessageState(
+ button = button,
+ progress = progress,
+ time = time,
+ eventSink = { eventSink(it) },
+ )
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt
new file mode 100644
index 0000000000..093d5336fd
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageState.kt
@@ -0,0 +1,32 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.timeline
+
+data class VoiceMessageState(
+ val button: Button,
+ val progress: Float,
+ val time: String,
+ val eventSink: (event: VoiceMessageEvents) -> Unit,
+) {
+ enum class Button {
+ Play,
+ Pause,
+ Downloading,
+ Retry,
+ Disabled,
+ }
+}
diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt
new file mode 100644
index 0000000000..83d2f1141f
--- /dev/null
+++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/VoiceMessageStateProvider.kt
@@ -0,0 +1,61 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.impl.voicemessages.timeline
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+
+open class VoiceMessageStateProvider : PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ aVoiceMessageState(
+ VoiceMessageState.Button.Downloading,
+ progress = 0f,
+ time = "0:00",
+ ),
+ aVoiceMessageState(
+ VoiceMessageState.Button.Retry,
+ progress = 0.5f,
+ time = "0:01",
+ ),
+ aVoiceMessageState(
+ VoiceMessageState.Button.Play,
+ progress = 1f,
+ time = "1:00",
+ ),
+ aVoiceMessageState(
+ VoiceMessageState.Button.Pause,
+ progress = 0.2f,
+ time = "10:00",
+ ),
+ aVoiceMessageState(
+ VoiceMessageState.Button.Disabled,
+ progress = 0.2f,
+ time = "30:00",
+ ),
+ )
+}
+
+fun aVoiceMessageState(
+ button: VoiceMessageState.Button = VoiceMessageState.Button.Play,
+ progress: Float = 0f,
+ time: String = "1:00",
+) = VoiceMessageState(
+ button = button,
+ progress = progress,
+ time = time,
+ eventSink = {},
+)
diff --git a/features/messages/impl/src/main/res/drawable/pause.xml b/features/messages/impl/src/main/res/drawable/pause.xml
new file mode 100644
index 0000000000..875a9ce403
--- /dev/null
+++ b/features/messages/impl/src/main/res/drawable/pause.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/features/messages/impl/src/main/res/drawable/play.xml b/features/messages/impl/src/main/res/drawable/play.xml
new file mode 100644
index 0000000000..4e9df7b71d
--- /dev/null
+++ b/features/messages/impl/src/main/res/drawable/play.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/features/messages/impl/src/main/res/drawable/retry.xml b/features/messages/impl/src/main/res/drawable/retry.xml
new file mode 100644
index 0000000000..c3fda8bca6
--- /dev/null
+++ b/features/messages/impl/src/main/res/drawable/retry.xml
@@ -0,0 +1,9 @@
+
+
+
diff --git a/features/messages/impl/src/main/res/values-cs/translations.xml b/features/messages/impl/src/main/res/values-cs/translations.xml
index 906ef6b987..48ea820a14 100644
--- a/features/messages/impl/src/main/res/values-cs/translations.xml
+++ b/features/messages/impl/src/main/res/values-cs/translations.xml
@@ -30,7 +30,6 @@
"Obnovení výchozího režimu se nezdařilo, zkuste to prosím znovu."
"Nastavení režimu se nezdařilo, zkuste to prosím znovu."
"Všechny zprávy"
- "Pouze zmínky a klíčová slova"
"V této místnosti mě upozornit na"
"Zobrazit méně"
"Zobrazit více"
@@ -38,6 +37,8 @@
"Vaši zprávu se nepodařilo odeslat"
"Přidat emoji"
"Zobrazit méně"
+ "Držte pro nahrávání"
+ "Všichni"
"Nahrání média se nezdařilo, zkuste to prosím znovu."
- "Odstranit"
+ "Pouze zmínky a klíčová slova"
diff --git a/features/messages/impl/src/main/res/values-de/translations.xml b/features/messages/impl/src/main/res/values-de/translations.xml
index 0249df4a9e..161868614c 100644
--- a/features/messages/impl/src/main/res/values-de/translations.xml
+++ b/features/messages/impl/src/main/res/values-de/translations.xml
@@ -29,7 +29,6 @@
"Fehler beim Wiederherstellen des Standardmodus. Bitte versuche es erneut."
"Fehler beim Einstellen des Modus. Bitte versuche es erneut."
"Alle Nachrichten"
- "Nur Erwähnungen und Schlüsselwörter"
"Benachrichtige mich in diesem Raum bei"
"Weniger anzeigen"
"Mehr anzeigen"
@@ -38,5 +37,5 @@
"Emoji hinzufügen"
"Weniger anzeigen"
"Fehler beim Verarbeiten des hochgeladenen Mediums. Bitte versuche es erneut."
- "Entfernen"
+ "Nur Erwähnungen und Schlüsselwörter"
diff --git a/features/messages/impl/src/main/res/values-es/translations.xml b/features/messages/impl/src/main/res/values-es/translations.xml
index 5d41b319bd..fe186df358 100644
--- a/features/messages/impl/src/main/res/values-es/translations.xml
+++ b/features/messages/impl/src/main/res/values-es/translations.xml
@@ -4,5 +4,4 @@
- "%1$d cambio en la sala"
- "%1$d cambios en la sala"
- "Eliminar"
diff --git a/features/messages/impl/src/main/res/values-fr/translations.xml b/features/messages/impl/src/main/res/values-fr/translations.xml
index 5ae43b98d7..f68a29ce16 100644
--- a/features/messages/impl/src/main/res/values-fr/translations.xml
+++ b/features/messages/impl/src/main/res/values-fr/translations.xml
@@ -4,6 +4,7 @@
- "%1$d changement dans le salon"
- "%1$d changements dans le salon"
+ "Notifier tout le salon"
"Appareil photo"
"Prendre une photo"
"Enregistrer une vidéo"
@@ -13,8 +14,9 @@
"Sondage"
"Formatage du texte"
"L’historique des messages n’est actuellement pas disponible dans ce salon"
+ "L’historique de la discussion n’est pas disponible. Vérifiez cette session pour accéder à l’historique."
"Impossible de récupérer les détails de l’utilisateur"
- "Souaitez-vous inviter l\'ancien membre à revenir ?"
+ "Souhaitez-vous inviter l’ancien membre à revenir ?"
"Vous êtes seul dans ce salon"
"Message copié"
"Vous n’êtes pas autorisé à publier dans ce salon"
@@ -29,7 +31,6 @@
"Échec de la restauration du mode par défaut, veuillez réessayer."
"Échec de la configuration du mode, veuillez réessayer."
"Tous les messages"
- "Mentions et mots clés uniquement"
"Dans ce salon, prévenez-moi pour"
"Afficher moins"
"Afficher plus"
@@ -37,6 +38,8 @@
"Votre message n’a pas pu être envoyé"
"Ajouter un émoji"
"Afficher moins"
+ "Maintenir pour enregistrer"
+ "Tout le monde"
"Échec du traitement des médias à télécharger, veuillez réessayer."
- "Supprimer"
+ "Mentions et mots clés uniquement"
diff --git a/features/messages/impl/src/main/res/values-it/translations.xml b/features/messages/impl/src/main/res/values-it/translations.xml
index 694de002fe..1b0a2c99a3 100644
--- a/features/messages/impl/src/main/res/values-it/translations.xml
+++ b/features/messages/impl/src/main/res/values-it/translations.xml
@@ -4,5 +4,4 @@
- "%1$d modifica alla stanza"
- "%1$d modifiche alla stanza"
- "Rimuovi"
diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml
index c351eb29cb..16e4867196 100644
--- a/features/messages/impl/src/main/res/values-ro/translations.xml
+++ b/features/messages/impl/src/main/res/values-ro/translations.xml
@@ -30,7 +30,6 @@
"Nu s-a reușit restaurarea modului implicit, vă rugăm să încercați din nou."
"Nu s-a reușit setarea modului, vă rugăm să încercați din nou."
"Toate mesajele"
- "Numai mențiuni și cuvinte cheie"
"În această cameră, anunțați-mă pentru"
"Afișați mai puțin"
"Afișați mai mult"
@@ -39,5 +38,5 @@
"Adăugați emoji"
"Afișați mai puțin"
"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."
- "Ștergeți"
+ "Numai mențiuni și cuvinte cheie"
diff --git a/features/messages/impl/src/main/res/values-ru/translations.xml b/features/messages/impl/src/main/res/values-ru/translations.xml
index 5dd73b8f8f..46ae80803f 100644
--- a/features/messages/impl/src/main/res/values-ru/translations.xml
+++ b/features/messages/impl/src/main/res/values-ru/translations.xml
@@ -30,7 +30,6 @@
"Не удалось восстановить режим по умолчанию, попробуйте еще раз."
"Не удалось настроить режим, попробуйте еще раз."
"Все сообщения"
- "Только упоминания и ключевые слова"
"В этой комнате уведомить меня о"
"Показать меньше"
"Показать больше"
@@ -38,6 +37,8 @@
"Не удалось отправить ваше сообщение"
"Добавить эмодзи"
"Показать меньше"
+ "Удерживайте для записи"
+ "Для всех"
"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."
- "Удалить"
+ "Только упоминания и ключевые слова"
diff --git a/features/messages/impl/src/main/res/values-sk/translations.xml b/features/messages/impl/src/main/res/values-sk/translations.xml
index 2c98aab148..8800aa7a22 100644
--- a/features/messages/impl/src/main/res/values-sk/translations.xml
+++ b/features/messages/impl/src/main/res/values-sk/translations.xml
@@ -5,6 +5,7 @@
- "%1$d zmeny miestnosti"
- "%1$d zmien miestnosti"
+ "Informovať celú miestnosť"
"Kamera"
"Odfotiť"
"Nahrať video"
@@ -14,6 +15,7 @@
"Anketa"
"Formátovanie textu"
"História správ v tejto miestnosti nie je momentálne k dispozícii"
+ "História správ nie je v tejto miestnosti k dispozícii. Ak chcete zobraziť históriu správ, overte toto zariadenie."
"Nepodarilo sa získať údaje o používateľovi"
"Chceli by ste ich pozvať späť?"
"V tomto rozhovore ste sami"
@@ -30,7 +32,6 @@
"Nepodarilo sa obnoviť predvolený režim, skúste to prosím znova."
"Nepodarilo sa nastaviť režim, skúste to prosím znova."
"Všetky správy"
- "Iba zmienky a kľúčové slová"
"V tejto miestnosti ma upozorniť na"
"Zobraziť menej"
"Zobraziť viac"
@@ -38,6 +39,8 @@
"Vašu správu sa nepodarilo odoslať"
"Pridať emoji"
"Zobraziť menej"
+ "Podržaním nahrajte"
+ "Všetci"
"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."
- "Odstrániť"
+ "Iba zmienky a kľúčové slová"
diff --git a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml
index 5f3b2b6393..d3e9c9bce9 100644
--- a/features/messages/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/messages/impl/src/main/res/values-zh-rTW/translations.xml
@@ -19,12 +19,11 @@
"無法重設為預設模式,請再試一次。"
"無法設定模式,請再試一次。"
"所有訊息"
- "僅限提及與關鍵字"
"較少"
"更多"
"重傳"
"無法傳送您的訊息"
"新增表情符號"
"較少"
- "移除"
+ "僅限提及與關鍵字"
diff --git a/features/messages/impl/src/main/res/values/localazy.xml b/features/messages/impl/src/main/res/values/localazy.xml
index d904a1933a..17f791e752 100644
--- a/features/messages/impl/src/main/res/values/localazy.xml
+++ b/features/messages/impl/src/main/res/values/localazy.xml
@@ -4,6 +4,7 @@
- "%1$d room change"
- "%1$d room changes"
+ "Notify the whole room"
"Camera"
"Take photo"
"Record video"
@@ -12,7 +13,8 @@
"Location"
"Poll"
"Text Formatting"
- "Message history is currently unavailable in this room"
+ "Message history is currently unavailable."
+ "Message history is unavailable in this room. Verify this device to see your message history."
"Could not retrieve user details"
"Would you like to invite them back?"
"You are alone in this chat"
@@ -29,7 +31,6 @@
"Failed restoring the default mode, please try again."
"Failed setting the mode, please try again."
"All messages"
- "Mentions and Keywords only"
"In this room, notify me for"
"Show less"
"Show more"
@@ -37,6 +38,8 @@
"Your message failed to send"
"Add emoji"
"Show less"
+ "Hold to record"
+ "Everyone"
"Failed processing media to upload, please try again."
- "Remove"
+ "Mentions and Keywords only"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
index def74c108a..6eb72777c1 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/MessagesPresenterTest.kt
@@ -40,6 +40,8 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemTextContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVideoContent
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
@@ -60,20 +62,28 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.MessageEventType
import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EVENT_ID
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_SESSION_ID_2
+import io.element.android.libraries.matrix.test.FakeMatrixClient
+import io.element.android.libraries.matrix.test.core.aBuildMeta
+import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
+import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.mediapickers.test.FakePickerProvider
+import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.mediaupload.api.MediaSender
import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
@@ -103,7 +113,8 @@ class MessagesPresenterTest {
val initialState = consumeItemsUntilTimeout().last()
assertThat(initialState.roomId).isEqualTo(A_ROOM_ID)
assertThat(initialState.roomName).isEqualTo(Async.Success(""))
- assertThat(initialState.roomAvatar).isEqualTo(Async.Success(AvatarData(id = A_ROOM_ID.value, name = "", size = AvatarSize.TimelineRoom)))
+ assertThat(initialState.roomAvatar)
+ .isEqualTo(Async.Success(AvatarData(id = A_ROOM_ID.value, name = "", url = AN_AVATAR_URL, size = AvatarSize.TimelineRoom)))
assertThat(initialState.userHasPermissionToSendMessage).isTrue()
assertThat(initialState.userHasPermissionToRedact).isFalse()
assertThat(initialState.hasNetworkConnection).isTrue()
@@ -600,24 +611,37 @@ class MessagesPresenterTest {
private fun TestScope.createMessagesPresenter(
coroutineDispatchers: CoroutineDispatchers = testCoroutineDispatchers(),
- matrixRoom: MatrixRoom = FakeMatrixRoom(),
+ matrixRoom: MatrixRoom = FakeMatrixRoom().apply {
+ givenRoomInfo(aRoomInfo(id = roomId.value, name = ""))
+ },
navigator: FakeMessagesNavigator = FakeMessagesNavigator(),
clipboardHelper: FakeClipboardHelper = FakeClipboardHelper(),
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
permissionsPresenter: PermissionsPresenter = FakePermissionsPresenter(),
): MessagesPresenter {
+ val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom)
+ val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter)
val messageComposerPresenter = MessageComposerPresenter(
appCoroutineScope = this,
room = matrixRoom,
mediaPickerProvider = FakePickerProvider(),
featureFlagService = FakeFeatureFlagService(mapOf(FeatureFlags.NotificationSettings.key to true)),
localMediaFactory = FakeLocalMediaFactory(mockMediaUrl),
- mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom),
+ mediaSender = mediaSender,
snackbarDispatcher = SnackbarDispatcher(),
analyticsService = analyticsService,
messageComposerContext = MessageComposerContextImpl(),
richTextEditorStateFactory = TestRichTextEditorStateFactory(),
- permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter),
+ permissionsPresenterFactory = permissionsPresenterFactory,
+ currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
+ )
+ val voiceMessageComposerPresenter = VoiceMessageComposerPresenter(
+ this,
+ FakeVoiceRecorder(),
+ analyticsService,
+ mediaSender,
+ player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
+ permissionsPresenterFactory,
)
val timelinePresenter = TimelinePresenter(
timelineItemsFactory = aTimelineItemsFactory(),
@@ -625,6 +649,8 @@ class MessagesPresenterTest {
dispatchers = coroutineDispatchers,
appScope = this,
analyticsService = analyticsService,
+ encryptionService = FakeEncryptionService(),
+ verificationService = FakeSessionVerificationService(),
)
val preferencesStore = InMemoryPreferencesStore(isRichTextEditorEnabled = true)
val actionListPresenter = ActionListPresenter(preferencesStore = preferencesStore)
@@ -634,6 +660,7 @@ class MessagesPresenterTest {
return MessagesPresenter(
room = matrixRoom,
composerPresenter = messageComposerPresenter,
+ voiceMessageComposerPresenter = voiceMessageComposerPresenter,
timelinePresenter = timelinePresenter,
actionListPresenter = actionListPresenter,
customReactionPresenter = customReactionPresenter,
@@ -645,6 +672,8 @@ class MessagesPresenterTest {
navigator = navigator,
clipboardHelper = clipboardHelper,
preferencesStore = preferencesStore,
+ featureFlagsService = FakeFeatureFlagService(),
+ buildMeta = aBuildMeta(),
dispatchers = coroutineDispatchers,
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
index f94e79f0cb..460979f289 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/actionlist/ActionListPresenterTest.kt
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemImageContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemStateEventContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
import io.element.android.libraries.featureflag.test.InMemoryPreferencesStore
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.tests.testutils.WarmUpRule
@@ -458,6 +459,33 @@ class ActionListPresenterTest {
assertThat(successState.displayEmojiReactions).isTrue()
}
}
+
+ @Test
+ fun `present - compute for voice message`() = runTest {
+ val presenter = createActionListPresenter(isDeveloperModeEnabled = false)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ val messageEvent = aMessageEvent(
+ isMine = true,
+ content = aTimelineItemVoiceContent(),
+ )
+ initialState.eventSink.invoke(ActionListEvents.ComputeForMessage(messageEvent, canRedact = false, canSendMessage = true))
+ val successState = awaitItem()
+ assertThat(successState.target).isEqualTo(
+ ActionListState.Target.Success(
+ messageEvent,
+ persistentListOf(
+ TimelineItemAction.Reply,
+ TimelineItemAction.Forward,
+ TimelineItemAction.Redact,
+ )
+ )
+ )
+ assertThat(successState.displayEmojiReactions).isTrue()
+ }
+ }
}
private fun createActionListPresenter(isDeveloperModeEnabled: Boolean): ActionListPresenter {
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
index 0d4dc98340..18fa0e390e 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/fixtures/timelineItemsFactory.kt
@@ -49,7 +49,11 @@ internal fun TestScope.aTimelineItemsFactory(): TimelineItemsFactory {
dispatchers = testCoroutineDispatchers(),
eventItemFactory = TimelineItemEventFactory(
contentFactory = TimelineItemContentFactory(
- messageFactory = TimelineItemContentMessageFactory(FakeFileSizeFormatter(), FileExtensionExtractorWithoutValidation()),
+ messageFactory = TimelineItemContentMessageFactory(
+ fileSizeFormatter = FakeFileSizeFormatter(),
+ fileExtensionExtractor = FileExtensionExtractorWithoutValidation(),
+ featureFlagService = FakeFeatureFlagService(),
+ ),
redactedMessageFactory = TimelineItemContentRedactedFactory(),
stickerFactory = TimelineItemContentStickerFactory(),
pollFactory = TimelineItemContentPollFactory(matrixClient, FakeFeatureFlagService()),
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
index 3f86ed8b80..f05c209085 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/textcomposer/MessageComposerPresenterTest.kt
@@ -31,6 +31,7 @@ import io.element.android.features.messages.impl.messagecomposer.MessageComposer
import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents
import io.element.android.features.messages.impl.messagecomposer.MessageComposerPresenter
import io.element.android.features.messages.impl.messagecomposer.MessageComposerState
+import io.element.android.features.messages.impl.messagecomposer.RoomMemberSuggestion
import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -42,13 +43,23 @@ import io.element.android.libraries.matrix.api.core.TransactionId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
+import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
+import io.element.android.libraries.matrix.api.room.RoomMembershipState
+import io.element.android.libraries.matrix.api.user.CurrentSessionIdHolder
import io.element.android.libraries.matrix.test.ANOTHER_MESSAGE
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_REPLY
+import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_TRANSACTION_ID
+import io.element.android.libraries.matrix.test.A_USER_ID
+import io.element.android.libraries.matrix.test.A_USER_ID_2
+import io.element.android.libraries.matrix.test.A_USER_ID_3
+import io.element.android.libraries.matrix.test.A_USER_ID_4
import io.element.android.libraries.matrix.test.A_USER_NAME
+import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.mediapickers.api.PickerProvider
import io.element.android.libraries.mediapickers.test.FakePickerProvider
import io.element.android.libraries.mediaupload.api.MediaPreProcessor
@@ -58,8 +69,10 @@ import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
-import io.element.android.libraries.textcomposer.Message
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.Message
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.Suggestion
+import io.element.android.libraries.textcomposer.model.SuggestionType
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.waitForPredicate
@@ -67,6 +80,7 @@ import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
+import okhttp3.internal.immutableListOf
import org.junit.Rule
import org.junit.Test
import java.io.File
@@ -99,7 +113,7 @@ class MessageComposerPresenterTest {
val initialState = awaitItem()
assertThat(initialState.isFullScreen).isFalse()
assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("")
- assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal(""))
+ assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal)
assertThat(initialState.showAttachmentSourcePicker).isFalse()
assertThat(initialState.canShareLocation).isTrue()
assertThat(initialState.attachmentsState).isEqualTo(AttachmentsState.None)
@@ -153,7 +167,10 @@ class MessageComposerPresenterTest {
assertThat(state.mode).isEqualTo(mode)
state = awaitItem()
assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE)
- backToNormalMode(state, skipCount = 1)
+ state = backToNormalMode(state, skipCount = 1)
+
+ // The message that was being edited is cleared
+ assertThat(state.richTextEditorState.messageHtml).isEqualTo("")
}
}
@@ -174,6 +191,26 @@ class MessageComposerPresenterTest {
}
}
+ @Test
+ fun `present - cancel reply`() = runTest {
+ val presenter = createPresenter(this)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ var state = awaitItem()
+ val mode = aReplyMode()
+ state.eventSink.invoke(MessageComposerEvents.SetMode(mode))
+ state = awaitItem()
+ assertThat(state.mode).isEqualTo(mode)
+ state.richTextEditorState.setHtml(A_REPLY)
+ state = backToNormalMode(state)
+
+ // The message typed while replying is not cleared
+ assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY)
+ }
+ }
+
@Test
fun `present - change mode to quote`() = runTest {
val presenter = createPresenter(this)
@@ -683,12 +720,110 @@ class MessageComposerPresenterTest {
}
}
- private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0) {
+ @Test
+ fun `present - room member mention suggestions`() = runTest {
+ val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
+ val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
+ val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
+ val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
+ val room = FakeMatrixRoom(
+ isDirect = false,
+ isOneToOne = false,
+ ).apply {
+ givenRoomMembersState(MatrixRoomMembersState.Ready(
+ immutableListOf(currentUser, invitedUser, bob, david),
+ ))
+ givenCanTriggerRoomNotification(Result.success(true))
+ }
+ val flagsService = FakeFeatureFlagService(
+ mapOf(
+ FeatureFlags.Mentions.key to true,
+ )
+ )
+ val presenter = createPresenter(this, room, featureFlagService = flagsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+
+ // A null suggestion (no suggestion was received) returns nothing
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(null))
+ assertThat(awaitItem().memberSuggestions).isEmpty()
+
+ // An empty suggestion returns the room and joined members that are not the current user
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
+ assertThat(awaitItem().memberSuggestions)
+ .containsExactly(RoomMemberSuggestion.Room, RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
+
+ // A suggestion containing a part of "room" will also return the room mention
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo")))
+ assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Room)
+
+ // A non-empty suggestion will return those joined members whose user id matches it
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob")))
+ assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Member(bob))
+
+ // A non-empty suggestion will return those joined members whose display name matches it
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave")))
+ assertThat(awaitItem().memberSuggestions).containsExactly(RoomMemberSuggestion.Member(david))
+
+ // If the suggestion isn't a mention, no suggestions are returned
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, "")))
+ assertThat(awaitItem().memberSuggestions).isEmpty()
+
+ // If user has no permission to send `@room` mentions, `RoomMemberSuggestion.Room` is not returned
+ room.givenCanTriggerRoomNotification(Result.success(false))
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
+ assertThat(awaitItem().memberSuggestions)
+ .containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
+
+ // If room is a DM, `RoomMemberSuggestion.Room` is not returned
+ room.givenCanTriggerRoomNotification(Result.success(true))
+ room.isDirect
+ }
+ }
+
+ @Test
+ fun `present - room member mention suggestions in a DM`() = runTest {
+ val currentUser = aRoomMember(userId = A_USER_ID, membership = RoomMembershipState.JOIN)
+ val invitedUser = aRoomMember(userId = A_USER_ID_3, membership = RoomMembershipState.INVITE)
+ val bob = aRoomMember(userId = A_USER_ID_2, membership = RoomMembershipState.JOIN)
+ val david = aRoomMember(userId = A_USER_ID_4, displayName = "Dave", membership = RoomMembershipState.JOIN)
+ val room = FakeMatrixRoom(
+ isDirect = true,
+ isOneToOne = true,
+ ).apply {
+ givenRoomMembersState(MatrixRoomMembersState.Ready(
+ immutableListOf(currentUser, invitedUser, bob, david),
+ ))
+ givenCanTriggerRoomNotification(Result.success(true))
+ }
+ val flagsService = FakeFeatureFlagService(
+ mapOf(
+ FeatureFlags.Mentions.key to true,
+ )
+ )
+ val presenter = createPresenter(this, room, featureFlagService = flagsService)
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ skipItems(1)
+ val initialState = awaitItem()
+
+ // An empty suggestion returns the joined members that are not the current user, but not the room
+ initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "")))
+ assertThat(awaitItem().memberSuggestions)
+ .containsExactly(RoomMemberSuggestion.Member(bob), RoomMemberSuggestion.Member(david))
+ }
+ }
+
+ private suspend fun ReceiveTurbine.backToNormalMode(state: MessageComposerState, skipCount: Int = 0): MessageComposerState {
state.eventSink.invoke(MessageComposerEvents.CloseSpecialMode)
skipItems(skipCount)
val normalState = awaitItem()
- assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal(""))
- assertThat(normalState.richTextEditorState.messageHtml).isEqualTo("")
+ assertThat(normalState.mode).isEqualTo(MessageComposerMode.Normal)
+ return normalState
}
private fun createPresenter(
@@ -710,6 +845,7 @@ class MessageComposerPresenterTest {
analyticsService,
MessageComposerContextImpl(),
TestRichTextEditorStateFactory(),
+ currentSessionIdHolder = CurrentSessionIdHolder(FakeMatrixClient(A_SESSION_ID)),
permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionPresenter),
)
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
index ec83b86cd9..eeca4026bf 100644
--- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/timeline/TimelinePresenterTest.kt
@@ -24,6 +24,7 @@ import im.vector.app.features.analytics.plan.PollEnd
import im.vector.app.features.analytics.plan.PollVote
import io.element.android.features.messages.fixtures.aMessageEvent
import io.element.android.features.messages.fixtures.aTimelineItemsFactory
+import io.element.android.features.messages.impl.timeline.session.SessionState
import io.element.android.features.messages.impl.timeline.TimelineEvents
import io.element.android.features.messages.impl.timeline.TimelinePresenter
import io.element.android.features.messages.impl.timeline.factories.TimelineItemsFactory
@@ -35,10 +36,12 @@ import io.element.android.libraries.matrix.api.timeline.item.event.EventReaction
import io.element.android.libraries.matrix.api.timeline.item.event.ReactionSender
import io.element.android.libraries.matrix.api.timeline.item.virtual.VirtualTimelineItem
import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.room.aMessageContent
import io.element.android.libraries.matrix.test.room.anEventTimelineItem
import io.element.android.libraries.matrix.test.timeline.FakeMatrixTimeline
+import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
@@ -67,6 +70,7 @@ class TimelinePresenterTest {
assertThat(initialState.timelineItems).isEmpty()
val loadedNoTimelineState = awaitItem()
assertThat(loadedNoTimelineState.timelineItems).isEmpty()
+ assertThat(loadedNoTimelineState.sessionState).isEqualTo(SessionState(isSessionVerified = false, isKeyBackupEnabled = false))
}
}
@@ -228,8 +232,8 @@ class TimelinePresenterTest {
senders = listOf(alice, charlie)
),
EventReaction(
- key = "👍",
- senders = listOf(alice, bob)
+ key = "👍",
+ senders = listOf(alice, bob)
),
EventReaction(
key = "🐶",
@@ -316,6 +320,8 @@ class TimelinePresenterTest {
dispatchers = testCoroutineDispatchers(),
appScope = this,
analyticsService = FakeAnalyticsService(),
+ encryptionService = FakeEncryptionService(),
+ verificationService = FakeSessionVerificationService(),
)
}
@@ -329,6 +335,8 @@ class TimelinePresenterTest {
dispatchers = testCoroutineDispatchers(),
appScope = this,
analyticsService = analyticsService,
+ encryptionService = FakeEncryptionService(),
+ verificationService = FakeSessionVerificationService(),
)
}
}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
new file mode 100644
index 0000000000..b35f5c66b3
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/composer/VoiceMessageComposerPresenterTest.kt
@@ -0,0 +1,597 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+@file:OptIn(ExperimentalCoroutinesApi::class)
+
+package io.element.android.features.messages.voicemessages.composer
+
+import android.Manifest
+import androidx.lifecycle.Lifecycle
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.TurbineTestContext
+import app.cash.turbine.test
+import com.google.common.truth.Truth.assertThat
+import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
+import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
+import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
+import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
+import io.element.android.libraries.mediaupload.api.MediaSender
+import io.element.android.libraries.mediaupload.test.FakeMediaPreProcessor
+import io.element.android.libraries.permissions.api.PermissionsPresenter
+import io.element.android.libraries.permissions.api.aPermissionsState
+import io.element.android.libraries.permissions.test.FakePermissionsPresenter
+import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
+import io.element.android.libraries.textcomposer.model.PressEvent
+import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
+import io.element.android.libraries.textcomposer.model.VoiceMessageState
+import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import io.element.android.tests.testutils.WarmUpRule
+import kotlinx.collections.immutable.toPersistentList
+import kotlinx.collections.immutable.toImmutableList
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.test.TestScope
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import kotlin.time.Duration.Companion.seconds
+
+class VoiceMessageComposerPresenterTest {
+
+ @get:Rule
+ val warmUpRule = WarmUpRule()
+
+ private val voiceRecorder = FakeVoiceRecorder(
+ recordingDuration = RECORDING_DURATION
+ )
+ private val analyticsService = FakeAnalyticsService()
+ private val matrixRoom = FakeMatrixRoom()
+ private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
+ private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
+
+ companion object {
+ private val RECORDING_DURATION = 1.seconds
+ private val RECORDING_STATE = VoiceMessageState.Recording(RECORDING_DURATION, listOf(0.1f, 0.2f).toPersistentList())
+ }
+
+ @Test
+ fun `present - initial state`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ voiceRecorder.assertCalls(started = 0)
+
+ testPauseAndDestroy(initialState)
+ }
+ }
+
+ @Test
+ fun `present - recording state`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
+ voiceRecorder.assertCalls(started = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - recording keeps screen on`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().apply {
+ eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ assertThat(keepScreenOn).isFalse()
+ }
+
+ awaitItem().apply {
+ assertThat(keepScreenOn).isTrue()
+ eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ }
+
+ val finalState = awaitItem().apply {
+ assertThat(keepScreenOn).isFalse()
+ }
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - abort recording`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.Tapped))
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - finish recording`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState())
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - play recording before it is ready`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ val finalState = awaitItem().apply {
+ this.eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
+ }
+
+ // Nothing should happen
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(RECORDING_DURATION, RECORDING_STATE.levels))
+ voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - play recording`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
+ val finalState = awaitItem().also {
+ assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true, playbackProgress = 0.1f))
+ }
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - pause recording`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
+ awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
+ val finalState = awaitItem().also {
+ assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f))
+ }
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - delete recording`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - delete while playing`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
+ awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
+ awaitItem().apply {
+ assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f))
+ }
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - send recording`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - send while playing`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
+ awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(
+ isSending = true, isPlaying = false, playbackProgress = 0.1f
+ ))
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - send recording before previous completed, waits`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().run {
+ eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ }
+ assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - send failures aren't tracked`() = runTest {
+ // Let sending fail due to media preprocessing error
+ mediaPreProcessor.givenResult(Result.failure(Exception()))
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ awaitItem().apply {
+ assertThat(voiceMessageState).isEqualTo(aPreviewState())
+ eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ }
+
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(aPreviewState(isSending = true))
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ assertThat(analyticsService.trackedErrors).hasSize(0)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - send failures can be retried`() = runTest {
+ // Let sending fail due to media preprocessing error
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ mediaPreProcessor.givenResult(Result.failure(Exception()))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ val previewState = awaitItem()
+
+ previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
+
+ ensureAllEventsConsumed()
+ assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+
+ mediaPreProcessor.givenAudioResult()
+ previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(1)
+ voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - send error - missing recording is tracked`() = runTest {
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ // Send the message before recording anything
+ initialState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
+
+ assertThat(initialState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ assertThat(analyticsService.trackedErrors).hasSize(1)
+ voiceRecorder.assertCalls(started = 0)
+
+ testPauseAndDestroy(initialState)
+ }
+ }
+
+ @Test
+ fun `present - record error - security exceptions are tracked`() = runTest {
+ val exception = SecurityException("")
+ voiceRecorder.givenThrowsSecurityException(exception)
+ val presenter = createVoiceMessageComposerPresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+
+ assertThat(matrixRoom.sendMediaCount).isEqualTo(0)
+ assertThat(analyticsService.trackedErrors).containsExactly(
+ VoiceMessageException.PermissionMissing(message = "Expected permission to record but none", cause = exception)
+ )
+ voiceRecorder.assertCalls(started = 1)
+
+ testPauseAndDestroy(initialState)
+ }
+ }
+
+ @Test
+ fun `present - permission accepted first time`() = runTest {
+ val permissionsPresenter = createFakePermissionsPresenter(
+ recordPermissionGranted = false,
+ )
+ val presenter = createVoiceMessageComposerPresenter(
+ permissionsPresenter = permissionsPresenter,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem()
+ initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+
+ initialState.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
+ voiceRecorder.assertCalls(stopped = 1)
+
+ permissionsPresenter.setPermissionGranted()
+
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
+ voiceRecorder.assertCalls(stopped = 1, started = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - permission denied previously`() = runTest {
+ val permissionsPresenter = createFakePermissionsPresenter(
+ recordPermissionGranted = false,
+ )
+ val presenter = createVoiceMessageComposerPresenter(
+ permissionsPresenter = permissionsPresenter,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+
+ // See the dialog and accept it
+ awaitItem().also {
+ assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(it.showPermissionRationaleDialog).isTrue()
+ it.eventSink(VoiceMessageComposerEvents.AcceptPermissionRationale)
+ }
+
+ // Dialog is hidden, user accepts permissions
+ assertThat(awaitItem().showPermissionRationaleDialog).isFalse()
+
+ permissionsPresenter.setPermissionGranted()
+
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ val finalState = awaitItem()
+ assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
+ voiceRecorder.assertCalls(started = 1)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ @Test
+ fun `present - permission rationale dismissed`() = runTest {
+ val permissionsPresenter = createFakePermissionsPresenter(
+ recordPermissionGranted = false,
+ )
+ val presenter = createVoiceMessageComposerPresenter(
+ permissionsPresenter = permissionsPresenter,
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+
+ // See the dialog and accept it
+ awaitItem().also {
+ assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(it.showPermissionRationaleDialog).isTrue()
+ it.eventSink(VoiceMessageComposerEvents.DismissPermissionsRationale)
+ }
+
+ // Dialog is hidden, user tries to record again
+ awaitItem().also {
+ assertThat(it.showPermissionRationaleDialog).isFalse()
+ it.eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
+ }
+
+ // Dialog is shown once again
+ val finalState = awaitItem().also {
+ assertThat(it.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ assertThat(it.showPermissionRationaleDialog).isTrue()
+ }
+ voiceRecorder.assertCalls(started = 0)
+
+ testPauseAndDestroy(finalState)
+ }
+ }
+
+ private suspend fun TurbineTestContext.testPauseAndDestroy(
+ mostRecentState: VoiceMessageComposerState,
+ ) {
+ mostRecentState.eventSink(
+ VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_PAUSE)
+ )
+
+ val onPauseState = when (val state = mostRecentState.voiceMessageState) {
+ VoiceMessageState.Idle -> mostRecentState
+ is VoiceMessageState.Recording -> {
+ // If recorder was active, it stops
+ awaitItem().apply {
+ assertThat(voiceMessageState).isEqualTo(aPreviewState())
+ }
+ }
+ is VoiceMessageState.Preview -> when (state.isPlaying) {
+ // If the preview was playing, it pauses
+ true -> awaitItem().apply {
+ assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f))
+ }
+ false -> mostRecentState
+ }
+ }
+
+ onPauseState.eventSink(
+ VoiceMessageComposerEvents.LifecycleEvent(event = Lifecycle.Event.ON_DESTROY)
+ )
+
+ when (val state = onPauseState.voiceMessageState) {
+ VoiceMessageState.Idle ->
+ ensureAllEventsConsumed()
+ is VoiceMessageState.Recording ->
+ assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ is VoiceMessageState.Preview -> when (state.isSending) {
+ true -> ensureAllEventsConsumed()
+ false -> assertThat(awaitItem().voiceMessageState).isEqualTo(VoiceMessageState.Idle)
+ }
+ }
+ }
+
+ private fun TestScope.createVoiceMessageComposerPresenter(
+ permissionsPresenter: PermissionsPresenter = createFakePermissionsPresenter(),
+ ): VoiceMessageComposerPresenter {
+ return VoiceMessageComposerPresenter(
+ this,
+ voiceRecorder,
+ analyticsService,
+ mediaSender,
+ player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
+ FakePermissionsPresenterFactory(permissionsPresenter),
+ )
+ }
+
+ private fun createFakePermissionsPresenter(
+ recordPermissionGranted: Boolean = true,
+ recordPermissionShowDialog: Boolean = false,
+ ): FakePermissionsPresenter {
+ val initialPermissionState = aPermissionsState(
+ showDialog = recordPermissionShowDialog,
+ permission = Manifest.permission.RECORD_AUDIO,
+ permissionGranted = recordPermissionGranted,
+ )
+ return FakePermissionsPresenter(
+ initialState = initialPermissionState
+ )
+ }
+
+ private fun aPreviewState(
+ isPlaying: Boolean = false,
+ playbackProgress: Float = 0f,
+ isSending: Boolean = false,
+ waveform: List = voiceRecorder.waveform,
+ ) = VoiceMessageState.Preview(
+ isPlaying = isPlaying,
+ playbackProgress = playbackProgress,
+ isSending = isSending,
+ waveform = waveform.toImmutableList(),
+ )
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt
new file mode 100644
index 0000000000..3b19e66450
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt
@@ -0,0 +1,142 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.voicemessages.timeline
+
+import com.google.common.truth.Truth
+import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessageMediaRepo
+import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.test.media.FakeMediaLoader
+import kotlinx.coroutines.test.runTest
+import org.junit.Rule
+import org.junit.Test
+import org.junit.rules.TemporaryFolder
+import java.io.File
+
+class DefaultVoiceMessageMediaRepoTest {
+
+ @get:Rule
+ val temporaryFolder = TemporaryFolder()
+
+ @Test
+ fun `cache miss - downloads and returns cached file successfully`() = runTest {
+ val fakeMediaLoader = FakeMediaLoader().apply {
+ path = temporaryFolder.createRustMediaFile().path
+ }
+ val repo = createDefaultVoiceMessageMediaRepo(
+ temporaryFolder = temporaryFolder,
+ matrixMediaLoader = fakeMediaLoader,
+ )
+
+ repo.getMediaFile().let { result ->
+ Truth.assertThat(result.isSuccess).isTrue()
+ result.getOrThrow().let { file ->
+ Truth.assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
+ Truth.assertThat(file.exists()).isTrue()
+ }
+ }
+ }
+
+ @Test
+ fun `cache miss - download fails`() = runTest {
+ val fakeMediaLoader = FakeMediaLoader().apply {
+ shouldFail = true
+ }
+ val repo = createDefaultVoiceMessageMediaRepo(
+ temporaryFolder = temporaryFolder,
+ matrixMediaLoader = fakeMediaLoader,
+ )
+
+ repo.getMediaFile().let { result ->
+ Truth.assertThat(result.isFailure).isTrue()
+ result.exceptionOrNull()?.let { exception ->
+ Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun `cache miss - download succeeds but file move fails`() = runTest {
+ val fakeMediaLoader = FakeMediaLoader().apply {
+ path = temporaryFolder.createRustMediaFile().path
+ }
+ File(temporaryFolder.cachedFilePath).apply {
+ parentFile?.mkdirs()
+ // Deny access to parent folder so move to cache will fail.
+ parentFile?.setReadable(false)
+ parentFile?.setWritable(false)
+ parentFile?.setExecutable(false)
+ }
+ val repo = createDefaultVoiceMessageMediaRepo(
+ temporaryFolder = temporaryFolder,
+ matrixMediaLoader = fakeMediaLoader,
+ )
+
+ repo.getMediaFile().let { result ->
+ Truth.assertThat(result.isFailure).isTrue()
+ result.exceptionOrNull()?.let { exception ->
+ Truth.assertThat(exception).apply {
+ isInstanceOf(IllegalStateException::class.java)
+ hasMessageThat().isEqualTo("Failed to move file to cache.")
+ }
+ }
+ }
+ }
+
+ @Test
+ fun `cache hit - returns cached file successfully`() = runTest {
+ temporaryFolder.createCachedFile()
+ val fakeMediaLoader = FakeMediaLoader().apply {
+ shouldFail = true // so that if we hit the media loader it will crash
+ }
+ val repo = createDefaultVoiceMessageMediaRepo(
+ temporaryFolder = temporaryFolder,
+ matrixMediaLoader = fakeMediaLoader,
+ )
+
+ repo.getMediaFile().let { result ->
+ Truth.assertThat(result.isSuccess).isTrue()
+ result.getOrThrow().let { file ->
+ Truth.assertThat(file.path).isEqualTo(temporaryFolder.cachedFilePath)
+ Truth.assertThat(file.exists()).isTrue()
+ }
+ }
+ }
+}
+
+private fun createDefaultVoiceMessageMediaRepo(
+ temporaryFolder: TemporaryFolder,
+ matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(),
+) = DefaultVoiceMessageMediaRepo(
+ cacheDir = temporaryFolder.root,
+ matrixMediaLoader = matrixMediaLoader,
+ mediaSource = MediaSource(
+ url = MXC_URI,
+ json = null
+ ),
+ mimeType = "audio/ogg",
+ body = "someBody.ogg"
+)
+
+private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
+private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
+private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
+ parentFile?.mkdirs()
+ createNewFile()
+}
+
+private fun TemporaryFolder.createRustMediaFile() = File(this.root, "rustMediaFile.ogg").apply { createNewFile() }
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/DefaultVoiceMessagePlayerTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/DefaultVoiceMessagePlayerTest.kt
new file mode 100644
index 0000000000..eac9a905bd
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/DefaultVoiceMessagePlayerTest.kt
@@ -0,0 +1,149 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.voicemessages.timeline
+
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.libraries.mediaplayer.api.MediaPlayer
+import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessagePlayer
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
+import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
+import io.element.android.libraries.matrix.api.core.EventId
+import io.element.android.libraries.matrix.api.media.MediaSource
+import io.element.android.libraries.matrix.test.AN_EVENT_ID
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class DefaultVoiceMessagePlayerTest {
+
+ @Test
+ fun `initial state`() = runTest {
+ createDefaultVoiceMessagePlayer().state.test {
+ awaitItem().let {
+ Truth.assertThat(it.isPlaying).isEqualTo(false)
+ Truth.assertThat(it.isMyMedia).isEqualTo(false)
+ Truth.assertThat(it.currentPosition).isEqualTo(0)
+ }
+ }
+ }
+
+ @Test
+ fun `downloading and play works`() = runTest {
+ val player = createDefaultVoiceMessagePlayer()
+ player.state.test {
+ skipItems(1) // skip initial state.
+ Truth.assertThat(player.play().isSuccess).isTrue()
+ awaitItem().let {
+ Truth.assertThat(it.isPlaying).isEqualTo(true)
+ Truth.assertThat(it.isMyMedia).isEqualTo(true)
+ Truth.assertThat(it.currentPosition).isEqualTo(1000)
+ }
+ }
+ }
+
+ @Test
+ fun `downloading and play fails`() = runTest {
+ val player = createDefaultVoiceMessagePlayer(
+ voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply {
+ shouldFail = true
+ },
+ )
+ player.state.test {
+ skipItems(1) // skip initial state.
+ Truth.assertThat(player.play().isFailure).isTrue()
+ }
+ }
+
+ @Test
+ fun `play fails with no eventId`() = runTest {
+ val player = createDefaultVoiceMessagePlayer(
+ eventId = null
+ )
+ player.state.test {
+ skipItems(1) // skip initial state.
+ Truth.assertThat(player.play().isFailure).isTrue()
+ }
+ }
+
+ @Test
+ fun `pause playing works`() = runTest {
+ val player = createDefaultVoiceMessagePlayer()
+ player.state.test {
+ skipItems(1) // skip initial state.
+ Truth.assertThat(player.play().isSuccess).isTrue()
+ skipItems(1) // skip play state
+ player.pause()
+ awaitItem().let {
+ Truth.assertThat(it.isPlaying).isEqualTo(false)
+ Truth.assertThat(it.isMyMedia).isEqualTo(true)
+ Truth.assertThat(it.currentPosition).isEqualTo(1000)
+ }
+ }
+ }
+
+ @Test
+ fun `play after pause works`() = runTest {
+ val player = createDefaultVoiceMessagePlayer()
+ player.state.test {
+ skipItems(1) // skip initial state.
+ Truth.assertThat(player.play().isSuccess).isTrue()
+ skipItems(1) // skip play state
+ player.pause()
+ skipItems(1)
+ player.play()
+ awaitItem().let {
+ Truth.assertThat(it.isPlaying).isEqualTo(true)
+ Truth.assertThat(it.isMyMedia).isEqualTo(true)
+ Truth.assertThat(it.currentPosition).isEqualTo(2000)
+ }
+ }
+ }
+
+ @Test
+ fun `seek to works`() = runTest {
+ val player = createDefaultVoiceMessagePlayer()
+ player.state.test {
+ skipItems(1) // skip initial state.
+ Truth.assertThat(player.play().isSuccess).isTrue()
+ skipItems(1) // skip play state
+ player.seekTo(2000)
+ awaitItem().let {
+ Truth.assertThat(it.isPlaying).isEqualTo(true)
+ Truth.assertThat(it.isMyMedia).isEqualTo(true)
+ Truth.assertThat(it.currentPosition).isEqualTo(2000)
+ }
+ }
+ }
+}
+
+private fun createDefaultVoiceMessagePlayer(
+ mediaPlayer: MediaPlayer = FakeMediaPlayer(),
+ voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
+ eventId: EventId? = AN_EVENT_ID,
+) = DefaultVoiceMessagePlayer(
+ mediaPlayer = mediaPlayer,
+ voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
+ eventId = eventId,
+ mediaSource = MediaSource(
+ url = MXC_URI,
+ json = null
+ ),
+ mimeType = "audio/ogg",
+ body = "someBody.ogg"
+)
+
+private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageMediaRepo.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageMediaRepo.kt
new file mode 100644
index 0000000000..198b65e445
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/FakeVoiceMessageMediaRepo.kt
@@ -0,0 +1,37 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.voicemessages.timeline
+
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
+import io.element.android.tests.testutils.simulateLongTask
+import java.io.File
+
+/**
+ * A fake implementation of [VoiceMessageMediaRepo] for testing purposes.
+ */
+class FakeVoiceMessageMediaRepo : VoiceMessageMediaRepo {
+
+ var shouldFail = false
+
+ override suspend fun getMediaFile(): Result = simulateLongTask {
+ if (shouldFail) {
+ Result.failure(IllegalStateException("Failed to get media file"))
+ } else {
+ Result.success(File(""))
+ }
+ }
+}
diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt
new file mode 100644
index 0000000000..257174ec8d
--- /dev/null
+++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/voicemessages/timeline/VoiceMessagePresenterTest.kt
@@ -0,0 +1,216 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.messages.voicemessages.timeline
+
+import app.cash.molecule.RecompositionMode
+import app.cash.molecule.moleculeFlow
+import app.cash.turbine.test
+import com.google.common.truth.Truth
+import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
+import io.element.android.features.messages.impl.timeline.model.event.aTimelineItemVoiceContent
+import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
+import io.element.android.features.messages.impl.voicemessages.timeline.DefaultVoiceMessagePlayer
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageEvents
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageMediaRepo
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessagePresenter
+import io.element.android.features.messages.impl.voicemessages.timeline.VoiceMessageState
+import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
+import io.element.android.services.analytics.api.AnalyticsService
+import io.element.android.services.analytics.test.FakeAnalyticsService
+import kotlinx.coroutines.test.runTest
+import org.junit.Test
+
+class VoiceMessagePresenterTest {
+ @Test
+ fun `initial state has proper default values`() = runTest {
+ val presenter = createVoiceMessagePresenter()
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().let {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("1:01")
+ }
+ }
+ }
+
+ @Test
+ fun `pressing play downloads and plays`() = runTest {
+ val presenter = createVoiceMessagePresenter(
+ content = aTimelineItemVoiceContent(durationMs = 2_000),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:02")
+ }
+
+ initialState.eventSink(VoiceMessageEvents.PlayPause)
+
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:02")
+ }
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
+ Truth.assertThat(it.progress).isEqualTo(0.5f)
+ Truth.assertThat(it.time).isEqualTo("0:01")
+ }
+ }
+ }
+
+ @Test
+ fun `pressing play downloads and fails`() = runTest {
+ val analyticsService = FakeAnalyticsService()
+ val presenter = createVoiceMessagePresenter(
+ voiceMessageMediaRepo = FakeVoiceMessageMediaRepo().apply { shouldFail = true },
+ analyticsService = analyticsService,
+ content = aTimelineItemVoiceContent(durationMs = 2_000),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:02")
+ }
+
+ initialState.eventSink(VoiceMessageEvents.PlayPause)
+
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:02")
+ }
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Retry)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:02")
+ }
+ analyticsService.trackedErrors.first().also {
+ Truth.assertThat(it).isInstanceOf(VoiceMessageException.PlayMessageError::class.java)
+ }
+ }
+ }
+
+ @Test
+ fun `pressing pause while playing pauses`() = runTest {
+ val presenter = createVoiceMessagePresenter(
+ content = aTimelineItemVoiceContent(durationMs = 2_000),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:02")
+ }
+
+ initialState.eventSink(VoiceMessageEvents.PlayPause)
+ skipItems(1) // skip downloading state
+
+ val playingState = awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
+ Truth.assertThat(it.progress).isEqualTo(0.5f)
+ Truth.assertThat(it.time).isEqualTo("0:01")
+ }
+
+ playingState.eventSink(VoiceMessageEvents.PlayPause)
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
+ Truth.assertThat(it.progress).isEqualTo(0.5f)
+ Truth.assertThat(it.time).isEqualTo("0:01")
+ }
+ }
+ }
+
+ @Test
+ fun `content with null eventId shows disabled button`() = runTest {
+ val presenter = createVoiceMessagePresenter(
+ content = aTimelineItemVoiceContent(eventId = null),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Disabled)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("1:01")
+ }
+ }
+ }
+
+ @Test
+ fun `seeking seeks`() = runTest {
+ val presenter = createVoiceMessagePresenter(
+ content = aTimelineItemVoiceContent(durationMs = 10_000),
+ )
+ moleculeFlow(RecompositionMode.Immediate) {
+ presenter.present()
+ }.test {
+ val initialState = awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Play)
+ Truth.assertThat(it.progress).isEqualTo(0f)
+ Truth.assertThat(it.time).isEqualTo("0:10")
+ }
+
+ initialState.eventSink(VoiceMessageEvents.PlayPause)
+
+ skipItems(1) // skip downloading state
+
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
+ Truth.assertThat(it.progress).isEqualTo(0.1f)
+ Truth.assertThat(it.time).isEqualTo("0:01")
+ }
+
+ initialState.eventSink(VoiceMessageEvents.Seek(0.5f))
+
+ awaitItem().also {
+ Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
+ Truth.assertThat(it.progress).isEqualTo(0.5f)
+ Truth.assertThat(it.time).isEqualTo("0:05")
+ }
+ }
+ }
+}
+
+fun createVoiceMessagePresenter(
+ voiceMessageMediaRepo: VoiceMessageMediaRepo = FakeVoiceMessageMediaRepo(),
+ analyticsService: AnalyticsService = FakeAnalyticsService(),
+ content: TimelineItemVoiceContent = aTimelineItemVoiceContent(),
+) = VoiceMessagePresenter(
+ voiceMessagePlayerFactory = { eventId, mediaSource, mimeType, body ->
+ DefaultVoiceMessagePlayer(
+ mediaPlayer = FakeMediaPlayer(),
+ voiceMessageMediaRepoFactory = { _, _, _ -> voiceMessageMediaRepo },
+ eventId = eventId,
+ mediaSource = mediaSource,
+ mimeType = mimeType,
+ body = body
+ )
+ },
+ analyticsService = analyticsService,
+ content = content,
+)
diff --git a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt
index 75c992f495..03af64e071 100644
--- a/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt
+++ b/features/messages/test/src/main/kotlin/io/element/android/features/messages/test/MessageComposerContextFake.kt
@@ -17,8 +17,8 @@
package io.element.android.features.messages.test
import io.element.android.features.messages.api.MessageComposerContext
-import io.element.android.libraries.textcomposer.MessageComposerMode
+import io.element.android.libraries.textcomposer.model.MessageComposerMode
class MessageComposerContextFake(
- override var composerMode: MessageComposerMode = MessageComposerMode.Normal(null)
+ override var composerMode: MessageComposerMode = MessageComposerMode.Normal
) : MessageComposerContext
diff --git a/features/onboarding/impl/src/main/res/values-cs/translations.xml b/features/onboarding/impl/src/main/res/values-cs/translations.xml
index 6b8f0eaa91..de8b00cd70 100644
--- a/features/onboarding/impl/src/main/res/values-cs/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-cs/translations.xml
@@ -3,7 +3,6 @@
"Ruční přihlášení"
"Přihlásit se pomocí QR kódu"
"Vytvořit účet"
- "Komunikujte a spolupracujte bezpečně"
"Vítejte u dosud nejrychlejšího Elementu. Vylepšený pro rychlost a jednoduchost."
"Vítejte v %1$s. Vylepšený, pro rychlost a jednoduchost."
"Buďte ve svém živlu"
diff --git a/features/onboarding/impl/src/main/res/values-de/translations.xml b/features/onboarding/impl/src/main/res/values-de/translations.xml
index b7f231a32a..ec802a1ba1 100644
--- a/features/onboarding/impl/src/main/res/values-de/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-de/translations.xml
@@ -3,7 +3,6 @@
"Manuell anmelden"
"Mit QR-Code anmelden"
"Konto erstellen"
- "Sicher kommunizieren und zusammenarbeiten"
"Willkommen beim schnellsten Element aller Zeiten. Optimiert für Geschwindigkeit und Einfachheit."
"Willkommen zu %1$s. Aufgeladen, für Geschwindigkeit und Einfachheit."
"Sei in deinem Element"
diff --git a/features/onboarding/impl/src/main/res/values-fr/translations.xml b/features/onboarding/impl/src/main/res/values-fr/translations.xml
index 789b29c5b8..94b9461a88 100644
--- a/features/onboarding/impl/src/main/res/values-fr/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-fr/translations.xml
@@ -3,7 +3,6 @@
"Se connecter manuellement"
"Se connecter avec un QR code"
"Créer un compte"
- "Communiquez et collaborez en toute sécurité"
"Bienvenue dans l’Element le plus rapide de tous les temps. Boosté pour plus de rapidité et de simplicité."
"Bienvenue sur %1$s. Boosté, pour plus de rapidité et de simplicité."
"Soyez dans votre Element"
diff --git a/features/onboarding/impl/src/main/res/values-ro/translations.xml b/features/onboarding/impl/src/main/res/values-ro/translations.xml
index 3572d3a47f..b6db94d213 100644
--- a/features/onboarding/impl/src/main/res/values-ro/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-ro/translations.xml
@@ -3,7 +3,6 @@
"Conectați-vă manual"
"Conectați-vă cu un cod QR"
"Creați un cont"
- "Comunicați și colaborați în siguranță"
"Bine ați venit la cel mai rapid Element din toate timpurile. Supraalimentat pentru viteză și simplitate."
"Bun venit în %1$s. Supraalimentat, pentru viteză și simplitate."
"Fii în Elementul tău"
diff --git a/features/onboarding/impl/src/main/res/values-ru/translations.xml b/features/onboarding/impl/src/main/res/values-ru/translations.xml
index 21bc36c78a..31d09cd396 100644
--- a/features/onboarding/impl/src/main/res/values-ru/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-ru/translations.xml
@@ -3,7 +3,6 @@
"Вход в систему вручную"
"Войти с помощью QR-кода"
"Создать учетную запись"
- "Безопасное общение и совместная работа"
"Добро пожаловать в самый быстрый Element. Преимущество в скорости и простоте."
"Добро пожаловать в %1$s. Supercharged — это скорость и простота."
"Будь c element"
diff --git a/features/onboarding/impl/src/main/res/values-sk/translations.xml b/features/onboarding/impl/src/main/res/values-sk/translations.xml
index b41e671956..9639f7c903 100644
--- a/features/onboarding/impl/src/main/res/values-sk/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-sk/translations.xml
@@ -3,7 +3,6 @@
"Prihlásiť sa manuálne"
"Prihlásiť sa pomocou QR kódu"
"Vytvoriť účet"
- "Komunikujte a spolupracujte bezpečne"
"Vitajte v najrýchlejšom Element vôbec. Nadupaný pre rýchlosť a jednoduchosť."
"Vitajte v %1$s. Nadupaný, pre rýchlosť a jednoduchosť."
"Buďte vo svojom elemente"
diff --git a/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml b/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml
index 64ab9f57b3..22c9d70004 100644
--- a/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml
+++ b/features/onboarding/impl/src/main/res/values-zh-rTW/translations.xml
@@ -3,7 +3,6 @@
"手動登入"
"使用 QR code 登入"
"建立帳號"
- "安全地通訊與協作"
"歡迎使用有史以來最快的 Element。速度超快,操作簡便。"
"Be in your element"
diff --git a/features/onboarding/impl/src/main/res/values/localazy.xml b/features/onboarding/impl/src/main/res/values/localazy.xml
index cdb258cdad..2e8d8724d4 100644
--- a/features/onboarding/impl/src/main/res/values/localazy.xml
+++ b/features/onboarding/impl/src/main/res/values/localazy.xml
@@ -3,7 +3,6 @@
"Sign in manually"
"Sign in with QR code"
"Create account"
- "Communicate and collaborate securely"
"Welcome to the fastest Element ever. Supercharged for speed and simplicity."
"Welcome to %1$s. Supercharged, for speed and simplicity."
"Be in your element"
diff --git a/features/preferences/api/build.gradle.kts b/features/preferences/api/build.gradle.kts
index c20fe9aabb..0278385ab3 100644
--- a/features/preferences/api/build.gradle.kts
+++ b/features/preferences/api/build.gradle.kts
@@ -15,6 +15,7 @@
*/
plugins {
id("io.element.android-library")
+ id("kotlin-parcelize")
}
android {
diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
index 3d1a516593..9d087e254e 100644
--- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
+++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt
@@ -16,16 +16,30 @@
package io.element.android.features.preferences.api
+import android.os.Parcelable
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
+import io.element.android.libraries.architecture.NodeInputs
+import io.element.android.libraries.matrix.api.core.RoomId
+import kotlinx.parcelize.Parcelize
interface PreferencesEntryPoint : FeatureEntryPoint {
+ sealed interface InitialTarget : Parcelable {
+ @Parcelize
+ data object Root : InitialTarget
+ @Parcelize
+ data object NotificationSettings : InitialTarget
+ }
+
+ data class Params(val initialElement: InitialTarget) : NodeInputs
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder {
+
+ fun params(params: Params): NodeBuilder
fun callback(callback: Callback): NodeBuilder
fun build(): Node
}
@@ -33,5 +47,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun onOpenBugReport()
fun onVerifyClicked()
+ fun onSecureBackupClicked()
+ fun onOpenRoomNotificationSettings(roomId: RoomId)
}
}
diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts
index a227d24b8b..406d6a2ff4 100644
--- a/features/preferences/impl/build.gradle.kts
+++ b/features/preferences/impl/build.gradle.kts
@@ -33,6 +33,7 @@ dependencies {
implementation(projects.anvilannotations)
anvil(projects.anvilcodegen)
implementation(projects.libraries.androidutils)
+ implementation(projects.appconfig)
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
@@ -41,6 +42,7 @@ dependencies {
implementation(projects.libraries.featureflag.ui)
implementation(projects.libraries.network)
implementation(projects.libraries.pushstore.api)
+ implementation(projects.libraries.indicator.api)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings)
@@ -49,6 +51,7 @@ dependencies {
implementation(projects.libraries.mediaupload.api)
implementation(projects.libraries.permissions.api)
implementation(projects.features.rageshake.api)
+ implementation(projects.features.lockscreen.api)
implementation(projects.features.analytics.api)
implementation(projects.features.ftue.api)
implementation(projects.features.logout.api)
@@ -76,6 +79,7 @@ dependencies {
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl)
+ testImplementation(projects.libraries.indicator.impl)
testImplementation(projects.features.logout.impl)
testImplementation(projects.services.analytics.test)
testImplementation(projects.features.analytics.impl)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt
index aa286394dc..e551d9d8dc 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPoint.kt
@@ -31,6 +31,11 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
return object : PreferencesEntryPoint.NodeBuilder {
val plugins = ArrayList()
+ override fun params(params: PreferencesEntryPoint.Params): PreferencesEntryPoint.NodeBuilder {
+ plugins += params
+ return this
+ }
+
override fun callback(callback: PreferencesEntryPoint.Callback): PreferencesEntryPoint.NodeBuilder {
plugins += callback
return this
@@ -42,3 +47,8 @@ class DefaultPreferencesEntryPoint @Inject constructor() : PreferencesEntryPoint
}
}
}
+
+internal fun PreferencesEntryPoint.InitialTarget.toNavTarget() = when (this) {
+ is PreferencesEntryPoint.InitialTarget.Root -> PreferencesFlowNode.NavTarget.Root
+ is PreferencesEntryPoint.InitialTarget.NotificationSettings -> PreferencesFlowNode.NavTarget.NotificationSettings
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
index 6cf0390db2..bd7d961123 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt
@@ -29,6 +29,8 @@ 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.lockscreen.api.LockScreenEntryPoint
+import io.element.android.features.logout.api.LogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.features.preferences.impl.about.AboutNode
import io.element.android.features.preferences.impl.advanced.AdvancedSettingsNode
@@ -43,6 +45,7 @@ import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.parcelize.Parcelize
@@ -50,9 +53,11 @@ import kotlinx.parcelize.Parcelize
class PreferencesFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext,
@Assisted plugins: List,
+ private val lockScreenEntryPoint: LockScreenEntryPoint,
+ private val logoutEntryPoint: LogoutEntryPoint,
) : BackstackNode(
backstack = BackStack(
- initialElement = NavTarget.Root,
+ initialElement = plugins.filterIsInstance().first().initialElement.toNavTarget(),
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
@@ -81,11 +86,17 @@ class PreferencesFlowNode @AssistedInject constructor(
@Parcelize
data object NotificationSettings : NavTarget
+ @Parcelize
+ data object LockScreenSettings : NavTarget
+
@Parcelize
data class EditDefaultNotificationSetting(val isOneToOne: Boolean) : NavTarget
@Parcelize
data class UserProfile(val matrixUser: MatrixUser) : NavTarget
+
+ @Parcelize
+ data object SignOut : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -100,6 +111,10 @@ class PreferencesFlowNode @AssistedInject constructor(
plugins().forEach { it.onVerifyClicked() }
}
+ override fun onSecureBackupClicked() {
+ plugins().forEach { it.onSecureBackupClicked() }
+ }
+
override fun onOpenAnalytics() {
backstack.push(NavTarget.AnalyticsSettings)
}
@@ -116,6 +131,10 @@ class PreferencesFlowNode @AssistedInject constructor(
backstack.push(NavTarget.NotificationSettings)
}
+ override fun onOpenLockScreenSettings() {
+ backstack.push(NavTarget.LockScreenSettings)
+ }
+
override fun onOpenAdvancedSettings() {
backstack.push(NavTarget.AdvancedSettings)
}
@@ -123,6 +142,10 @@ class PreferencesFlowNode @AssistedInject constructor(
override fun onOpenUserProfile(matrixUser: MatrixUser) {
backstack.push(NavTarget.UserProfile(matrixUser))
}
+
+ override fun onSignOutClicked() {
+ backstack.push(NavTarget.SignOut)
+ }
}
createNode(buildContext, plugins = listOf(callback))
}
@@ -152,8 +175,13 @@ class PreferencesFlowNode @AssistedInject constructor(
createNode(buildContext, listOf(notificationSettingsCallback))
}
is NavTarget.EditDefaultNotificationSetting -> {
+ val callback = object : EditDefaultNotificationSettingNode.Callback {
+ override fun openRoomNotificationSettings(roomId: RoomId) {
+ plugins().forEach { it.onOpenRoomNotificationSettings(roomId) }
+ }
+ }
val input = EditDefaultNotificationSettingNode.Inputs(navTarget.isOneToOne)
- createNode(buildContext, plugins = listOf(input))
+ createNode(buildContext, plugins = listOf(input, callback))
}
NavTarget.AdvancedSettings -> {
createNode(buildContext)
@@ -162,6 +190,21 @@ class PreferencesFlowNode @AssistedInject constructor(
val inputs = EditUserProfileNode.Inputs(navTarget.matrixUser)
createNode(buildContext, listOf(inputs))
}
+ NavTarget.LockScreenSettings -> {
+ lockScreenEntryPoint.nodeBuilder(this, buildContext)
+ .target(LockScreenEntryPoint.Target.Settings)
+ .build()
+ }
+ NavTarget.SignOut -> {
+ val callBack: LogoutEntryPoint.Callback = object : LogoutEntryPoint.Callback {
+ override fun onChangeRecoveryKeyClicked() {
+ plugins().forEach { it.onSecureBackupClicked() }
+ }
+ }
+ logoutEntryPoint.nodeBuilder(this, buildContext)
+ .callback(callBack)
+ .build()
+ }
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt
index 37641d684c..fea42baf5f 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt
@@ -19,4 +19,5 @@ package io.element.android.features.preferences.impl.advanced
sealed interface AdvancedSettingsEvents {
data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents
data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents
+ data class SetCustomElementCallBaseUrl(val baseUrl: String?) : AdvancedSettingsEvents
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
index 5738fe43c8..4bb5abfa19 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt
@@ -17,16 +17,25 @@
package io.element.android.features.preferences.impl.advanced
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
+import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
+import io.element.android.appconfig.ElementCallConfig
import io.element.android.features.preferences.api.store.PreferencesStore
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.featureflag.api.FeatureFlagService
+import io.element.android.libraries.featureflag.api.FeatureFlags
import kotlinx.coroutines.launch
+import java.net.URL
import javax.inject.Inject
class AdvancedSettingsPresenter @Inject constructor(
private val preferencesStore: PreferencesStore,
+ private val featureFlagService: FeatureFlagService,
) : Presenter {
@Composable
@@ -38,6 +47,14 @@ class AdvancedSettingsPresenter @Inject constructor(
val isDeveloperModeEnabled by preferencesStore
.isDeveloperModeEnabledFlow()
.collectAsState(initial = false)
+ val customElementCallBaseUrl by preferencesStore
+ .getCustomElementCallBaseUrlFlow()
+ .collectAsState(initial = null)
+
+ var canDisplayElementCallSettings by remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ canDisplayElementCallSettings = featureFlagService.isFeatureEnabled(FeatureFlags.InRoomCalls)
+ }
fun handleEvents(event: AdvancedSettingsEvents) {
when (event) {
@@ -47,13 +64,34 @@ class AdvancedSettingsPresenter @Inject constructor(
is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch {
preferencesStore.setDeveloperModeEnabled(event.enabled)
}
+ is AdvancedSettingsEvents.SetCustomElementCallBaseUrl -> localCoroutineScope.launch {
+ // If the URL is either empty or the default one, we want to save 'null' to remove the custom URL
+ val urlToSave = event.baseUrl.takeIf { !it.isNullOrEmpty() && it != ElementCallConfig.DEFAULT_BASE_URL }
+ preferencesStore.setCustomElementCallBaseUrl(urlToSave)
+ }
}
}
return AdvancedSettingsState(
isRichTextEditorEnabled = isRichTextEditorEnabled,
isDeveloperModeEnabled = isDeveloperModeEnabled,
- eventSink = ::handleEvents
+ customElementCallBaseUrlState = if (canDisplayElementCallSettings) {
+ CustomElementCallBaseUrlState(
+ baseUrl = customElementCallBaseUrl,
+ defaultUrl = ElementCallConfig.DEFAULT_BASE_URL,
+ validator = ::customElementCallUrlValidator,
+ )
+ } else null,
+ eventSink = { handleEvents(it) }
)
}
+
+ private fun customElementCallUrlValidator(url: String?): Boolean {
+ return runCatching {
+ if (url.isNullOrEmpty()) return@runCatching
+ val parsedUrl = URL(url)
+ if (parsedUrl.protocol !in listOf("http", "https")) error("Incorrect protocol")
+ if (parsedUrl.host.isNullOrBlank()) error("Missing host")
+ }.isSuccess
+ }
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt
index 19625b9ebc..cd56078b27 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt
@@ -16,8 +16,15 @@
package io.element.android.features.preferences.impl.advanced
-data class AdvancedSettingsState constructor(
+data class AdvancedSettingsState(
val isRichTextEditorEnabled: Boolean,
val isDeveloperModeEnabled: Boolean,
+ val customElementCallBaseUrlState: CustomElementCallBaseUrlState?,
val eventSink: (AdvancedSettingsEvents) -> Unit
)
+
+data class CustomElementCallBaseUrlState(
+ val baseUrl: String?,
+ val defaultUrl: String,
+ val validator: (String?) -> Boolean,
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt
index 5ab50c8a16..d3a2dee3f4 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt
@@ -24,14 +24,17 @@ open class AdvancedSettingsStateProvider : PreviewParameterProvider Unit,
modifier: Modifier = Modifier,
) {
+ fun isUsingDefaultUrl(value: String?): Boolean {
+ val defaultUrl = state.customElementCallBaseUrlState?.defaultUrl ?: return false
+ return value.isNullOrEmpty() || value == defaultUrl
+ }
+
PreferencePage(
modifier = modifier,
onBackPressed = onBackPressed,
@@ -50,6 +58,23 @@ fun AdvancedSettingsView(
isChecked = state.isDeveloperModeEnabled,
onCheckedChange = { state.eventSink(AdvancedSettingsEvents.SetDeveloperModeEnabled(it)) },
)
+ state.customElementCallBaseUrlState?.let { callUrlState ->
+ val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) {
+ stringResource(R.string.screen_advanced_settings_element_call_base_url_description)
+ } else {
+ callUrlState.baseUrl
+ }
+ PreferenceTextField(
+ headline = stringResource(R.string.screen_advanced_settings_element_call_base_url),
+ value = callUrlState.baseUrl ?: callUrlState.defaultUrl,
+ supportingText = supportingText,
+ validation = callUrlState.validator,
+ onValidationErrorMessage = stringResource(R.string.screen_advanced_settings_element_call_base_url_validation_error),
+ displayValue = { value -> !isUsingDefaultUrl(value) },
+ keyboardOptions = KeyboardOptions.Default.copy(autoCorrect = false, keyboardType = KeyboardType.Uri),
+ onChange = { state.eventSink(AdvancedSettingsEvents.SetCustomElementCallBaseUrl(it)) }
+ )
+ }
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt
index 374b8078ca..9e87675b3a 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsEvents.kt
@@ -24,4 +24,5 @@ sealed interface NotificationSettingsEvents {
data class SetCallNotificationsEnabled(val enabled: Boolean) : NotificationSettingsEvents
data object FixConfigurationMismatch : NotificationSettingsEvents
data object ClearConfigurationMismatchError : NotificationSettingsEvents
+ data object ClearNotificationChangeError : NotificationSettingsEvents
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
index 697d5887f0..689cca8f66 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt
@@ -23,7 +23,9 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@@ -50,6 +52,7 @@ class NotificationSettingsPresenter @Inject constructor(
val systemNotificationsEnabled: MutableState = remember {
mutableStateOf(systemNotificationsEnabledProvider.notificationsEnabled())
}
+ val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
val localCoroutineScope = rememberCoroutineScope()
val appNotificationsEnabled = userPushStore
@@ -67,8 +70,12 @@ class NotificationSettingsPresenter @Inject constructor(
fun handleEvents(event: NotificationSettingsEvents) {
when (event) {
- is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled)
- is NotificationSettingsEvents.SetCallNotificationsEnabled -> localCoroutineScope.setCallNotificationsEnabled(event.enabled)
+ is NotificationSettingsEvents.SetAtRoomNotificationsEnabled -> {
+ localCoroutineScope.setAtRoomNotificationsEnabled(event.enabled, changeNotificationSettingAction)
+ }
+ is NotificationSettingsEvents.SetCallNotificationsEnabled -> {
+ localCoroutineScope.setCallNotificationsEnabled(event.enabled, changeNotificationSettingAction)
+ }
is NotificationSettingsEvents.SetNotificationsEnabled -> localCoroutineScope.setNotificationsEnabled(userPushStore, event.enabled)
NotificationSettingsEvents.ClearConfigurationMismatchError -> {
matrixSettings.value = NotificationSettingsState.MatrixSettings.Invalid(fixFailed = false)
@@ -77,6 +84,7 @@ class NotificationSettingsPresenter @Inject constructor(
NotificationSettingsEvents.RefreshSystemNotificationsEnabled -> {
systemNotificationsEnabled.value = systemNotificationsEnabledProvider.notificationsEnabled()
}
+ NotificationSettingsEvents.ClearNotificationChangeError -> changeNotificationSettingAction.value = Async.Uninitialized
}
}
@@ -86,6 +94,7 @@ class NotificationSettingsPresenter @Inject constructor(
systemNotificationsEnabled = systemNotificationsEnabled.value,
appNotificationsEnabled = appNotificationsEnabled.value
),
+ changeNotificationSettingAction = changeNotificationSettingAction.value,
eventSink = ::handleEvents
)
}
@@ -154,12 +163,16 @@ class NotificationSettingsPresenter @Inject constructor(
)
}
- private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean) = launch {
- notificationSettingsService.setRoomMentionEnabled(enabled)
+ private fun CoroutineScope.setAtRoomNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch {
+ suspend {
+ notificationSettingsService.setRoomMentionEnabled(enabled).getOrThrow()
+ }.runCatchingUpdatingState(action)
}
- private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean) = launch {
- notificationSettingsService.setCallEnabled(enabled)
+ private fun CoroutineScope.setCallNotificationsEnabled(enabled: Boolean, action: MutableState>) = launch {
+ suspend {
+ notificationSettingsService.setCallEnabled(enabled).getOrThrow()
+ }.runCatchingUpdatingState(action)
}
private fun CoroutineScope.setNotificationsEnabled(userPushStore: UserPushStore, enabled: Boolean) = launch {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt
index cf3cf6e3d0..2b0faa110c 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsState.kt
@@ -17,12 +17,14 @@
package io.element.android.features.preferences.impl.notifications
import androidx.compose.runtime.Immutable
+import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
@Immutable
data class NotificationSettingsState(
val matrixSettings: MatrixSettings,
val appSettings: AppSettings,
+ val changeNotificationSettingAction: Async,
val eventSink: (NotificationSettingsEvents) -> Unit,
) {
sealed interface MatrixSettings {
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
index 1e653c47e0..9336857585 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsStateProvider.kt
@@ -17,16 +17,21 @@
package io.element.android.features.preferences.impl.notifications
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
open class NotificationSettingsStateProvider : PreviewParameterProvider {
override val values: Sequence
get() = sequenceOf(
aNotificationSettingsState(),
+ aNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)),
+ aNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))),
)
}
-fun aNotificationSettingsState() = NotificationSettingsState(
+fun aNotificationSettingsState(
+ changeNotificationSettingAction: Async = Async.Uninitialized,
+) = NotificationSettingsState(
matrixSettings = NotificationSettingsState.MatrixSettings.Valid(
atRoomNotificationsEnabled = true,
callNotificationsEnabled = true,
@@ -37,5 +42,6 @@ fun aNotificationSettingsState() = NotificationSettingsState(
systemNotificationsEnabled = false,
appNotificationsEnabled = true,
),
+ changeNotificationSettingAction = changeNotificationSettingAction,
eventSink = {}
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
index 0844480697..a1d248c1fd 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt
@@ -16,39 +16,27 @@
package io.element.android.features.preferences.impl.notifications
-import androidx.compose.foundation.layout.Box
-import androidx.compose.foundation.layout.Column
-import androidx.compose.foundation.layout.Row
-import androidx.compose.foundation.layout.Spacer
-import androidx.compose.foundation.layout.fillMaxWidth
-import androidx.compose.foundation.layout.height
-import androidx.compose.foundation.layout.padding
-import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
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 androidx.lifecycle.Lifecycle
import io.element.android.libraries.androidutils.system.startNotificationSettingsIntent
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.atomic.molecules.DialogLikeBannerMolecule
+import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.components.preferences.PreferencePage
import io.element.android.libraries.designsystem.components.preferences.PreferenceSwitch
import io.element.android.libraries.designsystem.components.preferences.PreferenceText
-import io.element.android.libraries.designsystem.components.preferences.PreferencePage
-import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview
-import io.element.android.libraries.designsystem.theme.components.Button
-import io.element.android.libraries.designsystem.theme.components.ButtonSize
-import io.element.android.libraries.designsystem.theme.components.Surface
-import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
-import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings
/**
@@ -87,9 +75,23 @@ fun NotificationSettingsView(
onGroupChatsClicked = { onOpenEditDefault(false) },
onDirectChatsClicked = { onOpenEditDefault(true) },
onMentionNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetAtRoomNotificationsEnabled(it)) },
+ // TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged = { state.eventSink(NotificationSettingsEvents.SetCallNotificationsEnabled(it)) },
)
}
+ when (state.changeNotificationSettingAction) {
+ is Async.Loading -> {
+ ProgressDialog()
+ }
+ is Async.Failure -> {
+ ErrorDialog(
+ title = stringResource(CommonStrings.dialog_title_error),
+ content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
+ onDismiss = { state.eventSink(NotificationSettingsEvents.ClearNotificationChangeError) },
+ )
+ }
+ else -> Unit
+ }
}
}
@@ -101,6 +103,7 @@ private fun NotificationSettingsContentView(
onGroupChatsClicked: () -> Unit,
onDirectChatsClicked: () -> Unit,
onMentionNotificationsChanged: (Boolean) -> Unit,
+ // TODO We are removing the call notification toggle until support for call notifications has been added
// onCallsNotificationsChanged: (Boolean) -> Unit,
modifier: Modifier = Modifier,
) {
@@ -151,7 +154,7 @@ private fun NotificationSettingsContentView(
onCheckedChange = onMentionNotificationsChanged
)
}
- // We are removing the call notification toggle until call support has been added
+ // TODO We are removing the call notification toggle until support for call notifications has been added
// PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_additional_settings_section_title)) {
// PreferenceSwitch(
// modifier = Modifier,
@@ -180,41 +183,14 @@ private fun InvalidNotificationSettingsView(
onDismissError: () -> Unit,
modifier: Modifier = Modifier
) {
- Box(modifier = modifier.padding(horizontal = 16.dp, vertical = 8.dp)) {
- Surface(
- Modifier.fillMaxWidth(),
- shape = MaterialTheme.shapes.small,
- color = MaterialTheme.colorScheme.surfaceVariant
- ) {
- Column(
- Modifier
- .fillMaxWidth()
- .padding(horizontal = 16.dp, vertical = 12.dp)
- ) {
- Row {
- Text(
- stringResource(CommonStrings.screen_notification_settings_configuration_mismatch),
- modifier = Modifier.weight(1f),
- style = ElementTheme.typography.fontBodyLgMedium,
- color = MaterialTheme.colorScheme.primary,
- textAlign = TextAlign.Start,
- )
- }
- Spacer(modifier = Modifier.height(4.dp))
- Text(
- stringResource(CommonStrings.screen_notification_settings_configuration_mismatch_description),
- style = ElementTheme.typography.fontBodyMdRegular,
- )
- Spacer(modifier = Modifier.height(12.dp))
- Button(
- text = stringResource(CommonStrings.action_continue),
- size = ButtonSize.Medium,
- modifier = Modifier.fillMaxWidth(),
- onClick = onContinueClicked,
- )
- }
- }
- }
+ DialogLikeBannerMolecule(
+ modifier = modifier,
+ title = stringResource(CommonStrings.screen_notification_settings_configuration_mismatch),
+ content = stringResource(CommonStrings.screen_notification_settings_configuration_mismatch_description),
+ onSubmitClicked = onContinueClicked,
+ onDismissClicked = null,
+ )
+
if (showError) {
ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error),
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt
index 6c4fd646f4..535203e35e 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingNode.kt
@@ -21,12 +21,14 @@ 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 com.bumble.appyx.core.plugin.plugins
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.di.SessionScope
+import io.element.android.libraries.matrix.api.core.RoomId
@ContributesNode(SessionScope::class)
class EditDefaultNotificationSettingNode @AssistedInject constructor(
@@ -35,20 +37,30 @@ class EditDefaultNotificationSettingNode @AssistedInject constructor(
presenterFactory: EditDefaultNotificationSettingPresenter.Factory
) : Node(buildContext, plugins = plugins) {
+ interface Callback : Plugin {
+ fun openRoomNotificationSettings(roomId: RoomId)
+ }
+
data class Inputs(
val isOneToOne: Boolean
) : NodeInputs
private val inputs = inputs()
+ private val callbacks = plugins()
private val presenter = presenterFactory.create(inputs.isOneToOne)
+ private fun openRoomNotificationSettings(roomId: RoomId) {
+ callbacks.forEach { it.openRoomNotificationSettings(roomId) }
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
EditDefaultNotificationSettingView(
state = state,
+ openRoomNotificationSettings = { openRoomNotificationSettings(it) },
onBackPressed = ::navigateUp,
- modifier = modifier
+ modifier = modifier,
)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
index 764b37c52d..79201e27d3 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingPresenter.kt
@@ -25,20 +25,28 @@ import androidx.compose.runtime.rememberCoroutineScope
import dagger.assisted.Assisted
import dagger.assisted.AssistedFactory
import dagger.assisted.AssistedInject
+import io.element.android.libraries.architecture.Async
import io.element.android.libraries.architecture.Presenter
+import io.element.android.libraries.architecture.runCatchingUpdatingState
+import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.notificationsettings.NotificationSettingsService
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.roomlist.RoomListService
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
+import java.text.Collator
import kotlin.time.Duration.Companion.seconds
class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
private val notificationSettingsService: NotificationSettingsService,
@Assisted private val isOneToOne: Boolean,
+ private val roomListService: RoomListService,
+ private val matrixClient: MatrixClient,
) : Presenter {
@AssistedFactory
interface Factory {
@@ -50,21 +58,34 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
val mode: MutableState = remember {
mutableStateOf(null)
}
+
+ val changeNotificationSettingAction: MutableState> = remember { mutableStateOf(Async.Uninitialized) }
+
+ val roomsWithUserDefinedMode: MutableState> = remember {
+ mutableStateOf(listOf())
+ }
+
val localCoroutineScope = rememberCoroutineScope()
LaunchedEffect(Unit) {
fetchSettings(mode)
observeNotificationSettings(mode)
+ observeRoomSummaries(roomsWithUserDefinedMode)
}
fun handleEvents(event: EditDefaultNotificationSettingStateEvents) {
when (event) {
- is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> localCoroutineScope.setDefaultNotificationMode(event.mode)
+ is EditDefaultNotificationSettingStateEvents.SetNotificationMode -> {
+ localCoroutineScope.setDefaultNotificationMode(event.mode, changeNotificationSettingAction)
+ }
+ EditDefaultNotificationSettingStateEvents.ClearError -> changeNotificationSettingAction.value = Async.Uninitialized
}
}
return EditDefaultNotificationSettingState(
isOneToOne = isOneToOne,
mode = mode.value,
+ roomsWithUserDefinedMode = roomsWithUserDefinedMode.value,
+ changeNotificationSettingAction = changeNotificationSettingAction.value,
eventSink = ::handleEvents
)
}
@@ -83,10 +104,39 @@ class EditDefaultNotificationSettingPresenter @AssistedInject constructor(
.launchIn(this)
}
- private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode) = launch {
- // On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did).
- notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne)
- notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne)
+ private fun CoroutineScope.observeRoomSummaries(roomsWithUserDefinedMode: MutableState>) {
+ roomListService.allRooms()
+ .summaries
+ .onEach {
+ updateRoomsWithUserDefinedMode(it, roomsWithUserDefinedMode)
+ }
+ .launchIn(this)
+ }
+
+ private fun CoroutineScope.updateRoomsWithUserDefinedMode(
+ summaries: List,
+ roomsWithUserDefinedMode: MutableState>
+ ) = launch {
+ val roomWithUserDefinedRules: Set = notificationSettingsService.getRoomsWithUserDefinedRules().getOrThrow().toSet()
+
+ val sortedSummaries = summaries
+ .filterIsInstance()
+ .filter {
+ val room = matrixClient.getRoom(it.details.roomId) ?: return@filter false
+ roomWithUserDefinedRules.contains(it.identifier()) && isOneToOne == room.isOneToOne
+ }
+ // locale sensitive sorting
+ .sortedWith(compareBy(Collator.getInstance()){ it.details.name })
+
+ roomsWithUserDefinedMode.value = sortedSummaries
+ }
+
+ private fun CoroutineScope.setDefaultNotificationMode(mode: RoomNotificationMode, action: MutableState>) = launch {
+ suspend {
+ // On modern clients, we don't have different settings for encrypted and non-encrypted rooms (Legacy clients did).
+ notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = true, mode = mode, isOneToOne = isOneToOne).getOrThrow()
+ notificationSettingsService.setDefaultRoomNotificationMode(isEncrypted = false, mode = mode, isOneToOne = isOneToOne).getOrThrow()
+ }.runCatchingUpdatingState(action)
}
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
index 62c708d988..e8590ec27f 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingState.kt
@@ -16,10 +16,14 @@
package io.element.android.features.preferences.impl.notifications.edit
+import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
data class EditDefaultNotificationSettingState(
val isOneToOne: Boolean,
val mode: RoomNotificationMode?,
+ val roomsWithUserDefinedMode: List,
+ val changeNotificationSettingAction: Async,
val eventSink: (EditDefaultNotificationSettingStateEvents) -> Unit,
)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt
index 75c9b6c1a4..f5774f1d78 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateEvents.kt
@@ -20,4 +20,5 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationMode
sealed interface EditDefaultNotificationSettingStateEvents {
data class SetNotificationMode(val mode: RoomNotificationMode): EditDefaultNotificationSettingStateEvents
+ data object ClearError: EditDefaultNotificationSettingStateEvents
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
new file mode 100644
index 0000000000..71fbfb1e1b
--- /dev/null
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingStateProvider.kt
@@ -0,0 +1,58 @@
+/*
+ * Copyright (c) 2023 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.
+ */
+
+package io.element.android.features.preferences.impl.notifications.edit
+
+import androidx.compose.ui.tooling.preview.PreviewParameterProvider
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.matrix.api.core.RoomId
+import io.element.android.libraries.matrix.api.room.RoomNotificationMode
+import io.element.android.libraries.matrix.api.roomlist.RoomSummary
+import io.element.android.libraries.matrix.api.roomlist.RoomSummaryDetails
+
+open class EditDefaultNotificationSettingStateProvider: PreviewParameterProvider {
+ override val values: Sequence
+ get() = sequenceOf(
+ anEditDefaultNotificationSettingsState(),
+ anEditDefaultNotificationSettingsState(isOneToOne = true),
+ anEditDefaultNotificationSettingsState(changeNotificationSettingAction = Async.Loading(Unit)),
+ anEditDefaultNotificationSettingsState(changeNotificationSettingAction = Async.Failure(Throwable("error"))),
+ )
+}
+
+private fun anEditDefaultNotificationSettingsState(
+ isOneToOne: Boolean = false,
+ changeNotificationSettingAction: Async = Async.Uninitialized
+) = EditDefaultNotificationSettingState(
+ isOneToOne = isOneToOne,
+ mode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ roomsWithUserDefinedMode = listOf(aRoomSummary()),
+ changeNotificationSettingAction = changeNotificationSettingAction,
+ eventSink = {}
+)
+
+private fun aRoomSummary() = RoomSummary.Filled(
+ RoomSummaryDetails(
+ roomId = RoomId("!roomId:domain"),
+ name = "Room",
+ avatarURLString = null,
+ isDirect = false,
+ lastMessage = null,
+ lastMessageTimestamp = null,
+ unreadNotificationCount = 0,
+ notificationMode = RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY,
+ )
+)
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
index 3c2c27ac5c..94f5a6b053 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt
@@ -21,8 +21,21 @@ import androidx.compose.foundation.selection.selectableGroup
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
+import androidx.compose.ui.tooling.preview.PreviewParameter
+import io.element.android.libraries.architecture.Async
+import io.element.android.libraries.designsystem.components.ProgressDialog
+import io.element.android.libraries.designsystem.components.avatar.Avatar
+import io.element.android.libraries.designsystem.components.avatar.AvatarData
+import io.element.android.libraries.designsystem.components.avatar.AvatarSize
+import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
+import io.element.android.libraries.designsystem.components.list.ListItemContent
import io.element.android.libraries.designsystem.components.preferences.PreferenceCategory
+import io.element.android.libraries.designsystem.preview.ElementPreview
+import io.element.android.libraries.designsystem.theme.components.ListItem
+import io.element.android.libraries.designsystem.theme.components.Text
+import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.designsystem.components.preferences.PreferencePage
+import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
@@ -33,11 +46,12 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun EditDefaultNotificationSettingView(
state: EditDefaultNotificationSettingState,
+ openRoomNotificationSettings:(roomId: RoomId) -> Unit,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier,
) {
- val title = if(state.isOneToOne) {
+ val title = if (state.isOneToOne) {
CommonStrings.screen_notification_settings_direct_chats
} else {
CommonStrings.screen_notification_settings_group_chats
@@ -51,7 +65,7 @@ fun EditDefaultNotificationSettingView(
// Only ALL_MESSAGES and MENTIONS_AND_KEYWORDS_ONLY are valid global defaults.
val validModes = listOf(RoomNotificationMode.ALL_MESSAGES, RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY)
- val categoryTitle = if(state.isOneToOne) {
+ val categoryTitle = if (state.isOneToOne) {
CommonStrings.screen_notification_settings_edit_screen_direct_section_header
} else {
CommonStrings.screen_notification_settings_edit_screen_group_section_header
@@ -70,6 +84,63 @@ fun EditDefaultNotificationSettingView(
}
}
}
+ if (state.roomsWithUserDefinedMode.isNotEmpty()) {
+ PreferenceCategory(title = stringResource(id = CommonStrings.screen_notification_settings_edit_custom_settings_section_title)) {
+ state.roomsWithUserDefinedMode.forEach { summary ->
+ val subtitle = when (summary.details.notificationMode) {
+ RoomNotificationMode.ALL_MESSAGES -> stringResource(id = CommonStrings.screen_notification_settings_edit_mode_all_messages)
+ RoomNotificationMode.MENTIONS_AND_KEYWORDS_ONLY -> {
+ stringResource(id = CommonStrings.screen_notification_settings_edit_mode_mentions_and_keywords)
+ }
+ RoomNotificationMode.MUTE -> stringResource(id = CommonStrings.common_mute)
+ null -> ""
+ }
+ val avatarData = AvatarData(
+ id = summary.identifier(),
+ name = summary.details.name,
+ url = summary.details.avatarURLString,
+ size = AvatarSize.CustomRoomNotificationSetting,
+ )
+ ListItem(
+ headlineContent = {
+ Text(text = summary.details.name)
+ },
+ supportingContent = {
+ Text(text = subtitle)
+ },
+ leadingContent = ListItemContent.Custom {
+ Avatar(avatarData = avatarData)
+ },
+ onClick = {
+ openRoomNotificationSettings(summary.details.roomId)
+ }
+ )
+ }
+ }
+ }
+ when (state.changeNotificationSettingAction) {
+ is Async.Loading -> {
+ ProgressDialog()
+ }
+ is Async.Failure -> {
+ ErrorDialog(
+ title = stringResource(CommonStrings.dialog_title_error),
+ content = stringResource(CommonStrings.screen_notification_settings_edit_failed_updating_default_mode),
+ onDismiss = { state.eventSink(EditDefaultNotificationSettingStateEvents.ClearError) },
+ )
+ }
+ else -> Unit
+ }
}
}
-
+@PreviewsDayNight
+@Composable
+internal fun EditDefaultNotificationSettingViewPreview(
+ @PreviewParameter(EditDefaultNotificationSettingStateProvider::class) state: EditDefaultNotificationSettingState
+) = ElementPreview {
+ EditDefaultNotificationSettingView(
+ state = state,
+ openRoomNotificationSettings = {},
+ onBackPressed = {},
+ )
+}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
index 407832627b..43ceb427b9 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt
@@ -31,7 +31,6 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.theme.ElementTheme
-import timber.log.Timber
@ContributesNode(SessionScope::class)
class PreferencesRootNode @AssistedInject constructor(
@@ -43,12 +42,15 @@ class PreferencesRootNode @AssistedInject constructor(
interface Callback : Plugin {
fun onOpenBugReport()
fun onVerifyClicked()
+ fun onSecureBackupClicked()
fun onOpenAnalytics()
fun onOpenAbout()
fun onOpenDeveloperSettings()
fun onOpenNotificationSettings()
+ fun onOpenLockScreenSettings()
fun onOpenAdvancedSettings()
fun onOpenUserProfile(matrixUser: MatrixUser)
+ fun onSignOutClicked()
}
private fun onOpenBugReport() {
@@ -59,6 +61,10 @@ class PreferencesRootNode @AssistedInject constructor(
plugins().forEach { it.onVerifyClicked() }
}
+ private fun onSecureBackupClicked() {
+ plugins().forEach { it.onSecureBackupClicked() }
+ }
+
private fun onOpenDeveloperSettings() {
plugins().forEach { it.onOpenDeveloperSettings() }
}
@@ -93,10 +99,18 @@ class PreferencesRootNode @AssistedInject constructor(
plugins().forEach { it.onOpenNotificationSettings() }
}
+ private fun onOpenLockScreenSettings() {
+ plugins().forEach { it.onOpenLockScreenSettings() }
+ }
+
private fun onOpenUserProfile(matrixUser: MatrixUser) {
plugins().forEach { it.onOpenUserProfile(matrixUser) }
}
+ private fun onSignOutClicked() {
+ plugins().forEach { it.onSignOutClicked() }
+ }
+
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
@@ -110,19 +124,14 @@ class PreferencesRootNode @AssistedInject constructor(
onOpenAnalytics = this::onOpenAnalytics,
onOpenAbout = this::onOpenAbout,
onVerifyClicked = this::onVerifyClicked,
+ onSecureBackupClicked = this::onSecureBackupClicked,
onOpenDeveloperSettings = this::onOpenDeveloperSettings,
onOpenAdvancedSettings = this::onOpenAdvancedSettings,
- onSuccessLogout = { onSuccessLogout(activity, it) },
onManageAccountClicked = { onManageAccountClicked(activity, it, isDark) },
onOpenNotificationSettings = this::onOpenNotificationSettings,
+ onOpenLockScreenSettings = this::onOpenLockScreenSettings,
onOpenUserProfile = this::onOpenUserProfile,
+ onSignOutClicked = this::onSignOutClicked,
)
}
-
- private fun onSuccessLogout(activity: Activity, url: String?) {
- Timber.d("Success logout with result url: $url")
- url?.let {
- activity.openUrlInChromeCustomTab(null, false, it)
- }
- }
}
diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
index 200785e03d..3295e3a59a 100644
--- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
+++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt
@@ -24,13 +24,13 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.saveable.rememberSaveable
-import io.element.android.features.logout.api.LogoutPreferencePresenter
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags
+import io.element.android.libraries.indicator.api.IndicatorService
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.oidc.AccountManagementAction
import io.element.android.libraries.matrix.api.user.MatrixUser
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
class PreferencesRootPresenter @Inject constructor(
- private val logoutPresenter: LogoutPreferencePresenter,
private val matrixClient: MatrixClient,
private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
@@ -50,6 +49,7 @@ class PreferencesRootPresenter @Inject constructor(
private val versionFormatter: VersionFormatter,
private val snackbarDispatcher: SnackbarDispatcher,
private val featureFlagService: FeatureFlagService,
+ private val indicatorService: IndicatorService,
) : Presenter {
@Composable
@@ -68,10 +68,19 @@ class PreferencesRootPresenter @Inject constructor(
LaunchedEffect(Unit) {
showNotificationSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.NotificationSettings)
}
+ val showLockScreenSettings = remember { mutableStateOf(false) }
+ LaunchedEffect(Unit) {
+ showLockScreenSettings.value = featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
+ }
// We should display the 'complete verification' option if the current session can be verified
val showCompleteVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(false)
+ val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator()
+
+ val secureStorageFlag by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SecureStorage)
+ .collectAsState(initial = null)
+
val accountManagementUrl: MutableState