Merge branch 'release/0.3.1' into main

This commit is contained in:
Benoit Marty 2023-11-09 17:17:26 +01:00
commit 2e46d549d3
1626 changed files with 2917 additions and 1500 deletions

View file

@ -49,8 +49,8 @@ body:
id: version id: version
attributes: attributes:
label: Application version and app store label: Application version and app store
description: You can find the version information in Settings -> Help & About. description: You can find the version information at the bottom of the Settings screen.
placeholder: e.g. Element X version 1.7.34, olm version 3.2.3 from F-Droid placeholder: e.g. Element X version 0.3.0
validations: validations:
required: false required: false
- type: input - type: input
@ -67,7 +67,7 @@ body:
attributes: attributes:
label: Will you send logs? label: Will you send logs?
description: | description: |
Did you know that you can shake your phone to submit logs for this issue? Trigger the defect, then shake your phone and you will see a popup asking if you would like to open the bug report screen. Click YES, and describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers. Trigger the defect, then click on the menu from the room list then "Report a bug". Describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
options: options:
- 'Yes' - 'Yes'
- 'No' - 'No'

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs: jobs:
@ -47,7 +47,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload APK APKs - name: Upload APK APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v3 uses: actions/upload-artifact@v3

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs: jobs:
@ -35,7 +35,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Assemble debug APK - name: Assemble debug APK
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleDebug $CI_GRADLE_ARG_PROPERTIES
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}

View file

@ -7,7 +7,7 @@ on:
- cron: "0 4 * * *" - cron: "0 4 * * *"
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs: jobs:

View file

@ -8,7 +8,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4
jobs: jobs:

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
jobs: jobs:

View file

@ -5,7 +5,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
jobs: jobs:
record: record:

View file

@ -7,7 +7,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
jobs: jobs:

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --no-daemon --warn CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --no-daemon --warn
jobs: jobs:

View file

@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn
jobs: jobs:

2
.idea/kotlinc.xml generated
View file

@ -1,6 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?> <?xml version="1.0" encoding="UTF-8"?>
<project version="4"> <project version="4">
<component name="KotlinJpsPluginSettings"> <component name="KotlinJpsPluginSettings">
<option name="version" value="1.9.10" /> <option name="version" value="1.9.20" />
</component> </component>
</project> </project>

View file

@ -6,9 +6,12 @@ appId: ${APP_ID}
- takeScreenshot: build/maestro/900-SignOutScreen - takeScreenshot: build/maestro/900-SignOutScreen
- back - back
- tapOn: "Sign out" - tapOn: "Sign out"
- tapOn: "Sign out" - tapOn:
id: "sign-out-submit"
# Ensure cancel cancels # Ensure cancel cancels
- tapOn: "Cancel" - tapOn: "Cancel"
- tapOn: "Sign out" - tapOn:
- tapOn: "Sign out anyway" id: "sign-out-submit"
- tapOn:
id: "dialog-positive"
- runFlow: ../assertions/assertInitDisplayed.yaml - runFlow: ../assertions/assertInitDisplayed.yaml

View file

@ -29,5 +29,6 @@ appId: ${APP_ID}
- tapOn: "People" - tapOn: "People"
# assert there's 1 member and 2 invitees # assert there's 1 member and 2 invitees
- tapOn: "Back" - tapOn: "Back"
- scroll
- tapOn: "Leave room" - tapOn: "Leave room"
- tapOn: "Leave" - tapOn: "Leave"

View file

@ -10,7 +10,12 @@ appId: ${APP_ID}
- back - back
- tapOn: - tapOn:
text: "Report bug" text: "Notifications"
- assertVisible: "Enable notifications on this device"
- back
- tapOn:
text: "Report a problem"
- assertVisible: "Report a bug" - assertVisible: "Report a bug"
- back - back
@ -21,6 +26,17 @@ appId: ${APP_ID}
- assertVisible: "Privacy policy" - assertVisible: "Privacy policy"
- back - back
- tapOn:
text: "Screen lock"
- assertVisible: "Choose PIN"
- hideKeyboard
- back
- tapOn:
text: "Advanced settings"
- assertVisible: "Rich text editor"
- back
- tapOn: - tapOn:
text: "Developer options" text: "Developer options"
- assertVisible: "Feature flags" - assertVisible: "Feature flags"

View file

@ -1,3 +1,20 @@
Changes in Element X v0.3.1 (2023-11-09)
========================================
Features ✨
----------
- Chat backup is still under a feature flag, but when enabled, user can enter their recovery key (it's also possible to input a passphrase) to unlock the encrypted room history. ([#1770](https://github.com/vector-im/element-x-android/pull/1770))
Bugfixes 🐛
----------
- Improve confusing text in the 'ready to start verification' screen. ([#879](https://github.com/vector-im/element-x-android/issues/879))
- Message composer wasn't resized when selecting a several lines message to reply to, then a single line one. ([#1560](https://github.com/vector-im/element-x-android/issues/1560))
Other changes
-------------
- PIN: Set lock grace period to 0. ([#1732](https://github.com/vector-im/element-x-android/issues/1732))
Changes in Element X v0.3.0 (2023-10-31) Changes in Element X v0.3.0 (2023-10-31)
======================================== ========================================

View file

@ -32,6 +32,7 @@ import androidx.core.view.WindowCompat
import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integration.NodeHost
import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.integrationpoint.NodeActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.features.lockscreen.api.handleSecureFlag
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
@ -53,6 +54,7 @@ class MainActivity : NodeActivity() {
installSplashScreen() installSplashScreen()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
appBindings = bindings() appBindings = bindings()
appBindings.lockScreenService().handleSecureFlag(this)
WindowCompat.setDecorFitsSystemWindows(window, false) WindowCompat.setDecorFitsSystemWindows(window, false)
setContent { setContent {
MainContent(appBindings) MainContent(appBindings)

View file

@ -17,6 +17,7 @@
package io.element.android.x.di package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
@ -27,4 +28,5 @@ interface AppBindings {
fun snackbarDispatcher(): SnackbarDispatcher fun snackbarDispatcher(): SnackbarDispatcher
fun tracingService(): TracingService fun tracingService(): TracingService
fun bugReporter(): BugReporter fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService
} }

View file

@ -15,6 +15,7 @@
*/ */
plugins { plugins {
id("java-library") id("java-library")
id("com.android.lint")
alias(libs.plugins.kotlin.jvm) alias(libs.plugins.kotlin.jvm)
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)

View file

@ -33,7 +33,7 @@ data class LockScreenConfig(
val isPinMandatory: Boolean, val isPinMandatory: Boolean,
/** /**
* Some PINs are blacklisted. * Some PINs are forbidden.
*/ */
val pinBlacklist: Set<String>, val pinBlacklist: Set<String>,
@ -56,6 +56,7 @@ data class LockScreenConfig(
* Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported. * Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
*/ */
val isStrongBiometricsEnabled: Boolean, val isStrongBiometricsEnabled: Boolean,
/** /**
* Authentication with weak methods (most face/iris unlock implementations) is supported. * Authentication with weak methods (most face/iris unlock implementations) is supported.
*/ */
@ -72,7 +73,7 @@ object LockScreenConfigModule {
pinBlacklist = setOf("0000", "1234"), pinBlacklist = setOf("0000", "1234"),
pinSize = 4, pinSize = 4,
maxPinCodeAttemptsBeforeLogout = 3, maxPinCodeAttemptsBeforeLogout = 3,
gracePeriod = 90.seconds, gracePeriod = 0.seconds,
isStrongBiometricsEnabled = true, isStrongBiometricsEnabled = true,
isWeakBiometricsEnabled = true, isWeakBiometricsEnabled = true,
) )

View file

@ -5,7 +5,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript { buildscript {
dependencies { dependencies {
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10") classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20")
classpath("com.google.gms:google-services:4.4.0") classpath("com.google.gms:google-services:4.4.0")
} }
} }
@ -62,7 +62,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml")) config.from(files("$rootDir/tools/detekt/detekt.yml"))
} }
dependencies { dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.3.0") detektPlugins("io.nlopez.compose.rules:detekt:0.3.3")
} }
// KtLint // KtLint

View file

@ -1,2 +1,2 @@
Main changes in this version: TODO. Main changes in this version: Element Call, voice message.
Full changelog: https://github.com/vector-im/element-x-android/releases Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -0,0 +1,2 @@
Main changes in this version: Mainly bug fixes.
Full changelog: https://github.com/vector-im/element-x-android/releases

View file

@ -27,20 +27,18 @@ import io.element.android.libraries.usersearch.api.UserRepository
import javax.inject.Inject import javax.inject.Inject
class AddPeoplePresenter @Inject constructor( class AddPeoplePresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory, userListPresenterFactory: UserListPresenter.Factory,
private val userRepository: UserRepository, userRepository: UserRepository,
private val dataStore: CreateRoomDataStore, dataStore: CreateRoomDataStore,
) : Presenter<UserListState> { ) : Presenter<UserListState> {
private val userListPresenter by lazy { private val userListPresenter = userListPresenterFactory.create(
userListPresenterFactory.create( UserListPresenterArgs(
UserListPresenterArgs( selectionMode = SelectionMode.Multiple,
selectionMode = SelectionMode.Multiple, ),
), userRepository,
userRepository, dataStore.selectedUserListDataStore,
dataStore.selectedUserListDataStore, )
)
}
@Composable @Composable
override fun present(): UserListState { override fun present(): UserListState {

View file

@ -36,7 +36,6 @@ import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.rememberModalBottomSheetState import androidx.compose.material.rememberModalBottomSheetState
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -49,13 +48,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.RoomPrivacyOption import io.element.android.features.createroom.impl.components.RoomPrivacyOption
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.LabelledTextField import io.element.android.libraries.designsystem.components.LabelledTextField
import io.element.android.libraries.designsystem.components.ProgressDialog import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview 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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Scaffold 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.Text
@ -84,12 +81,6 @@ fun ConfigureRoomView(
initialValue = ModalBottomSheetValue.Hidden, initialValue = ModalBottomSheetValue.Hidden,
) )
if (state.createRoomAction is Async.Success) {
LaunchedEffect(state.createRoomAction) {
onRoomCreated(state.createRoomAction.data)
}
}
fun onAvatarClicked() { fun onAvatarClicked() {
focusManager.clearFocus() focusManager.clearFocus()
coroutineScope.launch { coroutineScope.launch {
@ -158,21 +149,14 @@ fun ConfigureRoomView(
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) } onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
) )
when (state.createRoomAction) { AsyncView(
is Async.Loading -> { async = state.createRoomAction,
ProgressDialog(text = stringResource(CommonStrings.common_creating_room)) progressText = stringResource(CommonStrings.common_creating_room),
} onSuccess = { onRoomCreated(it) },
errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
is Async.Failure -> { onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
RetryDialog( onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
content = stringResource(R.string.screen_create_room_error_creating_room), )
onDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
)
}
else -> Unit
}
PermissionsView( PermissionsView(
state = state.cameraPermissionState, state = state.cameraPermissionState,

View file

@ -40,23 +40,21 @@ import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
class CreateRoomRootPresenter @Inject constructor( class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: UserListPresenter.Factory, presenterFactory: UserListPresenter.Factory,
private val userRepository: UserRepository, userRepository: UserRepository,
private val userListDataStore: UserListDataStore, userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val buildMeta: BuildMeta, private val buildMeta: BuildMeta,
) : Presenter<CreateRoomRootState> { ) : Presenter<CreateRoomRootState> {
private val presenter by lazy { private val presenter = presenterFactory.create(
presenterFactory.create( UserListPresenterArgs(
UserListPresenterArgs( selectionMode = SelectionMode.Single,
selectionMode = SelectionMode.Single, ),
), userRepository,
userRepository, userListDataStore,
userListDataStore, )
)
}
@Composable @Composable
override fun present(): CreateRoomRootState { override fun present(): CreateRoomRootState {

View file

@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.size
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -38,12 +37,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.components.ProgressDialog
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview 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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Scaffold import io.element.android.libraries.designsystem.theme.components.Scaffold
@ -64,12 +61,6 @@ fun CreateRoomRootView(
onOpenDM: (RoomId) -> Unit = {}, onOpenDM: (RoomId) -> Unit = {},
onInviteFriendsClicked: () -> Unit = {}, onInviteFriendsClicked: () -> Unit = {},
) { ) {
if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.data)
}
}
Scaffold( Scaffold(
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
topBar = { topBar = {
@ -102,26 +93,19 @@ fun CreateRoomRootView(
} }
} }
when (state.startDmAction) { AsyncView(
is Async.Loading -> { async = state.startDmAction,
ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat)) progressText = stringResource(CommonStrings.common_starting_chat),
} onSuccess = { onOpenDM(it) },
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
is Async.Failure -> { onRetry = {
RetryDialog( state.userListState.selectedUsers.firstOrNull()
content = stringResource(id = R.string.screen_start_chat_error_starting_chat), ?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) }, // Cancel start DM if there is no more selected user (should not happen)
onRetry = { ?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
state.userListState.selectedUsers.firstOrNull() },
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) } onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
// Cancel start DM if there is no more selected user (should not happen) )
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
)
}
else -> Unit
}
} }
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)

View file

@ -146,7 +146,7 @@ class FtueFlowNode @AssistedInject constructor(
} }
NavTarget.LockScreenSetup -> { NavTarget.LockScreenSetup -> {
val callback = object : LockScreenEntryPoint.Callback { val callback = object : LockScreenEntryPoint.Callback {
override fun onSetupCompleted() { override fun onSetupDone() {
lifecycleScope.launch { moveToNextStep() } lifecycleScope.launch { moveToNextStep() }
} }
} }

View file

@ -32,18 +32,16 @@ import io.element.android.libraries.di.AppScope
class NotificationsOptInNode @AssistedInject constructor( class NotificationsOptInNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val presenterFactory: NotificationsOptInPresenter.Factory, presenterFactory: NotificationsOptInPresenter.Factory,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback: NodeInputs { interface Callback : NodeInputs {
fun onNotificationsOptInFinished() fun onNotificationsOptInFinished()
} }
private val callback = inputs<Callback>() private val callback = inputs<Callback>()
private val presenter: NotificationsOptInPresenter by lazy { private val presenter: NotificationsOptInPresenter = presenterFactory.create(callback)
presenterFactory.create(callback)
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {

View file

@ -34,7 +34,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
class NotificationsOptInPresenter @AssistedInject constructor( class NotificationsOptInPresenter @AssistedInject constructor(
private val permissionsPresenterFactory: PermissionsPresenter.Factory, permissionsPresenterFactory: PermissionsPresenter.Factory,
@Assisted private val callback: NotificationsOptInNode.Callback, @Assisted private val callback: NotificationsOptInNode.Callback,
private val appCoroutineScope: CoroutineScope, private val appCoroutineScope: CoroutineScope,
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
@ -46,14 +46,13 @@ class NotificationsOptInPresenter @AssistedInject constructor(
fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
} }
private val postNotificationPermissionsPresenter by lazy { private val postNotificationPermissionsPresenter: PermissionsPresenter =
// Ask for POST_NOTIFICATION PERMISSION on Android 13+ // Ask for POST_NOTIFICATION PERMISSION on Android 13+
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS) permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
} else { } else {
NoopPermissionsPresenter() NoopPermissionsPresenter()
} }
}
@Composable @Composable
override fun present(): NotificationsOptInState { override fun present(): NotificationsOptInState {

View file

@ -120,7 +120,7 @@ class DefaultFtueState @Inject constructor(
private fun shouldDisplayLockscreenSetup(): Boolean { private fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking { return runBlocking {
lockScreenService.isSetupRequired() lockScreenService.isSetupRequired().first()
} }
} }

View file

@ -25,10 +25,6 @@ import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.systemBarsPadding import androidx.compose.foundation.layout.systemBarsPadding
import androidx.compose.foundation.rememberScrollState import androidx.compose.foundation.rememberScrollState
import androidx.compose.foundation.verticalScroll import androidx.compose.foundation.verticalScroll
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddComment
import androidx.compose.material.icons.outlined.Lock
import androidx.compose.material.icons.outlined.NewReleases
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
@ -41,10 +37,11 @@ import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSiz
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview 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.Button
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
@ -79,14 +76,7 @@ fun WelcomeView(
color = ElementTheme.colors.textPrimary, color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
) )
Spacer(modifier = Modifier.height(8.dp)) Spacer(modifier = Modifier.height(80.dp))
Text(
text = stringResource(R.string.screen_welcome_subtitle),
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(40.dp))
InfoListOrganism( InfoListOrganism(
items = listItems(), items = listItems(),
textStyle = ElementTheme.typography.fontBodyMdMedium, textStyle = ElementTheme.typography.fontBodyMdMedium,
@ -109,17 +99,13 @@ fun WelcomeView(
@Composable @Composable
private fun listItems() = persistentListOf( private fun listItems() = persistentListOf(
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_1),
iconVector = Icons.Outlined.NewReleases,
),
InfoListItem( InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_2), message = stringResource(R.string.screen_welcome_bullet_2),
iconVector = Icons.Outlined.Lock, iconId = CommonDrawables.ic_lock_outline,
), ),
InfoListItem( InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_3), message = stringResource(R.string.screen_welcome_bullet_3),
iconVector = Icons.Outlined.AddComment, iconId = CommonDrawables.ic_compound_chat_problem,
), ),
) )

View file

@ -5,7 +5,7 @@
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string> <string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string> <string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string> <string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms wont be available in this update."</string> <string name="screen_welcome_bullet_2">"Message history for encrypted rooms isnt available yet."</string>
<string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string> <string name="screen_welcome_bullet_3">"Wed love to hear from you, let us know what you think via the settings page."</string>
<string name="screen_welcome_button">"Let\'s go!"</string> <string name="screen_welcome_button">"Let\'s go!"</string>
<string name="screen_welcome_subtitle">"Heres what you need to know:"</string> <string name="screen_welcome_subtitle">"Heres what you need to know:"</string>

View file

@ -57,6 +57,7 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState( val state = createState(
@ -64,13 +65,15 @@ class DefaultFtueStateTests {
welcomeState = welcomeState, welcomeState = welcomeState,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
) )
welcomeState.setWelcomeScreenShown() welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID) migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
state.updateState() state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse() assertThat(state.shouldDisplayFlow.value).isFalse()
@ -85,6 +88,7 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState( val state = createState(
@ -92,7 +96,8 @@ class DefaultFtueStateTests {
welcomeState = welcomeState, welcomeState = welcomeState,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
) )
val steps = mutableListOf<FtueStep?>() val steps = mutableListOf<FtueStep?>()
@ -108,7 +113,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
// Fourth step, analytics opt in // Fourth step, entering PIN code
steps.add(state.getNextStep(steps.lastOrNull()))
lockScreenService.setIsPinSetup(true)
// Fifth step, analytics opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()
@ -119,6 +128,7 @@ class DefaultFtueStateTests {
FtueStep.MigrationScreen, FtueStep.MigrationScreen,
FtueStep.WelcomeScreen, FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn, FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn, FtueStep.AnalyticsOptIn,
null, // Final state null, // Final state
) )
@ -133,18 +143,20 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
) )
// Skip first 3 steps // Skip first 4 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID) migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.setWelcomeScreenShown() state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted() permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@ -160,18 +172,21 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob()) val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore() val migrationScreenStore = InMemoryMigrationScreenStore()
val lockScreenService = FakeLockScreenService()
val state = createState( val state = createState(
sdkIntVersion = Build.VERSION_CODES.M, sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope, coroutineScope = coroutineScope,
analyticsService = analyticsService, analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore, migrationScreenStore = migrationScreenStore,
lockScreenService = lockScreenService,
) )
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID) migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen) assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown() state.setWelcomeScreenShown()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn) assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent() analyticsService.setDidAskUserConsent()

View file

@ -16,11 +16,13 @@
package io.element.android.features.location.api package io.element.android.features.location.api
import android.annotation.SuppressLint
import android.os.Parcelable import android.os.Parcelable
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?""" private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
@SuppressLint("NewApi")
@Parcelize @Parcelize
data class Location( data class Location(
val lat: Double, val lat: Double,

View file

@ -28,7 +28,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake
import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.messages.test.MessageComposerContextFake import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.matrix.api.room.location.AssetType 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.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@ -49,7 +49,7 @@ class SendLocationPresenterTest {
private val permissionsPresenterFake = PermissionsPresenterFake() private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeMatrixRoom = FakeMatrixRoom() private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService() private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake() private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions() private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter( private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
@ -58,7 +58,7 @@ class SendLocationPresenterTest {
}, },
room = fakeMatrixRoom, room = fakeMatrixRoom,
analyticsService = fakeAnalyticsService, analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake, messageComposerContext = fakeMessageComposerContext,
locationActions = fakeLocationActions, locationActions = fakeLocationActions,
buildMeta = fakeBuildMeta, buildMeta = fakeBuildMeta,
) )
@ -379,7 +379,7 @@ class SendLocationPresenterTest {
shouldShowRationale = false, shouldShowRationale = false,
) )
) )
messageComposerContextFake.apply { fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit( composerMode = MessageComposerMode.Edit(
eventId = null, defaultContent = "", transactionId = null eventId = null, defaultContent = "", transactionId = null
) )
@ -425,7 +425,7 @@ class SendLocationPresenterTest {
shouldShowRationale = false, shouldShowRationale = false,
) )
) )
messageComposerContextFake.apply { fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit( composerMode = MessageComposerMode.Edit(
eventId = null, defaultContent = "", transactionId = null eventId = null, defaultContent = "", transactionId = null
) )

View file

@ -31,8 +31,8 @@ interface LockScreenEntryPoint : FeatureEntryPoint {
fun build(): Node fun build(): Node
} }
interface Callback: Plugin { interface Callback : Plugin {
fun onSetupCompleted() fun onSetupDone()
} }
enum class Target { enum class Target {

View file

@ -16,7 +16,14 @@
package io.element.android.features.lockscreen.api package io.element.android.features.lockscreen.api
import android.os.Build
import android.view.WindowManager
import androidx.activity.ComponentActivity
import androidx.lifecycle.lifecycleScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
interface LockScreenService { interface LockScreenService {
/** /**
@ -28,5 +35,34 @@ interface LockScreenService {
* Check if setting up the lock screen is required. * Check if setting up the lock screen is required.
* @return true if the lock screen is mandatory and not setup yet, false otherwise. * @return true if the lock screen is mandatory and not setup yet, false otherwise.
*/ */
suspend fun isSetupRequired(): Boolean fun isSetupRequired(): Flow<Boolean>
/**
* Check if pin is setup.
* @return true if the pin is setup, false otherwise.
*/
fun isPinSetup(): Flow<Boolean>
}
/**
* Makes sure the secure flag is set on the activity if the pin is setup.
* @param activity the activity to set the flag on.
*/
fun LockScreenService.handleSecureFlag(activity: ComponentActivity) {
isPinSetup()
.onEach { isPinSetup ->
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
activity.setRecentsScreenshotEnabled(!isPinSetup)
} else {
if (isPinSetup) {
activity.window.setFlags(
WindowManager.LayoutParams.FLAG_SECURE,
WindowManager.LayoutParams.FLAG_SECURE
)
} else {
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
}
}
}
.launchIn(activity.lifecycleScope)
} }

View file

@ -35,8 +35,12 @@ import io.element.android.services.appnavstate.api.AppForegroundStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration import kotlin.time.Duration
@ -113,14 +117,23 @@ class DefaultLockScreenService @Inject constructor(
} }
} }
override suspend fun isSetupRequired(): Boolean { override fun isPinSetup(): Flow<Boolean> {
return lockScreenConfig.isPinMandatory return combine(
&& featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinUnlock),
&& !pinCodeManager.isPinCodeAvailable() pinCodeManager.hasPinCode()
) { isEnabled, hasPinCode ->
isEnabled && hasPinCode
}
}
override fun isSetupRequired(): Flow<Boolean> {
return isPinSetup().map { isPinSetup ->
!isPinSetup && lockScreenConfig.isPinMandatory
}
} }
private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch { private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) { if (isPinSetup().first()) {
delay(gracePeriod) delay(gracePeriod)
_lockScreenState.value = LockScreenLockState.Locked _lockScreenState.value = LockScreenLockState.Locked
} }

View file

@ -20,7 +20,6 @@ import android.os.Parcelable
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.composable.Children 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.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.core.plugin.Plugin
@ -30,8 +29,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.lockscreen.api.LockScreenEntryPoint 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.settings.LockScreenSettingsFlowNode
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
@ -46,7 +43,6 @@ import kotlinx.parcelize.Parcelize
class LockScreenFlowNode @AssistedInject constructor( class LockScreenFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager,
) : BackstackNode<LockScreenFlowNode.NavTarget>( ) : BackstackNode<LockScreenFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget, initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
@ -71,26 +67,14 @@ class LockScreenFlowNode @AssistedInject constructor(
data object Settings : NavTarget data object Settings : NavTarget
} }
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() { private class OnSetupDoneCallback(private val plugins: List<LockScreenEntryPoint.Callback>) : LockScreenSetupFlowNode.Callback {
override fun onPinCodeCreated() { override fun onSetupDone() {
plugins<LockScreenEntryPoint.Callback>().forEach { plugins.forEach {
it.onSetupCompleted() it.onSetupDone()
} }
} }
} }
override fun onBuilt() {
super.onBuilt()
lifecycle.subscribe(
onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback)
},
onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback)
}
)
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Unlock -> { NavTarget.Unlock -> {
@ -98,7 +82,8 @@ class LockScreenFlowNode @AssistedInject constructor(
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs)) createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
} }
NavTarget.Setup -> { NavTarget.Setup -> {
createNode<LockScreenSetupFlowNode>(buildContext) val callback = OnSetupDoneCallback(plugins())
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.Settings -> { NavTarget.Settings -> {
createNode<LockScreenSettingsFlowNode>(buildContext) createNode<LockScreenSettingsFlowNode>(buildContext)

View file

@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity import androidx.fragment.app.FragmentActivity
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber import timber.log.Timber
import java.security.InvalidKeyException import java.security.InvalidKeyException
@ -86,7 +87,12 @@ class DefaultBiometricUnlock(
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult) val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback) val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo, cryptoObject) prompt.authenticate(promptInfo, cryptoObject)
return deferredAuthenticationResult.await() return try {
deferredAuthenticationResult.await()
} catch (cancellation: CancellationException) {
prompt.cancelAuthentication()
BiometricUnlock.AuthenticationResult.Failure(cancellation)
}
} }
@Throws(KeyPermanentlyInvalidatedException::class) @Throws(KeyPermanentlyInvalidatedException::class)
@ -110,7 +116,6 @@ private class AuthenticationCallback(
override fun onAuthenticationFailed() { override fun onAuthenticationFailed() {
super.onAuthenticationFailed() super.onAuthenticationFailed()
callbacks.forEach { it.onBiometricUnlockFailed(null) } callbacks.forEach { it.onBiometricUnlockFailed(null) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
} }
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) { override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.cryptography.api.EncryptionResult
import io.element.android.libraries.cryptography.api.SecretKeyRepository import io.element.android.libraries.cryptography.api.SecretKeyRepository
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.CopyOnWriteArrayList import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject import javax.inject.Inject
@ -46,7 +47,7 @@ class DefaultPinCodeManager @Inject constructor(
callbacks.remove(callback) callbacks.remove(callback)
} }
override suspend fun isPinCodeAvailable(): Boolean { override fun hasPinCode(): Flow<Boolean> {
return lockScreenStore.hasPinCode() return lockScreenStore.hasPinCode()
} }

View file

@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.pin package io.element.android.features.lockscreen.impl.pin
import kotlinx.coroutines.flow.Flow
/** /**
* This interface is the main interface to manage the pin code. * This interface is the main interface to manage the pin code.
* Implementation should take care of encrypting the pin code and storing it. * Implementation should take care of encrypting the pin code and storing it.
@ -55,7 +57,7 @@ interface PinCodeManager {
/** /**
* @return true if a pin code is available. * @return true if a pin code is available.
*/ */
suspend fun isPinCodeAvailable(): Boolean fun hasPinCode(): Flow<Boolean>
/** /**
* @return the size of the saved pin code. * @return the size of the saved pin code.

View file

@ -16,6 +16,9 @@
package io.element.android.features.lockscreen.impl.pin.model package io.element.android.features.lockscreen.impl.pin.model
import androidx.compose.runtime.Immutable
@Immutable
sealed interface PinDigit { sealed interface PinDigit {
data object Empty : PinDigit data object Empty : PinDigit
data class Filled(val value: Char) : PinDigit data class Filled(val value: Char) : PinDigit

View file

@ -32,16 +32,15 @@ import com.bumble.appyx.navmodel.backstack.operation.push
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode 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.DefaultPinCodeManagerCallback
import io.element.android.features.lockscreen.impl.pin.PinCodeManager 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.setup.pin.SetupPinNode
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@ -50,7 +49,6 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
@Assisted buildContext: BuildContext, @Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val pinCodeManager: PinCodeManager, private val pinCodeManager: PinCodeManager,
private val biometricUnlockManager: BiometricUnlockManager,
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>( ) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Unknown, initialElement = NavTarget.Unknown,
@ -68,44 +66,39 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
data object Unlock : NavTarget data object Unlock : NavTarget
@Parcelize @Parcelize
data object Setup : NavTarget data object SetupPin : NavTarget
@Parcelize @Parcelize
data object Settings : NavTarget data object Settings : NavTarget
} }
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() { private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
backstack.newRoot(NavTarget.Settings)
}
override fun onPinCodeRemoved() { override fun onPinCodeRemoved() {
navigateUp() navigateUp()
} }
}
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() { override fun onPinCodeCreated() {
override fun onBiometricUnlockSuccess() {
backstack.newRoot(NavTarget.Settings) backstack.newRoot(NavTarget.Settings)
} }
} }
init { override fun onBuilt() {
super.onBuilt()
lifecycleScope.launch { lifecycleScope.launch {
if (pinCodeManager.isPinCodeAvailable()) { val hasPinCode = pinCodeManager.hasPinCode().first()
if (hasPinCode) {
backstack.newRoot(NavTarget.Unlock) backstack.newRoot(NavTarget.Unlock)
} else { } else {
backstack.newRoot(NavTarget.Setup) backstack.newRoot(NavTarget.SetupPin)
} }
} }
lifecycle.subscribe( lifecycle.subscribe(
onCreate = { onCreate = {
pinCodeManager.addCallback(pinCodeManagerCallback) pinCodeManager.addCallback(pinCodeManagerCallback)
biometricUnlockManager.addCallback(biometricUnlockCallback)
}, },
onDestroy = { onDestroy = {
pinCodeManager.removeCallback(pinCodeManagerCallback) pinCodeManager.removeCallback(pinCodeManagerCallback)
biometricUnlockManager.removeCallback(biometricUnlockCallback)
} }
) )
} }
@ -114,25 +107,26 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
return when (navTarget) { return when (navTarget) {
NavTarget.Unlock -> { NavTarget.Unlock -> {
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true) val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs)) val callback = object : PinUnlockNode.Callback {
} override fun onUnlock() {
NavTarget.Setup -> {
val callback = object : LockScreenSetupFlowNode.Callback {
override fun onSetupDone() {
backstack.newRoot(NavTarget.Settings) backstack.newRoot(NavTarget.Settings)
} }
} }
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback)) createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.SetupPin -> {
createNode<SetupPinNode>(buildContext)
} }
NavTarget.Settings -> { NavTarget.Settings -> {
val callback = object : LockScreenSettingsNode.Callback { val callback = object : LockScreenSettingsNode.Callback {
override fun onChangePinClicked() { override fun onChangePinClicked() {
backstack.push(NavTarget.Setup) backstack.push(NavTarget.SetupPin)
} }
} }
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback)) createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
} }
NavTarget.Unknown -> node(buildContext) { } NavTarget.Unknown -> node(buildContext) { }
} }
} }

View file

@ -17,11 +17,10 @@
package io.element.android.features.lockscreen.impl.settings package io.element.android.features.lockscreen.impl.settings
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import io.element.android.appconfig.LockScreenConfig import io.element.android.appconfig.LockScreenConfig
@ -43,23 +42,15 @@ class LockScreenSettingsPresenter @Inject constructor(
@Composable @Composable
override fun present(): LockScreenSettingsState { override fun present(): LockScreenSettingsState {
var triggerComputation by remember { val showRemovePinOption by produceState(initialValue = false) {
mutableIntStateOf(0) pinCodeManager.hasPinCode().collect { hasPinCode ->
} value = !lockScreenConfig.isPinMandatory && hasPinCode
var showRemovePinOption by remember { }
mutableStateOf(false)
}
var showToggleBiometric by remember {
mutableStateOf(false)
} }
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false) val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
var showRemovePinConfirmation by remember { var showRemovePinConfirmation by remember {
mutableStateOf(false) mutableStateOf(false)
} }
LaunchedEffect(triggerComputation) {
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
showToggleBiometric = biometricUnlockManager.isDeviceSecured
}
fun handleEvents(event: LockScreenSettingsEvents) { fun handleEvents(event: LockScreenSettingsEvents) {
when (event) { when (event) {
@ -69,7 +60,6 @@ class LockScreenSettingsPresenter @Inject constructor(
if (showRemovePinConfirmation) { if (showRemovePinConfirmation) {
showRemovePinConfirmation = false showRemovePinConfirmation = false
pinCodeManager.deletePinCode() pinCodeManager.deletePinCode()
triggerComputation++
} }
} }
} }
@ -86,7 +76,7 @@ class LockScreenSettingsPresenter @Inject constructor(
showRemovePinOption = showRemovePinOption, showRemovePinOption = showRemovePinOption,
isBiometricEnabled = isBiometricEnabled, isBiometricEnabled = isBiometricEnabled,
showRemovePinConfirmation = showRemovePinConfirmation, showRemovePinConfirmation = showRemovePinConfirmation,
showToggleBiometric = showToggleBiometric, showToggleBiometric = biometricUnlockManager.isDeviceSecured,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -17,19 +17,16 @@
package io.element.android.features.lockscreen.impl.setup.biometric package io.element.android.features.lockscreen.impl.setup.biometric
import androidx.activity.compose.BackHandler 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.foundation.layout.padding
import androidx.compose.material.icons.Icons import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Fingerprint import androidx.compose.material.icons.filled.Fingerprint
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.features.lockscreen.impl.R import io.element.android.features.lockscreen.impl.R
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.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@ -76,10 +73,8 @@ private fun SetupBiometricFooter(
onSkipClicked: () -> Unit, onSkipClicked: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Column( ButtonColumnMolecule(
modifier = modifier.fillMaxWidth(), modifier = modifier,
verticalArrangement = spacedBy(16.dp),
horizontalAlignment = Alignment.CenterHorizontally
) { ) {
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication) val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
Button( Button(

View file

@ -17,6 +17,6 @@
package io.element.android.features.lockscreen.impl.setup.pin package io.element.android.features.lockscreen.impl.setup.pin
sealed interface SetupPinEvents { sealed interface SetupPinEvents {
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents
data object ClearFailure : SetupPinEvents data object ClearFailure : SetupPinEvents
} }

View file

@ -32,6 +32,11 @@ import io.element.android.libraries.core.meta.BuildMeta
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import javax.inject.Inject import javax.inject.Inject
/**
* Some time for the ui to refresh before showing confirmation step.
*/
private const val DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS = 100L
class SetupPinPresenter @Inject constructor( class SetupPinPresenter @Inject constructor(
private val lockScreenConfig: LockScreenConfig, private val lockScreenConfig: LockScreenConfig,
private val pinValidator: PinValidator, private val pinValidator: PinValidator,
@ -60,8 +65,7 @@ class SetupPinPresenter @Inject constructor(
setupPinFailure = pinValidationResult.failure setupPinFailure = pinValidationResult.failure
} }
PinValidator.Result.Valid -> { PinValidator.Result.Valid -> {
// Leave some time for the ui to refresh before showing confirmation delay(DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS)
delay(150)
isConfirmationStep = true isConfirmationStep = true
} }
} }
@ -81,7 +85,8 @@ class SetupPinPresenter @Inject constructor(
fun handleEvents(event: SetupPinEvents) { fun handleEvents(event: SetupPinEvents) {
when (event) { when (event) {
is SetupPinEvents.OnPinEntryChanged -> { is SetupPinEvents.OnPinEntryChanged -> {
if (isConfirmationStep) { // Use the fromConfirmationStep flag from ui to avoid race condition.
if (event.fromConfirmationStep) {
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText) confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
} else { } else {
choosePinEntry = choosePinEntry.fillWith(event.entryAsText) choosePinEntry = choosePinEntry.fillWith(event.entryAsText)

View file

@ -116,8 +116,8 @@ private fun SetupPinContent(
PinEntryTextField( PinEntryTextField(
pinEntry = state.activePinEntry, pinEntry = state.activePinEntry,
isSecured = true, isSecured = true,
onValueChange = { onValueChange = { entry ->
state.eventSink(SetupPinEvents.OnPinEntryChanged(it)) state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
}, },
modifier = modifier modifier = modifier
.focusRequester(focusRequester) .focusRequester(focusRequester)

View file

@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.storage package io.element.android.features.lockscreen.impl.storage
import kotlinx.coroutines.flow.Flow
/** /**
* Should be implemented by any class that provides access to the encrypted PIN code. * 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. * All methods are suspending in case there are async IO operations involved.
@ -39,5 +41,6 @@ interface EncryptedPinCodeStorage {
/** /**
* Returns whether the PIN code is stored or not. * Returns whether the PIN code is stored or not.
*/ */
suspend fun hasPinCode(): Boolean fun hasPinCode(): Flow<Boolean>
} }

View file

@ -85,10 +85,10 @@ class PreferencesLockScreenStore @Inject constructor(
} }
} }
override suspend fun hasPinCode(): Boolean { override fun hasPinCode(): Flow<Boolean> {
return context.dataStore.data.map { preferences -> return context.dataStore.data.map { preferences ->
preferences[pinCodeKey] != null preferences[pinCodeKey] != null
}.first() }
} }
override fun isBiometricUnlockAllowed(): Flow<Boolean> { override fun isBiometricUnlockAllowed(): Flow<Boolean> {

View file

@ -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.runtime.DisposableEffect
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 javax.inject.Inject
class PinUnlockHelper @Inject constructor(
private val biometricUnlockManager: BiometricUnlockManager,
private val pinCodeManager: PinCodeManager
) {
@Composable
fun OnUnlockEffect(onUnlock: () -> Unit) {
DisposableEffect(Unit) {
val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
override fun onBiometricUnlockSuccess() {
onUnlock()
}
}
val pinCodeVerifiedCallback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeVerified() {
onUnlock()
}
}
biometricUnlockManager.addCallback(biometricUnlockCallback)
pinCodeManager.addCallback(pinCodeVerifiedCallback)
onDispose {
biometricUnlockManager.removeCallback(biometricUnlockCallback)
pinCodeManager.removeCallback(pinCodeVerifiedCallback)
}
}
}
}

View file

@ -17,10 +17,12 @@
package io.element.android.features.lockscreen.impl.unlock package io.element.android.features.lockscreen.impl.unlock
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext 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.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -35,15 +37,30 @@ class PinUnlockNode @AssistedInject constructor(
private val presenter: PinUnlockPresenter, private val presenter: PinUnlockPresenter,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onUnlock()
}
data class Inputs( data class Inputs(
val isInAppUnlock: Boolean val isInAppUnlock: Boolean
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
private fun onUnlock() {
plugins<Callback>().forEach {
it.onUnlock()
}
}
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
val state = presenter.present() val state = presenter.present()
LaunchedEffect(state.isUnlocked) {
if (state.isUnlocked) {
onUnlock()
}
}
PinUnlockView( PinUnlockView(
state = state, state = state,
isInAppUnlock = inputs.isInAppUnlock, isInAppUnlock = inputs.isInAppUnlock,

View file

@ -43,6 +43,7 @@ class PinUnlockPresenter @Inject constructor(
private val biometricUnlockManager: BiometricUnlockManager, private val biometricUnlockManager: BiometricUnlockManager,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val pinUnlockHelper: PinUnlockHelper,
) : Presenter<PinUnlockState> { ) : Presenter<PinUnlockState> {
@Composable @Composable
@ -66,9 +67,10 @@ class PinUnlockPresenter @Inject constructor(
var biometricUnlockResult by remember { var biometricUnlockResult by remember {
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null) mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
} }
val isUnlocked = remember {
mutableStateOf(false)
}
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock() val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
LaunchedEffect(Unit) { LaunchedEffect(Unit) {
suspend { suspend {
val pinCodeSize = pinCodeManager.getPinCodeSize() val pinCodeSize = pinCodeManager.getPinCodeSize()
@ -94,6 +96,9 @@ class PinUnlockPresenter @Inject constructor(
showSignOutPrompt = true showSignOutPrompt = true
} }
} }
pinUnlockHelper.OnUnlockEffect {
isUnlocked.value = true
}
fun handleEvents(event: PinUnlockEvents) { fun handleEvents(event: PinUnlockEvents) {
when (event) { when (event) {
@ -129,6 +134,7 @@ class PinUnlockPresenter @Inject constructor(
signOutAction = signOutAction.value, signOutAction = signOutAction.value,
showBiometricUnlock = biometricUnlock.isActive, showBiometricUnlock = biometricUnlock.isActive,
biometricUnlockResult = biometricUnlockResult, biometricUnlockResult = biometricUnlockResult,
isUnlocked = isUnlocked.value,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }

View file

@ -28,6 +28,7 @@ data class PinUnlockState(
val showSignOutPrompt: Boolean, val showSignOutPrompt: Boolean,
val signOutAction: Async<String?>, val signOutAction: Async<String?>,
val showBiometricUnlock: Boolean, val showBiometricUnlock: Boolean,
val isUnlocked: Boolean,
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?, val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
val eventSink: (PinUnlockEvents) -> Unit val eventSink: (PinUnlockEvents) -> Unit
) { ) {

View file

@ -41,6 +41,7 @@ fun aPinUnlockState(
showSignOutPrompt: Boolean = false, showSignOutPrompt: Boolean = false,
showBiometricUnlock: Boolean = true, showBiometricUnlock: Boolean = true,
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null, biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
isUnlocked: Boolean = false,
signOutAction: Async<String?> = Async.Uninitialized, signOutAction: Async<String?> = Async.Uninitialized,
) = PinUnlockState( ) = PinUnlockState(
pinEntry = Async.Success(pinEntry), pinEntry = Async.Success(pinEntry),
@ -50,5 +51,6 @@ fun aPinUnlockState(
showBiometricUnlock = showBiometricUnlock, showBiometricUnlock = showBiometricUnlock,
signOutAction = signOutAction, signOutAction = signOutAction,
biometricUnlockResult = biometricUnlockResult, biometricUnlockResult = biometricUnlockResult,
isUnlocked = isUnlocked,
eventSink = {} eventSink = {}
) )

View file

@ -1,4 +1,39 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Máte %1$d pokus pro odemknutí"</item>
<item quantity="few">"Máte %1$d pokusy pro odemknutí"</item>
<item quantity="other">"Máte %1$d pokusů pro odemknutí"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Špatný PIN. Máte %1$d další pokus"</item>
<item quantity="few">"Špatný PIN. Máte %1$d další pokusy"</item>
<item quantity="other">"Špatný PIN. Máte %1$d dalších pokusů"</item>
</plurals>
<string name="screen_app_lock_biometric_authentication">"Biometrické ověřování"</string>
<string name="screen_app_lock_biometric_unlock">"biometrické odemknutí"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Odemkněte pomocí biometrie"</string>
<string name="screen_app_lock_forgot_pin">"Zapomněli jste PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Změnit PIN kód"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povolit biometrické odemykání"</string>
<string name="screen_app_lock_settings_remove_pin">"Odstranit PIN"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Opravdu chcete odstranit PIN?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Odstranit PIN?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Povolit %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Raději bych použil PIN"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Ušetřete si čas a použijte pokaždé %1$s pro odemknutí aplikace"</string>
<string name="screen_app_lock_setup_choose_pin">"Zvolte PIN"</string>
<string name="screen_app_lock_setup_confirm_pin">"Potvrďte PIN"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Z bezpečnostních důvodů si toto nemůžete zvolit jako svůj PIN kód"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Zvolte jiný PIN"</string>
<string name="screen_app_lock_setup_pin_context">"Zamkněte %1$s pro zvýšení bezpečnosti vašich konverzací.
Vyberte si něco zapamatovatelného. Pokud tento kód PIN zapomenete, budete z aplikace odhlášeni."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Zadejte stejný PIN dvakrát"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy se neshodují."</string>
<string name="screen_app_lock_signout_alert_message">"Abyste mohli pokračovat, budete se muset znovu přihlásit a vytvořit nový PIN"</string>
<string name="screen_app_lock_signout_alert_title">"Jste odhlášeni"</string>
<string name="screen_app_lock_use_biometric_android">"Použijte biometrické údaje"</string>
<string name="screen_app_lock_use_pin_android">"Použít PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string> <string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
</resources> </resources>

View file

@ -10,6 +10,7 @@
</plurals> </plurals>
<string name="screen_app_lock_biometric_authentication">"Authentification biométrique"</string> <string name="screen_app_lock_biometric_authentication">"Authentification biométrique"</string>
<string name="screen_app_lock_biometric_unlock">"Déverrouillage biométrique"</string> <string name="screen_app_lock_biometric_unlock">"Déverrouillage biométrique"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string>
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string> <string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string> <string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string>
@ -28,5 +29,7 @@
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Les codes PIN ne correspondent pas"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Les codes PIN ne correspondent pas"</string>
<string name="screen_app_lock_signout_alert_message">"Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."</string> <string name="screen_app_lock_signout_alert_message">"Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."</string>
<string name="screen_app_lock_signout_alert_title">"Vous êtes en train de vous déconnecter"</string> <string name="screen_app_lock_signout_alert_title">"Vous êtes en train de vous déconnecter"</string>
<string name="screen_app_lock_use_biometric_android">"Utiliser la biométrie"</string>
<string name="screen_app_lock_use_pin_android">"Utiliser le code PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string> <string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string>
</resources> </resources>

View file

@ -1,4 +1,39 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Вы попытались разблокировать %1$d раз"</item>
<item quantity="few">"Вы попытались разблокировать %1$d раз"</item>
<item quantity="many">"Вы попытались разблокировать много раз"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Неверный PIN-код. У вас остался %1$d шанс"</item>
<item quantity="few">"Неверный PIN-код. У вас остался %1$d шансов"</item>
<item quantity="many">"Неверный PIN-код. У вас остался %1$d шанса"</item>
</plurals>
<string name="screen_app_lock_biometric_authentication">"биометрическая идентификация"</string>
<string name="screen_app_lock_biometric_unlock">"биометрическая разблокировать"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Разблокировать с помощью биометрии"</string>
<string name="screen_app_lock_forgot_pin">"Забыли PIN-код?"</string>
<string name="screen_app_lock_settings_change_pin">"Измените PIN-код"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Разрешить биометрическую разблокировать"</string>
<string name="screen_app_lock_settings_remove_pin">"Удалить PIN-код"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Вы действительно хотите удалить PIN-код?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Удалить PIN-код?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Разрешить %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Я бы предпочел использовать PIN-код"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Сэкономьте время и используйте %1$s для разблокировки приложения"</string>
<string name="screen_app_lock_setup_choose_pin">"Выберите PIN-код"</string>
<string name="screen_app_lock_setup_confirm_pin">"Подтвердите PIN-код"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода."</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Выберите другой PIN-код"</string>
<string name="screen_app_lock_setup_pin_context">"Заблокируйте %1$s, чтобы повысить безопасность ваших чатов.
Выберите что-нибудь незабываемое. Если вы забудете этот PIN-код, вы выйдете из приложения."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Повторите PIN-код"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коды не совпадают"</string>
<string name="screen_app_lock_signout_alert_message">"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код."</string>
<string name="screen_app_lock_signout_alert_title">"Вы выходите из системы"</string>
<string name="screen_app_lock_use_biometric_android">"Использовать биометрию"</string>
<string name="screen_app_lock_use_pin_android">"Использовать PIN-код"</string>
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string> <string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
</resources> </resources>

View file

@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="screen_app_lock_subtitle">
<item quantity="one">"Máte %1$d pokus na odomknutie"</item>
<item quantity="few">"Máte %1$d pokusy na odomknutie"</item>
<item quantity="other">"Máte %1$d pokusov na odomknutie"</item>
</plurals>
<plurals name="screen_app_lock_subtitle_wrong_pin"> <plurals name="screen_app_lock_subtitle_wrong_pin">
<item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item> <item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item>
<item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item> <item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item>
@ -7,6 +12,7 @@
</plurals> </plurals>
<string name="screen_app_lock_biometric_authentication">"biometrické overenie"</string> <string name="screen_app_lock_biometric_authentication">"biometrické overenie"</string>
<string name="screen_app_lock_biometric_unlock">"biometrické odomknutie"</string> <string name="screen_app_lock_biometric_unlock">"biometrické odomknutie"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Odomknúť pomocou biometrie"</string>
<string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string> <string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string>
<string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string> <string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string>
@ -27,5 +33,7 @@ Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplik
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string>
<string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string> <string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string>
<string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string> <string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string>
<string name="screen_app_lock_use_biometric_android">"Použiť biometrické údaje"</string>
<string name="screen_app_lock_use_pin_android">"Použiť PIN"</string>
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string> <string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
</resources> </resources>

View file

@ -16,6 +16,7 @@
package io.element.android.features.lockscreen.impl.pin package io.element.android.features.lockscreen.impl.pin
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
@ -32,10 +33,13 @@ class DefaultPinCodeManagerTest {
@Test @Test
fun `given a pin code when create and delete assert no pin code left`() = runTest { fun `given a pin code when create and delete assert no pin code left`() = runTest {
pinCodeManager.createPinCode("1234") pinCodeManager.hasPinCode().test {
assertThat(pinCodeManager.isPinCodeAvailable()).isTrue() assertThat(awaitItem()).isFalse()
pinCodeManager.deletePinCode() pinCodeManager.createPinCode("1234")
assertThat(pinCodeManager.isPinCodeAvailable()).isFalse() assertThat(awaitItem()).isTrue()
pinCodeManager.deletePinCode()
assertThat(awaitItem()).isFalse()
}
} }
@Test @Test

View file

@ -24,7 +24,12 @@ private const val DEFAULT_REMAINING_ATTEMPTS = 3
class InMemoryLockScreenStore : LockScreenStore { class InMemoryLockScreenStore : LockScreenStore {
private val hasPinCode = MutableStateFlow(false)
private var pinCode: String? = null private var pinCode: String? = null
set(value) {
field = value
hasPinCode.value = value != null
}
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
private var isBiometricUnlockAllowed = MutableStateFlow(false) private var isBiometricUnlockAllowed = MutableStateFlow(false)
@ -52,8 +57,8 @@ class InMemoryLockScreenStore : LockScreenStore {
pinCode = null pinCode = null
} }
override suspend fun hasPinCode(): Boolean { override fun hasPinCode(): Flow<Boolean> {
return pinCode != null return hasPinCode
} }
override fun isBiometricUnlockAllowed(): Flow<Boolean> { override fun isBiometricUnlockAllowed(): Flow<Boolean> {

View file

@ -60,14 +60,14 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin)) state.onPinEntryChanged(halfCompletePin)
} }
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertText(halfCompletePin) state.choosePinEntry.assertText(halfCompletePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin)) state.onPinEntryChanged(blacklistedPin)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(blacklistedPin) state.choosePinEntry.assertText(blacklistedPin)
@ -77,7 +77,7 @@ class SetupPinPresenterTest {
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty() state.choosePinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.onPinEntryChanged(completePin)
} }
consumeItemsUntilPredicate { consumeItemsUntilPredicate {
it.isConfirmationStep it.isConfirmationStep
@ -85,7 +85,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin)) state.onPinEntryChanged(mismatchedPin)
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
@ -98,7 +98,7 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isFalse() assertThat(state.isConfirmationStep).isFalse()
assertThat(state.setupPinFailure).isNull() assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.onPinEntryChanged(completePin)
} }
consumeItemsUntilPredicate { consumeItemsUntilPredicate {
it.isConfirmationStep it.isConfirmationStep
@ -106,7 +106,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty() state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue() assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin)) state.onPinEntryChanged(completePin)
} }
awaitItem().also { state -> awaitItem().also { state ->
state.choosePinEntry.assertText(completePin) state.choosePinEntry.assertText(completePin)
@ -116,6 +116,10 @@ class SetupPinPresenterTest {
} }
} }
private fun SetupPinState.onPinEntryChanged(pinEntry: String){
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
}
private fun createSetupPinPresenter( private fun createSetupPinPresenter(
callback: PinCodeManager.Callback, callback: PinCodeManager.Callback,
lockScreenConfig: LockScreenConfig = aLockScreenConfig( lockScreenConfig: LockScreenConfig = aLockScreenConfig(

View file

@ -32,7 +32,6 @@ import io.element.android.libraries.architecture.Async
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.tests.testutils.awaitLastSequentialItem import io.element.android.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test
@ -44,13 +43,7 @@ class PinUnlockPresenterTest {
@Test @Test
fun `present - success verify flow`() = runTest { fun `present - success verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>() val presenter = createPinUnlockPresenter(this)
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -58,6 +51,7 @@ class PinUnlockPresenterTest {
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).isFalse() assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse() assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.isUnlocked).isFalse()
assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java) assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
} }
@ -77,20 +71,14 @@ class PinUnlockPresenterTest {
} }
awaitLastSequentialItem().also { state -> awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(completePin) state.pinEntry.assertText(completePin)
assertThat(state.isUnlocked).isTrue()
} }
pinCodeVerified.await()
} }
} }
@Test @Test
fun `present - failure verify flow`() = runTest { fun `present - failure verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>() val presenter = createPinUnlockPresenter(this)
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -160,6 +148,7 @@ class PinUnlockPresenterTest {
biometricUnlockManager = biometricUnlockManager, biometricUnlockManager = biometricUnlockManager,
matrixClient = FakeMatrixClient(), matrixClient = FakeMatrixClient(),
coroutineScope = scope, coroutineScope = scope,
pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
) )
} }
} }

View file

@ -18,21 +18,27 @@ package io.element.android.features.lockscreen.test
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class FakeLockScreenService : LockScreenService { class FakeLockScreenService : LockScreenService {
private var isSetupRequired: Boolean = false private var isPinSetup = MutableStateFlow(false)
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked) private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
override val lockState: StateFlow<LockScreenLockState> = _lockState override val lockState: StateFlow<LockScreenLockState> = _lockState
override suspend fun isSetupRequired(): Boolean { override fun isSetupRequired(): Flow<Boolean> {
return isSetupRequired return isPinSetup.map { !it }
} }
fun setIsSetupRequired(isSetupRequired: Boolean) { fun setIsPinSetup(isPinSetup: Boolean) {
this.isSetupRequired = isSetupRequired this.isPinSetup.value = isPinSetup
}
override fun isPinSetup(): Flow<Boolean> {
return isPinSetup
} }
fun setLockState(lockState: LockScreenLockState) { fun setLockState(lockState: LockScreenLockState) {

View file

@ -19,7 +19,7 @@ plugins {
alias(libs.plugins.anvil) alias(libs.plugins.anvil)
alias(libs.plugins.ksp) alias(libs.plugins.ksp)
id("kotlin-parcelize") id("kotlin-parcelize")
kotlin("plugin.serialization") version "1.9.10" kotlin("plugin.serialization") version "1.9.20"
} }
android { android {

View file

@ -25,17 +25,14 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.viewinterop.AndroidView import androidx.compose.ui.viewinterop.AndroidView
import io.element.android.features.login.impl.oidc.OidcUrlParser import io.element.android.features.login.impl.oidc.OidcUrlParser
import io.element.android.libraries.architecture.Async
import io.element.android.libraries.core.bool.orFalse import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@Composable @Composable
fun OidcView( fun OidcView(
@ -79,21 +76,11 @@ fun OidcView(
} }
) )
when (state.requestState) { AsyncView(
Async.Uninitialized -> Unit async = state.requestState,
is Async.Failure -> { onSuccess = { onNavigateBack() },
ErrorDialog( onErrorDismiss = { state.eventSink(OidcEvents.ClearError) }
content = state.requestState.error.toString(), )
onDismiss = { state.eventSink(OidcEvents.ClearError) }
)
}
is Async.Loading -> {
CircularProgressIndicator(
modifier = Modifier.align(Alignment.Center)
)
}
is Async.Success -> onNavigateBack()
}
} }
} }

View file

@ -14,7 +14,9 @@
<string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string> <string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string>
<string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string> <string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string>
<string name="screen_change_server_error_invalid_homeserver">"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."</string> <string name="screen_change_server_error_invalid_homeserver">"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."</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Tento server momentálne nepodporuje kĺzavú synchronizáciu."</string>
<string name="screen_change_server_form_header">"Adresa URL domovského servera"</string> <string name="screen_change_server_form_header">"Adresa URL domovského servera"</string>
<string name="screen_change_server_form_notice">"Môžete sa pripojiť iba k existujúcemu serveru, ktorý podporuje kĺzavú synchronizáciu. Správca domovského servera ju bude musieť nakonfigurovať. %1$s"</string>
<string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string> <string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string>
<string name="screen_change_server_title">"Vyberte svoj server"</string> <string name="screen_change_server_title">"Vyberte svoj server"</string>
<string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string> <string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string>

View file

@ -18,9 +18,9 @@ package io.element.android.features.logout.impl
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@ -29,11 +29,8 @@ import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
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.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.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
@ -41,15 +38,15 @@ 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.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.OutlinedButton 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.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
import io.element.android.libraries.designsystem.utils.CommonDrawables 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.BackupUploadState
import io.element.android.libraries.matrix.api.encryption.SteadyStateException import io.element.android.libraries.matrix.api.encryption.SteadyStateException
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun LogoutView( fun LogoutView(
state: LogoutState, state: LogoutState,
@ -60,29 +57,23 @@ fun LogoutView(
) { ) {
val eventSink = state.eventSink val eventSink = state.eventSink
HeaderFooterPage( FlowStepPage(
onBackClicked = onBackClicked,
title = title(state),
subTitle = subtitle(state),
iconResourceId = CommonDrawables.ic_key,
modifier = modifier, modifier = modifier,
topBar = { content = { Content(state) },
TopAppBar( buttons = {
navigationIcon = { BackButton(onClick = onBackClicked) }, Buttons(
title = {},
)
},
header = {
HeaderContent(state = state)
},
footer = {
BottomMenu(
state = state, state = state,
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked, onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
onLogoutClicked = { onLogoutClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = false)) eventSink(LogoutEvents.Logout(ignoreSdkError = false))
}, }
) )
} },
) { )
Content(state = state)
}
// Log out confirmation dialog // Log out confirmation dialog
if (state.showConfirmationDialog) { if (state.showConfirmationDialog) {
@ -90,9 +81,6 @@ fun LogoutView(
title = stringResource(id = CommonStrings.action_signout), title = stringResource(id = CommonStrings.action_signout),
content = stringResource(id = R.string.screen_signout_confirmation_dialog_content), content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
submitText = stringResource(id = CommonStrings.action_signout), submitText = stringResource(id = CommonStrings.action_signout),
onCancelClicked = {
eventSink(LogoutEvents.CloseDialogs)
},
onSubmitClicked = { onSubmitClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = false)) eventSink(LogoutEvents.Logout(ignoreSdkError = false))
}, },
@ -110,9 +98,6 @@ fun LogoutView(
title = stringResource(id = CommonStrings.dialog_title_error), title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(id = CommonStrings.error_unknown), content = stringResource(id = CommonStrings.error_unknown),
submitText = stringResource(id = CommonStrings.action_signout_anyway), submitText = stringResource(id = CommonStrings.action_signout_anyway),
onCancelClicked = {
eventSink(LogoutEvents.CloseDialogs)
},
onSubmitClicked = { onSubmitClicked = {
eventSink(LogoutEvents.Logout(ignoreSdkError = true)) eventSink(LogoutEvents.Logout(ignoreSdkError = true))
}, },
@ -130,30 +115,23 @@ fun LogoutView(
} }
@Composable @Composable
private fun HeaderContent( private fun title(state: LogoutState): String {
state: LogoutState, return when {
modifier: Modifier = Modifier,
) {
val title = when {
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title) 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) state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title)
else -> stringResource(CommonStrings.action_signout) else -> stringResource(CommonStrings.action_signout)
} }
val subtitle = when { }
@Composable
private fun subtitle(state: LogoutState): String? {
return when {
(state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection -> (state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection ->
stringResource(id = R.string.screen_signout_key_backup_offline_subtitle) stringResource(id = R.string.screen_signout_key_backup_offline_subtitle)
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_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) state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle)
else -> null 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 { private fun BackupUploadState.isBackingUp(): Boolean {
@ -169,35 +147,33 @@ private fun BackupUploadState.isBackingUp(): Boolean {
} }
@Composable @Composable
private fun BottomMenu( private fun ColumnScope.Buttons(
state: LogoutState, state: LogoutState,
onLogoutClicked: () -> Unit, onLogoutClicked: () -> Unit,
onChangeRecoveryKeyClicked: () -> Unit, onChangeRecoveryKeyClicked: () -> Unit,
) { ) {
val logoutAction = state.logoutAction val logoutAction = state.logoutAction
ButtonColumnMolecule( if (state.isLastSession) {
modifier = Modifier.padding(bottom = 20.dp) OutlinedButton(
) { text = stringResource(id = CommonStrings.common_settings),
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(), modifier = Modifier.fillMaxWidth(),
onClick = onLogoutClicked, 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()
.testTag(TestTags.signOut),
onClick = onLogoutClicked,
)
} }
@Composable @Composable
@ -213,7 +189,7 @@ private fun Content(
) { ) {
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
progress = state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat(), progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
trackColor = ElementTheme.colors.progressIndicatorTrackColor, trackColor = ElementTheme.colors.progressIndicatorTrackColor,
) )
Text( Text(

View file

@ -3,6 +3,16 @@
<string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string> <string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string>
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string> <string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string> <string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám."</string>
<string name="screen_signout_key_backup_disabled_title">"Vypnuli jste zálohování"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Když jste přešli do režimu offline, vaše klíče se ještě stále zálohovaly. Znovu se připojte, aby bylo možné před odhlášením zálohovat vaše klíče."</string>
<string name="screen_signout_key_backup_offline_title">"Vaše klíče jsou stále zálohovány"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Před odhlášením prosím počkejte na dokončení."</string>
<string name="screen_signout_key_backup_ongoing_title">"Vaše klíče jsou stále zálohovány"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám."</string>
<string name="screen_signout_recovery_disabled_title">"Obnovení není nastaveno"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, můžete ztratit přístup k šifrovaným zprávám."</string>
<string name="screen_signout_save_recovery_key_title">"Uložili jste si klíč pro obnovení?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string> <string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string>
<string name="screen_signout_preference_item">"Odhlásit se"</string> <string name="screen_signout_preference_item">"Odhlásit se"</string>
</resources> </resources>

View file

@ -3,6 +3,16 @@
<string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string> <string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string> <string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string> <string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
<string name="screen_signout_key_backup_disabled_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям."</string>
<string name="screen_signout_key_backup_disabled_title">"Вы отключили резервное копирование"</string>
<string name="screen_signout_key_backup_offline_subtitle">"Когда вы перешли в автономный режим, резервное копирование ваших ключей продолжалось. Повторно подключитесь, чтобы перед выходом из системы можно было создать резервную копию ключей."</string>
<string name="screen_signout_key_backup_offline_title">"Резервное копирование ключей все еще продолжается"</string>
<string name="screen_signout_key_backup_ongoing_subtitle">"Пожалуйста, дождитесь завершения процесса, прежде чем выходить из системы."</string>
<string name="screen_signout_key_backup_ongoing_title">"Резервное копирование ключей все еще продолжается"</string>
<string name="screen_signout_recovery_disabled_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям."</string>
<string name="screen_signout_recovery_disabled_title">"Восстановление не настроено"</string>
<string name="screen_signout_save_recovery_key_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы можете потерять доступ к зашифрованным сообщениям."</string>
<string name="screen_signout_save_recovery_key_title">"Вы сохранили свой ключ восстановления?"</string>
<string name="screen_signout_confirmation_dialog_submit">"Выйти"</string> <string name="screen_signout_confirmation_dialog_submit">"Выйти"</string>
<string name="screen_signout_preference_item">"Выйти"</string> <string name="screen_signout_preference_item">"Выйти"</string>
</resources> </resources>

View file

@ -75,6 +75,7 @@ dependencies {
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.dateformatter.test) testImplementation(projects.libraries.dateformatter.test)
testImplementation(projects.features.networkmonitor.test) testImplementation(projects.features.networkmonitor.test)
testImplementation(projects.features.messages.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(projects.libraries.featureflag.test) testImplementation(projects.libraries.featureflag.test)

View file

@ -170,9 +170,8 @@ private fun CustomSheetState.getIntOffset(): Int? = try {
null null
} }
private sealed class Slot { private sealed interface Slot {
data class SheetContent(val key: Int?) : Slot() data class SheetContent(val key: Int?) : Slot
data object DragHandle : Slot() data object DragHandle : Slot
data object Scaffold : Slot() data object Scaffold : Slot
} }

View file

@ -311,7 +311,7 @@ class MessagesPresenter @AssistedInject constructor(
val textContent = messageSummaryFormatter.format(targetEvent) val textContent = messageSummaryFormatter.format(targetEvent)
val attachmentThumbnailInfo = when (targetEvent.content) { val attachmentThumbnailInfo = when (targetEvent.content) {
is TimelineItemImageContent -> AttachmentThumbnailInfo( is TimelineItemImageContent -> AttachmentThumbnailInfo(
thumbnailSource = targetEvent.content.thumbnailSource, thumbnailSource = targetEvent.content.thumbnailSource ?: targetEvent.content.mediaSource,
textContent = targetEvent.content.body, textContent = targetEvent.content.body,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = targetEvent.content.blurhash, blurHash = targetEvent.content.blurhash,

View file

@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.Offset import androidx.compose.ui.geometry.Offset
@ -104,6 +105,7 @@ import io.element.android.libraries.theme.ElementTheme
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import timber.log.Timber import timber.log.Timber
import kotlin.random.Random
import androidx.compose.material3.Button as Material3Button import androidx.compose.material3.Button as Material3Button
@Composable @Composable
@ -339,6 +341,16 @@ private fun MessagesViewContent(
) )
} }
// This key is used to force the sheet to be remeasured when the content changes.
// Any state change that should trigger a height size should be added to the list of remembered values here.
val sheetResizeContentKey = remember(
state.composerState.mode.relatedEventId,
state.composerState.richTextEditorState.lineCount,
state.composerState.memberSuggestions.size
) {
Random.nextInt()
}
ExpandableBottomSheetScaffold( ExpandableBottomSheetScaffold(
sheetDragHandle = if (state.composerState.showTextFormatting) { sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() } @Composable { BottomSheetDragHandle() }
@ -371,7 +383,7 @@ private fun MessagesViewContent(
state = state, state = state,
) )
}, },
sheetContentKey = state.composerState.richTextEditorState.lineCount + state.composerState.memberSuggestions.size, sheetContentKey = sheetResizeContentKey,
sheetTonalElevation = 0.dp, sheetTonalElevation = 0.dp,
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp, sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
) )

View file

@ -260,7 +260,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
AttachmentThumbnail( AttachmentThumbnail(
modifier = imageModifier, modifier = imageModifier,
info = AttachmentThumbnailInfo( info = AttachmentThumbnailInfo(
thumbnailSource = event.content.mediaSource, thumbnailSource = event.content.thumbnailSource ?: event.content.mediaSource,
textContent = textContent, textContent = textContent,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = event.content.blurhash, blurHash = event.content.blurhash,

View file

@ -20,6 +20,7 @@ import android.Manifest
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.net.Uri import android.net.Uri
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@ -409,6 +410,7 @@ class MessageComposerPresenter @Inject constructor(
} }
} }
@Immutable
sealed interface RoomMemberSuggestion { sealed interface RoomMemberSuggestion {
data object Room : RoomMemberSuggestion data object Room : RoomMemberSuggestion
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion data class Member(val roomMember: RoomMember) : RoomMemberSuggestion

View file

@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Switch import androidx.compose.material3.Switch
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager import androidx.compose.ui.platform.LocalFocusManager
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
@ -42,10 +41,10 @@ import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.components.async.AsyncView
import io.element.android.libraries.designsystem.components.button.BackButton 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.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview 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.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
@ -64,21 +63,13 @@ fun ReportMessageView(
) { ) {
val focusManager = LocalFocusManager.current val focusManager = LocalFocusManager.current
val isSending = state.result is Async.Loading val isSending = state.result is Async.Loading
when (state.result) { AsyncView(
is Async.Success -> { async = state.result,
LaunchedEffect(state.result) { showProgressDialog = false,
onBackClicked() onSuccess = { onBackClicked() },
} errorMessage = { stringResource(CommonStrings.error_unknown) },
return onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
} )
is Async.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
)
}
else -> Unit
}
Scaffold( Scaffold(
topBar = { topBar = {

View file

@ -32,6 +32,7 @@ import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip import androidx.compose.ui.draw.clip
@ -44,8 +45,8 @@ import io.element.android.features.messages.impl.R
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.Icon 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.Surface
@ -55,6 +56,7 @@ import io.element.android.libraries.theme.ElementTheme
@Composable @Composable
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Suppress("ModifierClickableOrder") // This is needed to display the right ripple shape
fun MessagesReactionButton( fun MessagesReactionButton(
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
@ -102,11 +104,12 @@ fun MessagesReactionButton(
} }
} }
sealed class MessagesReactionsButtonContent { @Immutable
data class Text(val text: String) : MessagesReactionsButtonContent() sealed interface MessagesReactionsButtonContent {
data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent() data class Text(val text: String) : MessagesReactionsButtonContent
data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent
data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent() data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent
val isHighlighted get() = this is Reaction && reaction.isHighlighted val isHighlighted get() = this is Reaction && reaction.isHighlighted
} }

View file

@ -374,7 +374,9 @@ private fun MessageEventBubbleContent(
inReplyToClick: () -> Unit, inReplyToClick: () -> Unit,
onTimestampClicked: () -> Unit, onTimestampClicked: () -> Unit,
eventSink: (TimelineEvents) -> Unit, eventSink: (TimelineEvents) -> Unit,
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones @SuppressLint("ModifierParameter")
@Suppress("ModifierNaming")
bubbleModifier: Modifier = Modifier, // need to rename this modifier to prevent linter false positives
) { ) {
// Long clicks are not not automatically propagated from a `clickable` // Long clicks are not not automatically propagated from a `clickable`
@ -593,7 +595,7 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
val messageContent = inReplyTo.content as? MessageContent ?: return null val messageContent = inReplyTo.content as? MessageContent ?: return null
return when (val type = messageContent.type) { return when (val type = messageContent.type) {
is ImageMessageType -> AttachmentThumbnailInfo( is ImageMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource, thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = messageContent.body, textContent = messageContent.body,
type = AttachmentThumbnailType.Image, type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash, blurHash = type.info?.blurhash,

View file

@ -20,8 +20,8 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@ -33,7 +33,7 @@ fun TimelineItemEncryptedView(
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_decryption_error), text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CommonDrawables.ic_september_decryption_error, iconResourceId = CommonDrawables.ic_september_decryption_error,
extraPadding = extraPadding, extraPadding = extraPadding,

View file

@ -34,7 +34,7 @@ fun TimelineItemUnknownView(
TimelineItemInformativeView( TimelineItemInformativeView(
text = stringResource(id = CommonStrings.common_unsupported_event), text = stringResource(id = CommonStrings.common_unsupported_event),
iconDescription = stringResource(id = CommonStrings.dialog_title_warning), iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
iconResourceId = CommonDrawables.ic_compound_info, iconResourceId = CommonDrawables.ic_compound_info_solid,
extraPadding = extraPadding, extraPadding = extraPadding,
modifier = modifier modifier = modifier
) )

View file

@ -56,7 +56,7 @@ fun TimelineEncryptedHistoryBannerView(
) { ) {
Icon( Icon(
modifier = Modifier.size(20.dp), modifier = Modifier.size(20.dp),
resourceId = CommonDrawables.ic_compound_info, resourceId = CommonDrawables.ic_compound_info_solid,
contentDescription = "Info", contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary tint = ElementTheme.colors.iconInfoPrimary
) )
@ -79,7 +79,7 @@ private fun SessionState.toStringResId(): Int {
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun TimelineEncryptedHistoryBannerViewPreview( internal fun EncryptedHistoryBannerViewPreview(
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState, @PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
) = ElementPreview { ) = ElementPreview {
TimelineEncryptedHistoryBannerView(sessionState = sessionState) TimelineEncryptedHistoryBannerView(sessionState = sessionState)

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import androidx.media3.common.MimeTypes import androidx.media3.common.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> { open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
override val values: Sequence<TimelineItemImageContent> override val values: Sequence<TimelineItemImageContent>
@ -34,7 +35,7 @@ fun aTimelineItemImageContent() = TimelineItemImageContent(
mediaSource = MediaSource(""), mediaSource = MediaSource(""),
thumbnailSource = null, thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG, mimeType = MimeTypes.IMAGE_JPEG,
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", blurhash = A_BLUR_HASH,
width = null, width = null,
height = 300, height = 300,
aspectRatio = 0.5f, aspectRatio = 0.5f,

View file

@ -16,6 +16,9 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
@Immutable
sealed interface TimelineItemStateContent : TimelineItemEventContent { sealed interface TimelineItemStateContent : TimelineItemEventContent {
val body: String val body: String
} }

View file

@ -16,8 +16,10 @@
package io.element.android.features.messages.impl.timeline.model.event package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import org.jsoup.nodes.Document import org.jsoup.nodes.Document
@Immutable
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent { sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String val body: String
val htmlDocument: Document? val htmlDocument: Document?

View file

@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> { open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
override val values: Sequence<TimelineItemVideoContent> override val values: Sequence<TimelineItemVideoContent>
@ -32,7 +33,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent( fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "Video.mp4", body = "Video.mp4",
thumbnailSource = null, thumbnailSource = null,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr", blurHash = A_BLUR_HASH,
aspectRatio = 0.5f, aspectRatio = 0.5f,
duration = 100, duration = 100,
videoSource = MediaSource(""), videoSource = MediaSource(""),

View file

@ -36,13 +36,13 @@ class VoiceMessageComposerPlayer @Inject constructor(
val state: Flow<State> = mediaPlayer.state.map { state -> val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) { if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotPlaying return@map State.NotLoaded
} }
State( State(
isPlaying = state.isPlaying, isPlaying = state.isPlaying,
currentPosition = state.currentPosition, currentPosition = state.currentPosition,
duration = state.duration, duration = state.duration ?: 0L,
) )
}.distinctUntilChanged() }.distinctUntilChanged()
@ -52,16 +52,17 @@ class VoiceMessageComposerPlayer @Inject constructor(
* @param mediaPath The path to the media to be played. * @param mediaPath The path to the media to be played.
* @param mimeType The mime type of the media file. * @param mimeType The mime type of the media file.
*/ */
fun play(mediaPath: String, mimeType: String) { suspend fun play(mediaPath: String, mimeType: String) {
if (mediaPath == curPlayingMediaId) { if (mediaPath == curPlayingMediaId) {
mediaPlayer.play() mediaPlayer.play()
} else { } else {
lastPlayedMediaPath = mediaPath lastPlayedMediaPath = mediaPath
mediaPlayer.acquireControlAndPlay( mediaPlayer.setMedia(
uri = mediaPath, uri = mediaPath,
mediaId = mediaPath, mediaId = mediaPath,
mimeType = mimeType, mimeType = mimeType,
) )
mediaPlayer.play()
} }
} }
@ -89,17 +90,23 @@ class VoiceMessageComposerPlayer @Inject constructor(
val duration: Long, val duration: Long,
) { ) {
companion object { companion object {
val NotPlaying = State( val NotLoaded = State(
isPlaying = false, isPlaying = false,
currentPosition = 0L, currentPosition = 0L,
duration = 0L, duration = 0L,
) )
} }
val isLoaded get() = this != NotLoaded
/** /**
* The progress of this player between 0 and 1. * The progress of this player between 0 and 1.
*/ */
val progress: Float = val progress: Float =
if (duration <= currentPosition) 0f else currentPosition.toFloat() / duration.toFloat() if (duration == 0L)
0f
else
(currentPosition.toFloat() / duration.toFloat())
.coerceAtMost(1f) // Current position may exceed reported duration
} }
} }

View file

@ -27,6 +27,8 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue import androidx.compose.runtime.setValue
import androidx.core.net.toUri import androidx.core.net.toUri
import androidx.lifecycle.Lifecycle import androidx.lifecycle.Lifecycle
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
@ -40,14 +42,16 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.api.VoiceRecorder import io.element.android.libraries.voicerecorder.api.VoiceRecorder
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import timber.log.Timber import timber.log.Timber
import java.io.File import java.io.File
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@SingleIn(RoomScope::class) @SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor( class VoiceMessageComposerPresenter @Inject constructor(
@ -56,6 +60,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService, private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender, private val mediaSender: MediaSender,
private val player: VoiceMessageComposerPlayer, private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext,
permissionsPresenterFactory: PermissionsPresenter.Factory permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<VoiceMessageComposerState> { ) : Presenter<VoiceMessageComposerState> {
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO) private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
@ -68,8 +73,8 @@ class VoiceMessageComposerPresenter @Inject constructor(
val permissionState = permissionsPresenter.present() val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) } var isSending by remember { mutableStateOf(false) }
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying) val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } } val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } } val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
val onLifecycleEvent = { event: Lifecycle.Event -> val onLifecycleEvent = { event: Lifecycle.Event ->
@ -115,10 +120,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
VoiceMessagePlayerEvent.Play -> VoiceMessagePlayerEvent.Play ->
when (val recording = recorderState) { when (val recording = recorderState) {
is VoiceRecorderState.Finished -> is VoiceRecorderState.Finished ->
player.play( localCoroutineScope.launch {
mediaPath = recording.file.path, player.play(
mimeType = recording.mimeType, mediaPath = recording.file.path,
) mimeType = recording.mimeType,
)
}
else -> Timber.e("Voice message player event received but no file to play") else -> Timber.e("Voice message player event received but no file to play")
} }
VoiceMessagePlayerEvent.Pause -> { VoiceMessagePlayerEvent.Pause -> {
@ -151,6 +158,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
} }
isSending = true isSending = true
player.pause() player.pause()
analyticsService.captureComposerEvent()
appCoroutineScope.sendMessage( appCoroutineScope.sendMessage(
file = finishedState.file, file = finishedState.file,
mimeType = finishedState.mimeType, mimeType = finishedState.mimeType,
@ -185,8 +193,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
) )
is VoiceRecorderState.Finished -> VoiceMessageState.Preview( is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending, isSending = isSending,
isPlaying = isPlaying, isPlaying = playerState.isPlaying,
showCursor = playerState.isLoaded && !isSending,
playbackProgress = playerState.progress, playbackProgress = playerState.progress,
time = playerTime,
waveform = waveform, waveform = waveform,
) )
else -> VoiceMessageState.Idle else -> VoiceMessageState.Idle
@ -236,6 +246,16 @@ class VoiceMessageComposerPresenter @Inject constructor(
voiceRecorder.deleteRecording() voiceRecorder.deleteRecording()
} }
private fun AnalyticsService.captureComposerEvent() =
analyticsService.capture(
Composer(
inThread = messageComposerContext.composerMode.inThread,
isEditing = messageComposerContext.composerMode.isEditing,
isReply = messageComposerContext.composerMode.isReply,
messageType = Composer.MessageType.VoiceMessage,
)
)
} }
private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> = private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
@ -243,3 +263,20 @@ private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
?.waveform ?.waveform
.orEmpty() .orEmpty()
.toImmutableList() .toImmutableList()
/**
* The time to display depending on the player state.
*
* Either the current position or total duration.
*/
private fun displayTime(
playerState: VoiceMessageComposerPlayer.State,
recording: VoiceRecorderState
): Duration = when {
playerState.isLoaded ->
playerState.currentPosition.milliseconds
recording is VoiceRecorderState.Finished ->
recording.duration
else ->
0.milliseconds
}

View file

@ -24,7 +24,6 @@ import io.element.android.libraries.di.CacheDirectory
import io.element.android.libraries.di.RoomScope import io.element.android.libraries.di.RoomScope
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader 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.MediaSource
import io.element.android.libraries.matrix.api.media.toFile
import java.io.File import java.io.File
/** /**
@ -83,28 +82,29 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
): DefaultVoiceMessageMediaRepo ): DefaultVoiceMessageMediaRepo
} }
override suspend fun getMediaFile(): Result<File> = if (!isInCache()) { override suspend fun getMediaFile(): Result<File> = when {
matrixMediaLoader.downloadMediaFile( cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
cachedFile.exists() -> Result.success(cachedFile)
else -> matrixMediaLoader.downloadMediaFile(
source = mediaSource, source = mediaSource,
mimeType = mimeType, mimeType = mimeType,
body = body, body = body,
useCache = false,
).mapCatching { ).mapCatching {
val dest = cachedFilePath.apply { parentFile?.mkdirs() } it.use { mediaFile ->
// TODO By not closing the MediaFile we're leaking the rust file handle here. val dest = cachedFile.apply { parentFile?.mkdirs() }
// Not that big of a deal but better to avoid it someday. if (mediaFile.persist(dest.path)) {
if (it.toFile().renameTo(dest)) { dest
dest } else {
} else { error("Failed to move file to cache.")
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 val cachedFile: File? = mxcUri2FilePath(mediaSource.url)?.let {
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
private fun isInCache(): Boolean = cachedFilePath.exists() }
} }
/** /**
@ -123,12 +123,9 @@ private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
* Sanitizes an mxcUri to be used as a relative file path. * Sanitizes an mxcUri to be used as a relative file path.
* *
* @param mxcUri the Matrix Content (mxc://) URI of the voice message. * @param mxcUri the Matrix Content (mxc://) URI of the voice message.
* @return the relative file path as "<server-name>/<media-id>". * @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
* @throws IllegalStateException if the mxcUri is invalid.
*/ */
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) { private fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
}.let { match ->
buildString { buildString {
append(match.groupValues[1]) append(match.groupValues[1])
append("/") append("/")

View file

@ -151,11 +151,12 @@ class DefaultVoiceMessagePlayer(
} else { } else {
if (eventId != null) { if (eventId != null) {
repo.getMediaFile().mapCatching { mediaFile -> repo.getMediaFile().mapCatching { mediaFile ->
mediaPlayer.acquireControlAndPlay( mediaPlayer.setMedia(
uri = mediaFile.path, uri = mediaFile.path,
mediaId = eventId.value, mediaId = eventId.value,
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually. mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
) )
mediaPlayer.play()
} }
} else { } else {
Result.failure(IllegalStateException("Cannot play a voice message with no eventId")) Result.failure(IllegalStateException("Cannot play a voice message with no eventId"))

View file

@ -5,6 +5,7 @@
<item quantity="few">"%1$d změny místnosti"</item> <item quantity="few">"%1$d změny místnosti"</item>
<item quantity="other">"%1$d změn místnosti"</item> <item quantity="other">"%1$d změn místnosti"</item>
</plurals> </plurals>
<string name="screen_room_mentions_at_room_subtitle">"Informujte celou místnost"</string>
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string> <string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string> <string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string> <string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
@ -14,6 +15,7 @@
<string name="screen_room_attachment_source_poll">"Hlasování"</string> <string name="screen_room_attachment_source_poll">"Hlasování"</string>
<string name="screen_room_attachment_text_formatting">"Formátování textu"</string> <string name="screen_room_attachment_text_formatting">"Formátování textu"</string>
<string name="screen_room_encrypted_history_banner">"Historie zpráv je momentálně v této místnosti nedostupná"</string> <string name="screen_room_encrypted_history_banner">"Historie zpráv je momentálně v této místnosti nedostupná"</string>
<string name="screen_room_encrypted_history_banner_unverified">"Historie zpráv není v této místnosti k dispozici. Ověřte toto zařízení, abyste viděli historii zpráv."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string> <string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
<string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string> <string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string>
<string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string> <string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string>

View file

@ -5,6 +5,7 @@
<item quantity="few">"%1$d изменения в комнате"</item> <item quantity="few">"%1$d изменения в комнате"</item>
<item quantity="many">"%1$d изменений в комнате"</item> <item quantity="many">"%1$d изменений в комнате"</item>
</plurals> </plurals>
<string name="screen_room_mentions_at_room_subtitle">"Уведомить всю комнату"</string>
<string name="screen_room_attachment_source_camera">"Камера"</string> <string name="screen_room_attachment_source_camera">"Камера"</string>
<string name="screen_room_attachment_source_camera_photo">"Сделать фото"</string> <string name="screen_room_attachment_source_camera_photo">"Сделать фото"</string>
<string name="screen_room_attachment_source_camera_video">"Записать видео"</string> <string name="screen_room_attachment_source_camera_video">"Записать видео"</string>
@ -14,6 +15,7 @@
<string name="screen_room_attachment_source_poll">"Опрос"</string> <string name="screen_room_attachment_source_poll">"Опрос"</string>
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string> <string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате"</string> <string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате"</string>
<string name="screen_room_encrypted_history_banner_unverified">"История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений."</string>
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string> <string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
<string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string> <string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string>
<string name="screen_room_invite_again_alert_title">"Вы одни в этой комнате"</string> <string name="screen_room_invite_again_alert_title">"Вы одни в этой комнате"</string>

View file

@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer 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.VoiceMessageComposerPresenter
import io.element.android.features.messages.media.FakeLocalMediaFactory import io.element.android.features.messages.media.FakeLocalMediaFactory
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
@ -641,6 +642,7 @@ class MessagesPresenterTest {
analyticsService, analyticsService,
mediaSender, mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()), player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
messageComposerContext = FakeMessageComposerContext(),
permissionsPresenterFactory, permissionsPresenterFactory,
) )
val timelinePresenter = TimelinePresenter( val timelinePresenter = TimelinePresenter(

View file

@ -25,11 +25,16 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException 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.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer 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.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.features.messages.test.FakeMessageComposerContext
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_USER_NAME
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaupload.api.MediaSender
@ -38,19 +43,21 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
import io.element.android.libraries.permissions.api.aPermissionsState import io.element.android.libraries.permissions.api.aPermissionsState
import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
import io.element.android.libraries.textcomposer.model.MessageComposerMode
import io.element.android.libraries.textcomposer.model.PressEvent import io.element.android.libraries.textcomposer.model.PressEvent
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest { class VoiceMessageComposerPresenterTest {
@ -65,6 +72,7 @@ class VoiceMessageComposerPresenterTest {
private val matrixRoom = FakeMatrixRoom() private val matrixRoom = FakeMatrixRoom()
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() } private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom) private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
private val messageComposerContext = FakeMessageComposerContext()
companion object { companion object {
private val RECORDING_DURATION = 1.seconds private val RECORDING_DURATION = 1.seconds
@ -171,7 +179,7 @@ class VoiceMessageComposerPresenterTest {
} }
// Nothing should happen // Nothing should happen
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(RECORDING_DURATION, RECORDING_STATE.levels)) assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
testPauseAndDestroy(finalState) testPauseAndDestroy(finalState)
@ -187,8 +195,9 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true, playbackProgress = 0.1f)) assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
} }
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@ -205,9 +214,10 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
val finalState = awaitItem().also { val finalState = awaitItem().also {
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f)) assertThat(it.voiceMessageState).isEqualTo(aPausedState())
} }
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0) voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
@ -242,9 +252,10 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply { awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f)) assertThat(voiceMessageState).isEqualTo(aPausedState())
} }
val finalState = awaitItem() val finalState = awaitItem()
@ -264,7 +275,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -275,6 +286,35 @@ class VoiceMessageComposerPresenterTest {
} }
} }
@Test
fun `present - sending is tracked`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
// Send a normal voice message
messageComposerContext.composerMode = MessageComposerMode.Normal
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
skipItems(1) // Sending state
// Now reply with a voice message
messageComposerContext.composerMode = aReplyMode()
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
val finalState = awaitItem() // Sending state
assertThat(analyticsService.capturedEvents).containsExactly(
aVoiceMessageComposerEvent(isReply = false),
aVoiceMessageComposerEvent(isReply = true)
)
testPauseAndDestroy(finalState)
}
}
@Test @Test
fun `present - send while playing`() = runTest { fun `present - send while playing`() = runTest {
val presenter = createVoiceMessageComposerPresenter() val presenter = createVoiceMessageComposerPresenter()
@ -284,10 +324,9 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd)) awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play)) awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage) awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState( assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
isSending = true, isPlaying = false, playbackProgress = 0.1f
))
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -310,7 +349,7 @@ class VoiceMessageComposerPresenterTest {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage) eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
} }
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem() val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle) assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@ -359,7 +398,7 @@ class VoiceMessageComposerPresenterTest {
val previewState = awaitItem() val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage) previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true)) assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
ensureAllEventsConsumed() ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState()) assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
@ -534,7 +573,7 @@ class VoiceMessageComposerPresenterTest {
is VoiceMessageState.Preview -> when (state.isPlaying) { is VoiceMessageState.Preview -> when (state.isPlaying) {
// If the preview was playing, it pauses // If the preview was playing, it pauses
true -> awaitItem().apply { true -> awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f)) assertThat(voiceMessageState).isEqualTo(aPausedState())
} }
false -> mostRecentState false -> mostRecentState
} }
@ -565,6 +604,7 @@ class VoiceMessageComposerPresenterTest {
analyticsService, analyticsService,
mediaSender, mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()), player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
messageComposerContext = messageComposerContext,
FakePermissionsPresenterFactory(permissionsPresenter), FakePermissionsPresenterFactory(permissionsPresenter),
) )
} }
@ -587,11 +627,55 @@ class VoiceMessageComposerPresenterTest {
isPlaying: Boolean = false, isPlaying: Boolean = false,
playbackProgress: Float = 0f, playbackProgress: Float = 0f,
isSending: Boolean = false, isSending: Boolean = false,
time: Duration = RECORDING_DURATION,
showCursor: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform, waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview( ) = VoiceMessageState.Preview(
isPlaying = isPlaying, isPlaying = isPlaying,
playbackProgress = playbackProgress, playbackProgress = playbackProgress,
isSending = isSending, isSending = isSending,
time = time,
showCursor = showCursor,
waveform = waveform.toImmutableList(), waveform = waveform.toImmutableList(),
) )
private fun aLoadedState() =
aPreviewState(
isPlaying = false,
playbackProgress = 0.0f,
showCursor = true,
time = 0.seconds,
)
private fun aPlayingState() =
aPreviewState(
isPlaying = true,
playbackProgress = 0.1f,
showCursor = true,
time = RECORDING_DURATION,
)
private fun aPausedState() =
aPlayingState()
.copy(isPlaying = false)
private fun VoiceMessageState.Preview.toSendingState() =
copy(
isPlaying = false,
isSending = true,
showCursor = false,
time = RECORDING_DURATION,
)
} }
private fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
private fun aVoiceMessageComposerEvent(
isReply: Boolean = false
) = Composer(
inThread = false,
isEditing = false,
isReply = isReply,
messageType = Composer.MessageType.VoiceMessage,
startsThread = null
)

View file

@ -63,7 +63,7 @@ class DefaultVoiceMessageMediaRepoTest {
repo.getMediaFile().let { result -> repo.getMediaFile().let { result ->
Truth.assertThat(result.isFailure).isTrue() Truth.assertThat(result.isFailure).isTrue()
result.exceptionOrNull()?.let { exception -> result.exceptionOrNull()!!.let { exception ->
Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java) Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
} }
} }
@ -116,16 +116,32 @@ class DefaultVoiceMessageMediaRepoTest {
} }
} }
} }
@Test
fun `invalid mxc uri returns a failure`() = runTest {
val repo = createDefaultVoiceMessageMediaRepo(
temporaryFolder = temporaryFolder,
mxcUri = INVALID_MXC_URI,
)
repo.getMediaFile().let { result ->
Truth.assertThat(result.isFailure).isTrue()
result.exceptionOrNull()!!.let { exception ->
Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
Truth.assertThat(exception).hasMessageThat().isEqualTo("Invalid mxcUri.")
}
}
}
} }
private fun createDefaultVoiceMessageMediaRepo( private fun createDefaultVoiceMessageMediaRepo(
temporaryFolder: TemporaryFolder, temporaryFolder: TemporaryFolder,
matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(), matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(),
mxcUri: String = MXC_URI,
) = DefaultVoiceMessageMediaRepo( ) = DefaultVoiceMessageMediaRepo(
cacheDir = temporaryFolder.root, cacheDir = temporaryFolder.root,
matrixMediaLoader = matrixMediaLoader, matrixMediaLoader = matrixMediaLoader,
mediaSource = MediaSource( mediaSource = MediaSource(
url = MXC_URI, url = mxcUri,
json = null json = null
), ),
mimeType = "audio/ogg", mimeType = "audio/ogg",
@ -133,6 +149,7 @@ private fun createDefaultVoiceMessageMediaRepo(
) )
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg" private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
private const val INVALID_MXC_URI = "notAnMxcUri"
private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg" private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply { private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
parentFile?.mkdirs() parentFile?.mkdirs()

View file

@ -47,6 +47,11 @@ class DefaultVoiceMessagePlayerTest {
player.state.test { player.state.test {
skipItems(1) // skip initial state. skipItems(1) // skip initial state.
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.play().isSuccess).isTrue()
awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(false)
Truth.assertThat(it.isMyMedia).isEqualTo(true)
Truth.assertThat(it.currentPosition).isEqualTo(0)
}
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(true) Truth.assertThat(it.isPlaying).isEqualTo(true)
Truth.assertThat(it.isMyMedia).isEqualTo(true) Truth.assertThat(it.isMyMedia).isEqualTo(true)
@ -85,7 +90,7 @@ class DefaultVoiceMessagePlayerTest {
player.state.test { player.state.test {
skipItems(1) // skip initial state. skipItems(1) // skip initial state.
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.play().isSuccess).isTrue()
skipItems(1) // skip play state skipItems(2) // skip play states
player.pause() player.pause()
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(false) Truth.assertThat(it.isPlaying).isEqualTo(false)
@ -101,7 +106,7 @@ class DefaultVoiceMessagePlayerTest {
player.state.test { player.state.test {
skipItems(1) // skip initial state. skipItems(1) // skip initial state.
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.play().isSuccess).isTrue()
skipItems(1) // skip play state skipItems(2) // skip play states
player.pause() player.pause()
skipItems(1) skipItems(1)
player.play() player.play()
@ -119,7 +124,7 @@ class DefaultVoiceMessagePlayerTest {
player.state.test { player.state.test {
skipItems(1) // skip initial state. skipItems(1) // skip initial state.
Truth.assertThat(player.play().isSuccess).isTrue() Truth.assertThat(player.play().isSuccess).isTrue()
skipItems(1) // skip play state skipItems(2) // skip play states
player.seekTo(2000) player.seekTo(2000)
awaitItem().let { awaitItem().let {
Truth.assertThat(it.isPlaying).isEqualTo(true) Truth.assertThat(it.isPlaying).isEqualTo(true)

View file

@ -70,6 +70,11 @@ class VoiceMessagePresenterTest {
Truth.assertThat(it.progress).isEqualTo(0f) Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:02") Truth.assertThat(it.time).isEqualTo("0:02")
} }
awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
Truth.assertThat(it.progress).isEqualTo(0f)
Truth.assertThat(it.time).isEqualTo("0:00")
}
awaitItem().also { awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f) Truth.assertThat(it.progress).isEqualTo(0.5f)
@ -128,7 +133,7 @@ class VoiceMessagePresenterTest {
} }
initialState.eventSink(VoiceMessageEvents.PlayPause) initialState.eventSink(VoiceMessageEvents.PlayPause)
skipItems(1) // skip downloading state skipItems(2) // skip downloading states
val playingState = awaitItem().also { val playingState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
@ -177,7 +182,7 @@ class VoiceMessagePresenterTest {
initialState.eventSink(VoiceMessageEvents.PlayPause) initialState.eventSink(VoiceMessageEvents.PlayPause)
skipItems(1) // skip downloading state skipItems(2) // skip downloading states
awaitItem().also { awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause) Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)

View file

@ -19,6 +19,6 @@ package io.element.android.features.messages.test
import io.element.android.features.messages.api.MessageComposerContext import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.MessageComposerMode
class MessageComposerContextFake( class FakeMessageComposerContext(
override var composerMode: MessageComposerMode = MessageComposerMode.Normal override var composerMode: MessageComposerMode = MessageComposerMode.Normal
) : MessageComposerContext ) : MessageComposerContext

View file

@ -33,8 +33,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview 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.Icon
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
@ -96,10 +96,12 @@ internal fun PollAnswerView(
LinearProgressIndicator( LinearProgressIndicator(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(), color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
progress = when { progress = {
answerItem.isDisclosed -> answerItem.percentage when {
answerItem.isSelected -> 1f answerItem.isDisclosed -> answerItem.percentage
else -> 0f answerItem.isSelected -> 1f
else -> 0f
}
}, },
trackColor = ElementTheme.colors.progressIndicatorTrackColor, trackColor = ElementTheme.colors.progressIndicatorTrackColor,
strokeCap = StrokeCap.Round, strokeCap = StrokeCap.Round,

Some files were not shown because too many files have changed in this diff Show more