Merge branch 'release/0.3.1' into main
This commit is contained in:
commit
2e46d549d3
1626 changed files with 2917 additions and 1500 deletions
6
.github/ISSUE_TEMPLATE/bug.yml
vendored
6
.github/ISSUE_TEMPLATE/bug.yml
vendored
|
|
@ -49,8 +49,8 @@ body:
|
||||||
id: version
|
id: version
|
||||||
attributes:
|
attributes:
|
||||||
label: Application version and app store
|
label: Application version and app store
|
||||||
description: You can find the version information in Settings -> Help & About.
|
description: You can find the version information at the bottom of the Settings screen.
|
||||||
placeholder: e.g. Element X version 1.7.34, olm version 3.2.3 from F-Droid
|
placeholder: e.g. Element X version 0.3.0
|
||||||
validations:
|
validations:
|
||||||
required: false
|
required: false
|
||||||
- type: input
|
- type: input
|
||||||
|
|
@ -67,7 +67,7 @@ body:
|
||||||
attributes:
|
attributes:
|
||||||
label: Will you send logs?
|
label: Will you send logs?
|
||||||
description: |
|
description: |
|
||||||
Did you know that you can shake your phone to submit logs for this issue? Trigger the defect, then shake your phone and you will see a popup asking if you would like to open the bug report screen. Click YES, and describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
|
Trigger the defect, then click on the menu from the room list then "Report a bug". Describe the issue, mentioning that you have also filed a bug (it's helpful if you can include a link to the bug). Send the report to submit anonymous logs to the developers.
|
||||||
options:
|
options:
|
||||||
- 'Yes'
|
- 'Yes'
|
||||||
- 'No'
|
- 'No'
|
||||||
|
|
|
||||||
4
.github/workflows/build.yml
vendored
4
.github/workflows/build.yml
vendored
|
|
@ -9,7 +9,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -47,7 +47,7 @@ jobs:
|
||||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||||
run: ./gradlew assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew :app:assembleDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||||
- name: Upload APK APKs
|
- name: Upload APK APKs
|
||||||
if: ${{ matrix.variant == 'debug' }}
|
if: ${{ matrix.variant == 'debug' }}
|
||||||
uses: actions/upload-artifact@v3
|
uses: actions/upload-artifact@v3
|
||||||
|
|
|
||||||
4
.github/workflows/maestro.yml
vendored
4
.github/workflows/maestro.yml
vendored
|
|
@ -9,7 +9,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
@ -35,7 +35,7 @@ jobs:
|
||||||
distribution: 'temurin' # See 'Supported distributions' for available options
|
distribution: 'temurin' # See 'Supported distributions' for available options
|
||||||
java-version: '17'
|
java-version: '17'
|
||||||
- name: Assemble debug APK
|
- name: Assemble debug APK
|
||||||
run: ./gradlew assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
run: ./gradlew :app:assembleDebug $CI_GRADLE_ARG_PROPERTIES
|
||||||
env:
|
env:
|
||||||
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
|
||||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||||
|
|
|
||||||
2
.github/workflows/nightly.yml
vendored
2
.github/workflows/nightly.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
- cron: "0 4 * * *"
|
- cron: "0 4 * * *"
|
||||||
|
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
2
.github/workflows/nightlyReports.yml
vendored
2
.github/workflows/nightlyReports.yml
vendored
|
|
@ -8,7 +8,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
2
.github/workflows/quality.yml
vendored
2
.github/workflows/quality.yml
vendored
|
|
@ -9,7 +9,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon --warn
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
2
.github/workflows/recordScreenshots.yml
vendored
2
.github/workflows/recordScreenshots.yml
vendored
|
|
@ -5,7 +5,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
record:
|
record:
|
||||||
|
|
|
||||||
2
.github/workflows/release.yml
vendored
2
.github/workflows/release.yml
vendored
|
|
@ -7,7 +7,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 2 --no-daemon
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
2
.github/workflows/sonar.yml
vendored
2
.github/workflows/sonar.yml
vendored
|
|
@ -9,7 +9,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.daemon.jvm.options="-Xmx2g" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -XX:MaxMetaspaceSize=512m -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --no-daemon --warn
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --no-daemon --warn
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
2
.github/workflows/tests.yml
vendored
2
.github/workflows/tests.yml
vendored
|
|
@ -9,7 +9,7 @@ on:
|
||||||
|
|
||||||
# Enrich gradle.properties for CI/CD
|
# Enrich gradle.properties for CI/CD
|
||||||
env:
|
env:
|
||||||
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3072m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.daemon.jvm.options="-Xmx2560m" -Dkotlin.incremental=false
|
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx3584m -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
|
||||||
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 4 --warn
|
||||||
|
|
||||||
jobs:
|
jobs:
|
||||||
|
|
|
||||||
2
.idea/kotlinc.xml
generated
2
.idea/kotlinc.xml
generated
|
|
@ -1,6 +1,6 @@
|
||||||
<?xml version="1.0" encoding="UTF-8"?>
|
<?xml version="1.0" encoding="UTF-8"?>
|
||||||
<project version="4">
|
<project version="4">
|
||||||
<component name="KotlinJpsPluginSettings">
|
<component name="KotlinJpsPluginSettings">
|
||||||
<option name="version" value="1.9.10" />
|
<option name="version" value="1.9.20" />
|
||||||
</component>
|
</component>
|
||||||
</project>
|
</project>
|
||||||
|
|
@ -6,9 +6,12 @@ appId: ${APP_ID}
|
||||||
- takeScreenshot: build/maestro/900-SignOutScreen
|
- takeScreenshot: build/maestro/900-SignOutScreen
|
||||||
- back
|
- back
|
||||||
- tapOn: "Sign out"
|
- tapOn: "Sign out"
|
||||||
- tapOn: "Sign out"
|
- tapOn:
|
||||||
|
id: "sign-out-submit"
|
||||||
# Ensure cancel cancels
|
# Ensure cancel cancels
|
||||||
- tapOn: "Cancel"
|
- tapOn: "Cancel"
|
||||||
- tapOn: "Sign out"
|
- tapOn:
|
||||||
- tapOn: "Sign out anyway"
|
id: "sign-out-submit"
|
||||||
|
- tapOn:
|
||||||
|
id: "dialog-positive"
|
||||||
- runFlow: ../assertions/assertInitDisplayed.yaml
|
- runFlow: ../assertions/assertInitDisplayed.yaml
|
||||||
|
|
|
||||||
|
|
@ -29,5 +29,6 @@ appId: ${APP_ID}
|
||||||
- tapOn: "People"
|
- tapOn: "People"
|
||||||
# assert there's 1 member and 2 invitees
|
# assert there's 1 member and 2 invitees
|
||||||
- tapOn: "Back"
|
- tapOn: "Back"
|
||||||
|
- scroll
|
||||||
- tapOn: "Leave room"
|
- tapOn: "Leave room"
|
||||||
- tapOn: "Leave"
|
- tapOn: "Leave"
|
||||||
|
|
|
||||||
|
|
@ -10,7 +10,12 @@ appId: ${APP_ID}
|
||||||
- back
|
- back
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Report bug"
|
text: "Notifications"
|
||||||
|
- assertVisible: "Enable notifications on this device"
|
||||||
|
- back
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Report a problem"
|
||||||
- assertVisible: "Report a bug"
|
- assertVisible: "Report a bug"
|
||||||
- back
|
- back
|
||||||
|
|
||||||
|
|
@ -21,6 +26,17 @@ appId: ${APP_ID}
|
||||||
- assertVisible: "Privacy policy"
|
- assertVisible: "Privacy policy"
|
||||||
- back
|
- back
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Screen lock"
|
||||||
|
- assertVisible: "Choose PIN"
|
||||||
|
- hideKeyboard
|
||||||
|
- back
|
||||||
|
|
||||||
|
- tapOn:
|
||||||
|
text: "Advanced settings"
|
||||||
|
- assertVisible: "Rich text editor"
|
||||||
|
- back
|
||||||
|
|
||||||
- tapOn:
|
- tapOn:
|
||||||
text: "Developer options"
|
text: "Developer options"
|
||||||
- assertVisible: "Feature flags"
|
- assertVisible: "Feature flags"
|
||||||
|
|
|
||||||
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)
|
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.integration.NodeHost
|
||||||
import com.bumble.appyx.core.integrationpoint.NodeActivity
|
import com.bumble.appyx.core.integrationpoint.NodeActivity
|
||||||
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
import com.bumble.appyx.core.plugin.NodeReadyObserver
|
||||||
|
import io.element.android.features.lockscreen.api.handleSecureFlag
|
||||||
import io.element.android.libraries.architecture.bindings
|
import io.element.android.libraries.architecture.bindings
|
||||||
import io.element.android.libraries.core.log.logger.LoggerTag
|
import io.element.android.libraries.core.log.logger.LoggerTag
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
|
||||||
|
|
@ -53,6 +54,7 @@ class MainActivity : NodeActivity() {
|
||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
appBindings = bindings()
|
appBindings = bindings()
|
||||||
|
appBindings.lockScreenService().handleSecureFlag(this)
|
||||||
WindowCompat.setDecorFitsSystemWindows(window, false)
|
WindowCompat.setDecorFitsSystemWindows(window, false)
|
||||||
setContent {
|
setContent {
|
||||||
MainContent(appBindings)
|
MainContent(appBindings)
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,7 @@
|
||||||
package io.element.android.x.di
|
package io.element.android.x.di
|
||||||
|
|
||||||
import com.squareup.anvil.annotations.ContributesTo
|
import com.squareup.anvil.annotations.ContributesTo
|
||||||
|
import io.element.android.features.lockscreen.api.LockScreenService
|
||||||
import io.element.android.features.rageshake.api.reporter.BugReporter
|
import io.element.android.features.rageshake.api.reporter.BugReporter
|
||||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
|
|
@ -27,4 +28,5 @@ interface AppBindings {
|
||||||
fun snackbarDispatcher(): SnackbarDispatcher
|
fun snackbarDispatcher(): SnackbarDispatcher
|
||||||
fun tracingService(): TracingService
|
fun tracingService(): TracingService
|
||||||
fun bugReporter(): BugReporter
|
fun bugReporter(): BugReporter
|
||||||
|
fun lockScreenService(): LockScreenService
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -15,6 +15,7 @@
|
||||||
*/
|
*/
|
||||||
plugins {
|
plugins {
|
||||||
id("java-library")
|
id("java-library")
|
||||||
|
id("com.android.lint")
|
||||||
alias(libs.plugins.kotlin.jvm)
|
alias(libs.plugins.kotlin.jvm)
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
|
|
|
||||||
|
|
@ -33,7 +33,7 @@ data class LockScreenConfig(
|
||||||
val isPinMandatory: Boolean,
|
val isPinMandatory: Boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Some PINs are blacklisted.
|
* Some PINs are forbidden.
|
||||||
*/
|
*/
|
||||||
val pinBlacklist: Set<String>,
|
val pinBlacklist: Set<String>,
|
||||||
|
|
||||||
|
|
@ -56,6 +56,7 @@ data class LockScreenConfig(
|
||||||
* Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
|
* Authentication with strong methods (fingerprint, some face/iris unlock implementations) is supported.
|
||||||
*/
|
*/
|
||||||
val isStrongBiometricsEnabled: Boolean,
|
val isStrongBiometricsEnabled: Boolean,
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Authentication with weak methods (most face/iris unlock implementations) is supported.
|
* Authentication with weak methods (most face/iris unlock implementations) is supported.
|
||||||
*/
|
*/
|
||||||
|
|
@ -72,7 +73,7 @@ object LockScreenConfigModule {
|
||||||
pinBlacklist = setOf("0000", "1234"),
|
pinBlacklist = setOf("0000", "1234"),
|
||||||
pinSize = 4,
|
pinSize = 4,
|
||||||
maxPinCodeAttemptsBeforeLogout = 3,
|
maxPinCodeAttemptsBeforeLogout = 3,
|
||||||
gracePeriod = 90.seconds,
|
gracePeriod = 0.seconds,
|
||||||
isStrongBiometricsEnabled = true,
|
isStrongBiometricsEnabled = true,
|
||||||
isWeakBiometricsEnabled = true,
|
isWeakBiometricsEnabled = true,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@ import org.jetbrains.kotlin.cli.common.toBooleanLenient
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
dependencies {
|
dependencies {
|
||||||
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.10")
|
classpath("org.jetbrains.kotlin:kotlin-gradle-plugin:1.9.20")
|
||||||
classpath("com.google.gms:google-services:4.4.0")
|
classpath("com.google.gms:google-services:4.4.0")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -62,7 +62,7 @@ allprojects {
|
||||||
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
config.from(files("$rootDir/tools/detekt/detekt.yml"))
|
||||||
}
|
}
|
||||||
dependencies {
|
dependencies {
|
||||||
detektPlugins("io.nlopez.compose.rules:detekt:0.3.0")
|
detektPlugins("io.nlopez.compose.rules:detekt:0.3.3")
|
||||||
}
|
}
|
||||||
|
|
||||||
// KtLint
|
// KtLint
|
||||||
|
|
|
||||||
|
|
@ -1,2 +1,2 @@
|
||||||
Main changes in this version: TODO.
|
Main changes in this version: Element Call, voice message.
|
||||||
Full changelog: https://github.com/vector-im/element-x-android/releases
|
Full changelog: https://github.com/vector-im/element-x-android/releases
|
||||||
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
|
import javax.inject.Inject
|
||||||
|
|
||||||
class AddPeoplePresenter @Inject constructor(
|
class AddPeoplePresenter @Inject constructor(
|
||||||
private val userListPresenterFactory: UserListPresenter.Factory,
|
userListPresenterFactory: UserListPresenter.Factory,
|
||||||
private val userRepository: UserRepository,
|
userRepository: UserRepository,
|
||||||
private val dataStore: CreateRoomDataStore,
|
dataStore: CreateRoomDataStore,
|
||||||
) : Presenter<UserListState> {
|
) : Presenter<UserListState> {
|
||||||
|
|
||||||
private val userListPresenter by lazy {
|
private val userListPresenter = userListPresenterFactory.create(
|
||||||
userListPresenterFactory.create(
|
UserListPresenterArgs(
|
||||||
UserListPresenterArgs(
|
selectionMode = SelectionMode.Multiple,
|
||||||
selectionMode = SelectionMode.Multiple,
|
),
|
||||||
),
|
userRepository,
|
||||||
userRepository,
|
dataStore.selectedUserListDataStore,
|
||||||
dataStore.selectedUserListDataStore,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): UserListState {
|
override fun present(): UserListState {
|
||||||
|
|
|
||||||
|
|
@ -36,7 +36,6 @@ import androidx.compose.material.ModalBottomSheetValue
|
||||||
import androidx.compose.material.rememberModalBottomSheetState
|
import androidx.compose.material.rememberModalBottomSheetState
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.rememberCoroutineScope
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -49,13 +48,11 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.createroom.impl.R
|
import io.element.android.features.createroom.impl.R
|
||||||
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
|
import io.element.android.features.createroom.impl.components.RoomPrivacyOption
|
||||||
import io.element.android.libraries.architecture.Async
|
|
||||||
import io.element.android.libraries.designsystem.components.LabelledTextField
|
import io.element.android.libraries.designsystem.components.LabelledTextField
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.async.AsyncView
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -84,12 +81,6 @@ fun ConfigureRoomView(
|
||||||
initialValue = ModalBottomSheetValue.Hidden,
|
initialValue = ModalBottomSheetValue.Hidden,
|
||||||
)
|
)
|
||||||
|
|
||||||
if (state.createRoomAction is Async.Success) {
|
|
||||||
LaunchedEffect(state.createRoomAction) {
|
|
||||||
onRoomCreated(state.createRoomAction.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun onAvatarClicked() {
|
fun onAvatarClicked() {
|
||||||
focusManager.clearFocus()
|
focusManager.clearFocus()
|
||||||
coroutineScope.launch {
|
coroutineScope.launch {
|
||||||
|
|
@ -158,21 +149,14 @@ fun ConfigureRoomView(
|
||||||
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
|
onActionSelected = { state.eventSink(ConfigureRoomEvents.HandleAvatarAction(it)) }
|
||||||
)
|
)
|
||||||
|
|
||||||
when (state.createRoomAction) {
|
AsyncView(
|
||||||
is Async.Loading -> {
|
async = state.createRoomAction,
|
||||||
ProgressDialog(text = stringResource(CommonStrings.common_creating_room))
|
progressText = stringResource(CommonStrings.common_creating_room),
|
||||||
}
|
onSuccess = { onRoomCreated(it) },
|
||||||
|
errorMessage = { stringResource(R.string.screen_create_room_error_creating_room) },
|
||||||
is Async.Failure -> {
|
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
|
||||||
RetryDialog(
|
onErrorDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
|
||||||
content = stringResource(R.string.screen_create_room_error_creating_room),
|
)
|
||||||
onDismiss = { state.eventSink(ConfigureRoomEvents.CancelCreateRoom) },
|
|
||||||
onRetry = { state.eventSink(ConfigureRoomEvents.CreateRoom(state.config)) },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
PermissionsView(
|
PermissionsView(
|
||||||
state = state.cameraPermissionState,
|
state = state.cameraPermissionState,
|
||||||
|
|
|
||||||
|
|
@ -40,23 +40,21 @@ import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
class CreateRoomRootPresenter @Inject constructor(
|
class CreateRoomRootPresenter @Inject constructor(
|
||||||
private val presenterFactory: UserListPresenter.Factory,
|
presenterFactory: UserListPresenter.Factory,
|
||||||
private val userRepository: UserRepository,
|
userRepository: UserRepository,
|
||||||
private val userListDataStore: UserListDataStore,
|
userListDataStore: UserListDataStore,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val buildMeta: BuildMeta,
|
private val buildMeta: BuildMeta,
|
||||||
) : Presenter<CreateRoomRootState> {
|
) : Presenter<CreateRoomRootState> {
|
||||||
|
|
||||||
private val presenter by lazy {
|
private val presenter = presenterFactory.create(
|
||||||
presenterFactory.create(
|
UserListPresenterArgs(
|
||||||
UserListPresenterArgs(
|
selectionMode = SelectionMode.Single,
|
||||||
selectionMode = SelectionMode.Single,
|
),
|
||||||
),
|
userRepository,
|
||||||
userRepository,
|
userListDataStore,
|
||||||
userListDataStore,
|
)
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): CreateRoomRootState {
|
override fun present(): CreateRoomRootState {
|
||||||
|
|
|
||||||
|
|
@ -30,7 +30,6 @@ import androidx.compose.foundation.layout.size
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -38,12 +37,10 @@ import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.createroom.impl.R
|
import io.element.android.features.createroom.impl.R
|
||||||
import io.element.android.features.createroom.impl.components.UserListView
|
import io.element.android.features.createroom.impl.components.UserListView
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.designsystem.components.async.AsyncView
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.RetryDialog
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
import io.element.android.libraries.designsystem.theme.components.Scaffold
|
||||||
|
|
@ -64,12 +61,6 @@ fun CreateRoomRootView(
|
||||||
onOpenDM: (RoomId) -> Unit = {},
|
onOpenDM: (RoomId) -> Unit = {},
|
||||||
onInviteFriendsClicked: () -> Unit = {},
|
onInviteFriendsClicked: () -> Unit = {},
|
||||||
) {
|
) {
|
||||||
if (state.startDmAction is Async.Success) {
|
|
||||||
LaunchedEffect(state.startDmAction) {
|
|
||||||
onOpenDM(state.startDmAction.data)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier.fillMaxWidth(),
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
@ -102,26 +93,19 @@ fun CreateRoomRootView(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
when (state.startDmAction) {
|
AsyncView(
|
||||||
is Async.Loading -> {
|
async = state.startDmAction,
|
||||||
ProgressDialog(text = stringResource(id = CommonStrings.common_starting_chat))
|
progressText = stringResource(CommonStrings.common_starting_chat),
|
||||||
}
|
onSuccess = { onOpenDM(it) },
|
||||||
|
errorMessage = { stringResource(R.string.screen_start_chat_error_starting_chat) },
|
||||||
is Async.Failure -> {
|
onRetry = {
|
||||||
RetryDialog(
|
state.userListState.selectedUsers.firstOrNull()
|
||||||
content = stringResource(id = R.string.screen_start_chat_error_starting_chat),
|
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
|
||||||
onDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
|
// Cancel start DM if there is no more selected user (should not happen)
|
||||||
onRetry = {
|
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
||||||
state.userListState.selectedUsers.firstOrNull()
|
},
|
||||||
?.let { state.eventSink(CreateRoomRootEvents.StartDM(it)) }
|
onErrorDismiss = { state.eventSink(CreateRoomRootEvents.CancelStartDM) },
|
||||||
// Cancel start DM if there is no more selected user (should not happen)
|
)
|
||||||
?: state.eventSink(CreateRoomRootEvents.CancelStartDM)
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
@OptIn(ExperimentalMaterial3Api::class)
|
||||||
|
|
|
||||||
|
|
@ -146,7 +146,7 @@ class FtueFlowNode @AssistedInject constructor(
|
||||||
}
|
}
|
||||||
NavTarget.LockScreenSetup -> {
|
NavTarget.LockScreenSetup -> {
|
||||||
val callback = object : LockScreenEntryPoint.Callback {
|
val callback = object : LockScreenEntryPoint.Callback {
|
||||||
override fun onSetupCompleted() {
|
override fun onSetupDone() {
|
||||||
lifecycleScope.launch { moveToNextStep() }
|
lifecycleScope.launch { moveToNextStep() }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,18 +32,16 @@ import io.element.android.libraries.di.AppScope
|
||||||
class NotificationsOptInNode @AssistedInject constructor(
|
class NotificationsOptInNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val presenterFactory: NotificationsOptInPresenter.Factory,
|
presenterFactory: NotificationsOptInPresenter.Factory,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
interface Callback: NodeInputs {
|
interface Callback : NodeInputs {
|
||||||
fun onNotificationsOptInFinished()
|
fun onNotificationsOptInFinished()
|
||||||
}
|
}
|
||||||
|
|
||||||
private val callback = inputs<Callback>()
|
private val callback = inputs<Callback>()
|
||||||
|
|
||||||
private val presenter: NotificationsOptInPresenter by lazy {
|
private val presenter: NotificationsOptInPresenter = presenterFactory.create(callback)
|
||||||
presenterFactory.create(callback)
|
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
|
||||||
class NotificationsOptInPresenter @AssistedInject constructor(
|
class NotificationsOptInPresenter @AssistedInject constructor(
|
||||||
private val permissionsPresenterFactory: PermissionsPresenter.Factory,
|
permissionsPresenterFactory: PermissionsPresenter.Factory,
|
||||||
@Assisted private val callback: NotificationsOptInNode.Callback,
|
@Assisted private val callback: NotificationsOptInNode.Callback,
|
||||||
private val appCoroutineScope: CoroutineScope,
|
private val appCoroutineScope: CoroutineScope,
|
||||||
private val permissionStateProvider: PermissionStateProvider,
|
private val permissionStateProvider: PermissionStateProvider,
|
||||||
|
|
@ -46,14 +46,13 @@ class NotificationsOptInPresenter @AssistedInject constructor(
|
||||||
fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
|
fun create(callback: NotificationsOptInNode.Callback): NotificationsOptInPresenter
|
||||||
}
|
}
|
||||||
|
|
||||||
private val postNotificationPermissionsPresenter by lazy {
|
private val postNotificationPermissionsPresenter: PermissionsPresenter =
|
||||||
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
|
// Ask for POST_NOTIFICATION PERMISSION on Android 13+
|
||||||
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
if (buildVersionSdkIntProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
|
||||||
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
|
permissionsPresenterFactory.create(Manifest.permission.POST_NOTIFICATIONS)
|
||||||
} else {
|
} else {
|
||||||
NoopPermissionsPresenter()
|
NoopPermissionsPresenter()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): NotificationsOptInState {
|
override fun present(): NotificationsOptInState {
|
||||||
|
|
|
||||||
|
|
@ -120,7 +120,7 @@ class DefaultFtueState @Inject constructor(
|
||||||
|
|
||||||
private fun shouldDisplayLockscreenSetup(): Boolean {
|
private fun shouldDisplayLockscreenSetup(): Boolean {
|
||||||
return runBlocking {
|
return runBlocking {
|
||||||
lockScreenService.isSetupRequired()
|
lockScreenService.isSetupRequired().first()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -25,10 +25,6 @@ import androidx.compose.foundation.layout.height
|
||||||
import androidx.compose.foundation.layout.systemBarsPadding
|
import androidx.compose.foundation.layout.systemBarsPadding
|
||||||
import androidx.compose.foundation.rememberScrollState
|
import androidx.compose.foundation.rememberScrollState
|
||||||
import androidx.compose.foundation.verticalScroll
|
import androidx.compose.foundation.verticalScroll
|
||||||
import androidx.compose.material.icons.Icons
|
|
||||||
import androidx.compose.material.icons.outlined.AddComment
|
|
||||||
import androidx.compose.material.icons.outlined.Lock
|
|
||||||
import androidx.compose.material.icons.outlined.NewReleases
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
|
|
@ -41,10 +37,11 @@ import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSiz
|
||||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
|
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
|
||||||
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
|
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.testtags.TestTags
|
import io.element.android.libraries.testtags.TestTags
|
||||||
import io.element.android.libraries.testtags.testTag
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
@ -79,14 +76,7 @@ fun WelcomeView(
|
||||||
color = ElementTheme.colors.textPrimary,
|
color = ElementTheme.colors.textPrimary,
|
||||||
textAlign = TextAlign.Center,
|
textAlign = TextAlign.Center,
|
||||||
)
|
)
|
||||||
Spacer(modifier = Modifier.height(8.dp))
|
Spacer(modifier = Modifier.height(80.dp))
|
||||||
Text(
|
|
||||||
text = stringResource(R.string.screen_welcome_subtitle),
|
|
||||||
style = ElementTheme.typography.fontBodyMdRegular,
|
|
||||||
color = ElementTheme.colors.textPrimary,
|
|
||||||
textAlign = TextAlign.Center,
|
|
||||||
)
|
|
||||||
Spacer(modifier = Modifier.height(40.dp))
|
|
||||||
InfoListOrganism(
|
InfoListOrganism(
|
||||||
items = listItems(),
|
items = listItems(),
|
||||||
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
textStyle = ElementTheme.typography.fontBodyMdMedium,
|
||||||
|
|
@ -109,17 +99,13 @@ fun WelcomeView(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun listItems() = persistentListOf(
|
private fun listItems() = persistentListOf(
|
||||||
InfoListItem(
|
|
||||||
message = stringResource(R.string.screen_welcome_bullet_1),
|
|
||||||
iconVector = Icons.Outlined.NewReleases,
|
|
||||||
),
|
|
||||||
InfoListItem(
|
InfoListItem(
|
||||||
message = stringResource(R.string.screen_welcome_bullet_2),
|
message = stringResource(R.string.screen_welcome_bullet_2),
|
||||||
iconVector = Icons.Outlined.Lock,
|
iconId = CommonDrawables.ic_lock_outline,
|
||||||
),
|
),
|
||||||
InfoListItem(
|
InfoListItem(
|
||||||
message = stringResource(R.string.screen_welcome_bullet_3),
|
message = stringResource(R.string.screen_welcome_bullet_3),
|
||||||
iconVector = Icons.Outlined.AddComment,
|
iconId = CommonDrawables.ic_compound_chat_problem,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -5,7 +5,7 @@
|
||||||
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
|
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
|
||||||
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
|
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
|
||||||
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
<string name="screen_welcome_bullet_1">"Calls, polls, search and more will be added later this year."</string>
|
||||||
<string name="screen_welcome_bullet_2">"Message history for encrypted rooms 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_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_button">"Let\'s go!"</string>
|
||||||
<string name="screen_welcome_subtitle">"Here’s what you need to know:"</string>
|
<string name="screen_welcome_subtitle">"Here’s what you need to know:"</string>
|
||||||
|
|
|
||||||
|
|
@ -57,6 +57,7 @@ class DefaultFtueStateTests {
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
|
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = true)
|
||||||
|
val lockScreenService = FakeLockScreenService()
|
||||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
|
|
||||||
val state = createState(
|
val state = createState(
|
||||||
|
|
@ -64,13 +65,15 @@ class DefaultFtueStateTests {
|
||||||
welcomeState = welcomeState,
|
welcomeState = welcomeState,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
migrationScreenStore = migrationScreenStore,
|
migrationScreenStore = migrationScreenStore,
|
||||||
permissionStateProvider = permissionStateProvider
|
permissionStateProvider = permissionStateProvider,
|
||||||
|
lockScreenService = lockScreenService,
|
||||||
)
|
)
|
||||||
|
|
||||||
welcomeState.setWelcomeScreenShown()
|
welcomeState.setWelcomeScreenShown()
|
||||||
analyticsService.setDidAskUserConsent()
|
analyticsService.setDidAskUserConsent()
|
||||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||||
permissionStateProvider.setPermissionGranted()
|
permissionStateProvider.setPermissionGranted()
|
||||||
|
lockScreenService.setIsPinSetup(true)
|
||||||
state.updateState()
|
state.updateState()
|
||||||
|
|
||||||
assertThat(state.shouldDisplayFlow.value).isFalse()
|
assertThat(state.shouldDisplayFlow.value).isFalse()
|
||||||
|
|
@ -85,6 +88,7 @@ class DefaultFtueStateTests {
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||||
|
val lockScreenService = FakeLockScreenService()
|
||||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
|
|
||||||
val state = createState(
|
val state = createState(
|
||||||
|
|
@ -92,7 +96,8 @@ class DefaultFtueStateTests {
|
||||||
welcomeState = welcomeState,
|
welcomeState = welcomeState,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
migrationScreenStore = migrationScreenStore,
|
migrationScreenStore = migrationScreenStore,
|
||||||
permissionStateProvider = permissionStateProvider
|
permissionStateProvider = permissionStateProvider,
|
||||||
|
lockScreenService = lockScreenService,
|
||||||
)
|
)
|
||||||
val steps = mutableListOf<FtueStep?>()
|
val steps = mutableListOf<FtueStep?>()
|
||||||
|
|
||||||
|
|
@ -108,7 +113,11 @@ class DefaultFtueStateTests {
|
||||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
permissionStateProvider.setPermissionGranted()
|
permissionStateProvider.setPermissionGranted()
|
||||||
|
|
||||||
// Fourth step, analytics opt in
|
// Fourth step, entering PIN code
|
||||||
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
|
lockScreenService.setIsPinSetup(true)
|
||||||
|
|
||||||
|
// Fifth step, analytics opt in
|
||||||
steps.add(state.getNextStep(steps.lastOrNull()))
|
steps.add(state.getNextStep(steps.lastOrNull()))
|
||||||
analyticsService.setDidAskUserConsent()
|
analyticsService.setDidAskUserConsent()
|
||||||
|
|
||||||
|
|
@ -119,6 +128,7 @@ class DefaultFtueStateTests {
|
||||||
FtueStep.MigrationScreen,
|
FtueStep.MigrationScreen,
|
||||||
FtueStep.WelcomeScreen,
|
FtueStep.WelcomeScreen,
|
||||||
FtueStep.NotificationsOptIn,
|
FtueStep.NotificationsOptIn,
|
||||||
|
FtueStep.LockscreenSetup,
|
||||||
FtueStep.AnalyticsOptIn,
|
FtueStep.AnalyticsOptIn,
|
||||||
null, // Final state
|
null, // Final state
|
||||||
)
|
)
|
||||||
|
|
@ -133,18 +143,20 @@ class DefaultFtueStateTests {
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
|
||||||
|
val lockScreenService = FakeLockScreenService()
|
||||||
val state = createState(
|
val state = createState(
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
migrationScreenStore = migrationScreenStore,
|
migrationScreenStore = migrationScreenStore,
|
||||||
permissionStateProvider = permissionStateProvider,
|
permissionStateProvider = permissionStateProvider,
|
||||||
|
lockScreenService = lockScreenService,
|
||||||
)
|
)
|
||||||
|
|
||||||
// Skip first 3 steps
|
// Skip first 4 steps
|
||||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||||
state.setWelcomeScreenShown()
|
state.setWelcomeScreenShown()
|
||||||
permissionStateProvider.setPermissionGranted()
|
permissionStateProvider.setPermissionGranted()
|
||||||
|
lockScreenService.setIsPinSetup(true)
|
||||||
|
|
||||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||||
|
|
||||||
|
|
@ -160,18 +172,21 @@ class DefaultFtueStateTests {
|
||||||
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
val coroutineScope = CoroutineScope(coroutineContext + SupervisorJob())
|
||||||
val analyticsService = FakeAnalyticsService()
|
val analyticsService = FakeAnalyticsService()
|
||||||
val migrationScreenStore = InMemoryMigrationScreenStore()
|
val migrationScreenStore = InMemoryMigrationScreenStore()
|
||||||
|
val lockScreenService = FakeLockScreenService()
|
||||||
|
|
||||||
val state = createState(
|
val state = createState(
|
||||||
sdkIntVersion = Build.VERSION_CODES.M,
|
sdkIntVersion = Build.VERSION_CODES.M,
|
||||||
coroutineScope = coroutineScope,
|
coroutineScope = coroutineScope,
|
||||||
analyticsService = analyticsService,
|
analyticsService = analyticsService,
|
||||||
migrationScreenStore = migrationScreenStore,
|
migrationScreenStore = migrationScreenStore,
|
||||||
|
lockScreenService = lockScreenService,
|
||||||
)
|
)
|
||||||
|
|
||||||
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
migrationScreenStore.setMigrationScreenShown(A_SESSION_ID)
|
||||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
|
assertThat(state.getNextStep()).isEqualTo(FtueStep.WelcomeScreen)
|
||||||
|
|
||||||
state.setWelcomeScreenShown()
|
state.setWelcomeScreenShown()
|
||||||
|
lockScreenService.setIsPinSetup(true)
|
||||||
|
|
||||||
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
assertThat(state.getNextStep()).isEqualTo(FtueStep.AnalyticsOptIn)
|
||||||
|
|
||||||
analyticsService.setDidAskUserConsent()
|
analyticsService.setDidAskUserConsent()
|
||||||
|
|
|
||||||
|
|
@ -16,11 +16,13 @@
|
||||||
|
|
||||||
package io.element.android.features.location.api
|
package io.element.android.features.location.api
|
||||||
|
|
||||||
|
import android.annotation.SuppressLint
|
||||||
import android.os.Parcelable
|
import android.os.Parcelable
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
|
private const val GEO_URI_REGEX = """geo:(?<latitude>-?\d+(?:\.\d+)?),(?<longitude>-?\d+(?:\.\d+)?)(?:;u=(?<uncertainty>\d+(?:\.\d+)?))?"""
|
||||||
|
|
||||||
|
@SuppressLint("NewApi")
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data class Location(
|
data class Location(
|
||||||
val lat: Double,
|
val lat: Double,
|
||||||
|
|
|
||||||
|
|
@ -28,7 +28,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake
|
import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake
|
||||||
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
import io.element.android.features.location.impl.common.permissions.PermissionsState
|
||||||
import io.element.android.features.messages.test.MessageComposerContextFake
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.libraries.matrix.api.room.location.AssetType
|
import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||||
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
import io.element.android.libraries.matrix.test.core.aBuildMeta
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
|
|
@ -49,7 +49,7 @@ class SendLocationPresenterTest {
|
||||||
private val permissionsPresenterFake = PermissionsPresenterFake()
|
private val permissionsPresenterFake = PermissionsPresenterFake()
|
||||||
private val fakeMatrixRoom = FakeMatrixRoom()
|
private val fakeMatrixRoom = FakeMatrixRoom()
|
||||||
private val fakeAnalyticsService = FakeAnalyticsService()
|
private val fakeAnalyticsService = FakeAnalyticsService()
|
||||||
private val messageComposerContextFake = MessageComposerContextFake()
|
private val fakeMessageComposerContext = FakeMessageComposerContext()
|
||||||
private val fakeLocationActions = FakeLocationActions()
|
private val fakeLocationActions = FakeLocationActions()
|
||||||
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
|
||||||
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
|
private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter(
|
||||||
|
|
@ -58,7 +58,7 @@ class SendLocationPresenterTest {
|
||||||
},
|
},
|
||||||
room = fakeMatrixRoom,
|
room = fakeMatrixRoom,
|
||||||
analyticsService = fakeAnalyticsService,
|
analyticsService = fakeAnalyticsService,
|
||||||
messageComposerContext = messageComposerContextFake,
|
messageComposerContext = fakeMessageComposerContext,
|
||||||
locationActions = fakeLocationActions,
|
locationActions = fakeLocationActions,
|
||||||
buildMeta = fakeBuildMeta,
|
buildMeta = fakeBuildMeta,
|
||||||
)
|
)
|
||||||
|
|
@ -379,7 +379,7 @@ class SendLocationPresenterTest {
|
||||||
shouldShowRationale = false,
|
shouldShowRationale = false,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
messageComposerContextFake.apply {
|
fakeMessageComposerContext.apply {
|
||||||
composerMode = MessageComposerMode.Edit(
|
composerMode = MessageComposerMode.Edit(
|
||||||
eventId = null, defaultContent = "", transactionId = null
|
eventId = null, defaultContent = "", transactionId = null
|
||||||
)
|
)
|
||||||
|
|
@ -425,7 +425,7 @@ class SendLocationPresenterTest {
|
||||||
shouldShowRationale = false,
|
shouldShowRationale = false,
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
messageComposerContextFake.apply {
|
fakeMessageComposerContext.apply {
|
||||||
composerMode = MessageComposerMode.Edit(
|
composerMode = MessageComposerMode.Edit(
|
||||||
eventId = null, defaultContent = "", transactionId = null
|
eventId = null, defaultContent = "", transactionId = null
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -31,8 +31,8 @@ interface LockScreenEntryPoint : FeatureEntryPoint {
|
||||||
fun build(): Node
|
fun build(): Node
|
||||||
}
|
}
|
||||||
|
|
||||||
interface Callback: Plugin {
|
interface Callback : Plugin {
|
||||||
fun onSetupCompleted()
|
fun onSetupDone()
|
||||||
}
|
}
|
||||||
|
|
||||||
enum class Target {
|
enum class Target {
|
||||||
|
|
|
||||||
|
|
@ -16,7 +16,14 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.api
|
package io.element.android.features.lockscreen.api
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
import android.view.WindowManager
|
||||||
|
import androidx.activity.ComponentActivity
|
||||||
|
import androidx.lifecycle.lifecycleScope
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.launchIn
|
||||||
|
import kotlinx.coroutines.flow.onEach
|
||||||
|
|
||||||
interface LockScreenService {
|
interface LockScreenService {
|
||||||
/**
|
/**
|
||||||
|
|
@ -28,5 +35,34 @@ interface LockScreenService {
|
||||||
* Check if setting up the lock screen is required.
|
* Check if setting up the lock screen is required.
|
||||||
* @return true if the lock screen is mandatory and not setup yet, false otherwise.
|
* @return true if the lock screen is mandatory and not setup yet, false otherwise.
|
||||||
*/
|
*/
|
||||||
suspend fun isSetupRequired(): Boolean
|
fun isSetupRequired(): Flow<Boolean>
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Check if pin is setup.
|
||||||
|
* @return true if the pin is setup, false otherwise.
|
||||||
|
*/
|
||||||
|
fun isPinSetup(): Flow<Boolean>
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Makes sure the secure flag is set on the activity if the pin is setup.
|
||||||
|
* @param activity the activity to set the flag on.
|
||||||
|
*/
|
||||||
|
fun LockScreenService.handleSecureFlag(activity: ComponentActivity) {
|
||||||
|
isPinSetup()
|
||||||
|
.onEach { isPinSetup ->
|
||||||
|
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||||
|
activity.setRecentsScreenshotEnabled(!isPinSetup)
|
||||||
|
} else {
|
||||||
|
if (isPinSetup) {
|
||||||
|
activity.window.setFlags(
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE,
|
||||||
|
WindowManager.LayoutParams.FLAG_SECURE
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
.launchIn(activity.lifecycleScope)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -35,8 +35,12 @@ import io.element.android.services.appnavstate.api.AppForegroundStateService
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Job
|
import kotlinx.coroutines.Job
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.combine
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
import kotlin.time.Duration
|
import kotlin.time.Duration
|
||||||
|
|
@ -113,14 +117,23 @@ class DefaultLockScreenService @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun isSetupRequired(): Boolean {
|
override fun isPinSetup(): Flow<Boolean> {
|
||||||
return lockScreenConfig.isPinMandatory
|
return combine(
|
||||||
&& featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock)
|
featureFlagService.isFeatureEnabledFlow(FeatureFlags.PinUnlock),
|
||||||
&& !pinCodeManager.isPinCodeAvailable()
|
pinCodeManager.hasPinCode()
|
||||||
|
) { isEnabled, hasPinCode ->
|
||||||
|
isEnabled && hasPinCode
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isSetupRequired(): Flow<Boolean> {
|
||||||
|
return isPinSetup().map { isPinSetup ->
|
||||||
|
!isPinSetup && lockScreenConfig.isPinMandatory
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
|
private fun CoroutineScope.lockIfNeeded(gracePeriod: Duration = Duration.ZERO) = launch {
|
||||||
if (featureFlagService.isFeatureEnabled(FeatureFlags.PinUnlock) && pinCodeManager.isPinCodeAvailable()) {
|
if (isPinSetup().first()) {
|
||||||
delay(gracePeriod)
|
delay(gracePeriod)
|
||||||
_lockScreenState.value = LockScreenLockState.Locked
|
_lockScreenState.value = LockScreenLockState.Locked
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,7 +20,6 @@ import android.os.Parcelable
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.composable.Children
|
import com.bumble.appyx.core.composable.Children
|
||||||
import com.bumble.appyx.core.lifecycle.subscribe
|
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
|
@ -30,8 +29,6 @@ import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
import io.element.android.features.lockscreen.api.LockScreenEntryPoint
|
||||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
|
||||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
|
||||||
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
|
import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode
|
||||||
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
|
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
|
||||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
||||||
|
|
@ -46,7 +43,6 @@ import kotlinx.parcelize.Parcelize
|
||||||
class LockScreenFlowNode @AssistedInject constructor(
|
class LockScreenFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val pinCodeManager: PinCodeManager,
|
|
||||||
) : BackstackNode<LockScreenFlowNode.NavTarget>(
|
) : BackstackNode<LockScreenFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
|
initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget,
|
||||||
|
|
@ -71,26 +67,14 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||||
data object Settings : NavTarget
|
data object Settings : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
|
private class OnSetupDoneCallback(private val plugins: List<LockScreenEntryPoint.Callback>) : LockScreenSetupFlowNode.Callback {
|
||||||
override fun onPinCodeCreated() {
|
override fun onSetupDone() {
|
||||||
plugins<LockScreenEntryPoint.Callback>().forEach {
|
plugins.forEach {
|
||||||
it.onSetupCompleted()
|
it.onSetupDone()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onBuilt() {
|
|
||||||
super.onBuilt()
|
|
||||||
lifecycle.subscribe(
|
|
||||||
onCreate = {
|
|
||||||
pinCodeManager.addCallback(pinCodeManagerCallback)
|
|
||||||
},
|
|
||||||
onDestroy = {
|
|
||||||
pinCodeManager.removeCallback(pinCodeManagerCallback)
|
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Unlock -> {
|
NavTarget.Unlock -> {
|
||||||
|
|
@ -98,7 +82,8 @@ class LockScreenFlowNode @AssistedInject constructor(
|
||||||
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
|
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
|
||||||
}
|
}
|
||||||
NavTarget.Setup -> {
|
NavTarget.Setup -> {
|
||||||
createNode<LockScreenSetupFlowNode>(buildContext)
|
val callback = OnSetupDoneCallback(plugins())
|
||||||
|
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.Settings -> {
|
NavTarget.Settings -> {
|
||||||
createNode<LockScreenSettingsFlowNode>(buildContext)
|
createNode<LockScreenSettingsFlowNode>(buildContext)
|
||||||
|
|
|
||||||
|
|
@ -24,6 +24,7 @@ import androidx.core.content.ContextCompat
|
||||||
import androidx.fragment.app.FragmentActivity
|
import androidx.fragment.app.FragmentActivity
|
||||||
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
import io.element.android.libraries.cryptography.api.EncryptionDecryptionService
|
||||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
|
import kotlinx.coroutines.CancellationException
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
import kotlinx.coroutines.CompletableDeferred
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.security.InvalidKeyException
|
import java.security.InvalidKeyException
|
||||||
|
|
@ -86,7 +87,12 @@ class DefaultBiometricUnlock(
|
||||||
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
|
val callback = AuthenticationCallback(callbacks, deferredAuthenticationResult)
|
||||||
val prompt = BiometricPrompt(activity, executor, callback)
|
val prompt = BiometricPrompt(activity, executor, callback)
|
||||||
prompt.authenticate(promptInfo, cryptoObject)
|
prompt.authenticate(promptInfo, cryptoObject)
|
||||||
return deferredAuthenticationResult.await()
|
return try {
|
||||||
|
deferredAuthenticationResult.await()
|
||||||
|
} catch (cancellation: CancellationException) {
|
||||||
|
prompt.cancelAuthentication()
|
||||||
|
BiometricUnlock.AuthenticationResult.Failure(cancellation)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Throws(KeyPermanentlyInvalidatedException::class)
|
@Throws(KeyPermanentlyInvalidatedException::class)
|
||||||
|
|
@ -110,7 +116,6 @@ private class AuthenticationCallback(
|
||||||
override fun onAuthenticationFailed() {
|
override fun onAuthenticationFailed() {
|
||||||
super.onAuthenticationFailed()
|
super.onAuthenticationFailed()
|
||||||
callbacks.forEach { it.onBiometricUnlockFailed(null) }
|
callbacks.forEach { it.onBiometricUnlockFailed(null) }
|
||||||
deferredAuthenticationResult.complete(BiometricUnlock.AuthenticationResult.Failure(null))
|
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
override fun onAuthenticationSucceeded(result: BiometricPrompt.AuthenticationResult) {
|
||||||
|
|
|
||||||
|
|
@ -23,6 +23,7 @@ import io.element.android.libraries.cryptography.api.EncryptionResult
|
||||||
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
import io.element.android.libraries.cryptography.api.SecretKeyRepository
|
||||||
import io.element.android.libraries.di.AppScope
|
import io.element.android.libraries.di.AppScope
|
||||||
import io.element.android.libraries.di.SingleIn
|
import io.element.android.libraries.di.SingleIn
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import java.util.concurrent.CopyOnWriteArrayList
|
import java.util.concurrent.CopyOnWriteArrayList
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
|
@ -46,7 +47,7 @@ class DefaultPinCodeManager @Inject constructor(
|
||||||
callbacks.remove(callback)
|
callbacks.remove(callback)
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun isPinCodeAvailable(): Boolean {
|
override fun hasPinCode(): Flow<Boolean> {
|
||||||
return lockScreenStore.hasPinCode()
|
return lockScreenStore.hasPinCode()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.pin
|
package io.element.android.features.lockscreen.impl.pin
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* This interface is the main interface to manage the pin code.
|
* This interface is the main interface to manage the pin code.
|
||||||
* Implementation should take care of encrypting the pin code and storing it.
|
* Implementation should take care of encrypting the pin code and storing it.
|
||||||
|
|
@ -55,7 +57,7 @@ interface PinCodeManager {
|
||||||
/**
|
/**
|
||||||
* @return true if a pin code is available.
|
* @return true if a pin code is available.
|
||||||
*/
|
*/
|
||||||
suspend fun isPinCodeAvailable(): Boolean
|
fun hasPinCode(): Flow<Boolean>
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* @return the size of the saved pin code.
|
* @return the size of the saved pin code.
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.pin.model
|
package io.element.android.features.lockscreen.impl.pin.model
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface PinDigit {
|
sealed interface PinDigit {
|
||||||
data object Empty : PinDigit
|
data object Empty : PinDigit
|
||||||
data class Filled(val value: Char) : PinDigit
|
data class Filled(val value: Char) : PinDigit
|
||||||
|
|
|
||||||
|
|
@ -32,16 +32,15 @@ import com.bumble.appyx.navmodel.backstack.operation.push
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockManager
|
|
||||||
import io.element.android.features.lockscreen.impl.biometric.DefaultBiometricUnlockCallback
|
|
||||||
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
import io.element.android.features.lockscreen.impl.pin.DefaultPinCodeManagerCallback
|
||||||
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
import io.element.android.features.lockscreen.impl.pin.PinCodeManager
|
||||||
import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode
|
import io.element.android.features.lockscreen.impl.setup.pin.SetupPinNode
|
||||||
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode
|
||||||
import io.element.android.libraries.architecture.BackstackNode
|
import io.element.android.libraries.architecture.BackstackNode
|
||||||
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
|
||||||
import io.element.android.libraries.architecture.createNode
|
import io.element.android.libraries.architecture.createNode
|
||||||
import io.element.android.libraries.di.SessionScope
|
import io.element.android.libraries.di.SessionScope
|
||||||
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.parcelize.Parcelize
|
import kotlinx.parcelize.Parcelize
|
||||||
|
|
||||||
|
|
@ -50,7 +49,6 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||||
@Assisted buildContext: BuildContext,
|
@Assisted buildContext: BuildContext,
|
||||||
@Assisted plugins: List<Plugin>,
|
@Assisted plugins: List<Plugin>,
|
||||||
private val pinCodeManager: PinCodeManager,
|
private val pinCodeManager: PinCodeManager,
|
||||||
private val biometricUnlockManager: BiometricUnlockManager,
|
|
||||||
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
|
) : BackstackNode<LockScreenSettingsFlowNode.NavTarget>(
|
||||||
backstack = BackStack(
|
backstack = BackStack(
|
||||||
initialElement = NavTarget.Unknown,
|
initialElement = NavTarget.Unknown,
|
||||||
|
|
@ -68,44 +66,39 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||||
data object Unlock : NavTarget
|
data object Unlock : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object Setup : NavTarget
|
data object SetupPin : NavTarget
|
||||||
|
|
||||||
@Parcelize
|
@Parcelize
|
||||||
data object Settings : NavTarget
|
data object Settings : NavTarget
|
||||||
}
|
}
|
||||||
|
|
||||||
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
|
private val pinCodeManagerCallback = object : DefaultPinCodeManagerCallback() {
|
||||||
override fun onPinCodeVerified() {
|
|
||||||
backstack.newRoot(NavTarget.Settings)
|
|
||||||
}
|
|
||||||
|
|
||||||
override fun onPinCodeRemoved() {
|
override fun onPinCodeRemoved() {
|
||||||
navigateUp()
|
navigateUp()
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
private val biometricUnlockCallback = object : DefaultBiometricUnlockCallback() {
|
override fun onPinCodeCreated() {
|
||||||
override fun onBiometricUnlockSuccess() {
|
|
||||||
backstack.newRoot(NavTarget.Settings)
|
backstack.newRoot(NavTarget.Settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
init {
|
override fun onBuilt() {
|
||||||
|
super.onBuilt()
|
||||||
lifecycleScope.launch {
|
lifecycleScope.launch {
|
||||||
if (pinCodeManager.isPinCodeAvailable()) {
|
val hasPinCode = pinCodeManager.hasPinCode().first()
|
||||||
|
if (hasPinCode) {
|
||||||
backstack.newRoot(NavTarget.Unlock)
|
backstack.newRoot(NavTarget.Unlock)
|
||||||
} else {
|
} else {
|
||||||
backstack.newRoot(NavTarget.Setup)
|
backstack.newRoot(NavTarget.SetupPin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
lifecycle.subscribe(
|
lifecycle.subscribe(
|
||||||
onCreate = {
|
onCreate = {
|
||||||
pinCodeManager.addCallback(pinCodeManagerCallback)
|
pinCodeManager.addCallback(pinCodeManagerCallback)
|
||||||
biometricUnlockManager.addCallback(biometricUnlockCallback)
|
|
||||||
},
|
},
|
||||||
onDestroy = {
|
onDestroy = {
|
||||||
pinCodeManager.removeCallback(pinCodeManagerCallback)
|
pinCodeManager.removeCallback(pinCodeManagerCallback)
|
||||||
biometricUnlockManager.removeCallback(biometricUnlockCallback)
|
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -114,25 +107,26 @@ class LockScreenSettingsFlowNode @AssistedInject constructor(
|
||||||
return when (navTarget) {
|
return when (navTarget) {
|
||||||
NavTarget.Unlock -> {
|
NavTarget.Unlock -> {
|
||||||
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
|
val inputs = PinUnlockNode.Inputs(isInAppUnlock = true)
|
||||||
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs))
|
val callback = object : PinUnlockNode.Callback {
|
||||||
}
|
override fun onUnlock() {
|
||||||
NavTarget.Setup -> {
|
|
||||||
val callback = object : LockScreenSetupFlowNode.Callback {
|
|
||||||
override fun onSetupDone() {
|
|
||||||
backstack.newRoot(NavTarget.Settings)
|
backstack.newRoot(NavTarget.Settings)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<LockScreenSetupFlowNode>(buildContext, plugins = listOf(callback))
|
createNode<PinUnlockNode>(buildContext, plugins = listOf(inputs, callback))
|
||||||
|
}
|
||||||
|
NavTarget.SetupPin -> {
|
||||||
|
createNode<SetupPinNode>(buildContext)
|
||||||
}
|
}
|
||||||
NavTarget.Settings -> {
|
NavTarget.Settings -> {
|
||||||
val callback = object : LockScreenSettingsNode.Callback {
|
val callback = object : LockScreenSettingsNode.Callback {
|
||||||
override fun onChangePinClicked() {
|
override fun onChangePinClicked() {
|
||||||
backstack.push(NavTarget.Setup)
|
backstack.push(NavTarget.SetupPin)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
|
createNode<LockScreenSettingsNode>(buildContext, plugins = listOf(callback))
|
||||||
}
|
}
|
||||||
NavTarget.Unknown -> node(buildContext) { }
|
NavTarget.Unknown -> node(buildContext) { }
|
||||||
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -17,11 +17,10 @@
|
||||||
package io.element.android.features.lockscreen.impl.settings
|
package io.element.android.features.lockscreen.impl.settings
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.runtime.collectAsState
|
import androidx.compose.runtime.collectAsState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableIntStateOf
|
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.produceState
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import io.element.android.appconfig.LockScreenConfig
|
import io.element.android.appconfig.LockScreenConfig
|
||||||
|
|
@ -43,23 +42,15 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun present(): LockScreenSettingsState {
|
override fun present(): LockScreenSettingsState {
|
||||||
var triggerComputation by remember {
|
val showRemovePinOption by produceState(initialValue = false) {
|
||||||
mutableIntStateOf(0)
|
pinCodeManager.hasPinCode().collect { hasPinCode ->
|
||||||
}
|
value = !lockScreenConfig.isPinMandatory && hasPinCode
|
||||||
var showRemovePinOption by remember {
|
}
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
|
||||||
var showToggleBiometric by remember {
|
|
||||||
mutableStateOf(false)
|
|
||||||
}
|
}
|
||||||
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
val isBiometricEnabled by lockScreenStore.isBiometricUnlockAllowed().collectAsState(initial = false)
|
||||||
var showRemovePinConfirmation by remember {
|
var showRemovePinConfirmation by remember {
|
||||||
mutableStateOf(false)
|
mutableStateOf(false)
|
||||||
}
|
}
|
||||||
LaunchedEffect(triggerComputation) {
|
|
||||||
showRemovePinOption = !lockScreenConfig.isPinMandatory && pinCodeManager.isPinCodeAvailable()
|
|
||||||
showToggleBiometric = biometricUnlockManager.isDeviceSecured
|
|
||||||
}
|
|
||||||
|
|
||||||
fun handleEvents(event: LockScreenSettingsEvents) {
|
fun handleEvents(event: LockScreenSettingsEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -69,7 +60,6 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||||
if (showRemovePinConfirmation) {
|
if (showRemovePinConfirmation) {
|
||||||
showRemovePinConfirmation = false
|
showRemovePinConfirmation = false
|
||||||
pinCodeManager.deletePinCode()
|
pinCodeManager.deletePinCode()
|
||||||
triggerComputation++
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -86,7 +76,7 @@ class LockScreenSettingsPresenter @Inject constructor(
|
||||||
showRemovePinOption = showRemovePinOption,
|
showRemovePinOption = showRemovePinOption,
|
||||||
isBiometricEnabled = isBiometricEnabled,
|
isBiometricEnabled = isBiometricEnabled,
|
||||||
showRemovePinConfirmation = showRemovePinConfirmation,
|
showRemovePinConfirmation = showRemovePinConfirmation,
|
||||||
showToggleBiometric = showToggleBiometric,
|
showToggleBiometric = biometricUnlockManager.isDeviceSecured,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -17,19 +17,16 @@
|
||||||
package io.element.android.features.lockscreen.impl.setup.biometric
|
package io.element.android.features.lockscreen.impl.setup.biometric
|
||||||
|
|
||||||
import androidx.activity.compose.BackHandler
|
import androidx.activity.compose.BackHandler
|
||||||
import androidx.compose.foundation.layout.Arrangement.spacedBy
|
|
||||||
import androidx.compose.foundation.layout.Column
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material.icons.Icons
|
import androidx.compose.material.icons.Icons
|
||||||
import androidx.compose.material.icons.filled.Fingerprint
|
import androidx.compose.material.icons.filled.Fingerprint
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.features.lockscreen.impl.R
|
import io.element.android.features.lockscreen.impl.R
|
||||||
|
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
|
@ -76,10 +73,8 @@ private fun SetupBiometricFooter(
|
||||||
onSkipClicked: () -> Unit,
|
onSkipClicked: () -> Unit,
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
Column(
|
ButtonColumnMolecule(
|
||||||
modifier = modifier.fillMaxWidth(),
|
modifier = modifier,
|
||||||
verticalArrangement = spacedBy(16.dp),
|
|
||||||
horizontalAlignment = Alignment.CenterHorizontally
|
|
||||||
) {
|
) {
|
||||||
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
|
val biometricAuth = stringResource(id = R.string.screen_app_lock_biometric_authentication)
|
||||||
Button(
|
Button(
|
||||||
|
|
|
||||||
|
|
@ -17,6 +17,6 @@
|
||||||
package io.element.android.features.lockscreen.impl.setup.pin
|
package io.element.android.features.lockscreen.impl.setup.pin
|
||||||
|
|
||||||
sealed interface SetupPinEvents {
|
sealed interface SetupPinEvents {
|
||||||
data class OnPinEntryChanged(val entryAsText: String) : SetupPinEvents
|
data class OnPinEntryChanged(val entryAsText: String, val fromConfirmationStep: Boolean) : SetupPinEvents
|
||||||
data object ClearFailure : SetupPinEvents
|
data object ClearFailure : SetupPinEvents
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,11 @@ import io.element.android.libraries.core.meta.BuildMeta
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Some time for the ui to refresh before showing confirmation step.
|
||||||
|
*/
|
||||||
|
private const val DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS = 100L
|
||||||
|
|
||||||
class SetupPinPresenter @Inject constructor(
|
class SetupPinPresenter @Inject constructor(
|
||||||
private val lockScreenConfig: LockScreenConfig,
|
private val lockScreenConfig: LockScreenConfig,
|
||||||
private val pinValidator: PinValidator,
|
private val pinValidator: PinValidator,
|
||||||
|
|
@ -60,8 +65,7 @@ class SetupPinPresenter @Inject constructor(
|
||||||
setupPinFailure = pinValidationResult.failure
|
setupPinFailure = pinValidationResult.failure
|
||||||
}
|
}
|
||||||
PinValidator.Result.Valid -> {
|
PinValidator.Result.Valid -> {
|
||||||
// Leave some time for the ui to refresh before showing confirmation
|
delay(DELAY_BEFORE_CONFIRMATION_STEP_IN_MILLIS)
|
||||||
delay(150)
|
|
||||||
isConfirmationStep = true
|
isConfirmationStep = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -81,7 +85,8 @@ class SetupPinPresenter @Inject constructor(
|
||||||
fun handleEvents(event: SetupPinEvents) {
|
fun handleEvents(event: SetupPinEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
is SetupPinEvents.OnPinEntryChanged -> {
|
is SetupPinEvents.OnPinEntryChanged -> {
|
||||||
if (isConfirmationStep) {
|
// Use the fromConfirmationStep flag from ui to avoid race condition.
|
||||||
|
if (event.fromConfirmationStep) {
|
||||||
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
confirmPinEntry = confirmPinEntry.fillWith(event.entryAsText)
|
||||||
} else {
|
} else {
|
||||||
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
choosePinEntry = choosePinEntry.fillWith(event.entryAsText)
|
||||||
|
|
|
||||||
|
|
@ -116,8 +116,8 @@ private fun SetupPinContent(
|
||||||
PinEntryTextField(
|
PinEntryTextField(
|
||||||
pinEntry = state.activePinEntry,
|
pinEntry = state.activePinEntry,
|
||||||
isSecured = true,
|
isSecured = true,
|
||||||
onValueChange = {
|
onValueChange = { entry ->
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(it))
|
state.eventSink(SetupPinEvents.OnPinEntryChanged(entry, state.isConfirmationStep))
|
||||||
},
|
},
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
.focusRequester(focusRequester)
|
.focusRequester(focusRequester)
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,8 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.storage
|
package io.element.android.features.lockscreen.impl.storage
|
||||||
|
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Should be implemented by any class that provides access to the encrypted PIN code.
|
* Should be implemented by any class that provides access to the encrypted PIN code.
|
||||||
* All methods are suspending in case there are async IO operations involved.
|
* All methods are suspending in case there are async IO operations involved.
|
||||||
|
|
@ -39,5 +41,6 @@ interface EncryptedPinCodeStorage {
|
||||||
/**
|
/**
|
||||||
* Returns whether the PIN code is stored or not.
|
* Returns whether the PIN code is stored or not.
|
||||||
*/
|
*/
|
||||||
suspend fun hasPinCode(): Boolean
|
fun hasPinCode(): Flow<Boolean>
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -85,10 +85,10 @@ class PreferencesLockScreenStore @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hasPinCode(): Boolean {
|
override fun hasPinCode(): Flow<Boolean> {
|
||||||
return context.dataStore.data.map { preferences ->
|
return context.dataStore.data.map { preferences ->
|
||||||
preferences[pinCodeKey] != null
|
preferences[pinCodeKey] != null
|
||||||
}.first()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||||
|
|
|
||||||
|
|
@ -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
|
package io.element.android.features.lockscreen.impl.unlock
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import com.bumble.appyx.core.modality.BuildContext
|
import com.bumble.appyx.core.modality.BuildContext
|
||||||
import com.bumble.appyx.core.node.Node
|
import com.bumble.appyx.core.node.Node
|
||||||
import com.bumble.appyx.core.plugin.Plugin
|
import com.bumble.appyx.core.plugin.Plugin
|
||||||
|
import com.bumble.appyx.core.plugin.plugins
|
||||||
import dagger.assisted.Assisted
|
import dagger.assisted.Assisted
|
||||||
import dagger.assisted.AssistedInject
|
import dagger.assisted.AssistedInject
|
||||||
import io.element.android.anvilannotations.ContributesNode
|
import io.element.android.anvilannotations.ContributesNode
|
||||||
|
|
@ -35,15 +37,30 @@ class PinUnlockNode @AssistedInject constructor(
|
||||||
private val presenter: PinUnlockPresenter,
|
private val presenter: PinUnlockPresenter,
|
||||||
) : Node(buildContext, plugins = plugins) {
|
) : Node(buildContext, plugins = plugins) {
|
||||||
|
|
||||||
|
interface Callback : Plugin {
|
||||||
|
fun onUnlock()
|
||||||
|
}
|
||||||
|
|
||||||
data class Inputs(
|
data class Inputs(
|
||||||
val isInAppUnlock: Boolean
|
val isInAppUnlock: Boolean
|
||||||
) : NodeInputs
|
) : NodeInputs
|
||||||
|
|
||||||
private val inputs: Inputs = inputs()
|
private val inputs: Inputs = inputs()
|
||||||
|
|
||||||
|
private fun onUnlock() {
|
||||||
|
plugins<Callback>().forEach {
|
||||||
|
it.onUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
override fun View(modifier: Modifier) {
|
override fun View(modifier: Modifier) {
|
||||||
val state = presenter.present()
|
val state = presenter.present()
|
||||||
|
LaunchedEffect(state.isUnlocked) {
|
||||||
|
if (state.isUnlocked) {
|
||||||
|
onUnlock()
|
||||||
|
}
|
||||||
|
}
|
||||||
PinUnlockView(
|
PinUnlockView(
|
||||||
state = state,
|
state = state,
|
||||||
isInAppUnlock = inputs.isInAppUnlock,
|
isInAppUnlock = inputs.isInAppUnlock,
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
private val biometricUnlockManager: BiometricUnlockManager,
|
private val biometricUnlockManager: BiometricUnlockManager,
|
||||||
private val matrixClient: MatrixClient,
|
private val matrixClient: MatrixClient,
|
||||||
private val coroutineScope: CoroutineScope,
|
private val coroutineScope: CoroutineScope,
|
||||||
|
private val pinUnlockHelper: PinUnlockHelper,
|
||||||
) : Presenter<PinUnlockState> {
|
) : Presenter<PinUnlockState> {
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -66,9 +67,10 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
var biometricUnlockResult by remember {
|
var biometricUnlockResult by remember {
|
||||||
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
|
mutableStateOf<BiometricUnlock.AuthenticationResult?>(null)
|
||||||
}
|
}
|
||||||
|
val isUnlocked = remember {
|
||||||
|
mutableStateOf(false)
|
||||||
|
}
|
||||||
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
|
val biometricUnlock = biometricUnlockManager.rememberBiometricUnlock()
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
suspend {
|
suspend {
|
||||||
val pinCodeSize = pinCodeManager.getPinCodeSize()
|
val pinCodeSize = pinCodeManager.getPinCodeSize()
|
||||||
|
|
@ -94,6 +96,9 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
showSignOutPrompt = true
|
showSignOutPrompt = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
pinUnlockHelper.OnUnlockEffect {
|
||||||
|
isUnlocked.value = true
|
||||||
|
}
|
||||||
|
|
||||||
fun handleEvents(event: PinUnlockEvents) {
|
fun handleEvents(event: PinUnlockEvents) {
|
||||||
when (event) {
|
when (event) {
|
||||||
|
|
@ -129,6 +134,7 @@ class PinUnlockPresenter @Inject constructor(
|
||||||
signOutAction = signOutAction.value,
|
signOutAction = signOutAction.value,
|
||||||
showBiometricUnlock = biometricUnlock.isActive,
|
showBiometricUnlock = biometricUnlock.isActive,
|
||||||
biometricUnlockResult = biometricUnlockResult,
|
biometricUnlockResult = biometricUnlockResult,
|
||||||
|
isUnlocked = isUnlocked.value,
|
||||||
eventSink = ::handleEvents
|
eventSink = ::handleEvents
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -28,6 +28,7 @@ data class PinUnlockState(
|
||||||
val showSignOutPrompt: Boolean,
|
val showSignOutPrompt: Boolean,
|
||||||
val signOutAction: Async<String?>,
|
val signOutAction: Async<String?>,
|
||||||
val showBiometricUnlock: Boolean,
|
val showBiometricUnlock: Boolean,
|
||||||
|
val isUnlocked: Boolean,
|
||||||
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
|
val biometricUnlockResult: BiometricUnlock.AuthenticationResult?,
|
||||||
val eventSink: (PinUnlockEvents) -> Unit
|
val eventSink: (PinUnlockEvents) -> Unit
|
||||||
) {
|
) {
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ fun aPinUnlockState(
|
||||||
showSignOutPrompt: Boolean = false,
|
showSignOutPrompt: Boolean = false,
|
||||||
showBiometricUnlock: Boolean = true,
|
showBiometricUnlock: Boolean = true,
|
||||||
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
|
biometricUnlockResult: BiometricUnlock.AuthenticationResult? = null,
|
||||||
|
isUnlocked: Boolean = false,
|
||||||
signOutAction: Async<String?> = Async.Uninitialized,
|
signOutAction: Async<String?> = Async.Uninitialized,
|
||||||
) = PinUnlockState(
|
) = PinUnlockState(
|
||||||
pinEntry = Async.Success(pinEntry),
|
pinEntry = Async.Success(pinEntry),
|
||||||
|
|
@ -50,5 +51,6 @@ fun aPinUnlockState(
|
||||||
showBiometricUnlock = showBiometricUnlock,
|
showBiometricUnlock = showBiometricUnlock,
|
||||||
signOutAction = signOutAction,
|
signOutAction = signOutAction,
|
||||||
biometricUnlockResult = biometricUnlockResult,
|
biometricUnlockResult = biometricUnlockResult,
|
||||||
|
isUnlocked = isUnlocked,
|
||||||
eventSink = {}
|
eventSink = {}
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,39 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<plurals name="screen_app_lock_subtitle">
|
||||||
|
<item quantity="one">"Máte %1$d pokus pro odemknutí"</item>
|
||||||
|
<item quantity="few">"Máte %1$d pokusy pro odemknutí"</item>
|
||||||
|
<item quantity="other">"Máte %1$d pokusů pro odemknutí"</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
||||||
|
<item quantity="one">"Špatný PIN. Máte %1$d další pokus"</item>
|
||||||
|
<item quantity="few">"Špatný PIN. Máte %1$d další pokusy"</item>
|
||||||
|
<item quantity="other">"Špatný PIN. Máte %1$d dalších pokusů"</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="screen_app_lock_biometric_authentication">"Biometrické ověřování"</string>
|
||||||
|
<string name="screen_app_lock_biometric_unlock">"biometrické odemknutí"</string>
|
||||||
|
<string name="screen_app_lock_biometric_unlock_title_android">"Odemkněte pomocí biometrie"</string>
|
||||||
|
<string name="screen_app_lock_forgot_pin">"Zapomněli jste PIN?"</string>
|
||||||
|
<string name="screen_app_lock_settings_change_pin">"Změnit PIN kód"</string>
|
||||||
|
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povolit biometrické odemykání"</string>
|
||||||
|
<string name="screen_app_lock_settings_remove_pin">"Odstranit PIN"</string>
|
||||||
|
<string name="screen_app_lock_settings_remove_pin_alert_message">"Opravdu chcete odstranit PIN?"</string>
|
||||||
|
<string name="screen_app_lock_settings_remove_pin_alert_title">"Odstranit PIN?"</string>
|
||||||
|
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Povolit %1$s"</string>
|
||||||
|
<string name="screen_app_lock_setup_biometric_unlock_skip">"Raději bych použil PIN"</string>
|
||||||
|
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Ušetřete si čas a použijte pokaždé %1$s pro odemknutí aplikace"</string>
|
||||||
|
<string name="screen_app_lock_setup_choose_pin">"Zvolte PIN"</string>
|
||||||
|
<string name="screen_app_lock_setup_confirm_pin">"Potvrďte PIN"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Z bezpečnostních důvodů si toto nemůžete zvolit jako svůj PIN kód"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Zvolte jiný PIN"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_context">"Zamkněte %1$s pro zvýšení bezpečnosti vašich konverzací.
|
||||||
|
|
||||||
|
Vyberte si něco zapamatovatelného. Pokud tento kód PIN zapomenete, budete z aplikace odhlášeni."</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Zadejte stejný PIN dvakrát"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy se neshodují."</string>
|
||||||
|
<string name="screen_app_lock_signout_alert_message">"Abyste mohli pokračovat, budete se muset znovu přihlásit a vytvořit nový PIN"</string>
|
||||||
|
<string name="screen_app_lock_signout_alert_title">"Jste odhlášeni"</string>
|
||||||
|
<string name="screen_app_lock_use_biometric_android">"Použijte biometrické údaje"</string>
|
||||||
|
<string name="screen_app_lock_use_pin_android">"Použít PIN"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -10,6 +10,7 @@
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="screen_app_lock_biometric_authentication">"Authentification biométrique"</string>
|
<string name="screen_app_lock_biometric_authentication">"Authentification biométrique"</string>
|
||||||
<string name="screen_app_lock_biometric_unlock">"Déverrouillage biométrique"</string>
|
<string name="screen_app_lock_biometric_unlock">"Déverrouillage biométrique"</string>
|
||||||
|
<string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string>
|
||||||
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
|
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
|
||||||
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>
|
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>
|
||||||
<string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string>
|
<string name="screen_app_lock_settings_enable_biometric_unlock">"Autoriser le déverrouillage biométrique"</string>
|
||||||
|
|
@ -28,5 +29,7 @@
|
||||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Les codes PIN ne correspondent pas"</string>
|
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"Les codes PIN ne correspondent pas"</string>
|
||||||
<string name="screen_app_lock_signout_alert_message">"Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."</string>
|
<string name="screen_app_lock_signout_alert_message">"Pour continuer, vous devrez vous connecter à nouveau et créer un nouveau code PIN."</string>
|
||||||
<string name="screen_app_lock_signout_alert_title">"Vous êtes en train de vous déconnecter"</string>
|
<string name="screen_app_lock_signout_alert_title">"Vous êtes en train de vous déconnecter"</string>
|
||||||
|
<string name="screen_app_lock_use_biometric_android">"Utiliser la biométrie"</string>
|
||||||
|
<string name="screen_app_lock_use_pin_android">"Utiliser le code PIN"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Déconnexion…"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,4 +1,39 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<plurals name="screen_app_lock_subtitle">
|
||||||
|
<item quantity="one">"Вы попытались разблокировать %1$d раз"</item>
|
||||||
|
<item quantity="few">"Вы попытались разблокировать %1$d раз"</item>
|
||||||
|
<item quantity="many">"Вы попытались разблокировать много раз"</item>
|
||||||
|
</plurals>
|
||||||
|
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
||||||
|
<item quantity="one">"Неверный PIN-код. У вас остался %1$d шанс"</item>
|
||||||
|
<item quantity="few">"Неверный PIN-код. У вас остался %1$d шансов"</item>
|
||||||
|
<item quantity="many">"Неверный PIN-код. У вас остался %1$d шанса"</item>
|
||||||
|
</plurals>
|
||||||
|
<string name="screen_app_lock_biometric_authentication">"биометрическая идентификация"</string>
|
||||||
|
<string name="screen_app_lock_biometric_unlock">"биометрическая разблокировать"</string>
|
||||||
|
<string name="screen_app_lock_biometric_unlock_title_android">"Разблокировать с помощью биометрии"</string>
|
||||||
|
<string name="screen_app_lock_forgot_pin">"Забыли PIN-код?"</string>
|
||||||
|
<string name="screen_app_lock_settings_change_pin">"Измените PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_settings_enable_biometric_unlock">"Разрешить биометрическую разблокировать"</string>
|
||||||
|
<string name="screen_app_lock_settings_remove_pin">"Удалить PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_settings_remove_pin_alert_message">"Вы действительно хотите удалить PIN-код?"</string>
|
||||||
|
<string name="screen_app_lock_settings_remove_pin_alert_title">"Удалить PIN-код?"</string>
|
||||||
|
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Разрешить %1$s"</string>
|
||||||
|
<string name="screen_app_lock_setup_biometric_unlock_skip">"Я бы предпочел использовать PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Сэкономьте время и используйте %1$s для разблокировки приложения"</string>
|
||||||
|
<string name="screen_app_lock_setup_choose_pin">"Выберите PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_setup_confirm_pin">"Подтвердите PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Из соображений безопасности вы не можешь выбрать это в качестве PIN-кода."</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Выберите другой PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_context">"Заблокируйте %1$s, чтобы повысить безопасность ваших чатов.
|
||||||
|
|
||||||
|
Выберите что-нибудь незабываемое. Если вы забудете этот PIN-код, вы выйдете из приложения."</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Повторите PIN-код"</string>
|
||||||
|
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-коды не совпадают"</string>
|
||||||
|
<string name="screen_app_lock_signout_alert_message">"Чтобы продолжить, вам необходимо повторно войти в систему и создать новый PIN-код."</string>
|
||||||
|
<string name="screen_app_lock_signout_alert_title">"Вы выходите из системы"</string>
|
||||||
|
<string name="screen_app_lock_use_biometric_android">"Использовать биометрию"</string>
|
||||||
|
<string name="screen_app_lock_use_pin_android">"Использовать PIN-код"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,10 @@
|
||||||
<?xml version="1.0" encoding="utf-8"?>
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||||
|
<plurals name="screen_app_lock_subtitle">
|
||||||
|
<item quantity="one">"Máte %1$d pokus na odomknutie"</item>
|
||||||
|
<item quantity="few">"Máte %1$d pokusy na odomknutie"</item>
|
||||||
|
<item quantity="other">"Máte %1$d pokusov na odomknutie"</item>
|
||||||
|
</plurals>
|
||||||
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
<plurals name="screen_app_lock_subtitle_wrong_pin">
|
||||||
<item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item>
|
<item quantity="one">"Nesprávny PIN kód. Máte ešte %1$d pokus"</item>
|
||||||
<item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item>
|
<item quantity="few">"Nesprávny PIN kód. Máte ešte %1$d pokusy"</item>
|
||||||
|
|
@ -7,6 +12,7 @@
|
||||||
</plurals>
|
</plurals>
|
||||||
<string name="screen_app_lock_biometric_authentication">"biometrické overenie"</string>
|
<string name="screen_app_lock_biometric_authentication">"biometrické overenie"</string>
|
||||||
<string name="screen_app_lock_biometric_unlock">"biometrické odomknutie"</string>
|
<string name="screen_app_lock_biometric_unlock">"biometrické odomknutie"</string>
|
||||||
|
<string name="screen_app_lock_biometric_unlock_title_android">"Odomknúť pomocou biometrie"</string>
|
||||||
<string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string>
|
<string name="screen_app_lock_forgot_pin">"Zabudli ste PIN?"</string>
|
||||||
<string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string>
|
<string name="screen_app_lock_settings_change_pin">"Zmeniť PIN kód"</string>
|
||||||
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string>
|
<string name="screen_app_lock_settings_enable_biometric_unlock">"Povoliť biometrické odomknutie"</string>
|
||||||
|
|
@ -27,5 +33,7 @@ Vyberte si niečo zapamätateľné. Ak tento kód PIN zabudnete, budete z aplik
|
||||||
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string>
|
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN kódy sa nezhodujú"</string>
|
||||||
<string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string>
|
<string name="screen_app_lock_signout_alert_message">"Ak chcete pokračovať, musíte sa znovu prihlásiť a vytvoriť nový PIN kód."</string>
|
||||||
<string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string>
|
<string name="screen_app_lock_signout_alert_title">"Prebieha odhlasovanie"</string>
|
||||||
|
<string name="screen_app_lock_use_biometric_android">"Použiť biometrické údaje"</string>
|
||||||
|
<string name="screen_app_lock_use_pin_android">"Použiť PIN"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Prebieha odhlasovanie…"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,7 @@
|
||||||
|
|
||||||
package io.element.android.features.lockscreen.impl.pin
|
package io.element.android.features.lockscreen.impl.pin
|
||||||
|
|
||||||
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
import io.element.android.features.lockscreen.impl.pin.storage.InMemoryLockScreenStore
|
||||||
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
|
import io.element.android.libraries.cryptography.impl.AESEncryptionDecryptionService
|
||||||
|
|
@ -32,10 +33,13 @@ class DefaultPinCodeManagerTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `given a pin code when create and delete assert no pin code left`() = runTest {
|
fun `given a pin code when create and delete assert no pin code left`() = runTest {
|
||||||
pinCodeManager.createPinCode("1234")
|
pinCodeManager.hasPinCode().test {
|
||||||
assertThat(pinCodeManager.isPinCodeAvailable()).isTrue()
|
assertThat(awaitItem()).isFalse()
|
||||||
pinCodeManager.deletePinCode()
|
pinCodeManager.createPinCode("1234")
|
||||||
assertThat(pinCodeManager.isPinCodeAvailable()).isFalse()
|
assertThat(awaitItem()).isTrue()
|
||||||
|
pinCodeManager.deletePinCode()
|
||||||
|
assertThat(awaitItem()).isFalse()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,12 @@ private const val DEFAULT_REMAINING_ATTEMPTS = 3
|
||||||
|
|
||||||
class InMemoryLockScreenStore : LockScreenStore {
|
class InMemoryLockScreenStore : LockScreenStore {
|
||||||
|
|
||||||
|
private val hasPinCode = MutableStateFlow(false)
|
||||||
private var pinCode: String? = null
|
private var pinCode: String? = null
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
hasPinCode.value = value != null
|
||||||
|
}
|
||||||
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
|
private var remainingAttempts: Int = DEFAULT_REMAINING_ATTEMPTS
|
||||||
private var isBiometricUnlockAllowed = MutableStateFlow(false)
|
private var isBiometricUnlockAllowed = MutableStateFlow(false)
|
||||||
|
|
||||||
|
|
@ -52,8 +57,8 @@ class InMemoryLockScreenStore : LockScreenStore {
|
||||||
pinCode = null
|
pinCode = null
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun hasPinCode(): Boolean {
|
override fun hasPinCode(): Flow<Boolean> {
|
||||||
return pinCode != null
|
return hasPinCode
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
override fun isBiometricUnlockAllowed(): Flow<Boolean> {
|
||||||
|
|
|
||||||
|
|
@ -60,14 +60,14 @@ class SetupPinPresenterTest {
|
||||||
state.confirmPinEntry.assertEmpty()
|
state.confirmPinEntry.assertEmpty()
|
||||||
assertThat(state.setupPinFailure).isNull()
|
assertThat(state.setupPinFailure).isNull()
|
||||||
assertThat(state.isConfirmationStep).isFalse()
|
assertThat(state.isConfirmationStep).isFalse()
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(halfCompletePin))
|
state.onPinEntryChanged(halfCompletePin)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
state.choosePinEntry.assertText(halfCompletePin)
|
state.choosePinEntry.assertText(halfCompletePin)
|
||||||
state.confirmPinEntry.assertEmpty()
|
state.confirmPinEntry.assertEmpty()
|
||||||
assertThat(state.setupPinFailure).isNull()
|
assertThat(state.setupPinFailure).isNull()
|
||||||
assertThat(state.isConfirmationStep).isFalse()
|
assertThat(state.isConfirmationStep).isFalse()
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(blacklistedPin))
|
state.onPinEntryChanged(blacklistedPin)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.choosePinEntry.assertText(blacklistedPin)
|
state.choosePinEntry.assertText(blacklistedPin)
|
||||||
|
|
@ -77,7 +77,7 @@ class SetupPinPresenterTest {
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.choosePinEntry.assertEmpty()
|
state.choosePinEntry.assertEmpty()
|
||||||
assertThat(state.setupPinFailure).isNull()
|
assertThat(state.setupPinFailure).isNull()
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
state.onPinEntryChanged(completePin)
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate {
|
consumeItemsUntilPredicate {
|
||||||
it.isConfirmationStep
|
it.isConfirmationStep
|
||||||
|
|
@ -85,7 +85,7 @@ class SetupPinPresenterTest {
|
||||||
state.choosePinEntry.assertText(completePin)
|
state.choosePinEntry.assertText(completePin)
|
||||||
state.confirmPinEntry.assertEmpty()
|
state.confirmPinEntry.assertEmpty()
|
||||||
assertThat(state.isConfirmationStep).isTrue()
|
assertThat(state.isConfirmationStep).isTrue()
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(mismatchedPin))
|
state.onPinEntryChanged(mismatchedPin)
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.choosePinEntry.assertText(completePin)
|
state.choosePinEntry.assertText(completePin)
|
||||||
|
|
@ -98,7 +98,7 @@ class SetupPinPresenterTest {
|
||||||
state.confirmPinEntry.assertEmpty()
|
state.confirmPinEntry.assertEmpty()
|
||||||
assertThat(state.isConfirmationStep).isFalse()
|
assertThat(state.isConfirmationStep).isFalse()
|
||||||
assertThat(state.setupPinFailure).isNull()
|
assertThat(state.setupPinFailure).isNull()
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
state.onPinEntryChanged(completePin)
|
||||||
}
|
}
|
||||||
consumeItemsUntilPredicate {
|
consumeItemsUntilPredicate {
|
||||||
it.isConfirmationStep
|
it.isConfirmationStep
|
||||||
|
|
@ -106,7 +106,7 @@ class SetupPinPresenterTest {
|
||||||
state.choosePinEntry.assertText(completePin)
|
state.choosePinEntry.assertText(completePin)
|
||||||
state.confirmPinEntry.assertEmpty()
|
state.confirmPinEntry.assertEmpty()
|
||||||
assertThat(state.isConfirmationStep).isTrue()
|
assertThat(state.isConfirmationStep).isTrue()
|
||||||
state.eventSink(SetupPinEvents.OnPinEntryChanged(completePin))
|
state.onPinEntryChanged(completePin)
|
||||||
}
|
}
|
||||||
awaitItem().also { state ->
|
awaitItem().also { state ->
|
||||||
state.choosePinEntry.assertText(completePin)
|
state.choosePinEntry.assertText(completePin)
|
||||||
|
|
@ -116,6 +116,10 @@ class SetupPinPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun SetupPinState.onPinEntryChanged(pinEntry: String){
|
||||||
|
eventSink(SetupPinEvents.OnPinEntryChanged(pinEntry, isConfirmationStep))
|
||||||
|
}
|
||||||
|
|
||||||
private fun createSetupPinPresenter(
|
private fun createSetupPinPresenter(
|
||||||
callback: PinCodeManager.Callback,
|
callback: PinCodeManager.Callback,
|
||||||
lockScreenConfig: LockScreenConfig = aLockScreenConfig(
|
lockScreenConfig: LockScreenConfig = aLockScreenConfig(
|
||||||
|
|
|
||||||
|
|
@ -32,7 +32,6 @@ import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||||
import io.element.android.tests.testutils.awaitLastSequentialItem
|
import io.element.android.tests.testutils.awaitLastSequentialItem
|
||||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||||
import kotlinx.coroutines.CompletableDeferred
|
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
|
@ -44,13 +43,7 @@ class PinUnlockPresenterTest {
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - success verify flow`() = runTest {
|
fun `present - success verify flow`() = runTest {
|
||||||
val pinCodeVerified = CompletableDeferred<Unit>()
|
val presenter = createPinUnlockPresenter(this)
|
||||||
val callback = object : DefaultPinCodeManagerCallback() {
|
|
||||||
override fun onPinCodeCreated() {
|
|
||||||
pinCodeVerified.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val presenter = createPinUnlockPresenter(this, callback = callback)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -58,6 +51,7 @@ class PinUnlockPresenterTest {
|
||||||
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
|
assertThat(state.pinEntry).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
assertThat(state.showWrongPinTitle).isFalse()
|
assertThat(state.showWrongPinTitle).isFalse()
|
||||||
assertThat(state.showSignOutPrompt).isFalse()
|
assertThat(state.showSignOutPrompt).isFalse()
|
||||||
|
assertThat(state.isUnlocked).isFalse()
|
||||||
assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
|
assertThat(state.signOutAction).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
|
assertThat(state.remainingAttempts).isInstanceOf(Async.Uninitialized::class.java)
|
||||||
}
|
}
|
||||||
|
|
@ -77,20 +71,14 @@ class PinUnlockPresenterTest {
|
||||||
}
|
}
|
||||||
awaitLastSequentialItem().also { state ->
|
awaitLastSequentialItem().also { state ->
|
||||||
state.pinEntry.assertText(completePin)
|
state.pinEntry.assertText(completePin)
|
||||||
|
assertThat(state.isUnlocked).isTrue()
|
||||||
}
|
}
|
||||||
pinCodeVerified.await()
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - failure verify flow`() = runTest {
|
fun `present - failure verify flow`() = runTest {
|
||||||
val pinCodeVerified = CompletableDeferred<Unit>()
|
val presenter = createPinUnlockPresenter(this)
|
||||||
val callback = object : DefaultPinCodeManagerCallback() {
|
|
||||||
override fun onPinCodeCreated() {
|
|
||||||
pinCodeVerified.complete(Unit)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val presenter = createPinUnlockPresenter(this, callback = callback)
|
|
||||||
moleculeFlow(RecompositionMode.Immediate) {
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
presenter.present()
|
presenter.present()
|
||||||
}.test {
|
}.test {
|
||||||
|
|
@ -160,6 +148,7 @@ class PinUnlockPresenterTest {
|
||||||
biometricUnlockManager = biometricUnlockManager,
|
biometricUnlockManager = biometricUnlockManager,
|
||||||
matrixClient = FakeMatrixClient(),
|
matrixClient = FakeMatrixClient(),
|
||||||
coroutineScope = scope,
|
coroutineScope = scope,
|
||||||
|
pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -18,21 +18,27 @@ package io.element.android.features.lockscreen.test
|
||||||
|
|
||||||
import io.element.android.features.lockscreen.api.LockScreenLockState
|
import io.element.android.features.lockscreen.api.LockScreenLockState
|
||||||
import io.element.android.features.lockscreen.api.LockScreenService
|
import io.element.android.features.lockscreen.api.LockScreenService
|
||||||
|
import kotlinx.coroutines.flow.Flow
|
||||||
import kotlinx.coroutines.flow.MutableStateFlow
|
import kotlinx.coroutines.flow.MutableStateFlow
|
||||||
import kotlinx.coroutines.flow.StateFlow
|
import kotlinx.coroutines.flow.StateFlow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
|
|
||||||
class FakeLockScreenService : LockScreenService {
|
class FakeLockScreenService : LockScreenService {
|
||||||
|
|
||||||
private var isSetupRequired: Boolean = false
|
private var isPinSetup = MutableStateFlow(false)
|
||||||
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
|
private val _lockState: MutableStateFlow<LockScreenLockState> = MutableStateFlow(LockScreenLockState.Locked)
|
||||||
override val lockState: StateFlow<LockScreenLockState> = _lockState
|
override val lockState: StateFlow<LockScreenLockState> = _lockState
|
||||||
|
|
||||||
override suspend fun isSetupRequired(): Boolean {
|
override fun isSetupRequired(): Flow<Boolean> {
|
||||||
return isSetupRequired
|
return isPinSetup.map { !it }
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setIsSetupRequired(isSetupRequired: Boolean) {
|
fun setIsPinSetup(isPinSetup: Boolean) {
|
||||||
this.isSetupRequired = isSetupRequired
|
this.isPinSetup.value = isPinSetup
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun isPinSetup(): Flow<Boolean> {
|
||||||
|
return isPinSetup
|
||||||
}
|
}
|
||||||
|
|
||||||
fun setLockState(lockState: LockScreenLockState) {
|
fun setLockState(lockState: LockScreenLockState) {
|
||||||
|
|
|
||||||
|
|
@ -19,7 +19,7 @@ plugins {
|
||||||
alias(libs.plugins.anvil)
|
alias(libs.plugins.anvil)
|
||||||
alias(libs.plugins.ksp)
|
alias(libs.plugins.ksp)
|
||||||
id("kotlin-parcelize")
|
id("kotlin-parcelize")
|
||||||
kotlin("plugin.serialization") version "1.9.10"
|
kotlin("plugin.serialization") version "1.9.20"
|
||||||
}
|
}
|
||||||
|
|
||||||
android {
|
android {
|
||||||
|
|
|
||||||
|
|
@ -25,17 +25,14 @@ import androidx.compose.runtime.getValue
|
||||||
import androidx.compose.runtime.mutableStateOf
|
import androidx.compose.runtime.mutableStateOf
|
||||||
import androidx.compose.runtime.remember
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.compose.ui.Alignment
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.viewinterop.AndroidView
|
import androidx.compose.ui.viewinterop.AndroidView
|
||||||
import io.element.android.features.login.impl.oidc.OidcUrlParser
|
import io.element.android.features.login.impl.oidc.OidcUrlParser
|
||||||
import io.element.android.libraries.architecture.Async
|
|
||||||
import io.element.android.libraries.core.bool.orFalse
|
import io.element.android.libraries.core.bool.orFalse
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
import io.element.android.libraries.designsystem.components.async.AsyncView
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun OidcView(
|
fun OidcView(
|
||||||
|
|
@ -79,21 +76,11 @@ fun OidcView(
|
||||||
}
|
}
|
||||||
)
|
)
|
||||||
|
|
||||||
when (state.requestState) {
|
AsyncView(
|
||||||
Async.Uninitialized -> Unit
|
async = state.requestState,
|
||||||
is Async.Failure -> {
|
onSuccess = { onNavigateBack() },
|
||||||
ErrorDialog(
|
onErrorDismiss = { state.eventSink(OidcEvents.ClearError) }
|
||||||
content = state.requestState.error.toString(),
|
)
|
||||||
onDismiss = { state.eventSink(OidcEvents.ClearError) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Async.Loading -> {
|
|
||||||
CircularProgressIndicator(
|
|
||||||
modifier = Modifier.align(Alignment.Center)
|
|
||||||
)
|
|
||||||
}
|
|
||||||
is Async.Success -> onNavigateBack()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,9 @@
|
||||||
<string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string>
|
<string name="screen_change_account_provider_subtitle">"Použite iného poskytovateľa účtu, ako napríklad vlastný súkromný server alebo pracovný účet."</string>
|
||||||
<string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string>
|
<string name="screen_change_account_provider_title">"Zmeniť poskytovateľa účtu"</string>
|
||||||
<string name="screen_change_server_error_invalid_homeserver">"Nemohli sme sa spojiť s týmto domovským serverom. Skontrolujte prosím, či ste zadali URL adresu domovského servera správne. Ak je adresa URL správna, kontaktujte svoj domovský server pre ďalšiu pomoc."</string>
|
<string name="screen_change_server_error_invalid_homeserver">"Nemohli sme sa spojiť s týmto domovským serverom. Skontrolujte prosím, či ste zadali URL adresu domovského servera správne. Ak je adresa URL správna, kontaktujte svoj domovský server pre ďalšiu pomoc."</string>
|
||||||
|
<string name="screen_change_server_error_no_sliding_sync_message">"Tento server momentálne nepodporuje kĺzavú synchronizáciu."</string>
|
||||||
<string name="screen_change_server_form_header">"Adresa URL domovského servera"</string>
|
<string name="screen_change_server_form_header">"Adresa URL domovského servera"</string>
|
||||||
|
<string name="screen_change_server_form_notice">"Môžete sa pripojiť iba k existujúcemu serveru, ktorý podporuje kĺzavú synchronizáciu. Správca domovského servera ju bude musieť nakonfigurovať. %1$s"</string>
|
||||||
<string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string>
|
<string name="screen_change_server_subtitle">"Aká je adresa vášho servera?"</string>
|
||||||
<string name="screen_change_server_title">"Vyberte svoj server"</string>
|
<string name="screen_change_server_title">"Vyberte svoj server"</string>
|
||||||
<string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string>
|
<string name="screen_login_error_deactivated_account">"Tento účet bol deaktivovaný."</string>
|
||||||
|
|
|
||||||
|
|
@ -18,9 +18,9 @@ package io.element.android.features.logout.impl
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.Arrangement
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
import androidx.compose.foundation.layout.Column
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.ColumnScope
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
import androidx.compose.foundation.layout.padding
|
import androidx.compose.foundation.layout.padding
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
|
|
@ -29,11 +29,8 @@ import androidx.compose.ui.res.stringResource
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
|
import io.element.android.libraries.designsystem.atomic.pages.FlowStepPage
|
||||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
|
||||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
|
||||||
import io.element.android.libraries.designsystem.components.ProgressDialog
|
import io.element.android.libraries.designsystem.components.ProgressDialog
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
|
|
@ -41,15 +38,15 @@ import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
|
||||||
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
|
import io.element.android.libraries.designsystem.theme.progressIndicatorTrackColor
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
import io.element.android.libraries.matrix.api.encryption.BackupUploadState
|
||||||
import io.element.android.libraries.matrix.api.encryption.SteadyStateException
|
import io.element.android.libraries.matrix.api.encryption.SteadyStateException
|
||||||
|
import io.element.android.libraries.testtags.TestTags
|
||||||
|
import io.element.android.libraries.testtags.testTag
|
||||||
import io.element.android.libraries.theme.ElementTheme
|
import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3Api::class)
|
|
||||||
@Composable
|
@Composable
|
||||||
fun LogoutView(
|
fun LogoutView(
|
||||||
state: LogoutState,
|
state: LogoutState,
|
||||||
|
|
@ -60,29 +57,23 @@ fun LogoutView(
|
||||||
) {
|
) {
|
||||||
val eventSink = state.eventSink
|
val eventSink = state.eventSink
|
||||||
|
|
||||||
HeaderFooterPage(
|
FlowStepPage(
|
||||||
|
onBackClicked = onBackClicked,
|
||||||
|
title = title(state),
|
||||||
|
subTitle = subtitle(state),
|
||||||
|
iconResourceId = CommonDrawables.ic_key,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
topBar = {
|
content = { Content(state) },
|
||||||
TopAppBar(
|
buttons = {
|
||||||
navigationIcon = { BackButton(onClick = onBackClicked) },
|
Buttons(
|
||||||
title = {},
|
|
||||||
)
|
|
||||||
},
|
|
||||||
header = {
|
|
||||||
HeaderContent(state = state)
|
|
||||||
},
|
|
||||||
footer = {
|
|
||||||
BottomMenu(
|
|
||||||
state = state,
|
state = state,
|
||||||
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
|
onChangeRecoveryKeyClicked = onChangeRecoveryKeyClicked,
|
||||||
onLogoutClicked = {
|
onLogoutClicked = {
|
||||||
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
|
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
|
||||||
},
|
}
|
||||||
)
|
)
|
||||||
}
|
},
|
||||||
) {
|
)
|
||||||
Content(state = state)
|
|
||||||
}
|
|
||||||
|
|
||||||
// Log out confirmation dialog
|
// Log out confirmation dialog
|
||||||
if (state.showConfirmationDialog) {
|
if (state.showConfirmationDialog) {
|
||||||
|
|
@ -90,9 +81,6 @@ fun LogoutView(
|
||||||
title = stringResource(id = CommonStrings.action_signout),
|
title = stringResource(id = CommonStrings.action_signout),
|
||||||
content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
|
content = stringResource(id = R.string.screen_signout_confirmation_dialog_content),
|
||||||
submitText = stringResource(id = CommonStrings.action_signout),
|
submitText = stringResource(id = CommonStrings.action_signout),
|
||||||
onCancelClicked = {
|
|
||||||
eventSink(LogoutEvents.CloseDialogs)
|
|
||||||
},
|
|
||||||
onSubmitClicked = {
|
onSubmitClicked = {
|
||||||
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
|
eventSink(LogoutEvents.Logout(ignoreSdkError = false))
|
||||||
},
|
},
|
||||||
|
|
@ -110,9 +98,6 @@ fun LogoutView(
|
||||||
title = stringResource(id = CommonStrings.dialog_title_error),
|
title = stringResource(id = CommonStrings.dialog_title_error),
|
||||||
content = stringResource(id = CommonStrings.error_unknown),
|
content = stringResource(id = CommonStrings.error_unknown),
|
||||||
submitText = stringResource(id = CommonStrings.action_signout_anyway),
|
submitText = stringResource(id = CommonStrings.action_signout_anyway),
|
||||||
onCancelClicked = {
|
|
||||||
eventSink(LogoutEvents.CloseDialogs)
|
|
||||||
},
|
|
||||||
onSubmitClicked = {
|
onSubmitClicked = {
|
||||||
eventSink(LogoutEvents.Logout(ignoreSdkError = true))
|
eventSink(LogoutEvents.Logout(ignoreSdkError = true))
|
||||||
},
|
},
|
||||||
|
|
@ -130,30 +115,23 @@ fun LogoutView(
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun HeaderContent(
|
private fun title(state: LogoutState): String {
|
||||||
state: LogoutState,
|
return when {
|
||||||
modifier: Modifier = Modifier,
|
|
||||||
) {
|
|
||||||
val title = when {
|
|
||||||
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title)
|
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_title)
|
||||||
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title)
|
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_title)
|
||||||
else -> stringResource(CommonStrings.action_signout)
|
else -> stringResource(CommonStrings.action_signout)
|
||||||
}
|
}
|
||||||
val subtitle = when {
|
}
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
private fun subtitle(state: LogoutState): String? {
|
||||||
|
return when {
|
||||||
(state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection ->
|
(state.backupUploadState as? BackupUploadState.SteadyException)?.exception is SteadyStateException.Connection ->
|
||||||
stringResource(id = R.string.screen_signout_key_backup_offline_subtitle)
|
stringResource(id = R.string.screen_signout_key_backup_offline_subtitle)
|
||||||
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle)
|
state.backupUploadState.isBackingUp() -> stringResource(id = R.string.screen_signout_key_backup_ongoing_subtitle)
|
||||||
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle)
|
state.isLastSession -> stringResource(id = R.string.screen_signout_key_backup_disabled_subtitle)
|
||||||
else -> null
|
else -> null
|
||||||
}
|
}
|
||||||
|
|
||||||
val paddingTop = 0.dp
|
|
||||||
IconTitleSubtitleMolecule(
|
|
||||||
modifier = modifier.padding(top = paddingTop),
|
|
||||||
iconResourceId = CommonDrawables.ic_key,
|
|
||||||
title = title,
|
|
||||||
subTitle = subtitle,
|
|
||||||
)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun BackupUploadState.isBackingUp(): Boolean {
|
private fun BackupUploadState.isBackingUp(): Boolean {
|
||||||
|
|
@ -169,35 +147,33 @@ private fun BackupUploadState.isBackingUp(): Boolean {
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun BottomMenu(
|
private fun ColumnScope.Buttons(
|
||||||
state: LogoutState,
|
state: LogoutState,
|
||||||
onLogoutClicked: () -> Unit,
|
onLogoutClicked: () -> Unit,
|
||||||
onChangeRecoveryKeyClicked: () -> Unit,
|
onChangeRecoveryKeyClicked: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val logoutAction = state.logoutAction
|
val logoutAction = state.logoutAction
|
||||||
ButtonColumnMolecule(
|
if (state.isLastSession) {
|
||||||
modifier = Modifier.padding(bottom = 20.dp)
|
OutlinedButton(
|
||||||
) {
|
text = stringResource(id = CommonStrings.common_settings),
|
||||||
if (state.isLastSession) {
|
|
||||||
OutlinedButton(
|
|
||||||
text = stringResource(id = CommonStrings.common_settings),
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
onClick = onChangeRecoveryKeyClicked,
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val signOutSubmitRes = when {
|
|
||||||
logoutAction is Async.Loading -> R.string.screen_signout_in_progress_dialog_content
|
|
||||||
state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway
|
|
||||||
else -> CommonStrings.action_signout
|
|
||||||
}
|
|
||||||
Button(
|
|
||||||
text = stringResource(id = signOutSubmitRes),
|
|
||||||
showProgress = logoutAction is Async.Loading,
|
|
||||||
destructive = true,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
onClick = onLogoutClicked,
|
onClick = onChangeRecoveryKeyClicked,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
val signOutSubmitRes = when {
|
||||||
|
logoutAction is Async.Loading -> R.string.screen_signout_in_progress_dialog_content
|
||||||
|
state.backupUploadState.isBackingUp() -> CommonStrings.action_signout_anyway
|
||||||
|
else -> CommonStrings.action_signout
|
||||||
|
}
|
||||||
|
Button(
|
||||||
|
text = stringResource(id = signOutSubmitRes),
|
||||||
|
showProgress = logoutAction is Async.Loading,
|
||||||
|
destructive = true,
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxWidth()
|
||||||
|
.testTag(TestTags.signOut),
|
||||||
|
onClick = onLogoutClicked,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -213,7 +189,7 @@ private fun Content(
|
||||||
) {
|
) {
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
progress = state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat(),
|
progress = { state.backupUploadState.backedUpCount.toFloat() / state.backupUploadState.totalCount.toFloat() },
|
||||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||||
)
|
)
|
||||||
Text(
|
Text(
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@
|
||||||
<string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string>
|
<string name="screen_signout_confirmation_dialog_content">"Opravdu se chcete odhlásit?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
|
<string name="screen_signout_confirmation_dialog_title">"Odhlásit se"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Odhlašování…"</string>
|
||||||
|
<string name="screen_signout_key_backup_disabled_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám."</string>
|
||||||
|
<string name="screen_signout_key_backup_disabled_title">"Vypnuli jste zálohování"</string>
|
||||||
|
<string name="screen_signout_key_backup_offline_subtitle">"Když jste přešli do režimu offline, vaše klíče se ještě stále zálohovaly. Znovu se připojte, aby bylo možné před odhlášením zálohovat vaše klíče."</string>
|
||||||
|
<string name="screen_signout_key_backup_offline_title">"Vaše klíče jsou stále zálohovány"</string>
|
||||||
|
<string name="screen_signout_key_backup_ongoing_subtitle">"Před odhlášením prosím počkejte na dokončení."</string>
|
||||||
|
<string name="screen_signout_key_backup_ongoing_title">"Vaše klíče jsou stále zálohovány"</string>
|
||||||
|
<string name="screen_signout_recovery_disabled_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, ztratíte přístup ke svým šifrovaným zprávám."</string>
|
||||||
|
<string name="screen_signout_recovery_disabled_title">"Obnovení není nastaveno"</string>
|
||||||
|
<string name="screen_signout_save_recovery_key_subtitle">"Chystáte se odhlásit z poslední relace. Pokud se nyní odhlásíte, můžete ztratit přístup k šifrovaným zprávám."</string>
|
||||||
|
<string name="screen_signout_save_recovery_key_title">"Uložili jste si klíč pro obnovení?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string>
|
<string name="screen_signout_confirmation_dialog_submit">"Odhlásit se"</string>
|
||||||
<string name="screen_signout_preference_item">"Odhlásit se"</string>
|
<string name="screen_signout_preference_item">"Odhlásit se"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -3,6 +3,16 @@
|
||||||
<string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string>
|
<string name="screen_signout_confirmation_dialog_content">"Вы уверены, что вы хотите выйти?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
|
<string name="screen_signout_confirmation_dialog_title">"Выйти"</string>
|
||||||
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
|
<string name="screen_signout_in_progress_dialog_content">"Выполняется выход…"</string>
|
||||||
|
<string name="screen_signout_key_backup_disabled_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям."</string>
|
||||||
|
<string name="screen_signout_key_backup_disabled_title">"Вы отключили резервное копирование"</string>
|
||||||
|
<string name="screen_signout_key_backup_offline_subtitle">"Когда вы перешли в автономный режим, резервное копирование ваших ключей продолжалось. Повторно подключитесь, чтобы перед выходом из системы можно было создать резервную копию ключей."</string>
|
||||||
|
<string name="screen_signout_key_backup_offline_title">"Резервное копирование ключей все еще продолжается"</string>
|
||||||
|
<string name="screen_signout_key_backup_ongoing_subtitle">"Пожалуйста, дождитесь завершения процесса, прежде чем выходить из системы."</string>
|
||||||
|
<string name="screen_signout_key_backup_ongoing_title">"Резервное копирование ключей все еще продолжается"</string>
|
||||||
|
<string name="screen_signout_recovery_disabled_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы потеряете доступ к зашифрованным сообщениям."</string>
|
||||||
|
<string name="screen_signout_recovery_disabled_title">"Восстановление не настроено"</string>
|
||||||
|
<string name="screen_signout_save_recovery_key_subtitle">"Вы собираетесь выйти из последнего сеанса. Если вы выйдете из системы сейчас, вы можете потерять доступ к зашифрованным сообщениям."</string>
|
||||||
|
<string name="screen_signout_save_recovery_key_title">"Вы сохранили свой ключ восстановления?"</string>
|
||||||
<string name="screen_signout_confirmation_dialog_submit">"Выйти"</string>
|
<string name="screen_signout_confirmation_dialog_submit">"Выйти"</string>
|
||||||
<string name="screen_signout_preference_item">"Выйти"</string>
|
<string name="screen_signout_preference_item">"Выйти"</string>
|
||||||
</resources>
|
</resources>
|
||||||
|
|
|
||||||
|
|
@ -75,6 +75,7 @@ dependencies {
|
||||||
testImplementation(projects.libraries.matrix.test)
|
testImplementation(projects.libraries.matrix.test)
|
||||||
testImplementation(projects.libraries.dateformatter.test)
|
testImplementation(projects.libraries.dateformatter.test)
|
||||||
testImplementation(projects.features.networkmonitor.test)
|
testImplementation(projects.features.networkmonitor.test)
|
||||||
|
testImplementation(projects.features.messages.test)
|
||||||
testImplementation(projects.services.analytics.test)
|
testImplementation(projects.services.analytics.test)
|
||||||
testImplementation(projects.tests.testutils)
|
testImplementation(projects.tests.testutils)
|
||||||
testImplementation(projects.libraries.featureflag.test)
|
testImplementation(projects.libraries.featureflag.test)
|
||||||
|
|
|
||||||
|
|
@ -170,9 +170,8 @@ private fun CustomSheetState.getIntOffset(): Int? = try {
|
||||||
null
|
null
|
||||||
}
|
}
|
||||||
|
|
||||||
private sealed class Slot {
|
private sealed interface Slot {
|
||||||
data class SheetContent(val key: Int?) : Slot()
|
data class SheetContent(val key: Int?) : Slot
|
||||||
data object DragHandle : Slot()
|
data object DragHandle : Slot
|
||||||
data object Scaffold : Slot()
|
data object Scaffold : Slot
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -311,7 +311,7 @@ class MessagesPresenter @AssistedInject constructor(
|
||||||
val textContent = messageSummaryFormatter.format(targetEvent)
|
val textContent = messageSummaryFormatter.format(targetEvent)
|
||||||
val attachmentThumbnailInfo = when (targetEvent.content) {
|
val attachmentThumbnailInfo = when (targetEvent.content) {
|
||||||
is TimelineItemImageContent -> AttachmentThumbnailInfo(
|
is TimelineItemImageContent -> AttachmentThumbnailInfo(
|
||||||
thumbnailSource = targetEvent.content.thumbnailSource,
|
thumbnailSource = targetEvent.content.thumbnailSource ?: targetEvent.content.mediaSource,
|
||||||
textContent = targetEvent.content.body,
|
textContent = targetEvent.content.body,
|
||||||
type = AttachmentThumbnailType.Image,
|
type = AttachmentThumbnailType.Image,
|
||||||
blurHash = targetEvent.content.blurhash,
|
blurHash = targetEvent.content.blurhash,
|
||||||
|
|
|
||||||
|
|
@ -41,6 +41,7 @@ import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.geometry.Offset
|
import androidx.compose.ui.geometry.Offset
|
||||||
|
|
@ -104,6 +105,7 @@ import io.element.android.libraries.theme.ElementTheme
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
|
import kotlin.random.Random
|
||||||
import androidx.compose.material3.Button as Material3Button
|
import androidx.compose.material3.Button as Material3Button
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
|
|
@ -339,6 +341,16 @@ private fun MessagesViewContent(
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// This key is used to force the sheet to be remeasured when the content changes.
|
||||||
|
// Any state change that should trigger a height size should be added to the list of remembered values here.
|
||||||
|
val sheetResizeContentKey = remember(
|
||||||
|
state.composerState.mode.relatedEventId,
|
||||||
|
state.composerState.richTextEditorState.lineCount,
|
||||||
|
state.composerState.memberSuggestions.size
|
||||||
|
) {
|
||||||
|
Random.nextInt()
|
||||||
|
}
|
||||||
|
|
||||||
ExpandableBottomSheetScaffold(
|
ExpandableBottomSheetScaffold(
|
||||||
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
sheetDragHandle = if (state.composerState.showTextFormatting) {
|
||||||
@Composable { BottomSheetDragHandle() }
|
@Composable { BottomSheetDragHandle() }
|
||||||
|
|
@ -371,7 +383,7 @@ private fun MessagesViewContent(
|
||||||
state = state,
|
state = state,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
sheetContentKey = state.composerState.richTextEditorState.lineCount + state.composerState.memberSuggestions.size,
|
sheetContentKey = sheetResizeContentKey,
|
||||||
sheetTonalElevation = 0.dp,
|
sheetTonalElevation = 0.dp,
|
||||||
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
|
sheetShadowElevation = if (state.composerState.memberSuggestions.isNotEmpty()) 16.dp else 0.dp,
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -260,7 +260,7 @@ private fun MessageSummary(event: TimelineItem.Event, modifier: Modifier = Modif
|
||||||
AttachmentThumbnail(
|
AttachmentThumbnail(
|
||||||
modifier = imageModifier,
|
modifier = imageModifier,
|
||||||
info = AttachmentThumbnailInfo(
|
info = AttachmentThumbnailInfo(
|
||||||
thumbnailSource = event.content.mediaSource,
|
thumbnailSource = event.content.thumbnailSource ?: event.content.mediaSource,
|
||||||
textContent = textContent,
|
textContent = textContent,
|
||||||
type = AttachmentThumbnailType.Image,
|
type = AttachmentThumbnailType.Image,
|
||||||
blurHash = event.content.blurhash,
|
blurHash = event.content.blurhash,
|
||||||
|
|
|
||||||
|
|
@ -20,6 +20,7 @@ import android.Manifest
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.net.Uri
|
import android.net.Uri
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.MutableState
|
import androidx.compose.runtime.MutableState
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
|
|
@ -409,6 +410,7 @@ class MessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface RoomMemberSuggestion {
|
sealed interface RoomMemberSuggestion {
|
||||||
data object Room : RoomMemberSuggestion
|
data object Room : RoomMemberSuggestion
|
||||||
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
|
data class Member(val roomMember: RoomMember) : RoomMemberSuggestion
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,6 @@ import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.material3.Switch
|
import androidx.compose.material3.Switch
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.platform.LocalFocusManager
|
import androidx.compose.ui.platform.LocalFocusManager
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
|
|
@ -42,10 +41,10 @@ import androidx.compose.ui.text.style.TextAlign
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameter
|
import androidx.compose.ui.tooling.preview.PreviewParameter
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.architecture.Async
|
import io.element.android.libraries.architecture.Async
|
||||||
|
import io.element.android.libraries.designsystem.components.async.AsyncView
|
||||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||||
import io.element.android.libraries.designsystem.components.dialogs.ErrorDialog
|
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
|
||||||
import io.element.android.libraries.designsystem.theme.components.Button
|
import io.element.android.libraries.designsystem.theme.components.Button
|
||||||
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
import io.element.android.libraries.designsystem.theme.components.OutlinedTextField
|
||||||
|
|
@ -64,21 +63,13 @@ fun ReportMessageView(
|
||||||
) {
|
) {
|
||||||
val focusManager = LocalFocusManager.current
|
val focusManager = LocalFocusManager.current
|
||||||
val isSending = state.result is Async.Loading
|
val isSending = state.result is Async.Loading
|
||||||
when (state.result) {
|
AsyncView(
|
||||||
is Async.Success -> {
|
async = state.result,
|
||||||
LaunchedEffect(state.result) {
|
showProgressDialog = false,
|
||||||
onBackClicked()
|
onSuccess = { onBackClicked() },
|
||||||
}
|
errorMessage = { stringResource(CommonStrings.error_unknown) },
|
||||||
return
|
onErrorDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
|
||||||
}
|
)
|
||||||
is Async.Failure -> {
|
|
||||||
ErrorDialog(
|
|
||||||
content = stringResource(CommonStrings.error_unknown),
|
|
||||||
onDismiss = { state.eventSink(ReportMessageEvents.ClearError) }
|
|
||||||
)
|
|
||||||
}
|
|
||||||
else -> Unit
|
|
||||||
}
|
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
topBar = {
|
topBar = {
|
||||||
|
|
|
||||||
|
|
@ -32,6 +32,7 @@ import androidx.compose.foundation.shape.CornerSize
|
||||||
import androidx.compose.foundation.shape.RoundedCornerShape
|
import androidx.compose.foundation.shape.RoundedCornerShape
|
||||||
import androidx.compose.material3.MaterialTheme
|
import androidx.compose.material3.MaterialTheme
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import androidx.compose.ui.Alignment
|
import androidx.compose.ui.Alignment
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.draw.clip
|
import androidx.compose.ui.draw.clip
|
||||||
|
|
@ -44,8 +45,8 @@ import io.element.android.features.messages.impl.R
|
||||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
import io.element.android.features.messages.impl.timeline.model.AggregatedReaction
|
||||||
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
|
import io.element.android.features.messages.impl.timeline.model.AggregatedReactionProvider
|
||||||
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
import io.element.android.features.messages.impl.timeline.model.aTimelineItemReactions
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.text.toDp
|
import io.element.android.libraries.designsystem.text.toDp
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.Surface
|
import io.element.android.libraries.designsystem.theme.components.Surface
|
||||||
|
|
@ -55,6 +56,7 @@ import io.element.android.libraries.theme.ElementTheme
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
@OptIn(ExperimentalFoundationApi::class)
|
@OptIn(ExperimentalFoundationApi::class)
|
||||||
|
@Suppress("ModifierClickableOrder") // This is needed to display the right ripple shape
|
||||||
fun MessagesReactionButton(
|
fun MessagesReactionButton(
|
||||||
onClick: () -> Unit,
|
onClick: () -> Unit,
|
||||||
onLongClick: () -> Unit,
|
onLongClick: () -> Unit,
|
||||||
|
|
@ -102,11 +104,12 @@ fun MessagesReactionButton(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
sealed class MessagesReactionsButtonContent {
|
@Immutable
|
||||||
data class Text(val text: String) : MessagesReactionsButtonContent()
|
sealed interface MessagesReactionsButtonContent {
|
||||||
data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent()
|
data class Text(val text: String) : MessagesReactionsButtonContent
|
||||||
|
data class Icon(@DrawableRes val resourceId: Int) : MessagesReactionsButtonContent
|
||||||
|
|
||||||
data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent()
|
data class Reaction(val reaction: AggregatedReaction) : MessagesReactionsButtonContent
|
||||||
|
|
||||||
val isHighlighted get() = this is Reaction && reaction.isHighlighted
|
val isHighlighted get() = this is Reaction && reaction.isHighlighted
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -374,7 +374,9 @@ private fun MessageEventBubbleContent(
|
||||||
inReplyToClick: () -> Unit,
|
inReplyToClick: () -> Unit,
|
||||||
onTimestampClicked: () -> Unit,
|
onTimestampClicked: () -> Unit,
|
||||||
eventSink: (TimelineEvents) -> Unit,
|
eventSink: (TimelineEvents) -> Unit,
|
||||||
@SuppressLint("ModifierParameter") bubbleModifier: Modifier = Modifier, // need to rename this modifier to distinguish it from the following ones
|
@SuppressLint("ModifierParameter")
|
||||||
|
@Suppress("ModifierNaming")
|
||||||
|
bubbleModifier: Modifier = Modifier, // need to rename this modifier to prevent linter false positives
|
||||||
) {
|
) {
|
||||||
|
|
||||||
// Long clicks are not not automatically propagated from a `clickable`
|
// Long clicks are not not automatically propagated from a `clickable`
|
||||||
|
|
@ -593,7 +595,7 @@ private fun attachmentThumbnailInfoForInReplyTo(inReplyTo: InReplyTo.Ready): Att
|
||||||
val messageContent = inReplyTo.content as? MessageContent ?: return null
|
val messageContent = inReplyTo.content as? MessageContent ?: return null
|
||||||
return when (val type = messageContent.type) {
|
return when (val type = messageContent.type) {
|
||||||
is ImageMessageType -> AttachmentThumbnailInfo(
|
is ImageMessageType -> AttachmentThumbnailInfo(
|
||||||
thumbnailSource = type.info?.thumbnailSource,
|
thumbnailSource = type.info?.thumbnailSource ?: type.source,
|
||||||
textContent = messageContent.body,
|
textContent = messageContent.body,
|
||||||
type = AttachmentThumbnailType.Image,
|
type = AttachmentThumbnailType.Image,
|
||||||
blurHash = type.info?.blurhash,
|
blurHash = type.info?.blurhash,
|
||||||
|
|
|
||||||
|
|
@ -20,8 +20,8 @@ import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.Modifier
|
import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.res.stringResource
|
import androidx.compose.ui.res.stringResource
|
||||||
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemEncryptedContent
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
import io.element.android.libraries.designsystem.utils.CommonDrawables
|
||||||
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
import io.element.android.libraries.matrix.api.timeline.item.event.UnableToDecryptContent
|
||||||
import io.element.android.libraries.ui.strings.CommonStrings
|
import io.element.android.libraries.ui.strings.CommonStrings
|
||||||
|
|
@ -33,7 +33,7 @@ fun TimelineItemEncryptedView(
|
||||||
modifier: Modifier = Modifier
|
modifier: Modifier = Modifier
|
||||||
) {
|
) {
|
||||||
TimelineItemInformativeView(
|
TimelineItemInformativeView(
|
||||||
text = stringResource(id = CommonStrings.common_decryption_error),
|
text = stringResource(id = CommonStrings.common_waiting_for_decryption_key),
|
||||||
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
|
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
|
||||||
iconResourceId = CommonDrawables.ic_september_decryption_error,
|
iconResourceId = CommonDrawables.ic_september_decryption_error,
|
||||||
extraPadding = extraPadding,
|
extraPadding = extraPadding,
|
||||||
|
|
|
||||||
|
|
@ -34,7 +34,7 @@ fun TimelineItemUnknownView(
|
||||||
TimelineItemInformativeView(
|
TimelineItemInformativeView(
|
||||||
text = stringResource(id = CommonStrings.common_unsupported_event),
|
text = stringResource(id = CommonStrings.common_unsupported_event),
|
||||||
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
|
iconDescription = stringResource(id = CommonStrings.dialog_title_warning),
|
||||||
iconResourceId = CommonDrawables.ic_compound_info,
|
iconResourceId = CommonDrawables.ic_compound_info_solid,
|
||||||
extraPadding = extraPadding,
|
extraPadding = extraPadding,
|
||||||
modifier = modifier
|
modifier = modifier
|
||||||
)
|
)
|
||||||
|
|
|
||||||
|
|
@ -56,7 +56,7 @@ fun TimelineEncryptedHistoryBannerView(
|
||||||
) {
|
) {
|
||||||
Icon(
|
Icon(
|
||||||
modifier = Modifier.size(20.dp),
|
modifier = Modifier.size(20.dp),
|
||||||
resourceId = CommonDrawables.ic_compound_info,
|
resourceId = CommonDrawables.ic_compound_info_solid,
|
||||||
contentDescription = "Info",
|
contentDescription = "Info",
|
||||||
tint = ElementTheme.colors.iconInfoPrimary
|
tint = ElementTheme.colors.iconInfoPrimary
|
||||||
)
|
)
|
||||||
|
|
@ -79,7 +79,7 @@ private fun SessionState.toStringResId(): Int {
|
||||||
|
|
||||||
@PreviewsDayNight
|
@PreviewsDayNight
|
||||||
@Composable
|
@Composable
|
||||||
internal fun TimelineEncryptedHistoryBannerViewPreview(
|
internal fun EncryptedHistoryBannerViewPreview(
|
||||||
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
|
@PreviewParameter(SessionStateProvider::class) sessionState: SessionState,
|
||||||
) = ElementPreview {
|
) = ElementPreview {
|
||||||
TimelineEncryptedHistoryBannerView(sessionState = sessionState)
|
TimelineEncryptedHistoryBannerView(sessionState = sessionState)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import androidx.media3.common.MimeTypes
|
import androidx.media3.common.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||||
|
|
||||||
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
|
open class TimelineItemImageContentProvider : PreviewParameterProvider<TimelineItemImageContent> {
|
||||||
override val values: Sequence<TimelineItemImageContent>
|
override val values: Sequence<TimelineItemImageContent>
|
||||||
|
|
@ -34,7 +35,7 @@ fun aTimelineItemImageContent() = TimelineItemImageContent(
|
||||||
mediaSource = MediaSource(""),
|
mediaSource = MediaSource(""),
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
mimeType = MimeTypes.IMAGE_JPEG,
|
mimeType = MimeTypes.IMAGE_JPEG,
|
||||||
blurhash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
blurhash = A_BLUR_HASH,
|
||||||
width = null,
|
width = null,
|
||||||
height = 300,
|
height = 300,
|
||||||
aspectRatio = 0.5f,
|
aspectRatio = 0.5f,
|
||||||
|
|
|
||||||
|
|
@ -16,6 +16,9 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface TimelineItemStateContent : TimelineItemEventContent {
|
sealed interface TimelineItemStateContent : TimelineItemEventContent {
|
||||||
val body: String
|
val body: String
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -16,8 +16,10 @@
|
||||||
|
|
||||||
package io.element.android.features.messages.impl.timeline.model.event
|
package io.element.android.features.messages.impl.timeline.model.event
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Immutable
|
||||||
import org.jsoup.nodes.Document
|
import org.jsoup.nodes.Document
|
||||||
|
|
||||||
|
@Immutable
|
||||||
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
sealed interface TimelineItemTextBasedContent : TimelineItemEventContent {
|
||||||
val body: String
|
val body: String
|
||||||
val htmlDocument: Document?
|
val htmlDocument: Document?
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.timeline.model.event
|
||||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
|
import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH
|
||||||
|
|
||||||
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineItemVideoContent> {
|
||||||
override val values: Sequence<TimelineItemVideoContent>
|
override val values: Sequence<TimelineItemVideoContent>
|
||||||
|
|
@ -32,7 +33,7 @@ open class TimelineItemVideoContentProvider : PreviewParameterProvider<TimelineI
|
||||||
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
fun aTimelineItemVideoContent() = TimelineItemVideoContent(
|
||||||
body = "Video.mp4",
|
body = "Video.mp4",
|
||||||
thumbnailSource = null,
|
thumbnailSource = null,
|
||||||
blurHash = "TQF5:I_NtRE4kXt7Z#MwkCIARPjr",
|
blurHash = A_BLUR_HASH,
|
||||||
aspectRatio = 0.5f,
|
aspectRatio = 0.5f,
|
||||||
duration = 100,
|
duration = 100,
|
||||||
videoSource = MediaSource(""),
|
videoSource = MediaSource(""),
|
||||||
|
|
|
||||||
|
|
@ -36,13 +36,13 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
|
|
||||||
val state: Flow<State> = mediaPlayer.state.map { state ->
|
val state: Flow<State> = mediaPlayer.state.map { state ->
|
||||||
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
|
if (lastPlayedMediaPath == null || lastPlayedMediaPath != state.mediaId) {
|
||||||
return@map State.NotPlaying
|
return@map State.NotLoaded
|
||||||
}
|
}
|
||||||
|
|
||||||
State(
|
State(
|
||||||
isPlaying = state.isPlaying,
|
isPlaying = state.isPlaying,
|
||||||
currentPosition = state.currentPosition,
|
currentPosition = state.currentPosition,
|
||||||
duration = state.duration,
|
duration = state.duration ?: 0L,
|
||||||
)
|
)
|
||||||
}.distinctUntilChanged()
|
}.distinctUntilChanged()
|
||||||
|
|
||||||
|
|
@ -52,16 +52,17 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
* @param mediaPath The path to the media to be played.
|
* @param mediaPath The path to the media to be played.
|
||||||
* @param mimeType The mime type of the media file.
|
* @param mimeType The mime type of the media file.
|
||||||
*/
|
*/
|
||||||
fun play(mediaPath: String, mimeType: String) {
|
suspend fun play(mediaPath: String, mimeType: String) {
|
||||||
if (mediaPath == curPlayingMediaId) {
|
if (mediaPath == curPlayingMediaId) {
|
||||||
mediaPlayer.play()
|
mediaPlayer.play()
|
||||||
} else {
|
} else {
|
||||||
lastPlayedMediaPath = mediaPath
|
lastPlayedMediaPath = mediaPath
|
||||||
mediaPlayer.acquireControlAndPlay(
|
mediaPlayer.setMedia(
|
||||||
uri = mediaPath,
|
uri = mediaPath,
|
||||||
mediaId = mediaPath,
|
mediaId = mediaPath,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
)
|
)
|
||||||
|
mediaPlayer.play()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -89,17 +90,23 @@ class VoiceMessageComposerPlayer @Inject constructor(
|
||||||
val duration: Long,
|
val duration: Long,
|
||||||
) {
|
) {
|
||||||
companion object {
|
companion object {
|
||||||
val NotPlaying = State(
|
val NotLoaded = State(
|
||||||
isPlaying = false,
|
isPlaying = false,
|
||||||
currentPosition = 0L,
|
currentPosition = 0L,
|
||||||
duration = 0L,
|
duration = 0L,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
val isLoaded get() = this != NotLoaded
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* The progress of this player between 0 and 1.
|
* The progress of this player between 0 and 1.
|
||||||
*/
|
*/
|
||||||
val progress: Float =
|
val progress: Float =
|
||||||
if (duration <= currentPosition) 0f else currentPosition.toFloat() / duration.toFloat()
|
if (duration == 0L)
|
||||||
|
0f
|
||||||
|
else
|
||||||
|
(currentPosition.toFloat() / duration.toFloat())
|
||||||
|
.coerceAtMost(1f) // Current position may exceed reported duration
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -27,6 +27,8 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||||
import androidx.compose.runtime.setValue
|
import androidx.compose.runtime.setValue
|
||||||
import androidx.core.net.toUri
|
import androidx.core.net.toUri
|
||||||
import androidx.lifecycle.Lifecycle
|
import androidx.lifecycle.Lifecycle
|
||||||
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||||
import io.element.android.libraries.architecture.Presenter
|
import io.element.android.libraries.architecture.Presenter
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
|
|
@ -40,14 +42,16 @@ import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorder
|
||||||
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
import io.element.android.libraries.voicerecorder.api.VoiceRecorderState
|
||||||
import io.element.android.services.analytics.api.AnalyticsService
|
import io.element.android.services.analytics.api.AnalyticsService
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
|
||||||
import kotlinx.collections.immutable.ImmutableList
|
import kotlinx.collections.immutable.ImmutableList
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import timber.log.Timber
|
import timber.log.Timber
|
||||||
import java.io.File
|
import java.io.File
|
||||||
import javax.inject.Inject
|
import javax.inject.Inject
|
||||||
|
import kotlin.time.Duration
|
||||||
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
@SingleIn(RoomScope::class)
|
@SingleIn(RoomScope::class)
|
||||||
class VoiceMessageComposerPresenter @Inject constructor(
|
class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
|
|
@ -56,6 +60,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
private val analyticsService: AnalyticsService,
|
private val analyticsService: AnalyticsService,
|
||||||
private val mediaSender: MediaSender,
|
private val mediaSender: MediaSender,
|
||||||
private val player: VoiceMessageComposerPlayer,
|
private val player: VoiceMessageComposerPlayer,
|
||||||
|
private val messageComposerContext: MessageComposerContext,
|
||||||
permissionsPresenterFactory: PermissionsPresenter.Factory
|
permissionsPresenterFactory: PermissionsPresenter.Factory
|
||||||
) : Presenter<VoiceMessageComposerState> {
|
) : Presenter<VoiceMessageComposerState> {
|
||||||
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
|
private val permissionsPresenter = permissionsPresenterFactory.create(Manifest.permission.RECORD_AUDIO)
|
||||||
|
|
@ -68,8 +73,8 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
val permissionState = permissionsPresenter.present()
|
val permissionState = permissionsPresenter.present()
|
||||||
var isSending by remember { mutableStateOf(false) }
|
var isSending by remember { mutableStateOf(false) }
|
||||||
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotPlaying)
|
val playerState by player.state.collectAsState(initial = VoiceMessageComposerPlayer.State.NotLoaded)
|
||||||
val isPlaying by remember(playerState.isPlaying) { derivedStateOf { playerState.isPlaying } }
|
val playerTime by remember(playerState, recorderState) { derivedStateOf { displayTime(playerState, recorderState) } }
|
||||||
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
val waveform by remember(recorderState) { derivedStateOf { recorderState.finishedWaveform() } }
|
||||||
|
|
||||||
val onLifecycleEvent = { event: Lifecycle.Event ->
|
val onLifecycleEvent = { event: Lifecycle.Event ->
|
||||||
|
|
@ -115,10 +120,12 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
VoiceMessagePlayerEvent.Play ->
|
VoiceMessagePlayerEvent.Play ->
|
||||||
when (val recording = recorderState) {
|
when (val recording = recorderState) {
|
||||||
is VoiceRecorderState.Finished ->
|
is VoiceRecorderState.Finished ->
|
||||||
player.play(
|
localCoroutineScope.launch {
|
||||||
mediaPath = recording.file.path,
|
player.play(
|
||||||
mimeType = recording.mimeType,
|
mediaPath = recording.file.path,
|
||||||
)
|
mimeType = recording.mimeType,
|
||||||
|
)
|
||||||
|
}
|
||||||
else -> Timber.e("Voice message player event received but no file to play")
|
else -> Timber.e("Voice message player event received but no file to play")
|
||||||
}
|
}
|
||||||
VoiceMessagePlayerEvent.Pause -> {
|
VoiceMessagePlayerEvent.Pause -> {
|
||||||
|
|
@ -151,6 +158,7 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
}
|
}
|
||||||
isSending = true
|
isSending = true
|
||||||
player.pause()
|
player.pause()
|
||||||
|
analyticsService.captureComposerEvent()
|
||||||
appCoroutineScope.sendMessage(
|
appCoroutineScope.sendMessage(
|
||||||
file = finishedState.file,
|
file = finishedState.file,
|
||||||
mimeType = finishedState.mimeType,
|
mimeType = finishedState.mimeType,
|
||||||
|
|
@ -185,8 +193,10 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
)
|
)
|
||||||
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
|
is VoiceRecorderState.Finished -> VoiceMessageState.Preview(
|
||||||
isSending = isSending,
|
isSending = isSending,
|
||||||
isPlaying = isPlaying,
|
isPlaying = playerState.isPlaying,
|
||||||
|
showCursor = playerState.isLoaded && !isSending,
|
||||||
playbackProgress = playerState.progress,
|
playbackProgress = playerState.progress,
|
||||||
|
time = playerTime,
|
||||||
waveform = waveform,
|
waveform = waveform,
|
||||||
)
|
)
|
||||||
else -> VoiceMessageState.Idle
|
else -> VoiceMessageState.Idle
|
||||||
|
|
@ -236,6 +246,16 @@ class VoiceMessageComposerPresenter @Inject constructor(
|
||||||
|
|
||||||
voiceRecorder.deleteRecording()
|
voiceRecorder.deleteRecording()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun AnalyticsService.captureComposerEvent() =
|
||||||
|
analyticsService.capture(
|
||||||
|
Composer(
|
||||||
|
inThread = messageComposerContext.composerMode.inThread,
|
||||||
|
isEditing = messageComposerContext.composerMode.isEditing,
|
||||||
|
isReply = messageComposerContext.composerMode.isReply,
|
||||||
|
messageType = Composer.MessageType.VoiceMessage,
|
||||||
|
)
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
|
private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
|
||||||
|
|
@ -243,3 +263,20 @@ private fun VoiceRecorderState.finishedWaveform(): ImmutableList<Float> =
|
||||||
?.waveform
|
?.waveform
|
||||||
.orEmpty()
|
.orEmpty()
|
||||||
.toImmutableList()
|
.toImmutableList()
|
||||||
|
|
||||||
|
/**
|
||||||
|
* The time to display depending on the player state.
|
||||||
|
*
|
||||||
|
* Either the current position or total duration.
|
||||||
|
*/
|
||||||
|
private fun displayTime(
|
||||||
|
playerState: VoiceMessageComposerPlayer.State,
|
||||||
|
recording: VoiceRecorderState
|
||||||
|
): Duration = when {
|
||||||
|
playerState.isLoaded ->
|
||||||
|
playerState.currentPosition.milliseconds
|
||||||
|
recording is VoiceRecorderState.Finished ->
|
||||||
|
recording.duration
|
||||||
|
else ->
|
||||||
|
0.milliseconds
|
||||||
|
}
|
||||||
|
|
|
||||||
|
|
@ -24,7 +24,6 @@ import io.element.android.libraries.di.CacheDirectory
|
||||||
import io.element.android.libraries.di.RoomScope
|
import io.element.android.libraries.di.RoomScope
|
||||||
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
|
||||||
import io.element.android.libraries.matrix.api.media.MediaSource
|
import io.element.android.libraries.matrix.api.media.MediaSource
|
||||||
import io.element.android.libraries.matrix.api.media.toFile
|
|
||||||
import java.io.File
|
import java.io.File
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -83,28 +82,29 @@ class DefaultVoiceMessageMediaRepo @AssistedInject constructor(
|
||||||
): DefaultVoiceMessageMediaRepo
|
): DefaultVoiceMessageMediaRepo
|
||||||
}
|
}
|
||||||
|
|
||||||
override suspend fun getMediaFile(): Result<File> = if (!isInCache()) {
|
override suspend fun getMediaFile(): Result<File> = when {
|
||||||
matrixMediaLoader.downloadMediaFile(
|
cachedFile == null -> Result.failure(IllegalStateException("Invalid mxcUri."))
|
||||||
|
cachedFile.exists() -> Result.success(cachedFile)
|
||||||
|
else -> matrixMediaLoader.downloadMediaFile(
|
||||||
source = mediaSource,
|
source = mediaSource,
|
||||||
mimeType = mimeType,
|
mimeType = mimeType,
|
||||||
body = body,
|
body = body,
|
||||||
|
useCache = false,
|
||||||
).mapCatching {
|
).mapCatching {
|
||||||
val dest = cachedFilePath.apply { parentFile?.mkdirs() }
|
it.use { mediaFile ->
|
||||||
// TODO By not closing the MediaFile we're leaking the rust file handle here.
|
val dest = cachedFile.apply { parentFile?.mkdirs() }
|
||||||
// Not that big of a deal but better to avoid it someday.
|
if (mediaFile.persist(dest.path)) {
|
||||||
if (it.toFile().renameTo(dest)) {
|
dest
|
||||||
dest
|
} else {
|
||||||
} else {
|
error("Failed to move file to cache.")
|
||||||
error("Failed to move file to cache.")
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
} else {
|
|
||||||
Result.success(cachedFilePath)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private val cachedFilePath: File = File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/${mxcUri2FilePath(mediaSource.url)}")
|
private val cachedFile: File? = mxcUri2FilePath(mediaSource.url)?.let {
|
||||||
|
File("${cacheDir.path}/$CACHE_VOICE_SUBDIR/$it")
|
||||||
private fun isInCache(): Boolean = cachedFilePath.exists()
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
/**
|
||||||
|
|
@ -123,12 +123,9 @@ private val mxcRegex = Regex("""^mxc:\/\/([^\/]+)\/([^\/]+)$""")
|
||||||
* Sanitizes an mxcUri to be used as a relative file path.
|
* Sanitizes an mxcUri to be used as a relative file path.
|
||||||
*
|
*
|
||||||
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
* @param mxcUri the Matrix Content (mxc://) URI of the voice message.
|
||||||
* @return the relative file path as "<server-name>/<media-id>".
|
* @return the relative file path as "<server-name>/<media-id>" or null if the mxcUri is invalid.
|
||||||
* @throws IllegalStateException if the mxcUri is invalid.
|
|
||||||
*/
|
*/
|
||||||
private fun mxcUri2FilePath(mxcUri: String): String = checkNotNull(mxcRegex.matchEntire(mxcUri)) {
|
private fun mxcUri2FilePath(mxcUri: String): String? = mxcRegex.matchEntire(mxcUri)?.let { match ->
|
||||||
"mxcUri2FilePath: Invalid mxcUri: $mxcUri"
|
|
||||||
}.let { match ->
|
|
||||||
buildString {
|
buildString {
|
||||||
append(match.groupValues[1])
|
append(match.groupValues[1])
|
||||||
append("/")
|
append("/")
|
||||||
|
|
|
||||||
|
|
@ -151,11 +151,12 @@ class DefaultVoiceMessagePlayer(
|
||||||
} else {
|
} else {
|
||||||
if (eventId != null) {
|
if (eventId != null) {
|
||||||
repo.getMediaFile().mapCatching { mediaFile ->
|
repo.getMediaFile().mapCatching { mediaFile ->
|
||||||
mediaPlayer.acquireControlAndPlay(
|
mediaPlayer.setMedia(
|
||||||
uri = mediaFile.path,
|
uri = mediaFile.path,
|
||||||
mediaId = eventId.value,
|
mediaId = eventId.value,
|
||||||
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
mimeType = "audio/ogg" // Files in the voice cache have no extension so we need to set the mime type manually.
|
||||||
)
|
)
|
||||||
|
mediaPlayer.play()
|
||||||
}
|
}
|
||||||
} else {
|
} else {
|
||||||
Result.failure(IllegalStateException("Cannot play a voice message with no eventId"))
|
Result.failure(IllegalStateException("Cannot play a voice message with no eventId"))
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<item quantity="few">"%1$d změny místnosti"</item>
|
<item quantity="few">"%1$d změny místnosti"</item>
|
||||||
<item quantity="other">"%1$d změn místnosti"</item>
|
<item quantity="other">"%1$d změn místnosti"</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="screen_room_mentions_at_room_subtitle">"Informujte celou místnost"</string>
|
||||||
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
|
<string name="screen_room_attachment_source_camera">"Fotoaparát"</string>
|
||||||
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
|
<string name="screen_room_attachment_source_camera_photo">"Vyfotit"</string>
|
||||||
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
|
<string name="screen_room_attachment_source_camera_video">"Natočit video"</string>
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
<string name="screen_room_attachment_source_poll">"Hlasování"</string>
|
<string name="screen_room_attachment_source_poll">"Hlasování"</string>
|
||||||
<string name="screen_room_attachment_text_formatting">"Formátování textu"</string>
|
<string name="screen_room_attachment_text_formatting">"Formátování textu"</string>
|
||||||
<string name="screen_room_encrypted_history_banner">"Historie zpráv je momentálně v této místnosti nedostupná"</string>
|
<string name="screen_room_encrypted_history_banner">"Historie zpráv je momentálně v této místnosti nedostupná"</string>
|
||||||
|
<string name="screen_room_encrypted_history_banner_unverified">"Historie zpráv není v této místnosti k dispozici. Ověřte toto zařízení, abyste viděli historii zpráv."</string>
|
||||||
<string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
|
<string name="screen_room_error_failed_retrieving_user_details">"Nepodařilo se načíst údaje o uživateli"</string>
|
||||||
<string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string>
|
<string name="screen_room_invite_again_alert_message">"Chtěli byste je pozvat zpět?"</string>
|
||||||
<string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string>
|
<string name="screen_room_invite_again_alert_title">"V tomto chatu jste sami"</string>
|
||||||
|
|
|
||||||
|
|
@ -5,6 +5,7 @@
|
||||||
<item quantity="few">"%1$d изменения в комнате"</item>
|
<item quantity="few">"%1$d изменения в комнате"</item>
|
||||||
<item quantity="many">"%1$d изменений в комнате"</item>
|
<item quantity="many">"%1$d изменений в комнате"</item>
|
||||||
</plurals>
|
</plurals>
|
||||||
|
<string name="screen_room_mentions_at_room_subtitle">"Уведомить всю комнату"</string>
|
||||||
<string name="screen_room_attachment_source_camera">"Камера"</string>
|
<string name="screen_room_attachment_source_camera">"Камера"</string>
|
||||||
<string name="screen_room_attachment_source_camera_photo">"Сделать фото"</string>
|
<string name="screen_room_attachment_source_camera_photo">"Сделать фото"</string>
|
||||||
<string name="screen_room_attachment_source_camera_video">"Записать видео"</string>
|
<string name="screen_room_attachment_source_camera_video">"Записать видео"</string>
|
||||||
|
|
@ -14,6 +15,7 @@
|
||||||
<string name="screen_room_attachment_source_poll">"Опрос"</string>
|
<string name="screen_room_attachment_source_poll">"Опрос"</string>
|
||||||
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
|
<string name="screen_room_attachment_text_formatting">"Форматирование текста"</string>
|
||||||
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате"</string>
|
<string name="screen_room_encrypted_history_banner">"В настоящее время история сообщений недоступна в этой комнате"</string>
|
||||||
|
<string name="screen_room_encrypted_history_banner_unverified">"История сообщений в этой комнате недоступна. Проверьте это устройство, чтобы увидеть историю сообщений."</string>
|
||||||
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
|
<string name="screen_room_error_failed_retrieving_user_details">"Не удалось получить данные о пользователе"</string>
|
||||||
<string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string>
|
<string name="screen_room_invite_again_alert_message">"Хотите пригласить их снова?"</string>
|
||||||
<string name="screen_room_invite_again_alert_title">"Вы одни в этой комнате"</string>
|
<string name="screen_room_invite_again_alert_title">"Вы одни в этой комнате"</string>
|
||||||
|
|
|
||||||
|
|
@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.model.event.TimelineIt
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||||
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
import io.element.android.features.messages.media.FakeLocalMediaFactory
|
||||||
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
import io.element.android.features.messages.textcomposer.TestRichTextEditorStateFactory
|
||||||
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
import io.element.android.features.messages.timeline.components.customreaction.FakeEmojibaseProvider
|
||||||
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
import io.element.android.features.messages.utils.messagesummary.FakeMessageSummaryFormatter
|
||||||
|
|
@ -641,6 +642,7 @@ class MessagesPresenterTest {
|
||||||
analyticsService,
|
analyticsService,
|
||||||
mediaSender,
|
mediaSender,
|
||||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
||||||
|
messageComposerContext = FakeMessageComposerContext(),
|
||||||
permissionsPresenterFactory,
|
permissionsPresenterFactory,
|
||||||
)
|
)
|
||||||
val timelinePresenter = TimelinePresenter(
|
val timelinePresenter = TimelinePresenter(
|
||||||
|
|
|
||||||
|
|
@ -25,11 +25,16 @@ import app.cash.molecule.moleculeFlow
|
||||||
import app.cash.turbine.TurbineTestContext
|
import app.cash.turbine.TurbineTestContext
|
||||||
import app.cash.turbine.test
|
import app.cash.turbine.test
|
||||||
import com.google.common.truth.Truth.assertThat
|
import com.google.common.truth.Truth.assertThat
|
||||||
|
import im.vector.app.features.analytics.plan.Composer
|
||||||
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
import io.element.android.features.messages.impl.voicemessages.VoiceMessageException
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerEvents
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPlayer
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter
|
||||||
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerState
|
||||||
|
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||||
|
import io.element.android.libraries.matrix.test.AN_EVENT_ID
|
||||||
|
import io.element.android.libraries.matrix.test.A_MESSAGE
|
||||||
|
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||||
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
import io.element.android.libraries.mediaplayer.test.FakeMediaPlayer
|
||||||
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
|
||||||
import io.element.android.libraries.mediaupload.api.MediaSender
|
import io.element.android.libraries.mediaupload.api.MediaSender
|
||||||
|
|
@ -38,19 +43,21 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.api.aPermissionsState
|
import io.element.android.libraries.permissions.api.aPermissionsState
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenter
|
||||||
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory
|
||||||
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
import io.element.android.libraries.textcomposer.model.PressEvent
|
import io.element.android.libraries.textcomposer.model.PressEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent
|
||||||
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
import io.element.android.libraries.textcomposer.model.VoiceMessageState
|
||||||
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
import io.element.android.libraries.voicerecorder.test.FakeVoiceRecorder
|
||||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||||
import io.element.android.tests.testutils.WarmUpRule
|
import io.element.android.tests.testutils.WarmUpRule
|
||||||
import kotlinx.collections.immutable.toPersistentList
|
|
||||||
import kotlinx.collections.immutable.toImmutableList
|
import kotlinx.collections.immutable.toImmutableList
|
||||||
|
import kotlinx.collections.immutable.toPersistentList
|
||||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||||
import kotlinx.coroutines.test.TestScope
|
import kotlinx.coroutines.test.TestScope
|
||||||
import kotlinx.coroutines.test.runTest
|
import kotlinx.coroutines.test.runTest
|
||||||
import org.junit.Rule
|
import org.junit.Rule
|
||||||
import org.junit.Test
|
import org.junit.Test
|
||||||
|
import kotlin.time.Duration
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class VoiceMessageComposerPresenterTest {
|
class VoiceMessageComposerPresenterTest {
|
||||||
|
|
@ -65,6 +72,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
private val matrixRoom = FakeMatrixRoom()
|
private val matrixRoom = FakeMatrixRoom()
|
||||||
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
private val mediaPreProcessor = FakeMediaPreProcessor().apply { givenAudioResult() }
|
||||||
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
private val mediaSender = MediaSender(mediaPreProcessor, matrixRoom)
|
||||||
|
private val messageComposerContext = FakeMessageComposerContext()
|
||||||
|
|
||||||
companion object {
|
companion object {
|
||||||
private val RECORDING_DURATION = 1.seconds
|
private val RECORDING_DURATION = 1.seconds
|
||||||
|
|
@ -171,7 +179,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
// Nothing should happen
|
// Nothing should happen
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Recording(RECORDING_DURATION, RECORDING_STATE.levels))
|
assertThat(finalState.voiceMessageState).isEqualTo(RECORDING_STATE)
|
||||||
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
|
voiceRecorder.assertCalls(started = 1, stopped = 0, deleted = 0)
|
||||||
|
|
||||||
testPauseAndDestroy(finalState)
|
testPauseAndDestroy(finalState)
|
||||||
|
|
@ -187,8 +195,9 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
|
awaitItem().apply { assertThat(voiceMessageState).isEqualTo(aLoadedState()) }
|
||||||
val finalState = awaitItem().also {
|
val finalState = awaitItem().also {
|
||||||
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = true, playbackProgress = 0.1f))
|
assertThat(it.voiceMessageState).isEqualTo(aPlayingState())
|
||||||
}
|
}
|
||||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||||
|
|
||||||
|
|
@ -205,9 +214,10 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
|
skipItems(1) // Loaded state
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Pause))
|
||||||
val finalState = awaitItem().also {
|
val finalState = awaitItem().also {
|
||||||
assertThat(it.voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f))
|
assertThat(it.voiceMessageState).isEqualTo(aPausedState())
|
||||||
}
|
}
|
||||||
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
voiceRecorder.assertCalls(started = 1, stopped = 1, deleted = 0)
|
||||||
|
|
||||||
|
|
@ -242,9 +252,10 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
|
skipItems(1) // Loaded state
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
awaitItem().eventSink(VoiceMessageComposerEvents.DeleteVoiceMessage)
|
||||||
awaitItem().apply {
|
awaitItem().apply {
|
||||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(isPlaying = false, playbackProgress = 0.1f))
|
assertThat(voiceMessageState).isEqualTo(aPausedState())
|
||||||
}
|
}
|
||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
|
|
@ -264,7 +275,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
|
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
|
||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
@ -275,6 +286,35 @@ class VoiceMessageComposerPresenterTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `present - sending is tracked`() = runTest {
|
||||||
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
moleculeFlow(RecompositionMode.Immediate) {
|
||||||
|
presenter.present()
|
||||||
|
}.test {
|
||||||
|
// Send a normal voice message
|
||||||
|
messageComposerContext.composerMode = MessageComposerMode.Normal
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
skipItems(1) // Sending state
|
||||||
|
|
||||||
|
// Now reply with a voice message
|
||||||
|
messageComposerContext.composerMode = aReplyMode()
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
|
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
|
val finalState = awaitItem() // Sending state
|
||||||
|
|
||||||
|
assertThat(analyticsService.capturedEvents).containsExactly(
|
||||||
|
aVoiceMessageComposerEvent(isReply = false),
|
||||||
|
aVoiceMessageComposerEvent(isReply = true)
|
||||||
|
)
|
||||||
|
|
||||||
|
testPauseAndDestroy(finalState)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
@Test
|
@Test
|
||||||
fun `present - send while playing`() = runTest {
|
fun `present - send while playing`() = runTest {
|
||||||
val presenter = createVoiceMessageComposerPresenter()
|
val presenter = createVoiceMessageComposerPresenter()
|
||||||
|
|
@ -284,10 +324,9 @@ class VoiceMessageComposerPresenterTest {
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.PressStart))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
awaitItem().eventSink(VoiceMessageComposerEvents.RecordButtonEvent(PressEvent.LongPressEnd))
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
awaitItem().eventSink(VoiceMessageComposerEvents.PlayerEvent(VoiceMessagePlayerEvent.Play))
|
||||||
|
skipItems(1) // Loaded state
|
||||||
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
awaitItem().eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(
|
assertThat(awaitItem().voiceMessageState).isEqualTo(aPlayingState().toSendingState())
|
||||||
isSending = true, isPlaying = false, playbackProgress = 0.1f
|
|
||||||
))
|
|
||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
@ -310,7 +349,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
}
|
}
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
|
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
|
||||||
|
|
||||||
val finalState = awaitItem()
|
val finalState = awaitItem()
|
||||||
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
assertThat(finalState.voiceMessageState).isEqualTo(VoiceMessageState.Idle)
|
||||||
|
|
@ -359,7 +398,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
val previewState = awaitItem()
|
val previewState = awaitItem()
|
||||||
|
|
||||||
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
previewState.eventSink(VoiceMessageComposerEvents.SendVoiceMessage)
|
||||||
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState(isSending = true))
|
assertThat(awaitItem().voiceMessageState).isEqualTo(aPreviewState().toSendingState())
|
||||||
|
|
||||||
ensureAllEventsConsumed()
|
ensureAllEventsConsumed()
|
||||||
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
|
assertThat(previewState.voiceMessageState).isEqualTo(aPreviewState())
|
||||||
|
|
@ -534,7 +573,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
is VoiceMessageState.Preview -> when (state.isPlaying) {
|
is VoiceMessageState.Preview -> when (state.isPlaying) {
|
||||||
// If the preview was playing, it pauses
|
// If the preview was playing, it pauses
|
||||||
true -> awaitItem().apply {
|
true -> awaitItem().apply {
|
||||||
assertThat(voiceMessageState).isEqualTo(aPreviewState(playbackProgress = 0.1f))
|
assertThat(voiceMessageState).isEqualTo(aPausedState())
|
||||||
}
|
}
|
||||||
false -> mostRecentState
|
false -> mostRecentState
|
||||||
}
|
}
|
||||||
|
|
@ -565,6 +604,7 @@ class VoiceMessageComposerPresenterTest {
|
||||||
analyticsService,
|
analyticsService,
|
||||||
mediaSender,
|
mediaSender,
|
||||||
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
player = VoiceMessageComposerPlayer(FakeMediaPlayer()),
|
||||||
|
messageComposerContext = messageComposerContext,
|
||||||
FakePermissionsPresenterFactory(permissionsPresenter),
|
FakePermissionsPresenterFactory(permissionsPresenter),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
@ -587,11 +627,55 @@ class VoiceMessageComposerPresenterTest {
|
||||||
isPlaying: Boolean = false,
|
isPlaying: Boolean = false,
|
||||||
playbackProgress: Float = 0f,
|
playbackProgress: Float = 0f,
|
||||||
isSending: Boolean = false,
|
isSending: Boolean = false,
|
||||||
|
time: Duration = RECORDING_DURATION,
|
||||||
|
showCursor: Boolean = false,
|
||||||
waveform: List<Float> = voiceRecorder.waveform,
|
waveform: List<Float> = voiceRecorder.waveform,
|
||||||
) = VoiceMessageState.Preview(
|
) = VoiceMessageState.Preview(
|
||||||
isPlaying = isPlaying,
|
isPlaying = isPlaying,
|
||||||
playbackProgress = playbackProgress,
|
playbackProgress = playbackProgress,
|
||||||
isSending = isSending,
|
isSending = isSending,
|
||||||
|
time = time,
|
||||||
|
showCursor = showCursor,
|
||||||
waveform = waveform.toImmutableList(),
|
waveform = waveform.toImmutableList(),
|
||||||
)
|
)
|
||||||
|
|
||||||
|
private fun aLoadedState() =
|
||||||
|
aPreviewState(
|
||||||
|
isPlaying = false,
|
||||||
|
playbackProgress = 0.0f,
|
||||||
|
showCursor = true,
|
||||||
|
time = 0.seconds,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun aPlayingState() =
|
||||||
|
aPreviewState(
|
||||||
|
isPlaying = true,
|
||||||
|
playbackProgress = 0.1f,
|
||||||
|
showCursor = true,
|
||||||
|
time = RECORDING_DURATION,
|
||||||
|
)
|
||||||
|
|
||||||
|
private fun aPausedState() =
|
||||||
|
aPlayingState()
|
||||||
|
.copy(isPlaying = false)
|
||||||
|
|
||||||
|
private fun VoiceMessageState.Preview.toSendingState() =
|
||||||
|
copy(
|
||||||
|
isPlaying = false,
|
||||||
|
isSending = true,
|
||||||
|
showCursor = false,
|
||||||
|
time = RECORDING_DURATION,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE)
|
||||||
|
|
||||||
|
private fun aVoiceMessageComposerEvent(
|
||||||
|
isReply: Boolean = false
|
||||||
|
) = Composer(
|
||||||
|
inThread = false,
|
||||||
|
isEditing = false,
|
||||||
|
isReply = isReply,
|
||||||
|
messageType = Composer.MessageType.VoiceMessage,
|
||||||
|
startsThread = null
|
||||||
|
)
|
||||||
|
|
|
||||||
|
|
@ -63,7 +63,7 @@ class DefaultVoiceMessageMediaRepoTest {
|
||||||
|
|
||||||
repo.getMediaFile().let { result ->
|
repo.getMediaFile().let { result ->
|
||||||
Truth.assertThat(result.isFailure).isTrue()
|
Truth.assertThat(result.isFailure).isTrue()
|
||||||
result.exceptionOrNull()?.let { exception ->
|
result.exceptionOrNull()!!.let { exception ->
|
||||||
Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
@ -116,16 +116,32 @@ class DefaultVoiceMessageMediaRepoTest {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `invalid mxc uri returns a failure`() = runTest {
|
||||||
|
val repo = createDefaultVoiceMessageMediaRepo(
|
||||||
|
temporaryFolder = temporaryFolder,
|
||||||
|
mxcUri = INVALID_MXC_URI,
|
||||||
|
)
|
||||||
|
repo.getMediaFile().let { result ->
|
||||||
|
Truth.assertThat(result.isFailure).isTrue()
|
||||||
|
result.exceptionOrNull()!!.let { exception ->
|
||||||
|
Truth.assertThat(exception).isInstanceOf(RuntimeException::class.java)
|
||||||
|
Truth.assertThat(exception).hasMessageThat().isEqualTo("Invalid mxcUri.")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun createDefaultVoiceMessageMediaRepo(
|
private fun createDefaultVoiceMessageMediaRepo(
|
||||||
temporaryFolder: TemporaryFolder,
|
temporaryFolder: TemporaryFolder,
|
||||||
matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(),
|
||||||
|
mxcUri: String = MXC_URI,
|
||||||
) = DefaultVoiceMessageMediaRepo(
|
) = DefaultVoiceMessageMediaRepo(
|
||||||
cacheDir = temporaryFolder.root,
|
cacheDir = temporaryFolder.root,
|
||||||
matrixMediaLoader = matrixMediaLoader,
|
matrixMediaLoader = matrixMediaLoader,
|
||||||
mediaSource = MediaSource(
|
mediaSource = MediaSource(
|
||||||
url = MXC_URI,
|
url = mxcUri,
|
||||||
json = null
|
json = null
|
||||||
),
|
),
|
||||||
mimeType = "audio/ogg",
|
mimeType = "audio/ogg",
|
||||||
|
|
@ -133,6 +149,7 @@ private fun createDefaultVoiceMessageMediaRepo(
|
||||||
)
|
)
|
||||||
|
|
||||||
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
private const val MXC_URI = "mxc://matrix.org/1234567890abcdefg"
|
||||||
|
private const val INVALID_MXC_URI = "notAnMxcUri"
|
||||||
private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
|
private val TemporaryFolder.cachedFilePath get() = "${this.root.path}/temp/voice/matrix.org/1234567890abcdefg"
|
||||||
private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
|
private fun TemporaryFolder.createCachedFile() = File(cachedFilePath).apply {
|
||||||
parentFile?.mkdirs()
|
parentFile?.mkdirs()
|
||||||
|
|
|
||||||
|
|
@ -47,6 +47,11 @@ class DefaultVoiceMessagePlayerTest {
|
||||||
player.state.test {
|
player.state.test {
|
||||||
skipItems(1) // skip initial state.
|
skipItems(1) // skip initial state.
|
||||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
|
awaitItem().let {
|
||||||
|
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
||||||
|
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||||
|
Truth.assertThat(it.currentPosition).isEqualTo(0)
|
||||||
|
}
|
||||||
awaitItem().let {
|
awaitItem().let {
|
||||||
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||||
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
Truth.assertThat(it.isMyMedia).isEqualTo(true)
|
||||||
|
|
@ -85,7 +90,7 @@ class DefaultVoiceMessagePlayerTest {
|
||||||
player.state.test {
|
player.state.test {
|
||||||
skipItems(1) // skip initial state.
|
skipItems(1) // skip initial state.
|
||||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
skipItems(1) // skip play state
|
skipItems(2) // skip play states
|
||||||
player.pause()
|
player.pause()
|
||||||
awaitItem().let {
|
awaitItem().let {
|
||||||
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
Truth.assertThat(it.isPlaying).isEqualTo(false)
|
||||||
|
|
@ -101,7 +106,7 @@ class DefaultVoiceMessagePlayerTest {
|
||||||
player.state.test {
|
player.state.test {
|
||||||
skipItems(1) // skip initial state.
|
skipItems(1) // skip initial state.
|
||||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
skipItems(1) // skip play state
|
skipItems(2) // skip play states
|
||||||
player.pause()
|
player.pause()
|
||||||
skipItems(1)
|
skipItems(1)
|
||||||
player.play()
|
player.play()
|
||||||
|
|
@ -119,7 +124,7 @@ class DefaultVoiceMessagePlayerTest {
|
||||||
player.state.test {
|
player.state.test {
|
||||||
skipItems(1) // skip initial state.
|
skipItems(1) // skip initial state.
|
||||||
Truth.assertThat(player.play().isSuccess).isTrue()
|
Truth.assertThat(player.play().isSuccess).isTrue()
|
||||||
skipItems(1) // skip play state
|
skipItems(2) // skip play states
|
||||||
player.seekTo(2000)
|
player.seekTo(2000)
|
||||||
awaitItem().let {
|
awaitItem().let {
|
||||||
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
Truth.assertThat(it.isPlaying).isEqualTo(true)
|
||||||
|
|
|
||||||
|
|
@ -70,6 +70,11 @@ class VoiceMessagePresenterTest {
|
||||||
Truth.assertThat(it.progress).isEqualTo(0f)
|
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||||
Truth.assertThat(it.time).isEqualTo("0:02")
|
Truth.assertThat(it.time).isEqualTo("0:02")
|
||||||
}
|
}
|
||||||
|
awaitItem().also {
|
||||||
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Downloading)
|
||||||
|
Truth.assertThat(it.progress).isEqualTo(0f)
|
||||||
|
Truth.assertThat(it.time).isEqualTo("0:00")
|
||||||
|
}
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||||
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
Truth.assertThat(it.progress).isEqualTo(0.5f)
|
||||||
|
|
@ -128,7 +133,7 @@ class VoiceMessagePresenterTest {
|
||||||
}
|
}
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
skipItems(1) // skip downloading state
|
skipItems(2) // skip downloading states
|
||||||
|
|
||||||
val playingState = awaitItem().also {
|
val playingState = awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||||
|
|
@ -177,7 +182,7 @@ class VoiceMessagePresenterTest {
|
||||||
|
|
||||||
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
initialState.eventSink(VoiceMessageEvents.PlayPause)
|
||||||
|
|
||||||
skipItems(1) // skip downloading state
|
skipItems(2) // skip downloading states
|
||||||
|
|
||||||
awaitItem().also {
|
awaitItem().also {
|
||||||
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
Truth.assertThat(it.button).isEqualTo(VoiceMessageState.Button.Pause)
|
||||||
|
|
|
||||||
|
|
@ -19,6 +19,6 @@ package io.element.android.features.messages.test
|
||||||
import io.element.android.features.messages.api.MessageComposerContext
|
import io.element.android.features.messages.api.MessageComposerContext
|
||||||
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
import io.element.android.libraries.textcomposer.model.MessageComposerMode
|
||||||
|
|
||||||
class MessageComposerContextFake(
|
class FakeMessageComposerContext(
|
||||||
override var composerMode: MessageComposerMode = MessageComposerMode.Normal
|
override var composerMode: MessageComposerMode = MessageComposerMode.Normal
|
||||||
) : MessageComposerContext
|
) : MessageComposerContext
|
||||||
|
|
@ -33,8 +33,8 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.compose.ui.graphics.StrokeCap
|
import androidx.compose.ui.graphics.StrokeCap
|
||||||
import androidx.compose.ui.res.pluralStringResource
|
import androidx.compose.ui.res.pluralStringResource
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
|
||||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||||
|
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||||
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
|
||||||
import io.element.android.libraries.designsystem.theme.components.Text
|
import io.element.android.libraries.designsystem.theme.components.Text
|
||||||
|
|
@ -96,10 +96,12 @@ internal fun PollAnswerView(
|
||||||
LinearProgressIndicator(
|
LinearProgressIndicator(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
|
color = if (answerItem.isWinner) ElementTheme.colors.textSuccessPrimary else answerItem.isEnabled.toEnabledColor(),
|
||||||
progress = when {
|
progress = {
|
||||||
answerItem.isDisclosed -> answerItem.percentage
|
when {
|
||||||
answerItem.isSelected -> 1f
|
answerItem.isDisclosed -> answerItem.percentage
|
||||||
else -> 0f
|
answerItem.isSelected -> 1f
|
||||||
|
else -> 0f
|
||||||
|
}
|
||||||
},
|
},
|
||||||
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
trackColor = ElementTheme.colors.progressIndicatorTrackColor,
|
||||||
strokeCap = StrokeCap.Round,
|
strokeCap = StrokeCap.Round,
|
||||||
|
|
|
||||||
Some files were not shown because too many files have changed in this diff Show more
Loading…
Add table
Add a link
Reference in a new issue