Merge branch 'release/0.3.1' into main

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

View File

@@ -49,8 +49,8 @@ body:
id: version
attributes:
label: Application version and app store
description: You can find the version information in Settings -> Help & About.
placeholder: e.g. Element X version 1.7.34, olm version 3.2.3 from F-Droid
description: You can find the version information at the bottom of the Settings screen.
placeholder: e.g. Element X version 0.3.0
validations:
required: false
- type: input
@@ -67,7 +67,7 @@ body:
attributes:
label: Will you send logs?
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:
- 'Yes'
- 'No'

View File

@@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:
@@ -47,7 +47,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_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
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v3

View File

@@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:
@@ -35,7 +35,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Assemble debug APK
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew :app:assembleDebug $CI_GRADLE_ARG_PROPERTIES
env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}

View File

@@ -7,7 +7,7 @@ on:
- cron: "0 4 * * *"
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
jobs:

View File

@@ -8,7 +8,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:

View File

@@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:

View File

@@ -5,7 +5,7 @@ on:
# Enrich gradle.properties for CI/CD
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:
record:

View File

@@ -7,7 +7,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:

View File

@@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:

View File

@@ -9,7 +9,7 @@ on:
# Enrich gradle.properties for CI/CD
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
jobs:

2
.idea/kotlinc.xml generated
View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -17,6 +17,7 @@
package io.element.android.x.di
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.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope
@@ -27,4 +28,5 @@ interface AppBindings {
fun snackbarDispatcher(): SnackbarDispatcher
fun tracingService(): TracingService
fun bugReporter(): BugReporter
fun lockScreenService(): LockScreenService
}

View File

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

View File

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

View File

@@ -5,7 +5,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
buildscript {
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")
}
}
@@ -62,7 +62,7 @@ allprojects {
config.from(files("$rootDir/tools/detekt/detekt.yml"))
}
dependencies {
detektPlugins("io.nlopez.compose.rules:detekt:0.3.0")
detektPlugins("io.nlopez.compose.rules:detekt:0.3.3")
}
// KtLint

View File

@@ -1,2 +1,2 @@
Main changes in this version: TODO.
Full changelog: https://github.com/vector-im/element-x-android/releases
Main changes in this version: Element Call, voice message.
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
class AddPeoplePresenter @Inject constructor(
private val userListPresenterFactory: UserListPresenter.Factory,
private val userRepository: UserRepository,
private val dataStore: CreateRoomDataStore,
userListPresenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
dataStore: CreateRoomDataStore,
) : Presenter<UserListState> {
private val userListPresenter by lazy {
userListPresenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Multiple,
),
userRepository,
dataStore.selectedUserListDataStore,
)
}
private val userListPresenter = userListPresenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Multiple,
),
userRepository,
dataStore.selectedUserListDataStore,
)
@Composable
override fun present(): UserListState {

View File

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

View File

@@ -40,23 +40,21 @@ import kotlinx.coroutines.launch
import javax.inject.Inject
class CreateRoomRootPresenter @Inject constructor(
private val presenterFactory: UserListPresenter.Factory,
private val userRepository: UserRepository,
private val userListDataStore: UserListDataStore,
presenterFactory: UserListPresenter.Factory,
userRepository: UserRepository,
userListDataStore: UserListDataStore,
private val matrixClient: MatrixClient,
private val analyticsService: AnalyticsService,
private val buildMeta: BuildMeta,
) : Presenter<CreateRoomRootState> {
private val presenter by lazy {
presenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
userListDataStore,
)
}
private val presenter = presenterFactory.create(
UserListPresenterArgs(
selectionMode = SelectionMode.Single,
),
userRepository,
userListDataStore,
)
@Composable
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.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
@@ -38,12 +37,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.features.createroom.impl.R
import io.element.android.features.createroom.impl.components.UserListView
import io.element.android.libraries.architecture.Async
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.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.PreviewsDayNight
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.Scaffold
@@ -64,12 +61,6 @@ fun CreateRoomRootView(
onOpenDM: (RoomId) -> Unit = {},
onInviteFriendsClicked: () -> Unit = {},
) {
if (state.startDmAction is Async.Success) {
LaunchedEffect(state.startDmAction) {
onOpenDM(state.startDmAction.data)
}
}
Scaffold(
modifier = modifier.fillMaxWidth(),
topBar = {
@@ -102,26 +93,19 @@ fun CreateRoomRootView(
}
}
when (state.startDmAction) {
is Async.Loading -> {
ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat))
}
is Async.Failure -> {
RetryDialog(
content = stringResource(id = R.string.screen_start_chat_error_starting_chat),
onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
onRetry = {
state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
)
}
else -> Unit
}
AsyncView(
async = state.startDmAction,
progressText = stringResource(CommonStrings.common_starting_chat),
onSuccess = { onOpenDM(it) },
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
onRetry = {
state.userListState.selectedUsers.firstOrNull()
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
// Cancel start DM if there is no more selected user (should not happen)
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
},
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
)
}
@OptIn(ExperimentalMaterial3Api::class)

View File

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

View File

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

View File

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

View File

@@ -120,7 +120,7 @@ class DefaultFtueState @Inject constructor(
private fun shouldDisplayLockscreenSetup(): Boolean {
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.rememberScrollState
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.ui.Alignment
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.InfoListOrganism
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
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.testTag
import io.element.android.libraries.theme.ElementTheme
@@ -79,14 +76,7 @@ fun WelcomeView(
color = ElementTheme.colors.textPrimary,
textAlign = TextAlign.Center,
)
Spacer(modifier = Modifier.height(8.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))
Spacer(modifier = Modifier.height(80.dp))
InfoListOrganism(
items = listItems(),
textStyle = ElementTheme.typography.fontBodyMdMedium,
@@ -109,17 +99,13 @@ fun WelcomeView(
@Composable
private fun listItems() = persistentListOf(
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_1),
iconVector = Icons.Outlined.NewReleases,
),
InfoListItem(
message = stringResource(R.string.screen_welcome_bullet_2),
iconVector = Icons.Outlined.Lock,
iconId = CommonDrawables.ic_lock_outline,
),
InfoListItem(
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_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_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_button">"Let\'s go!"</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 migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(
@@ -64,13 +65,15 @@ class DefaultFtueStateTests {
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
welcomeState.setWelcomeScreenShown()
analyticsService.setDidAskUserConsent()
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
state.updateState()
assertThat(state.shouldDisplayFlow.value).isFalse()
@@ -85,6 +88,7 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val state = createState(
@@ -92,7 +96,8 @@ class DefaultFtueStateTests {
welcomeState = welcomeState,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
val steps = mutableListOf<FtueStep?>()
@@ -108,7 +113,11 @@ class DefaultFtueStateTests {
steps.add(state.getNextStep(steps.lastOrNull()))
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()))
analyticsService.setDidAskUserConsent()
@@ -119,6 +128,7 @@ class DefaultFtueStateTests {
FtueStep.MigrationScreen,
FtueStep.WelcomeScreen,
FtueStep.NotificationsOptIn,
FtueStep.LockscreenSetup,
FtueStep.AnalyticsOptIn,
null, // Final state
)
@@ -133,18 +143,20 @@ class DefaultFtueStateTests {
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
val lockScreenService = FakeLockScreenService()
val state = createState(
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService,
)
// Skip first 3 steps
// Skip first 4 steps
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
state.setWelcomeScreenShown()
permissionStateProvider.setPermissionGranted()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
@@ -160,18 +172,21 @@ class DefaultFtueStateTests {
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
val analyticsService = FakeAnalyticsService()
val migrationScreenStore = InMemoryMigrationScreenStore()
val lockScreenService = FakeLockScreenService()
val state = createState(
sdkIntVersion = Build.VERSION_CODES.M,
coroutineScope = coroutineScope,
analyticsService = analyticsService,
migrationScreenStore = migrationScreenStore,
lockScreenService = lockScreenService,
)
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
state.setWelcomeScreenShown()
lockScreenService.setIsPinSetup(true)
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
analyticsService.setDidAskUserConsent()

View File

@@ -16,11 +16,13 @@
package io.element.android.features.location.api
import android.annotation.SuppressLint
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
@SuppressLint("NewApi")
@Parcelize
data class Location(
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.PermissionsPresenterFake
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.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
@@ -49,7 +49,7 @@ class SendLocationPresenterTest {
private val permissionsPresenterFake = PermissionsPresenterFake()
private val fakeMatrixRoom = FakeMatrixRoom()
private val fakeAnalyticsService = FakeAnalyticsService()
private val messageComposerContextFake = MessageComposerContextFake()
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
@@ -58,7 +58,7 @@ class SendLocationPresenterTest {
},
room = fakeMatrixRoom,
analyticsService = fakeAnalyticsService,
messageComposerContext = messageComposerContextFake,
messageComposerContext = fakeMessageComposerContext,
locationActions = fakeLocationActions,
buildMeta = fakeBuildMeta,
)
@@ -379,7 +379,7 @@ class SendLocationPresenterTest {
shouldShowRationale = false,
)
)
messageComposerContextFake.apply {
fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit(
eventId = null, defaultContent = "", transactionId = null
)
@@ -425,7 +425,7 @@ class SendLocationPresenterTest {
shouldShowRationale = false,
)
)
messageComposerContextFake.apply {
fakeMessageComposerContext.apply {
composerMode = MessageComposerMode.Edit(
eventId = null, defaultContent = "", transactionId = null
)

View File

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

View File

@@ -16,7 +16,14 @@
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.launchIn
import kotlinx.coroutines.flow.onEach
interface LockScreenService {
/**
@@ -28,5 +35,34 @@ interface LockScreenService {
* Check if setting up the lock screen is required.
* @return true if the lock screen is mandatory and not setup yet, false otherwise.
*/
suspend fun isSetupRequired(): Boolean
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.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
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 javax.inject.Inject
import kotlin.time.Duration
@@ -113,14 +117,23 @@ class DefaultLockScreenService @Inject constructor(
}
}
override suspend fun isSetupRequired(): Boolean {
return lockScreenConfig.isPinMandatory
&& featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
&& !pinCodeManager.isPinCodeAvailable()
override fun isPinSetup(): Flow<Boolean> {
return combine(
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinUnlock),
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 {
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
if (isPinSetup().first()) {
delay(gracePeriod)
_lockScreenState.value = LockScreenLockState.Locked
}

View File

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

View File

@@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
import androidx.fragment.app.FragmentActivity
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
import io.element.android.libraries.cryptography.api.SecretKeyRepository
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CompletableDeferred
import timber.log.Timber
import java.security.InvalidKeyException
@@ -86,7 +87,12 @@ class DefaultBiometricUnlock(
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
val prompt = BiometricPrompt(activity, executor, callback)
prompt.authenticate(promptInfo, cryptoObject)
return deferredAuthenticationResult.await()
return try {
deferredAuthenticationResult.await()
} catch (cancellation: CancellationException) {
prompt.cancelAuthentication()
BiometricUnlock.AuthenticationResult.Failure(cancellation)
}
}
@Throws(KeyPermanentlyInvalidatedException::class)
@@ -110,7 +116,6 @@ private class AuthenticationCallback(
override fun onAuthenticationFailed() {
super.onAuthenticationFailed()
callbacks.forEach { it.onBiometricUnlockFailed(null) }
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
}
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.di.AppScope
import io.element.android.libraries.di.SingleIn
import kotlinx.coroutines.flow.Flow
import java.util.concurrent.CopyOnWriteArrayList
import javax.inject.Inject
@@ -46,7 +47,7 @@ class DefaultPinCodeManager @Inject constructor(
callbacks.remove(callback)
}
override suspend fun isPinCodeAvailable(): Boolean {
override fun hasPinCode(): Flow<Boolean> {
return lockScreenStore.hasPinCode()
}

View File

@@ -16,6 +16,8 @@
package io.element.android.features.lockscreen.impl.pin
import kotlinx.coroutines.flow.Flow
/**
* This interface is the main interface to manage the pin code.
* 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.
*/
suspend fun isPinCodeAvailable(): Boolean
fun hasPinCode(): Flow<Boolean>
/**
* @return the size of the saved pin code.

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -16,6 +16,8 @@
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.
* 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.
*/
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 ->
preferences[pinCodeKey] != null
}.first()
}
}
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
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins
import dagger.assisted.Assisted
import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode
@@ -35,15 +37,30 @@ class PinUnlockNode @AssistedInject constructor(
private val presenter: PinUnlockPresenter,
) : Node(buildContext, plugins = plugins) {
interface Callback : Plugin {
fun onUnlock()
}
data class Inputs(
val isInAppUnlock: Boolean
) : NodeInputs
private val inputs: Inputs = inputs()
private fun onUnlock() {
plugins<Callback>().forEach {
it.onUnlock()
}
}
@Composable
override fun View(modifier: Modifier) {
val state = presenter.present()
LaunchedEffect(state.isUnlocked) {
if (state.isUnlocked) {
onUnlock()
}
}
PinUnlockView(
state = state,
isInAppUnlock = inputs.isInAppUnlock,

View File

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

View File

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

View File

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

View File

@@ -1,4 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
</resources>

View File

@@ -10,6 +10,7 @@
</plurals>
<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_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_settings_change_pin">"Modifier le code PIN"</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_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_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>
</resources>

View File

@@ -1,4 +1,39 @@
<?xml version="1.0" encoding="utf-8"?>
<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>
</resources>

View File

@@ -1,5 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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">
<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>
@@ -7,6 +12,7 @@
</plurals>
<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_title_android">"Odomknúť pomocou biometrie"</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_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_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_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>
</resources>

View File

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

View File

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

View File

@@ -60,14 +60,14 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
state.onPinEntryChanged(halfCompletePin)
}
awaitItem().also { state ->
state.choosePinEntry.assertText(halfCompletePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
assertThat(state.isConfirmationStep).isFalse()
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
state.onPinEntryChanged(blacklistedPin)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(blacklistedPin)
@@ -77,7 +77,7 @@ class SetupPinPresenterTest {
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertEmpty()
assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
state.onPinEntryChanged(completePin)
}
consumeItemsUntilPredicate {
it.isConfirmationStep
@@ -85,7 +85,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
state.onPinEntryChanged(mismatchedPin)
}
awaitLastSequentialItem().also { state ->
state.choosePinEntry.assertText(completePin)
@@ -98,7 +98,7 @@ class SetupPinPresenterTest {
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isFalse()
assertThat(state.setupPinFailure).isNull()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
state.onPinEntryChanged(completePin)
}
consumeItemsUntilPredicate {
it.isConfirmationStep
@@ -106,7 +106,7 @@ class SetupPinPresenterTest {
state.choosePinEntry.assertText(completePin)
state.confirmPinEntry.assertEmpty()
assertThat(state.isConfirmationStep).isTrue()
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
state.onPinEntryChanged(completePin)
}
awaitItem().also { state ->
state.choosePinEntry.assertText(completePin)
@@ -116,6 +116,10 @@ class SetupPinPresenterTest {
}
}
private fun SetupPinState.onPinEntryChanged(pinEntry: String){
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
}
private fun createSetupPinPresenter(
callback: PinCodeManager.Callback,
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.tests.testutils.awaitLastSequentialItem
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.CompletableDeferred
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@@ -44,13 +43,7 @@ class PinUnlockPresenterTest {
@Test
fun `present - success verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
val presenter = createPinUnlockPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -58,6 +51,7 @@ class PinUnlockPresenterTest {
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.showWrongPinTitle).isFalse()
assertThat(state.showSignOutPrompt).isFalse()
assertThat(state.isUnlocked).isFalse()
assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
}
@@ -77,20 +71,14 @@ class PinUnlockPresenterTest {
}
awaitLastSequentialItem().also { state ->
state.pinEntry.assertText(completePin)
assertThat(state.isUnlocked).isTrue()
}
pinCodeVerified.await()
}
}
@Test
fun `present - failure verify flow`() = runTest {
val pinCodeVerified = CompletableDeferred<Unit>()
val callback = object : DefaultPinCodeManagerCallback() {
override fun onPinCodeCreated() {
pinCodeVerified.complete(Unit)
}
}
val presenter = createPinUnlockPresenter(this, callback = callback)
val presenter = createPinUnlockPresenter(this)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@@ -160,6 +148,7 @@ class PinUnlockPresenterTest {
biometricUnlockManager = biometricUnlockManager,
matrixClient = FakeMatrixClient(),
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.LockScreenService
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.map
class FakeLockScreenService : LockScreenService {
private var isSetupRequired: Boolean = false
private var isPinSetup = MutableStateFlow(false)
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
override val lockState: StateFlow<LockScreenLockState> = _lockState
override suspend fun isSetupRequired(): Boolean {
return isSetupRequired
override fun isSetupRequired(): Flow<Boolean> {
return isPinSetup.map { !it }
}
fun setIsSetupRequired(isSetupRequired: Boolean) {
this.isSetupRequired = isSetupRequired
fun setIsPinSetup(isPinSetup: Boolean) {
this.isPinSetup.value = isPinSetup
}
override fun isPinSetup(): Flow<Boolean> {
return isPinSetup
}
fun setLockState(lockState: LockScreenLockState) {

View File

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

View File

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

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

View File

@@ -3,6 +3,16 @@
<string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string>
<string name="screen_signout_confirmation_dialog_title">"Выйти"</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_preference_item">"Выйти"</string>
</resources>

View File

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

View File

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

View File

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

View File

@@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.DisposableEffect
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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 kotlinx.collections.immutable.ImmutableList
import timber.log.Timber
import kotlin.random.Random
import androidx.compose.material3.Button as Material3Button
@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(
sheetDragHandle = if (state.composerState.showTextFormatting) {
@Composable { BottomSheetDragHandle() }
@@ -371,7 +383,7 @@ private fun MessagesViewContent(
state = state,
)
},
sheetContentKey = state.composerState.richTextEditorState.lineCount + state.composerState.memberSuggestions.size,
sheetContentKey = sheetResizeContentKey,
sheetTonalElevation = 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(
modifier = imageModifier,
info = AttachmentThumbnailInfo(
thumbnailSource = event.content.mediaSource,
thumbnailSource = event.content.thumbnailSource ?: event.content.mediaSource,
textContent = textContent,
type = AttachmentThumbnailType.Image,
blurHash = event.content.blurhash,

View File

@@ -20,6 +20,7 @@ import android.Manifest
import android.annotation.SuppressLint
import android.net.Uri
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.getValue
@@ -409,6 +410,7 @@ class MessageComposerPresenter @Inject constructor(
}
}
@Immutable
sealed interface RoomMemberSuggestion {
data object Room : 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.Switch
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalFocusManager
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.unit.dp
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.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.PreviewsDayNight
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.OutlinedTextField
@@ -64,21 +63,13 @@ fun ReportMessageView(
) {
val focusManager = LocalFocusManager.current
val isSending = state.result is Async.Loading
when (state.result) {
is Async.Success -> {
LaunchedEffect(state.result) {
onBackClicked()
}
return
}
is Async.Failure -> {
ErrorDialog(
content = stringResource(CommonStrings.error_unknown),
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
)
}
else -> Unit
}
AsyncView(
async = state.result,
showProgressDialog = false,
onSuccess = { onBackClicked() },
errorMessage = { stringResource(CommonStrings.error_unknown) },
onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
)
Scaffold(
topBar = {

View File

@@ -32,6 +32,7 @@ import androidx.compose.foundation.shape.CornerSize
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
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.AggregatedReactionProvider
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.PreviewsDayNight
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.Surface
@@ -55,6 +56,7 @@ import io.element.android.libraries.theme.ElementTheme
@Composable
@OptIn(ExperimentalFoundationApi::class)
@Suppress("ModifierClickableOrder") // This is needed to display the right ripple shape
fun MessagesReactionButton(
onClick: () -> Unit,
onLongClick: () -> Unit,
@@ -102,11 +104,12 @@ fun MessagesReactionButton(
}
}
sealed class MessagesReactionsButtonContent {
data class Text(val text: String) : MessagesReactionsButtonContent()
data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent()
@Immutable
sealed interface 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
}

View File

@@ -374,7 +374,9 @@ private fun MessageEventBubbleContent(
inReplyToClick: () -> Unit,
onTimestampClicked: () -> 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`
@@ -593,7 +595,7 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
val messageContent = inReplyTo.content as? MessageContent ?: return null
return when (val type = messageContent.type) {
is ImageMessageType -> AttachmentThumbnailInfo(
thumbnailSource = type.info?.thumbnailSource,
thumbnailSource = type.info?.thumbnailSource ?: type.source,
textContent = messageContent.body,
type = AttachmentThumbnailType.Image,
blurHash = type.info?.blurhash,

View File

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

View File

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

View File

@@ -56,7 +56,7 @@ fun TimelineEncryptedHistoryBannerView(
) {
Icon(
modifier = Modifier.size(20.dp),
resourceId = CommonDrawables.ic_compound_info,
resourceId = CommonDrawables.ic_compound_info_solid,
contentDescription = "Info",
tint = ElementTheme.colors.iconInfoPrimary
)
@@ -79,7 +79,7 @@ private fun SessionState.toStringResId(): Int {
@PreviewsDayNight
@Composable
internal fun TimelineEncryptedHistoryBannerViewPreview(
internal fun EncryptedHistoryBannerViewPreview(
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
) = ElementPreview {
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.media3.common.MimeTypes
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> {
override val values: Sequence<TimelineItemImageContent>
@@ -34,7 +35,7 @@ fun aTimelineItemImageContent() = TimelineItemImageContent(
mediaSource = MediaSource(""),
thumbnailSource = null,
mimeType = MimeTypes.IMAGE_JPEG,
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
blurhash = A_BLUR_HASH,
width = null,
height = 300,
aspectRatio = 0.5f,

View File

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

View File

@@ -16,8 +16,10 @@
package io.element.android.features.messages.impl.timeline.model.event
import androidx.compose.runtime.Immutable
import org.jsoup.nodes.Document
@Immutable
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
val body: String
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 io.element.android.libraries.core.mimetype.MimeTypes
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> {
override val values: Sequence<TimelineItemVideoContent>
@@ -32,7 +33,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
body = "Video.mp4",
thumbnailSource = null,
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
blurHash = A_BLUR_HASH,
aspectRatio = 0.5f,
duration = 100,
videoSource = MediaSource(""),

View File

@@ -36,13 +36,13 @@ class VoiceMessageComposerPlayer @Inject constructor(
val state: Flow<State> = mediaPlayer.state.map { state ->
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
return@map State.NotPlaying
return@map State.NotLoaded
}
State(
isPlaying = state.isPlaying,
currentPosition = state.currentPosition,
duration = state.duration,
duration = state.duration ?: 0L,
)
}.distinctUntilChanged()
@@ -52,16 +52,17 @@ class VoiceMessageComposerPlayer @Inject constructor(
* @param mediaPath The path to the media to be played.
* @param mimeType The mime type of the media file.
*/
fun play(mediaPath: String, mimeType: String) {
suspend fun play(mediaPath: String, mimeType: String) {
if (mediaPath == curPlayingMediaId) {
mediaPlayer.play()
} else {
lastPlayedMediaPath = mediaPath
mediaPlayer.acquireControlAndPlay(
mediaPlayer.setMedia(
uri = mediaPath,
mediaId = mediaPath,
mimeType = mimeType,
)
mediaPlayer.play()
}
}
@@ -89,17 +90,23 @@ class VoiceMessageComposerPlayer @Inject constructor(
val duration: Long,
) {
companion object {
val NotPlaying = State(
val NotLoaded = State(
isPlaying = false,
currentPosition = 0L,
duration = 0L,
)
}
val isLoaded get() = this != NotLoaded
/**
* The progress of this player between 0 and 1.
*/
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.core.net.toUri
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.libraries.architecture.Presenter
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.VoiceRecorderState
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import timber.log.Timber
import java.io.File
import javax.inject.Inject
import kotlin.time.Duration
import kotlin.time.Duration.Companion.milliseconds
@SingleIn(RoomScope::class)
class VoiceMessageComposerPresenter @Inject constructor(
@@ -56,6 +60,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val mediaSender: MediaSender,
private val player: VoiceMessageComposerPlayer,
private val messageComposerContext: MessageComposerContext,
permissionsPresenterFactory: PermissionsPresenter.Factory
) : Presenter<VoiceMessageComposerState> {
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
@@ -68,8 +73,8 @@ class VoiceMessageComposerPresenter @Inject constructor(
val permissionState = permissionsPresenter.present()
var isSending by remember { mutableStateOf(false) }
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying)
val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } }
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
val onLifecycleEvent = { event: Lifecycle.Event ->
@@ -115,10 +120,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
VoiceMessagePlayerEvent.Play ->
when (val recording = recorderState) {
is VoiceRecorderState.Finished ->
player.play(
mediaPath = recording.file.path,
mimeType = recording.mimeType,
)
localCoroutineScope.launch {
player.play(
mediaPath = recording.file.path,
mimeType = recording.mimeType,
)
}
else -> Timber.e("Voice message player event received but no file to play")
}
VoiceMessagePlayerEvent.Pause -> {
@@ -151,6 +158,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
}
isSending = true
player.pause()
analyticsService.captureComposerEvent()
appCoroutineScope.sendMessage(
file = finishedState.file,
mimeType = finishedState.mimeType,
@@ -185,8 +193,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
)
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
isSending = isSending,
isPlaying = isPlaying,
isPlaying = playerState.isPlaying,
showCursor = playerState.isLoaded && !isSending,
playbackProgress = playerState.progress,
time = playerTime,
waveform = waveform,
)
else -> VoiceMessageState.Idle
@@ -236,6 +246,16 @@ class VoiceMessageComposerPresenter @Inject constructor(
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> =
@@ -243,3 +263,20 @@ private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
?.waveform
.orEmpty()
.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.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.media.toFile
import java.io.File
/**
@@ -83,28 +82,29 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
): DefaultVoiceMessageMediaRepo
}
override suspend fun getMediaFile(): Result<File> = if (!isInCache()) {
matrixMediaLoader.downloadMediaFile(
override suspend fun getMediaFile(): Result<File> = when {
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
cachedFile.exists() -> Result.success(cachedFile)
else -> matrixMediaLoader.downloadMediaFile(
source = mediaSource,
mimeType = mimeType,
body = body,
useCache = false,
).mapCatching {
val dest = cachedFilePath.apply { parentFile?.mkdirs() }
// TODO By not closing the MediaFile we're leaking the rust file handle here.
// Not that big of a deal but better to avoid it someday.
if (it.toFile().renameTo(dest)) {
dest
} else {
error("Failed to move file to cache.")
it.use { mediaFile ->
val dest = cachedFile.apply { parentFile?.mkdirs() }
if (mediaFile.persist(dest.path)) {
dest
} else {
error("Failed to move file to cache.")
}
}
}
} else {
Result.success(cachedFilePath)
}
private val cachedFilePath: File = File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mediaSource.url)}")
private fun isInCache(): Boolean = cachedFilePath.exists()
private val cachedFile: File? = mxcUri2FilePath(mediaSource.url)?.let {
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
}
}
/**
@@ -123,12 +123,9 @@ private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
* Sanitizes an mxcUri to be used as a relative file path.
*
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
* @return the relative file path as "<server-name>/<media-id>".
* @throws IllegalStateException if the mxcUri is invalid.
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
*/
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
}.let { match ->
private fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
buildString {
append(match.groupValues[1])
append("/")

View File

@@ -151,11 +151,12 @@ class DefaultVoiceMessagePlayer(
} else {
if (eventId != null) {
repo.getMediaFile().mapCatching { mediaFile ->
mediaPlayer.acquireControlAndPlay(
mediaPlayer.setMedia(
uri = mediaFile.path,
mediaId = eventId.value,
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
)
mediaPlayer.play()
}
} else {
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="other">"%1$d změn místnosti"</item>
</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_photo">"Vyfotit"</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_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_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_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>

View File

@@ -5,6 +5,7 @@
<item quantity="few">"%1$d изменения в комнате"</item>
<item quantity="many">"%1$d изменений в комнате"</item>
</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_photo">"Сделать фото"</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_text_formatting">"Форматирование текста"</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_invite_again_alert_message">"Хотите пригласить их снова?"</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.VoiceMessageComposerPresenter
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.timeline.components.customreaction.FakeEmojibaseProvider
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
@@ -641,6 +642,7 @@ class MessagesPresenterTest {
analyticsService,
mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
messageComposerContext = FakeMessageComposerContext(),
permissionsPresenterFactory,
)
val timelinePresenter = TimelinePresenter(

View File

@@ -25,11 +25,16 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
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.composer.VoiceMessageComposerEvents
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
import io.element.android.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.matrix.test.room.FakeMatrixRoom
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.test.FakePermissionsPresenter
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.VoiceMessagePlayerEvent
import io.element.android.libraries.textcomposer.model.VoiceMessageState
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.collections.immutable.toPersistentList
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration
import kotlin.time.Duration.Companion.seconds
class VoiceMessageComposerPresenterTest {
@@ -65,6 +72,7 @@ class VoiceMessageComposerPresenterTest {
private val matrixRoom = FakeMatrixRoom()
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
private val messageComposerContext = FakeMessageComposerContext()
companion object {
private val RECORDING_DURATION = 1.seconds
@@ -171,7 +179,7 @@ class VoiceMessageComposerPresenterTest {
}
// 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)
testPauseAndDestroy(finalState)
@@ -187,8 +195,9 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
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)
@@ -205,9 +214,10 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
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)
@@ -242,9 +252,10 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f))
assertThat(voiceMessageState).isEqualTo(aPausedState())
}
val finalState = awaitItem()
@@ -264,7 +275,7 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem()
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
fun `present - send while playing`() = runTest {
val presenter = createVoiceMessageComposerPresenter()
@@ -284,10 +324,9 @@ class VoiceMessageComposerPresenterTest {
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
skipItems(1) // Loaded state
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(
isSending = true, isPlaying = false, playbackProgress = 0.1f
))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -310,7 +349,7 @@ class VoiceMessageComposerPresenterTest {
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
}
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
val finalState = awaitItem()
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
@@ -359,7 +398,7 @@ class VoiceMessageComposerPresenterTest {
val previewState = awaitItem()
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
ensureAllEventsConsumed()
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
@@ -534,7 +573,7 @@ class VoiceMessageComposerPresenterTest {
is VoiceMessageState.Preview -> when (state.isPlaying) {
// If the preview was playing, it pauses
true -> awaitItem().apply {
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f))
assertThat(voiceMessageState).isEqualTo(aPausedState())
}
false -> mostRecentState
}
@@ -565,6 +604,7 @@ class VoiceMessageComposerPresenterTest {
analyticsService,
mediaSender,
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
messageComposerContext = messageComposerContext,
FakePermissionsPresenterFactory(permissionsPresenter),
)
}
@@ -587,11 +627,55 @@ class VoiceMessageComposerPresenterTest {
isPlaying: Boolean = false,
playbackProgress: Float = 0f,
isSending: Boolean = false,
time: Duration = RECORDING_DURATION,
showCursor: Boolean = false,
waveform: List<Float> = voiceRecorder.waveform,
) = VoiceMessageState.Preview(
isPlaying = isPlaying,
playbackProgress = playbackProgress,
isSending = isSending,
time = time,
showCursor = showCursor,
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 ->
Truth.assertThat(result.isFailure).isTrue()
result.exceptionOrNull()?.let { exception ->
result.exceptionOrNull()!!.let { exception ->
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(
temporaryFolder: TemporaryFolder,
matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(),
mxcUri: String = MXC_URI,
) = DefaultVoiceMessageMediaRepo(
cacheDir = temporaryFolder.root,
matrixMediaLoader = matrixMediaLoader,
mediaSource = MediaSource(
url = MXC_URI,
url = mxcUri,
json = null
),
mimeType = "audio/ogg",
@@ -133,6 +149,7 @@ private fun createDefaultVoiceMessageMediaRepo(
)
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 fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
parentFile?.mkdirs()

View File

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

View File

@@ -70,6 +70,11 @@ class VoiceMessagePresenterTest {
Truth.assertThat(it.progress).isEqualTo(0f)
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 {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
Truth.assertThat(it.progress).isEqualTo(0.5f)
@@ -128,7 +133,7 @@ class VoiceMessagePresenterTest {
}
initialState.eventSink(VoiceMessageEvents.PlayPause)
skipItems(1) // skip downloading state
skipItems(2) // skip downloading states
val playingState = awaitItem().also {
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
@@ -177,7 +182,7 @@ class VoiceMessagePresenterTest {
initialState.eventSink(VoiceMessageEvents.PlayPause)
skipItems(1) // skip downloading state
skipItems(2) // skip downloading states
awaitItem().also {
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.libraries.textcomposer.model.MessageComposerMode
class MessageComposerContextFake(
class FakeMessageComposerContext(
override var composerMode: MessageComposerMode = MessageComposerMode.Normal
) : MessageComposerContext

View File

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

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