Merge branch 'release/0.3.1' into main
This commit is contained in:
6
.github/ISSUE_TEMPLATE/bug.yml
vendored
6
.github/ISSUE_TEMPLATE/bug.yml
vendored
@@ -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'
|
||||
|
||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
@@ -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
|
||||
|
||||
4
.github/workflows/maestro.yml
vendored
4
.github/workflows/maestro.yml
vendored
@@ -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 }}
|
||||
|
||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/recordScreenshots.yml
vendored
2
.github/workflows/recordScreenshots.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/sonar.yml
vendored
2
.github/workflows/sonar.yml
vendored
@@ -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:
|
||||
|
||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
@@ -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
2
.idea/kotlinc.xml
generated
@@ -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>
|
||||
@@ -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
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
17
CHANGES.md
17
CHANGES.md
@@ -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)
|
||||
========================================
|
||||
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
2
fastlane/metadata/android/en-US/changelogs/40003010.txt
Normal file
2
fastlane/metadata/android/en-US/changelogs/40003010.txt
Normal file
@@ -0,0 +1,2 @@
|
||||
Main changes in this version: Mainly bug fixes.
|
||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||
@@ -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 {
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -146,7 +146,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||
}
|
||||
NavTarget.LockScreenSetup -> {
|
||||
val callback = object : LockScreenEntryPoint.Callback {
|
||||
override fun onSetupCompleted() {
|
||||
override fun onSetupDone() {
|
||||
lifecycleScope.launch { moveToNextStep() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -120,7 +120,7 @@ class DefaultFtueState @Inject constructor(
|
||||
|
||||
private fun shouldDisplayLockscreenSetup(): Boolean {
|
||||
return runBlocking {
|
||||
lockScreenService.isSetupRequired()
|
||||
lockScreenService.isSetupRequired().first()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
)
|
||||
|
||||
|
||||
@@ -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 won’t be available in this update."</string>
|
||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms isn’t available yet."</string>
|
||||
<string name="screen_welcome_bullet_3">"We’d 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">"Here’s what you need to know:"</string>
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -31,8 +31,8 @@ interface LockScreenEntryPoint : FeatureEntryPoint {
|
||||
fun build(): Node
|
||||
}
|
||||
|
||||
interface Callback: Plugin {
|
||||
fun onSetupCompleted()
|
||||
interface Callback : Plugin {
|
||||
fun onSetupDone()
|
||||
}
|
||||
|
||||
enum class Target {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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()
|
||||
}
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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) { }
|
||||
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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>
|
||||
|
||||
}
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
@@ -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
|
||||
) {
|
||||
|
||||
@@ -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 = {}
|
||||
)
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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) }
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 = {
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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?
|
||||
|
||||
@@ -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(""),
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
}
|
||||
|
||||
@@ -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("/")
|
||||
|
||||
@@ -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"))
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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(
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
@@ -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
Reference in New Issue
Block a user