diff --git a/.github/workflows/danger.yml b/.github/workflows/danger.yml index 295c203107..b89ab1e56e 100644 --- a/.github/workflows/danger.yml +++ b/.github/workflows/danger.yml @@ -11,7 +11,7 @@ jobs: - run: | npm install --save-dev @babel/plugin-transform-flow-strip-types - name: Danger - uses: danger/danger-js@12.2.0 + uses: danger/danger-js@12.3.0 with: args: "--dangerfile ./tools/danger/dangerfile.js" env: diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index b62dbb0127..dd0e2f2a41 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -10,7 +10,7 @@ on: # Enrich gradle.properties for CI/CD env: GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx6g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC - CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon --warn + CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon jobs: checkScript: @@ -33,12 +33,13 @@ jobs: - name: Search for invalid screenshot files run: ./tools/test/checkInvalidScreenshots.py - check: - name: Project Check Suite + # Code checks + konsist: + name: Konsist tests runs-on: ubuntu-latest # Allow all jobs on main and develop. Just one per PR. concurrency: - group: ${{ github.ref == 'refs/heads/main' && format('check-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-develop-{0}', github.sha) || format('check-{0}', github.ref) }} + group: ${{ github.ref == 'refs/heads/main' && format('check-konsist-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-konsist-develop-{0}', github.sha) || format('check-konsist-{0}', github.ref) }} cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -55,8 +56,40 @@ jobs: uses: gradle/actions/setup-gradle@v3 with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - - name: Run code quality check suite - run: ./gradlew runQualityChecks $CI_GRADLE_ARG_PROPERTIES + - name: Run Konsist tests + run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: konsist-report + path: | + **/build/reports/**/*.* + + lint: + name: Android lint check + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-lint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-lint-develop-{0}', github.sha) || format('check-lint-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run lint + run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug $CI_GRADLE_ARG_PROPERTIES - name: Upload reports if: always() uses: actions/upload-artifact@v4 @@ -64,6 +97,108 @@ jobs: name: linting-report path: | **/build/reports/**/*.* + + detekt: + name: Detekt checks + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-detekt-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-detekt-develop-{0}', github.sha) || format('check-detekt-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run Detekt + run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: detekt-report + path: | + **/build/reports/**/*.* + + ktlint: + name: Ktlint checks + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-ktlint-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-ktlint-develop-{0}', github.sha) || format('check-ktlint-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run Ktlint check + run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES + - name: Upload reports + if: always() + uses: actions/upload-artifact@v4 + with: + name: ktlint-report + path: | + **/build/reports/**/*.* + + knit: + name: Knit checks + runs-on: ubuntu-latest + # Allow all jobs on main and develop. Just one per PR. + concurrency: + group: ${{ github.ref == 'refs/heads/main' && format('check-knit-main-{0}', github.sha) || github.ref == 'refs/heads/develop' && format('check-knit-develop-{0}', github.sha) || format('check-knit-{0}', github.ref) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + with: + cache-read-only: ${{ github.ref != 'refs/heads/develop' }} + - name: Run Knit + run: ./gradlew knitCheck $CI_GRADLE_ARG_PROPERTIES + + upload_reports: + name: Project Check Suite + runs-on: ubuntu-latest + needs: [konsist, lint, ktlint, detekt] + steps: + - uses: actions/checkout@v4 + with: + # Ensure we are building the branch and not the branch after being merged on develop + # https://github.com/actions/checkout/issues/881 + ref: ${{ github.event_name == 'pull_request' && github.event.pull_request.head.sha || github.ref }} + - name: Download reports from previous jobs + uses: actions/download-artifact@v4 - name: Prepare Danger if: always() run: | @@ -72,7 +207,7 @@ jobs: yarn add danger-plugin-lint-report --dev - name: Danger lint if: always() - uses: danger/danger-js@12.2.0 + uses: danger/danger-js@12.3.0 with: args: "--dangerfile ./tools/danger/dangerfile-lint.js" env: diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index 4c84c4e2fe..bc16e4ad35 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -1,4 +1,4 @@ -name: Create release App Bundle +name: Create release App Bundle and APKs on: workflow_dispatch: @@ -11,11 +11,11 @@ env: CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon jobs: - release: - name: Create App Bundle + gplay: + name: Create App Bundle (Gplay) runs-on: ubuntu-latest concurrency: - group: ${{ github.ref == 'refs/head/main' && format('build-release-main-{0}', github.sha) }} + group: ${{ github.ref == 'refs/head/main' && format('build-release-main-gplay-{0}', github.sha) }} cancel-in-progress: true steps: - uses: actions/checkout@v4 @@ -38,3 +38,31 @@ jobs: name: elementx-app-gplay-bundle-unsigned path: | app/build/outputs/bundle/gplayRelease/app-gplay-release.aab + + fdroid: + name: Create APKs (FDroid) + runs-on: ubuntu-latest + concurrency: + group: ${{ github.ref == 'refs/head/main' && format('build-release-main-fdroid-{0}', github.sha) }} + cancel-in-progress: true + steps: + - uses: actions/checkout@v4 + - name: Use JDK 17 + uses: actions/setup-java@v4 + with: + distribution: 'temurin' # See 'Supported distributions' for available options + java-version: '17' + - name: Configure gradle + uses: gradle/actions/setup-gradle@v3 + - name: Create APKs + env: + ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} + ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} + ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} + run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES + - name: Upload apks as artifact + uses: actions/upload-artifact@v4 + with: + name: elementx-app-fdroid-apks-unsigned + path: | + app/build/outputs/apk/fdroid/release/*.apk diff --git a/.idea/dictionaries/shared.xml b/.idea/dictionaries/shared.xml index c792687763..01db2cef83 100644 --- a/.idea/dictionaries/shared.xml +++ b/.idea/dictionaries/shared.xml @@ -3,7 +3,9 @@ backstack blurhash + fdroid ftue + gplay homeserver konsist kover diff --git a/.maestro/tests/roomList/timeline/messages/text.yaml b/.maestro/tests/roomList/timeline/messages/text.yaml index 6767886d8d..cae5e310c0 100644 --- a/.maestro/tests/roomList/timeline/messages/text.yaml +++ b/.maestro/tests/roomList/timeline/messages/text.yaml @@ -2,7 +2,7 @@ appId: ${MAESTRO_APP_ID} --- - takeScreenshot: build/maestro/510-Timeline - tapOn: - id: "rich_text_editor" + id: "text_editor" - inputText: "Hello world!" - tapOn: "Send" - hideKeyboard diff --git a/.maestro/tests/settings/settings.yaml b/.maestro/tests/settings/settings.yaml index c77d118a3b..15181a458b 100644 --- a/.maestro/tests/settings/settings.yaml +++ b/.maestro/tests/settings/settings.yaml @@ -34,7 +34,7 @@ appId: ${MAESTRO_APP_ID} - tapOn: text: "Advanced settings" -- assertVisible: "Rich text editor" +- assertVisible: "View source" - back - tapOn: diff --git a/CHANGES.md b/CHANGES.md index d091404139..d303339a27 100644 --- a/CHANGES.md +++ b/CHANGES.md @@ -1,3 +1,26 @@ +Changes in Element X v0.4.13 (2024-05-22) +========================================= + +Features ✨ +---------- + - Add plain text editor based on Markdown input. ([#2840](https://github.com/element-hq/element-x-android/issues/2840)) + +Bugfixes 🐛 +---------- + - Use members display names for their membership state events. ([#2286](https://github.com/element-hq/element-x-android/issues/2286)) + - Make sure explicit links in messages take priority over links found by linkification (urls, emails, phone numbers, etc.) ([#2291](https://github.com/element-hq/element-x-android/issues/2291)) + - Fix modal contents overlapping screen lock pin. ([#2692](https://github.com/element-hq/element-x-android/issues/2692)) + - Fix a crash when trying to create an `EncryptedFile` in Android 6. ([#2846](https://github.com/element-hq/element-x-android/issues/2846)) + - Session falsely displayed as 'verified' with no internet connection. ([#2884](https://github.com/element-hq/element-x-android/issues/2884)) + +Other changes +------------- + - Allow configuring push notification provider ([#2340](https://github.com/element-hq/element-x-android/issues/2340)) + - UX cleanup: reorder text composer actions to prioritise camera ones. ([#2803](https://github.com/element-hq/element-x-android/issues/2803)) + - Translation added into Portuguese and Simplified Chinese ([#2834](https://github.com/element-hq/element-x-android/issues/2834)) + - Use via parameters when joining a room from permalink. ([#2843](https://github.com/element-hq/element-x-android/issues/2843)) + + Changes in Element X v0.4.12 (2024-05-13) ========================================= diff --git a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt index 437c6700f5..605dda5691 100644 --- a/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt +++ b/app/src/main/kotlin/io/element/android/x/ElementXApplication.kt @@ -36,6 +36,6 @@ class ElementXApplication : Application(), DaggerComponentOwner { initializeComponent(TracingInitializer::class.java) initializeComponent(CacheCleanerInitializer::class.java) } - logApplicationInfo() + logApplicationInfo(this) } } diff --git a/app/src/main/kotlin/io/element/android/x/MainActivity.kt b/app/src/main/kotlin/io/element/android/x/MainActivity.kt index c11e63a164..eb3f9450f5 100644 --- a/app/src/main/kotlin/io/element/android/x/MainActivity.kt +++ b/app/src/main/kotlin/io/element/android/x/MainActivity.kt @@ -32,6 +32,9 @@ import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalUriHandler import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.lifecycleScope +import androidx.lifecycle.repeatOnLifecycle import com.bumble.appyx.core.integration.NodeHost import com.bumble.appyx.core.integrationpoint.NodeActivity import com.bumble.appyx.core.plugin.NodeReadyObserver @@ -39,13 +42,16 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.Theme import io.element.android.compound.theme.isDark import io.element.android.compound.theme.mapToTheme +import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.handleSecureFlag -import io.element.android.features.lockscreen.api.isLocked import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher import io.element.android.x.di.AppBindings import io.element.android.x.intent.SafeUriHandler +import kotlinx.coroutines.launch import timber.log.Timber private val loggerTag = LoggerTag("MainActivity") @@ -59,27 +65,13 @@ class MainActivity : NodeActivity() { installSplashScreen() super.onCreate(savedInstanceState) appBindings = bindings() - appBindings.lockScreenService().handleSecureFlag(this) + setupLockManagement(appBindings.lockScreenService(), appBindings.lockScreenEntryPoint()) enableEdgeToEdge() setContent { MainContent(appBindings) } } - @Deprecated("") - override fun onBackPressed() { - // If the app is locked, we need to intercept onBackPressed before it goes to OnBackPressedDispatcher. - // Indeed, otherwise we would need to trick Appyx backstack management everywhere. - // Without this trick, we would get pop operations on the hidden backstack. - if (appBindings.lockScreenService().isLocked) { - // Do not kill the app in this case, just go to background. - moveTaskToBack(false) - } else { - @Suppress("DEPRECATION") - super.onBackPressed() - } - } - @Composable private fun MainContent(appBindings: AppBindings) { val theme by remember { @@ -96,8 +88,8 @@ class MainActivity : NodeActivity() { ) { Box( modifier = Modifier - .fillMaxSize() - .background(MaterialTheme.colorScheme.background), + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), ) { if (migrationState.migrationAction.isSuccess()) { MainNodeHost() @@ -131,6 +123,22 @@ class MainActivity : NodeActivity() { } } + private fun setupLockManagement( + lockScreenService: LockScreenService, + lockScreenEntryPoint: LockScreenEntryPoint + ) { + lockScreenService.handleSecureFlag(this) + lifecycleScope.launch { + repeatOnLifecycle(Lifecycle.State.RESUMED) { + lockScreenService.lockState.collect { state -> + if (state == LockScreenLockState.Locked) { + startActivity(lockScreenEntryPoint.pinUnlockIntent(this@MainActivity)) + } + } + } + } + } + /** * Called when: * - the launcher icon is clicked (if the app is already running); diff --git a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt index d8be841b97..bad34edfa0 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppBindings.kt @@ -18,6 +18,7 @@ package io.element.android.x.di import com.squareup.anvil.annotations.ContributesTo import io.element.android.features.api.MigrationEntryPoint +import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.rageshake.api.reporter.BugReporter @@ -38,4 +39,6 @@ interface AppBindings { fun preferencesStore(): AppPreferencesStore fun migrationEntryPoint(): MigrationEntryPoint + + fun lockScreenEntryPoint(): LockScreenEntryPoint } diff --git a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt index a121e0a6fb..3187cae410 100644 --- a/app/src/main/kotlin/io/element/android/x/di/AppModule.kt +++ b/app/src/main/kotlin/io/element/android/x/di/AppModule.kt @@ -26,6 +26,7 @@ import dagger.Provides import io.element.android.appconfig.ApplicationConfig import io.element.android.features.messages.impl.timeline.components.customreaction.DefaultEmojibaseProvider import io.element.android.features.messages.impl.timeline.components.customreaction.EmojibaseProvider +import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType @@ -87,7 +88,7 @@ object AppModule { // TODO EAx Config.LOW_PRIVACY_LOG_ENABLE, lowPrivacyLoggingEnabled = false, versionName = BuildConfig.VERSION_NAME, - versionCode = BuildConfig.VERSION_CODE, + versionCode = context.getVersionCodeFromManifest(), gitRevision = BuildConfig.GIT_REVISION, gitBranchName = BuildConfig.GIT_BRANCH_NAME, flavorDescription = BuildConfig.FLAVOR_DESCRIPTION, diff --git a/app/src/main/kotlin/io/element/android/x/info/Logs.kt b/app/src/main/kotlin/io/element/android/x/info/Logs.kt index 3cabc937a1..53eaf8f824 100644 --- a/app/src/main/kotlin/io/element/android/x/info/Logs.kt +++ b/app/src/main/kotlin/io/element/android/x/info/Logs.kt @@ -16,17 +16,19 @@ package io.element.android.x.info +import android.content.Context +import io.element.android.libraries.androidutils.system.getVersionCodeFromManifest import io.element.android.x.BuildConfig import timber.log.Timber import java.text.SimpleDateFormat import java.util.Date import java.util.Locale -fun logApplicationInfo() { +fun logApplicationInfo(context: Context) { val appVersion = buildString { append(BuildConfig.VERSION_NAME) append(" (") - append(BuildConfig.VERSION_CODE) + append(context.getVersionCodeFromManifest()) append(") - ") append(BuildConfig.BUILD_TYPE) append(" / ") diff --git a/app/src/main/res/xml/locales_config.xml b/app/src/main/res/xml/locales_config.xml index 3cb60308d8..0c0f3a851d 100644 --- a/app/src/main/res/xml/locales_config.xml +++ b/app/src/main/res/xml/locales_config.xml @@ -10,7 +10,8 @@ - + + diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt similarity index 66% rename from libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt rename to appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt index be8c86448e..a927064f3d 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/Config.kt +++ b/appconfig/src/main/kotlin/io/element/android/appconfig/MessageComposerConfig.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 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. @@ -14,10 +14,11 @@ * limitations under the License. */ -package io.element.android.libraries.designsystem.components.preferences +package io.element.android.appconfig -import androidx.compose.ui.unit.dp - -internal val preferenceMinHeightOnlyTitle = 56.dp -internal val preferenceMinHeight = 56.dp -internal val preferencePaddingHorizontal = 16.dp +object MessageComposerConfig { + /** + * Enable the rich text editing in the composer. + */ + const val ENABLE_RICH_TEXT_EDITING = true +} diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index df86219cd1..bd68a9151e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -47,9 +47,6 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState -import io.element.android.features.lockscreen.api.LockScreenEntryPoint -import io.element.android.features.lockscreen.api.LockScreenLockState -import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.preferences.api.PreferencesEntryPoint @@ -100,8 +97,6 @@ class LoggedInFlowNode @AssistedInject constructor( private val coroutineScope: CoroutineScope, private val networkMonitor: NetworkMonitor, private val ftueService: FtueService, - private val lockScreenEntryPoint: LockScreenEntryPoint, - private val lockScreenStateService: LockScreenService, private val roomDirectoryEntryPoint: RoomDirectoryEntryPoint, private val matrixClient: MatrixClient, snackbarDispatcher: SnackbarDispatcher, @@ -111,7 +106,7 @@ class LoggedInFlowNode @AssistedInject constructor( savedStateMap = buildContext.savedStateMap, ), permanentNavModel = PermanentNavModel( - navTargets = setOf(NavTarget.LoggedInPermanent, NavTarget.LockPermanent), + navTargets = setOf(NavTarget.LoggedInPermanent), savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, @@ -189,9 +184,6 @@ class LoggedInFlowNode @AssistedInject constructor( @Parcelize data object LoggedInPermanent : NavTarget - @Parcelize - data object LockPermanent : NavTarget - @Parcelize data object RoomList : NavTarget @@ -235,11 +227,6 @@ class LoggedInFlowNode @AssistedInject constructor( NavTarget.LoggedInPermanent -> { createNode(buildContext) } - NavTarget.LockPermanent -> { - lockScreenEntryPoint.nodeBuilder(this, buildContext) - .target(LockScreenEntryPoint.Target.Unlock) - .build() - } NavTarget.RoomList -> { val callback = object : RoomListEntryPoint.Callback { override fun onRoomClicked(roomId: RoomId) { @@ -430,15 +417,11 @@ class LoggedInFlowNode @AssistedInject constructor( @Composable override fun View(modifier: Modifier) { Box(modifier = modifier) { - val lockScreenState by lockScreenStateService.lockState.collectAsState() val ftueState by ftueService.state.collectAsState() BackstackView() if (ftueState is FtueState.Complete) { PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) } - if (lockScreenState == LockScreenLockState.Locked) { - PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LockPermanent) - } } } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt index 313f4aafe0..6362695772 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/loggedin/LoggedInPresenter.kt @@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatu import io.element.android.libraries.push.api.PushService import io.element.android.services.analytics.api.AnalyticsService import kotlinx.coroutines.flow.map +import timber.log.Timber import javax.inject.Inject class LoggedInPresenter @Inject constructor( @@ -55,10 +56,26 @@ class LoggedInPresenter @Inject constructor( LaunchedEffect(isVerified) { if (isVerified) { // Ensure pusher is registered - // TODO Manually select push provider for now - val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect - val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect - pushService.registerWith(matrixClient, pushProvider, distributor) + val currentPushProvider = pushService.getCurrentPushProvider() + val result = if (currentPushProvider == null) { + // Register with the first available push provider + val pushProvider = pushService.getAvailablePushProviders().firstOrNull() ?: return@LaunchedEffect + val distributor = pushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, pushProvider, distributor) + } else { + val currentPushDistributor = currentPushProvider.getCurrentDistributor(matrixClient) + if (currentPushDistributor == null) { + // Register with the first available distributor + val distributor = currentPushProvider.getDistributors().firstOrNull() ?: return@LaunchedEffect + pushService.registerWith(matrixClient, currentPushProvider, distributor) + } else { + // Re-register with the current distributor + pushService.registerWith(matrixClient, currentPushProvider, currentPushDistributor) + } + } + result.onFailure { + Timber.e(it, "Failed to register pusher") + } } } diff --git a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt index 6dce8a6310..4fb518b058 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/di/MatrixClientsHolderTest.kt @@ -20,21 +20,21 @@ import com.bumble.appyx.core.state.MutableSavedStateMapImpl import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import kotlinx.coroutines.test.runTest import org.junit.Test class MatrixClientsHolderTest { @Test fun `test getOrNull`() { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull() } @Test fun `test getOrRestore`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -47,7 +47,7 @@ class MatrixClientsHolderTest { @Test fun `test remove`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -60,7 +60,7 @@ class MatrixClientsHolderTest { @Test fun `test remove all`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) @@ -73,7 +73,7 @@ class MatrixClientsHolderTest { @Test fun `test save and restore`() = runTest { - val fakeAuthenticationService = FakeAuthenticationService() + val fakeAuthenticationService = FakeMatrixAuthenticationService() val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService) val fakeMatrixClient = FakeMatrixClient() fakeAuthenticationService.givenMatrixClient(fakeMatrixClient) diff --git a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt index 074009cacc..0a0b507742 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/intent/IntentResolverTest.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError import org.junit.Assert.assertThrows import org.junit.Test import org.junit.runner.RunWith @@ -229,7 +230,7 @@ class IntentResolverTest { } private fun createIntentResolver( - permalinkParserResult: () -> PermalinkData = { throw NotImplementedError() } + permalinkParserResult: () -> PermalinkData = { lambdaError() } ): IntentResolver { return IntentResolver( deeplinkParser = DeeplinkParser(), diff --git a/changelog.d/2803.misc b/changelog.d/2803.misc deleted file mode 100644 index ee67bc9e00..0000000000 --- a/changelog.d/2803.misc +++ /dev/null @@ -1 +0,0 @@ -UX cleanup: reorder text composer actions to prioritise camera ones. diff --git a/changelog.d/2809.bugfix b/changelog.d/2809.bugfix new file mode 100644 index 0000000000..70e3079686 --- /dev/null +++ b/changelog.d/2809.bugfix @@ -0,0 +1 @@ +Render selected/deselected room list filters on top diff --git a/changelog.d/2834.misc b/changelog.d/2834.misc deleted file mode 100644 index 27b52fd7b0..0000000000 --- a/changelog.d/2834.misc +++ /dev/null @@ -1 +0,0 @@ -Translation added into Portuguese and Simplified Chinese diff --git a/changelog.d/2843.misc b/changelog.d/2843.misc deleted file mode 100644 index 7141e460d6..0000000000 --- a/changelog.d/2843.misc +++ /dev/null @@ -1 +0,0 @@ -Use via parameters when joining a room from permalink. diff --git a/changelog.d/2846.bugfix b/changelog.d/2846.bugfix deleted file mode 100644 index 2644b853f0..0000000000 --- a/changelog.d/2846.bugfix +++ /dev/null @@ -1 +0,0 @@ -Fix a crash when trying to create an `EncryptedFile` in Android 6. diff --git a/changelog.d/2893.misc b/changelog.d/2893.misc new file mode 100644 index 0000000000..ee122ae436 --- /dev/null +++ b/changelog.d/2893.misc @@ -0,0 +1 @@ +BugReporting | Add public device keys to rageshakes diff --git a/changelog.d/2896.bugfix b/changelog.d/2896.bugfix new file mode 100644 index 0000000000..ac1c95b47b --- /dev/null +++ b/changelog.d/2896.bugfix @@ -0,0 +1 @@ +Set auto captilization, multiline and autocompletion flags for the markdown EditText. diff --git a/changelog.d/2898.bugfix b/changelog.d/2898.bugfix new file mode 100644 index 0000000000..74dbdb7b85 --- /dev/null +++ b/changelog.d/2898.bugfix @@ -0,0 +1 @@ +Restoree Markdown text input contents when returning to the room screen. diff --git a/changelog.d/2917.bugfix b/changelog.d/2917.bugfix new file mode 100644 index 0000000000..cc810393fc --- /dev/null +++ b/changelog.d/2917.bugfix @@ -0,0 +1 @@ +Fixed sending rich content from android keyboards on the markdown text input diff --git a/fastlane/metadata/android/en-US/changelogs/40004130.txt b/fastlane/metadata/android/en-US/changelogs/40004130.txt new file mode 100644 index 0000000000..a00be64eae --- /dev/null +++ b/fastlane/metadata/android/en-US/changelogs/40004130.txt @@ -0,0 +1,2 @@ +Main changes in this version: Add plain text editor based on Markdown input. +Full changelog: https://github.com/element-hq/element-x-android/releases diff --git a/features/analytics/api/src/main/res/values-ka/translations.xml b/features/analytics/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fa12b9cc94 --- /dev/null +++ b/features/analytics/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "გააზიარეთ ანონიმური გამოყენების მონაცემები, რათა დაგვეხმაროთ პრობლემების იდენტიფიცირებაში." + "შეგიძლიათ, წაიკითხოთ ჩვენი ყველა პირობა %1$s." + "აქ" + "გააზიარეთ ანალიტიკური მონაცემები" + diff --git a/features/analytics/api/src/main/res/values-pt/translations.xml b/features/analytics/api/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/analytics/api/src/main/res/values-pt/translations.xml rename to features/analytics/api/src/main/res/values-pt-rBR/translations.xml diff --git a/features/analytics/impl/src/main/res/values-ka/translations.xml b/features/analytics/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fb561f4442 --- /dev/null +++ b/features/analytics/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "ჩვენ არ ჩავწერთ და არ დავაფიქსირებთ პერსონალურ მონაცემებს" + "გააზიარეთ ანონიმური გამოყენების მონაცემები, რათა დაგვეხმაროთ პრობლემების იდენტიფიცირებაში." + "შეგიძლიათ, წაიკითხოთ ჩვენი ყველა პირობა %1$s." + "აქ" + "ამის გამორთვა ნებისმიერ დროს შეგიძლიათ" + "თქვენს მონაცემებს მესამე პირს არ გადავცემთ" + "დაგვეხმარეთ, გავაუმჯობესოთ %1$s" + diff --git a/features/analytics/impl/src/main/res/values-pt/translations.xml b/features/analytics/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/analytics/impl/src/main/res/values-pt/translations.xml rename to features/analytics/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/call/src/main/res/values-ka/translations.xml b/features/call/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..755bb2402b --- /dev/null +++ b/features/call/src/main/res/values-ka/translations.xml @@ -0,0 +1,6 @@ + + + "მიმდინარე ზარი" + "დააწკაპუნეთ ზარში დასაბრუნებლად" + "☎️ ზარი მიმდინარეობს" + diff --git a/features/call/src/main/res/values-pt/translations.xml b/features/call/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/call/src/main/res/values-pt/translations.xml rename to features/call/src/main/res/values-pt-rBR/translations.xml diff --git a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt index 8eecc94e78..a9ddb1bf6b 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/ui/CallScreenPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.tests.testutils.WarmUpRule @@ -68,7 +68,7 @@ class CallScreenPresenterTest { @Test fun `present - with CallType RoomCall loads URL and runs WidgetDriver`() = runTest { - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val widgetProvider = FakeCallWidgetProvider(widgetDriver) val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), @@ -91,7 +91,7 @@ class CallScreenPresenterTest { @Test fun `present - set message interceptor, send and receive messages`() = runTest { - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, @@ -119,7 +119,7 @@ class CallScreenPresenterTest { @Test fun `present - hang up event closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, @@ -149,7 +149,7 @@ class CallScreenPresenterTest { @Test fun `present - a received hang up message closes the screen and stops the widget driver`() = runTest(UnconfinedTestDispatcher()) { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), widgetDriver = widgetDriver, @@ -178,7 +178,7 @@ class CallScreenPresenterTest { @Test fun `present - automatically starts the Matrix client sync when on RoomCall`() = runTest { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val matrixClient = FakeMatrixClient() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), @@ -201,7 +201,7 @@ class CallScreenPresenterTest { @Test fun `present - automatically stops the Matrix client sync on dispose`() = runTest { val navigator = FakeCallScreenNavigator() - val widgetDriver = FakeWidgetDriver() + val widgetDriver = FakeMatrixWidgetDriver() val matrixClient = FakeMatrixClient() val presenter = createCallScreenPresenter( callType = CallType.RoomCall(A_SESSION_ID, A_ROOM_ID), @@ -229,7 +229,7 @@ class CallScreenPresenterTest { private fun TestScope.createCallScreenPresenter( callType: CallType, navigator: CallScreenNavigator = FakeCallScreenNavigator(), - widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), widgetProvider: FakeCallWidgetProvider = FakeCallWidgetProvider(widgetDriver), dispatchers: CoroutineDispatchers = testCoroutineDispatchers(), matrixClientsProvider: FakeMatrixClientProvider = FakeMatrixClientProvider(), diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt index 368db19fcc..0b7e2ce953 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/DefaultCallWidgetProviderTest.kt @@ -26,7 +26,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.widget.FakeCallWidgetSettingsProvider -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import kotlinx.coroutines.test.runTest import org.junit.Test @@ -76,7 +76,7 @@ class DefaultCallWidgetProviderTest { fun `getWidget - returns a widget driver when all steps are successful`() = runTest { val room = FakeMatrixRoom().apply { givenGenerateWidgetWebViewUrlResult(Result.success("url")) - givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver())) } val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) @@ -89,7 +89,7 @@ class DefaultCallWidgetProviderTest { fun `getWidget - will use a custom base url if it exists`() = runTest { val room = FakeMatrixRoom().apply { givenGenerateWidgetWebViewUrlResult(Result.success("url")) - givenGetWidgetDriverResult(Result.success(FakeWidgetDriver())) + givenGetWidgetDriverResult(Result.success(FakeMatrixWidgetDriver())) } val client = FakeMatrixClient().apply { givenGetRoomResult(A_ROOM_ID, room) diff --git a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt index 27e47ee708..c9e9ebb2ae 100644 --- a/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt +++ b/features/call/src/test/kotlin/io/element/android/features/call/utils/FakeCallWidgetProvider.kt @@ -19,10 +19,10 @@ package io.element.android.features.call.utils import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver class FakeCallWidgetProvider( - private val widgetDriver: FakeWidgetDriver = FakeWidgetDriver(), + private val widgetDriver: FakeMatrixWidgetDriver = FakeMatrixWidgetDriver(), private val url: String = "https://call.element.io", ) : CallWidgetProvider { var getWidgetCalled = false diff --git a/features/createroom/impl/src/main/res/values-ka/translations.xml b/features/createroom/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..45a6c79d33 --- /dev/null +++ b/features/createroom/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,14 @@ + + + "ახალი ოთახი" + "ხალხის მოწვევა" + "ოთახის შექმნისას შეცდომა მოხდა" + "ამ ოთახში შეტყობინებები დაშიფრულია. შემდგომ დაშიფვრის გამორთვა შეუძლებელია." + "კერძო ოთახი (მხოლოდ მოწვევა)" + "შეტყობინებები არ არის დაშიფრული და ყველას შეუძლია მათი წაკითხვა. შეგიძლიათ ჩართოთ დაშიფვრა მოგვიანებით." + "საჯარო ოთახი (ნებისმიერი)" + "ოთახის სახელი" + "ოთახის შექმნა" + "თემა (სურვილისამებრ)" + "ჩატის დაწყების მცდელობისას შეცდომა მოხდა" + diff --git a/features/createroom/impl/src/main/res/values-pt/translations.xml b/features/createroom/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/createroom/impl/src/main/res/values-pt/translations.xml rename to features/createroom/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt index 772343ea58..6720fa0274 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/FtueFlowNode.kt @@ -132,9 +132,8 @@ class FtueFlowNode @AssistedInject constructor( lifecycleScope.launch { moveToNextStep() } } } - lockScreenEntryPoint.nodeBuilder(this, buildContext) + lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Setup) .callback(callback) - .target(LockScreenEntryPoint.Target.Setup) .build() } } diff --git a/features/ftue/impl/src/main/res/values-be/translations.xml b/features/ftue/impl/src/main/res/values-be/translations.xml index 8496f9f631..53e2370f8f 100644 --- a/features/ftue/impl/src/main/res/values-be/translations.xml +++ b/features/ftue/impl/src/main/res/values-be/translations.xml @@ -11,6 +11,18 @@ "Злучэнне небяспечнае" "Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе." "Увядзіце наступны нумар на іншай прыладзе." + "Уваход быў адменены на іншай прыладзе." + "Запыт на ўваход скасаваны" + "Запыт на іншай прыладзе не быў прыняты." + "Уваход адхілены" + "Тэрмін уваходу скончыўся. Калі ласка, паспрабуйце яшчэ раз." + "Уваход у сістэму не быў завершаны своечасова" + "Ваша іншая прылада не падтрымлівае ўваход у %s з дапамогай QR-кода. + +Паспрабуйце ўвайсці ў сістэму ўручную або адскануйце QR-код з дапамогай іншай прылады." + "QR-код не падтрымліваецца" + "Ваш правайдар уліковага запісу не падтрымлівае %1$s." + "%1$s не падтрымліваецца" "Гатовы да сканавання" "Адкрыйце %1$s на настольнай прыладзе" "Націсніце на свой аватар" diff --git a/features/ftue/impl/src/main/res/values-cs/translations.xml b/features/ftue/impl/src/main/res/values-cs/translations.xml index e27eeb7404..b9e93b071f 100644 --- a/features/ftue/impl/src/main/res/values-cs/translations.xml +++ b/features/ftue/impl/src/main/res/values-cs/translations.xml @@ -11,6 +11,18 @@ "Připojení není zabezpečené" "Budete požádáni o zadání dvou níže uvedených číslic." "Zadejte níže uvedené číslo na svém dalším zařízení" + "Přihlášení bylo na druhém zařízení zrušeno." + "Žádost o přihlášení zrušena" + "Požadavek na vašem druhém zařízení nebyl přijat." + "Přihlášení odmítnuto" + "Platnost přihlášení vypršela. Zkuste to prosím znovu." + "Přihlášení nebylo dokončeno včas" + "Vaše druhé zařízení nepodporuje přihlášení k %su pomocí QR kódu. + +Zkuste se přihlásit ručně nebo naskenujte QR kód pomocí jiného zařízení." + "QR kód není podporován" + "Váš poskytovatel účtu nepodporuje %1$s." + "%1$s není podporováno" "Připraveno ke skenování" "Otevřete %1$s na stolním počítači" "Klikněte na svůj avatar" diff --git a/features/ftue/impl/src/main/res/values-fr/translations.xml b/features/ftue/impl/src/main/res/values-fr/translations.xml index c69b729fc7..f4180be584 100644 --- a/features/ftue/impl/src/main/res/values-fr/translations.xml +++ b/features/ftue/impl/src/main/res/values-fr/translations.xml @@ -11,11 +11,22 @@ "La connexion n’est pas sécurisée" "Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil." "Saisissez le nombre ci-dessous sur votre autre appareil" + "La connexion a été annulée sur l’autre appareil." + "Demande de connexion annulée" + "La demande sur l’autre appareil n’a pas été acceptée." + "Connexion refusée" + "Connexion expirée. Veuillez essayer à nouveau." + "La connexion a pris trop de temps." + "Votre autre appareil ne supporte pas la connexion à %s avec un code QR. Essayer de vous connecter manuellement, ou scanner le code QR avec un autre appareil." + "Code QR non supporté" + "Votre fournisseur de compte ne supporte pas %1$s." + "%1$s n’est pas supporté" + "Prêt à scanner" "Ouvrez %1$s sur un ordinateur" "Cliquez sur votre image de profil" "Choisissez %1$s" "“Associer une nouvelle session”" - "Suivez les instructions affichées" + "Scanner le code QR avec cet appareil" "Ouvrez %1$s sur un autre appareil pour obtenir le QR code" "Scannez le QR code affiché sur l’autre appareil." "Essayer à nouveau" diff --git a/features/ftue/impl/src/main/res/values-ka/translations.xml b/features/ftue/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..04aacccf83 --- /dev/null +++ b/features/ftue/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,11 @@ + + + "თქვენ შეგიძლიათ შეცვალოთ თქვენი პარამეტრები მოგვიანებით." + "ყველა შეტყობინებაზე შეტყობინებების მიღება" + "ზარები, გამოკითხვები, ძიება და სხვა დაემატება ამ წლის ბოლოს." + "დაშიფრული ოთახებისთვის შეტყობინებების ისტორია ჯერ არ არის ხელმისაწვდომი." + "ჩვენ სიამოვნებით მოვისმინოთ თქვენგან, შეგვატყობინეთ რას ფიქრობთ პარამეტრების გვერდზე." + "დავიწყოთ!" + "აი, რა უნდა იცოდეთ:" + "კეთილი იყოს თქვენი მობრძანება %1$s-ში!" + diff --git a/features/ftue/impl/src/main/res/values-pt/translations.xml b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml similarity index 97% rename from features/ftue/impl/src/main/res/values-pt/translations.xml rename to features/ftue/impl/src/main/res/values-pt-rBR/translations.xml index 5b2660f6c6..1635e12c74 100644 --- a/features/ftue/impl/src/main/res/values-pt/translations.xml +++ b/features/ftue/impl/src/main/res/values-pt-rBR/translations.xml @@ -11,6 +11,7 @@ "Ligação insegura" "Ser-te-á pedido que insiras os dois dígitos indicados neste dispositivo." "Insere o número abaixo no teu dispositivo" + "Pedido de início de sessão cancelado" "Pronto para ler" "Abre a %1$s num computador" "Carrega no teu avatar" diff --git a/features/ftue/impl/src/main/res/values-ro/translations.xml b/features/ftue/impl/src/main/res/values-ro/translations.xml index 73160c1954..58482738d6 100644 --- a/features/ftue/impl/src/main/res/values-ro/translations.xml +++ b/features/ftue/impl/src/main/res/values-ro/translations.xml @@ -11,11 +11,12 @@ "Conexiunea nu este sigură" "Vi se va cere să introduceți cele două cifre afișate pe acest dispozitiv." "Introduceți numărul de mai jos pe celălalt dispozitiv" + "Gata de scanare" "Deschideți %1$s pe un dispozitiv desktop" "Faceți clic pe avatarul dumneavoastră" "Selectați %1$s" "„Conectați un dispozitiv nou”" - "Urmați instrucțiunile afișate" + "Scanați codul QR cu acest dispozitiv" "Deschideți %1$s pe un alt dispozitiv pentru a obține codul QR" "Utilizați codul QR afișat pe celălalt dispozitiv." "Încercați din nou" diff --git a/features/ftue/impl/src/main/res/values-ru/translations.xml b/features/ftue/impl/src/main/res/values-ru/translations.xml index 300647b7f0..c4e48539f3 100644 --- a/features/ftue/impl/src/main/res/values-ru/translations.xml +++ b/features/ftue/impl/src/main/res/values-ru/translations.xml @@ -11,11 +11,24 @@ "Соединение не защищено" "Вам нужно будет ввести две цифры, показанные на этом устройстве." "Введите показанный номер на своем другом устройстве" + "Вход на другом устройстве был отменен." + "Запрос на вход отменен" + "Запрос не был принят на другом устройстве." + "Вход отклонен" + "Срок действия входа истек. Пожалуйста, попробуйте еще раз." + "Вход в систему не был выполнен вовремя" + "Другое устройство не поддерживает вход в %s с помощью QR-кода. + +Попробуйте войти вручную или отсканируйте QR-код на другом устройстве." + "QR-код не поддерживается" + "Поставщик учетной записи не поддерживает %1$s." + "%1$s не поддерживается" + "Готово к сканированию" "Откройте %1$s на настольном устройстве" "Нажмите на свое изображение" "Выбрать %1$s" "\"Привязать новое устройство\"" - "Соблюдайте показанную инструкцию" + "Отсканируйте QR-код с помощью этого устройства" "Откройте %1$s на другом устройстве, чтобы получить QR-код" "Используйте QR-код, показанный на другом устройстве." "Повторить попытку" diff --git a/features/ftue/impl/src/main/res/values-sk/translations.xml b/features/ftue/impl/src/main/res/values-sk/translations.xml index 0344f5588e..6f4d8b9b9b 100644 --- a/features/ftue/impl/src/main/res/values-sk/translations.xml +++ b/features/ftue/impl/src/main/res/values-sk/translations.xml @@ -11,6 +11,18 @@ "Pripojenie nie je bezpečené" "Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení." "Zadajte nižšie uvedené číslo na vašom druhom zariadení" + "Prihlásenie bolo zrušené na druhom zariadení." + "Žiadosť o prihlásenie bola zrušená" + "Žiadosť na vašom druhom zariadení nebola prijatá." + "Prihlásenie bolo odmietnuté" + "Platnosť prihlásenia vypršala. Skúste to prosím znova." + "Prihlásenie nebolo včas dokončené" + "Vaše druhé zariadenie nepodporuje prihlásenie do aplikácie %s pomocou QR kódu. + +Skúste sa prihlásiť manuálne alebo naskenujte QR kód pomocou iného zariadenia." + "QR kód nie je podporovaný" + "Poskytovateľ vášho účtu nepodporuje %1$s." + "%1$s nie je podporovaný" "Pripravené na skenovanie" "Otvorte %1$s na stolnom zariadení" "Kliknite na svoj obrázok" diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 256dcbae31..4038f21908 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -11,6 +11,18 @@ "Connection not secure" "You’ll be asked to enter the two digits shown on this device." "Enter the number below on your other device" + "The sign in was cancelled on the other device." + "Sign in request cancelled" + "The request on your other device was not accepted." + "Sign in declined" + "Sign in expired. Please try again." + "The sign in was not completed in time" + "Your other device does not support signing in to %s with a QR code. + +Try signing in manually, or scan the QR code with another device." + "QR code not supported" + "Your account provider does not support %1$s." + "%1$s not supported" "Ready to scan" "Open %1$s on a desktop device" "Click on your avatar" diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeScreenState.kt similarity index 94% rename from features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt rename to features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeScreenState.kt index 6b4d4b2287..66ffe24285 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeState.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/welcome/state/FakeWelcomeScreenState.kt @@ -16,7 +16,7 @@ package io.element.android.features.ftue.impl.welcome.state -class FakeWelcomeState : WelcomeScreenState { +class FakeWelcomeScreenState : WelcomeScreenState { private var isWelcomeScreenNeeded = true override fun isWelcomeScreenNeeded(): Boolean { diff --git a/features/invite/impl/src/main/res/values-ka/translations.xml b/features/invite/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..3accaf9eda --- /dev/null +++ b/features/invite/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,9 @@ + + + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?" + "მოწვევაზე უარის თქმა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?" + "ჩატზე უარის თქვა" + "მოწვევები არ არის" + "%1$s (%2$s) მოგიწვიათ" + diff --git a/features/invite/impl/src/main/res/values-pt/translations.xml b/features/invite/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/invite/impl/src/main/res/values-pt/translations.xml rename to features/invite/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt index 247df956e6..72552b9ed1 100644 --- a/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt +++ b/features/invite/impl/src/test/kotlin/io/element/android/features/invite/impl/response/AcceptDeclineInvitePresenterTest.kt @@ -192,9 +192,11 @@ class AcceptDeclineInvitePresenterTest { cancelAndConsumeRemainingEvents() } assert(joinRoomFailure) - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID), value(emptyList()), value(JoinedRoom.Trigger.Invite)) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(emptyList()), + value(JoinedRoom.Trigger.Invite) ) } @@ -221,9 +223,11 @@ class AcceptDeclineInvitePresenterTest { cancelAndConsumeRemainingEvents() } assert(joinRoomSuccess) - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID), value(emptyList()), value(JoinedRoom.Trigger.Invite)) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(emptyList()), + value(JoinedRoom.Trigger.Invite) ) } diff --git a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt index 2019cd77d0..5f1efe827e 100644 --- a/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt +++ b/features/joinroom/impl/src/main/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenter.kt @@ -42,7 +42,6 @@ import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias -import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.RoomType @@ -96,7 +95,7 @@ class JoinRoomPresenter @AssistedInject constructor( } else -> { value = ContentState.Loading(roomIdOrAlias) - val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias()) + val result = matrixClient.getRoomPreviewFromRoomId(roomId, serverNames) value = result.fold( onSuccess = { roomPreview -> roomPreview.toContentState() diff --git a/features/joinroom/impl/src/main/res/values-pt/translations.xml b/features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/joinroom/impl/src/main/res/values-pt/translations.xml rename to features/joinroom/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt index 16a8c1ecd1..2a054b820f 100644 --- a/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt +++ b/features/joinroom/impl/src/test/kotlin/io/element/android/features/joinroom/impl/JoinRoomPresenterTest.kt @@ -366,7 +366,7 @@ class JoinRoomPresenterTest { @Test fun `present - when room is not known RoomPreview is loaded`() = runTest { val client = FakeMatrixClient( - getRoomPreviewResult = { + getRoomPreviewFromRoomIdResult = { _, _ -> Result.success( RoomPreview( roomId = A_ROOM_ID, @@ -411,7 +411,7 @@ class JoinRoomPresenterTest { @Test fun `present - when room is not known RoomPreview is loaded with error`() = runTest { val client = FakeMatrixClient( - getRoomPreviewResult = { + getRoomPreviewFromRoomIdResult = { _, _ -> Result.failure(AN_EXCEPTION) } ) @@ -449,7 +449,7 @@ class JoinRoomPresenterTest { @Test fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest { val client = FakeMatrixClient( - getRoomPreviewResult = { + getRoomPreviewFromRoomIdResult = { _, _ -> Result.failure(Exception("403")) } ) diff --git a/features/leaveroom/api/src/main/res/values-ka/translations.xml b/features/leaveroom/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..bb2c4e6ed0 --- /dev/null +++ b/features/leaveroom/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,6 @@ + + + "დარწმუნებული ბრძანდებით, რომ ამ ოთახის დატოვება გსურთ? თქვენ აქ მარტო ხართ და ჩატის დატოვებისას აქ თქვენს ჩათვლით ვერავინ ვერ გაწევრიანდება." + "დარწმუნებული ბრძანდებით, რომ ამ ოთახის დატოვება გსურთ? ეს ოთახი არ არის საჯარო და მოწვევის გარეშე ვერ შეძლებთ ხელახლა გაწევრიანებას." + "დარწმუნებული ბრძანდებით, რომ ოთახის დატოვება გსურთ?" + diff --git a/features/leaveroom/api/src/main/res/values-pt/translations.xml b/features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/leaveroom/api/src/main/res/values-pt/translations.xml rename to features/leaveroom/api/src/main/res/values-pt-rBR/translations.xml diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt similarity index 95% rename from features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt rename to features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt index e79ac9e453..8bbaf5c428 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/PermissionsPresenterFake.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/common/permissions/FakePermissionsPresenter.kt @@ -18,7 +18,7 @@ package io.element.android.features.location.impl.common.permissions import androidx.compose.runtime.Composable -class PermissionsPresenterFake : PermissionsPresenter { +class FakePermissionsPresenter : PermissionsPresenter { val events = mutableListOf() private fun handleEvent(event: PermissionsEvents) { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt index b6b469c44a..af4ac6c9c7 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/send/SendLocationPresenterTest.kt @@ -24,9 +24,9 @@ import im.vector.app.features.analytics.plan.Composer import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter -import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.matrix.api.room.location.AssetType @@ -45,7 +45,7 @@ class SendLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeMatrixRoom = FakeMatrixRoom() private val fakeAnalyticsService = FakeAnalyticsService() private val fakeMessageComposerContext = FakeMessageComposerContext() @@ -53,7 +53,7 @@ class SendLocationPresenterTest { private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val sendLocationPresenter: SendLocationPresenter = SendLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, room = fakeMatrixRoom, analyticsService = fakeAnalyticsService, @@ -64,7 +64,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions granted`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, @@ -90,7 +90,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions partially granted`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.SomeGranted, shouldShowRationale = false, @@ -116,7 +116,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions denied`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -142,7 +142,7 @@ class SendLocationPresenterTest { @Test fun `initial state with permissions denied once`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -168,7 +168,7 @@ class SendLocationPresenterTest { @Test fun `rationale dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -199,7 +199,7 @@ class SendLocationPresenterTest { @Test fun `rationale dialog continue`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -221,13 +221,13 @@ class SendLocationPresenterTest { // Continue the dialog sends permission request to the permissions presenter myLocationState.eventSink(SendLocationEvents.RequestPermissions) - assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -258,7 +258,7 @@ class SendLocationPresenterTest { @Test fun `share sender location`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.AllGranted, shouldShowRationale = false, @@ -314,7 +314,7 @@ class SendLocationPresenterTest { @Test fun `share pin location`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -370,7 +370,7 @@ class SendLocationPresenterTest { @Test fun `composer context passes through analytics`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -418,7 +418,7 @@ class SendLocationPresenterTest { @Test fun `open settings activity`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index ff80a3935d..dab964b6e1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -23,9 +23,9 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.location.api.Location import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.actions.FakeLocationActions +import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsPresenter -import io.element.android.features.location.impl.common.permissions.PermissionsPresenterFake import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule @@ -38,13 +38,13 @@ class ShowLocationPresenterTest { @get:Rule val warmUpRule = WarmUpRule() - private val permissionsPresenterFake = PermissionsPresenterFake() + private val fakePermissionsPresenter = FakePermissionsPresenter() private val fakeLocationActions = FakeLocationActions() private val fakeBuildMeta = aBuildMeta(applicationName = "app name") private val location = Location(1.23, 4.56, 7.8f) private val presenter = ShowLocationPresenter( permissionsPresenterFactory = object : PermissionsPresenter.Factory { - override fun create(permissions: List): PermissionsPresenter = permissionsPresenterFake + override fun create(permissions: List): PermissionsPresenter = fakePermissionsPresenter }, fakeLocationActions, fakeBuildMeta, @@ -54,7 +54,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with no location permission`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -74,7 +74,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state location permission denied once`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -94,7 +94,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with location permission`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -109,7 +109,7 @@ class ShowLocationPresenterTest { @Test fun `emits initial state with partial location permission`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -137,7 +137,7 @@ class ShowLocationPresenterTest { @Test fun `centers on user location`() = runTest { - permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) + fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -165,7 +165,7 @@ class ShowLocationPresenterTest { @Test fun `rationale dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -196,7 +196,7 @@ class ShowLocationPresenterTest { @Test fun `rationale dialog continue`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = true, @@ -218,13 +218,13 @@ class ShowLocationPresenterTest { // Continue the dialog sends permission request to the permissions presenter trackLocationState.eventSink(ShowLocationEvents.RequestPermissions) - assertThat(permissionsPresenterFake.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) + assertThat(fakePermissionsPresenter.events.last()).isEqualTo(PermissionsEvents.RequestPermissions) } } @Test fun `permission denied dialog dismiss`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, @@ -255,7 +255,7 @@ class ShowLocationPresenterTest { @Test fun `open settings activity`() = runTest { - permissionsPresenterFake.givenState( + fakePermissionsPresenter.givenState( aPermissionsState( permissions = PermissionsState.Permissions.NoneGranted, shouldShowRationale = false, diff --git a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt index 6a2fb0c72d..f31fc5af4a 100644 --- a/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt +++ b/features/lockscreen/api/src/main/kotlin/io/element/android/features/lockscreen/api/LockScreenEntryPoint.kt @@ -16,17 +16,19 @@ package io.element.android.features.lockscreen.api +import android.content.Context +import android.content.Intent import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint interface LockScreenEntryPoint : FeatureEntryPoint { - fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder + fun nodeBuilder(parentNode: Node, buildContext: BuildContext, navTarget: Target): NodeBuilder + fun pinUnlockIntent(context: Context): Intent interface NodeBuilder { fun callback(callback: Callback): NodeBuilder - fun target(target: Target): NodeBuilder fun build(): Node } @@ -37,6 +39,5 @@ interface LockScreenEntryPoint : FeatureEntryPoint { enum class Target { Settings, Setup, - Unlock } } diff --git a/features/lockscreen/impl/src/main/AndroidManifest.xml b/features/lockscreen/impl/src/main/AndroidManifest.xml new file mode 100644 index 0000000000..083647d1c2 --- /dev/null +++ b/features/lockscreen/impl/src/main/AndroidManifest.xml @@ -0,0 +1,25 @@ + + + + + + + + + diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt index 5065cdc7f2..a6889a96e7 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/DefaultLockScreenEntryPoint.kt @@ -16,18 +16,20 @@ package io.element.android.features.lockscreen.impl +import android.content.Context +import android.content.Intent import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.node.Node import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.lockscreen.api.LockScreenEntryPoint +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity import io.element.android.libraries.architecture.createNode import io.element.android.libraries.di.AppScope import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { - override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): LockScreenEntryPoint.NodeBuilder { - var innerTarget: LockScreenEntryPoint.Target = LockScreenEntryPoint.Target.Unlock + override fun nodeBuilder(parentNode: Node, buildContext: BuildContext, navTarget: LockScreenEntryPoint.Target): LockScreenEntryPoint.NodeBuilder { val callbacks = mutableListOf() return object : LockScreenEntryPoint.NodeBuilder { @@ -36,15 +38,9 @@ class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { return this } - override fun target(target: LockScreenEntryPoint.Target): LockScreenEntryPoint.NodeBuilder { - innerTarget = target - return this - } - override fun build(): Node { val inputs = LockScreenFlowNode.Inputs( - when (innerTarget) { - LockScreenEntryPoint.Target.Unlock -> LockScreenFlowNode.NavTarget.Unlock + when (navTarget) { LockScreenEntryPoint.Target.Setup -> LockScreenFlowNode.NavTarget.Setup LockScreenEntryPoint.Target.Settings -> LockScreenFlowNode.NavTarget.Settings } @@ -54,4 +50,8 @@ class DefaultLockScreenEntryPoint @Inject constructor() : LockScreenEntryPoint { } } } + + override fun pinUnlockIntent(context: Context): Intent { + return PinUnlockActivity.newIntent(context) + } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt index 8dd75fc65d..fcbb0336a4 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/LockScreenFlowNode.kt @@ -30,7 +30,6 @@ import io.element.android.anvilannotations.ContributesNode import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.impl.settings.LockScreenSettingsFlowNode import io.element.android.features.lockscreen.impl.setup.LockScreenSetupFlowNode -import io.element.android.features.lockscreen.impl.unlock.PinUnlockNode import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.NodeInputs @@ -44,20 +43,17 @@ class LockScreenFlowNode @AssistedInject constructor( @Assisted plugins: List, ) : BaseFlowNode( backstack = BackStack( - initialElement = plugins.filterIsInstance(Inputs::class.java).first().initialNavTarget, + initialElement = plugins.filterIsInstance().first().initialNavTarget, savedStateMap = buildContext.savedStateMap, ), buildContext = buildContext, plugins = plugins, ) { data class Inputs( - val initialNavTarget: NavTarget = NavTarget.Unlock, + val initialNavTarget: NavTarget, ) : NodeInputs sealed interface NavTarget : Parcelable { - @Parcelize - data object Unlock : NavTarget - @Parcelize data object Setup : NavTarget @@ -75,10 +71,6 @@ class LockScreenFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { - NavTarget.Unlock -> { - val inputs = PinUnlockNode.Inputs(isInAppUnlock = false) - createNode(buildContext, plugins = listOf(inputs)) - } NavTarget.Setup -> { val callback = OnSetupDoneCallback(plugins()) createNode(buildContext, plugins = listOf(callback)) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt index 0890f38d1e..13fe1e62aa 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsFlowNode.kt @@ -103,13 +103,12 @@ class LockScreenSettingsFlowNode @AssistedInject constructor( override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { return when (navTarget) { NavTarget.Unlock -> { - val inputs = PinUnlockNode.Inputs(isInAppUnlock = true) val callback = object : PinUnlockNode.Callback { override fun onUnlock() { backstack.newRoot(NavTarget.Settings) } } - createNode(buildContext, plugins = listOf(inputs, callback)) + createNode(buildContext, plugins = listOf(callback)) } NavTarget.SetupPin -> { createNode(buildContext) diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt index 7c8634a078..b53618df11 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/settings/LockScreenSettingsView.kt @@ -43,7 +43,7 @@ fun LockScreenSettingsView( onBackPressed = onBackPressed, modifier = modifier ) { - PreferenceCategory(showDivider = false) { + PreferenceCategory(showTopDivider = false) { PreferenceText( title = stringResource(id = R.string.screen_app_lock_settings_change_pin), onClick = onChangePinClicked diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt index da6853a39c..f357869375 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockNode.kt @@ -26,8 +26,6 @@ import com.bumble.appyx.core.plugin.plugins import dagger.assisted.Assisted import dagger.assisted.AssistedInject import io.element.android.anvilannotations.ContributesNode -import io.element.android.libraries.architecture.NodeInputs -import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.SessionScope @ContributesNode(SessionScope::class) @@ -40,12 +38,6 @@ class PinUnlockNode @AssistedInject constructor( fun onUnlock() } - data class Inputs( - val isInAppUnlock: Boolean - ) : NodeInputs - - private val inputs: Inputs = inputs() - private fun onUnlock() { plugins().forEach { it.onUnlock() @@ -62,7 +54,9 @@ class PinUnlockNode @AssistedInject constructor( } PinUnlockView( state = state, - isInAppUnlock = inputs.isInAppUnlock, + // UnlockNode is only used for in-app unlock, so we can safely set isInAppUnlock to true. + // It's set to false in PinUnlockActivity. + isInAppUnlock = true, modifier = modifier ) } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt index aebf95aa37..db56b8c17b 100644 --- a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenter.kt @@ -29,11 +29,11 @@ import io.element.android.features.lockscreen.impl.biometric.BiometricUnlockMana import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel +import io.element.android.features.lockscreen.impl.unlock.signout.SignOut import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runCatchingUpdatingState import io.element.android.libraries.core.bool.orFalse -import io.element.android.libraries.matrix.api.MatrixClient import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject @@ -41,7 +41,7 @@ import javax.inject.Inject class PinUnlockPresenter @Inject constructor( private val pinCodeManager: PinCodeManager, private val biometricUnlockManager: BiometricUnlockManager, - private val matrixClient: MatrixClient, + private val signOut: SignOut, private val coroutineScope: CoroutineScope, private val pinUnlockHelper: PinUnlockHelper, ) : Presenter { @@ -179,7 +179,7 @@ class PinUnlockPresenter @Inject constructor( private fun CoroutineScope.signOut(signOutAction: MutableState>) = launch { suspend { - matrixClient.logout(ignoreSdkError = true) + signOut() }.runCatchingUpdatingState(signOutAction) } } diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt new file mode 100644 index 0000000000..9c228b0736 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/activity/PinUnlockActivity.kt @@ -0,0 +1,71 @@ +/* + * Copyright (c) 2024 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.activity + +import android.content.Context +import android.content.Intent +import android.os.Bundle +import androidx.activity.OnBackPressedCallback +import androidx.activity.compose.setContent +import androidx.activity.enableEdgeToEdge +import androidx.appcompat.app.AppCompatActivity +import androidx.lifecycle.lifecycleScope +import io.element.android.compound.theme.ElementTheme +import io.element.android.features.lockscreen.api.LockScreenLockState +import io.element.android.features.lockscreen.api.LockScreenService +import io.element.android.features.lockscreen.impl.unlock.PinUnlockPresenter +import io.element.android.features.lockscreen.impl.unlock.PinUnlockView +import io.element.android.features.lockscreen.impl.unlock.di.PinUnlockBindings +import io.element.android.libraries.architecture.bindings +import kotlinx.coroutines.launch +import javax.inject.Inject + +class PinUnlockActivity : AppCompatActivity() { + internal companion object { + fun newIntent(context: Context): Intent { + return Intent(context, PinUnlockActivity::class.java) + } + } + + @Inject lateinit var presenter: PinUnlockPresenter + @Inject lateinit var lockScreenService: LockScreenService + + override fun onCreate(savedInstanceState: Bundle?) { + enableEdgeToEdge() + super.onCreate(savedInstanceState) + bindings().inject(this) + setContent { + ElementTheme { + val state = presenter.present() + PinUnlockView(state = state, isInAppUnlock = false) + } + } + lifecycleScope.launch { + lockScreenService.lockState.collect { state -> + if (state == LockScreenLockState.Unlocked) { + finish() + } + } + } + val onBackPressedCallback = object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + moveTaskToBack(true) + } + } + onBackPressedDispatcher.addCallback(this, onBackPressedCallback) + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt new file mode 100644 index 0000000000..ddd62d2fb6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/di/PinUnlockBindings.kt @@ -0,0 +1,26 @@ +/* + * Copyright (c) 2024 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.di + +import com.squareup.anvil.annotations.ContributesTo +import io.element.android.features.lockscreen.impl.unlock.activity.PinUnlockActivity +import io.element.android.libraries.di.AppScope + +@ContributesTo(AppScope::class) +interface PinUnlockBindings { + fun inject(activity: PinUnlockActivity) +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt new file mode 100644 index 0000000000..2c541911e6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/DefaultSignOut.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 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.signout + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.MatrixClientProvider +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import javax.inject.Inject + +@ContributesBinding(AppScope::class) +class DefaultSignOut @Inject constructor( + private val authenticationService: MatrixAuthenticationService, + private val matrixClientProvider: MatrixClientProvider, +) : SignOut { + override suspend fun invoke(): String? { + val currentSession = authenticationService.getLatestSessionId() + return if (currentSession != null) { + matrixClientProvider.getOrRestore(currentSession) + .getOrThrow() + .logout(ignoreSdkError = true) + } else { + error("No session to sign out") + } + } +} diff --git a/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt new file mode 100644 index 0000000000..f4c91aece6 --- /dev/null +++ b/features/lockscreen/impl/src/main/kotlin/io/element/android/features/lockscreen/impl/unlock/signout/SignOut.kt @@ -0,0 +1,21 @@ +/* + * Copyright (c) 2024 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.signout + +interface SignOut { + suspend operator fun invoke(): String? +} diff --git a/features/lockscreen/impl/src/main/res/values-ka/translations.xml b/features/lockscreen/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..26fd97b671 --- /dev/null +++ b/features/lockscreen/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,21 @@ + + + "დაგავიწყდათ PIN?" + "PIN კოდის შეცვლა" + "ბიომეტრიული განბლოკვის დაშვება" + "პინ კოდის წაშლა" + "დარწმუნებული ხართ, რომ გსურთ PIN-ის წაშლა?" + "გსურთ PIN-ის წაშლა?" + "დაადასტურეთ PIN" + "გასაგრძელებლად საჭიროა ხელახლა შესვლა და ახალი PIN-ის შექმნა" + "თქვენ ახლა გადიხართ…" + + "თქვენ გაქვთ %1$d მცდელობა განსაბლოკად" + "თქვენ გაქვთ %1$d მცდელობა განსაბლოკად" + + + "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ" + "არასწორი PIN. თქვენ %1$d შანსი დაგრჩათ" + + "გასვლა…" + diff --git a/features/lockscreen/impl/src/main/res/values-pt/translations.xml b/features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/lockscreen/impl/src/main/res/values-pt/translations.xml rename to features/lockscreen/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt new file mode 100644 index 0000000000..392693d9ef --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/DefaultSignOutTest.kt @@ -0,0 +1,61 @@ +/* + * Copyright (c) 2024 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 com.google.common.truth.Truth.assertThat +import io.element.android.features.lockscreen.impl.unlock.signout.DefaultSignOut +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultSignOutTest { + private val matrixClient = FakeMatrixClient() + private val authenticationService = FakeMatrixAuthenticationService() + private val matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + private val sut = DefaultSignOut(authenticationService, matrixClientProvider) + + @Test + fun `when no active session then it throws`() = runTest { + authenticationService.getLatestSessionIdLambda = { null } + val result = runCatching { sut.invoke() } + assertThat(result.isFailure).isTrue() + } + + @Test + fun `with one active session and successful logout on client`() = runTest { + val logoutLambda = lambdaRecorder { _: Boolean -> null } + authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId } + matrixClient.logoutLambda = logoutLambda + val result = runCatching { sut.invoke() } + assertThat(result.isSuccess).isTrue() + assert(logoutLambda).isCalledOnce() + } + + @Test + fun `with one active session and and failed logout on client`() = runTest { + val logoutLambda = lambdaRecorder { _: Boolean -> error("Failed to logout") } + authenticationService.getLatestSessionIdLambda = { matrixClient.sessionId } + matrixClient.logoutLambda = logoutLambda + val result = runCatching { sut.invoke() } + assertThat(result.isFailure).isTrue() + assert(logoutLambda).isCalledOnce() + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt new file mode 100644 index 0000000000..883a5bf97b --- /dev/null +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/FakeSignOut.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.lockscreen.impl.unlock + +import io.element.android.features.lockscreen.impl.unlock.signout.SignOut +import io.element.android.tests.testutils.simulateLongTask + +class FakeSignOut( + var lambda: () -> String? = { null } +) : SignOut { + override suspend fun invoke(): String? = simulateLongTask { + lambda() + } +} diff --git a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt index 6827055b2c..89d0e92ee2 100644 --- a/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt +++ b/features/lockscreen/impl/src/test/kotlin/io/element/android/features/lockscreen/impl/unlock/PinUnlockPresenterTest.kt @@ -28,8 +28,10 @@ import io.element.android.features.lockscreen.impl.pin.PinCodeManager import io.element.android.features.lockscreen.impl.pin.model.PinEntry import io.element.android.features.lockscreen.impl.pin.model.assertText import io.element.android.features.lockscreen.impl.unlock.keypad.PinKeypadModel +import io.element.android.features.lockscreen.impl.unlock.signout.SignOut import io.element.android.libraries.architecture.AsyncData -import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.tests.testutils.lambda.assert +import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.test.runTest import org.junit.Test @@ -104,7 +106,9 @@ class PinUnlockPresenterTest { @Test fun `present - forgot pin flow`() = runTest { - val presenter = createPinUnlockPresenter(this) + val signOutLambda = lambdaRecorder { null } + val signOut = FakeSignOut(signOutLambda) + val presenter = createPinUnlockPresenter(this, signOut = signOut) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -131,6 +135,7 @@ class PinUnlockPresenterTest { awaitItem().also { state -> assertThat(state.signOutAction).isInstanceOf(AsyncData.Success::class.java) } + assert(signOutLambda).isCalledOnce().withNoParameter() } } @@ -142,6 +147,7 @@ class PinUnlockPresenterTest { scope: CoroutineScope, biometricUnlockManager: BiometricUnlockManager = FakeBiometricUnlockManager(), callback: PinCodeManager.Callback = DefaultPinCodeManagerCallback(), + signOut: SignOut = FakeSignOut(), ): PinUnlockPresenter { val pinCodeManager = aPinCodeManager().apply { addCallback(callback) @@ -150,7 +156,7 @@ class PinUnlockPresenterTest { return PinUnlockPresenter( pinCodeManager = pinCodeManager, biometricUnlockManager = biometricUnlockManager, - matrixClient = FakeMatrixClient(), + signOut = signOut, coroutineScope = scope, pinUnlockHelper = PinUnlockHelper(biometricUnlockManager, pinCodeManager), ) diff --git a/features/login/impl/src/main/res/values-ka/translations.xml b/features/login/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..2187fc9e95 --- /dev/null +++ b/features/login/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,42 @@ + + + "ანგარიშის მიმწოდებლის შეცვლა" + "სახლის სერვერის მისამართი" + "შეიყვანეთ საძიებო სიტყვა ან დომენის მისამართი." + "მოძებნეთ კომპანია, საზოგადოება ან კერძო სერვერი." + "ანგარიშის მომწოდებლის მოძებნა" + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ შესვლას %s-ში" + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ ანგარიშის შექმნას %s-ში" + "Matrix.org არის დიდი, უფასო სერვერი საჯარო Matrix ქსელში უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის, რომელსაც მართავს Matrix.org ფონდი." + "სხვა" + "გამოიყენეთ სხვა ანგარიშის პროვაიდერი, როგორიცაა თქვენი პირადი სერვერი ან სამუშაო ანგარიში." + "შეცვალეთ ანგარიშის მომწოდებელი" + "ჩვენ ვერ მივაღწიეთ ამ სახლის სერვერს. გთხოვთ, შეამოწმოთ, რომ სწორად შეიყვანეთ სახლის სერვერის URL. თუ URL სწორია, დაუკავშირდით თქვენი სახლის სერვერის ადმინისტრატორს დამატებითი დახმარებისთვის." + "ამჟამად ეს სერვერი მხარს არ უჭერს \"sliding sync\"-ს." + "სახლის სერვერის URL" + "თქვენ შეგიძლიათ დაუკავშირდეთ მხოლოდ იმ სერვერს, რომელიც მხარს უჭერს \"sliding sync\"-ს. თქვენი სახლის სერვერის ადმინისტრატორს დასჭირდება მისი კონფიგურაცია.%1$s" + "რა არის თქვენი სერვერის მისამართი?" + "აირჩიეთ თქვენი სერვერი" + "ეს ანგარიში დეაქტივირებულია." + "არასწორი მომხმარებლის სახელი და/ან პაროლი" + "მოცემული მომხმარებლის იდენტიფიკატორი არასწორია. დასაშვები ფორმატი: ‘@user:homeserver.org’" + "მოცემული სახლის სერვერი მხარს არ უჭერს პაროლით ან OIDC-ით შესვლას. გთხოვთ, დაუკავშირდეთ თქვენს ადმინისტრატორს ან აარჩიეთ სხვა სახლის სერვერი." + "შეიყვანეთ თქვენი დეტალები" + "Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის." + "კეთილი იყოს თქვენი მობრძანება!" + "შესვლა %1$s-ში" + "შეცვალეთ ანგარიშის მომწოდებელი" + "კერძო სერვერი Element-ის თანამშრომლებისთვის." + "Matrix არის ღია ქსელი უსაფრთხო, დეცენტრალიზებული კომუნიკაციისთვის." + "აქ იქნება თქვენი საუბრები - ისევე, როგორც თქვენ ელ. ფოსტაში ინახება თქვენი ელ.წერილები." + "თქვენ აპირებთ შესვლას %1$s-ში" + "თქვენ აპირებთ ანგარიშის შექმნას %1$s-ში" + "ახლა დიდი მოთხოვნაა %1$s-ზე %2$s-ში. დაბრუნდით რამდენიმე დღეში და სცადეთ ერთხელაც. + +მადლობა მოთმენისათვის!" + "კეთილი იყოს თქვენი მობრძანება %1$s-ში!" + "თითქმის მზადაა." + "თქვენ შეხვედით." + diff --git a/features/login/impl/src/main/res/values-pt/translations.xml b/features/login/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/login/impl/src/main/res/values-pt/translations.xml rename to features/login/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/login/impl/src/main/res/values-sv/translations.xml b/features/login/impl/src/main/res/values-sv/translations.xml index 011552465a..800af345fd 100644 --- a/features/login/impl/src/main/res/values-sv/translations.xml +++ b/features/login/impl/src/main/res/values-sv/translations.xml @@ -14,6 +14,8 @@ "Använd en annan kontoleverantör, till exempel din egen privata server eller ett jobbkonto." "Byt kontoleverantör" "Vi kunde inte nå den här hemservern. Kontrollera att du har angett hemserverns URL korrekt. Om URL:en är korrekt kontaktar du administratören för hemservern för ytterligare hjälp." + "Sliding Sync är inte tillgängligt på grund av ett problem i well-known-filen: +%1$s" "Den här servern stöder för närvarande inte sliding sync." "Hemserverns URL" "Du kan bara ansluta till en befintlig server som stöder sliding sync. Din hemserveradministratör måste konfigurera det. %1$s" @@ -22,6 +24,7 @@ "Detta konto har avaktiverats." "Felaktigt användarnamn och/eller lösenord" "Detta är inte en giltig användaridentifierare. Förväntat format: \'@användare:hemserver.org\'" + "Den här servern är konfigurerad för att använda uppdateringstokens. Dessa stöds inte när du använder lösenordsbaserad inloggning." "Den valda hemservern stöder inte lösenord eller OIDC-inloggning. Kontakta administratören eller välj en annan hemserver." "Ange dina uppgifter" "Matrix är ett öppet nätverk för säker, decentraliserad kommunikation." diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt index f8518f6033..e6969921f3 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/changeserver/ChangeServerPresenterTest.kt @@ -25,7 +25,7 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderDat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -38,7 +38,7 @@ class ChangeServerPresenterTest { @Test fun `present - initial state`() = runTest { val presenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) moleculeFlow(RecompositionMode.Immediate) { @@ -51,7 +51,7 @@ class ChangeServerPresenterTest { @Test fun `present - change server ok`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, AccountProviderDataSource() @@ -72,7 +72,7 @@ class ChangeServerPresenterTest { @Test fun `present - change server error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = ChangeServerPresenter( authenticationService, AccountProviderDataSource() diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt index 34b497866a..38d0506dd8 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/oidc/webview/OidcPresenterTest.kt @@ -26,7 +26,7 @@ import io.element.android.features.login.api.oidc.OidcAction import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.auth.A_OIDC_DATA -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.runTest @@ -41,7 +41,7 @@ class OidcPresenterTest { fun `present - initial state`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -56,7 +56,7 @@ class OidcPresenterTest { fun `present - go back`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -72,7 +72,7 @@ class OidcPresenterTest { @Test fun `present - go back with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = OidcPresenter( A_OIDC_DATA, authenticationService, @@ -95,7 +95,7 @@ class OidcPresenterTest { fun `present - user cancels from webview`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -113,7 +113,7 @@ class OidcPresenterTest { fun `present - login success`() = runTest { val presenter = OidcPresenter( A_OIDC_DATA, - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), ) moleculeFlow(RecompositionMode.Immediate) { presenter.present() @@ -128,7 +128,7 @@ class OidcPresenterTest { @Test fun `present - login error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = OidcPresenter( A_OIDC_DATA, authenticationService, diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt index 04680ede17..f7696652d0 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/changeaccountprovider/ChangeAccountProviderPresenterTest.kt @@ -23,7 +23,7 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource import io.element.android.features.login.impl.changeserver.ChangeServerPresenter -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -36,7 +36,7 @@ class ChangeAccountProviderPresenterTest { @Test fun `present - initial state`() = runTest { val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = ChangeAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt index 57f18d7eb4..5a02797bdb 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_OIDC import io.element.android.libraries.matrix.test.A_THROWABLE -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.waitForPredicate import kotlinx.coroutines.test.runTest @@ -57,7 +57,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue password login`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -79,7 +79,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - continue oidc`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -101,7 +101,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - cancel with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -129,7 +129,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - cancel with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -156,7 +156,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - success with failure`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, @@ -186,7 +186,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - oidc - success with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val defaultOidcActionFlow = DefaultOidcActionFlow() val defaultLoginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) @@ -219,7 +219,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - submit fails`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -238,7 +238,7 @@ class ConfirmAccountProviderPresenterTest { @Test fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val presenter = createConfirmAccountProviderPresenter( matrixAuthenticationService = authenticationService, ) @@ -267,7 +267,7 @@ class ConfirmAccountProviderPresenterTest { private fun createConfirmAccountProviderPresenter( params: ConfirmAccountProviderPresenter.Params = ConfirmAccountProviderPresenter.Params(isAccountCreation = false), accountProviderDataSource: AccountProviderDataSource = AccountProviderDataSource(), - matrixAuthenticationService: MatrixAuthenticationService = FakeAuthenticationService(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), defaultOidcActionFlow: DefaultOidcActionFlow = DefaultOidcActionFlow(), defaultLoginUserStory: DefaultLoginUserStory = DefaultLoginUserStory(), ) = ConfirmAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt index 4658df0ab9..db73c25687 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/loginpassword/LoginPasswordPresenterTest.kt @@ -30,7 +30,7 @@ import io.element.android.libraries.matrix.test.A_PASSWORD import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_USER_NAME -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest import org.junit.Rule @@ -42,7 +42,7 @@ class LoginPasswordPresenterTest { @Test fun `present - initial state`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -63,7 +63,7 @@ class LoginPasswordPresenterTest { @Test fun `present - enter login and password`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -89,7 +89,7 @@ class LoginPasswordPresenterTest { @Test fun `present - submit`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } val presenter = LoginPasswordPresenter( @@ -118,7 +118,7 @@ class LoginPasswordPresenterTest { @Test fun `present - submit with error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( @@ -146,7 +146,7 @@ class LoginPasswordPresenterTest { @Test fun `present - clear error`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val accountProviderDataSource = AccountProviderDataSource() val loginUserStory = DefaultLoginUserStory() val presenter = LoginPasswordPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt index 28aa1090a9..5288746583 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/searchaccountprovider/SearchAccountProviderPresenterTest.kt @@ -29,7 +29,7 @@ import io.element.android.features.login.impl.resolver.network.WellKnownBaseConf import io.element.android.features.login.impl.resolver.network.WellKnownSlidingSyncConfig import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.test.A_HOMESERVER_URL -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.runTest @@ -44,7 +44,7 @@ class SearchAccountProviderPresenterTest { fun `present - initial state`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -64,7 +64,7 @@ class SearchAccountProviderPresenterTest { fun `present - enter text no result`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -88,7 +88,7 @@ class SearchAccountProviderPresenterTest { fun `present - enter valid url no wellknown`() = runTest { val fakeWellknownRequest = FakeWellknownRequest() val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -123,7 +123,7 @@ class SearchAccountProviderPresenterTest { ) ) val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( @@ -158,7 +158,7 @@ class SearchAccountProviderPresenterTest { ) ) val changeServerPresenter = ChangeServerPresenter( - FakeAuthenticationService(), + FakeMatrixAuthenticationService(), AccountProviderDataSource() ) val presenter = SearchAccountProviderPresenter( diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt index f88f47ae26..4f751a5c9e 100644 --- a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/waitlistscreen/WaitListPresenterTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER import io.element.android.libraries.matrix.test.A_HOMESERVER_URL import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.A_USER_ID -import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService import io.element.android.libraries.matrix.test.core.aBuildMeta import io.element.android.tests.testutils.WarmUpRule import kotlinx.coroutines.test.runTest @@ -41,7 +41,7 @@ class WaitListPresenterTest { @Test fun `present - initial state`() = runTest { - val authenticationService = FakeAuthenticationService().apply { + val authenticationService = FakeMatrixAuthenticationService().apply { givenHomeserver(A_HOMESERVER) } val loginUserStory = DefaultLoginUserStory() @@ -63,7 +63,7 @@ class WaitListPresenterTest { @Test fun `present - attempt login with error`() = runTest { - val authenticationService = FakeAuthenticationService().apply { + val authenticationService = FakeMatrixAuthenticationService().apply { givenLoginError(A_THROWABLE) } val loginUserStory = DefaultLoginUserStory() @@ -94,7 +94,7 @@ class WaitListPresenterTest { @Test fun `present - attempt login with success`() = runTest { - val authenticationService = FakeAuthenticationService() + val authenticationService = FakeMatrixAuthenticationService() val loginUserStory = DefaultLoginUserStory().apply { setLoginFlowIsDone(false) } val presenter = WaitListPresenter( LoginFormState.Default, diff --git a/features/logout/impl/build.gradle.kts b/features/logout/impl/build.gradle.kts index 699d6c93b8..1383384c84 100644 --- a/features/logout/impl/build.gradle.kts +++ b/features/logout/impl/build.gradle.kts @@ -55,7 +55,6 @@ dependencies { testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(libs.test.robolectric) - testImplementation(libs.test.junitext) testImplementation(libs.androidx.compose.ui.test.junit) testReleaseImplementation(libs.androidx.compose.ui.test.manifest) testImplementation(projects.libraries.matrix.test) diff --git a/features/logout/impl/src/main/res/values-ka/translations.xml b/features/logout/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..2226fd240f --- /dev/null +++ b/features/logout/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,8 @@ + + + "დარწმუნებული ხართ, რომ გსურთ გამოსვლა?" + "გამოსვლა" + "გამოსვლა" + "გასვლა…" + "გამოსვლა" + diff --git a/features/logout/impl/src/main/res/values-pt/translations.xml b/features/logout/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/logout/impl/src/main/res/values-pt/translations.xml rename to features/logout/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt index 9531ea8886..70b346ba8d 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/LogoutPresenterTest.kt @@ -144,7 +144,9 @@ class LogoutPresenterTest { @Test fun `present - logout with error then cancel`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { _ -> + throw A_THROWABLE + } } val presenter = createLogoutPresenter( matrixClient, @@ -170,7 +172,13 @@ class LogoutPresenterTest { @Test fun `present - logout with error then force`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { ignoreSdkError -> + if (!ignoreSdkError) { + throw A_THROWABLE + } else { + null + } + } } val presenter = createLogoutPresenter( matrixClient, diff --git a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt index bf3df93731..14d340570c 100644 --- a/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt +++ b/features/logout/impl/src/test/kotlin/io/element/android/features/logout/impl/direct/DefaultDirectLogoutPresenterTest.kt @@ -125,7 +125,9 @@ class DefaultDirectLogoutPresenterTest { @Test fun `present - logout with error then cancel`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { _ -> + throw A_THROWABLE + } } val presenter = createDefaultDirectLogoutPresenter( matrixClient, @@ -151,7 +153,13 @@ class DefaultDirectLogoutPresenterTest { @Test fun `present - logout with error then force`() = runTest { val matrixClient = FakeMatrixClient().apply { - givenLogoutError(A_THROWABLE) + logoutLambda = { ignoreSdkError -> + if (!ignoreSdkError) { + throw A_THROWABLE + } else { + null + } + } } val presenter = createDefaultDirectLogoutPresenter( matrixClient, diff --git a/features/messages/impl/build.gradle.kts b/features/messages/impl/build.gradle.kts index 2de95dff48..df0c4dff7a 100644 --- a/features/messages/impl/build.gradle.kts +++ b/features/messages/impl/build.gradle.kts @@ -99,7 +99,6 @@ dependencies { testImplementation(projects.libraries.mediaviewer.test) testImplementation(projects.libraries.testtags) testImplementation(libs.test.mockk) - testImplementation(libs.test.junitext) testImplementation(libs.test.robolectric) testImplementation(projects.features.poll.test) testImplementation(projects.features.poll.impl) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt index e5127ea865..579ea87868 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesPresenter.kt @@ -31,6 +31,7 @@ import androidx.compose.runtime.setValue import dagger.assisted.Assisted import dagger.assisted.AssistedFactory import dagger.assisted.AssistedInject +import io.element.android.appconfig.MessageComposerConfig import io.element.android.features.messages.api.timeline.HtmlConverterProvider import io.element.android.features.messages.impl.actionlist.ActionListEvents import io.element.android.features.messages.impl.actionlist.ActionListPresenter @@ -66,7 +67,6 @@ import io.element.android.features.messages.impl.utils.messagesummary.MessageSum import io.element.android.features.messages.impl.voicemessages.composer.VoiceMessageComposerPresenter import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus -import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.libraries.androidutils.clipboard.ClipboardHelper import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter @@ -113,7 +113,6 @@ class MessagesPresenter @AssistedInject constructor( private val messageSummaryFormatter: MessageSummaryFormatter, private val dispatchers: CoroutineDispatchers, private val clipboardHelper: ClipboardHelper, - private val appPreferencesStore: AppPreferencesStore, private val featureFlagsService: FeatureFlagService, private val htmlConverterProvider: HtmlConverterProvider, @Assisted private val navigator: MessagesNavigator, @@ -171,17 +170,15 @@ class MessagesPresenter @AssistedInject constructor( val inviteProgress = remember { mutableStateOf>(AsyncData.Uninitialized) } var showReinvitePrompt by remember { mutableStateOf(false) } - LaunchedEffect(hasDismissedInviteDialog, composerState.hasFocus, syncUpdateFlow.value) { + LaunchedEffect(hasDismissedInviteDialog, composerState.textEditorState.hasFocus(), syncUpdateFlow.value) { withContext(dispatchers.io) { - showReinvitePrompt = !hasDismissedInviteDialog && composerState.hasFocus && room.isDirect && room.activeMemberCount == 1L + showReinvitePrompt = !hasDismissedInviteDialog && composerState.textEditorState.hasFocus() && room.isDirect && room.activeMemberCount == 1L } } val networkConnectionStatus by networkMonitor.connectivity.collectAsState() val snackbarMessage by snackbarDispatcher.collectSnackbarMessageAsState() - val enableTextFormatting by appPreferencesStore.isRichTextEditorEnabledFlow().collectAsState(initial = true) - var enableVoiceMessages by remember { mutableStateOf(false) } LaunchedEffect(featureFlagsService) { enableVoiceMessages = featureFlagsService.isFeatureEnabled(FeatureFlags.VoiceMessages) @@ -194,7 +191,7 @@ class MessagesPresenter @AssistedInject constructor( action = event.action, targetEvent = event.event, composerState = composerState, - enableTextFormatting = enableTextFormatting, + enableTextFormatting = composerState.showTextFormatting, timelineState = timelineState, ) } @@ -239,7 +236,7 @@ class MessagesPresenter @AssistedInject constructor( snackbarMessage = snackbarMessage, showReinvitePrompt = showReinvitePrompt, inviteProgress = inviteProgress.value, - enableTextFormatting = enableTextFormatting, + enableTextFormatting = MessageComposerConfig.ENABLE_RICH_TEXT_EDITING, enableVoiceMessages = enableVoiceMessages, appName = buildMeta.applicationName, callState = callState, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt index acd7e86d58..fd00eba04b 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesStateProvider.kt @@ -45,6 +45,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.textcomposer.aRichTextEditorState import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentSetOf @@ -99,9 +100,9 @@ fun aMessagesState( userHasPermissionToRedactOther: Boolean = false, userHasPermissionToSendReaction: Boolean = true, composerState: MessageComposerState = aMessageComposerState( - richTextEditorState = aRichTextEditorState(initialText = "Hello", initialFocus = true), - isFullScreen = false, - mode = MessageComposerMode.Normal, + textEditorState = TextEditorState.Rich(aRichTextEditorState(initialText = "Hello", initialFocus = true)), + isFullScreen = false, + mode = MessageComposerMode.Normal, ), voiceMessageComposerState: VoiceMessageComposerState = aVoiceMessageComposerState(), timelineState: TimelineState = aTimelineState( diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt index 597fd4b8c9..e7b65f353f 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesView.kt @@ -362,7 +362,7 @@ private fun MessagesViewContent( // Any state change that should trigger a height size should be added to the list of remembered values here. val sheetResizeContentKey = remember { mutableIntStateOf(0) } LaunchedEffect( - state.composerState.richTextEditorState.lineCount, + state.composerState.textEditorState.lineCount, state.composerState.showTextFormatting, ) { sheetResizeContentKey.intValue = Random.nextInt() @@ -439,7 +439,6 @@ private fun MessagesViewComposerBottomSheetContents( state = state.composerState, voiceMessageState = state.voiceMessageComposerState, subcomposing = subcomposing, - enableTextFormatting = state.enableTextFormatting, enableVoiceMessages = state.enableVoiceMessages, modifier = Modifier.fillMaxWidth(), ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt index cf48ea1478..6639910ec9 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsPickerView.kt @@ -43,6 +43,7 @@ import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -51,8 +52,8 @@ fun MentionSuggestionsPickerView( roomId: RoomId, roomName: String?, roomAvatarData: AvatarData?, - memberSuggestions: ImmutableList, - onSuggestionSelected: (MentionSuggestion) -> Unit, + memberSuggestions: ImmutableList, + onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { LazyColumn( @@ -62,8 +63,8 @@ fun MentionSuggestionsPickerView( memberSuggestions, key = { suggestion -> when (suggestion) { - is MentionSuggestion.Room -> "@room" - is MentionSuggestion.Member -> suggestion.roomMember.userId.value + is ResolvedMentionSuggestion.AtRoom -> "@room" + is ResolvedMentionSuggestion.Member -> suggestion.roomMember.userId.value } } ) { @@ -84,18 +85,18 @@ fun MentionSuggestionsPickerView( @Composable private fun RoomMemberSuggestionItemView( - memberSuggestion: MentionSuggestion, + memberSuggestion: ResolvedMentionSuggestion, roomId: String, roomName: String?, roomAvatar: AvatarData?, - onSuggestionSelected: (MentionSuggestion) -> Unit, + onSuggestionSelected: (ResolvedMentionSuggestion) -> Unit, modifier: Modifier = Modifier, ) { Row(modifier = modifier.clickable { onSuggestionSelected(memberSuggestion) }, horizontalArrangement = Arrangement.spacedBy(16.dp)) { val avatarSize = AvatarSize.TimelineRoom val avatarData = when (memberSuggestion) { - is MentionSuggestion.Room -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) - is MentionSuggestion.Member -> AvatarData( + is ResolvedMentionSuggestion.AtRoom -> roomAvatar?.copy(size = avatarSize) ?: AvatarData(roomId, roomName, null, avatarSize) + is ResolvedMentionSuggestion.Member -> AvatarData( memberSuggestion.roomMember.userId.value, memberSuggestion.roomMember.displayName, memberSuggestion.roomMember.avatarUrl, @@ -103,13 +104,13 @@ private fun RoomMemberSuggestionItemView( ) } val title = when (memberSuggestion) { - is MentionSuggestion.Room -> stringResource(R.string.screen_room_mentions_at_room_title) - is MentionSuggestion.Member -> memberSuggestion.roomMember.displayName + is ResolvedMentionSuggestion.AtRoom -> stringResource(R.string.screen_room_mentions_at_room_title) + is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.displayName } val subtitle = when (memberSuggestion) { - is MentionSuggestion.Room -> "@room" - is MentionSuggestion.Member -> memberSuggestion.roomMember.userId.value + is ResolvedMentionSuggestion.AtRoom -> "@room" + is ResolvedMentionSuggestion.Member -> memberSuggestion.roomMember.userId.value } Avatar(avatarData = avatarData, modifier = Modifier.padding(start = 16.dp, top = 12.dp, bottom = 12.dp)) @@ -159,9 +160,9 @@ internal fun MentionSuggestionsPickerViewPreview() { roomName = "Room", roomAvatarData = null, memberSuggestions = persistentListOf( - MentionSuggestion.Room, - MentionSuggestion.Member(roomMember), - MentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), + ResolvedMentionSuggestion.AtRoom, + ResolvedMentionSuggestion.Member(roomMember), + ResolvedMentionSuggestion.Member(roomMember.copy(userId = UserId("@bob:server.org"), displayName = "Bob")), ), onSuggestionSelected = {} ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt index 696f0fe93e..f0e89c1148 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestionsProcessor.kt @@ -22,6 +22,7 @@ import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState import io.element.android.libraries.matrix.api.room.RoomMember import io.element.android.libraries.matrix.api.room.RoomMembershipState import io.element.android.libraries.matrix.api.room.roomMembers +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType @@ -45,7 +46,7 @@ object MentionSuggestionsProcessor { roomMembersState: MatrixRoomMembersState, currentUserId: UserId, canSendRoomMention: suspend () -> Boolean, - ): List { + ): List { val members = roomMembersState.roomMembers() return when { members.isNullOrEmpty() || suggestion == null -> { @@ -78,7 +79,7 @@ object MentionSuggestionsProcessor { roomMembers: List?, currentUserId: UserId, canSendRoomMention: Boolean, - ): List { + ): List { return if (roomMembers.isNullOrEmpty()) { emptyList() } else { @@ -96,10 +97,10 @@ object MentionSuggestionsProcessor { .filterUpTo(MAX_BATCH_ITEMS) { member -> isJoinedMemberAndNotSelf(member) && memberMatchesQuery(member, query) } - .map(MentionSuggestion::Member) + .map(ResolvedMentionSuggestion::Member) if ("room".contains(query) && canSendRoomMention) { - listOf(MentionSuggestion.Room) + matchingMembers + listOf(ResolvedMentionSuggestion.AtRoom) + matchingMembers } else { matchingMembers } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt index 9ddf1f7aae..19ca038bd2 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerEvents.kt @@ -18,15 +18,14 @@ package io.element.android.features.messages.impl.messagecomposer import android.net.Uri import androidx.compose.runtime.Immutable -import io.element.android.features.messages.impl.mentions.MentionSuggestion -import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion @Immutable sealed interface MessageComposerEvents { data object ToggleFullScreenState : MessageComposerEvents - data class SendMessage(val message: Message) : MessageComposerEvents + data object SendMessage : MessageComposerEvents data class SendUri(val uri: Uri) : MessageComposerEvents data object CloseSpecialMode : MessageComposerEvents data class SetMode(val composerMode: MessageComposerMode) : MessageComposerEvents @@ -45,5 +44,5 @@ sealed interface MessageComposerEvents { data class Error(val error: Throwable) : MessageComposerEvents data class TypingNotice(val isTyping: Boolean) : MessageComposerEvents data class SuggestionReceived(val suggestion: Suggestion?) : MessageComposerEvents - data class InsertMention(val mention: MentionSuggestion) : MessageComposerEvents + data class InsertMention(val mention: ResolvedMentionSuggestion) : MessageComposerEvents } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 30bdadcbe5..929dfe024a 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -19,6 +19,7 @@ package io.element.android.features.messages.impl.messagecomposer import android.Manifest import android.annotation.SuppressLint import android.net.Uri +import androidx.annotation.VisibleForTesting import androidx.compose.runtime.Composable import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect @@ -29,6 +30,7 @@ import androidx.compose.runtime.mutableStateListOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.runtime.rememberUpdatedState import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.media3.common.MimeTypes @@ -36,7 +38,6 @@ import androidx.media3.common.util.UnstableApi import im.vector.app.features.analytics.plan.Composer import io.element.android.features.messages.impl.attachments.Attachment import io.element.android.features.messages.impl.attachments.preview.error.sendAttachmentError -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.mentions.MentionSuggestionsProcessor import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.preferences.api.store.SessionPreferencesStore @@ -59,17 +60,21 @@ import io.element.android.libraries.mediaupload.api.MediaSender import io.element.android.libraries.mediaviewer.api.local.LocalMediaFactory import io.element.android.libraries.permissions.api.PermissionsEvents import io.element.android.libraries.permissions.api.PermissionsPresenter +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState +import io.element.android.libraries.textcomposer.model.rememberMarkdownTextEditorState import io.element.android.services.analytics.api.AnalyticsService -import io.element.android.wysiwyg.compose.RichTextEditorState import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toPersistentList import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.Job +import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.combine @@ -108,12 +113,27 @@ class MessageComposerPresenter @Inject constructor( private val suggestionSearchTrigger = MutableStateFlow(null) + // Used to disable some UI related elements in tests + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var isTesting: Boolean = false + + @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) + internal var showTextFormatting: Boolean by mutableStateOf(false) + @OptIn(FlowPreview::class) @SuppressLint("UnsafeOptInUsageError") @Composable override fun present(): MessageComposerState { val localCoroutineScope = rememberCoroutineScope() + // Initially disabled so we don't set focus and text twice + var applyFormattingModeChanges by remember { mutableStateOf(false) } + val richTextEditorState = richTextEditorStateFactory.remember() + if (isTesting) { + richTextEditorState.isReadyToProcessActions = true + } + val markdownTextEditorState = rememberMarkdownTextEditorState(initialText = null, initialFocus = false) + var isMentionsEnabled by remember { mutableStateOf(false) } LaunchedEffect(Unit) { isMentionsEnabled = featureFlagService.isFeatureEnabled(FeatureFlags.Mentions) @@ -149,18 +169,20 @@ class MessageComposerPresenter @Inject constructor( val isFullScreen = rememberSaveable { mutableStateOf(false) } - val richTextEditorState = richTextEditorStateFactory.create() val ongoingSendAttachmentJob = remember { mutableStateOf(null) } var showAttachmentSourcePicker: Boolean by remember { mutableStateOf(false) } - var showTextFormatting: Boolean by remember { mutableStateOf(false) } val sendTypingNotifications by sessionPreferencesStore.isSendTypingNotificationsEnabled().collectAsState(initial = true) LaunchedEffect(messageComposerContext.composerMode) { when (val modeValue = messageComposerContext.composerMode) { is MessageComposerMode.Edit -> - richTextEditorState.setHtml(modeValue.defaultContent) + if (showTextFormatting) { + richTextEditorState.setHtml(modeValue.defaultContent) + } else { + markdownTextEditorState.text.update(modeValue.defaultContent, true) + } else -> Unit } } @@ -188,7 +210,7 @@ class MessageComposerPresenter @Inject constructor( } } - val memberSuggestions = remember { mutableStateListOf() } + val memberSuggestions = remember { mutableStateListOf() } LaunchedEffect(isMentionsEnabled) { if (!isMentionsEnabled) return@LaunchedEffect val currentUserId = currentSessionIdHolder.current @@ -229,22 +251,69 @@ class MessageComposerPresenter @Inject constructor( } } + val textEditorState by rememberUpdatedState( + if (showTextFormatting) { + TextEditorState.Rich(richTextEditorState) + } else { + TextEditorState.Markdown(markdownTextEditorState) + } + ) + + LaunchedEffect(showTextFormatting) { + if (!applyFormattingModeChanges) { + applyFormattingModeChanges = true + return@LaunchedEffect + } + if (showTextFormatting) { + val markdown = markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + richTextEditorState.setMarkdown(markdown) + richTextEditorState.requestFocus() + } else { + val markdown = richTextEditorState.messageMarkdown + markdownTextEditorState.text.update(markdown, true) + // Give some time for the focus of the previous editor to be cleared + delay(100) + markdownTextEditorState.requestFocusAction() + } + } + + val mentionSpanProvider = if (isTesting) { + null + } else { + rememberMentionSpanProvider( + currentUserId = room.sessionId, + permalinkParser = permalinkParser, + ) + } + fun handleEvents(event: MessageComposerEvents) { when (event) { MessageComposerEvents.ToggleFullScreenState -> isFullScreen.value = !isFullScreen.value MessageComposerEvents.CloseSpecialMode -> { if (messageComposerContext.composerMode is MessageComposerMode.Edit) { localCoroutineScope.launch { - richTextEditorState.setHtml("") + textEditorState.reset() } } messageComposerContext.composerMode = MessageComposerMode.Normal } - is MessageComposerEvents.SendMessage -> appCoroutineScope.sendMessage( - message = event.message, - updateComposerMode = { messageComposerContext.composerMode = it }, - richTextEditorState = richTextEditorState, - ) + is MessageComposerEvents.SendMessage -> { + val html = if (showTextFormatting) { + richTextEditorState.messageHtml + } else { + null + } + val markdown = if (showTextFormatting) { + richTextEditorState.messageMarkdown + } else { + markdownTextEditorState.getMessageMarkdown(permalinkBuilder) + } + appCoroutineScope.sendMessage( + message = Message(html = html, markdown = markdown), + updateComposerMode = { messageComposerContext.composerMode = it }, + textEditorState = textEditorState, + ) + } is MessageComposerEvents.SendUri -> appCoroutineScope.sendAttachment( attachment = Attachment.Media( localMedia = localMediaFactory.createFromUri( @@ -335,15 +404,26 @@ class MessageComposerPresenter @Inject constructor( } is MessageComposerEvents.InsertMention -> { localCoroutineScope.launch { - when (val mention = event.mention) { - is MentionSuggestion.Room -> { - richTextEditorState.insertAtRoomMentionAtSuggestion() + if (showTextFormatting) { + when (val mention = event.mention) { + is ResolvedMentionSuggestion.AtRoom -> { + richTextEditorState.insertAtRoomMentionAtSuggestion() + } + is ResolvedMentionSuggestion.Member -> { + val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch + richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } } - is MentionSuggestion.Member -> { - val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value - val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return@launch - richTextEditorState.insertMentionAtSuggestion(text = text, link = link) + } else if (markdownTextEditorState.currentMentionSuggestion != null) { + mentionSpanProvider?.let { + markdownTextEditorState.insertMention( + mention = event.mention, + mentionSpanProvider = it, + permalinkBuilder = permalinkBuilder, + ) } + suggestionSearchTrigger.value = null } } } @@ -351,7 +431,7 @@ class MessageComposerPresenter @Inject constructor( } return MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, permalinkParser = permalinkParser, isFullScreen = isFullScreen.value, mode = messageComposerContext.composerMode, @@ -369,21 +449,26 @@ class MessageComposerPresenter @Inject constructor( private fun CoroutineScope.sendMessage( message: Message, updateComposerMode: (newComposerMode: MessageComposerMode) -> Unit, - richTextEditorState: RichTextEditorState, + textEditorState: TextEditorState, ) = launch { val capturedMode = messageComposerContext.composerMode - val mentions = richTextEditorState.mentionsState?.let { state -> - buildList { - if (state.hasAtRoomMention) { - add(Mention.AtRoom) - } - for (userId in state.userIds) { - add(Mention.User(UserId(userId))) - } + val mentions = when (textEditorState) { + is TextEditorState.Rich -> { + textEditorState.richTextEditorState.mentionsState?.let { state -> + buildList { + if (state.hasAtRoomMention) { + add(Mention.AtRoom) + } + for (userId in state.userIds) { + add(Mention.User(UserId(userId))) + } + } + }.orEmpty() } - }.orEmpty() + is TextEditorState.Markdown -> textEditorState.state.getMentions() + } // Reset composer right away - richTextEditorState.setHtml("") + textEditorState.reset() updateComposerMode(MessageComposerMode.Normal) when (capturedMode) { is MessageComposerMode.Normal -> room.sendMessage(body = message.markdown, htmlBody = message.html, mentions = mentions) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt index 194ce1914c..4ac69ed3d6 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerState.kt @@ -19,16 +19,16 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.runtime.Immutable import androidx.compose.runtime.Stable import io.element.android.features.messages.impl.attachments.Attachment -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList @Stable data class MessageComposerState( - val richTextEditorState: RichTextEditorState, + val textEditorState: TextEditorState, val permalinkParser: PermalinkParser, val isFullScreen: Boolean, val mode: MessageComposerMode, @@ -37,12 +37,10 @@ data class MessageComposerState( val canShareLocation: Boolean, val canCreatePoll: Boolean, val attachmentsState: AttachmentsState, - val memberSuggestions: ImmutableList, + val memberSuggestions: ImmutableList, val currentUserId: UserId, val eventSink: (MessageComposerEvents) -> Unit, -) { - val hasFocus: Boolean = richTextEditorState.hasFocus -} +) @Immutable sealed interface AttachmentsState { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt index 340f7328f3..7ec47f4ef5 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerStateProvider.kt @@ -17,13 +17,13 @@ package io.element.android.features.messages.impl.messagecomposer import androidx.compose.ui.tooling.preview.PreviewParameterProvider -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.textcomposer.aRichTextEditorState +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode -import io.element.android.wysiwyg.compose.RichTextEditorState +import io.element.android.libraries.textcomposer.model.TextEditorState import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf @@ -35,7 +35,7 @@ open class MessageComposerStateProvider : PreviewParameterProvider = persistentListOf(), + memberSuggestions: ImmutableList = persistentListOf(), ) = MessageComposerState( - richTextEditorState = richTextEditorState, + textEditorState = textEditorState, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO() }, diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt index e68cf29844..207d811707 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerView.kt @@ -33,7 +33,6 @@ import io.element.android.features.messages.impl.voicemessages.composer.aVoiceMe import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.textcomposer.TextComposer -import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent @@ -44,13 +43,12 @@ internal fun MessageComposerView( state: MessageComposerState, voiceMessageState: VoiceMessageComposerState, subcomposing: Boolean, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, modifier: Modifier = Modifier, ) { val view = LocalView.current - fun sendMessage(message: Message) { - state.eventSink(MessageComposerEvents.SendMessage(message)) + fun sendMessage() { + state.eventSink(MessageComposerEvents.SendMessage) } fun sendUri(uri: Uri) { @@ -85,7 +83,7 @@ internal fun MessageComposerView( val coroutineScope = rememberCoroutineScope() fun onRequestFocus() { coroutineScope.launch { - state.richTextEditorState.requestFocus() + state.textEditorState.requestFocus() } } @@ -107,7 +105,7 @@ internal fun MessageComposerView( TextComposer( modifier = modifier, - state = state.richTextEditorState, + state = state.textEditorState, voiceMessageState = voiceMessageState.voiceMessageState, permalinkParser = state.permalinkParser, subcomposing = subcomposing, @@ -118,7 +116,6 @@ internal fun MessageComposerView( onResetComposerMode = ::onCloseSpecialMode, onAddAttachment = ::onAddAttachment, onDismissTextFormatting = ::onDismissTextFormatting, - enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, onVoiceRecorderEvent = onVoiceRecorderEvent, onVoicePlayerEvent = onVoicePlayerEvent, @@ -142,7 +139,6 @@ internal fun MessageComposerViewPreview( modifier = Modifier.height(IntrinsicSize.Min), state = state, voiceMessageState = aVoiceMessageComposerState(), - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) @@ -150,7 +146,6 @@ internal fun MessageComposerViewPreview( modifier = Modifier.height(200.dp), state = state, voiceMessageState = aVoiceMessageComposerState(), - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) @@ -167,7 +162,6 @@ internal fun MessageComposerViewVoicePreview( modifier = Modifier.height(IntrinsicSize.Min), state = aMessageComposerState(), voiceMessageState = state, - enableTextFormatting = true, enableVoiceMessages = true, subcomposing = false, ) diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt index 52fff81c31..4ce09e800d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/RichTextEditorStateFactory.kt @@ -25,13 +25,13 @@ import javax.inject.Inject interface RichTextEditorStateFactory { @Composable - fun create(): RichTextEditorState + fun remember(): RichTextEditorState } @ContributesBinding(AppScope::class) class DefaultRichTextEditorStateFactory @Inject constructor() : RichTextEditorStateFactory { @Composable - override fun create(): RichTextEditorState { + override fun remember(): RichTextEditorState { return rememberRichTextEditorState() } } diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt index 2aba9c3669..5c0623b480 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactory.kt @@ -268,12 +268,16 @@ class TimelineItemContentMessageFactory @Inject constructor( } // Find and set as URLSpans any links present in the text LinkifyCompat.addLinks(this, Linkify.WEB_URLS or Linkify.PHONE_NUMBERS or Linkify.EMAIL_ADDRESSES) - // Restore old spans if they don't conflict with the new ones + // Restore old spans, remove new ones if there is a conflict for ((urlSpan, location) in oldURLSpans) { val (start, end) = location - if (getSpans(start, end).isEmpty()) { - setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + val addedSpans = getSpans(start, end).orEmpty() + if (addedSpans.isNotEmpty()) { + for (span in addedSpans) { + removeSpan(span) + } } + setSpan(urlSpan, start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) } return this } diff --git a/features/messages/impl/src/main/res/values-ka/translations.xml b/features/messages/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..080696776f --- /dev/null +++ b/features/messages/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,42 @@ + + + "აქტივობები" + "დროშები" + "Საჭმელ-სასმელი" + "ცხოველები & ბუნება" + "ობიექტები" + "ღიმილები & ხალხი" + "მოგზაურობა და ადგილები" + "სიმბოლოები" + "მომხმარებლის დაბლოკვა" + "შეამოწმეთ, გსურთ თუ არა ამ მომხმარებლის ყველა მიმდინარე და მომავალი შეტყობინების დამალვა" + "ეს შეტყობინება გაგზავნილი იქნება თქვენი სახლის სერვერის ადმინისტრატორისადმი. მას არ ექნება დაშიფვრული შეტყობინებების წაკითხვის შესაძლებლობა." + "ამ კონტენტის დარეპორტების მიზეზი" + "კამერა" + "ფოტოს გადაღება" + "ვიდეოს ჩაწერა" + "დანართი" + "ფოტოსა და ვიდეოს ბიბლიოთეკა" + "ადგილმდებარეობა" + "გამოკითხვა" + "ტექსტის ფორმატირება" + "შეტყობინებების ისტორია ამჟამად მიუწვდომელია." + "გსურთ მათი კვლავ მოწვევა?" + "თქვენ მარტო ხართ ამ ჩატში" + "ყველა" + "Ხელახლა გაგზავნა" + "თქვენი შეტყობინების გაგზავნა ვერ მოხერხდა" + "ემოჯის დამატება" + "ეს არის %1$s-ს დასაწყისი." + "ეს არის ამ საუბრის დასაწყისი." + "ნაკლების ჩვენება" + "შეტყობინება დაკოპირდა" + "თქვენ არ გაქვთ ამ ოთახში გამოქვეყნების ნებართვა" + "ნაკლების ჩვენება" + "მეტის ჩვენება" + "ახალი" + + "%1$dოთახის ცვლილება" + "%1$dოთახის ცვლილებები" + + diff --git a/features/messages/impl/src/main/res/values-pt/translations.xml b/features/messages/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/messages/impl/src/main/res/values-pt/translations.xml rename to features/messages/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/messages/impl/src/main/res/values-ro/translations.xml b/features/messages/impl/src/main/res/values-ro/translations.xml index 6295c1310a..9b44893ce2 100644 --- a/features/messages/impl/src/main/res/values-ro/translations.xml +++ b/features/messages/impl/src/main/res/values-ro/translations.xml @@ -49,6 +49,7 @@ "%1$s tastează" + "%1$s tastează" "%1$s tastează" "%1$s și %2$s" diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt index 0fe401584b..14eb3616ad 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/MessagesPresenterTest.kt @@ -538,7 +538,7 @@ class MessagesPresenterTest { // Initially the composer doesn't have focus, so we don't show the alert assertThat(initialState.showReinvitePrompt).isFalse() // When the input field is focused we show the alert - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = consumeItemsUntilPredicate(timeout = 250.milliseconds) { state -> state.showReinvitePrompt }.last() @@ -561,7 +561,7 @@ class MessagesPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -576,7 +576,7 @@ class MessagesPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.showReinvitePrompt).isFalse() - initialState.composerState.richTextEditorState.requestFocus() + initialState.composerState.textEditorState.requestFocus() val focusedState = awaitItem() assertThat(focusedState.showReinvitePrompt).isFalse() } @@ -781,7 +781,7 @@ class MessagesPresenterTest { ): MessagesPresenter { val mediaSender = MediaSender(FakeMediaPreProcessor(), matrixRoom) val permissionsPresenterFactory = FakePermissionsPresenterFactory(permissionsPresenter) - val appPreferencesStore = InMemoryAppPreferencesStore(isRichTextEditorEnabled = true) + val appPreferencesStore = InMemoryAppPreferencesStore() val sessionPreferencesStore = InMemorySessionPreferencesStore() val messageComposerPresenter = MessageComposerPresenter( appCoroutineScope = this, @@ -800,7 +800,10 @@ class MessagesPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = FakePermalinkBuilder(), timelineController = TimelineController(matrixRoom), - ) + ).apply { + showTextFormatting = true + isTesting = true + } val voiceMessageComposerPresenter = VoiceMessageComposerPresenter( this, FakeVoiceRecorder(), @@ -853,7 +856,6 @@ class MessagesPresenterTest { messageSummaryFormatter = FakeMessageSummaryFormatter(), navigator = navigator, clipboardHelper = clipboardHelper, - appPreferencesStore = appPreferencesStore, featureFlagsService = FakeFeatureFlagService(), buildMeta = aBuildMeta(), dispatchers = coroutineDispatchers, diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt index 7274027059..f0bf4c42ef 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/MessageComposerPresenterTest.kt @@ -26,7 +26,6 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.messages.impl.mentions.MentionSuggestion import io.element.android.features.messages.impl.messagecomposer.AttachmentsState import io.element.android.features.messages.impl.messagecomposer.MessageComposerContextImpl import io.element.android.features.messages.impl.messagecomposer.MessageComposerEvents @@ -77,10 +76,11 @@ import io.element.android.libraries.permissions.api.PermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenter import io.element.android.libraries.permissions.test.FakePermissionsPresenterFactory import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore -import io.element.android.libraries.textcomposer.model.Message +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.lambda.any @@ -127,7 +127,7 @@ class MessageComposerPresenterTest { }.test { val initialState = awaitFirstItem() assertThat(initialState.isFullScreen).isFalse() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") assertThat(initialState.mode).isEqualTo(MessageComposerMode.Normal) assertThat(initialState.showAttachmentSourcePicker).isFalse() assertThat(initialState.canShareLocation).isTrue() @@ -158,10 +158,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml(A_MESSAGE) - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - initialState.richTextEditorState.setHtml("") - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + initialState.textEditorState.setHtml(A_MESSAGE) + assertThat(initialState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + initialState.textEditorState.setHtml("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") } } @@ -170,7 +170,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { var state = awaitFirstItem() val mode = anEditMode() @@ -178,11 +178,11 @@ class MessageComposerPresenterTest { state = awaitItem() assertThat(state.mode).isEqualTo(mode) state = awaitItem() - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) state = backToNormalMode(state, skipCount = 1) // The message that was being edited is cleared - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") } } @@ -197,7 +197,7 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @@ -213,11 +213,11 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - state.richTextEditorState.setHtml(A_REPLY) + state.textEditorState.setHtml(A_REPLY) state = backToNormalMode(state) // The message typed while replying is not cleared - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) } } @@ -232,25 +232,54 @@ class MessageComposerPresenterTest { state.eventSink.invoke(MessageComposerEvents.SetMode(mode)) state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") + assertThat(state.textEditorState.messageHtml()).isEqualTo("") backToNormalMode(state) } } @Test - fun `present - send message`() = runTest { + fun `present - send message with rich text enabled`() = runTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml(A_MESSAGE) + initialState.textEditorState.setHtml(A_MESSAGE) val withMessageState = awaitItem() - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") + waitForPredicate { analyticsService.capturedEvents.size == 1 } + assertThat(analyticsService.capturedEvents).containsExactly( + Composer( + inThread = false, + isEditing = false, + isReply = false, + messageType = Composer.MessageType.Text, + ) + ) + } + } + + @Test + fun `present - send message with plain text enabled`() = runTest { + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("") }) + val presenter = createPresenter(this, isRichTextEditorEnabled = false) + moleculeFlow(RecompositionMode.Immediate) { + val state = presenter.present() + val messageMarkdown = state.textEditorState.messageMarkdown(permalinkBuilder) + remember(state, messageMarkdown) { state } + }.test { + val initialState = awaitFirstItem() + initialState.textEditorState.setMarkdown(A_MESSAGE) + val withMessageState = awaitItem() + assertThat(withMessageState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo(A_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isNull() + withMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) + val messageSentState = awaitItem() + assertThat(messageSentState.textEditorState.messageMarkdown(permalinkBuilder)).isEqualTo("") waitForPredicate { analyticsService.capturedEvents.size == 1 } assertThat(analyticsService.capturedEvents).containsExactly( Composer( @@ -278,23 +307,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = anEditMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -328,23 +357,23 @@ class MessageComposerPresenterTest { ) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = anEditMode(eventId = null, transactionId = A_TRANSACTION_ID) initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) skipItems(1) val withMessageState = awaitItem() assertThat(withMessageState.mode).isEqualTo(mode) - assertThat(withMessageState.richTextEditorState.messageHtml).isEqualTo(A_MESSAGE) - withMessageState.richTextEditorState.setHtml(ANOTHER_MESSAGE) + assertThat(withMessageState.textEditorState.messageHtml()).isEqualTo(A_MESSAGE) + withMessageState.textEditorState.setHtml(ANOTHER_MESSAGE) val withEditedMessageState = awaitItem() - assertThat(withEditedMessageState.richTextEditorState.messageHtml).isEqualTo(ANOTHER_MESSAGE) - withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage(ANOTHER_MESSAGE.toMessage())) + assertThat(withEditedMessageState.textEditorState.messageHtml()).isEqualTo(ANOTHER_MESSAGE) + withEditedMessageState.eventSink.invoke(MessageComposerEvents.SendMessage) skipItems(1) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -380,17 +409,17 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - assertThat(initialState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(initialState.textEditorState.messageHtml()).isEqualTo("") val mode = aReplyMode() initialState.eventSink.invoke(MessageComposerEvents.SetMode(mode)) val state = awaitItem() assertThat(state.mode).isEqualTo(mode) - assertThat(state.richTextEditorState.messageHtml).isEqualTo("") - state.richTextEditorState.setHtml(A_REPLY) - assertThat(state.richTextEditorState.messageHtml).isEqualTo(A_REPLY) - state.eventSink.invoke(MessageComposerEvents.SendMessage(A_REPLY.toMessage())) + assertThat(state.textEditorState.messageHtml()).isEqualTo("") + state.textEditorState.setHtml(A_REPLY) + assertThat(state.textEditorState.messageHtml()).isEqualTo(A_REPLY) + state.eventSink.invoke(MessageComposerEvents.SendMessage) val messageSentState = awaitItem() - assertThat(messageSentState.richTextEditorState.messageHtml).isEqualTo("") + assertThat(messageSentState.textEditorState.messageHtml()).isEqualTo("") advanceUntilIdle() @@ -725,7 +754,7 @@ class MessageComposerPresenterTest { @Test fun `present - ToggleTextFormatting toggles text formatting`() = runTest { - val presenter = createPresenter(this) + val presenter = createPresenter(this, isRichTextEditorEnabled = false) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -735,11 +764,12 @@ class MessageComposerPresenterTest { val composerOptions = awaitItem() assertThat(composerOptions.showAttachmentSourcePicker).isTrue() composerOptions.eventSink(MessageComposerEvents.ToggleTextFormatting(true)) - awaitItem() // composer options closed + skipItems(2) // composer options closed val showTextFormatting = awaitItem() assertThat(showTextFormatting.showAttachmentSourcePicker).isFalse() assertThat(showTextFormatting.showTextFormatting).isTrue() showTextFormatting.eventSink(MessageComposerEvents.ToggleTextFormatting(false)) + skipItems(1) val finished = awaitItem() assertThat(finished.showTextFormatting).isFalse() } @@ -781,19 +811,19 @@ class MessageComposerPresenterTest { // An empty suggestion returns the room and joined members that are not the current user initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Room, MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.AtRoom, ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) // A suggestion containing a part of "room" will also return the room mention initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "roo"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Room) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.AtRoom) // A non-empty suggestion will return those joined members whose user id matches it initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "bob"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(bob)) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(bob)) // A non-empty suggestion will return those joined members whose display name matches it initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, "dave"))) - assertThat(awaitItem().memberSuggestions).containsExactly(MentionSuggestion.Member(david)) + assertThat(awaitItem().memberSuggestions).containsExactly(ResolvedMentionSuggestion.Member(david)) // If the suggestion isn't a mention, no suggestions are returned initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Command, ""))) @@ -803,7 +833,7 @@ class MessageComposerPresenterTest { room.givenCanTriggerRoomNotification(Result.success(false)) initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) // If room is a DM, `RoomMemberSuggestion.Room` is not returned room.givenCanTriggerRoomNotification(Result.success(true)) @@ -844,7 +874,7 @@ class MessageComposerPresenterTest { initialState.eventSink(MessageComposerEvents.SuggestionReceived(Suggestion(0, 0, SuggestionType.Mention, ""))) skipItems(1) assertThat(awaitItem().memberSuggestions) - .containsExactly(MentionSuggestion.Member(bob), MentionSuggestion.Member(david)) + .containsExactly(ResolvedMentionSuggestion.Member(bob), ResolvedMentionSuggestion.Member(david)) } } @@ -862,10 +892,10 @@ class MessageComposerPresenterTest { presenter.present() }.test { val initialState = awaitFirstItem() - initialState.richTextEditorState.setHtml("Hey @bo") - initialState.eventSink(MessageComposerEvents.InsertMention(MentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) + initialState.textEditorState.setHtml("Hey @bo") + initialState.eventSink(MessageComposerEvents.InsertMention(ResolvedMentionSuggestion.Member(aRoomMember(userId = A_USER_ID_2)))) - assertThat(initialState.richTextEditorState.messageHtml) + assertThat(initialState.textEditorState.messageHtml()) .isEqualTo("Hey ${A_USER_ID_2.value}") } } @@ -892,14 +922,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on message sent val mentionUser1 = listOf(A_USER_ID.value) - initialState.richTextEditorState.mentionsState = MentionsState( + (initialState.textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser1, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.richTextEditorState.setHtml(A_MESSAGE) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.textEditorState.setHtml(A_MESSAGE) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() @@ -908,14 +938,14 @@ class MessageComposerPresenterTest { // Check intentional mentions on reply sent initialState.eventSink(MessageComposerEvents.SetMode(aReplyMode())) val mentionUser2 = listOf(A_USER_ID_2.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser2, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() assert(replyMessageLambda) @@ -926,14 +956,14 @@ class MessageComposerPresenterTest { skipItems(1) initialState.eventSink(MessageComposerEvents.SetMode(anEditMode())) val mentionUser3 = listOf(A_USER_ID_3.value) - awaitItem().richTextEditorState.mentionsState = MentionsState( + (awaitItem().textEditorState as? TextEditorState.Rich)?.richTextEditorState?.mentionsState = MentionsState( userIds = mentionUser3, roomIds = emptyList(), roomAliases = emptyList(), hasAtRoomMention = false ) - initialState.eventSink(MessageComposerEvents.SendMessage(A_MESSAGE.toMessage())) + initialState.eventSink(MessageComposerEvents.SendMessage) advanceUntilIdle() assert(editMessageLambda) @@ -949,7 +979,7 @@ class MessageComposerPresenterTest { val presenter = createPresenter(this) moleculeFlow(RecompositionMode.Immediate) { val state = presenter.present() - remember(state, state.richTextEditorState.messageHtml) { state } + remember(state, state.textEditorState.messageHtml()) { state } }.test { val initialState = awaitFirstItem() initialState.eventSink.invoke(MessageComposerEvents.SendUri(Uri.parse("content://uri"))) @@ -1007,7 +1037,8 @@ class MessageComposerPresenterTest { mediaPreProcessor: MediaPreProcessor = this.mediaPreProcessor, snackbarDispatcher: SnackbarDispatcher = this.snackbarDispatcher, permissionPresenter: PermissionsPresenter = FakePermissionsPresenter(), - permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder() + permalinkBuilder: PermalinkBuilder = FakePermalinkBuilder(), + isRichTextEditorEnabled: Boolean = true, ) = MessageComposerPresenter( coroutineScope, room, @@ -1025,7 +1056,10 @@ class MessageComposerPresenterTest { permalinkParser = FakePermalinkParser(), permalinkBuilder = permalinkBuilder, timelineController = TimelineController(room), - ) + ).apply { + isTesting = true + showTextFormatting = isRichTextEditorEnabled + } private suspend fun ReceiveTurbine.awaitFirstItem(): T { // Skip 2 item if Mentions feature is enabled, else 1 @@ -1043,7 +1077,10 @@ fun anEditMode( fun aReplyMode() = MessageComposerMode.Reply(A_USER_NAME, null, false, AN_EVENT_ID, A_MESSAGE) fun aQuoteMode() = MessageComposerMode.Quote(AN_EVENT_ID, A_MESSAGE) -private fun String.toMessage() = Message( - html = this, - markdown = this, -) +private suspend fun TextEditorState.setHtml(html: String) { + (this as? TextEditorState.Rich)?.richTextEditorState?.setHtml(html) ?: error("TextEditorState is not Rich") +} + +private fun TextEditorState.setMarkdown(markdown: String) { + (this as? TextEditorState.Markdown)?.state?.text?.update(markdown, needsDisplaying = false) ?: error("TextEditorState is not Markdown") +} diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt index 17e1c65daf..921a7331fd 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/textcomposer/TestRichTextEditorStateFactory.kt @@ -23,7 +23,7 @@ import io.element.android.wysiwyg.compose.rememberRichTextEditorState class TestRichTextEditorStateFactory : RichTextEditorStateFactory { @Composable - override fun create(): RichTextEditorState { + override fun remember(): RichTextEditorState { return rememberRichTextEditorState("", fake = true) } } diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt index 35de78f65b..6d8fb1ad9a 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/factories/event/TimelineItemContentMessageFactoryTest.kt @@ -16,7 +16,9 @@ package io.element.android.features.messages.impl.timeline.factories.event +import android.net.Uri import android.text.SpannableString +import android.text.SpannableStringBuilder import android.text.Spanned import android.text.style.URLSpan import androidx.core.text.buildSpannedString @@ -46,6 +48,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.media.ThumbnailInfo import io.element.android.libraries.matrix.api.media.VideoInfo +import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType @@ -75,6 +78,7 @@ import org.robolectric.RobolectricTestRunner import kotlin.time.Duration import kotlin.time.Duration.Companion.minutes +@Suppress("LargeClass") @RunWith(RobolectricTestRunner::class) class TimelineItemContentMessageFactoryTest { @Test @@ -641,6 +645,31 @@ class TimelineItemContentMessageFactoryTest { assertThat((result as TimelineItemEmoteContent).formattedBody).isEqualTo(SpannableString("* Bob formatted")) } + @Test + fun `a message with existing URLSpans keeps it after linkification`() = runTest { + val expectedSpanned = SpannableStringBuilder().apply { + append("Test ") + inSpans(URLSpan("https://www.example.org")) { + append("me@matrix.org") + } + } + val sut = createTimelineItemContentMessageFactory( + htmlConverterTransform = { expectedSpanned }, + permalinkParser = FakePermalinkParser { PermalinkData.FallbackLink(Uri.EMPTY) } + ) + val result = sut.create( + content = createMessageContent( + type = TextMessageType( + body = "Test [me@matrix.org](https://www.example.org)", + formatted = FormattedBody(MessageFormat.HTML, "Test me@matrix.org") + ) + ), + senderDisambiguatedDisplayName = "Bob", + eventId = AN_EVENT_ID, + ) + assertThat((result as TimelineItemTextContent).formattedBody).isEqualTo(expectedSpanned) + } + private fun createMessageContent( body: String = "Body", inReplyTo: InReplyTo? = null, @@ -660,12 +689,13 @@ class TimelineItemContentMessageFactoryTest { private fun createTimelineItemContentMessageFactory( featureFlagService: FeatureFlagService = FakeFeatureFlagService(), htmlConverterTransform: (String) -> CharSequence = { it }, + permalinkParser: FakePermalinkParser = FakePermalinkParser(), ) = TimelineItemContentMessageFactory( fileSizeFormatter = FakeFileSizeFormatter(), fileExtensionExtractor = FileExtensionExtractorWithoutValidation(), featureFlagService = featureFlagService, htmlConverterProvider = FakeHtmlConverterProvider(htmlConverterTransform), - permalinkParser = FakePermalinkParser(), + permalinkParser = permalinkParser, ) private fun createStickerContent( diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt index d73f7246c9..b103cc8e68 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToDetailTest.kt @@ -58,6 +58,7 @@ class InReplyToDetailTest { senderProfile = aProfileTimelineDetails(), content = RoomMembershipContent( userId = A_USER_ID, + userDisplayName = null, change = MembershipChange.INVITED, ) ) diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt index b56d871704..c155325444 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/timeline/model/InReplyToMetadataKtTest.kt @@ -380,7 +380,7 @@ class InReplyToMetadataKtTest { fun `room membership content`() = runTest { moleculeFlow(RecompositionMode.Immediate) { anInReplyToDetailsReady( - eventContent = RoomMembershipContent(A_USER_ID, null) + eventContent = RoomMembershipContent(A_USER_ID, null, null) ).metadata() }.test { awaitItem().let { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt index a37f2e775f..6e12756df7 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/voicemessages/timeline/DefaultVoiceMessageMediaRepoTest.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes 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.mxc.MxcTools -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -34,12 +34,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - downloads and returns cached file successfully`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { path = temporaryFolder.createRustMediaFile().path } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -53,12 +53,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - download fails`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -71,7 +71,7 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache miss - download succeeds but file move fails`() = runTest { - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { path = temporaryFolder.createRustMediaFile().path } File(temporaryFolder.cachedFilePath).apply { @@ -83,7 +83,7 @@ class DefaultVoiceMessageMediaRepoTest { } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -100,12 +100,12 @@ class DefaultVoiceMessageMediaRepoTest { @Test fun `cache hit - returns cached file successfully`() = runTest { temporaryFolder.createCachedFile() - val fakeMediaLoader = FakeMediaLoader().apply { + val matrixMediaLoader = FakeMatrixMediaLoader().apply { shouldFail = true // so that if we hit the media loader it will crash } val repo = createDefaultVoiceMessageMediaRepo( temporaryFolder = temporaryFolder, - matrixMediaLoader = fakeMediaLoader, + matrixMediaLoader = matrixMediaLoader, ) repo.getMediaFile().let { result -> @@ -135,7 +135,7 @@ class DefaultVoiceMessageMediaRepoTest { private fun createDefaultVoiceMessageMediaRepo( temporaryFolder: TemporaryFolder, - matrixMediaLoader: MatrixMediaLoader = FakeMediaLoader(), + matrixMediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), mxcUri: String = MXC_URI, ) = DefaultVoiceMessageMediaRepo( cacheDir = temporaryFolder.root, diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt index 3ea0625f76..29be8682e3 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/MigrationPresenterTest.kt @@ -37,7 +37,7 @@ class MigrationPresenterTest { @Test fun `present - no migration should occurs if ApplicationMigrationVersion is the last one`() = runTest { - val migrations = (1..10).map { FakeMigration(it) } + val migrations = (1..10).map { FakeAppMigration(it) } val store = InMemoryMigrationStore(migrations.maxOf { it.order }) val presenter = createPresenter( migrationStore = store, @@ -57,7 +57,7 @@ class MigrationPresenterTest { @Test fun `present - testing all migrations`() = runTest { val store = InMemoryMigrationStore(0) - val migrations = (1..10).map { FakeMigration(it) } + val migrations = (1..10).map { FakeAppMigration(it) } val presenter = createPresenter( migrationStore = store, migrations = migrations.toSet(), @@ -81,13 +81,13 @@ class MigrationPresenterTest { private fun createPresenter( migrationStore: MigrationStore = InMemoryMigrationStore(0), - migrations: Set = setOf(FakeMigration(1)), + migrations: Set = setOf(FakeAppMigration(1)), ) = MigrationPresenter( migrationStore = migrationStore, migrations = migrations, ) -private class FakeMigration( +private class FakeAppMigration( override val order: Int, var migrateLambda: LambdaNoParamRecorder = lambdaRecorder { -> }, ) : AppMigration { diff --git a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt index 1a077fda2e..6bb2f5babd 100644 --- a/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt +++ b/features/migration/impl/src/test/kotlin/io/element/android/features/migration/impl/migrations/AppMigration02Test.kt @@ -17,7 +17,7 @@ package io.element.android.features.migration.impl.migrations import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.preferences.test.FakeSessionPreferenceStoreFactory +import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.libraries.sessionstorage.test.aSessionData @@ -33,7 +33,7 @@ class AppMigration02Test { updateData(aSessionData()) } val sessionPreferencesStore = InMemorySessionPreferencesStore(isSessionVerificationSkipped = false) - val sessionPreferencesStoreFactory = FakeSessionPreferenceStoreFactory( + val sessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory( getLambda = lambdaRecorder { _, _, -> sessionPreferencesStore }, ) val migration = AppMigration02(sessionStore = sessionStore, sessionPreferenceStoreFactory = sessionPreferencesStoreFactory) diff --git a/features/onboarding/impl/src/main/res/values-ka/translations.xml b/features/onboarding/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..e75edfdefa --- /dev/null +++ b/features/onboarding/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,9 @@ + + + "ხელით შესვლა" + "შესვლა QR კოდით" + "ანგარიშის შექმნა" + "კეთილი იყოს თქვენი მობრძანება უსწრაფეს %1$s-ში. დამუხტულია სიჩქარისა და სიმარტივისათვის." + "კეთილი იყოს თქვენი მობრძანება %1$s-ში! დამუხტული სიჩქარისა და სიმარტივისთვის." + "იყავი შენს element-ში" + diff --git a/features/onboarding/impl/src/main/res/values-pt/translations.xml b/features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/onboarding/impl/src/main/res/values-pt/translations.xml rename to features/onboarding/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/poll/impl/src/main/res/values-ka/translations.xml b/features/poll/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..c2417983af --- /dev/null +++ b/features/poll/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,11 @@ + + + "ვარიანტის დამატება" + "შედეგების ჩვენება მხოლოდ გამოკითხვის დასრულების შემდეგ" + "ხმების დამალვა" + "ვარიანტი %1$d" + "კითხვა ან თემა" + "რას ეხება გამოკითხვა?" + "გამოკითხვის შექმნა" + "გამოკითხვის რედაქტირება" + diff --git a/features/poll/impl/src/main/res/values-pt/translations.xml b/features/poll/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/poll/impl/src/main/res/values-pt/translations.xml rename to features/poll/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index edfb275f17..44616d0566 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -57,6 +57,7 @@ dependencies { implementation(projects.libraries.mediaupload.api) implementation(projects.libraries.permissions.api) implementation(projects.libraries.push.api) + implementation(projects.libraries.pushproviders.api) implementation(projects.features.rageshake.api) implementation(projects.features.lockscreen.api) implementation(projects.features.analytics.api) @@ -90,6 +91,7 @@ dependencies { testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.impl) testImplementation(projects.libraries.indicator.impl) + testImplementation(projects.libraries.pushproviders.test) testImplementation(projects.features.logout.impl) testImplementation(projects.services.analytics.test) testImplementation(projects.services.toolbox.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index b93e02dd39..ac8a881348 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -212,9 +212,7 @@ class PreferencesFlowNode @AssistedInject constructor( createNode(buildContext, listOf(inputs)) } NavTarget.LockScreenSettings -> { - lockScreenEntryPoint.nodeBuilder(this, buildContext) - .target(LockScreenEntryPoint.Target.Settings) - .build() + lockScreenEntryPoint.nodeBuilder(this, buildContext, LockScreenEntryPoint.Target.Settings).build() } NavTarget.BlockedUsers -> { createNode(buildContext) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt index 8ee433f630..4ed4277599 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsEvents.kt @@ -19,10 +19,12 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme sealed interface AdvancedSettingsEvents { - data class SetRichTextEditorEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetDeveloperModeEnabled(val enabled: Boolean) : AdvancedSettingsEvents data class SetSharePresenceEnabled(val enabled: Boolean) : AdvancedSettingsEvents data object ChangeTheme : AdvancedSettingsEvents data object CancelChangeTheme : AdvancedSettingsEvents data class SetTheme(val theme: Theme) : AdvancedSettingsEvents + data object ChangePushProvider : AdvancedSettingsEvents + data object CancelChangePushProvider : AdvancedSettingsEvents + data class SetPushProvider(val index: Int) : AdvancedSettingsEvents } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt index 2f0c2b7417..21d84567e1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenter.kt @@ -17,8 +17,10 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope @@ -27,20 +29,26 @@ import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.preferences.api.store.SessionPreferencesStore +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import kotlinx.collections.immutable.toImmutableList +import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import javax.inject.Inject class AdvancedSettingsPresenter @Inject constructor( private val appPreferencesStore: AppPreferencesStore, private val sessionPreferencesStore: SessionPreferencesStore, + private val matrixClient: MatrixClient, + private val pushService: PushService, ) : Presenter { @Composable override fun present(): AdvancedSettingsState { val localCoroutineScope = rememberCoroutineScope() - val isRichTextEditorEnabled by appPreferencesStore - .isRichTextEditorEnabledFlow() - .collectAsState(initial = false) val isDeveloperModeEnabled by appPreferencesStore .isDeveloperModeEnabledFlow() .collectAsState(initial = false) @@ -52,11 +60,64 @@ class AdvancedSettingsPresenter @Inject constructor( } .collectAsState(initial = Theme.System) var showChangeThemeDialog by remember { mutableStateOf(false) } + + // List of PushProvider -> Distributor + val distributors = remember { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } + } + } + // List of Distributor names + val distributorNames = remember { + distributors.map { it.second.name } + } + + var currentDistributorName by remember { mutableStateOf>(AsyncAction.Uninitialized) } + var refreshPushProvider by remember { mutableIntStateOf(0) } + + LaunchedEffect(refreshPushProvider) { + val p = pushService.getCurrentPushProvider() + val name = p?.getCurrentDistributor(matrixClient)?.name + currentDistributorName = if (name != null) { + AsyncAction.Success(name) + } else { + AsyncAction.Failure(Exception("Failed to get current push provider")) + } + } + + var showChangePushProviderDialog by remember { mutableStateOf(false) } + + fun CoroutineScope.changePushProvider( + data: Pair? + ) = launch { + showChangePushProviderDialog = false + data ?: return@launch + // No op if the value is the same. + if (data.second.name == currentDistributorName.dataOrNull()) return@launch + currentDistributorName = AsyncAction.Loading + data.let { (pushProvider, distributor) -> + pushService.registerWith( + matrixClient = matrixClient, + pushProvider = pushProvider, + distributor = distributor + ) + .fold( + { + currentDistributorName = AsyncAction.Success(distributor.name) + refreshPushProvider++ + }, + { + currentDistributorName = AsyncAction.Failure(it) + } + ) + } + } + fun handleEvents(event: AdvancedSettingsEvents) { when (event) { - is AdvancedSettingsEvents.SetRichTextEditorEnabled -> localCoroutineScope.launch { - appPreferencesStore.setRichTextEditorEnabled(event.enabled) - } is AdvancedSettingsEvents.SetDeveloperModeEnabled -> localCoroutineScope.launch { appPreferencesStore.setDeveloperModeEnabled(event.enabled) } @@ -69,15 +130,20 @@ class AdvancedSettingsPresenter @Inject constructor( appPreferencesStore.setTheme(event.theme.name) showChangeThemeDialog = false } + AdvancedSettingsEvents.ChangePushProvider -> showChangePushProviderDialog = true + AdvancedSettingsEvents.CancelChangePushProvider -> showChangePushProviderDialog = false + is AdvancedSettingsEvents.SetPushProvider -> localCoroutineScope.changePushProvider(distributors.getOrNull(event.index)) } } return AdvancedSettingsState( - isRichTextEditorEnabled = isRichTextEditorEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSharePresenceEnabled, theme = theme, showChangeThemeDialog = showChangeThemeDialog, + currentPushDistributor = currentDistributorName, + availablePushDistributors = distributorNames.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, eventSink = { handleEvents(it) } ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt index 469f8a630c..23de2fda13 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsState.kt @@ -17,12 +17,16 @@ package io.element.android.features.preferences.impl.advanced import io.element.android.compound.theme.Theme +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.collections.immutable.ImmutableList data class AdvancedSettingsState( - val isRichTextEditorEnabled: Boolean, val isDeveloperModeEnabled: Boolean, val isSharePresenceEnabled: Boolean, val theme: Theme, val showChangeThemeDialog: Boolean, + val currentPushDistributor: AsyncAction, + val availablePushDistributors: ImmutableList, + val showChangePushProviderDialog: Boolean, val eventSink: (AdvancedSettingsEvents) -> Unit ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt index 5e255fc091..5e6af364f2 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsStateProvider.kt @@ -18,28 +18,37 @@ package io.element.android.features.preferences.impl.advanced import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.compound.theme.Theme +import io.element.android.libraries.architecture.AsyncAction +import kotlinx.collections.immutable.toImmutableList open class AdvancedSettingsStateProvider : PreviewParameterProvider { override val values: Sequence get() = sequenceOf( aAdvancedSettingsState(), - aAdvancedSettingsState(isRichTextEditorEnabled = true), aAdvancedSettingsState(isDeveloperModeEnabled = true), aAdvancedSettingsState(showChangeThemeDialog = true), aAdvancedSettingsState(isSendPublicReadReceiptsEnabled = true), + aAdvancedSettingsState(showChangePushProviderDialog = true), + aAdvancedSettingsState(currentPushDistributor = AsyncAction.Loading), + aAdvancedSettingsState(currentPushDistributor = AsyncAction.Failure(Exception("Failed to change distributor"))), ) } fun aAdvancedSettingsState( - isRichTextEditorEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false, isSendPublicReadReceiptsEnabled: Boolean = false, showChangeThemeDialog: Boolean = false, + currentPushDistributor: AsyncAction = AsyncAction.Success("Firebase"), + availablePushDistributors: List = listOf("Firebase", "ntfy"), + showChangePushProviderDialog: Boolean = false, + eventSink: (AdvancedSettingsEvents) -> Unit = {}, ) = AdvancedSettingsState( - isRichTextEditorEnabled = isRichTextEditorEnabled, isDeveloperModeEnabled = isDeveloperModeEnabled, isSharePresenceEnabled = isSendPublicReadReceiptsEnabled, theme = Theme.System, showChangeThemeDialog = showChangeThemeDialog, - eventSink = {} + currentPushDistributor = currentPushDistributor, + availablePushDistributors = availablePushDistributors.toImmutableList(), + showChangePushProviderDialog = showChangePushProviderDialog, + eventSink = eventSink ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt index 9e739c3a00..9b82a87bbc 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsView.kt @@ -16,19 +16,24 @@ package io.element.android.features.preferences.impl.advanced +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.progressSemantics import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter +import androidx.compose.ui.unit.dp import io.element.android.compound.theme.Theme import io.element.android.compound.theme.themes import io.element.android.features.preferences.impl.R +import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.designsystem.components.dialogs.ListOption import io.element.android.libraries.designsystem.components.dialogs.SingleSelectionDialog import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.components.preferences.PreferencePage import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.ui.strings.CommonStrings @@ -57,18 +62,6 @@ fun AdvancedSettingsView( state.eventSink(AdvancedSettingsEvents.ChangeTheme) } ) - ListItem( - headlineContent = { - Text(text = stringResource(id = CommonStrings.common_rich_text_editor)) - }, - supportingContent = { - Text(text = stringResource(id = R.string.screen_advanced_settings_rich_text_editor_description)) - }, - trailingContent = ListItemContent.Switch( - checked = state.isRichTextEditorEnabled, - ), - onClick = { state.eventSink(AdvancedSettingsEvents.SetRichTextEditorEnabled(!state.isRichTextEditorEnabled)) } - ) ListItem( headlineContent = { Text(text = stringResource(id = CommonStrings.action_view_source)) @@ -93,6 +86,34 @@ fun AdvancedSettingsView( ), onClick = { state.eventSink(AdvancedSettingsEvents.SetSharePresenceEnabled(!state.isSharePresenceEnabled)) } ) + ListItem( + headlineContent = { + Text(text = stringResource(id = R.string.screen_advanced_settings_push_provider_android)) + }, + trailingContent = when (state.currentPushDistributor) { + AsyncAction.Uninitialized, + AsyncAction.Confirming, + AsyncAction.Loading -> ListItemContent.Custom { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + is AsyncAction.Failure -> ListItemContent.Text( + stringResource(id = CommonStrings.common_error) + ) + is AsyncAction.Success -> ListItemContent.Text( + state.currentPushDistributor.dataOrNull() ?: "" + ) + }, + onClick = { + if (state.currentPushDistributor.isReady()) { + state.eventSink(AdvancedSettingsEvents.ChangePushProvider) + } + } + ) } if (state.showChangeThemeDialog) { @@ -109,6 +130,22 @@ fun AdvancedSettingsView( onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangeTheme) }, ) } + + if (state.showChangePushProviderDialog) { + SingleSelectionDialog( + title = stringResource(id = R.string.screen_advanced_settings_choose_distributor_dialog_title_android), + options = state.availablePushDistributors.map { + ListOption(title = it) + }.toImmutableList(), + initialSelection = state.availablePushDistributors.indexOf(state.currentPushDistributor.dataOrNull()), + onOptionSelected = { index -> + state.eventSink( + AdvancedSettingsEvents.SetPushProvider(index) + ) + }, + onDismissRequest = { state.eventSink(AdvancedSettingsEvents.CancelChangePushProvider) }, + ) + } } @Composable diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt index 5a40534b87..dceeb795f1 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsView.kt @@ -48,7 +48,10 @@ fun DeveloperSettingsView( title = stringResource(id = CommonStrings.common_developer_options) ) { // Note: this is OK to hardcode strings in this debug screen. - PreferenceCategory(title = "Feature flags") { + PreferenceCategory( + title = "Feature flags", + showTopDivider = false, + ) { FeatureListContent(state) } ElementCallCategory(state = state) @@ -67,14 +70,14 @@ fun DeveloperSettingsView( RageshakePreferencesView( state = state.rageshakeState, ) - PreferenceCategory(title = "Crash", showDivider = false) { + PreferenceCategory(title = "Crash", showTopDivider = false) { PreferenceText( title = "Crash the app 💥", onClick = { error("This crash is a test.") } ) } val cache = state.cacheSize - PreferenceCategory(title = "Cache", showDivider = false) { + PreferenceCategory(title = "Cache", showTopDivider = false) { PreferenceText( title = "Clear cache", currentValue = cache.dataOrNull(), @@ -93,11 +96,12 @@ fun DeveloperSettingsView( private fun ElementCallCategory( state: DeveloperSettingsState, ) { - PreferenceCategory(title = "Element Call", showDivider = true) { + PreferenceCategory(title = "Element Call", showTopDivider = true) { val callUrlState = state.customElementCallBaseUrlState fun isUsingDefaultUrl(value: String?): Boolean { return value.isNullOrEmpty() || value == callUrlState.defaultUrl } + val supportingText = if (isUsingDefaultUrl(callUrlState.baseUrl)) { stringResource(R.string.screen_advanced_settings_element_call_base_url_description) } else { diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt index d62f972d71..7dda9b684b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsView.kt @@ -17,7 +17,6 @@ package io.element.android.features.preferences.impl.notifications import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.res.stringResource @@ -121,7 +120,6 @@ private fun NotificationSettingsContentView( PreferenceSwitch( title = stringResource(id = R.string.screen_notification_settings_enable_notifications), isChecked = systemSettings.appNotificationsEnabled, - switchAlignment = Alignment.Top, onCheckedChange = onNotificationsEnabledChanged ) @@ -145,7 +143,6 @@ private fun NotificationSettingsContentView( modifier = Modifier, title = stringResource(id = R.string.screen_notification_settings_room_mention_label), isChecked = matrixSettings.atRoomNotificationsEnabled, - switchAlignment = Alignment.Top, onCheckedChange = onMentionNotificationsChanged ) } @@ -162,7 +159,6 @@ private fun NotificationSettingsContentView( modifier = Modifier, title = stringResource(id = R.string.screen_notification_settings_invite_for_me_label), isChecked = matrixSettings.inviteForMeNotificationsEnabled, - switchAlignment = Alignment.Top, onCheckedChange = onInviteForMeNotificationsChanged ) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt index 1938a4b4f0..b3ac493b0e 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/edit/EditDefaultNotificationSettingView.kt @@ -68,7 +68,10 @@ fun EditDefaultNotificationSettingView( } else { R.string.screen_notification_settings_edit_screen_group_section_header } - PreferenceCategory(title = stringResource(id = categoryTitle)) { + PreferenceCategory( + title = stringResource(id = categoryTitle), + showTopDivider = false, + ) { if (state.mode != null) { Column(modifier = Modifier.selectableGroup()) { validModes.forEach { item -> diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 20c28427a8..86992d4ac7 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -74,7 +74,7 @@ class PreferencesRootPresenter @Inject constructor( } // We should display the 'complete verification' option if the current session can be verified - val canVerifyUserSession by sessionVerificationService.canVerifySessionFlow.collectAsState(false) + val canVerifyUserSession by sessionVerificationService.needsSessionVerification.collectAsState(false) val showSecureBackupIndicator by indicatorService.showSettingChatBackupIndicator() diff --git a/features/preferences/impl/src/main/res/values-be/translations.xml b/features/preferences/impl/src/main/res/values-be/translations.xml index 450a47ce61..33d1b2706e 100644 --- a/features/preferences/impl/src/main/res/values-be/translations.xml +++ b/features/preferences/impl/src/main/res/values-be/translations.xml @@ -1,10 +1,12 @@ + "Выберыце спосаб атрымання апавяшчэнняў" "Рэжым распрацоўшчыка" "Падайце распрацоўнікам доступ да функцый і функцыянальным магчымасцям." "Базавы URL сервера званкоў Element" "Задайце свой сервер Element Call." "Адрас пазначаны няправільна, пераканайцеся, што вы ўказалі пратакол (http/https) і правільны адрас." + "Пастаўшчык push-апавяшчэнняў" "Адключыць рэдактар фарматаванага тэксту і ўключыць Markdown." "Апавяшчэнні аб чытанні" "Калі выключыць, вашы пасведчанні аб прачытанні нікому не будуць адпраўляцца. Вы па-ранейшаму будзеце атрымліваць пасведчанні аб прачытанні ад іншых карыстальнікаў." diff --git a/features/preferences/impl/src/main/res/values-cs/translations.xml b/features/preferences/impl/src/main/res/values-cs/translations.xml index 60bc4b2b3e..587623d4e5 100644 --- a/features/preferences/impl/src/main/res/values-cs/translations.xml +++ b/features/preferences/impl/src/main/res/values-cs/translations.xml @@ -1,10 +1,12 @@ + "Vyberte, jak chcete přijímat oznámení" "Vývojářský režim" "Povolením získáte přístup k funkcím a funkcím pro vývojáře." "Vlastní URL pro Element Call" "Nastavte vlastní URL pro Element Call." "Neplatné URL, ujistěte se, že jste uvedli protokol (http/https) a správnou adresu." + "Poskytovatel push oznámení" "Vypněte editor formátovaného textu pro ruční zadání Markdown." "Potvrzení o přečtení" "Pokud je vypnuto, potvrzení o přečtení se nikomu neodesílají. Stále budete dostávat potvrzení o přečtení od ostatních uživatelů." diff --git a/features/preferences/impl/src/main/res/values-de/translations.xml b/features/preferences/impl/src/main/res/values-de/translations.xml index 1faac8c4eb..3013315b21 100644 --- a/features/preferences/impl/src/main/res/values-de/translations.xml +++ b/features/preferences/impl/src/main/res/values-de/translations.xml @@ -1,5 +1,6 @@ + "Wähle aus, wie du Benachrichtigungen erhalten möchtest" "Entwickler-Modus" "Aktivieren, um Zugriff auf Features und Funktionen für Entwickler zu aktivieren." "Benutzerdefinierte Element-Aufruf-Basis-URL" diff --git a/features/preferences/impl/src/main/res/values-es/translations.xml b/features/preferences/impl/src/main/res/values-es/translations.xml index 3b306ee6f6..db20929504 100644 --- a/features/preferences/impl/src/main/res/values-es/translations.xml +++ b/features/preferences/impl/src/main/res/values-es/translations.xml @@ -1,5 +1,6 @@ + "Elige cómo recibir las notificaciones" "Modo desarrollador" "Habilita para tener acceso a características y funcionalidades para desarrolladores." "URL base personalizada de Element Call" diff --git a/features/preferences/impl/src/main/res/values-fr/translations.xml b/features/preferences/impl/src/main/res/values-fr/translations.xml index 9c415fea2d..ac12390e76 100644 --- a/features/preferences/impl/src/main/res/values-fr/translations.xml +++ b/features/preferences/impl/src/main/res/values-fr/translations.xml @@ -1,10 +1,12 @@ + "Choisissez le mode de réception des notifications" "Mode développeur" "Activer pour pouvoir accéder aux fonctionnalités destinées aux développeurs." "URL de base pour Element Call personnalisée" "Configurer une URL de base pour Element Call." "URL invalide, assurez-vous d’inclure le protocol (http/https) et l’adresse correcte." + "Fournisseur de Push" "Désactivez l’éditeur de texte enrichi pour saisir manuellement du Markdown." "Accusés de lecture" "En cas de désactivation, vos accusés de lecture ne seront pas envoyés aux autres membres. Vous verrez toujours les accusés des autres membres." diff --git a/features/preferences/impl/src/main/res/values-hu/translations.xml b/features/preferences/impl/src/main/res/values-hu/translations.xml index 841695edcf..964e2dc58e 100644 --- a/features/preferences/impl/src/main/res/values-hu/translations.xml +++ b/features/preferences/impl/src/main/res/values-hu/translations.xml @@ -1,5 +1,6 @@ + "Válassza ki az értesítések fogadási módját" "Fejlesztői mód" "Engedélyezze, hogy elérje a fejlesztőknek szánt funkciókat." "Egyéni Element Call alapwebcím" diff --git a/features/preferences/impl/src/main/res/values-in/translations.xml b/features/preferences/impl/src/main/res/values-in/translations.xml index 8bca603eeb..2068782846 100644 --- a/features/preferences/impl/src/main/res/values-in/translations.xml +++ b/features/preferences/impl/src/main/res/values-in/translations.xml @@ -1,5 +1,6 @@ + "Pilih cara menerima notifikasi" "Mode pengembang" "Aktifkan untuk mengakses fitur dan fungsi untuk para pengembang." "URL dasar Element Call khusus" diff --git a/features/preferences/impl/src/main/res/values-it/translations.xml b/features/preferences/impl/src/main/res/values-it/translations.xml index e15213218c..b8a483d290 100644 --- a/features/preferences/impl/src/main/res/values-it/translations.xml +++ b/features/preferences/impl/src/main/res/values-it/translations.xml @@ -1,5 +1,6 @@ + "Scegli come ricevere le notifiche" "Modalità sviluppatore" "Attiva per avere accesso alle funzionalità per sviluppatori." "URL base di Element Call personalizzato" diff --git a/features/preferences/impl/src/main/res/values-ka/translations.xml b/features/preferences/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..f27eac6d40 --- /dev/null +++ b/features/preferences/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,44 @@ + + + "აირჩიეთ, თუ როგორ გსურთ შეტყობინებების მიღება" + "დეველოპერის რეჟიმი" + "ჩართეთ დეველოპერების ფუნქციებზე წვდომა." + "მორგებული Element-ის ზარის საბაზისო URL" + "დააყენეთ საბაზისო URL Element-ის ზარებისათვის." + "არასწორი URL, გთხოვთ, დარწმუნდეთ, რომ შეიტანეთ პროტოკოლი (http/https) და სწორი მისამართი." + "გამორთეთ მდიდარი ტექსტის რედაქტორი, რათა ხელით აკრიფოთ Markdown." + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "ნაჩვენები სახელი" + "თქვენი ნაჩვენები სახელი" + "დაფიქსირდა უცნობი შეცდომა და ინფორმაციის შეცვლა ვერ მოხერხდა." + "პროფილის განახლება ვერ მოხერხდა" + "Პროფილის რედაქტირება" + "პროფილის განახლება…" + "დამატებითი პარამეტრები" + "აუდიო და ვიდეო ზარები" + "კონფიგურაციის შეუსაბამობა" + "ჩვენ გავამარტივეთ შეტყობინებების პარამეტრები, რათა გაგიადვილოთ ვარიანტების პოვნა. + +თქვენ მიერ წარსულში არჩეული ზოგიერთი მორგებული პარამეტრი აქ არ არის ნაჩვენები, მაგრამ ისინი კვლავ აქტიურია. თუ გააგრძელებთ, თქვენი ზოგიერთი პარამეტრი შეიძლება შეიცვალოს." + "პირდაპირი ჩატები" + "მორგებული პარამეტრი ჩატზე" + "შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა." + "ყველა შეტყობინება" + "მხოლოდ ხსენებები და საკვანძო სიტყვები" + "პირდაპირ ჩატებზე, შემატყობინეთ:" + "ჯგუფურ ჩატებზე, შემატყობინეთ:" + "შეტყობინებების ჩართვა ამ მოწყობილობაზე" + "კონფიგურაცია არ გამოსწორებულა, გთხოვთ, კვლავ სცადოთ." + "ჯგუფური ჩატები" + "ხსენებები" + "ყველა" + "ხსენებები" + "ჩემი შეტყობინება შემდეგისთვის:" + "ჩემი შეტყობინება @room-ზე" + "შეტყობინებების მისაღებად გთხოვთ შეცვალოთ %1$s." + "სისტემის პარამეტრები" + "სისტემის შეტყობინებები გამორთულია" + "შეტყობინებები" + diff --git a/features/preferences/impl/src/main/res/values-pt/translations.xml b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml similarity index 96% rename from features/preferences/impl/src/main/res/values-pt/translations.xml rename to features/preferences/impl/src/main/res/values-pt-rBR/translations.xml index 25b8cad01a..5986f8e93a 100644 --- a/features/preferences/impl/src/main/res/values-pt/translations.xml +++ b/features/preferences/impl/src/main/res/values-pt-rBR/translations.xml @@ -1,10 +1,12 @@ + "Escolhe como receber notificações" "Modo de programador" "Permite o acesso a funcionalidades para programadores." "URL base para Element Call personalizado" "Define um URL base para a Element Call." "URL inválido, certifica-te de que incluis o protocolo (http/https) e o endereço correto." + "Fornecedor de envio" "Desativa o editor de texto rico para poderes escrever Markdown manualmente." "Recibos de leitura" "Se desativada, os teus recibos de leitura não serão enviados a ninguém. Continuas a receber recibos de leitura de outros utilizadores." diff --git a/features/preferences/impl/src/main/res/values-ro/translations.xml b/features/preferences/impl/src/main/res/values-ro/translations.xml index e9d26e8578..b191002a32 100644 --- a/features/preferences/impl/src/main/res/values-ro/translations.xml +++ b/features/preferences/impl/src/main/res/values-ro/translations.xml @@ -1,5 +1,6 @@ + "Alegeți modul de primire a notificărilor" "Modul dezvoltator" "Activați pentru a avea acces la funcționalități pentru dezvoltatori." "Adresa URL de bază Element Call" diff --git a/features/preferences/impl/src/main/res/values-ru/translations.xml b/features/preferences/impl/src/main/res/values-ru/translations.xml index 3b24cf8c80..3c4a49dd6d 100644 --- a/features/preferences/impl/src/main/res/values-ru/translations.xml +++ b/features/preferences/impl/src/main/res/values-ru/translations.xml @@ -1,10 +1,12 @@ + "Выберите способ получения уведомлений" "Режим разработчика" "Предоставьте разработчикам доступ к функциям и функциональным возможностям." "Базовый URL сервера звонков Element" "Задайте свой сервер Element Call." "Адрес указан неверно, удостоверьтесь, что вы указали протокол (http/https) и правильный адрес." + "Поставщик push-уведомлений" "Отключить редактор форматированного текста и включить Markdown." "Уведомления о прочтении" "Если этот параметр выключен, ваш статус о прочтении не будет отображаться. Вы по-прежнему будете видеть статус о прочтении от других пользователей." diff --git a/features/preferences/impl/src/main/res/values-sk/translations.xml b/features/preferences/impl/src/main/res/values-sk/translations.xml index f382d0ab6f..edabc77ec5 100644 --- a/features/preferences/impl/src/main/res/values-sk/translations.xml +++ b/features/preferences/impl/src/main/res/values-sk/translations.xml @@ -1,10 +1,12 @@ + "Vyberte spôsob prijímania oznámení" "Vývojársky režim" "Umožniť prístup k možnostiam a funkciám pre vývojárov." "Vlastná Element Call základná URL adresa" "Nastaviť vlastnú základnú URL adresu pre Element Call." "Neplatná adresa URL, uistite sa, že ste uviedli protokol (http/https) a správnu adresu." + "Poskytovateľ oznámení Push" "Vypnite rozšírený textový editor na ručné písanie Markdown." "Potvrdenia o prečítaní" "Ak je táto funkcia vypnutá, vaše potvrdenia o prečítaní sa nebudú nikomu odosielať. Stále budete dostávať potvrdenia o prečítaní od ostatných používateľov." diff --git a/features/preferences/impl/src/main/res/values-sv/translations.xml b/features/preferences/impl/src/main/res/values-sv/translations.xml index adcb60f9d5..bf91212cab 100644 --- a/features/preferences/impl/src/main/res/values-sv/translations.xml +++ b/features/preferences/impl/src/main/res/values-sv/translations.xml @@ -1,5 +1,6 @@ + "Välj hur du vill ta emot aviseringar" "Utvecklarläge" "Aktivera för att ha tillgång till funktionalitet för utvecklare." "Anpassad bas-URL för Element Call" @@ -14,6 +15,7 @@ "Avblockera" "Du kommer att kunna se alla meddelanden från dem igen." "Avblockera användare" + "Avblockerar …" "Visningsnamn" "Ditt visningsnamn" "Ett okänt fel påträffades och informationen kunde inte ändras." diff --git a/features/preferences/impl/src/main/res/values-uk/translations.xml b/features/preferences/impl/src/main/res/values-uk/translations.xml index 7ab2f12f50..0fdf2a67ad 100644 --- a/features/preferences/impl/src/main/res/values-uk/translations.xml +++ b/features/preferences/impl/src/main/res/values-uk/translations.xml @@ -1,5 +1,6 @@ + "Виберіть спосіб отримання сповіщень" "Режим розробника" "Увімкніть доступ до функцій і можливостей для розробників." "Користувацька URL-адреса Element Call" diff --git a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml index 68ea61f94e..000be710d7 100644 --- a/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh-rTW/translations.xml @@ -1,5 +1,6 @@ + "選擇接收通知的機制" "開發者模式" "分享動態" "解除封鎖" diff --git a/features/preferences/impl/src/main/res/values-zh/translations.xml b/features/preferences/impl/src/main/res/values-zh/translations.xml index fe496fbb2e..5a4485ed14 100644 --- a/features/preferences/impl/src/main/res/values-zh/translations.xml +++ b/features/preferences/impl/src/main/res/values-zh/translations.xml @@ -1,5 +1,6 @@ + "选择如何接收通知" "开发者模式" "允许开发人员访问特性和功能。" "自定义 Element 通话 URL" diff --git a/features/preferences/impl/src/main/res/values/localazy.xml b/features/preferences/impl/src/main/res/values/localazy.xml index 56a5c0ba03..d9b6b583ed 100644 --- a/features/preferences/impl/src/main/res/values/localazy.xml +++ b/features/preferences/impl/src/main/res/values/localazy.xml @@ -1,10 +1,12 @@ + "Choose how to receive notifications" "Developer mode" "Enable to have access to features and functionality for developers." "Custom Element Call base URL" "Set a custom base URL for Element Call." "Invalid URL, please make sure you include the protocol (http/https) and the correct address." + "Push notification provider" "Disable the rich text editor to type Markdown manually." "Read receipts" "If turned off, your read receipts won\'t be sent to anyone. You will still receive read receipts from other users." diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt index 8f6c45c51d..7e4175d3bd 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsPresenterTest.kt @@ -21,8 +21,16 @@ import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.compound.theme.Theme +import io.element.android.libraries.architecture.AsyncAction +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore +import io.element.android.libraries.push.api.PushService +import io.element.android.libraries.push.test.FakePushService +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.awaitLastSequentialItem import kotlinx.coroutines.test.runTest @@ -41,7 +49,6 @@ class AdvancedSettingsPresenterTest { }.test { val initialState = awaitLastSequentialItem() assertThat(initialState.isDeveloperModeEnabled).isFalse() - assertThat(initialState.isRichTextEditorEnabled).isFalse() assertThat(initialState.showChangeThemeDialog).isFalse() assertThat(initialState.isSharePresenceEnabled).isTrue() assertThat(initialState.theme).isEqualTo(Theme.System) @@ -63,21 +70,6 @@ class AdvancedSettingsPresenterTest { } } - @Test - fun `present - rich text editor on off`() = runTest { - val presenter = createAdvancedSettingsPresenter() - moleculeFlow(RecompositionMode.Immediate) { - presenter.present() - }.test { - val initialState = awaitLastSequentialItem() - assertThat(initialState.isRichTextEditorEnabled).isFalse() - initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(true)) - assertThat(awaitItem().isRichTextEditorEnabled).isTrue() - initialState.eventSink.invoke(AdvancedSettingsEvents.SetRichTextEditorEnabled(false)) - assertThat(awaitItem().isRichTextEditorEnabled).isFalse() - } - } - @Test fun `present - share presence off on`() = runTest { val presenter = createAdvancedSettingsPresenter() @@ -116,11 +108,93 @@ class AdvancedSettingsPresenterTest { } } + @Test + fun `present - change push provider`() = runTest { + val presenter = createAdvancedSettingsPresenter( + pushService = createFakePushService(), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + assertThat(initialState.currentPushDistributor).isEqualTo(AsyncAction.Success("aDistributorName0")) + assertThat(initialState.availablePushDistributors).containsExactly("aDistributorName0", "aDistributorName1") + initialState.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + // Cancel + withDialog.eventSink(AdvancedSettingsEvents.CancelChangePushProvider) + val withoutDialog = awaitItem() + assertThat(withoutDialog.showChangePushProviderDialog).isFalse() + withDialog.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) + assertThat(awaitItem().showChangePushProviderDialog).isTrue() + withDialog.eventSink(AdvancedSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isEqualTo(AsyncAction.Loading) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isEqualTo(AsyncAction.Success("aDistributorName1")) + cancelAndIgnoreRemainingEvents() + } + } + + @Test + fun `present - change push provider error`() = runTest { + val presenter = createAdvancedSettingsPresenter( + pushService = createFakePushService( + registerWithLambda = { _, _, _ -> + Result.failure(Exception("An error")) + }, + ), + ) + moleculeFlow(RecompositionMode.Immediate) { + presenter.present() + }.test { + val initialState = awaitLastSequentialItem() + initialState.eventSink.invoke(AdvancedSettingsEvents.ChangePushProvider) + val withDialog = awaitItem() + assertThat(withDialog.showChangePushProviderDialog).isTrue() + withDialog.eventSink(AdvancedSettingsEvents.SetPushProvider(1)) + val withNewProvider = awaitItem() + assertThat(withNewProvider.showChangePushProviderDialog).isFalse() + assertThat(withNewProvider.currentPushDistributor).isEqualTo(AsyncAction.Loading) + val lastItem = awaitItem() + assertThat(lastItem.currentPushDistributor).isInstanceOf(AsyncAction.Failure::class.java) + } + } + + private fun createFakePushService( + registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + } + ): PushService { + val pushProvider1 = FakePushProvider( + index = 0, + name = "aFakePushProvider0", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue0", "aDistributorName0")), + ) + val pushProvider2 = FakePushProvider( + index = 1, + name = "aFakePushProvider1", + isAvailable = true, + distributors = listOf(Distributor("aDistributorValue1", "aDistributorName1")), + ) + return FakePushService( + availablePushProviders = listOf(pushProvider1, pushProvider2), + registerWithLambda = registerWithLambda, + ) + } + private fun createAdvancedSettingsPresenter( appPreferencesStore: InMemoryAppPreferencesStore = InMemoryAppPreferencesStore(), sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(), + matrixClient: MatrixClient = FakeMatrixClient(), + pushService: PushService = FakePushService(), ) = AdvancedSettingsPresenter( appPreferencesStore = appPreferencesStore, sessionPreferencesStore = sessionPreferencesStore, + matrixClient = matrixClient, + pushService = pushService, ) } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt new file mode 100644 index 0000000000..ee8c900579 --- /dev/null +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/advanced/AdvancedSettingsViewTest.kt @@ -0,0 +1,145 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.features.preferences.impl.advanced + +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.compose.ui.test.onNodeWithText +import androidx.compose.ui.test.performClick +import androidx.test.ext.junit.runners.AndroidJUnit4 +import io.element.android.compound.theme.Theme +import io.element.android.features.preferences.impl.R +import io.element.android.libraries.ui.strings.CommonStrings +import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder +import io.element.android.tests.testutils.clickOn +import io.element.android.tests.testutils.ensureCalledOnce +import io.element.android.tests.testutils.pressBack +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith +import org.robolectric.annotation.Config + +@RunWith(AndroidJUnit4::class) +class AdvancedSettingsViewTest { + @get:Rule + val rule = createAndroidComposeRule() + + @Test + fun `clicking on back invokes the expected callback`() { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnce { + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + onBackPressed = it + ) + rule.pressBack() + } + } + + @Test + fun `clicking on Appearance emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(CommonStrings.common_appearance) + eventsRecorder.assertSingle(AdvancedSettingsEvents.ChangeTheme) + } + + @Test + fun `clicking on other theme emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + showChangeThemeDialog = true + ), + ) + rule.clickOn(CommonStrings.common_dark) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetTheme(Theme.Dark)) + } + + @Test + fun `clicking on View source emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(CommonStrings.action_view_source) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetDeveloperModeEnabled(true)) + } + + @Test + fun `clicking on Share presence emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + ), + ) + rule.clickOn(R.string.screen_advanced_settings_share_presence) + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetSharePresenceEnabled(true)) + } + + @Config(qualifiers = "h1024dp") + @Test + fun `clicking on Push notification provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder + ), + ) + rule.clickOn(R.string.screen_advanced_settings_push_provider_android) + eventsRecorder.assertSingle(AdvancedSettingsEvents.ChangePushProvider) + } + + @Test + fun `clicking on a push provider emits the expected event`() { + val eventsRecorder = EventsRecorder() + rule.setAdvancedSettingsView( + state = aAdvancedSettingsState( + eventSink = eventsRecorder, + showChangePushProviderDialog = true, + availablePushDistributors = listOf("P1", "P2") + ), + ) + rule.onNodeWithText("P2").performClick() + eventsRecorder.assertSingle(AdvancedSettingsEvents.SetPushProvider(1)) + } +} + +private fun AndroidComposeTestRule.setAdvancedSettingsView( + state: AdvancedSettingsState, + onBackPressed: () -> Unit = EnsureNeverCalled(), +) { + setContent { + AdvancedSettingsView( + state = state, + onBackPressed = onBackPressed, + ) + } +} diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt index ea120a27c1..e41d25a412 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/developer/DeveloperSettingsViewTest.kt @@ -82,6 +82,7 @@ class DeveloperSettingsViewTest { } } + @Config(qualifiers = "h1024dp") @Test fun `clicking on configure tracing invokes the expected callback`() { val eventsRecorder = EventsRecorder(expectEvents = false) @@ -96,7 +97,7 @@ class DeveloperSettingsViewTest { } } - @Config(qualifiers = "h1024dp") + @Config(qualifiers = "h1500dp") @Test fun `clicking on clear cache emits the expected event`() { val eventsRecorder = EventsRecorder() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt index e058d4691b..a6e9c63208 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTests.kt @@ -19,12 +19,12 @@ package io.element.android.features.preferences.impl.notifications import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test -import com.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.room.RoomNotificationMode import io.element.android.libraries.matrix.test.A_THROWABLE import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory import io.element.android.tests.testutils.consumeItemsUntilPredicate import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/features/rageshake/api/src/main/res/values-ka/translations.xml b/features/rageshake/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..64c22d0763 --- /dev/null +++ b/features/rageshake/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?" + "როგორც ჩანს, იმედგაცრუებით ტელეფონს აჯანჯღალებთ. გსურთ, გახსნათ შეცდომის დარეპორტების ეკრანი?" + "Rageshake" + "გამოვლენის ზღვარი" + diff --git a/features/rageshake/api/src/main/res/values-pt/translations.xml b/features/rageshake/api/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/rageshake/api/src/main/res/values-pt/translations.xml rename to features/rageshake/api/src/main/res/values-pt-rBR/translations.xml diff --git a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt index 295fa17563..ddde3bd420 100755 --- a/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt +++ b/features/rageshake/impl/src/main/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporter.kt @@ -36,7 +36,9 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.SingleIn +import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.SdkMetadata +import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.network.useragent.UserAgentProvider import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.coroutines.CancellationException @@ -79,6 +81,7 @@ class DefaultBugReporter @Inject constructor( private val buildMeta: BuildMeta, private val bugReporterUrlProvider: BugReporterUrlProvider, private val sdkMetadata: SdkMetadata, + private val matrixClientProvider: MatrixClientProvider, ) : BugReporter { companion object { // filenames @@ -145,7 +148,7 @@ class DefaultBugReporter @Inject constructor( val sessionData = sessionStore.getLatestSession() val deviceId = sessionData?.deviceId ?: "undefined" - val userId = sessionData?.userId ?: "undefined" + val userId = sessionData?.userId?.let { UserId(it) } if (!isCancelled) { // build the multi part request @@ -153,9 +156,20 @@ class DefaultBugReporter @Inject constructor( .addFormDataPart("text", bugDescription) .addFormDataPart("app", context.getString(R.string.bug_report_app_name)) .addFormDataPart("user_agent", userAgentProvider.provide()) - .addFormDataPart("user_id", userId) + .addFormDataPart("user_id", userId?.toString() ?: "undefined") .addFormDataPart("can_contact", canContact.toString()) .addFormDataPart("device_id", deviceId) + .apply { + userId?.let { + matrixClientProvider.getOrNull(it)?.let { client -> + val curveKey = client.encryptionService().deviceCurve25519() + val edKey = client.encryptionService().deviceEd25519() + if (curveKey != null && edKey != null) { + addFormDataPart("device_keys", "curve25519:$curveKey, ed25519:$edKey") + } + } + } + } .addFormDataPart("device", Build.MODEL.trim()) .addFormDataPart("locale", Locale.getDefault().toString()) .addFormDataPart("sdk_sha", sdkMetadata.sdkGitSha) diff --git a/features/rageshake/impl/src/main/res/values-ka/translations.xml b/features/rageshake/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..6f0afb86b1 --- /dev/null +++ b/features/rageshake/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,15 @@ + + + "ეკრანის ანაბეჭდის დართვა" + "შეგიძლიათ, დამიკავშირდეთ თუ გაქვთ შემდგომი კითხვები." + "დამიკავშირდით" + "ეკრანის ანაბეჭდის რედაქტირება" + "გთხოვთ, აღწეროთ პრობლემა. რა გააკეთე? რა შედეგს ელოდებოდით? რა მოხდა სინამდვილეში? გთხოვთ, ყველაფერი დაწვრილებით თქვათ." + "აღწერეთ პრობლემა…" + "თუ შესაძლებელია, გთხოვთ, დაწეროთ აღწერა ინგლისურ ენაზე." + "გაუმართაობის ჟურნალის გაგზავნა" + "ჟურნალების დაშვება" + "ეკრანის ანაბეჭდის გაგზავნა" + "ჟურნალები თქვენს შეტყობინებაში შევა იმაში დასარწმუნებლად, რომ ყველაფერი სწორად მუშაობს. ჟურნალების გარეშე გასაგზავნად გათიშეთ ეს პარამეტრი." + "%1$s ავარიულად გაითიშა ბოლოს გამოიყენებისას. გსურთ, გამოგვიგზავნოთ ავარიული გათიშვის ჟურნალი?" + diff --git a/features/rageshake/impl/src/main/res/values-pt/translations.xml b/features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/rageshake/impl/src/main/res/values-pt/translations.xml rename to features/rageshake/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt index e0c033afc7..9b07b1c942 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/BugReportPresenterTest.kt @@ -136,7 +136,7 @@ class BugReportPresenterTest { initialState.eventSink.invoke(BugReportEvents.ResetAll) val resetState = awaitItem() assertThat(resetState.hasCrashLogs).isFalse() - logFilesRemoverLambda.assertions().isCalledExactly(1) + logFilesRemoverLambda.assertions().isCalledOnce() // TODO Make it live assertThat(resetState.screenshotUri).isNull() } } @@ -144,7 +144,7 @@ class BugReportPresenterTest { @Test fun `present - send success`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Success), + FakeBugReporter(mode = FakeBugReporter.Mode.Success), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) @@ -170,7 +170,7 @@ class BugReportPresenterTest { @Test fun `present - send failure`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Failure), + FakeBugReporter(mode = FakeBugReporter.Mode.Failure), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) @@ -219,7 +219,7 @@ class BugReportPresenterTest { @Test fun `present - send cancel`() = runTest { val presenter = createPresenter( - FakeBugReporter(mode = FakeBugReporterMode.Cancel), + FakeBugReporter(mode = FakeBugReporter.Mode.Cancel), FakeCrashDataStore(crashData = A_CRASH_DATA, appHasCrashed = true), FakeScreenshotHolder(screenshotUri = A_SCREENSHOT_URI), ) diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt index cce2d5d144..a4d4f83da9 100644 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/bugreport/FakeBugReporter.kt @@ -22,7 +22,13 @@ import io.element.android.libraries.matrix.test.A_FAILURE_REASON import kotlinx.coroutines.delay import java.io.File -class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Success) : BugReporter { +class FakeBugReporter(val mode: Mode = Mode.Success) : BugReporter { + enum class Mode { + Success, + Failure, + Cancel + } + override suspend fun sendBugReport( withDevicesLogs: Boolean, withCrashLogs: Boolean, @@ -37,12 +43,12 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes listener?.onProgress(50) delay(100) when (mode) { - FakeBugReporterMode.Success -> Unit - FakeBugReporterMode.Failure -> { + Mode.Success -> Unit + Mode.Failure -> { listener?.onUploadFailed(A_FAILURE_REASON) return } - FakeBugReporterMode.Cancel -> { + Mode.Cancel -> { listener?.onUploadCancelled() return } @@ -64,9 +70,3 @@ class FakeBugReporter(val mode: FakeBugReporterMode = FakeBugReporterMode.Succes // No op } } - -enum class FakeBugReporterMode { - Success, - Failure, - Cancel -} diff --git a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt index f2c8d0d0a8..cb28c973dc 100755 --- a/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt +++ b/features/rageshake/impl/src/test/kotlin/io/element/android/features/rageshake/impl/reporter/DefaultBugReporterTest.kt @@ -20,16 +20,25 @@ import com.google.common.truth.Truth.assertThat import io.element.android.features.rageshake.api.reporter.BugReporterListener import io.element.android.features.rageshake.test.crash.FakeCrashDataStore import io.element.android.features.rageshake.test.screenshot.FakeScreenshotHolder +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.FakeMatrixClientProvider import io.element.android.libraries.matrix.test.FakeSdkMetadata import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.libraries.network.useragent.DefaultUserAgentProvider +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runTest +import okhttp3.MultipartReader import okhttp3.OkHttpClient import okhttp3.mockwebserver.MockResponse import okhttp3.mockwebserver.MockWebServer +import okhttp3.mockwebserver.RecordedRequest +import okio.buffer +import okio.source import org.junit.Test import org.junit.runner.RunWith import org.robolectric.RobolectricTestRunner @@ -84,6 +93,202 @@ class DefaultBugReporterTest { assertThat(onUploadSucceedCalled).isTrue() } + @Test + fun `test sendBugReport form data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore().apply { + storeData(mockSessionData("@foo:eample.com", "ABCDEFGH")) + } + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys("CURVECURVECURVE", "EDKEYEDKEYEDKY") + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore(), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = mockSessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + val progressValues = mutableListOf() + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + theBugDescription = "a bug occurred", + canContact = true, + listener = object : BugReporterListener { + override fun onUploadCancelled() {} + + override fun onUploadFailed(reason: String?) {} + + override fun onProgress(progress: Int) { + progressValues.add(progress) + } + + override fun onUploadSucceed() {} + }, + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + + assertThat(foundValues["app"]).isEqualTo("element-x-android") + assertThat(foundValues["can_contact"]).isEqualTo("true") + assertThat(foundValues["device_id"]).isEqualTo("ABCDEFGH") + assertThat(foundValues["sdk_sha"]).isEqualTo("123456789") + assertThat(foundValues["user_id"]).isEqualTo("@foo:eample.com") + assertThat(foundValues["text"]).isEqualTo("a bug occurred") + assertThat(foundValues["device_keys"]).isEqualTo("curve25519:CURVECURVECURVE, ed25519:EDKEYEDKEYEDKY") + + // device_key now added given they are not null + assertThat(progressValues.size).isEqualTo(EXPECTED_NUMBER_OF_PROGRESS_VALUE + 1) + + server.shutdown() + } + + @Test + fun `test sendBugReport should not report device_keys if not known`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val mockSessionStore = InMemorySessionStore().apply { + storeData(mockSessionData("@foo:eample.com", "ABCDEFGH")) + } + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + val matrixClient = FakeMatrixClient(encryptionService = fakeEncryptionService) + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore(), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = mockSessionStore, + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.success(matrixClient) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + theBugDescription = "a bug occurred", + canContact = true, + listener = null + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + assertThat(foundValues["device_keys"]).isNull() + server.shutdown() + } + + @Test + fun `test sendBugReport no client provider no session data`() = runTest { + val server = MockWebServer() + server.enqueue( + MockResponse() + .setResponseCode(200) + ) + server.start() + + val buildMeta = aBuildMeta() + val fakeEncryptionService = FakeEncryptionService() + + fakeEncryptionService.givenDeviceKeys(null, null) + val sut = DefaultBugReporter( + context = RuntimeEnvironment.getApplication(), + screenshotHolder = FakeScreenshotHolder(), + crashDataStore = FakeCrashDataStore("I did crash", true), + coroutineDispatchers = testCoroutineDispatchers(), + okHttpClient = { OkHttpClient.Builder().build() }, + userAgentProvider = DefaultUserAgentProvider(buildMeta, FakeSdkMetadata("123456789")), + sessionStore = InMemorySessionStore(), + buildMeta = buildMeta, + bugReporterUrlProvider = { server.url("/") }, + sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider(getClient = { Result.failure(Exception("Mock no client")) }) + ) + + sut.sendBugReport( + withDevicesLogs = true, + withCrashLogs = true, + withScreenshot = true, + theBugDescription = "a bug occurred", + canContact = true, + listener = null + ) + val request = server.takeRequest() + + val foundValues = collectValuesFromFormData(request) + println("## FOUND VALUES $foundValues") + assertThat(foundValues["device_keys"]).isNull() + assertThat(foundValues["device_id"]).isEqualTo("undefined") + assertThat(foundValues["user_id"]).isEqualTo("undefined") + assertThat(foundValues["label"]).isEqualTo("crash") + } + + private fun collectValuesFromFormData(request: RecordedRequest): HashMap { + val boundary = request.headers["Content-Type"]!!.split("=").last() + val foundValues = HashMap() + request.body.inputStream().source().buffer().use { + val multipartReader = MultipartReader(it, boundary) + // Just use simple parsing to detect basic properties + val regex = "form-data; name=\"(\\w*)\".*".toRegex() + multipartReader.use { + var part = multipartReader.nextPart() + while (part != null) { + part.headers["Content-Disposition"]?.let { contentDisposition -> + regex.find(contentDisposition)?.groupValues?.get(1)?.let { name -> + foundValues.put(name, part!!.body.readUtf8()) + } + } + part = multipartReader.nextPart() + } + } + } + return foundValues + } + + private fun mockSessionData(userId: String, deviceId: String) = SessionData( + userId = userId, + deviceId = deviceId, + homeserverUrl = "example.com", + accessToken = "AA", + isTokenValid = true, + loginType = LoginType.DIRECT, + loginTimestamp = null, + oidcData = null, + refreshToken = null, + slidingSyncProxy = null, + passphrase = null + ) @Test fun `test sendBugReport error`() = runTest { val server = MockWebServer() @@ -150,6 +355,7 @@ class DefaultBugReporterTest { buildMeta = buildMeta, bugReporterUrlProvider = { server.url("/") }, sdkMetadata = FakeSdkMetadata("123456789"), + matrixClientProvider = FakeMatrixClientProvider() ) } diff --git a/features/roomaliasresolver/impl/src/main/res/values-pt/translations.xml b/features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/roomaliasresolver/impl/src/main/res/values-pt/translations.xml rename to features/roomaliasresolver/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt index 76807c07e6..34bb3a7a65 100644 --- a/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt +++ b/features/roomdetails/impl/src/main/kotlin/io/element/android/features/roomdetails/impl/RoomDetailsView.kt @@ -399,7 +399,10 @@ private fun TopicSection( roomTopic: RoomTopicState, onActionClicked: (RoomDetailsAction) -> Unit, ) { - PreferenceCategory(title = stringResource(CommonStrings.common_topic)) { + PreferenceCategory( + title = stringResource(CommonStrings.common_topic), + showTopDivider = false, + ) { if (roomTopic is RoomTopicState.CanAddTopic) { PreferenceText( title = stringResource(R.string.screen_room_details_add_topic_title), @@ -489,7 +492,7 @@ private fun SecuritySection() { @Composable private fun OtherActionsSection(isDm: Boolean, onLeaveRoom: () -> Unit) { - PreferenceCategory(showDivider = false) { + PreferenceCategory(showTopDivider = true) { ListItem( headlineContent = { val leaveText = stringResource( diff --git a/features/roomdetails/impl/src/main/res/values-be/translations.xml b/features/roomdetails/impl/src/main/res/values-be/translations.xml index 46f987b994..13d5dedf0a 100644 --- a/features/roomdetails/impl/src/main/res/values-be/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-be/translations.xml @@ -66,7 +66,7 @@ "Блакіроўка %1$s" "%1$d удзельнік" - "%1$d удзельніка" + "%1$d удзельнікі" "%1$d удзельнікаў" "Выдаліць і заблакіраваць удзельніка" diff --git a/features/roomdetails/impl/src/main/res/values-fr/translations.xml b/features/roomdetails/impl/src/main/res/values-fr/translations.xml index 144b82fd87..b9f3eed338 100644 --- a/features/roomdetails/impl/src/main/res/values-fr/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-fr/translations.xml @@ -35,6 +35,9 @@ "Ajouter un sujet" "Déjà membre" "Déjà invité(e)" + "Chiffré" + "Non chiffré" + "Salon public" "Modifier le salon" "Une erreur inconnue s’est produite et les informations n’ont pas pu être modifiées." "Impossible de mettre à jour le salon" diff --git a/features/roomdetails/impl/src/main/res/values-ka/translations.xml b/features/roomdetails/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..75f7121b45 --- /dev/null +++ b/features/roomdetails/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,45 @@ + + + "შეტყობინებების პარამეტრის განახლებისას მოხდა შეცდომა." + "ყველა" + "თემის დამატება" + "უკვე წევრია" + "უკვე მოწვეულია" + "ოთახის რედაქტირება" + "უცნობი შეცდომა მოხდა. ინფორმაციის შეცვლა ვერ მოხერხდა." + "ოთახის განახლება შეუძლებელია" + "შეტყობინებები დაცულია საკეტებით. მხოლოდ თქვენ და მიმღებებს გაქვთ მათი განშიფვრის უნიკალური გასაღებები." + "შეტყობინების დაშიფვრა ჩართულია" + "შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა." + "ამ ოთახის დადუმება ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "ამ ოთახის დადუმების მოხსნა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "ხალხის მოწვევა" + "ოთახის დატოვება" + "მორგებული" + "ნაგულისხმევი" + "შეტყობინებები" + "ოთახის სახელი" + "უსაფრთხოება" + "ოთახის გაზიარება" + "თემა" + "ოთახის განახლება…" + + "%1$d ადამიანი" + "%1$d ადამიანი" + + "მომლოდინე" + "ოთახის წევრები" + "მორგებული პარამეტრის დაშვება" + "ამის ჩართვა უგულებელყოფს თქვენს ნაგულისხმევ პარამეტრს" + "ამ ჩატში ჩემი შეტყობინება:" + "თქვენ შეგიძლიათ შეცვალოთ იგი თქვენს %1$s ." + "გლობალური პარამეტრები" + "Სტანდარტული პარამეტრები" + "მორგებული პარამეტრის წაშლა" + "შეტყობინებების პარამეტრების ჩატვირთვისას მოხდა შეცდომა." + "ნაგულისხმევი რეჟიმის აღდგენა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "რეჟიმის დაყენება ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "ყველა შეტყობინება" + "მხოლოდ ხსენებები და საკვანძო სიტყვები" + "ამ ოთახში, შემატყობინეთ:" + diff --git a/features/roomdetails/impl/src/main/res/values-pt/translations.xml b/features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/roomdetails/impl/src/main/res/values-pt/translations.xml rename to features/roomdetails/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/roomdetails/impl/src/main/res/values-ro/translations.xml b/features/roomdetails/impl/src/main/res/values-ro/translations.xml index 2f26e8335b..05cd542f3b 100644 --- a/features/roomdetails/impl/src/main/res/values-ro/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ro/translations.xml @@ -35,6 +35,9 @@ "Adăugare subiect" "Deja membru" "Deja invitat" + "Criptat" + "Necriptat" + "Cameră publică" "Editați camera" "A apărut o eroare la actualizarea detaliilor camerei" "Nu s-a putut actualiza camera" diff --git a/features/roomdetails/impl/src/main/res/values-ru/translations.xml b/features/roomdetails/impl/src/main/res/values-ru/translations.xml index 1b0bc8d193..d439cff033 100644 --- a/features/roomdetails/impl/src/main/res/values-ru/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-ru/translations.xml @@ -35,6 +35,9 @@ "Добавить тему" "Уже зарегистрирован" "Уже приглашены" + "Зашифровано" + "Не зашифровано" + "Общественная комната" "Редактировать комнату" "Произошла неизвестная ошибка и информацию не удалось изменить." "Не удалось обновить комнату" diff --git a/features/roomdetails/impl/src/main/res/values-sv/translations.xml b/features/roomdetails/impl/src/main/res/values-sv/translations.xml index 73f4f945a8..416a22c81b 100644 --- a/features/roomdetails/impl/src/main/res/values-sv/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-sv/translations.xml @@ -4,6 +4,16 @@ "Din hemserver stöder inte det här alternativet i krypterade rum, du kanske inte aviseras i vissa rum." "Omröstningar" "Alla" + "Meddelanden och innehåll" + "Rumsdetaljer" + "Du kommer inte att kunna ångra den här åtgärden. Du befordrar användaren till att ha samma behörighetsnivå som du." + "Lägg till Admin?" + "Degradera" + "Du kommer inte att kunna ångra denna ändring eftersom du degraderar dig själv, om du är den sista privilegierade användaren i rummet kommer det att vara omöjligt att återfå privilegier." + "Degradera dig själv?" + "Administratörer" + "Moderatorer" + "Medlemmar" "Lägg till ämne" "Redan medlem" "Redan inbjuden" @@ -21,19 +31,33 @@ "Anpassad" "Förval" "Aviseringar" + "Roller och behörigheter" "Rumsnamn" "Säkerhet" "Dela rum" "Ämne" "Uppdaterar rummet …" + "Bannar %1$s" "%1$d person" "%1$d personer" + "Ta bort från rummet" + "Ta bort och banna medlem" + "Ta bara bort medlem" + "Ta bort medlem och banna från att gå med i framtiden?" + "Avbanna" + "Denne kommer kunna gå med i rummet igen om denne bjuds in" + "Avbanna användare" + "Visa profil" + "Bannade" + "Medlemmar" "Väntar" + "Tar bort %1$s …" "Admin" "Moderator" "Rumsmedlemmar" + "Avbannar %1$s" "Tillåt anpassad inställning" "Om du aktiverar detta åsidosätts din standardinställning" "Meddela mig i den här chatten för" @@ -48,4 +72,11 @@ "Alla meddelanden" "Endast omnämnanden och nyckelord" "I det här rummet, meddela mig för" + "Administratörer" + "Meddelanden och innehåll" + "Moderatorer" + "Behörigheter" + "Roller" + "Rumsdetaljer" + "Roller och behörigheter" diff --git a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml index 11769b3176..e2101473cf 100644 --- a/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/roomdetails/impl/src/main/res/values-zh-rTW/translations.xml @@ -2,10 +2,27 @@ "更新通知設定時發生錯誤。" "所有投票" + "僅限管理員" + "移除訊息" "所有人" + "成員管理" + "訊息與內容" + "管理員和版主" + "聊天室資訊" + "傳送訊息" + "編輯管理員" + "編輯版主" + "管理員" + "版主" + "成員" + "您有尚未儲存的變更" + "是否儲存變更?" "新增主題" "已是成員" "已邀請" + "已加密" + "未加密" + "公開的聊天室" "編輯聊天室" "無法更新聊天室" "訊息已加密" @@ -18,15 +35,22 @@ "自訂" "預設" "通知" + "身份與權限" "聊天室名稱" "安全性" "分享聊天室" "主題" "正在更新聊天室…" + "此聊天室沒有黑名單。" "%1$d 位夥伴" + "查看個人檔案" + "黑名單" + "成員" "待定" + "管理員" + "版主" "聊天室成員" "全域設定" "預設" @@ -34,4 +58,18 @@ "無法設定模式,請再試一次。" "所有訊息" "僅限提及與關鍵字" + "管理員" + "變更我的身份" + "降級為普通成員" + "降級為版主" + "成員管理" + "訊息與內容" + "版主" + "權限" + "重設權限" + "重設之後,您會遺失當前的設定。" + "確定要重設權限嗎?" + "身份" + "聊天室資訊" + "身份與權限" diff --git a/features/roomdirectory/impl/src/main/res/values-pt/translations.xml b/features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/roomdirectory/impl/src/main/res/values-pt/translations.xml rename to features/roomdirectory/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt index fcdc260c6e..7a1cb2039a 100644 --- a/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt +++ b/features/roomlist/impl/src/main/kotlin/io/element/android/features/roomlist/impl/filters/RoomListFiltersView.kt @@ -16,6 +16,9 @@ package io.element.android.features.roomlist.impl.filters +import androidx.compose.animation.animateColorAsState +import androidx.compose.animation.core.Spring +import androidx.compose.animation.core.spring import androidx.compose.foundation.ExperimentalFoundationApi import androidx.compose.foundation.background import androidx.compose.foundation.clickable @@ -31,13 +34,15 @@ import androidx.compose.foundation.shape.CircleShape import androidx.compose.material3.FilterChip import androidx.compose.material3.FilterChipDefaults import androidx.compose.runtime.Composable -import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip import androidx.compose.ui.res.stringResource import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp +import androidx.compose.ui.zIndex import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementPreview @@ -62,6 +67,7 @@ fun RoomListFiltersView( } val lazyListState = rememberLazyListState() + val previousFilters = remember { mutableStateOf(listOf()) } LazyRow( contentPadding = PaddingValues(start = 8.dp, end = 16.dp), modifier = modifier.fillMaxWidth(), @@ -75,28 +81,30 @@ fun RoomListFiltersView( modifier = Modifier .padding(start = 8.dp) .testTag(TestTags.homeScreenClearFilters), - onClick = ::onClearFiltersClicked + onClick = { + previousFilters.value = state.selectedFilters() + onClearFiltersClicked() + } ) } } - for (filterWithSelection in state.filterSelectionStates) { + state.filterSelectionStates.forEachIndexed { i, filterWithSelection -> item(filterWithSelection.filter) { + val zIndex = (if (previousFilters.value.contains(filterWithSelection.filter)) state.filterSelectionStates.size else 0) - i.toFloat() RoomListFilterView( - modifier = Modifier.animateItemPlacement(), + modifier = Modifier + .animateItemPlacement() + .zIndex(zIndex), roomListFilter = filterWithSelection.filter, selected = filterWithSelection.isSelected, - onClick = ::onToggleFilter, + onClick = { + previousFilters.value = state.selectedFilters() + onToggleFilter(it) + }, ) } } } - LaunchedEffect(state.filterSelectionStates) { - // Checking for canScrollBackward is necessary for the itemPlacementAnimation to work correctly. - // We don't want the itemPlacementAnimation to be triggered when clearing the filters. - if (!state.hasAnyFilterSelected || lazyListState.canScrollBackward) { - lazyListState.animateScrollToItem(0) - } - } } @Composable @@ -126,16 +134,27 @@ private fun RoomListFilterView( onClick: (RoomListFilter) -> Unit, modifier: Modifier = Modifier ) { + val background = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.bgActionPrimaryRest else ElementTheme.colors.bgCanvasDefault, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip background colour", + ) + val textColour = animateColorAsState( + targetValue = if (selected) ElementTheme.colors.textOnSolidPrimary else ElementTheme.colors.textPrimary, + animationSpec = spring(stiffness = Spring.StiffnessMediumLow), + label = "chip text colour", + ) + FilterChip( selected = selected, onClick = { onClick(roomListFilter) }, modifier = modifier.height(36.dp), shape = CircleShape, colors = FilterChipDefaults.filterChipColors( - containerColor = ElementTheme.colors.bgCanvasDefault, - selectedContainerColor = ElementTheme.colors.bgActionPrimaryRest, - labelColor = ElementTheme.colors.textPrimary, - selectedLabelColor = ElementTheme.colors.textOnSolidPrimary, + containerColor = background.value, + selectedContainerColor = background.value, + labelColor = textColour.value, + selectedLabelColor = textColour.value ), label = { Text(text = stringResource(id = roomListFilter.stringResource)) diff --git a/features/roomlist/impl/src/main/res/values-ka/translations.xml b/features/roomlist/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..de6358f443 --- /dev/null +++ b/features/roomlist/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,18 @@ + + + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ მოწვევაზე %1$s-ში?" + "მოწვევაზე უარის თქმა" + "დარწმუნებული ხართ, რომ გსურთ, უარი თქვათ ჩატზე %1$s-თან?" + "ჩატზე უარის თქვა" + "მოწვევები არ არის" + "%1$s (%2$s) მოგიწვიათ" + "ეს არის ერთჯერადი პროცესი, მადლობა ლოდინისთვის." + "თქვენი ანგარიშის კონფიგურაცია" + "ახალი საუბრისა ან ოთახის შექმნა" + "დაიწყეთ ვინმესთვის შეტყობინების გაგზავნით." + "არც ერთი ჩატი ჯერ არაა." + "ხალხი" + "ჩატები" + "როგორც ჩანს, ახალ მოწყობილობას იყენებთ. დაადასტურეთ სხვა მოწყობილობით თქვენს დაშიფრულ შეტყობინებებზე წვდომისთვის." + "დაადასტურეთ, რომ ეს თქვენ ხართ" + diff --git a/features/roomlist/impl/src/main/res/values-pt/translations.xml b/features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/roomlist/impl/src/main/res/values-pt/translations.xml rename to features/roomlist/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml index 8d0ec972f7..6d01b67889 100644 --- a/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/roomlist/impl/src/main/res/values-zh-rTW/translations.xml @@ -7,10 +7,13 @@ "正在設定您的帳號。" "建立新的對話或聊天室" "我的最愛" + "邀請" "夥伴" "聊天室" "未讀" "所有聊天室" + "標為已讀" + "標為未讀" "您似乎正在使用新的裝置。請使用另一個裝置進行驗證,以存取您的加密訊息。" "驗證這是您本人" diff --git a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt index 053b4e51a8..e682e6608d 100644 --- a/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt +++ b/features/roomlist/impl/src/test/kotlin/io/element/android/features/roomlist/impl/RoomListPresenterTests.kt @@ -140,7 +140,7 @@ class RoomListPresenterTests { }.test { val initialState = awaitItem() assertThat(initialState.showAvatarIndicator).isTrue() - sessionVerificationService.givenCanVerifySession(false) + sessionVerificationService.givenNeedsSessionVerification(false) encryptionService.emitBackupState(BackupState.ENABLED) val finalState = awaitItem() assertThat(finalState.showAvatarIndicator).isFalse() @@ -282,7 +282,7 @@ class RoomListPresenterTests { roomListService = roomListService, encryptionService = encryptionService, sessionVerificationService = FakeSessionVerificationService().apply { - givenCanVerifySession(false) + givenNeedsSessionVerification(false) }, syncService = FakeSyncService(initialState = SyncState.Running) ) diff --git a/features/securebackup/impl/src/main/res/values-ka/translations.xml b/features/securebackup/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..e31153859c --- /dev/null +++ b/features/securebackup/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,40 @@ + + + "სარეზერვო ასლის გამორთვა" + "სარეზერვო ასლის ჩართვა" + "სარეზერვო ასლი უზრუნველყოფს იმას, რომ თქვენ შეტყობინებების ისტორიას არ დაკარგავთ. %1$s" + "სარეზერვო ასლი" + "აღდგენის გასაღების შეცვლა" + "შეიყვანეთ აღდგენის გასაღები" + "თქვენი ჩატის სარეზერვო ასლი ამჟამად არ არის სინქრონიზებული." + "აღდგენის დაყენება" + "მიიღეთ წვდომა თქვენს დაშიფრულ შეტყობინებებზე, თუ დაკარგავთ თქვენს ყველა მოწყობილობას ან გამოხვალთ სისტემიდან %1$s-დან ყველგან." + "გამორთვა" + "თქვენ დაკარგავთ დაშიფრულ შეტყობინებებს, თუ ყველა მოწყობილობიდან გამოხვალთ." + "დარწმუნებული ხართ, რომ გსურთ გამორთოთ სარეზერვო ასლი?" + "სარეზერვო ასლის გამორთვა წაშლის თქვენი მიმდინარე დაშიფვრის გასაღების სარეზერვო ასლს და გამორთავს უსაფრთხოების სხვა ფუნქციებს. ამ შემთხვევაში, თქვენ:" + "არ გექნებათ დაშიფვრული შეტყობინებების ისტორია ახალ მოწყობილობებზე" + "დაკარვავთ წვდომას დაშიფრულ შეტყობინებებზე თუ ყველგან გამოხვალთ %1$s-დან" + "დარწმუნებული ხართ, რომ გსურთ გამორთოთ სარეზერვო ასლი?" + "მიიღეთ ახალი აღდგენის გასაღები, თუ დაკარგეთ არსებული. აღდგენის გასაღების შეცვლის შემდეგ, ძველი აღარ იმუშავებს." + "ახალი აღდგენის გასაღების შექმნა" + "დარწმუნდით, რომ შეგიძლიათ შეინახოთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას" + "აღდგენის გასაღები შეიცვალა" + "გსურთ აღდგენის გასაღების შეცვლა?" + "დარწმუნდით, რომ ვერავინ ხედავს ამ ეკრანს!" + "თუ თქვენ გაქვთ უსაფრთხოების გასაღები ან უსაფრთხოების ფრაზა, ეს ასევე იმუშავებს." + "შეყვანა" + "აღდგენის გასაღები დადასტურებულია" + "შეიყვანეთ თქვენი აღდგენის გასაღები" + "აღდგენის გასაღების შენახვა" + "ჩაწერეთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას ან შეინახეთ პაროლის მენეჯერში." + "აღდგენის გასაღების დასაკოპირებლად, დააწკაპუნეთ" + "შეინახეთ აღდგენის გასაღები" + "თქვენ ვერ შეძლებთ წვდომას თქვენი ახალი აღდგენის გასაღებზე ამ ნაბიჯის შემდეგ." + "შეინახეთ თქვენი აღდგენის გასაღები?" + "თქვენი ჩატის სარეზერვო ასლი დაცულია აღდგენის გასაღებით. თუ დაყენების შემდეგ გჭირდებათ ახალი აღდგენის გასაღები, შეგიძლიათ ხელახლა შექმნათ „აღდგენის გასაღების შეცვლის“ არჩევით." + "შექმენით აღდგენის გასაღები" + "დარწმუნდით, რომ შეგიძლიათ შეინახოთ თქვენი აღდგენის გასაღები სადმე უსაფრთხო ადგილას" + "აღდგენის დაყენება წარმატებით დასრულდა" + "აღდგენის დაყენება" + diff --git a/features/securebackup/impl/src/main/res/values-pt/translations.xml b/features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/securebackup/impl/src/main/res/values-pt/translations.xml rename to features/securebackup/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/signedout/impl/src/main/res/values-ka/translations.xml b/features/signedout/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..37c86149ed --- /dev/null +++ b/features/signedout/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,8 @@ + + + "თქვენ პაროლი შეცვალეთ სხვა სესიაში" + "თქვენ სესია წაშალეთ სხვა სესიიდან" + "თქვენი სერვერის ადმინისტრატორმა გააუქმა თქვენი წვდომა" + "ალბათ, თქვენ გამოხვედით ქვემოთ ჩამოთვლილი ერთ-ერთი მიზეზის გამო. გთხოვთ, შეხვიდეთ ანგარიშში, რათა გააგრძელოთ %s-ს გამოყენება." + "თქვენ ანგარიშიდან გამოსული ხართ" + diff --git a/features/signedout/impl/src/main/res/values-pt/translations.xml b/features/signedout/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/signedout/impl/src/main/res/values-pt/translations.xml rename to features/signedout/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt index 6ad1bf8484..424219158a 100644 --- a/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt +++ b/features/userprofile/shared/src/main/kotlin/io/element/android/features/userprofile/shared/blockuser/BlockUserSection.kt @@ -45,7 +45,7 @@ fun BlockUserSection( ) { PreferenceCategory( modifier = modifier, - showDivider = false, + showTopDivider = false, ) { when (state.isBlocked) { is AsyncData.Failure -> PreferenceBlockUser(isBlocked = state.isBlocked.prevData, isLoading = false, eventSink = state.eventSink) diff --git a/features/userprofile/shared/src/main/res/values-ka/translations.xml b/features/userprofile/shared/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..0054f19946 --- /dev/null +++ b/features/userprofile/shared/src/main/res/values-ka/translations.xml @@ -0,0 +1,10 @@ + + + "დაბლოკვა" + "დაბლოკილი მომხმარებლები ვერ შეძლებენ თქვენთვის შეტყობინების გაგზავნას და ყველა მათი შეტყობინება თქვენთვის დამალული იქნება. თქვენ მათი განბლოკვა ნებისმეირ დროს შეგიძლიათ." + "მომხმარებლის დაბლოკვა" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "ჩატის დაწყების მცდელობისას შეცდომა მოხდა" + diff --git a/features/userprofile/shared/src/main/res/values-pt/translations.xml b/features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/userprofile/shared/src/main/res/values-pt/translations.xml rename to features/userprofile/shared/src/main/res/values-pt-rBR/translations.xml diff --git a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt index b31cbd0162..ed7342eb60 100644 --- a/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt +++ b/features/verifysession/impl/src/main/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenter.kt @@ -25,7 +25,6 @@ import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope -import androidx.compose.runtime.setValue import com.freeletics.flowredux.compose.rememberStateAndDispatch import io.element.android.features.preferences.api.store.SessionPreferencesStore import io.element.android.libraries.architecture.AsyncData @@ -61,7 +60,7 @@ class VerifySelfSessionPresenter @Inject constructor( val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState() val stateAndDispatch = stateMachine.rememberStateAndDispatch() val skipVerification by sessionPreferencesStore.isSessionVerificationSkipped().collectAsState(initial = false) - val needsVerification by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = true) + val needsVerification by sessionVerificationService.needsSessionVerification.collectAsState(initial = true) val verificationFlowStep by remember { derivedStateOf { when { diff --git a/features/verifysession/impl/src/main/res/values-ka/translations.xml b/features/verifysession/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fb1f3c6db4 --- /dev/null +++ b/features/verifysession/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,17 @@ + + + "რაღაცა არასწორადაა. ან მოთხოვნის ვადაა ამოწურული, ან მოთხოვნა უარყოფილი იყო." + "დაადასტურეთ, რომ ქვემოთ მოყვანილი ემოჯიები შეესაბამება თქვენს სხვა სესიაზე ნაჩვენებს." + "შეადარეთ ემოჯიები" + "თქვენი ახალი სესია დადასტურებულია. მას აქვს წვდომა დაშიფრულ შეტყობინებებზე და სხვა მომხმარებლები მას სანდოდ ხედავენ." + "დაამტკიცეთ, რომ ეს თქვენ ხართ, რათა მიიღოთ წვდომა თქვენი დაშიფრული შეტყობინებების ისტორიასთან." + "არსებული სესიის გახსნა" + "დადასტურების ხელახლა ცდა" + "მზად ვარ" + "ველოდებით დამთხვევას" + "შეადარეთ უნიკალური ემოჯი, დარწმუნდით, რომ ისინი ერთი დ იმავე თანმიმდევრობით გამოჩნდნენ." + "ისინი არ ემთხვევიან ერთმანეთს" + "ისინი ემთხვევიან ერთმანეთს" + "მიიღეთ დადასტურების მოთხოვნა თქვენს სხვა სესიაში ამ პროცესის გასაგრძელებლად." + "მოთხოვნის მიღებას ველოდებით" + diff --git a/features/verifysession/impl/src/main/res/values-pt/translations.xml b/features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from features/verifysession/impl/src/main/res/values-pt/translations.xml rename to features/verifysession/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/features/verifysession/impl/src/main/res/values-sv/translations.xml b/features/verifysession/impl/src/main/res/values-sv/translations.xml index f1b2fcc6e5..5460b76d12 100644 --- a/features/verifysession/impl/src/main/res/values-sv/translations.xml +++ b/features/verifysession/impl/src/main/res/values-sv/translations.xml @@ -6,6 +6,7 @@ "Bekräfta att siffrorna nedan matchar de som visas på din andra session." "Jämför siffror" "Din nya session är nu verifierad. Den har tillgång till dina krypterade meddelanden, och andra användare kommer att se den som betrodd." + "Ange återställningsnyckel" "Bevisa att det är du för att komma åt din krypterade meddelandehistorik." "Öppna en befintlig session" "Försök att verifiera igen" diff --git a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml index e98480316a..f5a26eaf33 100644 --- a/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml +++ b/features/verifysession/impl/src/main/res/values-zh-rTW/translations.xml @@ -6,7 +6,7 @@ "您可以安全地讀取和發送訊息了,與您聊天的人也可以信任這部裝置。" "裝置已驗證" "使用另一部裝置" - "正在等待其他裝置……" + "正在等待其他裝置…" "似乎出了一點問題。有可能是因為等候逾時,或是請求被拒絕。" "確認顯示在其他工作階段上的表情符號是否和下方的相同。" "比對表情符號" diff --git a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt index 3dd391da16..1a27891bef 100644 --- a/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt +++ b/features/verifysession/impl/src/test/kotlin/io/element/android/features/verifysession/impl/VerifySelfSessionPresenterTests.kt @@ -296,8 +296,7 @@ class VerifySelfSessionPresenterTests { @Test fun `present - When verification is not needed, the flow is completed`() = runTest { val service = FakeSessionVerificationService().apply { - givenCanVerifySession(false) - givenIsReady(true) + givenNeedsSessionVerification(false) givenVerifiedStatus(SessionVerifiedStatus.Verified) givenVerificationFlowState(VerificationFlowState.Finished) } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 38f879f28b..7c69e9b612 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -3,7 +3,7 @@ [versions] # Project -android_gradle_plugin = "8.4.0" +android_gradle_plugin = "8.4.1" kotlin = "1.9.24" ksp = "1.9.24-1.0.20" firebaseAppDistribution = "5.0.0" @@ -43,7 +43,7 @@ serialization_json = "1.6.3" showkase = "1.0.2" appyx = "1.4.0" sqldelight = "2.0.2" -wysiwyg = "2.37.2" +wysiwyg = "2.37.3" telephoto = "0.11.2" # DI @@ -54,7 +54,6 @@ anvil = "2.4.9" autoservice = "1.1.1" # quality -junit = "4.13.2" androidx-test-ext-junit = "1.1.5" espresso-core = "3.5.1" kover = "0.8.0" @@ -114,7 +113,6 @@ coroutines_test = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-test", ve # Accompanist accompanist_permission = { module = "com.google.accompanist:accompanist-permissions", version.ref = "accompanist" } -accompanist_systemui = { module = "com.google.accompanist:accompanist-systemuicontroller", version.ref = "accompanist" } # Libraries squareup_seismic = "com.squareup:seismic:1.0.3" @@ -135,13 +133,12 @@ test_corektx = { module = "androidx.test:core-ktx", version.ref = "test_core" } test_arch_core = "androidx.arch.core:core-testing:2.2.0" test_junit = "junit:junit:4.13.2" test_runner = "androidx.test:runner:1.5.2" -test_junitext = "androidx.test.ext:junit:1.1.5" -test_mockk = "io.mockk:mockk:1.13.10" +test_mockk = "io.mockk:mockk:1.13.11" test_konsist = "com.lemonappdev:konsist:0.13.0" test_turbine = "app.cash.turbine:turbine:1.1.0" test_truth = "com.google.truth:truth:1.4.2" test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.16" -test_robolectric = "org.robolectric:robolectric:4.12.1" +test_robolectric = "org.robolectric:robolectric:4.12.2" test_appyx_junit = { module = "com.bumble.appyx:testing-junit4", version.ref = "appyx" } # Others @@ -157,9 +154,9 @@ showkase = { module = "com.airbnb.android:showkase", version.ref = "showkase" } showkase_processor = { module = "com.airbnb.android:showkase-processor", version.ref = "showkase" } jsoup = "org.jsoup:jsoup:1.17.2" appyx_core = { module = "com.bumble.appyx:core", version.ref = "appyx" } -molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.2" +molecule-runtime = "app.cash.molecule:molecule-runtime:1.4.3" timber = "com.jakewharton.timber:timber:5.0.1" -matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.18" +matrix_sdk = "org.matrix.rustcomponents:sdk-android:0.2.20" matrix_richtexteditor = { module = "io.element.android:wysiwyg", version.ref = "wysiwyg" } matrix_richtexteditor_compose = { module = "io.element.android:wysiwyg-compose", version.ref = "wysiwyg" } sqldelight-driver-android = { module = "app.cash.sqldelight:android-driver", version.ref = "sqldelight" } @@ -167,7 +164,7 @@ sqldelight-driver-jvm = { module = "app.cash.sqldelight:sqlite-driver", version. sqldelight-coroutines = { module = "app.cash.sqldelight:coroutines-extensions", version.ref = "sqldelight" } sqlcipher = "net.zetetic:android-database-sqlcipher:4.5.4" sqlite = "androidx.sqlite:sqlite-ktx:2.4.0" -unifiedpush = "com.github.UnifiedPush:android-connector:2.1.1" +unifiedpush = "com.github.UnifiedPush:android-connector:2.4.0" otaliastudios_transcoder = "com.otaliastudios:transcoder:0.10.5" vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0" telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" } @@ -180,7 +177,7 @@ opusencoder = "io.element.android:opusencoder:1.1.0" kotlinpoet = "com.squareup:kotlinpoet:1.16.0" # Analytics -posthog = "com.posthog:posthog-android:3.2.1" +posthog = "com.posthog:posthog-android:3.3.0" sentry = "io.sentry:sentry-android:7.9.0" # main branch can be tested replacing the version with main-SNAPSHOT matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.21.0" @@ -204,7 +201,6 @@ google_autoservice_annotations = { module = "com.google.auto.service:auto-servic # value of `composecompiler` (which is used to set composeOptions.kotlinCompilerExtensionVersion. # See https://github.com/renovatebot/renovate/issues/18354 android_composeCompiler = { module = "androidx.compose.compiler:compiler", version.ref = "composecompiler" } -junit = { group = "junit", name = "junit", version.ref = "junit" } androidx-test-ext-junit = { group = "androidx.test.ext", name = "junit", version.ref = "androidx-test-ext-junit" } espresso-core = { group = "androidx.test.espresso", name = "espresso-core", version.ref = "espresso-core" } appcompat = "androidx.appcompat:appcompat:1.6.1" @@ -226,7 +222,6 @@ dependencygraph = "com.savvasdalkitsis.module-dependency-graph:0.12" dependencycheck = "org.owasp.dependencycheck:9.1.0" dependencyanalysis = { id = "com.autonomousapps.dependency-analysis", version.ref = "dependencyAnalysis" } paparazzi = "app.cash.paparazzi:1.3.3" -kover = { id = "org.jetbrains.kotlinx.kover", version.ref = "kover" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } firebaseAppDistribution = { id = "com.google.firebase.appdistribution", version.ref = "firebaseAppDistribution" } knit = { id = "org.jetbrains.kotlinx.knit", version = "0.5.0" } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt index 737eab7ac7..49e055cc29 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/system/SystemUtils.kt @@ -28,6 +28,7 @@ import android.widget.Toast import androidx.activity.result.ActivityResultLauncher import androidx.annotation.ChecksSdkIntAtLeast import androidx.annotation.RequiresApi +import androidx.core.content.pm.PackageInfoCompat import io.element.android.libraries.androidutils.R import io.element.android.libraries.androidutils.compat.getApplicationInfoCompat import io.element.android.libraries.core.mimetype.MimeTypes @@ -47,6 +48,19 @@ fun Context.getApplicationLabel(packageName: String): String { } } +/** + * Retrieve the versionCode from the Manifest. + * The value is more accurate than BuildConfig.VERSION_CODE, as it is correct according to the + * computation in the `androidComponents` block of the app build.gradle.kts file. + * In other words, the last digit (for the architecture) will be set, whereas BuildConfig.VERSION_CODE + * last digit will always be 0. + */ +fun Context.getVersionCodeFromManifest(): Long { + return PackageInfoCompat.getLongVersionCode( + packageManager.getPackageInfo(packageName, 0) + ) +} + // ============================================================================================================== // Clipboard helper // ============================================================================================================== diff --git a/libraries/androidutils/src/main/res/values-ka/translations.xml b/libraries/androidutils/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..ee284f3a40 --- /dev/null +++ b/libraries/androidutils/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "თავსებადი აპლიკაცია ვერ მოიძებნა ამ მოქმედების შესასრულებლად." + diff --git a/libraries/androidutils/src/main/res/values-pt/translations.xml b/libraries/androidutils/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/androidutils/src/main/res/values-pt/translations.xml rename to libraries/androidutils/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt index e69b1b9eaf..9545f1ab68 100644 --- a/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt +++ b/libraries/architecture/src/main/kotlin/io/element/android/libraries/architecture/AsyncAction.kt @@ -86,6 +86,8 @@ sealed interface AsyncAction { fun isFailure(): Boolean = this is Failure fun isSuccess(): Boolean = this is Success + + fun isReady() = isSuccess() || isFailure() } suspend inline fun MutableState>.runCatchingUpdatingState( diff --git a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt index ad5581f631..a9cb78548f 100644 --- a/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt +++ b/libraries/core/src/main/kotlin/io/element/android/libraries/core/meta/BuildMeta.kt @@ -25,7 +25,7 @@ data class BuildMeta( val applicationId: String, val lowPrivacyLoggingEnabled: Boolean, val versionName: String, - val versionCode: Int, + val versionCode: Long, val gitRevision: String, val gitBranchName: String, val flavorDescription: String, diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt index f86177d98e..bae1301615 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCategory.kt @@ -19,22 +19,19 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.ColumnScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp -import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.designsystem.theme.components.ListSectionHeader @Composable fun PreferenceCategory( modifier: Modifier = Modifier, title: String? = null, - showDivider: Boolean = true, + showTopDivider: Boolean = true, content: @Composable ColumnScope.() -> Unit, ) { Column( @@ -42,30 +39,17 @@ fun PreferenceCategory( .fillMaxWidth() ) { if (title != null) { - PreferenceCategoryTitle(title = title) - } - content() - if (showDivider) { + ListSectionHeader( + title = title, + hasDivider = showTopDivider, + ) + } else if (showTopDivider) { PreferenceDivider() } + content() } } -@Composable -private fun PreferenceCategoryTitle(title: String) { - Text( - modifier = Modifier.padding( - top = 20.dp, - bottom = 8.dp, - start = preferencePaddingHorizontal, - end = preferencePaddingHorizontal, - ), - style = ElementTheme.typography.fontBodyLgMedium, - color = ElementTheme.materialColors.primary, - text = title, - ) -} - @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceCategoryPreview() = ElementThemedPreview { diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt index cebe0ddbe2..99c1ba3f6b 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceCheckbox.kt @@ -17,25 +17,18 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable -import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.icons.CompoundDrawables import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Checkbox +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor @@ -52,45 +45,36 @@ fun PreferenceCheckbox( @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .clickable { onCheckedChange(!isChecked) } - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, enabled = enabled, - isVisible = showIconAreaIfNoIcon - ) - Column( - modifier = Modifier.weight(1f), - verticalArrangement = Arrangement.spacedBy(4.dp), - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, color = enabled.toEnabledColor(), ) - if (supportingText != null) { + }, + supportingContent = supportingText?.let { + { Text( style = ElementTheme.typography.fontBodyMdRegular, - text = supportingText, + text = it, color = enabled.toSecondaryEnabledColor(), ) } - } - Checkbox( - modifier = Modifier - .align(Alignment.CenterVertically), + }, + trailingContent = ListItemContent.Checkbox( checked = isChecked, enabled = enabled, - onCheckedChange = onCheckedChange - ) - } + ), + ) } @Preview(group = PreviewGroup.Preferences) @@ -112,5 +96,31 @@ internal fun PreferenceCheckboxPreview() = ElementThemedPreview { isChecked = true, onCheckedChange = {}, ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = CompoundDrawables.ic_compound_threads, + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = true, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceCheckbox( + title = "Checkbox with supporting text", + supportingText = "Supporting text", + iconResourceId = null, + showIconAreaIfNoIcon = false, + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt index 27a846aa10..57bd88b637 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceDivider.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview -import io.element.android.compound.theme.ElementTheme import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.HorizontalDivider @@ -28,10 +27,7 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi fun PreferenceDivider( modifier: Modifier = Modifier, ) { - HorizontalDivider( - modifier = modifier, - color = ElementTheme.colors.borderDisabled, - ) + HorizontalDivider(modifier = modifier) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt index 80813a2d1a..e217f5150a 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceRow.kt @@ -19,14 +19,13 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.RowScope import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.heightIn -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.tooling.preview.Preview import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text /** @@ -37,15 +36,17 @@ fun PreferenceRow( modifier: Modifier = Modifier, content: @Composable RowScope.() -> Unit, ) { - Row( - modifier = modifier - .padding(horizontal = preferencePaddingHorizontal) - .heightIn(min = preferenceMinHeight) - .fillMaxWidth(), - verticalAlignment = Alignment.CenterVertically, - ) { - content() - } + ListItem( + modifier = modifier, + headlineContent = { + Row( + modifier = Modifier.fillMaxWidth(), + verticalAlignment = Alignment.CenterVertically, + ) { + content() + } + } + ) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt index 392343f48e..9173c1e193 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSlide.kt @@ -19,23 +19,18 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes import androidx.annotation.FloatRange import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.padding import androidx.compose.runtime.Composable import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Slider import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.toEnabledColor @Composable fun PreferenceSlide( @@ -51,51 +46,57 @@ fun PreferenceSlide( summary: String? = null, steps: Int = 0, ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, - isVisible = showIconAreaIfNoIcon, - ) - Column( - modifier = Modifier - .weight(1f), - ) { - Text( - style = ElementTheme.typography.fontBodyLgRegular, - text = title, - color = enabled.toEnabledColor(), - ) - summary?.let { + enabled = enabled, + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { + Column { Text( - style = ElementTheme.typography.fontBodyMdRegular, - text = summary, - color = enabled.toEnabledColor(), + style = ElementTheme.typography.fontBodyLgRegular, + text = title, + ) + summary?.let { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = summary, + ) + } + Slider( + value = value, + steps = steps, + onValueChange = onValueChange, + enabled = enabled, ) } - Slider( - value = value, - steps = steps, - onValueChange = onValueChange, - enabled = enabled, - ) } - } + ) } @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceSlidePreview() = ElementThemedPreview { - PreferenceSlide( - icon = CompoundIcons.UserProfile(), - title = "Slide", - summary = "Summary", - value = 0.75F, - onValueChange = {}, - ) + Column { + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = true, + value = 0.75F, + onValueChange = {}, + ) + PreferenceSlide( + icon = CompoundIcons.UserProfile(), + title = "Slide", + summary = "Summary", + enabled = false, + value = 0.75F, + onValueChange = {}, + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt index e79e915484..388dcec23f 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceSwitch.kt @@ -17,30 +17,19 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Column -import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth -import androidx.compose.foundation.layout.height -import androidx.compose.foundation.layout.padding -import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable -import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.graphics.vector.ImageVector import androidx.compose.ui.tooling.preview.Preview -import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup -import io.element.android.libraries.designsystem.theme.components.Switch +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text -import io.element.android.libraries.designsystem.toEnabledColor -import io.element.android.libraries.designsystem.toSecondaryEnabledColor @Composable fun PreferenceSwitch( @@ -53,62 +42,65 @@ fun PreferenceSwitch( icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, showIconAreaIfNoIcon: Boolean = false, - switchAlignment: Alignment.Vertical = Alignment.CenterVertically ) { - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = preferenceMinHeight) - .clickable { onCheckedChange(!isChecked) } - .padding(vertical = 4.dp, horizontal = preferencePaddingHorizontal), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onCheckedChange.takeIf { enabled }?.let { { onCheckedChange(!isChecked) } }, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, enabled = enabled, - isVisible = showIconAreaIfNoIcon - ) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, - color = enabled.toEnabledColor(), ) - if (subtitle != null) { - Spacer(modifier = Modifier.height(4.dp)) + }, + supportingContent = subtitle?.let { + { Text( style = ElementTheme.typography.fontBodyMdRegular, text = subtitle, - color = enabled.toSecondaryEnabledColor(), ) } - } - Spacer(modifier = Modifier.width(16.dp)) - // TODO Create a wrapper for Switch - Switch( - modifier = Modifier - .align(switchAlignment), + }, + trailingContent = ListItemContent.Switch( checked = isChecked, enabled = enabled, - onCheckedChange = onCheckedChange ) - } + ) } @Preview(group = PreviewGroup.Preferences) @Composable internal fun PreferenceSwitchPreview() = ElementThemedPreview { - PreferenceSwitch( - title = "Switch", - subtitle = "Subtitle Switch", - icon = CompoundIcons.Threads(), - enabled = true, - isChecked = true, - onCheckedChange = {}, - ) + Column { + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = true, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch", + subtitle = "Subtitle Switch", + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + PreferenceSwitch( + title = "Switch no subtitle", + subtitle = null, + icon = CompoundIcons.Threads(), + enabled = false, + isChecked = true, + onCheckedChange = {}, + ) + } } diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt index e8a859b54a..85f1c10b21 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/PreferenceText.kt @@ -17,12 +17,9 @@ package io.element.android.libraries.designsystem.components.preferences import androidx.annotation.DrawableRes -import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row -import androidx.compose.foundation.layout.defaultMinSize -import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.progressSemantics @@ -38,18 +35,17 @@ import io.element.android.compound.theme.ElementTheme import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.architecture.coverage.ExcludeFromCoverage import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom -import io.element.android.libraries.designsystem.components.preferences.components.PreferenceIcon +import io.element.android.libraries.designsystem.components.list.ListItemContent +import io.element.android.libraries.designsystem.components.preferences.components.preferenceIcon import io.element.android.libraries.designsystem.preview.ElementPreviewDark import io.element.android.libraries.designsystem.preview.ElementPreviewLight import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator +import io.element.android.libraries.designsystem.theme.components.ListItem import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.toEnabledColor import io.element.android.libraries.designsystem.toSecondaryEnabledColor -/** - * Tried to use ListItem, but it cannot really match the design. Keep custom Layout for now. - */ @Composable fun PreferenceText( title: String, @@ -67,76 +63,76 @@ fun PreferenceText( tintColor: Color? = null, onClick: () -> Unit = {}, ) { - val minHeight = if (subtitle == null && subtitleAnnotated == null) preferenceMinHeightOnlyTitle else preferenceMinHeight - - Row( - modifier = modifier - .fillMaxWidth() - .defaultMinSize(minHeight = minHeight) - .clickable { onClick() } - .padding(horizontal = preferencePaddingHorizontal, vertical = 4.dp), - verticalAlignment = Alignment.CenterVertically - ) { - PreferenceIcon( + ListItem( + modifier = modifier, + enabled = enabled, + onClick = onClick, + leadingContent = preferenceIcon( icon = icon, iconResourceId = iconResourceId, showIconBadge = showIconBadge, enabled = enabled, - isVisible = showIconAreaIfNoIcon, - tintColor = tintColor ?: enabled.toSecondaryEnabledColor(), - ) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { + showIconAreaIfNoIcon = showIconAreaIfNoIcon, + tintColor = tintColor, + ), + headlineContent = { Text( style = ElementTheme.typography.fontBodyLgRegular, text = title, color = tintColor ?: enabled.toEnabledColor(), ) - if (subtitle != null) { + }, + supportingContent = if (subtitle != null) { + { Text( style = ElementTheme.typography.fontBodyMdRegular, text = subtitle, color = tintColor ?: enabled.toSecondaryEnabledColor(), ) - } else if (subtitleAnnotated != null) { - Text( - style = ElementTheme.typography.fontBodyMdRegular, - text = subtitleAnnotated, - color = tintColor ?: enabled.toSecondaryEnabledColor(), - ) } + } else { + subtitleAnnotated?.let { + { + Text( + style = ElementTheme.typography.fontBodyMdRegular, + text = it, + color = tintColor ?: enabled.toSecondaryEnabledColor(), + ) + } + } + }, + trailingContent = if (currentValue != null || loadingCurrentValue || showEndBadge) { + ListItemContent.Custom { + Row( + verticalAlignment = Alignment.CenterVertically, + ) { + if (currentValue != null) { + Text( + text = currentValue, + style = ElementTheme.typography.fontBodyXsMedium, + color = enabled.toSecondaryEnabledColor(), + ) + } else if (loadingCurrentValue) { + CircularProgressIndicator( + modifier = Modifier + .progressSemantics() + .size(20.dp), + strokeWidth = 2.dp + ) + } + if (showEndBadge) { + val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 16.dp else 0.dp + RedIndicatorAtom( + modifier = Modifier + .padding(start = endBadgeStartPadding) + ) + } + } + } + } else { + null } - if (currentValue != null) { - Text( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = 16.dp, end = 8.dp), - text = currentValue, - style = ElementTheme.typography.fontBodyXsMedium, - color = enabled.toSecondaryEnabledColor(), - ) - } else if (loadingCurrentValue) { - CircularProgressIndicator( - modifier = Modifier - .progressSemantics() - .padding(start = 16.dp, end = 8.dp) - .size(20.dp) - .align(Alignment.CenterVertically), - strokeWidth = 2.dp - ) - } - if (showEndBadge) { - val endBadgeStartPadding = if (currentValue != null || loadingCurrentValue) 8.dp else 16.dp - RedIndicatorAtom( - modifier = Modifier - .align(Alignment.CenterVertically) - .padding(start = endBadgeStartPadding) - ) - } - } + ) } @Preview(group = PreviewGroup.Preferences) diff --git a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt index 61e8aabdd7..f2f1e5ffb7 100644 --- a/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt +++ b/libraries/designsystem/src/main/kotlin/io/element/android/libraries/designsystem/components/preferences/components/PreferenceIcon.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.designsystem.components.preferences.compone import androidx.annotation.DrawableRes import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Spacer -import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width import androidx.compose.runtime.Composable @@ -31,13 +30,39 @@ import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.unit.dp import io.element.android.libraries.designsystem.atomic.atoms.RedIndicatorAtom +import io.element.android.libraries.designsystem.components.list.ListItemContent import io.element.android.libraries.designsystem.preview.ElementThemedPreview import io.element.android.libraries.designsystem.preview.PreviewGroup import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.toSecondaryEnabledColor @Composable -fun PreferenceIcon( +fun preferenceIcon( + icon: ImageVector? = null, + @DrawableRes iconResourceId: Int? = null, + showIconBadge: Boolean = false, + tintColor: Color? = null, + enabled: Boolean = true, + showIconAreaIfNoIcon: Boolean = false, +): ListItemContent.Custom? { + return if (icon != null || iconResourceId != null || showIconAreaIfNoIcon) { + ListItemContent.Custom { + PreferenceIcon( + icon = icon, + iconResourceId = iconResourceId, + showIconBadge = showIconBadge, + enabled = enabled, + isVisible = showIconAreaIfNoIcon, + tintColor = tintColor, + ) + } + } else { + null + } +} + +@Composable +private fun PreferenceIcon( modifier: Modifier = Modifier, icon: ImageVector? = null, @DrawableRes iconResourceId: Int? = null, @@ -54,19 +79,17 @@ fun PreferenceIcon( contentDescription = null, tint = tintColor ?: enabled.toSecondaryEnabledColor(), modifier = Modifier - .padding(end = 16.dp) .size(24.dp), ) if (showIconBadge) { RedIndicatorAtom( modifier = Modifier .align(Alignment.TopEnd) - .padding(end = 16.dp) ) } } } else if (isVisible) { - Spacer(modifier = modifier.width(40.dp)) + Spacer(modifier = modifier.width(24.dp)) } } diff --git a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt index 1c58cffd43..72748a84ff 100644 --- a/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt +++ b/libraries/eventformatter/impl/src/main/kotlin/io/element/android/libraries/eventformatter/impl/RoomMembershipContentFormatter.kt @@ -34,6 +34,7 @@ class RoomMembershipContentFormatter @Inject constructor( ): CharSequence? { val userId = membershipContent.userId val memberIsYou = matrixClient.isMe(userId) + val userDisplayNameOrId = membershipContent.userDisplayName ?: userId.value return when (membershipContent.change) { MembershipChange.JOINED -> if (memberIsYou) { sp.getString(R.string.state_event_room_join_by_you) @@ -46,41 +47,41 @@ class RoomMembershipContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_leave, senderDisambiguatedDisplayName) } MembershipChange.BANNED, MembershipChange.KICKED_AND_BANNED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_ban_by_you, userId.value) + sp.getString(R.string.state_event_room_ban_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_ban, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.UNBANNED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_unban_by_you, userId.value) + sp.getString(R.string.state_event_room_unban_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_unban, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.KICKED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_remove_by_you, userId.value) + sp.getString(R.string.state_event_room_remove_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_remove, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.INVITED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_invite_by_you, userId.value) + sp.getString(R.string.state_event_room_invite_by_you, userDisplayNameOrId) } else if (memberIsYou) { sp.getString(R.string.state_event_room_invite_you, senderDisambiguatedDisplayName) } else { - sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_invite, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.INVITATION_ACCEPTED -> if (memberIsYou) { sp.getString(R.string.state_event_room_invite_accepted_by_you) } else { - sp.getString(R.string.state_event_room_invite_accepted, userId.value) + sp.getString(R.string.state_event_room_invite_accepted, userDisplayNameOrId) } MembershipChange.INVITATION_REJECTED -> if (memberIsYou) { sp.getString(R.string.state_event_room_reject_by_you) } else { - sp.getString(R.string.state_event_room_reject, userId.value) + sp.getString(R.string.state_event_room_reject, userDisplayNameOrId) } MembershipChange.INVITATION_REVOKED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userId.value) + sp.getString(R.string.state_event_room_third_party_revoked_invite_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_third_party_revoked_invite, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.KNOCKED -> if (memberIsYou) { sp.getString(R.string.state_event_room_knock_by_you) @@ -88,9 +89,9 @@ class RoomMembershipContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_knock, senderDisambiguatedDisplayName) } MembershipChange.KNOCK_ACCEPTED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_knock_accepted_by_you, userId.value) + sp.getString(R.string.state_event_room_knock_accepted_by_you, userDisplayNameOrId) } else { - sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_knock_accepted, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.KNOCK_RETRACTED -> if (memberIsYou) { sp.getString(R.string.state_event_room_knock_retracted_by_you) @@ -98,11 +99,11 @@ class RoomMembershipContentFormatter @Inject constructor( sp.getString(R.string.state_event_room_knock_retracted, senderDisambiguatedDisplayName) } MembershipChange.KNOCK_DENIED -> if (senderIsYou) { - sp.getString(R.string.state_event_room_knock_denied_by_you, userId.value) + sp.getString(R.string.state_event_room_knock_denied_by_you, userDisplayNameOrId) } else if (memberIsYou) { sp.getString(R.string.state_event_room_knock_denied_you, senderDisambiguatedDisplayName) } else { - sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userId.value) + sp.getString(R.string.state_event_room_knock_denied, senderDisambiguatedDisplayName, userDisplayNameOrId) } MembershipChange.NONE -> if (senderIsYou) { sp.getString(R.string.state_event_room_none_by_you) diff --git a/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml b/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..7fa4f65716 --- /dev/null +++ b/libraries/eventformatter/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,57 @@ + + + "(ფოტოც შეიცვალა)" + "%1$s პროფილის ფოტო შეცვალა" + "თქვენ შეცვალეთ პროფილის ფოტო" + "%1$s თავისი ნაჩვენები სახელი შეცვალა %2$s დან %3$s ზე" + "თქვენ შეცვალეთ თქვენი ნაჩვენები სახელი %1$s -დან %2$s -ზე" + "%1$s წაშალა თავისი ნაჩვენები სახელი (იყო %2$s)" + "თქვენ წაშალეთ ნაჩვენები სახელი (იყო %1$s)" + "%1$s თავისი ნაჩვენები სახელი შეცვალა %2$s" + "თქვენი ახალი ნაჩვენები სახელი - %1$s" + "%1$s ოთახის ფოტო შეცვალა" + "თქვენ შეცვალეთ ოთახის ფოტო" + "%1$s წაშალა ოთახის ფოტო" + "თქვენ წაშალეთ ოთახის ფოტო" + "%1$s დაბლოკა %2$s" + "თქვენ დაბლოკეთ %1$s" + "%1$s შექმნა ოთახი" + "თქვენ შექმენით ოთახი" + "%1$s მოიწვია %2$s" + "%1$s მიიღო მოწვევა" + "თქვენ მიიღეთ მოწვევა" + "თქვენ მოიწვიეთ %1$s" + "%1$s მოგიწვიათ" + "%1$s გაწევრიანდა ოთახში" + "თქვენ გაწევრიანდით ოთახში" + "%1$s გაწევრიანება მოითხოვა" + "%1$s გაწევრიანების უფლება მისცა %2$s" + "თქვენ %1$s გაწევრიანების უფლება მიეცით" + "თქვენ მოითხოვეთ გაწევრიანება" + "%1$s უარი თქვა %2$s-ს გაწევრიანების მოთხოვნაზე" + "თქვენ უარი თქვით %1$s გაწევრიანების თხოვნაზე" + "%1$s უარი თქვა თქვენს მოთხოვნაზე გაწევრიანების შესახებ" + "%1$s აღარ არის დაინტერესებული გაწევრიანებით" + "თქვენ გააუქმეთ გაწევრიანების მოთხოვნა" + "%1$s დატოვა ოთახი" + "თქვენ დატოვეთ ოთახი" + "%1$s შეცვალა ოთახის სახელი: %2$s" + "თქვენ შეცვალეთ ოთახის სახელი: %1$s" + "%1$s წაშალა ოთახის სახელი" + "თქვენ წაშალეთ ოთახის სახელი" + "%1$s მოწვევაზე უარი თქვა" + "თქვენ უარი თქვით მოწვევაზე" + "%1$s გააგდო %2$s" + "თქვენ გააგდეთ %1$s" + "%1$s მოიწვია %2$s ოთახში" + "თქვენ მოიწვიეთ %1$s ოთახში" + "%1$s გააუქმო %2$s-ს ოთახში მოწვევა" + "თქვენ %1$s-ს ოთახში მოწვევა გააუქმეთ" + "%1$s შეცვალა თემა: %2$s" + "თქვენ შეცვალეთ თემა: %1$s" + "%1$s წაშალა ოთახის თემა" + "თქვენ წაშალეთ ოთახის თემა" + "%1$s განბლოკა %2$s" + "თქვენ განბლოკეთ %1$s" + "%1$s უცნობი ცვლილება შეიტანა თავის წევრობაში" + diff --git a/libraries/eventformatter/impl/src/main/res/values-pt/translations.xml b/libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/eventformatter/impl/src/main/res/values-pt/translations.xml rename to libraries/eventformatter/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt index 9ee1dce352..f5e151b541 100644 --- a/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt +++ b/libraries/eventformatter/impl/src/test/kotlin/io/element/android/libraries/eventformatter/impl/DefaultRoomLastMessageFormatterTest.kt @@ -254,9 +254,9 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - joined`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.JOINED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.JOINED) val youJoinedRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youJoinedRoom = formatter.format(youJoinedRoomEvent, false) @@ -270,9 +270,9 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - left`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.LEFT) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.LEFT) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.LEFT) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.LEFT) val youLeftRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youLeftRoom = formatter.format(youLeftRoomEvent, false) @@ -286,67 +286,71 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - banned`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED) - val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.BANNED) - val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED_AND_BANNED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val youKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.BANNED) + val someoneKickedContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED_AND_BANNED) val youBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youBanned = formatter.format(youBannedEvent, false) - assertThat(youBanned).isEqualTo("You banned ${youContent.userId}") + assertThat(youBanned).isEqualTo("You banned $third") val youKickBannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youKickedContent) val youKickedBanned = formatter.format(youKickBannedEvent, false) - assertThat(youKickedBanned).isEqualTo("You banned ${youContent.userId}") + assertThat(youKickedBanned).isEqualTo("You banned $third") val someoneBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneBanned = formatter.format(someoneBannedEvent, false) - assertThat(someoneBanned).isEqualTo("$otherName banned ${someoneContent.userId}") + assertThat(someoneBanned).isEqualTo("$otherName banned $third") val someoneKickBannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneKickedContent) val someoneKickBanned = formatter.format(someoneKickBannedEvent, false) - assertThat(someoneKickBanned).isEqualTo("$otherName banned ${someoneContent.userId}") + assertThat(someoneKickBanned).isEqualTo("$otherName banned $third") } @Test @Config(qualifiers = "en") fun `Membership change - unban`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.UNBANNED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.UNBANNED) val youUnbannedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youUnbanned = formatter.format(youUnbannedEvent, false) - assertThat(youUnbanned).isEqualTo("You unbanned ${youContent.userId}") + assertThat(youUnbanned).isEqualTo("You unbanned $third") val someoneUnbannedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneUnbanned = formatter.format(someoneUnbannedEvent, false) - assertThat(someoneUnbanned).isEqualTo("$otherName unbanned ${someoneContent.userId}") + assertThat(someoneUnbanned).isEqualTo("$otherName unbanned $third") } @Test @Config(qualifiers = "en") fun `Membership change - kicked`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KICKED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KICKED) val youKickedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youKicked = formatter.format(youKickedEvent, false) - assertThat(youKicked).isEqualTo("You removed ${youContent.userId}") + assertThat(youKicked).isEqualTo("You removed $third") val someoneKickedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneKicked = formatter.format(someoneKickedEvent, false) - assertThat(someoneKicked).isEqualTo("$otherName removed ${someoneContent.userId}") + assertThat(someoneKicked).isEqualTo("$otherName removed $third") } @Test @Config(qualifiers = "en") fun `Membership change - invited`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITED) val youWereInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) val youWereInvited = formatter.format(youWereInvitedEvent, false) @@ -354,19 +358,19 @@ class DefaultRoomLastMessageFormatterTest { val youInvitedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youInvited = formatter.format(youInvitedEvent, false) - assertThat(youInvited).isEqualTo("You invited ${someoneContent.userId}") + assertThat(youInvited).isEqualTo("You invited $third") val someoneInvitedEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneInvited = formatter.format(someoneInvitedEvent, false) - assertThat(someoneInvited).isEqualTo("$otherName invited ${someoneContent.userId}") + assertThat(someoneInvited).isEqualTo("$otherName invited $third") } @Test @Config(qualifiers = "en") fun `Membership change - invitation accepted`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_ACCEPTED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_ACCEPTED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_ACCEPTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_ACCEPTED) val youAcceptedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youAcceptedInvite = formatter.format(youAcceptedInviteEvent, false) @@ -374,15 +378,15 @@ class DefaultRoomLastMessageFormatterTest { val someoneAcceptedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneAcceptedInvite = formatter.format(someoneAcceptedInviteEvent, false) - assertThat(someoneAcceptedInvite).isEqualTo("${someoneContent.userId} accepted the invite") + assertThat(someoneAcceptedInvite).isEqualTo("$otherName accepted the invite") } @Test @Config(qualifiers = "en") fun `Membership change - invitation rejected`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.INVITATION_REJECTED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REJECTED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.INVITATION_REJECTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.INVITATION_REJECTED) val youRejectedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youRejectedInvite = formatter.format(youRejectedInviteEvent, false) @@ -390,30 +394,31 @@ class DefaultRoomLastMessageFormatterTest { val someoneRejectedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneRejectedInvite = formatter.format(someoneRejectedInviteEvent, false) - assertThat(someoneRejectedInvite).isEqualTo("${someoneContent.userId} rejected the invitation") + assertThat(someoneRejectedInvite).isEqualTo("$otherName rejected the invitation") } @Test @Config(qualifiers = "en") fun `Membership change - invitation revoked`() { - val otherName = "Someone" - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.INVITATION_REVOKED) + val otherName = "Other" + val third = "Someone" + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.INVITATION_REVOKED) val youRevokedInviteEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youRevokedInvite = formatter.format(youRevokedInviteEvent, false) - assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for ${someoneContent.userId} to join the room") + assertThat(youRevokedInvite).isEqualTo("You revoked the invitation for $third to join the room") val someoneRevokedInviteEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneRevokedInvite = formatter.format(someoneRevokedInviteEvent, false) - assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for ${someoneContent.userId} to join the room") + assertThat(someoneRevokedInvite).isEqualTo("$otherName revoked the invitation for $third to join the room") } @Test @Config(qualifiers = "en") fun `Membership change - knocked`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCKED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCKED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCKED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.KNOCKED) val youKnockedEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youKnocked = formatter.format(youKnockedEvent, false) @@ -427,24 +432,25 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - knock accepted`() { - val otherName = "Someone" - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_ACCEPTED) + val otherName = "Other" + val third = "Someone" + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_ACCEPTED) val youAcceptedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youAcceptedKnock = formatter.format(youAcceptedKnockEvent, false) - assertThat(youAcceptedKnock).isEqualTo("You allowed ${someoneContent.userId} to join") + assertThat(youAcceptedKnock).isEqualTo("You allowed $third to join") val someoneAcceptedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneAcceptedKnock = formatter.format(someoneAcceptedKnockEvent, false) - assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed ${someoneContent.userId} to join") + assertThat(someoneAcceptedKnock).isEqualTo("$otherName allowed $third to join") } @Test @Config(qualifiers = "en") fun `Membership change - knock retracted`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_RETRACTED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_RETRACTED) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.KNOCK_RETRACTED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), null, MembershipChange.KNOCK_RETRACTED) val youRetractedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youRetractedKnock = formatter.format(youRetractedKnockEvent, false) @@ -458,17 +464,18 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - knock denied`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.KNOCK_DENIED) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.KNOCK_DENIED) + val otherName = "Other" + val third = "Someone" + val youContent = RoomMembershipContent(A_USER_ID, third, MembershipChange.KNOCK_DENIED) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), third, MembershipChange.KNOCK_DENIED) val youDeniedKnockEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = someoneContent) val youDeniedKnock = formatter.format(youDeniedKnockEvent, false) - assertThat(youDeniedKnock).isEqualTo("You rejected ${someoneContent.userId}'s request to join") + assertThat(youDeniedKnock).isEqualTo("You rejected $third's request to join") val someoneDeniedKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = someoneContent) val someoneDeniedKnock = formatter.format(someoneDeniedKnockEvent, false) - assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected ${someoneContent.userId}'s request to join") + assertThat(someoneDeniedKnock).isEqualTo("$otherName rejected $third's request to join") val someoneDeniedYourKnockEvent = createRoomEvent(sentByYou = false, senderDisplayName = otherName, content = youContent) val someoneDeniedYourKnock = formatter.format(someoneDeniedYourKnockEvent, false) @@ -478,9 +485,9 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Membership change - None`() { - val otherName = "Someone" - val youContent = RoomMembershipContent(A_USER_ID, MembershipChange.NONE) - val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), MembershipChange.NONE) + val otherName = "Other" + val youContent = RoomMembershipContent(A_USER_ID, null, MembershipChange.NONE) + val someoneContent = RoomMembershipContent(UserId("@someone_else:domain"), otherName, MembershipChange.NONE) val youNoneRoomEvent = createRoomEvent(sentByYou = true, senderDisplayName = null, content = youContent) val youNoneRoom = formatter.format(youNoneRoomEvent, false) @@ -497,7 +504,7 @@ class DefaultRoomLastMessageFormatterTest { val otherChanges = arrayOf(MembershipChange.ERROR, MembershipChange.NOT_IMPLEMENTED, null) val results = otherChanges.map { change -> - val content = RoomMembershipContent(A_USER_ID, change) + val content = RoomMembershipContent(A_USER_ID, null, change) val event = createRoomEvent(sentByYou = false, senderDisplayName = "Someone", content = content) val result = formatter.format(event, false) change to result @@ -513,7 +520,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - avatar`() { - val otherName = "Someone" + val otherName = "Other" val changedContent = StateContent("", OtherState.RoomAvatar("new_avatar")) val removedContent = StateContent("", OtherState.RoomAvatar(null)) @@ -537,7 +544,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - create`() { - val otherName = "Someone" + val otherName = "Other" val content = StateContent("", OtherState.RoomCreate) val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) @@ -552,7 +559,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - encryption`() { - val otherName = "Someone" + val otherName = "Other" val content = StateContent("", OtherState.RoomEncryption) val youCreatedRoomMessage = createRoomEvent(sentByYou = true, senderDisplayName = null, content = content) @@ -567,7 +574,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - room name`() { - val otherName = "Someone" + val otherName = "Other" val newName = "New name" val changedContent = StateContent("", OtherState.RoomName(newName)) val removedContent = StateContent("", OtherState.RoomName(null)) @@ -592,7 +599,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - third party invite`() { - val otherName = "Someone" + val otherName = "Other" val inviteeName = "Alice" val changedContent = StateContent("", OtherState.RoomThirdPartyInvite(inviteeName)) val removedContent = StateContent("", OtherState.RoomThirdPartyInvite(null)) @@ -617,7 +624,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Room state change - room topic`() { - val otherName = "Someone" + val otherName = "Other" val roomTopic = "New topic" val changedContent = StateContent("", OtherState.RoomTopic(roomTopic)) val removedContent = StateContent("", OtherState.RoomTopic(null)) @@ -677,7 +684,7 @@ class DefaultRoomLastMessageFormatterTest { @Test @Config(qualifiers = "en") fun `Profile change - avatar`() { - val otherName = "Someone" + val otherName = "Other" val changedContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = "old_avatar_url") val setContent = aProfileChangeMessageContent(avatarUrl = "new_avatar_url", prevAvatarUrl = null) val removedContent = aProfileChangeMessageContent(avatarUrl = null, prevAvatarUrl = "old_avatar_url") @@ -722,7 +729,7 @@ class DefaultRoomLastMessageFormatterTest { fun `Profile change - display name`() { val newDisplayName = "New" val oldDisplayName = "Old" - val otherName = "Someone" + val otherName = "Other" val changedContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = oldDisplayName) val setContent = aProfileChangeMessageContent(displayName = newDisplayName, prevDisplayName = null) val removedContent = aProfileChangeMessageContent(displayName = null, prevDisplayName = oldDisplayName) diff --git a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt index aae0f1f913..6b72b31d19 100644 --- a/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt +++ b/libraries/indicator/impl/src/main/kotlin/io/element/android/libraries/indicator/impl/DefaultIndicatorService.kt @@ -38,7 +38,7 @@ class DefaultIndicatorService @Inject constructor( ) : IndicatorService { @Composable override fun showRoomListTopBarIndicator(): State { - val canVerifySession by sessionVerificationService.canVerifySessionFlow.collectAsState(initial = false) + val canVerifySession by sessionVerificationService.needsSessionVerification.collectAsState(initial = false) val settingChatBackupIndicator = showSettingChatBackupIndicator() return remember { diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index fc57e69005..a4b808d653 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -19,7 +19,6 @@ package io.element.android.libraries.matrix.api import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters @@ -105,5 +104,5 @@ interface MatrixClient : Closeable { suspend fun trackRecentlyVisitedRoom(roomId: RoomId): Result suspend fun getRecentlyVisitedRooms(): Result> suspend fun resolveRoomAlias(roomAlias: RoomAlias): Result - suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result + suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index 36c786a26f..f47487c634 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -50,4 +50,16 @@ interface EncryptionService { * Wait for backup upload steady state. */ fun waitForBackupUploadSteadyState(): Flow + + /** + * Get the public curve25519 key of our own device in base64. This is usually what is + * called the identity key of the device. + */ + suspend fun deviceCurve25519(): String? + + /** + * Get the public ed25519 key of our own device. This is usually what is + * called the fingerprint of the device. + */ + suspend fun deviceEd25519(): String? } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt index 71a642965f..2a16e7be35 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/PushersService.kt @@ -18,5 +18,5 @@ package io.element.android.libraries.matrix.api.pusher interface PushersService { suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData): Result - suspend fun unsetHttpPusher(): Result + suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt new file mode 100644 index 0000000000..2bd91a6d02 --- /dev/null +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/pusher/UnsetHttpPusherData.kt @@ -0,0 +1,22 @@ +/* + * Copyright (c) 2024 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.libraries.matrix.api.pusher + +data class UnsetHttpPusherData( + val pushKey: String, + val appId: String, +) diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt index c0aac298a8..0150c78a4a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/timeline/item/event/EventContent.kt @@ -73,6 +73,7 @@ data class UnableToDecryptContent( data class RoomMembershipContent( val userId: UserId, + val userDisplayName: String?, val change: MembershipChange? ) : EventContent diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt index 8d22fc174e..01687d754a 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/verification/SessionVerificationService.kt @@ -26,12 +26,6 @@ interface SessionVerificationService { */ val verificationFlowState: StateFlow - /** - * The internal service that checks verification can only run after the initial sync. - * This [StateFlow] will notify consumers when the service is ready to be used. - */ - val isReady: StateFlow - /** * Returns whether the current verification status is either: [SessionVerifiedStatus.Unknown], [SessionVerifiedStatus.NotVerified] * or [SessionVerifiedStatus.Verified]. @@ -39,9 +33,9 @@ interface SessionVerificationService { val sessionVerifiedStatus: StateFlow /** - * Returns whether the current session needs to be verified and the SDK is ready to start the verification. + * Returns whether the current session needs to be verified. */ - val canVerifySessionFlow: Flow + val needsSessionVerification: Flow /** * Request verification of the current session. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index 8a370df09b..2dd28a1ae0 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -25,7 +25,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters import io.element.android.libraries.matrix.api.createroom.RoomPreset @@ -487,9 +486,12 @@ class RustMatrixClient( } } - override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result = withContext(sessionDispatcher) { + override suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List): Result = withContext(sessionDispatcher) { runCatching { - client.getRoomPreview(roomIdOrAlias.identifier).let(RoomPreviewMapper::map) + client.getRoomPreviewFromRoomId( + roomId = roomId.value, + viaServers = serverNames, + ).let(RoomPreviewMapper::map) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt index f5a6390989..68ab4a611e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustEncryptionService.kt @@ -190,4 +190,12 @@ internal class RustEncryptionService( it.mapRecoveryException() } } + + override suspend fun deviceCurve25519(): String? { + return service.curve25519Key() + } + + override suspend fun deviceEd25519(): String? { + return service.ed25519Key() + } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt index 60ca4df311..2686d03c6b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/pushers/RustPushersService.kt @@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.impl.pushers import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import kotlinx.coroutines.withContext import org.matrix.rustcomponents.sdk.Client import org.matrix.rustcomponents.sdk.HttpPusherData @@ -54,8 +55,16 @@ class RustPushersService( } } - override suspend fun unsetHttpPusher(): Result { - // TODO Missing client API. We need to set the pusher with Kind == null, but we do not have access to this field from the SDK. - return Result.success(Unit) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result { + return withContext(dispatchers.io) { + runCatching { + client.deletePusher( + identifiers = PusherIdentifiers( + pushkey = unsetHttpPusherData.pushKey, + appId = unsetHttpPusherData.appId + ), + ) + } + } } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt index 14ea994d38..04e3632d09 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/timeline/item/event/TimelineEventContentMapper.kt @@ -88,8 +88,9 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap } is TimelineItemContentKind.RoomMembership -> { RoomMembershipContent( - UserId(kind.userId), - kind.change?.map() + userId = UserId(kind.userId), + userDisplayName = kind.userDisplayName, + change = kind.change?.map() ) } is TimelineItemContentKind.State -> { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt index 88403e8928..5b6d960d09 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/verification/RustSessionVerificationService.kt @@ -30,8 +30,8 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.asStateFlow -import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.launchIn +import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch @@ -80,10 +80,14 @@ class RustSessionVerificationService( private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus.asStateFlow() - override val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) + /** + * The internal service that checks verification can only run after the initial sync. + * This [StateFlow] will notify consumers when the service is ready to be used. + */ + private val isReady = isSyncServiceReady.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, false) - override val canVerifySessionFlow = combine(sessionVerifiedStatus, isReady) { verificationStatus, isReady -> - isReady && verificationStatus == SessionVerifiedStatus.NotVerified + override val needsSessionVerification = sessionVerifiedStatus.map { verificationStatus -> + verificationStatus == SessionVerifiedStatus.NotVerified } init { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt index 8bbdf47e21..6296f219de 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/join/DefaultJoinRoomTest.kt @@ -57,9 +57,9 @@ class DefaultJoinRoomTest { .isNeverCalled() joinRoomLambda .assertions() - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID)) + .isCalledOnce() + .with( + value(A_ROOM_ID) ) assertThat(analyticsService.capturedEvents).containsExactly( roomResult.toAnalyticsJoinedRoom(aTrigger) @@ -88,9 +88,10 @@ class DefaultJoinRoomTest { sut.invoke(A_ROOM_ID, A_SERVER_LIST, aTrigger) joinRoomByIdOrAliasLambda .assertions() - .isCalledExactly(1) - .withSequence( - listOf(value(A_ROOM_ID), value(A_SERVER_LIST)) + .isCalledOnce() + .with( + value(A_ROOM_ID), + value(A_SERVER_LIST) ) joinRoomLambda .assertions() diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt index 2c72481a25..63b0664bc7 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/timeline/postprocessor/RoomBeginningPostProcessorTest.kt @@ -34,7 +34,7 @@ class RoomBeginningPostProcessorTest { fun `processor removes room creation event and self-join event from DM timeline`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = false) @@ -44,13 +44,13 @@ class RoomBeginningPostProcessorTest { @Test fun `processor removes room creation event and self-join event from DM timeline even if they're not the first items`() { val timelineItems = listOf( - MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))), MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val expected = listOf( - MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member_other", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))), MatrixTimelineItem.Event("m.room.message", anEventTimelineItem(content = aMessageContent("hi"))), ) val processor = RoomBeginningPostProcessor() @@ -62,7 +62,7 @@ class RoomBeginningPostProcessorTest { fun `processor will add beginning of room item if it's not a DM`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = false, hasMoreToLoadBackwards = false) @@ -85,7 +85,7 @@ class RoomBeginningPostProcessorTest { fun `processor won't remove items if it's not at the start of the timeline`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) @@ -95,7 +95,7 @@ class RoomBeginningPostProcessorTest { @Test fun `processor won't remove the first member join event if it can't find the room creation event`() { val timelineItems = listOf( - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) @@ -106,7 +106,7 @@ class RoomBeginningPostProcessorTest { fun `processor won't remove the first member join event if it's not from the room creator`() { val timelineItems = listOf( MatrixTimelineItem.Event("m.room.create", anEventTimelineItem(sender = A_USER_ID, content = StateContent("", OtherState.RoomCreate))), - MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, MembershipChange.JOINED))), + MatrixTimelineItem.Event("m.room.member", anEventTimelineItem(content = RoomMembershipContent(A_USER_ID_2, null, MembershipChange.JOINED))), ) val processor = RoomBeginningPostProcessor() val processedItems = processor.process(timelineItems, isDm = true, hasMoreToLoadBackwards = true) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index def7bc01fd..be029705fb 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -20,7 +20,6 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.ProgressCallback import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.matrix.api.core.RoomIdOrAlias import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.createroom.CreateRoomParameters @@ -41,7 +40,7 @@ import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.notification.FakeNotificationService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.pushers.FakePushersService @@ -54,7 +53,6 @@ import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.delay import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.flowOf @@ -68,7 +66,7 @@ class FakeMatrixClient( private val userDisplayName: String? = A_USER_NAME, private val userAvatarUrl: String? = AN_AVATAR_URL, override val roomListService: RoomListService = FakeRoomListService(), - override val mediaLoader: MatrixMediaLoader = FakeMediaLoader(), + override val mediaLoader: MatrixMediaLoader = FakeMatrixMediaLoader(), private val sessionVerificationService: FakeSessionVerificationService = FakeSessionVerificationService(), private val pushersService: FakePushersService = FakePushersService(), private val notificationService: FakeNotificationService = FakeNotificationService(), @@ -78,7 +76,7 @@ class FakeMatrixClient( private val roomDirectoryService: RoomDirectoryService = FakeRoomDirectoryService(), private val accountManagementUrlString: Result = Result.success(null), private val resolveRoomAliasResult: (RoomAlias) -> Result = { Result.success(ResolvedRoomAlias(A_ROOM_ID, emptyList())) }, - private val getRoomPreviewResult: (RoomIdOrAlias) -> Result = { Result.failure(AN_EXCEPTION) }, + private val getRoomPreviewFromRoomIdResult: (RoomId, List) -> Result = { _, _ -> Result.failure(AN_EXCEPTION) }, ) : MatrixClient { var setDisplayNameCalled: Boolean = false private set @@ -96,7 +94,6 @@ class FakeMatrixClient( private var createRoomResult: Result = Result.success(A_ROOM_ID) private var createDmResult: Result = Result.success(A_ROOM_ID) private var findDmResult: RoomId? = A_ROOM_ID - private var logoutFailure: Throwable? = null private val getRoomResults = mutableMapOf() private val searchUserResults = mutableMapOf>() private val getProfileResults = mutableMapOf>() @@ -116,6 +113,9 @@ class FakeMatrixClient( var getRoomInfoFlowLambda = { _: RoomId -> flowOf>(Optional.empty()) } + var logoutLambda: (Boolean) -> String? = { + null + } override suspend fun getRoom(roomId: RoomId): MatrixRoom? { return getRoomResults[roomId] @@ -160,12 +160,8 @@ class FakeMatrixClient( override suspend fun clearCache() { } - override suspend fun logout(ignoreSdkError: Boolean): String? { - delay(100) - if (ignoreSdkError.not()) { - logoutFailure?.let { throw it } - } - return null + override suspend fun logout(ignoreSdkError: Boolean): String? = simulateLongTask { + return logoutLambda(ignoreSdkError) } override fun close() = Unit @@ -229,10 +225,6 @@ class FakeMatrixClient( // Mocks - fun givenLogoutError(failure: Throwable?) { - logoutFailure = failure - } - fun givenCreateRoomResult(result: Result) { createRoomResult = result } @@ -297,8 +289,8 @@ class FakeMatrixClient( resolveRoomAliasResult(roomAlias) } - override suspend fun getRoomPreview(roomIdOrAlias: RoomIdOrAlias): Result = simulateLongTask { - getRoomPreviewResult(roomIdOrAlias) + override suspend fun getRoomPreviewFromRoomId(roomId: RoomId, serverNames: List) = simulateLongTask { + getRoomPreviewFromRoomIdResult(roomId, serverNames) } override suspend fun getRecentlyVisitedRooms(): Result> { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt index e507e34033..d10d1ad0d2 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/TestData.kt @@ -30,6 +30,7 @@ import io.element.android.libraries.matrix.api.room.RoomNotificationSettings const val A_USER_NAME = "alice" const val A_PASSWORD = "password" +const val A_SECRET = "secret" val A_USER_ID = UserId("@alice:server.org") val A_USER_ID_2 = UserId("@bob:server.org") diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt similarity index 89% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt index 2cf6b77a78..9cec3cbecf 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeAuthenticationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/auth/FakeMatrixAuthenticationService.kt @@ -31,7 +31,9 @@ import kotlinx.coroutines.flow.flowOf val A_OIDC_DATA = OidcDetails(url = "a-url") -class FakeAuthenticationService : MatrixAuthenticationService { +class FakeMatrixAuthenticationService( + private val matrixClientResult: ((SessionId) -> Result)? = null +) : MatrixAuthenticationService { private val homeserver = MutableStateFlow(null) private var oidcError: Throwable? = null private var oidcCancelError: Throwable? = null @@ -39,15 +41,18 @@ class FakeAuthenticationService : MatrixAuthenticationService { private var changeServerError: Throwable? = null private var matrixClient: MatrixClient? = null + var getLatestSessionIdLambda: (() -> SessionId?) = { null } + override fun loggedInStateFlow(): Flow { return flowOf(LoggedInState.NotLoggedIn) } - override suspend fun getLatestSessionId(): SessionId? { - return null - } + override suspend fun getLatestSessionId(): SessionId? = getLatestSessionIdLambda() override suspend fun restoreSession(sessionId: SessionId): Result { + if (matrixClientResult != null) { + return matrixClientResult.invoke(sessionId) + } return if (matrixClient != null) { Result.success(matrixClient!!) } else { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt index 2a70a56051..52c15d05a4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/core/BuildMeta.kt @@ -28,7 +28,7 @@ fun aBuildMeta( applicationId: String = "", lowPrivacyLoggingEnabled: Boolean = true, versionName: String = "", - versionCode: Int = 0, + versionCode: Long = 0, gitRevision: String = "", gitBranchName: String = "", flavorDescription: String = "", diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt index cc7f53eca3..b864c69b0b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeEncryptionService.kt @@ -39,6 +39,9 @@ class FakeEncryptionService : EncryptionService { private var enableBackupsFailure: Exception? = null + private var curve25519: String? = null + private var ed25519: String? = null + fun givenEnableBackupsFailure(exception: Exception?) { enableBackupsFailure = exception } @@ -94,6 +97,15 @@ class FakeEncryptionService : EncryptionService { return waitForBackupUploadSteadyStateFlow } + fun givenDeviceKeys(curve25519: String?, ed25519: String?) { + this.curve25519 = curve25519 + this.ed25519 = ed25519 + } + + override suspend fun deviceCurve25519(): String? = curve25519 + + override suspend fun deviceEd25519(): String? = ed25519 + suspend fun emitBackupState(state: BackupState) { backupStateStateFlow.emit(state) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt similarity index 97% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt index c31beb8e4a..b161a082e7 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMediaLoader.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/media/FakeMatrixMediaLoader.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.matrix.api.media.MediaFile import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.tests.testutils.simulateLongTask -class FakeMediaLoader : MatrixMediaLoader { +class FakeMatrixMediaLoader : MatrixMediaLoader { var shouldFail = false var path: String = "" diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt index 273899e762..f700c3b6af 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkBuilder.kt @@ -20,9 +20,9 @@ import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder class FakePermalinkBuilder( - private val result: () -> Result = { Result.failure(Exception("Not implemented")) } + private val result: (UserId) -> Result = { Result.failure(Exception("Not implemented")) } ) : PermalinkBuilder { override fun permalinkForUser(userId: UserId): Result { - return result() + return result(userId) } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt index d1ffb70f99..525746e690 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/permalink/FakePermalinkParser.kt @@ -18,9 +18,10 @@ package io.element.android.libraries.matrix.test.permalink import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser +import io.element.android.tests.testutils.lambda.lambdaError class FakePermalinkParser( - private var result: () -> PermalinkData = { TODO("Not implemented") } + private var result: () -> PermalinkData = { lambdaError() } ) : PermalinkParser { fun givenResult(result: PermalinkData) { this.result = { result } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt index 6ff7e4a20b..3ede3b272f 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/pushers/FakePushersService.kt @@ -18,8 +18,13 @@ package io.element.android.libraries.matrix.test.pushers import io.element.android.libraries.matrix.api.pusher.PushersService import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.tests.testutils.lambda.lambdaError -class FakePushersService : PushersService { - override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = Result.success(Unit) - override suspend fun unsetHttpPusher(): Result = Result.success(Unit) +class FakePushersService( + private val setHttpPusherResult: (SetHttpPusherData) -> Result = { lambdaError() }, + private val unsetHttpPusherResult: (UnsetHttpPusherData) -> Result = { lambdaError() }, +) : PushersService { + override suspend fun setHttpPusher(setHttpPusherData: SetHttpPusherData) = setHttpPusherResult(setHttpPusherData) + override suspend fun unsetHttpPusher(unsetHttpPusherData: UnsetHttpPusherData): Result = unsetHttpPusherResult(unsetHttpPusherData) } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt index a780d33ecd..4b4a8e1af0 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/room/FakeMatrixRoom.kt @@ -55,7 +55,7 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID import io.element.android.libraries.matrix.test.media.FakeMediaUploadHandler import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.timeline.FakeTimeline -import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver +import io.element.android.libraries.matrix.test.widget.FakeMatrixWidgetDriver import io.element.android.tests.testutils.simulateLongTask import kotlinx.collections.immutable.ImmutableMap import kotlinx.collections.immutable.persistentMapOf @@ -125,7 +125,7 @@ class FakeMatrixRoom( private var endPollResult = Result.success(Unit) private var progressCallbackValues = emptyList>() private var generateWidgetWebViewUrlResult = Result.success("https://call.element.io") - private var getWidgetDriverResult: Result = Result.success(FakeWidgetDriver()) + private var getWidgetDriverResult: Result = Result.success(FakeMatrixWidgetDriver()) private var canUserTriggerRoomNotificationResult: Result = Result.success(true) private var canUserJoinCallResult: Result = Result.success(true) private var setIsFavoriteResult = Result.success(Unit) diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt index cde77d7c61..780758acbe 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/verification/FakeSessionVerificationService.kt @@ -25,17 +25,14 @@ import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow class FakeSessionVerificationService : SessionVerificationService { - private val _isReady = MutableStateFlow(false) private val _sessionVerifiedStatus = MutableStateFlow(SessionVerifiedStatus.Unknown) private var _verificationFlowState = MutableStateFlow(VerificationFlowState.Initial) - private var _canVerifySessionFlow = MutableStateFlow(true) + private var _needsSessionVerification = MutableStateFlow(true) var shouldFail = false override val verificationFlowState: StateFlow = _verificationFlowState override val sessionVerifiedStatus: StateFlow = _sessionVerifiedStatus - override val canVerifySessionFlow: Flow = _canVerifySessionFlow - - override val isReady: StateFlow = _isReady + override val needsSessionVerification: Flow = _needsSessionVerification override suspend fun requestVerification() { if (!shouldFail) { @@ -85,12 +82,8 @@ class FakeSessionVerificationService : SessionVerificationService { _verificationFlowState.value = state } - fun givenCanVerifySession(canVerify: Boolean) { - _canVerifySessionFlow.value = canVerify - } - - fun givenIsReady(value: Boolean) { - _isReady.value = value + fun givenNeedsSessionVerification(needsVerification: Boolean) { + _needsSessionVerification.value = needsVerification } override suspend fun reset() { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt similarity index 98% rename from libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt rename to libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt index a64691fb40..be60b0011b 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeWidgetDriver.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/widget/FakeMatrixWidgetDriver.kt @@ -20,7 +20,7 @@ import io.element.android.libraries.matrix.api.widget.MatrixWidgetDriver import kotlinx.coroutines.flow.MutableSharedFlow import java.util.UUID -class FakeWidgetDriver( +class FakeMatrixWidgetDriver( override val id: String = UUID.randomUUID().toString(), ) : MatrixWidgetDriver { private val _sentMessages = mutableListOf() diff --git a/libraries/matrixui/src/main/res/values-ka/translations.xml b/libraries/matrixui/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..07619e7dfd --- /dev/null +++ b/libraries/matrixui/src/main/res/values-ka/translations.xml @@ -0,0 +1,4 @@ + + + "%1$s (%2$s) მოგიწვიათ" + diff --git a/libraries/matrixui/src/main/res/values-pt/translations.xml b/libraries/matrixui/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/matrixui/src/main/res/values-pt/translations.xml rename to libraries/matrixui/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt index 42a0a93c64..67d8f7c3a1 100644 --- a/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt +++ b/libraries/mediaviewer/api/src/test/kotlin/io/element/android/libraries/mediaviewer/MediaViewerPresenterTest.kt @@ -25,7 +25,7 @@ import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher -import io.element.android.libraries.matrix.test.media.FakeMediaLoader +import io.element.android.libraries.matrix.test.media.FakeMatrixMediaLoader import io.element.android.libraries.matrix.test.media.aMediaSource import io.element.android.libraries.mediaviewer.api.local.anApkMediaInfo import io.element.android.libraries.mediaviewer.api.viewer.MediaViewerEvents @@ -51,9 +51,9 @@ class MediaViewerPresenterTest { @Test fun `present - download media success scenario`() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -71,10 +71,10 @@ class MediaViewerPresenterTest { @Test fun `present - check all actions `() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() val snackbarDispatcher = SnackbarDispatcher() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions, snackbarDispatcher) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions, snackbarDispatcher) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { @@ -118,13 +118,13 @@ class MediaViewerPresenterTest { @Test fun `present - download media failure then retry with success scenario`() = runTest { - val mediaLoader = FakeMediaLoader() + val matrixMediaLoader = FakeMatrixMediaLoader() val mediaActions = FakeLocalMediaActions() - val presenter = createMediaViewerPresenter(mediaLoader, mediaActions) + val presenter = createMediaViewerPresenter(matrixMediaLoader, mediaActions) moleculeFlow(RecompositionMode.Immediate) { presenter.present() }.test { - mediaLoader.shouldFail = true + matrixMediaLoader.shouldFail = true val initialState = awaitItem() assertThat(initialState.downloadedMedia).isEqualTo(AsyncData.Uninitialized) assertThat(initialState.mediaInfo).isEqualTo(TESTED_MEDIA_INFO) @@ -132,7 +132,7 @@ class MediaViewerPresenterTest { assertThat(loadingState.downloadedMedia).isInstanceOf(AsyncData.Loading::class.java) val failureState = awaitItem() assertThat(failureState.downloadedMedia).isInstanceOf(AsyncData.Failure::class.java) - mediaLoader.shouldFail = false + matrixMediaLoader.shouldFail = false failureState.eventSink(MediaViewerEvents.RetryLoading) // There is one recomposition because of the retry mechanism skipItems(1) @@ -146,7 +146,7 @@ class MediaViewerPresenterTest { } private fun createMediaViewerPresenter( - mediaLoader: FakeMediaLoader, + matrixMediaLoader: FakeMatrixMediaLoader, localMediaActions: FakeLocalMediaActions, snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), canShare: Boolean = true, @@ -161,7 +161,7 @@ class MediaViewerPresenterTest { canDownload = canDownload, ), localMediaFactory = localMediaFactory, - mediaLoader = mediaLoader, + mediaLoader = matrixMediaLoader, localMediaActions = localMediaActions, snackbarDispatcher = snackbarDispatcher, ) diff --git a/libraries/permissions/api/src/main/res/values-ka/translations.xml b/libraries/permissions/api/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..ffeb3a4312 --- /dev/null +++ b/libraries/permissions/api/src/main/res/values-ka/translations.xml @@ -0,0 +1,7 @@ + + + "იმისათვის, რომ აპლიკაციამ გამოიყენოს კამერა, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "იმისათვის, რომ აპლიკაციამ მიკროფონი გამოიყენოს, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + "იმისათვის, რომ აპლიკაციამ გამოაჩინოს შეტყობინებები, გთხოვთ, მიანიჭოთ ნებართვა სისტემის პარამეტრებში." + diff --git a/libraries/permissions/api/src/main/res/values-pt/translations.xml b/libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/permissions/api/src/main/res/values-pt/translations.xml rename to libraries/permissions/api/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/permissions/impl/src/main/res/values-pt/translations.xml b/libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/permissions/impl/src/main/res/values-pt/translations.xml rename to libraries/permissions/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt index 4e78978873..8bdea34727 100644 --- a/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt +++ b/libraries/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/store/AppPreferencesStore.kt @@ -19,9 +19,6 @@ package io.element.android.features.preferences.api.store import kotlinx.coroutines.flow.Flow interface AppPreferencesStore { - suspend fun setRichTextEditorEnabled(enabled: Boolean) - fun isRichTextEditorEnabledFlow(): Flow - suspend fun setDeveloperModeEnabled(enabled: Boolean) fun isDeveloperModeEnabledFlow(): Flow diff --git a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt index fdbd7dde8c..95b455a99f 100644 --- a/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt +++ b/libraries/preferences/impl/src/main/kotlin/io/element/android/libraries/preferences/impl/store/DefaultAppPreferencesStore.kt @@ -25,7 +25,6 @@ import androidx.datastore.preferences.core.stringPreferencesKey import androidx.datastore.preferences.preferencesDataStore import com.squareup.anvil.annotations.ContributesBinding import io.element.android.features.preferences.api.store.AppPreferencesStore -import io.element.android.libraries.core.bool.orTrue import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.di.AppScope @@ -36,7 +35,6 @@ import javax.inject.Inject private val Context.dataStore: DataStore by preferencesDataStore(name = "elementx_preferences") -private val richTextEditorKey = booleanPreferencesKey("richTextEditor") private val developerModeKey = booleanPreferencesKey("developerMode") private val customElementCallBaseUrlKey = stringPreferencesKey("elementCallBaseUrl") private val themeKey = stringPreferencesKey("theme") @@ -48,19 +46,6 @@ class DefaultAppPreferencesStore @Inject constructor( ) : AppPreferencesStore { private val store = context.dataStore - override suspend fun setRichTextEditorEnabled(enabled: Boolean) { - store.edit { prefs -> - prefs[richTextEditorKey] = enabled - } - } - - override fun isRichTextEditorEnabledFlow(): Flow { - return store.data.map { prefs -> - // enabled by default - prefs[richTextEditorKey].orTrue() - } - } - override suspend fun setDeveloperModeEnabled(enabled: Boolean) { store.edit { prefs -> prefs[developerModeKey] = enabled diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt similarity index 97% rename from libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt rename to libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt index 264ac4ec3a..96761a9712 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferenceStoreFactory.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/FakeSessionPreferencesStoreFactory.kt @@ -24,7 +24,7 @@ import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.CoroutineScope -class FakeSessionPreferenceStoreFactory( +class FakeSessionPreferencesStoreFactory( var getLambda: LambdaTwoParamsRecorder = lambdaRecorder { _, _ -> throw NotImplementedError() }, var removeLambda: LambdaOneParamRecorder = lambdaRecorder { _ -> }, ) : SessionPreferencesStoreFactory { diff --git a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt index e29c4758ca..25563d59eb 100644 --- a/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt +++ b/libraries/preferences/test/src/main/kotlin/io/element/android/libraries/preferences/test/InMemoryAppPreferencesStore.kt @@ -21,24 +21,14 @@ import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow class InMemoryAppPreferencesStore( - isRichTextEditorEnabled: Boolean = false, isDeveloperModeEnabled: Boolean = false, customElementCallBaseUrl: String? = null, theme: String? = null, ) : AppPreferencesStore { - private val isRichTextEditorEnabled = MutableStateFlow(isRichTextEditorEnabled) private val isDeveloperModeEnabled = MutableStateFlow(isDeveloperModeEnabled) private val customElementCallBaseUrl = MutableStateFlow(customElementCallBaseUrl) private val theme = MutableStateFlow(theme) - override suspend fun setRichTextEditorEnabled(enabled: Boolean) { - isRichTextEditorEnabled.value = enabled - } - - override fun isRichTextEditorEnabledFlow(): Flow { - return isRichTextEditorEnabled - } - override suspend fun setDeveloperModeEnabled(enabled: Boolean) { isDeveloperModeEnabled.value = enabled } diff --git a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt index abfc328e9f..ce27acb7b3 100644 --- a/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt +++ b/libraries/push/api/src/main/kotlin/io/element/android/libraries/push/api/PushService.kt @@ -21,8 +21,10 @@ import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider interface PushService { - // TODO Move away - fun notificationStyleChanged() + /** + * Return the current push provider, or null if none. + */ + suspend fun getCurrentPushProvider(): PushProvider? /** * Return the list of push providers, available at compile time, and @@ -35,7 +37,11 @@ interface PushService { * * The method has effect only if the [PushProvider] is different than the current one. */ - suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) + suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result /** * Return false in case of early error. diff --git a/libraries/push/impl/build.gradle.kts b/libraries/push/impl/build.gradle.kts index ee528a4ae7..05e249798f 100644 --- a/libraries/push/impl/build.gradle.kts +++ b/libraries/push/impl/build.gradle.kts @@ -72,6 +72,7 @@ dependencies { testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.pushproviders.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.toolbox.impl) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt index cff18cfb3d..47e26fb920 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPushService.kt @@ -21,22 +21,23 @@ import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.push.api.GetCurrentPushProvider import io.element.android.libraries.push.api.PushService -import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.test.TestPush import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import timber.log.Timber import javax.inject.Inject @ContributesBinding(AppScope::class) class DefaultPushService @Inject constructor( - private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, - private val pushersManager: PushersManager, + private val testPush: TestPush, private val userPushStoreFactory: UserPushStoreFactory, private val pushProviders: Set<@JvmSuppressWildcards PushProvider>, private val getCurrentPushProvider: GetCurrentPushProvider, ) : PushService { - override fun notificationStyleChanged() { - defaultNotificationDrawerManager.notificationStyleChanged() + override suspend fun getCurrentPushProvider(): PushProvider? { + val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() + return pushProviders.find { it.name == currentPushProvider } } override fun getAvailablePushProviders(): List { @@ -45,26 +46,36 @@ class DefaultPushService @Inject constructor( .sortedBy { it.index } } - /** - * Get current push provider, compare with provided one, then unregister and register if different, and store change. - */ - override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result { + Timber.d("Registering with ${pushProvider.name}/${distributor.name}}") val userPushStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) val currentPushProviderName = userPushStore.getPushProviderName() - if (currentPushProviderName != pushProvider.name) { + val currentPushProvider = pushProviders.find { it.name == currentPushProviderName } + val currentDistributorValue = currentPushProvider?.getCurrentDistributor(matrixClient)?.value + if (currentPushProviderName != pushProvider.name || currentDistributorValue != distributor.value) { // Unregister previous one if any - pushProviders.find { it.name == currentPushProviderName }?.unregister(matrixClient) + currentPushProvider + ?.also { Timber.d("Unregistering previous push provider $currentPushProviderName/$currentDistributorValue") } + ?.unregister(matrixClient) + ?.onFailure { + Timber.w(it, "Failed to unregister previous push provider") + return Result.failure(it) + } } - pushProvider.registerWith(matrixClient, distributor) // Store new value userPushStore.setPushProviderName(pushProvider.name) + // Then try to register + return pushProvider.registerWith(matrixClient, distributor) } override suspend fun testPush(): Boolean { - val currentPushProvider = getCurrentPushProvider.getCurrentPushProvider() - val pushProvider = pushProviders.find { it.name == currentPushProvider } ?: return false + val pushProvider = getCurrentPushProvider() ?: return false val config = pushProvider.getCurrentUserPushConfig() ?: return false - pushersManager.testPush(config) + testPush.execute(config) return true } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt similarity index 67% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt index 4306072e48..481081de1f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/PushersManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriber.kt @@ -22,12 +22,9 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.MatrixClient -import io.element.android.libraries.matrix.api.core.EventId -import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData -import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest -import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret @@ -36,48 +33,37 @@ import javax.inject.Inject internal const val DEFAULT_PUSHER_FILE_TAG = "mobile" -private val loggerTag = LoggerTag("PushersManager", LoggerTag.PushLoggerTag) +private val loggerTag = LoggerTag("DefaultPusherSubscriber", LoggerTag.PushLoggerTag) @ContributesBinding(AppScope::class) -class PushersManager @Inject constructor( - // private val localeProvider: LocaleProvider, +class DefaultPusherSubscriber @Inject constructor( private val buildMeta: BuildMeta, - // private val getDeviceInfoUseCase: GetDeviceInfoUseCase, - private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, private val pushClientSecret: PushClientSecret, private val userPushStoreFactory: UserPushStoreFactory, ) : PusherSubscriber { - suspend fun testPush(config: CurrentUserPushConfig) { - pushGatewayNotifyRequest.execute( - PushGatewayNotifyRequest.Params( - url = config.url, - appId = PushConfig.PUSHER_APP_ID, - pushKey = config.pushKey, - eventId = TEST_EVENT_ID, - roomId = TEST_ROOM_ID, - ) - ) - } - /** * Register a pusher to the server if not done yet. */ - override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { + override suspend fun registerPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) if (userDataStore.getCurrentRegisteredPushKey() == pushKey) { Timber.tag(loggerTag.value) .d("Unnecessary to register again the same pusher, but do it in case the pusher has been removed from the server") } - matrixClient.pushersService().setHttpPusher( - createHttpPusher(pushKey, gateway, matrixClient.sessionId) - ).fold( - { + return matrixClient.pushersService() + .setHttpPusher( + createHttpPusher(pushKey, gateway, matrixClient.sessionId) + ) + .onSuccess { userDataStore.setCurrentRegisteredPushKey(pushKey) - }, - { throwable -> + } + .onFailure { throwable -> Timber.tag(loggerTag.value).e(throwable, "Unable to register the pusher") } - ) } private suspend fun createHttpPusher( @@ -106,12 +92,24 @@ class PushersManager @Inject constructor( return "{\"cs\":\"$secretForUser\"}" } - override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) { - matrixClient.pushersService().unsetHttpPusher() - } - - companion object { - val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") - val TEST_ROOM_ID = RoomId("!room:domain") + override suspend fun unregisterPusher( + matrixClient: MatrixClient, + pushKey: String, + gateway: String, + ): Result { + val userDataStore = userPushStoreFactory.getOrCreate(matrixClient.sessionId) + return matrixClient.pushersService() + .unsetHttpPusher( + unsetHttpPusherData = UnsetHttpPusherData( + pushKey = pushKey, + appId = PushConfig.PUSHER_APP_ID + ) + ) + .onSuccess { + userDataStore.setCurrentRegisteredPushKey(null) + } + .onFailure { throwable -> + Timber.tag(loggerTag.value).e(throwable, "Unable to unregister the pusher") + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt similarity index 96% rename from libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt rename to libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt index edc773b89b..0ab01937f4 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolver.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolver.kt @@ -19,7 +19,9 @@ package io.element.android.libraries.push.impl.notifications import android.content.Context import android.net.Uri import androidx.core.content.FileProvider +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClientProvider @@ -55,7 +57,7 @@ import io.element.android.services.toolbox.api.systemclock.SystemClock import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("NotifiableEventResolver", LoggerTag.NotificationLoggerTag) +private val loggerTag = LoggerTag("DefaultNotifiableEventResolver", LoggerTag.NotificationLoggerTag) /** * The notifiable event resolver is able to create a NotifiableEvent (view model for notifications) from an sdk Event. @@ -63,15 +65,20 @@ private val loggerTag = LoggerTag("NotifiableEventResolver", LoggerTag.Notificat * The NotifiableEventResolver is the only aware of session/store, the NotificationDrawerManager has no knowledge of that, * this pattern allow decoupling between the object responsible of displaying notifications and the matrix sdk. */ -class NotifiableEventResolver @Inject constructor( +interface NotifiableEventResolver { + suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? +} + +@ContributesBinding(AppScope::class) +class DefaultNotifiableEventResolver @Inject constructor( private val stringProvider: StringProvider, private val clock: SystemClock, private val matrixClientProvider: MatrixClientProvider, private val notificationMediaRepoFactory: NotificationMediaRepo.Factory, @ApplicationContext private val context: Context, private val permalinkParser: PermalinkParser, -) { - suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { +) : NotifiableEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { // Restore session val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return null val notificationService = client.notificationService() diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt index 7c9a60c279..f4cd1bab39 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManager.kt @@ -221,18 +221,6 @@ class DefaultNotificationDrawerManager @Inject constructor( } } - // TODO EAx Must be per account - fun notificationStyleChanged() { - updateEvents(doRender = true) { - val newSettings = true // pushDataStore.useCompleteNotificationFormat() - if (newSettings != useCompleteNotificationFormat) { - // Settings has changed, remove all current notifications - notificationRenderer.cancelAllNotifications() - useCompleteNotificationFormat = newSettings - } - } - } - private fun updateEvents( doRender: Boolean, action: (NotificationEventQueue) -> Unit, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt index 2c9abb77b3..1d3f349364 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandler.kt @@ -16,27 +16,19 @@ package io.element.android.libraries.push.impl.push -import android.os.Handler -import android.os.Looper import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService -import io.element.android.libraries.push.impl.PushersManager -import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver -import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import io.element.android.libraries.push.impl.test.DefaultTestPush import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler import io.element.android.libraries.pushproviders.api.PushData import io.element.android.libraries.pushproviders.api.PushHandler import io.element.android.libraries.pushstore.api.UserPushStoreFactory import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.flow.first -import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -44,23 +36,15 @@ private val loggerTag = LoggerTag("PushHandler", LoggerTag.PushLoggerTag) @ContributesBinding(AppScope::class) class DefaultPushHandler @Inject constructor( - private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, + private val onNotifiableEventReceived: OnNotifiableEventReceived, private val notifiableEventResolver: NotifiableEventResolver, - private val defaultPushDataStore: DefaultPushDataStore, + private val incrementPushDataStore: IncrementPushDataStore, private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, - // private val actionIds: NotificationActionIds, private val buildMeta: BuildMeta, private val matrixAuthenticationService: MatrixAuthenticationService, private val diagnosticPushHandler: DiagnosticPushHandler, ) : PushHandler { - private val coroutineScope = CoroutineScope(SupervisorJob()) - - // UI handler - private val uiHandler by lazy { - Handler(Looper.getMainLooper()) - } - /** * Called when message is received. * @@ -68,21 +52,15 @@ class DefaultPushHandler @Inject constructor( */ override suspend fun handle(pushData: PushData) { Timber.tag(loggerTag.value).d("## handling pushData: ${pushData.roomId}/${pushData.eventId}") - if (buildMeta.lowPrivacyLoggingEnabled) { Timber.tag(loggerTag.value).d("## pushData: $pushData") } - - defaultPushDataStore.incrementPushCounter() - + incrementPushDataStore.incrementPushCounter() // Diagnostic Push - if (pushData.eventId == PushersManager.TEST_EVENT_ID) { + if (pushData.eventId == DefaultTestPush.TEST_EVENT_ID) { diagnosticPushHandler.handlePush() - return - } - - uiHandler.post { - coroutineScope.launch(Dispatchers.IO) { handleInternal(pushData) } + } else { + handleInternal(pushData) } } @@ -98,7 +76,6 @@ class DefaultPushHandler @Inject constructor( } else { Timber.tag(loggerTag.value).d("## handleInternal()") } - val clientSecret = pushData.clientSecret // clientSecret should not be null. If this happens, restore default session val userId = clientSecret @@ -109,27 +86,22 @@ class DefaultPushHandler @Inject constructor( ?: run { matrixAuthenticationService.getLatestSessionId() } - if (userId == null) { Timber.w("Unable to get a session") return } - - val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) - - if (notifiableEvent == null) { - Timber.w("Unable to get a notification data") - return - } - val userPushStore = userPushStoreFactory.getOrCreate(userId) - if (!userPushStore.getNotificationEnabledForDevice().first()) { + if (userPushStore.getNotificationEnabledForDevice().first()) { + val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId) + if (notifiableEvent == null) { + Timber.w("Unable to get a notification data") + return + } + onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent) + } else { // TODO We need to check if this is an incoming call Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.") - return } - - defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) } catch (e: Exception) { Timber.tag(loggerTag.value).e(e, "## handleInternal() failed") } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt new file mode 100644 index 0000000000..9a7f99176c --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/IncrementPushDataStore.kt @@ -0,0 +1,35 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.push + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.store.DefaultPushDataStore +import javax.inject.Inject + +interface IncrementPushDataStore { + suspend fun incrementPushCounter() +} + +@ContributesBinding(AppScope::class) +class DefaultIncrementPushDataStore @Inject constructor( + private val defaultPushDataStore: DefaultPushDataStore +) : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + defaultPushDataStore.incrementPushCounter() + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt new file mode 100644 index 0000000000..6d4ad34eb8 --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/push/OnNotifiableEventReceived.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.push + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.push.impl.notifications.DefaultNotificationDrawerManager +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import javax.inject.Inject + +interface OnNotifiableEventReceived { + fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) +} + +@ContributesBinding(AppScope::class) +class DefaultOnNotifiableEventReceived @Inject constructor( + private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager, +) : OnNotifiableEventReceived { + override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + defaultNotificationDrawerManager.onNotifiableEventReceived(notifiableEvent) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt index d8de5429ef..4df39587f1 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayAPI.kt @@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.pushgateway import retrofit2.http.Body import retrofit2.http.POST -internal interface PushGatewayAPI { +interface PushGatewayAPI { /** * Ask the Push Gateway to send a push to the current device. * diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt new file mode 100644 index 0000000000..1ea4c72fbb --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayApiFactory.kt @@ -0,0 +1,36 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.pushgateway + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import javax.inject.Inject + +interface PushGatewayApiFactory { + fun create(baseUrl: String): PushGatewayAPI +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayApiFactory @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : PushGatewayApiFactory { + override fun create(baseUrl: String): PushGatewayAPI { + return retrofitFactory.create(baseUrl) + .create(PushGatewayAPI::class.java) + } +} diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt index 7adedfcfd2..ad5a264168 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayDevice.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayDevice( +data class PushGatewayDevice( /** * Required. The app_id given when the pusher was created. */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt index 5e341e3286..28ad04a078 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotification.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotification( +data class PushGatewayNotification( @SerialName("event_id") val eventId: String, @SerialName("room_id") diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt index ce41d2d83e..14727cab2f 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyBody.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotifyBody( +data class PushGatewayNotifyBody( /** * Required. Information about the push notification */ diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt index e8c01493ab..41c8a05423 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyRequest.kt @@ -15,15 +15,14 @@ */ package io.element.android.libraries.push.impl.pushgateway +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId -import io.element.android.libraries.network.RetrofitFactory import io.element.android.libraries.push.api.gateway.PushGatewayFailure import javax.inject.Inject -class PushGatewayNotifyRequest @Inject constructor( - private val retrofitFactory: RetrofitFactory, -) { +interface PushGatewayNotifyRequest { data class Params( val url: String, val appId: String, @@ -32,13 +31,18 @@ class PushGatewayNotifyRequest @Inject constructor( val roomId: RoomId, ) - suspend fun execute(params: Params) { - val sygnalApi = retrofitFactory.create( + suspend fun execute(params: Params) +} + +@ContributesBinding(AppScope::class) +class DefaultPushGatewayNotifyRequest @Inject constructor( + private val pushGatewayApiFactory: PushGatewayApiFactory, +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + val pushGatewayApi = pushGatewayApiFactory.create( params.url.substringBefore(PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH) ) - .create(PushGatewayAPI::class.java) - - val response = sygnalApi.notify( + val response = pushGatewayApi.notify( PushGatewayNotifyBody( PushGatewayNotification( eventId = params.eventId.value, diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt index 13d9cbad1d..75b5e52111 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/pushgateway/PushGatewayNotifyResponse.kt @@ -20,7 +20,7 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable -internal data class PushGatewayNotifyResponse( +data class PushGatewayNotifyResponse( @SerialName("rejected") val rejectedPushKeys: List ) diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt new file mode 100644 index 0000000000..667918941e --- /dev/null +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/test/TestPush.kt @@ -0,0 +1,52 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.test + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import javax.inject.Inject + +interface TestPush { + suspend fun execute(config: CurrentUserPushConfig) +} + +@ContributesBinding(AppScope::class) +class DefaultTestPush @Inject constructor( + private val pushGatewayNotifyRequest: PushGatewayNotifyRequest, +) : TestPush { + override suspend fun execute(config: CurrentUserPushConfig) { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = config.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = config.pushKey, + eventId = TEST_EVENT_ID, + roomId = TEST_ROOM_ID, + ) + ) + } + + companion object { + val TEST_EVENT_ID = EventId("\$THIS_IS_A_FAKE_EVENT_ID") + val TEST_ROOM_ID = RoomId("!room:domain") + } +} diff --git a/libraries/push/impl/src/main/res/values-be/translations.xml b/libraries/push/impl/src/main/res/values-be/translations.xml index 686d10a281..fd85f11ac3 100644 --- a/libraries/push/impl/src/main/res/values-be/translations.xml +++ b/libraries/push/impl/src/main/res/values-be/translations.xml @@ -52,7 +52,6 @@ "%d пакоя" "%d пакояў" - "Выберыце спосаб атрымання апавяшчэнняў" "Фонавая сінхранізацыя" "Сэрвісы Google" "Службы Google Play не знойдзены. Апавяшчэнні могуць не працаваць належным чынам." diff --git a/libraries/push/impl/src/main/res/values-cs/translations.xml b/libraries/push/impl/src/main/res/values-cs/translations.xml index fac2a69784..c2d93dc94b 100644 --- a/libraries/push/impl/src/main/res/values-cs/translations.xml +++ b/libraries/push/impl/src/main/res/values-cs/translations.xml @@ -52,7 +52,6 @@ "%d místnosti" "%d místností" - "Vyberte, jak chcete přijímat oznámení" "Synchronizace na pozadí" "Služby Google" "Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně." diff --git a/libraries/push/impl/src/main/res/values-de/translations.xml b/libraries/push/impl/src/main/res/values-de/translations.xml index 2757e89dea..0d1988ecba 100644 --- a/libraries/push/impl/src/main/res/values-de/translations.xml +++ b/libraries/push/impl/src/main/res/values-de/translations.xml @@ -46,7 +46,6 @@ "%d Raum" "%d Räume" - "Wähle aus, wie du Benachrichtigungen erhalten möchtest" "Hintergrundsynchronisation" "Google-Dienste" "Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig." diff --git a/libraries/push/impl/src/main/res/values-es/translations.xml b/libraries/push/impl/src/main/res/values-es/translations.xml index 5e548f0571..37f495a163 100644 --- a/libraries/push/impl/src/main/res/values-es/translations.xml +++ b/libraries/push/impl/src/main/res/values-es/translations.xml @@ -46,7 +46,6 @@ "%d sala" "%d salas" - "Elige cómo recibir las notificaciones" "Sincronización en segundo plano" "Servicios de Google" "No se han encontrado Servicios de Google Play válidos. Es posible que las notificaciones no funcionen correctamente." diff --git a/libraries/push/impl/src/main/res/values-fr/translations.xml b/libraries/push/impl/src/main/res/values-fr/translations.xml index 8501b8ad76..6173724762 100644 --- a/libraries/push/impl/src/main/res/values-fr/translations.xml +++ b/libraries/push/impl/src/main/res/values-fr/translations.xml @@ -46,7 +46,6 @@ "%d salon" "%d salons" - "Choisissez le mode de réception des notifications" "Synchronisation en arrière-plan" "Services Google" "Aucun service Google Play valide n’a été trouvé. Les notifications peuvent ne pas fonctionner correctement." diff --git a/libraries/push/impl/src/main/res/values-hu/translations.xml b/libraries/push/impl/src/main/res/values-hu/translations.xml index 7b0c66aa65..75804dd0a2 100644 --- a/libraries/push/impl/src/main/res/values-hu/translations.xml +++ b/libraries/push/impl/src/main/res/values-hu/translations.xml @@ -46,7 +46,6 @@ "%d szoba" "%d szoba" - "Válassza ki az értesítések fogadási módját" "Háttérszinkronizálás" "Google szolgáltatások" "A Google Play szolgáltatások nem találhatók. Előfordulhat, hogy az értesítések nem működnek megfelelően." diff --git a/libraries/push/impl/src/main/res/values-in/translations.xml b/libraries/push/impl/src/main/res/values-in/translations.xml index b933330be1..cfc6621d17 100644 --- a/libraries/push/impl/src/main/res/values-in/translations.xml +++ b/libraries/push/impl/src/main/res/values-in/translations.xml @@ -40,7 +40,6 @@ "%d ruangan" - "Pilih cara menerima notifikasi" "Sinkronisasi latar belakang" "Layanan Google" "Tidak ditemukan Layanan Google Play yang valid. Pemberitahuan mungkin tidak berfungsi dengan baik." diff --git a/libraries/push/impl/src/main/res/values-it/translations.xml b/libraries/push/impl/src/main/res/values-it/translations.xml index 9544a9cb53..a15e3daa14 100644 --- a/libraries/push/impl/src/main/res/values-it/translations.xml +++ b/libraries/push/impl/src/main/res/values-it/translations.xml @@ -46,7 +46,6 @@ "%d stanza" "%d stanze" - "Scegli come ricevere le notifiche" "Sincronizzazione in background" "Servizi Google" "Google Play Services non trovato. Le notifiche non funzioneranno bene." diff --git a/libraries/push/impl/src/main/res/values-ka/translations.xml b/libraries/push/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..4f02db32fa --- /dev/null +++ b/libraries/push/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,50 @@ + + + "ზარი" + "მოვლენებისთვის მოსმენა" + "ხმაურიანი შეტყობინებები" + "ჩუმი შეტყობინებები" + + "%1$s: %2$d შეტყობინება" + "%1$s: %2$d შეტყობინება" + + + "%d შეტყობინება" + "%d შეტყობინება" + + "შეტყობინება" + "** გაგზავნა ვერ მოხერხდა - გთხოვთ, გახსნათ ოთახი" + "გაწევრიანება" + "უარყოფა" + + "%d მოწვევა" + "%d მოწვევები" + + "მოგიწვიათ ჩატში" + "ახალი შეტყობინებები" + + "%d ახალი მესიჯი" + "%d ახალი მესიჯი" + + "რეაგირება მოხდა: %1$s" + "Სწრაფი პასუხი" + "მოგიწვიათ ოთახში" + "მე" + "თქვენ ხედავთ შეტყობინებას! დამაწკაპუნეთ!" + "%1$s: %2$s" + "%1$s: %2$s %3$s" + + "%d წაუკითხავი შეტყობინება" + "%d წაუკითხავი შეტყობინება" + + "%1$s და %2$s" + "%1$s %2$s-ში" + "%1$s %2$s-ში და %3$s" + + "%d ოთახი" + "%d ოთახი" + + "ფონის სინქრონიზაცია" + "Google სერვისები" + "მოქმედი Google Play სერვისები ვერ მოიძებნა. შეტყობინებები შეიძლება ვერ იმუშაოს სწორად." + diff --git a/libraries/push/impl/src/main/res/values-pt/translations.xml b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml similarity index 98% rename from libraries/push/impl/src/main/res/values-pt/translations.xml rename to libraries/push/impl/src/main/res/values-pt-rBR/translations.xml index 6f6eeee423..c5513675f5 100644 --- a/libraries/push/impl/src/main/res/values-pt/translations.xml +++ b/libraries/push/impl/src/main/res/values-pt-rBR/translations.xml @@ -46,7 +46,6 @@ "%d sala" "%d salas" - "Escolhe como receber notificações" "Sincronização em segundo plano" "Serviços do Google Play" "Nenhuns Serviços do Google Play válidos encontrados. As notificações poderão não funcionar devidamente." diff --git a/libraries/push/impl/src/main/res/values-ro/translations.xml b/libraries/push/impl/src/main/res/values-ro/translations.xml index ca0b6f55c5..b9862a69a3 100644 --- a/libraries/push/impl/src/main/res/values-ro/translations.xml +++ b/libraries/push/impl/src/main/res/values-ro/translations.xml @@ -46,7 +46,6 @@ "%d cameră" "%d camere" - "Alegeți modul de primire a notificărilor" "Sincronizare în fundal" "Servicii Google" "Nu au fost găsite servicii Google Play valide. Este posibil ca notificările să nu funcționeze corect." @@ -58,6 +57,7 @@ "Nu s-au găsit furnizori push." "S-a găsit %1$d furnizor push: %2$s" + "S-au găsit %1$d furnizori push: %2$s" "S-au găsit %1$d furnizori push: %2$s" "Detectați furnizorii push" diff --git a/libraries/push/impl/src/main/res/values-ru/translations.xml b/libraries/push/impl/src/main/res/values-ru/translations.xml index 65242651b2..ff78cba4ee 100644 --- a/libraries/push/impl/src/main/res/values-ru/translations.xml +++ b/libraries/push/impl/src/main/res/values-ru/translations.xml @@ -52,7 +52,6 @@ "%d комнаты" "%d комнат" - "Выберите способ получения уведомлений" "Фоновая синхронизация" "Сервисы Google" "Не найдены действующие службы Google Play. Уведомления могут работать некорректно." diff --git a/libraries/push/impl/src/main/res/values-sk/translations.xml b/libraries/push/impl/src/main/res/values-sk/translations.xml index 95aed49bd3..e984d1547c 100644 --- a/libraries/push/impl/src/main/res/values-sk/translations.xml +++ b/libraries/push/impl/src/main/res/values-sk/translations.xml @@ -52,7 +52,6 @@ "%d miestnosti" "%d miestností" - "Vyberte spôsob prijímania oznámení" "Synchronizácia na pozadí" "Služby Google" "Nenašli sa žiadne platné služby Google Play. Oznámenia nemusia fungovať správne." diff --git a/libraries/push/impl/src/main/res/values-sv/translations.xml b/libraries/push/impl/src/main/res/values-sv/translations.xml index 6930a3b13e..8d26670c34 100644 --- a/libraries/push/impl/src/main/res/values-sv/translations.xml +++ b/libraries/push/impl/src/main/res/values-sv/translations.xml @@ -45,7 +45,6 @@ "%d rum" "%d rum" - "Välj hur du vill ta emot aviseringar" "Bakgrundssynkronisering" "Google-tjänster" "Inga giltiga Google Play-tjänster hittades. Aviseringar kanske inte fungerar korrekt." diff --git a/libraries/push/impl/src/main/res/values-uk/translations.xml b/libraries/push/impl/src/main/res/values-uk/translations.xml index 99a9700618..e1a3f8bf47 100644 --- a/libraries/push/impl/src/main/res/values-uk/translations.xml +++ b/libraries/push/impl/src/main/res/values-uk/translations.xml @@ -52,7 +52,6 @@ "%d кімнати" "%d кімнат" - "Виберіть спосіб отримання сповіщень" "Фонова синхронізація" "Сервіси Google" "Не знайдено дійсних сервісів Google Play. Сповіщення можуть не працювати належним чином." diff --git a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml index 86b8661c35..d2c91db3b1 100644 --- a/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh-rTW/translations.xml @@ -22,6 +22,7 @@ "%d 則新訊息" "回應 %1$s" + "標為已讀" "快速回覆" "邀請您加入聊天室" "我" @@ -31,7 +32,6 @@ "%d 個聊天室" - "選擇接收通知的機制" "背景同步" "Google 服務" diff --git a/libraries/push/impl/src/main/res/values-zh/translations.xml b/libraries/push/impl/src/main/res/values-zh/translations.xml index bd22f97e90..9a4e2f47d2 100644 --- a/libraries/push/impl/src/main/res/values-zh/translations.xml +++ b/libraries/push/impl/src/main/res/values-zh/translations.xml @@ -40,7 +40,6 @@ "%d 个房间" - "选择如何接收通知" "后台同步" "谷歌服务" "找不到有效的 Google Play 服务。通知可能无法正常工作。" diff --git a/libraries/push/impl/src/main/res/values/localazy.xml b/libraries/push/impl/src/main/res/values/localazy.xml index c9d7627d07..1064d5c31e 100644 --- a/libraries/push/impl/src/main/res/values/localazy.xml +++ b/libraries/push/impl/src/main/res/values/localazy.xml @@ -46,7 +46,6 @@ "%d room" "%d rooms" - "Choose how to receive notifications" "Background synchronization" "Google Services" "No valid Google Play Services found. Notifications may not work properly." diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt new file mode 100644 index 0000000000..546d69b008 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPushServiceTest.kt @@ -0,0 +1,221 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.api.GetCurrentPushProvider +import io.element.android.libraries.push.impl.test.FakeTestPush +import io.element.android.libraries.push.impl.test.TestPush +import io.element.android.libraries.push.test.FakeGetCurrentPushProvider +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.libraries.pushproviders.test.FakePushProvider +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushServiceTest { + @Test + fun `test push no push provider`() = runTest { + val defaultPushService = createDefaultPushService() + assertThat(defaultPushService.testPush()).isFalse() + } + + @Test + fun `test push no config`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + assertThat(defaultPushService.testPush()).isFalse() + } + + @Test + fun `test push ok`() = runTest { + val aConfig = CurrentUserPushConfig( + url = "aUrl", + pushKey = "aPushKey", + ) + val testPushResult = lambdaRecorder { } + val aPushProvider = FakePushProvider( + currentUserPushConfig = aConfig + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + testPush = FakeTestPush(executeResult = testPushResult), + ) + assertThat(defaultPushService.testPush()).isTrue() + testPushResult.assertions() + .isCalledOnce() + .with(value(aConfig)) + } + + @Test + fun `getCurrentPushProvider null`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getCurrentPushProvider() + assertThat(result).isNull() + } + + @Test + fun `getCurrentPushProvider ok`() = runTest { + val aPushProvider = FakePushProvider() + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aPushProvider.name), + ) + val result = defaultPushService.getCurrentPushProvider() + assertThat(result).isEqualTo(aPushProvider) + } + + @Test + fun `getAvailablePushProviders empty`() = runTest { + val defaultPushService = createDefaultPushService() + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).isEmpty() + } + + @Test + fun `registerWith ok`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.success(Unit) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result).isEqualTo(Result.success(Unit)) + } + + @Test + fun `registerWith fail to register`() = runTest { + val client = FakeMatrixClient() + val aPushProvider = FakePushProvider( + registerWithResult = { _, _ -> Result.failure(AN_EXCEPTION) }, + ) + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService() + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `registerWith fail to unregister previous push provider`() = runTest { + val client = FakeMatrixClient() + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = { Result.failure(AN_EXCEPTION) }, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isFailure).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aCurrentPushProvider.name) + } + + @Test + fun `registerWith unregister previous push provider and register new OK`() = runTest { + val client = FakeMatrixClient() + val unregisterLambda = lambdaRecorder> { Result.success(Unit) } + val registerLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val aCurrentPushProvider = FakePushProvider( + unregisterWithResult = unregisterLambda, + name = "aCurrentPushProvider", + ) + val aPushProvider = FakePushProvider( + registerWithResult = registerLambda, + name = "aPushProvider", + ) + val userPushStore = FakeUserPushStore().apply { + setPushProviderName(aCurrentPushProvider.name) + } + val aDistributor = Distributor("aValue", "aName") + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aCurrentPushProvider, aPushProvider), + getCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = aCurrentPushProvider.name), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPushService.registerWith(client, aPushProvider, aDistributor) + assertThat(result.isSuccess).isTrue() + assertThat(userPushStore.getPushProviderName()).isEqualTo(aPushProvider.name) + unregisterLambda.assertions() + .isCalledOnce() + .with(value(client)) + registerLambda.assertions() + .isCalledOnce() + .with(value(client), value(aDistributor)) + } + + @Test + fun `getAvailablePushProviders sorted`() = runTest { + val aPushProvider1 = FakePushProvider( + index = 1, + name = "aPushProvider1", + ) + val aPushProvider2 = FakePushProvider( + index = 2, + name = "aPushProvider2", + ) + val aPushProvider3 = FakePushProvider( + index = 3, + name = "aPushProvider3", + ) + val defaultPushService = createDefaultPushService( + pushProviders = setOf(aPushProvider1, aPushProvider3, aPushProvider2), + ) + val result = defaultPushService.getAvailablePushProviders() + assertThat(result).containsExactly(aPushProvider1, aPushProvider2, aPushProvider3).inOrder() + } + + private fun createDefaultPushService( + testPush: TestPush = FakeTestPush(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushProviders: Set<@JvmSuppressWildcards PushProvider> = emptySet(), + getCurrentPushProvider: GetCurrentPushProvider = FakeGetCurrentPushProvider(currentPushProvider = null), + ): DefaultPushService { + return DefaultPushService( + testPush = testPush, + userPushStoreFactory = userPushStoreFactory, + pushProviders = pushProviders, + getCurrentPushProvider = getCurrentPushProvider, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt new file mode 100644 index 0000000000..dd9363de9e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/DefaultPusherSubscriberTest.kt @@ -0,0 +1,193 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl + +import com.google.common.truth.Truth.assertThat +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.pusher.SetHttpPusherData +import io.element.android.libraries.matrix.api.pusher.UnsetHttpPusherData +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.matrix.test.pushers.FakePushersService +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPusherSubscriberTest { + @Test + fun `test register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test re-register pusher OK`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.success(Unit), + ) + } + + @Test + fun `test register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = null, + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + @Test + fun `test re-register pusher error`() = runTest { + testRegisterPusher( + currentPushKey = "aPushKey", + registerResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testRegisterPusher( + currentPushKey: String?, + registerResult: Result, + ) { + val setHttpPusherResult = lambdaRecorder> { registerResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + setHttpPusherResult = setHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(registerResult) + setHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + SetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + url = "aGateway", + appDisplayName = "MyApp", + deviceDisplayName = "MyDevice", + profileTag = DEFAULT_PUSHER_FILE_TAG + "_", + lang = "en", + defaultPayload = "{\"cs\":\"$A_SECRET\"}", + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (registerResult.isSuccess) "aPushKey" else currentPushKey + ) + } + + @Test + fun `test unregister pusher OK`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.success(Unit), + ) + } + + @Test + fun `test unregister pusher error`() = runTest { + testUnregisterPusher( + currentPushKey = "aPushKey", + unregisterResult = Result.failure(AN_EXCEPTION), + ) + } + + private suspend fun testUnregisterPusher( + currentPushKey: String?, + unregisterResult: Result, + ) { + val unsetHttpPusherResult = lambdaRecorder> { unregisterResult } + val userPushStore = FakeUserPushStore().apply { + setCurrentRegisteredPushKey(currentPushKey) + } + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo(currentPushKey) + + val matrixClient = FakeMatrixClient( + pushersService = FakePushersService( + unsetHttpPusherResult = unsetHttpPusherResult, + ), + ) + val defaultPusherSubscriber = createDefaultPusherSubscriber( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET }, + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { userPushStore }, + ), + ) + val result = defaultPusherSubscriber.unregisterPusher( + matrixClient = matrixClient, + pushKey = "aPushKey", + gateway = "aGateway", + ) + assertThat(result).isEqualTo(unregisterResult) + unsetHttpPusherResult.assertions() + .isCalledOnce() + .with( + value( + UnsetHttpPusherData( + pushKey = "aPushKey", + appId = PushConfig.PUSHER_APP_ID, + ), + ) + ) + assertThat(userPushStore.getCurrentRegisteredPushKey()).isEqualTo( + if (unregisterResult.isSuccess) null else currentPushKey + ) + } + + private fun createDefaultPusherSubscriber( + buildMeta: BuildMeta = aBuildMeta(applicationName = "MyApp"), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + ): DefaultPusherSubscriber { + return DefaultPusherSubscriber( + buildMeta = buildMeta, + pushClientSecret = pushClientSecret, + userPushStoreFactory = userPushStoreFactory, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt similarity index 94% rename from libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt rename to libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt index d0cd5a30e5..8eebbbbb5c 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/NotifiableEventResolverTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotifiableEventResolverTest.kt @@ -59,17 +59,17 @@ import org.robolectric.RuntimeEnvironment import org.robolectric.annotation.Config @RunWith(RobolectricTestRunner::class) -class NotifiableEventResolverTest { +class DefaultNotifiableEventResolverTest { @Test fun `resolve event no session`() = runTest { - val sut = createNotifiableEventResolver(notificationService = null) + val sut = createDefaultNotifiableEventResolver(notificationService = null) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) assertThat(result).isNull() } @Test fun `resolve event failure`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.failure(AN_EXCEPTION) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) @@ -78,7 +78,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event null`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success(null) ) val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID) @@ -87,7 +87,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message text`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -105,7 +105,7 @@ class NotifiableEventResolverTest { @Test @Config(qualifiers = "en") fun `resolve event message with mention`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -123,7 +123,7 @@ class NotifiableEventResolverTest { @Test fun `resolve HTML formatted event message text takes plain text version`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -146,7 +146,7 @@ class NotifiableEventResolverTest { @Test fun `resolve incorrectly formatted event message text uses fallback`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -169,7 +169,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message audio`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -186,7 +186,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message video`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -203,7 +203,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message voice`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -220,7 +220,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message image`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -237,7 +237,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message sticker`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -254,7 +254,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message file`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -271,7 +271,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message location`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -288,7 +288,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message notice`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -305,7 +305,7 @@ class NotifiableEventResolverTest { @Test fun `resolve event message emote`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomMessage( @@ -322,7 +322,7 @@ class NotifiableEventResolverTest { @Test fun `resolve poll`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.Poll( @@ -339,7 +339,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent invite room`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -372,7 +372,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent invite direct`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -405,7 +405,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomMemberContent other`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.StateEvent.RoomMemberContent( @@ -421,7 +421,7 @@ class NotifiableEventResolverTest { @Test fun `resolve RoomEncrypted`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.RoomEncrypted @@ -445,7 +445,7 @@ class NotifiableEventResolverTest { @Test fun `resolve CallInvite`() = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = NotificationContent.MessageLike.CallInvite(A_USER_ID_2) @@ -517,7 +517,7 @@ class NotifiableEventResolverTest { } private fun testNull(content: NotificationContent) = runTest { - val sut = createNotifiableEventResolver( + val sut = createDefaultNotifiableEventResolver( notificationResult = Result.success( createNotificationData( content = content @@ -528,10 +528,10 @@ class NotifiableEventResolverTest { assertThat(result).isNull() } - private fun createNotifiableEventResolver( + private fun createDefaultNotifiableEventResolver( notificationService: FakeNotificationService? = FakeNotificationService(), notificationResult: Result = Result.success(null), - ): NotifiableEventResolver { + ): DefaultNotifiableEventResolver { val context = RuntimeEnvironment.getApplication() as Context notificationService?.givenGetNotificationResult(notificationResult) val matrixClientProvider = FakeMatrixClientProvider(getClient = { @@ -544,7 +544,7 @@ class NotifiableEventResolverTest { val notificationMediaRepoFactory = NotificationMediaRepo.Factory { FakeNotificationMediaRepo() } - return NotifiableEventResolver( + return DefaultNotifiableEventResolver( stringProvider = AndroidStringProvider(context.resources), clock = FakeSystemClock(), matrixClientProvider = matrixClientProvider, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt index b84e7b4be3..e3220f8dfa 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/DefaultNotificationDrawerManagerTest.kt @@ -59,7 +59,6 @@ class DefaultNotificationDrawerManagerTest { fun `cover all APIs`() = runTest { // For now just call all the API. Later, add more valuable tests. val defaultNotificationDrawerManager = createDefaultNotificationDrawerManager() - defaultNotificationDrawerManager.notificationStyleChanged() defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = true) defaultNotificationDrawerManager.clearAllMessagesEvents(A_SESSION_ID, doRender = false) defaultNotificationDrawerManager.clearEvent(A_SESSION_ID, AN_EVENT_ID, doRender = true) diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt new file mode 100644 index 0000000000..a9ccabaa6d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/notifications/FakeNotifiableEventResolver.kt @@ -0,0 +1,31 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.notifications + +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeNotifiableEventResolver( + private val notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() } +) : NotifiableEventResolver { + override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? { + return notifiableEventResult(sessionId, roomId, eventId) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt new file mode 100644 index 0000000000..7739efbd1d --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/DefaultPushHandlerTest.kt @@ -0,0 +1,268 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.push.impl.push + +import app.cash.turbine.test +import io.element.android.libraries.core.meta.BuildMeta +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.EventId +import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.matrix.test.core.aBuildMeta +import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver +import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent +import io.element.android.libraries.push.impl.test.DefaultTestPush +import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushstore.api.UserPushStore +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaError +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultPushHandlerTest { + @Test + fun `when classical PushData is received, the notification drawer is informed`() = runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isCalledOnce() + .with(value(aNotifiableMessageEvent)) + } + + @Test + fun `when classical PushData is received, but notifications are disabled, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStore = FakeUserPushStore().apply { + setNotificationEnabledForDevice(false) + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when PushData is received, but client secret is not known, fallback the latest session`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + getLatestSessionIdLambda = { A_USER_ID } + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isCalledOnce() + .with(value(aNotifiableMessageEvent)) + } + + @Test + fun `when PushData is received, but client secret is not known, and there is no latest session, nothing happen`() = + runTest { + val aNotifiableMessageEvent = aNotifiableMessageEvent() + val notifiableEventResult = + lambdaRecorder { _, _, _ -> aNotifiableMessageEvent } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService().apply { + getLatestSessionIdLambda = { null } + }, + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isNeverCalled() + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when classical PushData is received, but not able to resolve the event, nothing happen`() = + runTest { + val notifiableEventResult = + lambdaRecorder { _, _, _ -> null } + val onNotifiableEventReceived = lambdaRecorder {} + val incrementPushCounterResult = lambdaRecorder {} + val aPushData = PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val defaultPushHandler = createDefaultPushHandler( + onNotifiableEventReceived = onNotifiableEventReceived, + notifiableEventResult = notifiableEventResult, + buildMeta = aBuildMeta( + // Also test `lowPrivacyLoggingEnabled = false` here + lowPrivacyLoggingEnabled = false + ), + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + incrementPushCounterResult = incrementPushCounterResult + ) + defaultPushHandler.handle(aPushData) + incrementPushCounterResult.assertions() + .isCalledOnce() + notifiableEventResult.assertions() + .isCalledOnce() + .with(value(A_USER_ID), value(A_ROOM_ID), value(AN_EVENT_ID)) + onNotifiableEventReceived.assertions() + .isNeverCalled() + } + + @Test + fun `when diagnostic PushData is received, the diagnostic push handler is informed `() = + runTest { + val aPushData = PushData( + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = A_ROOM_ID, + unread = 0, + clientSecret = A_SECRET, + ) + val diagnosticPushHandler = DiagnosticPushHandler() + val defaultPushHandler = createDefaultPushHandler( + diagnosticPushHandler = diagnosticPushHandler, + incrementPushCounterResult = { } + ) + diagnosticPushHandler.state.test { + defaultPushHandler.handle(aPushData) + awaitItem() + } + } + + private fun createDefaultPushHandler( + onNotifiableEventReceived: (NotifiableEvent) -> Unit = { lambdaError() }, + notifiableEventResult: (SessionId, RoomId, EventId) -> NotifiableEvent? = { _, _, _ -> lambdaError() }, + incrementPushCounterResult: () -> Unit = { lambdaError() }, + userPushStore: UserPushStore = FakeUserPushStore(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + buildMeta: BuildMeta = aBuildMeta(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(), + ): DefaultPushHandler { + return DefaultPushHandler( + onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived), + notifiableEventResolver = FakeNotifiableEventResolver(notifiableEventResult), + incrementPushDataStore = object : IncrementPushDataStore { + override suspend fun incrementPushCounter() { + incrementPushCounterResult() + } + }, + userPushStoreFactory = FakeUserPushStoreFactory { userPushStore }, + pushClientSecret = pushClientSecret, + buildMeta = buildMeta, + matrixAuthenticationService = matrixAuthenticationService, + diagnosticPushHandler = diagnosticPushHandler, + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt new file mode 100644 index 0000000000..3c9e025830 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/push/FakeOnNotifiableEventReceived.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.push + +import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent + +class FakeOnNotifiableEventReceived( + private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit, +) : OnNotifiableEventReceived { + override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) { + onNotifiableEventReceivedResult(notifiableEvent) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt new file mode 100644 index 0000000000..ba432b4c7a --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/DefaultPushGatewayNotifyRequestTest.kt @@ -0,0 +1,102 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.pushgateway + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.push.api.gateway.PushGatewayFailure +import io.element.android.libraries.push.impl.test.DefaultTestPush +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertThrows +import org.junit.Test + +class DefaultPushGatewayNotifyRequestTest { + @Test + fun `notify success`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify success, url is stripped`() = runTest { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = emptyList() + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl" + PushGatewayConfig.URI_PUSH_GATEWAY_PREFIX_PATH, + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } + + @Test + fun `notify with rejected push key should throw expected Exception`() { + val factory = FakePushGatewayApiFactory( + notifyResponse = { + PushGatewayNotifyResponse( + rejectedPushKeys = listOf("aPushKey") + ) + } + ) + val pushGatewayNotifyRequest = DefaultPushGatewayNotifyRequest( + pushGatewayApiFactory = factory, + ) + assertThrows(PushGatewayFailure.PusherRejected::class.java) { + runTest { + pushGatewayNotifyRequest.execute( + PushGatewayNotifyRequest.Params( + url = "aUrl", + appId = "anAppId", + pushKey = "aPushKey", + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + } + } + assertThat(factory.baseUrlParameter).isEqualTo("aUrl") + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt new file mode 100644 index 0000000000..0b9730843e --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/pushgateway/FakePushGatewayApiFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.pushgateway + +class FakePushGatewayApiFactory( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): PushGatewayAPI { + baseUrlParameter = baseUrl + return FakePushGatewayAPI(notifyResponse) + } +} + +class FakePushGatewayAPI( + private val notifyResponse: () -> PushGatewayNotifyResponse +) : PushGatewayAPI { + override suspend fun notify(body: PushGatewayNotifyBody): PushGatewayNotifyResponse { + return notifyResponse() + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt new file mode 100644 index 0000000000..0ccd08df82 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/DefaultTestPushTest.kt @@ -0,0 +1,55 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.test + +import io.element.android.appconfig.PushConfig +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultTestPushTest { + @Test + fun `test DefaultTestPush`() = runTest { + val executeResult = lambdaRecorder { } + val defaultTestPush = DefaultTestPush( + pushGatewayNotifyRequest = FakePushGatewayNotifyRequest( + executeResult = executeResult, + ) + ) + val aConfig = CurrentUserPushConfig( + url = "aUrl", + pushKey = "aPushKey", + ) + defaultTestPush.execute(aConfig) + executeResult.assertions() + .isCalledOnce() + .with( + value( + PushGatewayNotifyRequest.Params( + url = aConfig.url, + appId = PushConfig.PUSHER_APP_ID, + pushKey = aConfig.pushKey, + eventId = DefaultTestPush.TEST_EVENT_ID, + roomId = DefaultTestPush.TEST_ROOM_ID, + ) + ) + ) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt new file mode 100644 index 0000000000..d0fa5a546f --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakePushGatewayNotifyRequest.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.test + +import io.element.android.libraries.push.impl.pushgateway.PushGatewayNotifyRequest +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushGatewayNotifyRequest( + private val executeResult: (PushGatewayNotifyRequest.Params) -> Unit = { lambdaError() } +) : PushGatewayNotifyRequest { + override suspend fun execute(params: PushGatewayNotifyRequest.Params) { + executeResult(params) + } +} diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt new file mode 100644 index 0000000000..d7bf8c8c42 --- /dev/null +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/test/FakeTestPush.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 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.libraries.push.impl.test + +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeTestPush( + private val executeResult: (CurrentUserPushConfig) -> Unit = { lambdaError() } +) : TestPush { + override suspend fun execute(config: CurrentUserPushConfig) { + executeResult(config) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt index 969815ec66..584e93d8e1 100644 --- a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePushService.kt @@ -23,16 +23,26 @@ import io.element.android.libraries.pushproviders.api.PushProvider import io.element.android.tests.testutils.simulateLongTask class FakePushService( - private val testPushBlock: suspend () -> Boolean = { true } + private val testPushBlock: suspend () -> Boolean = { true }, + private val availablePushProviders: List = emptyList(), + private val registerWithLambda: suspend (MatrixClient, PushProvider, Distributor) -> Result = { _, _, _ -> + Result.success(Unit) + }, ) : PushService { - override fun notificationStyleChanged() { + override suspend fun getCurrentPushProvider(): PushProvider? { + return availablePushProviders.firstOrNull() } override fun getAvailablePushProviders(): List { - return emptyList() + return availablePushProviders } - override suspend fun registerWith(matrixClient: MatrixClient, pushProvider: PushProvider, distributor: Distributor) { + override suspend fun registerWith( + matrixClient: MatrixClient, + pushProvider: PushProvider, + distributor: Distributor, + ): Result = simulateLongTask { + return registerWithLambda(matrixClient, pushProvider, distributor) } override suspend fun testPush(): Boolean = simulateLongTask { diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt new file mode 100644 index 0000000000..8338bb1e4c --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/FakePusherSubscriber.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 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.libraries.push.test + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePusherSubscriber( + private val registerPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, + private val unregisterPusherResult: (MatrixClient, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : PusherSubscriber { + override suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return registerPusherResult(matrixClient, pushKey, gateway) + } + + override suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result { + return unregisterPusherResult(matrixClient, pushKey, gateway) + } +} diff --git a/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt new file mode 100644 index 0000000000..c370250bd0 --- /dev/null +++ b/libraries/push/test/src/main/kotlin/io/element/android/libraries/push/test/test/FakePushHandler.kt @@ -0,0 +1,29 @@ +/* + * Copyright (c) 2024 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.libraries.push.test.test + +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushHandler( + private val handleResult: (PushData) -> Unit = { lambdaError() } +) : PushHandler { + override suspend fun handle(pushData: PushData) { + handleResult(pushData) + } +} diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt index 7eda80fed9..1db3dc2610 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/Distributor.kt @@ -16,6 +16,14 @@ package io.element.android.libraries.pushproviders.api +/** + * Firebase does not have the concept of distributor. So for Firebase, there will be one distributor: + * Distributor("Firebase", "Firebase"). + * + * For UnifiedPush, for instance, the Distributor can be: + * Distributor("io.heckel.ntfy", "ntfy"). + * But other values are possible. + */ data class Distributor( val value: String, val name: String, diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt index 4e9b818dd4..d111dc139f 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PushProvider.kt @@ -42,12 +42,17 @@ interface PushProvider { /** * Register the pusher to the homeserver. */ - suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) + suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result + + /** + * Return the current distributor, or null if none. + */ + suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? /** * Unregister the pusher. */ - suspend fun unregister(matrixClient: MatrixClient) + suspend fun unregister(matrixClient: MatrixClient): Result suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? } diff --git a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt index 2529e4bb96..d38f5dec1e 100644 --- a/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt +++ b/libraries/pushproviders/api/src/main/kotlin/io/element/android/libraries/pushproviders/api/PusherSubscriber.kt @@ -19,6 +19,6 @@ package io.element.android.libraries.pushproviders.api import io.element.android.libraries.matrix.api.MatrixClient interface PusherSubscriber { - suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) - suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String) + suspend fun registerPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result + suspend fun unregisterPusher(matrixClient: MatrixClient, pushKey: String, gateway: String): Result } diff --git a/libraries/pushproviders/firebase/build.gradle.kts b/libraries/pushproviders/firebase/build.gradle.kts index 6e36b92a09..58d3b882d9 100644 --- a/libraries/pushproviders/firebase/build.gradle.kts +++ b/libraries/pushproviders/firebase/build.gradle.kts @@ -58,7 +58,11 @@ dependencies { testImplementation(libs.test.junit) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) + testImplementation(libs.test.robolectric) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) + testImplementation(projects.libraries.sessionStorage.implMemory) testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test) } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt index 20d0de4ebf..baddab1a0d 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebaseNewTokenHandler.kt @@ -16,7 +16,10 @@ package io.element.android.libraries.pushproviders.firebase +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushproviders.api.PusherSubscriber @@ -31,24 +34,42 @@ private val loggerTag = LoggerTag("FirebaseNewTokenHandler", LoggerTag.PushLogge /** * Handle new token receive from Firebase. Will update all the sessions which are using Firebase as a push provider. */ -class FirebaseNewTokenHandler @Inject constructor( +interface FirebaseNewTokenHandler { + suspend fun handle(firebaseToken: String) +} + +@ContributesBinding(AppScope::class) +class DefaultFirebaseNewTokenHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, private val sessionStore: SessionStore, private val userPushStoreFactory: UserPushStoreFactory, private val matrixAuthenticationService: MatrixAuthenticationService, private val firebaseStore: FirebaseStore, -) { - suspend fun handle(firebaseToken: String) { +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { firebaseStore.storeFcmToken(firebaseToken) // Register the pusher for all the sessions sessionStore.getAllSessions().toUserList() .map { SessionId(it) } - .forEach { userId -> - val userDataStore = userPushStoreFactory.getOrCreate(userId) + .forEach { sessionId -> + val userDataStore = userPushStoreFactory.getOrCreate(sessionId) if (userDataStore.getPushProviderName() == FirebaseConfig.NAME) { - matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> - pusherSubscriber.registerPusher(client, firebaseToken, FirebaseConfig.PUSHER_HTTP_URL) - } + matrixAuthenticationService + .restoreSession(sessionId) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to restore session $sessionId") + } + .flatMap { client -> + pusherSubscriber + .registerPusher( + matrixClient = client, + pushKey = firebaseToken, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to register pusher for session $sessionId") + } + } } else { Timber.tag(loggerTag.value).d("This session is not using Firebase pusher") } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt index 317d49f3b6..0228f8f74b 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProvider.kt @@ -43,21 +43,34 @@ class FirebasePushProvider @Inject constructor( } override fun getDistributors(): List { - return listOf(Distributor("Firebase", "Firebase")) + return listOf(firebaseDistributor) } - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { - val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + val pushKey = firebaseStore.getFcmToken() ?: return Result.failure( + IllegalStateException( + "Unable to register pusher, Firebase token is not known." + ) + ).also { Timber.tag(loggerTag.value).w("Unable to register pusher, Firebase token is not known.") } - pusherSubscriber.registerPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) + return pusherSubscriber.registerPusher( + matrixClient = matrixClient, + pushKey = pushKey, + gateway = FirebaseConfig.PUSHER_HTTP_URL, + ) } - override suspend fun unregister(matrixClient: MatrixClient) { - val pushKey = firebaseStore.getFcmToken() ?: return Unit.also { + override suspend fun getCurrentDistributor(matrixClient: MatrixClient) = firebaseDistributor + + override suspend fun unregister(matrixClient: MatrixClient): Result { + val pushKey = firebaseStore.getFcmToken() + return if (pushKey == null) { Timber.tag(loggerTag.value).w("Unable to unregister pusher, Firebase token is not known.") + Result.success(Unit) + } else { + pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } - pusherSubscriber.unregisterPusher(matrixClient, pushKey, FirebaseConfig.PUSHER_HTTP_URL) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { @@ -68,4 +81,8 @@ class FirebasePushProvider @Inject constructor( ) } } + + companion object { + private val firebaseDistributor = Distributor("Firebase", "Firebase") + } } diff --git a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt index 3d251f6e64..a8bc069893 100644 --- a/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt +++ b/libraries/pushproviders/firebase/src/main/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingService.kt @@ -22,7 +22,6 @@ import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.pushproviders.api.PushHandler import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import timber.log.Timber import javax.inject.Inject @@ -33,8 +32,7 @@ class VectorFirebaseMessagingService : FirebaseMessagingService() { @Inject lateinit var firebaseNewTokenHandler: FirebaseNewTokenHandler @Inject lateinit var pushParser: FirebasePushParser @Inject lateinit var pushHandler: PushHandler - - private val coroutineScope = CoroutineScope(SupervisorJob()) + @Inject lateinit var coroutineScope: CoroutineScope override fun onCreate() { super.onCreate() diff --git a/libraries/pushproviders/firebase/src/main/res/values-pt/translations.xml b/libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/pushproviders/firebase/src/main/res/values-pt/translations.xml rename to libraries/pushproviders/firebase/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt new file mode 100644 index 0000000000..585a5e2a08 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/DefaultFirebaseNewTokenHandlerTest.kt @@ -0,0 +1,186 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.A_USER_ID_2 +import io.element.android.libraries.matrix.test.A_USER_ID_3 +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.sessionstorage.api.LoginType +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemoryMultiSessionsStore +import io.element.android.libraries.sessionstorage.impl.memory.InMemorySessionStore +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultFirebaseNewTokenHandlerTest { + @Test + fun `when a new token is received it is stored in the firebase store`() = runTest { + val firebaseStore = InMemoryFirebaseStore() + assertThat(firebaseStore.getFcmToken()).isNull() + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + firebaseStore = firebaseStore, + ) + firebaseNewTokenHandler.handle("aToken") + assertThat(firebaseStore.getFcmToken()).isEqualTo("aToken") + } + + @Test + fun `when a new token is received, the handler registers the pusher again to all sessions using Firebase`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val aMatrixClient2 = FakeMatrixClient(A_USER_ID_2) + val aMatrixClient3 = FakeMatrixClient(A_USER_ID_3) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + storeData(aSessionData(A_USER_ID_2)) + storeData(aSessionData(A_USER_ID_3)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { sessionId -> + when (sessionId) { + A_USER_ID -> Result.success(aMatrixClient1) + A_USER_ID_2 -> Result.success(aMatrixClient2) + A_USER_ID_3 -> Result.success(aMatrixClient3) + else -> Result.failure(IllegalStateException()) + } + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { sessionId -> + when (sessionId) { + A_USER_ID -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + A_USER_ID_2 -> FakeUserPushStore(pushProviderName = "Other") + A_USER_ID_3 -> FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + else -> error("Unexpected sessionId: $sessionId") + } + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isCalledExactly(2) + .withSequence( + listOf(value(aMatrixClient1), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)), + listOf(value(aMatrixClient3), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)), + ) + } + + @Test + fun `when a new token is received, if the session cannot be restore, nothing happen`() = runTest { + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { _ -> + Result.failure(IllegalStateException()) + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + .isNeverCalled() + } + + @Test + fun `when a new token is received, error when registering the pusher is ignored`() = runTest { + val aMatrixClient1 = FakeMatrixClient(A_USER_ID) + val registerPusherResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val pusherSubscriber = FakePusherSubscriber(registerPusherResult = registerPusherResult) + val firebaseNewTokenHandler = createDefaultFirebaseNewTokenHandler( + sessionStore = InMemoryMultiSessionsStore().apply { + storeData(aSessionData(A_USER_ID)) + }, + matrixAuthenticationService = FakeMatrixAuthenticationService( + matrixClientResult = { _ -> + Result.success(aMatrixClient1) + } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { _ -> + FakeUserPushStore(pushProviderName = FirebaseConfig.NAME) + } + ), + pusherSubscriber = pusherSubscriber, + ) + firebaseNewTokenHandler.handle("aToken") + registerPusherResult.assertions() + registerPusherResult.assertions() + .isCalledOnce() + .with(value(aMatrixClient1), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + private fun createDefaultFirebaseNewTokenHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + sessionStore: SessionStore = InMemorySessionStore(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(), + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + ): FirebaseNewTokenHandler { + return DefaultFirebaseNewTokenHandler( + pusherSubscriber = pusherSubscriber, + sessionStore = sessionStore, + userPushStoreFactory = userPushStoreFactory, + matrixAuthenticationService = matrixAuthenticationService, + firebaseStore = firebaseStore + ) + } + + private fun aSessionData( + sessionId: SessionId, + ): SessionData { + return SessionData( + userId = sessionId.value, + deviceId = "aDeviceId", + accessToken = "anAccessToken", + refreshToken = "aRefreshToken", + homeserverUrl = "aHomeserverUrl", + oidcData = null, + slidingSyncProxy = null, + loginTimestamp = null, + isTokenValid = true, + loginType = LoginType.UNKNOWN, + passphrase = null, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt new file mode 100644 index 0000000000..aa66f0288c --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeFirebaseNewTokenHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.firebase + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeFirebaseNewTokenHandler( + private val handleResult: (String) -> Unit = { lambdaError() } +) : FirebaseNewTokenHandler { + override suspend fun handle(firebaseToken: String) { + handleResult(firebaseToken) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt new file mode 100644 index 0000000000..6994e6140e --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FakeIsPlayServiceAvailable.kt @@ -0,0 +1,23 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.firebase + +class FakeIsPlayServiceAvailable( + private val isAvailable: Boolean, +) : IsPlayServiceAvailable { + override fun isAvailable() = isAvailable +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt new file mode 100644 index 0000000000..880be2f053 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/FirebasePushProviderTest.kt @@ -0,0 +1,194 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.firebase + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class FirebasePushProviderTest { + @Test + fun `test index and name`() { + val firebasePushProvider = createFirebasePushProvider() + assertThat(firebasePushProvider.name).isEqualTo(FirebaseConfig.NAME) + assertThat(firebasePushProvider.index).isEqualTo(FirebaseConfig.INDEX) + } + + @Test + fun `getDistributors return the unique distributor`() { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("Firebase", "Firebase")) + } + + @Test + fun `getCurrentDistributor always return the unique distributor`() = runTest { + val firebasePushProvider = createFirebasePushProvider() + val result = firebasePushProvider.getCurrentDistributor(FakeMatrixClient()) + assertThat(result).isEqualTo(Distributor("Firebase", "Firebase")) + } + + @Test + fun `isAvailable true`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = true) + ) + assertThat(firebasePushProvider.isAvailable()).isTrue() + } + + @Test + fun `isAvailable false`() { + val firebasePushProvider = createFirebasePushProvider( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(isAvailable = false) + ) + assertThat(firebasePushProvider.isAvailable()).isFalse() + } + + @Test + fun `register ok`() = runTest { + val matrixClient = FakeMatrixClient() + val registerPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = registerPusherResultLambda + ) + ) + val result = firebasePushProvider.registerWith(matrixClient, Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + registerPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + @Test + fun `register ko no token`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.success(Unit) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `register ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val unregisterPusherResultLambda = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = unregisterPusherResultLambda + ) + ) + val result = firebasePushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + unregisterPusherResultLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aToken"), value(FirebaseConfig.PUSHER_HTTP_URL)) + } + + @Test + fun `unregister no token - in this case, the error is ignored`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ), + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `unregister ko error`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = firebasePushProvider.unregister(FakeMatrixClient()) + assertThat(result.isFailure).isTrue() + } + + @Test + fun `getCurrentUserPushConfig no push ket`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = null + ) + ) + val result = firebasePushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val firebasePushProvider = createFirebasePushProvider( + firebaseStore = InMemoryFirebaseStore( + token = "aToken" + ), + ) + val result = firebasePushProvider.getCurrentUserPushConfig() + assertThat(result).isEqualTo(CurrentUserPushConfig(FirebaseConfig.PUSHER_HTTP_URL, "aToken")) + } + + private fun createFirebasePushProvider( + firebaseStore: FirebaseStore = InMemoryFirebaseStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + isPlayServiceAvailable: IsPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + ): FirebasePushProvider { + return FirebasePushProvider( + firebaseStore = firebaseStore, + pusherSubscriber = pusherSubscriber, + isPlayServiceAvailable = isPlayServiceAvailable, + ) + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt new file mode 100644 index 0000000000..9dfb453919 --- /dev/null +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/VectorFirebaseMessagingServiceTest.kt @@ -0,0 +1,95 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.firebase + +import android.os.Bundle +import com.google.firebase.messaging.RemoteMessage +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorFirebaseMessagingServiceTest { + @Test + fun `test receiving invalid data`() = runTest { + val lambda = lambdaRecorder(ensureNeverCalled = true) { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived(RemoteMessage(Bundle())) + } + + @Test + fun `test receiving valid data`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + pushHandler = FakePushHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onMessageReceived( + message = RemoteMessage( + Bundle().apply { + putString("event_id", AN_EVENT_ID.value) + putString("room_id", A_ROOM_ID.value) + putString("cs", A_SECRET) + }, + ) + ) + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value(PushData(AN_EVENT_ID, A_ROOM_ID, null, A_SECRET))) + } + + @Test + fun `test new token is forwarded to the handler`() = runTest { + val lambda = lambdaRecorder { } + val vectorFirebaseMessagingService = createVectorFirebaseMessagingService( + firebaseNewTokenHandler = FakeFirebaseNewTokenHandler(handleResult = lambda) + ) + vectorFirebaseMessagingService.onNewToken("aToken") + advanceUntilIdle() + lambda.assertions() + .isCalledOnce() + .with(value("aToken")) + } + + private fun TestScope.createVectorFirebaseMessagingService( + firebaseNewTokenHandler: FirebaseNewTokenHandler = FakeFirebaseNewTokenHandler(), + pushHandler: PushHandler = FakePushHandler(), + ): VectorFirebaseMessagingService { + return VectorFirebaseMessagingService().apply { + this.firebaseNewTokenHandler = firebaseNewTokenHandler + this.pushParser = FirebasePushParser() + this.pushHandler = pushHandler + this.coroutineScope = this@createVectorFirebaseMessagingService + } + } +} diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt index 6f1a3da7cb..fae5ba9f12 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseAvailabilityTestTest.kt @@ -18,8 +18,10 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.libraries.pushproviders.firebase.IsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FakeIsPlayServiceAvailable +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -29,11 +31,7 @@ class FirebaseAvailabilityTestTest { @Test fun `test FirebaseAvailabilityTest success`() = runTest { val sut = FirebaseAvailabilityTest( - isPlayServiceAvailable = object : IsPlayServiceAvailable { - override fun isAvailable(): Boolean { - return true - } - }, + isPlayServiceAvailable = FakeIsPlayServiceAvailable(true), stringProvider = FakeStringProvider(), ) launch { @@ -50,11 +48,7 @@ class FirebaseAvailabilityTestTest { @Test fun `test FirebaseAvailabilityTest failure`() = runTest { val sut = FirebaseAvailabilityTest( - isPlayServiceAvailable = object : IsPlayServiceAvailable { - override fun isAvailable(): Boolean { - return false - } - }, + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), stringProvider = FakeStringProvider(), ) launch { @@ -67,4 +61,14 @@ class FirebaseAvailabilityTestTest { assertThat(lastItem.status).isEqualTo(NotificationTroubleshootTestState.Status.Failure(false)) } } + + @Test + fun `test FirebaseAvailabilityTest isRelevant`() { + val sut = FirebaseAvailabilityTest( + isPlayServiceAvailable = FakeIsPlayServiceAvailable(false), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } } diff --git a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt index 2d8de62ad9..245a6095d4 100644 --- a/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt +++ b/libraries/pushproviders/firebase/src/test/kotlin/io/element/android/libraries/pushproviders/firebase/troubleshoot/FirebaseTokenTestTest.kt @@ -19,8 +19,10 @@ package io.element.android.libraries.pushproviders.firebase.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.pushproviders.firebase.FakeFirebaseTroubleshooter +import io.element.android.libraries.pushproviders.firebase.FirebaseConfig import io.element.android.libraries.pushproviders.firebase.InMemoryFirebaseStore import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -75,6 +77,17 @@ class FirebaseTokenTestTest { } } + @Test + fun `test FirebaseTokenTest isRelevant`() { + val sut = FirebaseTokenTest( + firebaseStore = InMemoryFirebaseStore(null), + firebaseTroubleshooter = FakeFirebaseTroubleshooter(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "unknown"))).isFalse() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = FirebaseConfig.NAME))).isTrue() + } + companion object { private const val FAKE_TOKEN = "abcdefghijk" } diff --git a/libraries/pushproviders/test/build.gradle.kts b/libraries/pushproviders/test/build.gradle.kts index ddb68ed43f..9a0d2c139c 100644 --- a/libraries/pushproviders/test/build.gradle.kts +++ b/libraries/pushproviders/test/build.gradle.kts @@ -24,4 +24,5 @@ android { dependencies { implementation(projects.libraries.matrix.api) implementation(projects.libraries.pushproviders.api) + implementation(projects.tests.testutils) } diff --git a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt index 8d8b94ec19..7b37d0d296 100644 --- a/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt +++ b/libraries/pushproviders/test/src/main/kotlin/io/element/android/libraries/pushproviders/test/FakePushProvider.kt @@ -20,26 +20,34 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig import io.element.android.libraries.pushproviders.api.Distributor import io.element.android.libraries.pushproviders.api.PushProvider +import io.element.android.tests.testutils.lambda.lambdaError class FakePushProvider( override val index: Int = 0, override val name: String = "aFakePushProvider", private val isAvailable: Boolean = true, - private val distributors: List = emptyList() + private val distributors: List = listOf(Distributor("aDistributorValue", "aDistributorName")), + private val currentUserPushConfig: CurrentUserPushConfig? = null, + private val registerWithResult: (MatrixClient, Distributor) -> Result = { _, _ -> lambdaError() }, + private val unregisterWithResult: (MatrixClient) -> Result = { lambdaError() }, ) : PushProvider { override fun isAvailable(): Boolean = isAvailable override fun getDistributors(): List = distributors - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { - // No-op + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { + return registerWithResult(matrixClient, distributor) } - override suspend fun unregister(matrixClient: MatrixClient) { - // No-op + override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { + return distributors.firstOrNull() + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { + return unregisterWithResult(matrixClient) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { - return null + return currentUserPushConfig } } diff --git a/libraries/pushproviders/unifiedpush/build.gradle.kts b/libraries/pushproviders/unifiedpush/build.gradle.kts index d5dcc9727d..d1c8cf5ee1 100644 --- a/libraries/pushproviders/unifiedpush/build.gradle.kts +++ b/libraries/pushproviders/unifiedpush/build.gradle.kts @@ -55,9 +55,13 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(libs.test.junit) + testImplementation(libs.test.robolectric) testImplementation(libs.test.truth) testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) + testImplementation(projects.libraries.push.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.tests.testutils) testImplementation(projects.services.toolbox.test) + testImplementation(projects.services.appnavstate.test) } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt index 8dd71118b3..6670f18ea2 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/RegisterUnifiedPushUseCase.kt @@ -17,56 +17,41 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext -import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.pushproviders.api.Distributor -import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout import org.unifiedpush.android.connector.UnifiedPush import javax.inject.Inject +import kotlin.time.Duration.Companion.seconds -class RegisterUnifiedPushUseCase @Inject constructor( +interface RegisterUnifiedPushUseCase { + suspend fun execute(distributor: Distributor, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultRegisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - private val pusherSubscriber: PusherSubscriber, - private val unifiedPushStore: UnifiedPushStore, -) { - sealed interface RegisterUnifiedPushResult { - data object Success : RegisterUnifiedPushResult - data object NeedToAskUserForDistributor : RegisterUnifiedPushResult - data object Error : RegisterUnifiedPushResult - } - - suspend fun execute(matrixClient: MatrixClient, distributor: Distributor, clientSecret: String): RegisterUnifiedPushResult { - val distributorValue = distributor.value - if (distributorValue.isNotEmpty()) { - saveAndRegisterApp(distributorValue, clientSecret) - val endpoint = unifiedPushStore.getEndpoint(clientSecret) ?: return RegisterUnifiedPushResult.Error - val gateway = unifiedPushStore.getPushGateway(clientSecret) ?: return RegisterUnifiedPushResult.Error - pusherSubscriber.registerPusher(matrixClient, endpoint, gateway) - return RegisterUnifiedPushResult.Success - } - - // TODO Below should never happen? - if (UnifiedPush.getDistributor(context).isNotEmpty()) { - registerApp(clientSecret) - return RegisterUnifiedPushResult.Success - } - - val distributors = UnifiedPush.getDistributors(context) - - return if (distributors.size == 1) { - saveAndRegisterApp(distributors.first(), clientSecret) - RegisterUnifiedPushResult.Success - } else { - RegisterUnifiedPushResult.NeedToAskUserForDistributor - } - } - - private fun saveAndRegisterApp(distributor: String, clientSecret: String) { - UnifiedPush.saveDistributor(context, distributor) - registerApp(clientSecret) - } - - private fun registerApp(clientSecret: String) { + private val endpointRegistrationHandler: EndpointRegistrationHandler, +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + UnifiedPush.saveDistributor(context, distributor.value) + // This will trigger the callback + // VectorUnifiedPushMessagingReceiver.onNewEndpoint UnifiedPush.registerApp(context = context, instance = clientSecret) + // Wait for VectorUnifiedPushMessagingReceiver.onNewEndpoint to proceed + return runCatching { + withTimeout(30.seconds) { + val result = endpointRegistrationHandler.state + .filter { it.clientSecret == clientSecret } + .first() + .result + result.getOrThrow() + } + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt new file mode 100644 index 0000000000..84a923df44 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushApiFactory.kt @@ -0,0 +1,37 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.network.RetrofitFactory +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import javax.inject.Inject + +interface UnifiedPushApiFactory { + fun create(baseUrl: String): UnifiedPushApi +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushApiFactory @Inject constructor( + private val retrofitFactory: RetrofitFactory, +) : UnifiedPushApiFactory { + override fun create(baseUrl: String): UnifiedPushApi { + return retrofitFactory.create(baseUrl) + .create(UnifiedPushApi::class.java) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt index 54b80a5110..c39c7ec066 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushGatewayResolver.kt @@ -16,29 +16,33 @@ package io.element.android.libraries.pushproviders.unifiedpush +import com.squareup.anvil.annotations.ContributesBinding import io.element.android.libraries.core.coroutine.CoroutineDispatchers -import io.element.android.libraries.network.RetrofitFactory -import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi +import io.element.android.libraries.di.AppScope import kotlinx.coroutines.withContext import timber.log.Timber import java.net.URL import javax.inject.Inject -class UnifiedPushGatewayResolver @Inject constructor( - private val retrofitFactory: RetrofitFactory, +interface UnifiedPushGatewayResolver { + suspend fun getGateway(endpoint: String): String +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushGatewayResolver @Inject constructor( + private val unifiedPushApiFactory: UnifiedPushApiFactory, private val coroutineDispatchers: CoroutineDispatchers, -) { - suspend fun getGateway(endpoint: String): String? { +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): String { val gateway = UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL - val url = URL(endpoint) - val port = if (url.port != -1) ":${url.port}" else "" - val customBase = "${url.protocol}://${url.host}$port" - val customUrl = "$customBase/_matrix/push/v1/notify" - Timber.i("Testing $customUrl") try { + val url = URL(endpoint) + val port = if (url.port != -1) ":${url.port}" else "" + val customBase = "${url.protocol}://${url.host}$port" + val customUrl = "$customBase/_matrix/push/v1/notify" + Timber.i("Testing $customUrl") return withContext(coroutineDispatchers.io) { - val api = retrofitFactory.create(customBase) - .create(UnifiedPushApi::class.java) + val api = unifiedPushApiFactory.create(customBase) try { val discoveryResponse = api.discover() if (discoveryResponse.unifiedpush.gateway == "matrix") { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt index 4ee637a3ab..f839e6a03e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushNewGatewayHandler.kt @@ -16,7 +16,10 @@ package io.element.android.libraries.pushproviders.unifiedpush +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.core.extensions.flatMap import io.element.android.libraries.core.log.logger.LoggerTag +import io.element.android.libraries.di.AppScope import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.pushproviders.api.PusherSubscriber import io.element.android.libraries.pushstore.api.UserPushStoreFactory @@ -24,29 +27,41 @@ import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret import timber.log.Timber import javax.inject.Inject -private val loggerTag = LoggerTag("UnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) +private val loggerTag = LoggerTag("DefaultUnifiedPushNewGatewayHandler", LoggerTag.PushLoggerTag) /** - * Handle new endpoint received from UnifiedPush. Will update all the sessions which are using UnifiedPush as a push provider. + * Handle new endpoint received from UnifiedPush. Will update the session matching the client secret. */ -class UnifiedPushNewGatewayHandler @Inject constructor( +interface UnifiedPushNewGatewayHandler { + suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushNewGatewayHandler @Inject constructor( private val pusherSubscriber: PusherSubscriber, private val userPushStoreFactory: UserPushStoreFactory, private val pushClientSecret: PushClientSecret, private val matrixAuthenticationService: MatrixAuthenticationService, -) { - suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String) { +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { // Register the pusher for the session with this client secret, if is it using UnifiedPush. - val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Unit.also { + val userId = pushClientSecret.getUserIdFromSecret(clientSecret) ?: return Result.failure( + IllegalStateException("Unable to retrieve session") + ).also { Timber.w("Unable to retrieve session") } val userDataStore = userPushStoreFactory.getOrCreate(userId) - if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { - matrixAuthenticationService.restoreSession(userId).getOrNull()?.use { client -> - pusherSubscriber.registerPusher(client, endpoint, pushGateway) - } + return if (userDataStore.getPushProviderName() == UnifiedPushConfig.NAME) { + matrixAuthenticationService + .restoreSession(userId) + .flatMap { client -> + pusherSubscriber.registerPusher(client, endpoint, pushGateway) + } } else { Timber.tag(loggerTag.value).d("This session is not using UnifiedPush pusher") + Result.failure( + IllegalStateException("This session is not using UnifiedPush pusher") + ) } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt index f68cd8542b..46c7c0f9bb 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParser.kt @@ -18,7 +18,6 @@ package io.element.android.libraries.pushproviders.unifiedpush import io.element.android.libraries.core.data.tryOrNull import io.element.android.libraries.pushproviders.api.PushData -import kotlinx.serialization.decodeFromString import kotlinx.serialization.json.Json import javax.inject.Inject diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt index e7ea1841c5..4530a4667f 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProvider.kt @@ -58,14 +58,22 @@ class UnifiedPushProvider @Inject constructor( return unifiedPushDistributorProvider.getDistributors() } - override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor) { + override suspend fun registerWith(matrixClient: MatrixClient, distributor: Distributor): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - registerUnifiedPushUseCase.execute(matrixClient, distributor, clientSecret) + return registerUnifiedPushUseCase.execute(distributor, clientSecret) + .onSuccess { + unifiedPushStore.setDistributorValue(matrixClient.sessionId, distributor.value) + } } - override suspend fun unregister(matrixClient: MatrixClient) { + override suspend fun getCurrentDistributor(matrixClient: MatrixClient): Distributor? { + val distributorValue = unifiedPushStore.getDistributorValue(matrixClient.sessionId) + return getDistributors().find { it.value == distributorValue } + } + + override suspend fun unregister(matrixClient: MatrixClient): Result { val clientSecret = pushClientSecret.getSecretForUser(matrixClient.sessionId) - unRegisterUnifiedPushUseCase.execute(clientSecret) + return unRegisterUnifiedPushUseCase.execute(matrixClient, clientSecret) } override suspend fun getCurrentUserPushConfig(): CurrentUserPushConfig? { diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt index d063dfce3e..dc1bd86d9e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushStore.kt @@ -19,31 +19,44 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context import android.content.SharedPreferences import androidx.core.content.edit +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext import io.element.android.libraries.di.DefaultPreferences +import io.element.android.libraries.matrix.api.core.UserId import javax.inject.Inject -class UnifiedPushStore @Inject constructor( +interface UnifiedPushStore { + fun getEndpoint(clientSecret: String): String? + fun storeUpEndpoint(clientSecret: String, endpoint: String?) + fun getPushGateway(clientSecret: String): String? + fun storePushGateway(clientSecret: String, gateway: String?) + fun getDistributorValue(userId: UserId): String? + fun setDistributorValue(userId: UserId, value: String) +} + +@ContributesBinding(AppScope::class) +class DefaultUnifiedPushStore @Inject constructor( @ApplicationContext val context: Context, @DefaultPreferences private val defaultPrefs: SharedPreferences, -) { +) : UnifiedPushStore { /** * Retrieves the UnifiedPush Endpoint. * * @param clientSecret the client secret, to identify the session * @return the UnifiedPush Endpoint or null if not received */ - fun getEndpoint(clientSecret: String): String? { + override fun getEndpoint(clientSecret: String): String? { return defaultPrefs.getString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, null) } /** * Store UnifiedPush Endpoint to the SharedPrefs. * - * @param endpoint the endpoint to store * @param clientSecret the client secret, to identify the session + * @param endpoint the endpoint to store */ - fun storeUpEndpoint(endpoint: String?, clientSecret: String) { + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { defaultPrefs.edit { putString(PREFS_ENDPOINT_OR_TOKEN + clientSecret, endpoint) } @@ -55,24 +68,35 @@ class UnifiedPushStore @Inject constructor( * @param clientSecret the client secret, to identify the session * @return the Push Gateway or null if not defined */ - fun getPushGateway(clientSecret: String): String? { + override fun getPushGateway(clientSecret: String): String? { return defaultPrefs.getString(PREFS_PUSH_GATEWAY + clientSecret, null) } /** * Store Push Gateway to the SharedPrefs. * - * @param gateway the push gateway to store * @param clientSecret the client secret, to identify the session + * @param gateway the push gateway to store */ - fun storePushGateway(gateway: String?, clientSecret: String) { + override fun storePushGateway(clientSecret: String, gateway: String?) { defaultPrefs.edit { putString(PREFS_PUSH_GATEWAY + clientSecret, gateway) } } + override fun getDistributorValue(userId: UserId): String? { + return defaultPrefs.getString(PREFS_DISTRIBUTOR + userId, null) + } + + override fun setDistributorValue(userId: UserId, value: String) { + defaultPrefs.edit { + putString(PREFS_DISTRIBUTOR + userId, value) + } + } + companion object { private const val PREFS_ENDPOINT_OR_TOKEN = "UP_ENDPOINT_OR_TOKEN" private const val PREFS_PUSH_GATEWAY = "PUSH_GATEWAY" + private const val PREFS_DISTRIBUTOR = "DISTRIBUTOR" } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt index b6030564ca..769f6507d5 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnregisterUnifiedPushUseCase.kt @@ -17,30 +17,40 @@ package io.element.android.libraries.pushproviders.unifiedpush import android.content.Context +import com.squareup.anvil.annotations.ContributesBinding +import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.ApplicationContext +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.pushproviders.api.PusherSubscriber import org.unifiedpush.android.connector.UnifiedPush import timber.log.Timber import javax.inject.Inject -class UnregisterUnifiedPushUseCase @Inject constructor( +interface UnregisterUnifiedPushUseCase { + suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result +} + +@ContributesBinding(AppScope::class) +class DefaultUnregisterUnifiedPushUseCase @Inject constructor( @ApplicationContext private val context: Context, - // private val pushDataStore: PushDataStore, private val unifiedPushStore: UnifiedPushStore, - // private val unifiedPushGatewayResolver: UnifiedPushGatewayResolver, -) { - suspend fun execute(clientSecret: String) { - // val mode = BackgroundSyncMode.FDROID_BACKGROUND_SYNC_MODE_FOR_REALTIME - // pushDataStore.setFdroidSyncBackgroundMode(mode) - try { - unifiedPushStore.getEndpoint(clientSecret)?.let { - Timber.d("Removing $it") - // TODO pushersManager?.unregisterPusher(it) - } - } catch (e: Exception) { - Timber.d(e, "Probably unregistering a non existing pusher") + private val pusherSubscriber: PusherSubscriber, +) : UnregisterUnifiedPushUseCase { + override suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { + val endpoint = unifiedPushStore.getEndpoint(clientSecret) + val gateway = unifiedPushStore.getPushGateway(clientSecret) + if (endpoint == null || gateway == null) { + Timber.w("No endpoint or gateway found for client secret") + // Ensure we don't have any remaining data, but ignore this error + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) + return Result.success(Unit) } - unifiedPushStore.storeUpEndpoint(null, clientSecret) - unifiedPushStore.storePushGateway(null, clientSecret) - UnifiedPush.unregisterApp(context) + return pusherSubscriber.unregisterPusher(matrixClient, endpoint, gateway) + .onSuccess { + unifiedPushStore.storeUpEndpoint(clientSecret, null) + unifiedPushStore.storePushGateway(clientSecret, null) + UnifiedPush.unregisterApp(context) + } } } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt index e28d67ecf7..a52b1b0e6e 100644 --- a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiver.kt @@ -21,8 +21,9 @@ import android.content.Intent import io.element.android.libraries.architecture.bindings import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.SupervisorJob import kotlinx.coroutines.launch import org.unifiedpush.android.connector.MessagingReceiver import timber.log.Timber @@ -37,8 +38,8 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { @Inject lateinit var unifiedPushStore: UnifiedPushStore @Inject lateinit var unifiedPushGatewayResolver: UnifiedPushGatewayResolver @Inject lateinit var newGatewayHandler: UnifiedPushNewGatewayHandler - - private val coroutineScope = CoroutineScope(SupervisorJob()) + @Inject lateinit var endpointRegistrationHandler: EndpointRegistrationHandler + @Inject lateinit var coroutineScope: CoroutineScope override fun onReceive(context: Context, intent: Intent) { context.applicationContext.bindings().inject(this) @@ -69,20 +70,23 @@ class VectorUnifiedPushMessagingReceiver : MessagingReceiver() { * You should send the endpoint to your application server and sync for missing notifications. */ override fun onNewEndpoint(context: Context, endpoint: String, instance: String) { - Timber.tag(loggerTag.value).i("onNewEndpoint: adding $endpoint") - // If the endpoint has changed - // or the gateway has changed - if (unifiedPushStore.getEndpoint(instance) != endpoint) { - unifiedPushStore.storeUpEndpoint(endpoint, instance) - coroutineScope.launch { - val gateway = unifiedPushGatewayResolver.getGateway(endpoint) - unifiedPushStore.storePushGateway(gateway, instance) - gateway?.let { pushGateway -> - newGatewayHandler.handle(endpoint, pushGateway, instance) + Timber.tag(loggerTag.value).i("onNewEndpoint: $endpoint") + coroutineScope.launch { + val gateway = unifiedPushGatewayResolver.getGateway(endpoint) + unifiedPushStore.storePushGateway(instance, gateway) + val result = newGatewayHandler.handle(endpoint, gateway, instance) + .onFailure { + Timber.tag(loggerTag.value).e(it, "Failed to handle new gateway") } - } - } else { - Timber.tag(loggerTag.value).i("onNewEndpoint: skipped") + .onSuccess { + unifiedPushStore.storeUpEndpoint(instance, endpoint) + } + endpointRegistrationHandler.registrationDone( + RegistrationResult( + clientSecret = instance, + result = result, + ) + ) } guardServiceStarter.stop() } diff --git a/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt new file mode 100644 index 0000000000..504ae51916 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/main/kotlin/io/element/android/libraries/pushproviders/unifiedpush/registration/EndpointRegistrationHandler.kt @@ -0,0 +1,38 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush.registration + +import io.element.android.libraries.di.AppScope +import io.element.android.libraries.di.SingleIn +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.SharedFlow +import javax.inject.Inject + +data class RegistrationResult( + val clientSecret: String, + val result: Result, +) + +@SingleIn(AppScope::class) +class EndpointRegistrationHandler @Inject constructor() { + private val _state = MutableSharedFlow() + val state: SharedFlow = _state + + suspend fun registrationDone(result: RegistrationResult) { + _state.emit(result) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-pt/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/pushproviders/unifiedpush/src/main/res/values-pt/translations.xml rename to libraries/pushproviders/unifiedpush/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml index e3ae65cd6c..fa96a07474 100644 --- a/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml +++ b/libraries/pushproviders/unifiedpush/src/main/res/values-ro/translations.xml @@ -4,6 +4,7 @@ "Nu au fost găsiți distribuitori push." "%1$d distribuitor găsit: %2$s." + "%1$d distribuitori găsiți: %2$s." "%1$d distribuitori găsiți: %2$s." "Verificați UnifiedPush" diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000000..cda9516b65 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultRegisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,86 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultRegisterUnifiedPushUseCaseTest { + @Test + fun `test registration successful`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.success(Unit))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isTrue() + } + + @Test + fun `test registration error`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + launch { + delay(100) + endpointRegistrationHandler.registrationDone(RegistrationResult(A_SECRET, Result.failure(AN_EXCEPTION))) + } + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + @Test + fun `test registration timeout`() = runTest { + val endpointRegistrationHandler = EndpointRegistrationHandler() + val useCase = createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler = endpointRegistrationHandler + ) + val aDistributor = Distributor("aValue", "aName") + val result = useCase.execute(aDistributor, A_SECRET) + assertThat(result.isSuccess).isFalse() + } + + private fun TestScope.createDefaultRegisterUnifiedPushUseCase( + endpointRegistrationHandler: EndpointRegistrationHandler + ): DefaultRegisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultRegisterUnifiedPushUseCase( + context = context, + endpointRegistrationHandler = endpointRegistrationHandler, + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt new file mode 100644 index 0000000000..e180e36c38 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushGatewayResolverTest.kt @@ -0,0 +1,144 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryUnifiedPush +import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushGatewayResolverTest { + private val matrixDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "matrix" + ) + ) + } + + private val invalidDiscoveryResponse = { + DiscoveryResponse( + unifiedpush = DiscoveryUnifiedPush( + gateway = "" + ) + ) + } + + @Test + fun `when a custom url provide a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo("https://custom.url/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with port provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo("https://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with port and path provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url:123") + assertThat(result).isEqualTo("https://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url with http scheme provides a correct matrix gateway, the custom url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url:123/some/path") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url:123") + assertThat(result).isEqualTo("http://custom.url:123/_matrix/push/v1/notify") + } + + @Test + fun `when a custom url is not reachable, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { throw AN_EXCEPTION } + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("http://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("http://custom.url") + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + @Test + fun `when a custom url is invalid, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = matrixDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("invalid") + assertThat(unifiedPushApiFactory.baseUrlParameter).isNull() + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + @Test + fun `when a custom url provides a invalid matrix gateway, the default url is returned`() = runTest { + val unifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = invalidDiscoveryResponse + ) + val sut = createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory + ) + val result = sut.getGateway("https://custom.url") + assertThat(unifiedPushApiFactory.baseUrlParameter).isEqualTo("https://custom.url") + assertThat(result).isEqualTo(UnifiedPushConfig.DEFAULT_PUSH_GATEWAY_HTTP_URL) + } + + private fun TestScope.createDefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory: UnifiedPushApiFactory = FakeUnifiedPushApiFactory( + discoveryResponse = { DiscoveryResponse() } + ) + ) = DefaultUnifiedPushGatewayResolver( + unifiedPushApiFactory = unifiedPushApiFactory, + coroutineDispatchers = testCoroutineDispatchers() + ) +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt new file mode 100644 index 0000000000..09637fcd21 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnifiedPushNewGatewayHandlerTest.kt @@ -0,0 +1,143 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_USER_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.libraries.pushstore.api.UserPushStoreFactory +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStore +import io.element.android.libraries.pushstore.test.userpushstore.FakeUserPushStoreFactory +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class DefaultUnifiedPushNewGatewayHandlerTest { + @Test + fun `error when fail to retrieve the session`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { null } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("Unable to retrieve session") + } + + @Test + fun `error when the session is not using UnifiedPush`() = runTest { + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = "other") } + ) + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("This session is not using UnifiedPush pusher") + } + + @Test + fun `error when the registration fails`() = runTest { + val aMatrixClient = FakeMatrixClient() + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = { _, _, _ -> Result.failure(IllegalStateException("an error")) } + ), + matrixAuthenticationService = FakeMatrixAuthenticationService(matrixClientResult = { Result.success(aMatrixClient) }), + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result.isFailure).isTrue() + assertThat(result.exceptionOrNull()).isInstanceOf(IllegalStateException::class.java) + assertThat(result.exceptionOrNull()?.message).isEqualTo("an error") + } + + @Test + fun `happy path`() = runTest { + val aMatrixClient = FakeMatrixClient() + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> + Result.success(Unit) + } + val defaultUnifiedPushNewGatewayHandler = createDefaultUnifiedPushNewGatewayHandler( + pushClientSecret = FakePushClientSecret( + getUserIdFromSecretResult = { A_USER_ID } + ), + userPushStoreFactory = FakeUserPushStoreFactory( + userPushStore = { FakeUserPushStore(pushProviderName = UnifiedPushConfig.NAME) } + ), + pusherSubscriber = FakePusherSubscriber( + registerPusherResult = lambda + ), + matrixAuthenticationService = FakeMatrixAuthenticationService(matrixClientResult = { Result.success(aMatrixClient) }), + ) + val result = defaultUnifiedPushNewGatewayHandler.handle( + endpoint = "aEndpoint", + pushGateway = "aPushGateway", + clientSecret = A_SECRET, + ) + assertThat(result).isEqualTo(Result.success(Unit)) + lambda.assertions() + .isCalledOnce() + .with(value(aMatrixClient), value("aEndpoint"), value("aPushGateway")) + } + + private fun createDefaultUnifiedPushNewGatewayHandler( + pusherSubscriber: PusherSubscriber = FakePusherSubscriber(), + userPushStoreFactory: UserPushStoreFactory = FakeUserPushStoreFactory(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService() + ): DefaultUnifiedPushNewGatewayHandler { + return DefaultUnifiedPushNewGatewayHandler( + pusherSubscriber = pusherSubscriber, + userPushStoreFactory = userPushStoreFactory, + pushClientSecret = pushClientSecret, + matrixAuthenticationService = matrixAuthenticationService + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt new file mode 100644 index 0000000000..dfa03707cc --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/DefaultUnregisterUnifiedPushUseCaseTest.kt @@ -0,0 +1,139 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.push.test.FakePusherSubscriber +import io.element.android.libraries.pushproviders.api.PusherSubscriber +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class DefaultUnregisterUnifiedPushUseCaseTest { + @Test + fun `test un registration successful`() = runTest { + val lambda = lambdaRecorder { _: MatrixClient, _: String, _: String -> Result.success(Unit) } + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = lambda + ) + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + lambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value("aEndpoint"), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no endpoint - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { null }, + getPushGatewayResult = { "aGateway" }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error - no gateway - will not unregister but return success`() = runTest { + val matrixClient = FakeMatrixClient() + val storeUpEndpointResult = lambdaRecorder { _: String, _: String? -> } + val storePushGatewayResult = lambdaRecorder { _: String, _: String? -> } + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { null }, + storeUpEndpointResult = storeUpEndpointResult, + storePushGatewayResult = storePushGatewayResult, + ), + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isSuccess).isTrue() + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value(null)) + } + + @Test + fun `test un registration error`() = runTest { + val matrixClient = FakeMatrixClient() + val useCase = createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore = FakeUnifiedPushStore( + getEndpointResult = { "aEndpoint" }, + getPushGatewayResult = { "aGateway" }, + ), + pusherSubscriber = FakePusherSubscriber( + unregisterPusherResult = { _, _, _ -> Result.failure(AN_EXCEPTION) } + ) + ) + val result = useCase.execute(matrixClient, A_SECRET) + assertThat(result.isFailure).isTrue() + } + + private fun createDefaultUnregisterUnifiedPushUseCase( + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + pusherSubscriber: PusherSubscriber = FakePusherSubscriber() + ): DefaultUnregisterUnifiedPushUseCase { + val context = InstrumentationRegistry.getInstrumentation().context + return DefaultUnregisterUnifiedPushUseCase( + context = context, + unifiedPushStore = unifiedPushStore, + pusherSubscriber = pusherSubscriber + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..1800903dea --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeRegisterUnifiedPushUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeRegisterUnifiedPushUseCase( + private val result: (Distributor, String) -> Result = { _, _ -> lambdaError() } +) : RegisterUnifiedPushUseCase { + override suspend fun execute(distributor: Distributor, clientSecret: String): Result { + return result(distributor, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt new file mode 100644 index 0000000000..e0d7808505 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushApiFactory.kt @@ -0,0 +1,40 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.pushproviders.unifiedpush.network.DiscoveryResponse +import io.element.android.libraries.pushproviders.unifiedpush.network.UnifiedPushApi + +class FakeUnifiedPushApiFactory( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApiFactory { + var baseUrlParameter: String? = null + private set + + override fun create(baseUrl: String): UnifiedPushApi { + baseUrlParameter = baseUrl + return FakeUnifiedPushApi(discoveryResponse) + } +} + +class FakeUnifiedPushApi( + private val discoveryResponse: () -> DiscoveryResponse +) : UnifiedPushApi { + override suspend fun discover(): DiscoveryResponse { + return discoveryResponse() + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt new file mode 100644 index 0000000000..0bc52fbae8 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushGatewayResolver.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushGatewayResolver( + private val getGatewayResult: (String) -> String = { lambdaError() }, +) : UnifiedPushGatewayResolver { + override suspend fun getGateway(endpoint: String): String { + return getGatewayResult(endpoint) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt new file mode 100644 index 0000000000..b8d70baada --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushNewGatewayHandler.kt @@ -0,0 +1,27 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushNewGatewayHandler( + private val handleResult: suspend (String, String, String) -> Result = { _, _, _ -> lambdaError() }, +) : UnifiedPushNewGatewayHandler { + override suspend fun handle(endpoint: String, pushGateway: String, clientSecret: String): Result { + return handleResult(endpoint, pushGateway, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt new file mode 100644 index 0000000000..aa381d9535 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnifiedPushStore.kt @@ -0,0 +1,53 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnifiedPushStore( + private val getEndpointResult: (String) -> String? = { lambdaError() }, + private val storeUpEndpointResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getPushGatewayResult: (String) -> String? = { lambdaError() }, + private val storePushGatewayResult: (String, String?) -> Unit = { _, _ -> lambdaError() }, + private val getDistributorValueResult: (UserId) -> String? = { lambdaError() }, + private val setDistributorValueResult: (UserId, String) -> Unit = { _, _ -> lambdaError() }, +) : UnifiedPushStore { + override fun getEndpoint(clientSecret: String): String? { + return getEndpointResult(clientSecret) + } + + override fun storeUpEndpoint(clientSecret: String, endpoint: String?) { + storeUpEndpointResult(clientSecret, endpoint) + } + + override fun getPushGateway(clientSecret: String): String? { + return getPushGatewayResult(clientSecret) + } + + override fun storePushGateway(clientSecret: String, gateway: String?) { + storePushGatewayResult(clientSecret, gateway) + } + + override fun getDistributorValue(userId: UserId): String? { + return getDistributorValueResult(userId) + } + + override fun setDistributorValue(userId: UserId, value: String) { + setDistributorValueResult(userId, value) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt new file mode 100644 index 0000000000..9f3293420a --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/FakeUnregisterUnifiedPushUseCase.kt @@ -0,0 +1,28 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.tests.testutils.lambda.lambdaError + +class FakeUnregisterUnifiedPushUseCase( + private val result: (MatrixClient, String) -> Result = { _, _ -> lambdaError() } +) : UnregisterUnifiedPushUseCase { + override suspend fun execute(matrixClient: MatrixClient, clientSecret: String): Result { + return result(matrixClient, clientSecret) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt index bbccc92581..da710037c4 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushParserTest.kt @@ -82,9 +82,8 @@ class UnifiedPushParserTest { } companion object { - private val UNIFIED_PUSH_DATA = + val UNIFIED_PUSH_DATA = "{\"notification\":{\"event_id\":\"$AN_EVENT_ID\",\"room_id\":\"$A_ROOM_ID\",\"counts\":{\"unread\":1},\"prio\":\"high\"}}" - // TODO Check client secret format? } } diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt new file mode 100644 index 0000000000..826f08a1b0 --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/UnifiedPushProviderTest.kt @@ -0,0 +1,322 @@ +/* + * Copyright (c) 2024 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.libraries.pushproviders.unifiedpush + +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.FakeMatrixClient +import io.element.android.libraries.pushproviders.api.CurrentUserPushConfig +import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.troubleshoot.FakeUnifiedPushDistributorProvider +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.FakePushClientSecret +import io.element.android.services.appnavstate.api.AppNavigationState +import io.element.android.services.appnavstate.api.AppNavigationStateService +import io.element.android.services.appnavstate.api.NavigationState +import io.element.android.services.appnavstate.test.FakeAppNavigationStateService +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.test.runTest +import org.junit.Test + +class UnifiedPushProviderTest { + @Test + fun `test index and name`() { + val unifiedPushProvider = createUnifiedPushProvider() + assertThat(unifiedPushProvider.name).isEqualTo(UnifiedPushConfig.NAME) + assertThat(unifiedPushProvider.index).isEqualTo(UnifiedPushConfig.INDEX) + } + + @Test + fun `getDistributors return the available distributors`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value", "Name"), + ) + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).containsExactly(Distributor("value", "Name")) + assertThat(unifiedPushProvider.isAvailable()).isTrue() + } + + @Test + fun `getDistributors return empty`() { + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getDistributors() + assertThat(result).isEmpty() + assertThat(unifiedPushProvider.isAvailable()).isFalse() + } + + @Test + fun `register ok`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val setDistributorValueResultLambda = lambdaRecorder { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + setDistributorValueResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID), value("value")) + } + + @Test + fun `register ko`() = runTest { + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val setDistributorValueResultLambda = lambdaRecorder(ensureNeverCalled = true) { _, _ -> } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + registerUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase( + result = executeLambda, + ), + unifiedPushStore = FakeUnifiedPushStore( + setDistributorValueResult = setDistributorValueResultLambda, + ), + ) + val result = unifiedPushProvider.registerWith(FakeMatrixClient(), Distributor("value", "Name")) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(Distributor("value", "Name")), value(A_SECRET)) + } + + @Test + fun `unregister ok`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.success(Unit) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + result = executeLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.success(Unit)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET)) + } + + @Test + fun `unregister ko`() = runTest { + val matrixClient = FakeMatrixClient() + val getSecretForUserResultLambda = lambdaRecorder { A_SECRET } + val executeLambda = lambdaRecorder> { _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushProvider = createUnifiedPushProvider( + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = getSecretForUserResultLambda, + ), + unRegisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase( + result = executeLambda, + ), + ) + val result = unifiedPushProvider.unregister(matrixClient) + assertThat(result).isEqualTo(Result.failure(AN_EXCEPTION)) + getSecretForUserResultLambda.assertions() + .isCalledOnce() + .with(value(A_SESSION_ID)) + executeLambda.assertions() + .isCalledOnce() + .with(value(matrixClient), value(A_SECRET)) + } + + @Test + fun `getCurrentDistributor ok`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + Distributor("value2", "Name2"), + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isEqualTo(distributor) + } + + @Test + fun `getCurrentDistributor not know`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { "unknown" } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = listOf( + distributor, + ) + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isNull() + } + + @Test + fun `getCurrentDistributor not found`() = runTest { + val distributor = Distributor("value", "Name") + val matrixClient = FakeMatrixClient() + val unifiedPushProvider = createUnifiedPushProvider( + unifiedPushStore = FakeUnifiedPushStore( + getDistributorValueResult = { distributor.value } + ), + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider( + getDistributorsResult = emptyList() + ) + ) + val result = unifiedPushProvider.getCurrentDistributor(matrixClient) + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no session`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider() + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push gateway`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { null } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig no push key`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { null } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isNull() + } + + @Test + fun `getCurrentUserPushConfig ok`() = runTest { + val unifiedPushProvider = createUnifiedPushProvider( + appNavigationStateService = FakeAppNavigationStateService( + appNavigationState = MutableStateFlow( + AppNavigationState( + navigationState = NavigationState.Session(owner = "owner", sessionId = A_SESSION_ID), + isInForeground = true + ) + ) + ), + pushClientSecret = FakePushClientSecret( + getSecretForUserResult = { A_SECRET } + ), + unifiedPushStore = FakeUnifiedPushStore( + getPushGatewayResult = { "aPushGateway" }, + getEndpointResult = { "aEndpoint" } + ), + ) + val result = unifiedPushProvider.getCurrentUserPushConfig() + assertThat(result).isEqualTo(CurrentUserPushConfig("aPushGateway", "aEndpoint")) + } + + private fun createUnifiedPushProvider( + unifiedPushDistributorProvider: UnifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + registerUnifiedPushUseCase: RegisterUnifiedPushUseCase = FakeRegisterUnifiedPushUseCase(), + unRegisterUnifiedPushUseCase: UnregisterUnifiedPushUseCase = FakeUnregisterUnifiedPushUseCase(), + pushClientSecret: PushClientSecret = FakePushClientSecret(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + appNavigationStateService: AppNavigationStateService = FakeAppNavigationStateService(), + ): UnifiedPushProvider { + return UnifiedPushProvider( + unifiedPushDistributorProvider = unifiedPushDistributorProvider, + registerUnifiedPushUseCase = registerUnifiedPushUseCase, + unRegisterUnifiedPushUseCase = unRegisterUnifiedPushUseCase, + pushClientSecret = pushClientSecret, + unifiedPushStore = unifiedPushStore, + appNavigationStateService = appNavigationStateService + ) + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt new file mode 100644 index 0000000000..e2054caacb --- /dev/null +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/VectorUnifiedPushMessagingReceiverTest.kt @@ -0,0 +1,196 @@ +/* + * Copyright (c) 2024 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +@file:OptIn(ExperimentalCoroutinesApi::class) + +package io.element.android.libraries.pushproviders.unifiedpush + +import androidx.test.platform.app.InstrumentationRegistry +import app.cash.turbine.test +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.test.AN_EVENT_ID +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import io.element.android.libraries.matrix.test.A_ROOM_ID +import io.element.android.libraries.matrix.test.A_SECRET +import io.element.android.libraries.push.test.test.FakePushHandler +import io.element.android.libraries.pushproviders.api.PushData +import io.element.android.libraries.pushproviders.api.PushHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.EndpointRegistrationHandler +import io.element.android.libraries.pushproviders.unifiedpush.registration.RegistrationResult +import io.element.android.tests.testutils.lambda.lambdaRecorder +import io.element.android.tests.testutils.lambda.value +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.advanceUntilIdle +import kotlinx.coroutines.test.runTest +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +@RunWith(RobolectricTestRunner::class) +class VectorUnifiedPushMessagingReceiverTest { + @Test + fun `onUnregistered does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onUnregistered(context, A_SECRET) + } + + @Test + fun `onRegistrationFailed does nothing`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver() + vectorUnifiedPushMessagingReceiver.onRegistrationFailed(context, A_SECRET) + } + + @Test + fun `onMessage valid invoke the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder {} + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, UnifiedPushParserTest.UNIFIED_PUSH_DATA.toByteArray(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isCalledOnce() + .with( + value( + PushData( + eventId = AN_EVENT_ID, + roomId = A_ROOM_ID, + unread = 1, + clientSecret = A_SECRET + ) + ) + ) + } + + @Test + fun `onMessage invalid does not invoke the push handler`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val pushHandlerResult = lambdaRecorder {} + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + pushHandler = FakePushHandler( + handleResult = pushHandlerResult + ), + ) + vectorUnifiedPushMessagingReceiver.onMessage(context, "".toByteArray(), A_SECRET) + advanceUntilIdle() + pushHandlerResult.assertions() + .isNeverCalled() + } + + @Test + fun `onNewEndpoint run the expected tasks`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.success(Unit) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { "aGateway" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.success(Unit) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGateway")) + storeUpEndpointResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("anEndpoint")) + } + + @Test + fun `onNewEndpoint, if registration fails, the endpoint should not be stored`() = runTest { + val context = InstrumentationRegistry.getInstrumentation().context + val storePushGatewayResult = lambdaRecorder { _, _ -> } + val storeUpEndpointResult = lambdaRecorder { _, _ -> } + val unifiedPushStore = FakeUnifiedPushStore( + storePushGatewayResult = storePushGatewayResult, + storeUpEndpointResult = storeUpEndpointResult, + ) + val endpointRegistrationHandler = EndpointRegistrationHandler() + val handleResult = lambdaRecorder> { _, _, _ -> Result.failure(AN_EXCEPTION) } + val unifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler( + handleResult = handleResult + ) + val vectorUnifiedPushMessagingReceiver = createVectorUnifiedPushMessagingReceiver( + unifiedPushStore = unifiedPushStore, + unifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver( + getGatewayResult = { "aGateway" } + ), + endpointRegistrationHandler = endpointRegistrationHandler, + unifiedPushNewGatewayHandler = unifiedPushNewGatewayHandler, + ) + endpointRegistrationHandler.state.test { + vectorUnifiedPushMessagingReceiver.onNewEndpoint(context, "anEndpoint", A_SECRET) + advanceUntilIdle() + assertThat(awaitItem()).isEqualTo( + RegistrationResult( + clientSecret = A_SECRET, + result = Result.failure(AN_EXCEPTION) + ) + ) + } + storePushGatewayResult.assertions() + .isCalledOnce() + .with(value(A_SECRET), value("aGateway")) + storeUpEndpointResult.assertions() + .isNeverCalled() + } + + private fun TestScope.createVectorUnifiedPushMessagingReceiver( + pushHandler: PushHandler = FakePushHandler(), + unifiedPushStore: UnifiedPushStore = FakeUnifiedPushStore(), + unifiedPushGatewayResolver: UnifiedPushGatewayResolver = FakeUnifiedPushGatewayResolver(), + unifiedPushNewGatewayHandler: UnifiedPushNewGatewayHandler = FakeUnifiedPushNewGatewayHandler(), + endpointRegistrationHandler: EndpointRegistrationHandler = EndpointRegistrationHandler(), + ): VectorUnifiedPushMessagingReceiver { + return VectorUnifiedPushMessagingReceiver().apply { + this.pushParser = UnifiedPushParser() + this.pushHandler = pushHandler + this.guardServiceStarter = NoopGuardServiceStarter() + this.unifiedPushStore = unifiedPushStore + this.unifiedPushGatewayResolver = unifiedPushGatewayResolver + this.newGatewayHandler = unifiedPushNewGatewayHandler + this.endpointRegistrationHandler = endpointRegistrationHandler + this.coroutineScope = this@createVectorUnifiedPushMessagingReceiver + } + } +} diff --git a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt index 117e8b7457..9f79f7363b 100644 --- a/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt +++ b/libraries/pushproviders/unifiedpush/src/test/kotlin/io/element/android/libraries/pushproviders/unifiedpush/troubleshoot/UnifiedPushTestTest.kt @@ -19,7 +19,9 @@ package io.element.android.libraries.pushproviders.unifiedpush.troubleshoot import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import io.element.android.libraries.pushproviders.api.Distributor +import io.element.android.libraries.pushproviders.unifiedpush.UnifiedPushConfig import io.element.android.libraries.troubleshoot.api.test.NotificationTroubleshootTestState +import io.element.android.libraries.troubleshoot.api.test.TestFilterData import io.element.android.services.toolbox.test.strings.FakeStringProvider import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest @@ -81,4 +83,15 @@ class UnifiedPushTestTest { assertThat(awaitItem().status).isEqualTo(NotificationTroubleshootTestState.Status.Success) } } + + @Test + fun `test isRelevant`() { + val sut = UnifiedPushTest( + unifiedPushDistributorProvider = FakeUnifiedPushDistributorProvider(), + openDistributorWebPageAction = FakeOpenDistributorWebPageAction(), + stringProvider = FakeStringProvider(), + ) + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = UnifiedPushConfig.NAME))).isTrue() + assertThat(sut.isRelevant(TestFilterData(currentPushProviderName = "other"))).isFalse() + } } diff --git a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt index fcfd6475c3..d24cc5ff5e 100644 --- a/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt +++ b/libraries/pushstore/api/src/main/kotlin/io/element/android/libraries/pushstore/api/UserPushStore.kt @@ -24,7 +24,7 @@ interface UserPushStore { suspend fun getPushProviderName(): String? suspend fun setPushProviderName(value: String) suspend fun getCurrentRegisteredPushKey(): String? - suspend fun setCurrentRegisteredPushKey(value: String) + suspend fun setCurrentRegisteredPushKey(value: String?) fun getNotificationEnabledForDevice(): Flow suspend fun setNotificationEnabledForDevice(enabled: Boolean) diff --git a/libraries/pushstore/impl/build.gradle.kts b/libraries/pushstore/impl/build.gradle.kts index 28e53e011c..69f0f21e55 100644 --- a/libraries/pushstore/impl/build.gradle.kts +++ b/libraries/pushstore/impl/build.gradle.kts @@ -49,6 +49,7 @@ dependencies { testImplementation(libs.coroutines.test) testImplementation(projects.libraries.matrix.test) testImplementation(projects.services.appnavstate.test) + testImplementation(projects.libraries.pushstore.test) testImplementation(projects.libraries.sessionStorage.test) androidTestImplementation(libs.coroutines.test) diff --git a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt index f7a159f6c3..cfcc9e3da4 100644 --- a/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt +++ b/libraries/pushstore/impl/src/main/kotlin/io/element/android/libraries/pushstore/impl/UserPushStoreDataStore.kt @@ -76,9 +76,13 @@ class UserPushStoreDataStore( return context.dataStore.data.first()[currentPushKey] } - override suspend fun setCurrentRegisteredPushKey(value: String) { + override suspend fun setCurrentRegisteredPushKey(value: String?) { context.dataStore.edit { - it[currentPushKey] = value + if (value == null) { + it.remove(currentPushKey) + } else { + it[currentPushKey] = value + } } } diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt index dc0e5b3651..0277feef45 100644 --- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt +++ b/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/PushClientSecretImplTest.kt @@ -18,6 +18,7 @@ package io.element.android.libraries.pushstore.impl.clientsecret import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.test.userpushstore.clientsecret.InMemoryPushClientSecretStore import io.element.android.libraries.sessionstorage.test.observer.NoOpSessionObserver import kotlinx.coroutines.test.runTest import org.junit.Test diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt similarity index 93% rename from libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt index 2afbf3210e..112a752368 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStore.kt @@ -14,14 +14,15 @@ * limitations under the License. */ -package com.element.android.libraries.pushstore.test.userpushstore +package io.element.android.libraries.pushstore.test.userpushstore import io.element.android.libraries.pushstore.api.UserPushStore import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow -class FakeUserPushStore : UserPushStore { +class FakeUserPushStore( private var pushProviderName: String? = null +) : UserPushStore { private var currentRegisteredPushKey: String? = null private val notificationEnabledForDevice = MutableStateFlow(true) override suspend fun getPushProviderName(): String? { @@ -36,7 +37,7 @@ class FakeUserPushStore : UserPushStore { return currentRegisteredPushKey } - override suspend fun setCurrentRegisteredPushKey(value: String) { + override suspend fun setCurrentRegisteredPushKey(value: String?) { currentRegisteredPushKey = value } diff --git a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt similarity index 78% rename from libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt index 2f4f524cc2..14fd4ce3a6 100644 --- a/libraries/pushstore/test/src/main/kotlin/com/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/FakeUserPushStoreFactory.kt @@ -14,14 +14,16 @@ * limitations under the License. */ -package com.element.android.libraries.pushstore.test.userpushstore +package io.element.android.libraries.pushstore.test.userpushstore import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushstore.api.UserPushStore import io.element.android.libraries.pushstore.api.UserPushStoreFactory -class FakeUserPushStoreFactory : UserPushStoreFactory { +class FakeUserPushStoreFactory( + val userPushStore: (SessionId) -> UserPushStore = { FakeUserPushStore() } +) : UserPushStoreFactory { override fun getOrCreate(userId: SessionId): UserPushStore { - return FakeUserPushStore() + return userPushStore(userId) } } diff --git a/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt new file mode 100644 index 0000000000..25759ecc45 --- /dev/null +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/FakePushClientSecret.kt @@ -0,0 +1,34 @@ +/* + * Copyright (c) 2024 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.libraries.pushstore.test.userpushstore.clientsecret + +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecret +import io.element.android.tests.testutils.lambda.lambdaError + +class FakePushClientSecret( + private val getSecretForUserResult: (SessionId) -> String = { lambdaError() }, + private val getUserIdFromSecretResult: (String) -> SessionId? = { lambdaError() } +) : PushClientSecret { + override suspend fun getSecretForUser(userId: SessionId): String { + return getSecretForUserResult(userId) + } + + override suspend fun getUserIdFromSecret(clientSecret: String): SessionId? { + return getUserIdFromSecretResult(clientSecret) + } +} diff --git a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt similarity index 94% rename from libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt rename to libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt index 8c9b577967..632014109e 100644 --- a/libraries/pushstore/impl/src/test/kotlin/io/element/android/libraries/pushstore/impl/clientsecret/InMemoryPushClientSecretStore.kt +++ b/libraries/pushstore/test/src/main/kotlin/io/element/android/libraries/pushstore/test/userpushstore/clientsecret/InMemoryPushClientSecretStore.kt @@ -14,7 +14,7 @@ * limitations under the License. */ -package io.element.android.libraries.pushstore.impl.clientsecret +package io.element.android.libraries.pushstore.test.userpushstore.clientsecret import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.pushstore.api.clientsecret.PushClientSecretStore diff --git a/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt new file mode 100644 index 0000000000..5331d5755b --- /dev/null +++ b/libraries/session-storage/impl-memory/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/memory/InMemoryMultiSessionsStore.kt @@ -0,0 +1,44 @@ +/* + * Copyright (c) 2023 New Vector Ltd + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ + +package io.element.android.libraries.sessionstorage.impl.memory + +import io.element.android.libraries.sessionstorage.api.LoggedInState +import io.element.android.libraries.sessionstorage.api.SessionData +import io.element.android.libraries.sessionstorage.api.SessionStore +import kotlinx.coroutines.flow.Flow + +class InMemoryMultiSessionsStore : SessionStore { + private val sessions = mutableListOf() + + override fun isLoggedIn(): Flow = error("Not implemented") + + override fun sessionsFlow(): Flow> = error("Not implemented") + + override suspend fun storeData(sessionData: SessionData) { + sessions.add(sessionData) + } + + override suspend fun updateData(sessionData: SessionData) = error("Not implemented") + + override suspend fun getSession(sessionId: String): SessionData? = error("Not implemented") + + override suspend fun getAllSessions(): List = sessions + + override suspend fun getLatestSession(): SessionData = error("Not implemented") + + override suspend fun removeSession(sessionId: String) = error("Not implemented") +} diff --git a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt index 5a74fa42f0..3bad8f648d 100644 --- a/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt +++ b/libraries/testtags/src/main/kotlin/io/element/android/libraries/testtags/TestTags.kt @@ -75,9 +75,14 @@ object TestTags { val welcomeScreenTitle = TestTag("welcome_screen-title") /** - * RichTextEditor. + * TextEditor. */ - val richTextEditor = TestTag("rich_text_editor") + val textEditor = TestTag("text_editor") + + /** + * EditText inside the MarkdownTextInput. + */ + val plainTextEditor = TestTag("plain_text_editor") /** * Message bubble. diff --git a/libraries/textcomposer/impl/build.gradle.kts b/libraries/textcomposer/impl/build.gradle.kts index 0bbb508b3c..21fd124765 100644 --- a/libraries/textcomposer/impl/build.gradle.kts +++ b/libraries/textcomposer/impl/build.gradle.kts @@ -22,6 +22,9 @@ plugins { android { namespace = "io.element.android.libraries.textcomposer" + testOptions { + unitTests.isIncludeAndroidResources = true + } } dependencies { @@ -47,9 +50,13 @@ dependencies { ksp(libs.showkase.processor) testImplementation(libs.test.junit) - testImplementation(libs.test.truth) testImplementation(libs.coroutines.test) + testImplementation(libs.molecule.runtime) testImplementation(libs.test.robolectric) + testImplementation(libs.test.truth) + testImplementation(libs.test.turbine) testImplementation(projects.libraries.matrix.test) testImplementation(projects.tests.testutils) + testImplementation(libs.androidx.compose.ui.test.junit) + testReleaseImplementation(libs.androidx.compose.ui.test.manifest) } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt new file mode 100644 index 0000000000..c50c81fb87 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/ComposerModeView.kt @@ -0,0 +1,184 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer + +import androidx.compose.foundation.background +import androidx.compose.foundation.clickable +import androidx.compose.foundation.interaction.MutableInteractionSource +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Row +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.width +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.ripple.rememberRipple +import androidx.compose.material3.MaterialTheme +import androidx.compose.runtime.Composable +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.draw.clipToBounds +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.text.style.TextOverflow +import androidx.compose.ui.unit.dp +import io.element.android.compound.theme.ElementTheme +import io.element.android.compound.tokens.generated.CompoundIcons +import io.element.android.libraries.designsystem.theme.components.Icon +import io.element.android.libraries.designsystem.theme.components.Text +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail +import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo +import io.element.android.libraries.textcomposer.model.MessageComposerMode +import io.element.android.libraries.ui.strings.CommonStrings + +@Composable +internal fun ComposerModeView( + composerMode: MessageComposerMode, + onResetComposerMode: () -> Unit, +) { + when (composerMode) { + is MessageComposerMode.Edit -> { + EditingModeView(onResetComposerMode = onResetComposerMode) + } + is MessageComposerMode.Reply -> { + ReplyToModeView( + modifier = Modifier.padding(8.dp), + senderName = composerMode.senderName, + text = composerMode.defaultContent, + attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, + onResetComposerMode = onResetComposerMode, + ) + } + else -> Unit + } +} + +@Composable +private fun EditingModeView( + onResetComposerMode: () -> Unit, +) { + Row( + horizontalArrangement = Arrangement.spacedBy(4.dp), + verticalAlignment = Alignment.CenterVertically, + modifier = Modifier + .fillMaxWidth() + .padding(start = 12.dp) + ) { + Icon( + imageVector = CompoundIcons.Edit(), + contentDescription = stringResource(CommonStrings.common_editing), + tint = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(vertical = 8.dp) + .size(16.dp), + ) + Text( + stringResource(CommonStrings.common_editing), + style = ElementTheme.typography.fontBodySmRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(vertical = 8.dp) + .weight(1f) + ) + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), + tint = ElementTheme.materialColors.secondary, + modifier = Modifier + .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ), + ) + } +} + +@Composable +private fun ReplyToModeView( + senderName: String, + text: String?, + attachmentThumbnailInfo: AttachmentThumbnailInfo?, + onResetComposerMode: () -> Unit, + modifier: Modifier = Modifier, +) { + Row( + modifier + .clip(RoundedCornerShape(13.dp)) + .background(MaterialTheme.colorScheme.surface) + .padding(4.dp) + ) { + if (attachmentThumbnailInfo != null) { + AttachmentThumbnail( + info = attachmentThumbnailInfo, + backgroundColor = MaterialTheme.colorScheme.surfaceVariant, + modifier = Modifier + .size(36.dp) + .clip(RoundedCornerShape(9.dp)) + ) + } + Spacer(modifier = Modifier.width(8.dp)) + Column( + modifier = Modifier + .weight(1f) + .align(Alignment.CenterVertically) + ) { + Text( + text = senderName, + modifier = Modifier + .fillMaxWidth() + .clipToBounds(), + maxLines = 1, + overflow = TextOverflow.Ellipsis, + style = ElementTheme.typography.fontBodySmMedium, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.primary, + ) + Text( + modifier = Modifier.fillMaxWidth(), + text = text.orEmpty(), + style = ElementTheme.typography.fontBodyMdRegular, + textAlign = TextAlign.Start, + color = ElementTheme.materialColors.secondary, + maxLines = if (attachmentThumbnailInfo != null) 1 else 2, + overflow = TextOverflow.Ellipsis, + ) + } + Icon( + imageVector = CompoundIcons.Close(), + contentDescription = stringResource(CommonStrings.action_close), + tint = MaterialTheme.colorScheme.secondary, + modifier = Modifier + .padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) + .size(16.dp) + .clickable( + enabled = true, + onClick = onResetComposerMode, + interactionSource = remember { MutableInteractionSource() }, + indication = rememberRipple(bounded = false) + ), + ) + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt index e31183bd14..7216f7b9cd 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/TextComposer.kt @@ -19,8 +19,6 @@ package io.element.android.libraries.textcomposer import android.net.Uri import androidx.compose.foundation.background import androidx.compose.foundation.border -import androidx.compose.foundation.clickable -import androidx.compose.foundation.interaction.MutableInteractionSource import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Column @@ -34,30 +32,22 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.requiredHeightIn import androidx.compose.foundation.layout.size import androidx.compose.foundation.layout.width -import androidx.compose.foundation.shape.RoundedCornerShape -import androidx.compose.material.ripple.rememberRipple -import androidx.compose.material3.MaterialTheme import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.remember import androidx.compose.runtime.rememberUpdatedState import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.draw.clip -import androidx.compose.ui.draw.clipToBounds import androidx.compose.ui.res.stringResource -import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextOverflow import androidx.compose.ui.unit.dp import io.element.android.compound.theme.ElementTheme -import io.element.android.compound.tokens.generated.CompoundIcons import io.element.android.libraries.designsystem.components.media.createFakeWaveform import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator -import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.TransactionId @@ -66,7 +56,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource import io.element.android.libraries.matrix.api.permalink.PermalinkData import io.element.android.libraries.matrix.api.permalink.PermalinkParser import io.element.android.libraries.matrix.ui.components.A_BLUR_HASH -import io.element.android.libraries.matrix.ui.components.AttachmentThumbnail import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType import io.element.android.libraries.testtags.TestTags @@ -79,11 +68,13 @@ import io.element.android.libraries.textcomposer.components.VoiceMessageDeleteBu import io.element.android.libraries.textcomposer.components.VoiceMessagePreview import io.element.android.libraries.textcomposer.components.VoiceMessageRecorderButton import io.element.android.libraries.textcomposer.components.VoiceMessageRecording +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState import io.element.android.libraries.textcomposer.components.textInputRoundedCornerShape import io.element.android.libraries.textcomposer.mentions.rememberMentionSpanProvider -import io.element.android.libraries.textcomposer.model.Message import io.element.android.libraries.textcomposer.model.MessageComposerMode import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.TextEditorState import io.element.android.libraries.textcomposer.model.VoiceMessagePlayerEvent import io.element.android.libraries.textcomposer.model.VoiceMessageRecorderEvent import io.element.android.libraries.textcomposer.model.VoiceMessageState @@ -98,15 +89,14 @@ import kotlin.time.Duration.Companion.seconds @Composable fun TextComposer( - state: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, permalinkParser: PermalinkParser, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, onRequestFocus: () -> Unit, - onSendMessage: (Message) -> Unit, + onSendMessage: () -> Unit, onResetComposerMode: () -> Unit, onAddAttachment: () -> Unit, onDismissTextFormatting: () -> Unit, @@ -122,9 +112,12 @@ fun TextComposer( showTextFormatting: Boolean = false, subcomposing: Boolean = false, ) { + val markdown = when (state) { + is TextEditorState.Markdown -> state.state.text.value() + is TextEditorState.Rich -> state.richTextEditorState.messageMarkdown + } val onSendClicked = { - val html = if (enableTextFormatting) state.messageHtml else null - onSendMessage(Message(html = html, markdown = state.messageMarkdown)) + onSendMessage() } val onPlayVoiceMessageClicked = { @@ -153,32 +146,58 @@ fun TextComposer( } } - val textInput: @Composable () -> Unit = remember(state, subcomposing, composerMode, onResetComposerMode, onError) { - @Composable { - val mentionSpanProvider = rememberMentionSpanProvider( - currentUserId = currentUserId, - permalinkParser = permalinkParser, - ) - TextInput( - state = state, - subcomposing = subcomposing, - placeholder = if (composerMode.inThread) { - stringResource(id = CommonStrings.action_reply_in_thread) - } else { - stringResource(id = R.string.rich_text_editor_composer_placeholder) - }, - composerMode = composerMode, - onResetComposerMode = onResetComposerMode, - resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, - resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, - onError = onError, - onTyping = onTyping, - onRichContentSelected = onRichContentSelected, - ) + val placeholder = if (composerMode.inThread) { + stringResource(id = CommonStrings.action_reply_in_thread) + } else { + stringResource(id = R.string.rich_text_editor_composer_placeholder) + } + val textInput: @Composable () -> Unit = when (state) { + is TextEditorState.Rich -> { + remember(state.richTextEditorState, subcomposing, composerMode, onResetComposerMode, onError) { + @Composable { + val mentionSpanProvider = rememberMentionSpanProvider( + currentUserId = currentUserId, + permalinkParser = permalinkParser, + ) + TextInput( + state = state.richTextEditorState, + subcomposing = subcomposing, + placeholder = placeholder, + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + resolveMentionDisplay = { text, url -> TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor(text, url)) }, + resolveRoomMentionDisplay = { TextDisplay.Custom(mentionSpanProvider.getMentionSpanFor("@room", "#")) }, + onError = onError, + onTyping = onTyping, + onRichContentSelected = onRichContentSelected, + ) + } + } + } + is TextEditorState.Markdown -> { + @Composable { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus()) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.state.text.value().isEmpty() }, + subcomposing = subcomposing, + ) { + MarkdownTextInput( + state = state.state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + richTextEditorStyle = style, + onRichContentSelected = onRichContentSelected, + ) + } + } } } - val canSendMessage by remember { derivedStateOf { state.messageMarkdown.isNotBlank() } } + val canSendMessage = markdown.isNotBlank() val sendButton = @Composable { SendButton( canSendMessage = canSendMessage, @@ -205,7 +224,9 @@ fun TextComposer( ) } - val textFormattingOptions = @Composable { TextFormatting(state = state) } + val textFormattingOptions: @Composable (() -> Unit)? = (state as? TextEditorState.Rich)?.let { + @Composable { TextFormatting(state = it.richTextEditorState) } + } val sendOrRecordButton = when { enableVoiceMessages && !canSendMessage -> @@ -217,8 +238,7 @@ fun TextComposer( false -> sendVoiceButton } } - else -> - sendButton + else -> sendButton } val voiceRecording = @Composable { @@ -251,7 +271,7 @@ fun TextComposer( } } - if (showTextFormatting) { + if (showTextFormatting && textFormattingOptions != null) { TextFormattingLayout( modifier = layoutModifier, textInput = textInput, @@ -282,14 +302,16 @@ fun TextComposer( SoftKeyboardEffect(showTextFormatting, onRequestFocus) { it } } - val menuAction = state.menuAction val latestOnSuggestionReceived by rememberUpdatedState(onSuggestionReceived) - LaunchedEffect(menuAction) { - if (menuAction is MenuAction.Suggestion) { - val suggestion = Suggestion(menuAction.suggestionPattern) - latestOnSuggestionReceived(suggestion) - } else { - latestOnSuggestionReceived(null) + if (state is TextEditorState.Rich) { + val menuAction = state.richTextEditorState.menuAction + LaunchedEffect(menuAction) { + if (menuAction is MenuAction.Suggestion) { + val suggestion = Suggestion(menuAction.suggestionPattern) + latestOnSuggestionReceived(suggestion) + } else { + latestOnSuggestionReceived(null) + } } } } @@ -400,17 +422,13 @@ private fun TextFormattingLayout( } @Composable -private fun TextInput( - state: RichTextEditorState, - subcomposing: Boolean, - placeholder: String, +private fun TextInputBox( composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, - resolveRoomMentionDisplay: () -> TextDisplay, - resolveMentionDisplay: (text: String, url: String) -> TextDisplay, - onError: (Throwable) -> Unit, - onTyping: (Boolean) -> Unit, - onRichContentSelected: ((Uri) -> Unit)?, + placeholder: String, + showPlaceholder: () -> Boolean, + subcomposing: Boolean, + textInput: @Composable () -> Unit, ) { val bgColor = ElementTheme.colors.bgSubtleSecondary val borderColor = ElementTheme.colors.borderDisabled @@ -431,11 +449,12 @@ private fun TextInput( Box( modifier = Modifier .padding(top = 4.dp, bottom = 4.dp, start = 12.dp, end = 42.dp) - .testTag(TestTags.richTextEditor), + // Apply test tag only once, otherwise 2 nodes will have it (both the normal and subcomposing one) and tests will fail + .then(if (!subcomposing) Modifier.testTag(TestTags.textEditor) else Modifier), contentAlignment = Alignment.CenterStart, ) { // Placeholder - if (state.messageHtml.isEmpty()) { + if (showPlaceholder()) { Text( placeholder, style = defaultTypography.copy( @@ -446,155 +465,45 @@ private fun TextInput( ) } - RichTextEditor( - state = state, - // Disable most of the editor functionality if it's just being measured for a subcomposition. - // This prevents it gaining focus and mutating the state. - registerStateUpdates = !subcomposing, - modifier = Modifier - .padding(top = 6.dp, bottom = 6.dp) - .fillMaxWidth(), - style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), - resolveMentionDisplay = resolveMentionDisplay, - resolveRoomMentionDisplay = resolveRoomMentionDisplay, - onError = onError, - onRichContentSelected = onRichContentSelected, - onTyping = onTyping, - ) + textInput() } } } @Composable -private fun ComposerModeView( +private fun TextInput( + state: RichTextEditorState, + subcomposing: Boolean, + placeholder: String, composerMode: MessageComposerMode, onResetComposerMode: () -> Unit, + resolveRoomMentionDisplay: () -> TextDisplay, + resolveMentionDisplay: (text: String, url: String) -> TextDisplay, + onError: (Throwable) -> Unit, + onTyping: (Boolean) -> Unit, + onRichContentSelected: ((Uri) -> Unit)?, ) { - when (composerMode) { - is MessageComposerMode.Edit -> { - EditingModeView(onResetComposerMode = onResetComposerMode) - } - is MessageComposerMode.Reply -> { - ReplyToModeView( - modifier = Modifier.padding(8.dp), - senderName = composerMode.senderName, - text = composerMode.defaultContent, - attachmentThumbnailInfo = composerMode.attachmentThumbnailInfo, - onResetComposerMode = onResetComposerMode, - ) - } - else -> Unit - } -} - -@Composable -private fun EditingModeView( - onResetComposerMode: () -> Unit, -) { - Row( - horizontalArrangement = Arrangement.spacedBy(4.dp), - verticalAlignment = Alignment.CenterVertically, - modifier = Modifier - .fillMaxWidth() - .padding(start = 12.dp) + TextInputBox( + composerMode = composerMode, + onResetComposerMode = onResetComposerMode, + placeholder = placeholder, + showPlaceholder = { state.messageHtml.isEmpty() }, + subcomposing = subcomposing, ) { - Icon( - imageVector = CompoundIcons.Edit(), - contentDescription = stringResource(CommonStrings.common_editing), - tint = ElementTheme.materialColors.secondary, + RichTextEditor( + state = state, + // Disable most of the editor functionality if it's just being measured for a subcomposition. + // This prevents it gaining focus and mutating the state. + registerStateUpdates = !subcomposing, modifier = Modifier - .padding(vertical = 8.dp) - .size(16.dp), - ) - Text( - stringResource(CommonStrings.common_editing), - style = ElementTheme.typography.fontBodySmRegular, - textAlign = TextAlign.Start, - color = ElementTheme.materialColors.secondary, - modifier = Modifier - .padding(vertical = 8.dp) - .weight(1f) - ) - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_close), - tint = ElementTheme.materialColors.secondary, - modifier = Modifier - .padding(top = 8.dp, bottom = 8.dp, start = 16.dp, end = 12.dp) - .size(16.dp) - .clickable( - enabled = true, - onClick = onResetComposerMode, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ), - ) - } -} - -@Composable -private fun ReplyToModeView( - senderName: String, - text: String?, - attachmentThumbnailInfo: AttachmentThumbnailInfo?, - onResetComposerMode: () -> Unit, - modifier: Modifier = Modifier, -) { - Row( - modifier - .clip(RoundedCornerShape(13.dp)) - .background(MaterialTheme.colorScheme.surface) - .padding(4.dp) - ) { - if (attachmentThumbnailInfo != null) { - AttachmentThumbnail( - info = attachmentThumbnailInfo, - backgroundColor = MaterialTheme.colorScheme.surfaceVariant, - modifier = Modifier - .size(36.dp) - .clip(RoundedCornerShape(9.dp)) - ) - } - Spacer(modifier = Modifier.width(8.dp)) - Column( - modifier = Modifier - .weight(1f) - .align(Alignment.CenterVertically) - ) { - Text( - text = senderName, - modifier = Modifier - .fillMaxWidth() - .clipToBounds(), - maxLines = 1, - overflow = TextOverflow.Ellipsis, - style = ElementTheme.typography.fontBodySmMedium, - textAlign = TextAlign.Start, - color = ElementTheme.materialColors.primary, - ) - Text( - modifier = Modifier.fillMaxWidth(), - text = text.orEmpty(), - style = ElementTheme.typography.fontBodyMdRegular, - textAlign = TextAlign.Start, - color = ElementTheme.materialColors.secondary, - maxLines = if (attachmentThumbnailInfo != null) 1 else 2, - overflow = TextOverflow.Ellipsis, - ) - } - Icon( - imageVector = CompoundIcons.Close(), - contentDescription = stringResource(CommonStrings.action_close), - tint = MaterialTheme.colorScheme.secondary, - modifier = Modifier - .padding(end = 4.dp, top = 4.dp, start = 16.dp, bottom = 16.dp) - .size(16.dp) - .clickable( - enabled = true, - onClick = onResetComposerMode, - interactionSource = remember { MutableInteractionSource() }, - indication = rememberRipple(bounded = false) - ), + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus), + resolveMentionDisplay = resolveMentionDisplay, + resolveRoomMentionDisplay = resolveRoomMentionDisplay, + onError = onError, + onRichContentSelected = onRichContentSelected, + onTyping = onTyping, ) } } @@ -606,43 +515,41 @@ internal fun TextComposerSimplePreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(initialText = "", initialFocus = true), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost"), ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState( - initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", - initialFocus = true + TextEditorState.Markdown( + aMarkdownTextEditorState( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + initialFocus = true + ) ), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message without focus"), + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message without focus", initialFocus = false)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -656,33 +563,32 @@ internal fun TextComposerSimplePreview() = ElementPreview { internal fun TextComposerFormattingPreview() = ElementPreview { PreviewColumn(items = persistentListOf({ ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState( - initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + TextEditorState.Rich( + aRichTextEditorState( + initialText = "A message\nWith several lines\nTo preview larger textfields and long lines with overflow", + ) ), voiceMessageState = VoiceMessageState.Idle, showTextFormatting = true, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -694,10 +600,23 @@ internal fun TextComposerFormattingPreview() = ElementPreview { internal fun TextComposerEditPreview() = ElementPreview { PreviewColumn(items = persistentListOf({ ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), + voiceMessageState = VoiceMessageState.Idle, + composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), + enableVoiceMessages = true, + currentUserId = UserId("@alice:localhost") + ) + })) +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextComposerEditPreview() = ElementPreview { + PreviewColumn(items = persistentListOf({ + ATextComposer( + TextEditorState.Markdown(aMarkdownTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Edit(EventId("$1234"), "Some text", TransactionId("1234")), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -711,7 +630,7 @@ internal fun TextComposerReplyPreview() = ElementPreview { items = persistentListOf( { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -722,14 +641,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { "With several lines\n" + "To preview larger textfields and long lines with overflow" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(), + TextEditorState.Rich(aRichTextEditorState()), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = true, @@ -740,14 +658,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { "With several lines\n" + "To preview larger textfields and long lines with overflow" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = true, @@ -761,14 +678,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "image.jpg" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -782,14 +698,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "video.mp4" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message"), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message")), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -803,14 +718,13 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "logs.txt" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) }, { ATextComposer( - aRichTextEditorState(initialText = "A message", initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialText = "A message", initialFocus = true)), voiceMessageState = VoiceMessageState.Idle, composerMode = MessageComposerMode.Reply( isThreaded = false, @@ -824,7 +738,6 @@ internal fun TextComposerReplyPreview() = ElementPreview { ), defaultContent = "Shared location" ), - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -840,10 +753,9 @@ internal fun TextComposerVoicePreview() = ElementPreview { fun VoicePreview( voiceMessageState: VoiceMessageState ) = ATextComposer( - aRichTextEditorState(initialFocus = true), + TextEditorState.Rich(aRichTextEditorState(initialFocus = true)), voiceMessageState = voiceMessageState, composerMode = MessageComposerMode.Normal, - enableTextFormatting = true, enableVoiceMessages = true, currentUserId = UserId("@alice:localhost") ) @@ -902,23 +814,21 @@ private fun PreviewColumn( @Composable private fun ATextComposer( - richTextEditorState: RichTextEditorState, + state: TextEditorState, voiceMessageState: VoiceMessageState, composerMode: MessageComposerMode, - enableTextFormatting: Boolean, enableVoiceMessages: Boolean, currentUserId: UserId, showTextFormatting: Boolean = false, ) { TextComposer( - state = richTextEditorState, + state = state, showTextFormatting = showTextFormatting, voiceMessageState = voiceMessageState, permalinkParser = object : PermalinkParser { override fun parse(uriString: String): PermalinkData = TODO("Not yet implemented") }, composerMode = composerMode, - enableTextFormatting = enableTextFormatting, enableVoiceMessages = enableVoiceMessages, currentUserId = currentUserId, onRequestFocus = {}, diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt new file mode 100644 index 0000000000..98842c35de --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownEditText.kt @@ -0,0 +1,48 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.components.markdown + +import android.content.Context +import androidx.appcompat.widget.AppCompatEditText + +internal class MarkdownEditText( + context: Context, +) : AppCompatEditText(context) { + var onSelectionChangeListener: ((Int, Int) -> Unit)? = null + + private var isModifyingText = false + + fun updateEditableText(charSequence: CharSequence) { + isModifyingText = true + editableText.clear() + editableText.append(charSequence) + isModifyingText = false + } + + override fun setText(text: CharSequence?, type: BufferType?) { + isModifyingText = true + super.setText(text, type) + isModifyingText = false + } + + override fun onSelectionChanged(selStart: Int, selEnd: Int) { + super.onSelectionChanged(selStart, selEnd) + if (!isModifyingText) { + onSelectionChangeListener?.invoke(selStart, selEnd) + } + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt new file mode 100644 index 0000000000..00b00ef62b --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/MarkdownTextInput.kt @@ -0,0 +1,204 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.components.markdown + +import android.content.ClipData +import android.graphics.Color +import android.net.Uri +import android.text.Editable +import android.text.InputType +import android.text.Selection +import android.view.View +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.runtime.Composable +import androidx.compose.ui.Modifier +import androidx.compose.ui.unit.dp +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.text.getSpans +import androidx.core.view.ContentInfoCompat +import androidx.core.view.OnReceiveContentListener +import androidx.core.view.ViewCompat +import androidx.core.view.setPadding +import androidx.core.widget.addTextChangedListener +import io.element.android.libraries.designsystem.preview.ElementPreview +import io.element.android.libraries.designsystem.preview.PreviewsDayNight +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.wysiwyg.compose.RichTextEditorStyle +import io.element.android.wysiwyg.compose.internal.applyStyleInCompose + +@Suppress("ModifierMissing") +@Composable +fun MarkdownTextInput( + state: MarkdownTextEditorState, + subcomposing: Boolean, + onTyping: (Boolean) -> Unit, + onSuggestionReceived: (Suggestion?) -> Unit, + richTextEditorStyle: RichTextEditorStyle, + onRichContentSelected: ((Uri) -> Unit)?, +) { + val canUpdateState = !subcomposing + + // Copied from io.element.android.wysiwyg.internal.utils.UriContentListener + class ReceiveUriContentListener( + private val onContent: (uri: Uri) -> Unit, + ) : OnReceiveContentListener { + override fun onReceiveContent(view: View, payload: ContentInfoCompat): ContentInfoCompat? { + val split = payload.partition { item -> item.uri != null } + val uriContent = split.first + val remaining = split.second + + if (uriContent != null) { + val clip: ClipData = uriContent.clip + for (i in 0 until clip.itemCount) { + val uri = clip.getItemAt(i).uri + // ... app-specific logic to handle the URI ... + onContent(uri) + } + } + // Return anything that we didn't handle ourselves. This preserves the default platform + // behavior for text and anything else for which we are not implementing custom handling. + return remaining + } + } + + AndroidView( + modifier = Modifier + .padding(top = 6.dp, bottom = 6.dp) + .fillMaxWidth(), + factory = { context -> + MarkdownEditText(context).apply { + tag = TestTags.plainTextEditor.value // Needed for UI tests + setPadding(0) + setBackgroundColor(Color.TRANSPARENT) + setText(state.text.value()) + inputType = InputType.TYPE_CLASS_TEXT or + InputType.TYPE_TEXT_FLAG_CAP_SENTENCES or + InputType.TYPE_TEXT_FLAG_MULTI_LINE or + InputType.TYPE_TEXT_FLAG_AUTO_CORRECT + if (canUpdateState) { + setSelection(state.selection.first, state.selection.last) + setOnFocusChangeListener { _, hasFocus -> + state.hasFocus = hasFocus + } + addTextChangedListener { editable -> + onTyping(!editable.isNullOrEmpty()) + state.text.update(editable, false) + state.lineCount = lineCount + + state.currentMentionSuggestion = editable?.checkSuggestionNeeded() + onSuggestionReceived(state.currentMentionSuggestion) + } + onSelectionChangeListener = { selStart, selEnd -> + state.selection = selStart..selEnd + state.currentMentionSuggestion = editableText.checkSuggestionNeeded() + onSuggestionReceived(state.currentMentionSuggestion) + } + if (onRichContentSelected != null) { + ViewCompat.setOnReceiveContentListener( + this, + arrayOf("image/*"), + ReceiveUriContentListener { onRichContentSelected(it) } + ) + } + state.requestFocusAction = { this.requestFocus() } + } + } + }, + update = { editText -> + editText.applyStyleInCompose(richTextEditorStyle) + + if (state.text.needsDisplaying()) { + editText.updateEditableText(state.text.value()) + if (canUpdateState) { + state.text.update(editText.editableText, false) + } + } + if (canUpdateState) { + val newSelectionStart = state.selection.first + val newSelectionEnd = state.selection.last + val currentTextRange = 0..editText.editableText.length + val didSelectionChange = { editText.selectionStart != newSelectionStart || editText.selectionEnd != newSelectionEnd } + val isNewSelectionValid = { newSelectionStart in currentTextRange && newSelectionEnd in currentTextRange } + if (didSelectionChange() && isNewSelectionValid()) { + editText.setSelection(state.selection.first, state.selection.last) + } + } + } + ) +} + +private fun Editable.checkSuggestionNeeded(): Suggestion? { + if (this.isEmpty()) return null + val start = Selection.getSelectionStart(this) + val end = Selection.getSelectionEnd(this) + var startOfWord = start + while ((startOfWord > 0 || startOfWord == length) && !this[startOfWord - 1].isWhitespace()) { + startOfWord-- + } + if (startOfWord !in indices) return null + val firstChar = this[startOfWord] + + // If a mention span already exists we don't need suggestions + if (getSpans(startOfWord, startOfWord + 1).isNotEmpty()) return null + + return if (firstChar in listOf('@', '#', '/')) { + var endOfWord = end + while (endOfWord < this.length && !this[endOfWord].isWhitespace()) { + endOfWord++ + } + val text = this.subSequence(startOfWord + 1, endOfWord).toString() + val suggestionType = when (firstChar) { + '@' -> SuggestionType.Mention + '#' -> SuggestionType.Room + '/' -> SuggestionType.Command + else -> error("Unknown suggestion type. This should never happen.") + } + Suggestion(startOfWord, endOfWord, suggestionType, text) + } else { + null + } +} + +@PreviewsDayNight +@Composable +internal fun MarkdownTextInputPreview() { + ElementPreview { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = true) + MarkdownTextInput( + state = aMarkdownTextEditorState(), + subcomposing = false, + onTyping = {}, + onSuggestionReceived = {}, + richTextEditorStyle = style, + onRichContentSelected = {}, + ) + } +} + +internal fun aMarkdownTextEditorState( + initialText: String = "Hello, World!", + initialFocus: Boolean = true, +) = MarkdownTextEditorState( + initialText = initialText, + initialFocus = initialFocus, +) diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt new file mode 100644 index 0000000000..5491f9ccf4 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/components/markdown/StableCharSequence.kt @@ -0,0 +1,42 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.components.markdown + +import android.text.SpannableString +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.setValue +import io.element.android.libraries.core.extensions.orEmpty + +@Stable +class StableCharSequence(initialText: CharSequence = "") { + private var value by mutableStateOf(SpannableString(initialText)) + private var needsDisplaying by mutableStateOf(false) + + fun update(newText: CharSequence?, needsDisplaying: Boolean) { + value = SpannableString(newText.orEmpty()) + this.needsDisplaying = needsDisplaying + } + + fun value(): CharSequence = value + fun needsDisplaying(): Boolean = needsDisplaying + + override fun toString(): String { + return "ImmutableCharSequence(value='$value', needsDisplaying=$needsDisplaying)" + } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt index 9788f1f6c3..fe1c0c2167 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpan.kt @@ -26,6 +26,8 @@ import kotlin.math.min import kotlin.math.roundToInt class MentionSpan( + val text: String, + val rawValue: String, val type: Type, val backgroundColor: Int, val textColor: Int, @@ -39,29 +41,25 @@ class MentionSpan( private var actualText: CharSequence? = null private var textWidth = 0 - private var cachedRect: RectF = RectF() private val backgroundPaint = Paint().apply { isAntiAlias = true color = backgroundColor } override fun getSize(paint: Paint, text: CharSequence?, start: Int, end: Int, fm: Paint.FontMetricsInt?): Int { - val mentionText = getActualText(text, start, end) + val mentionText = getActualText(this.text) paint.typeface = typeface textWidth = paint.measureText(mentionText, 0, mentionText.length).roundToInt() return textWidth + startPadding + endPadding } override fun draw(canvas: Canvas, text: CharSequence?, start: Int, end: Int, x: Float, top: Int, y: Int, bottom: Int, paint: Paint) { - val mentionText = getActualText(text, start, end) + val mentionText = getActualText(this.text) // Extra vertical space to add below the baseline (y). This helps us center the span vertically val extraVerticalSpace = y + paint.ascent() + paint.descent() - top - if (cachedRect.isEmpty) { - cachedRect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) - } - val rect = cachedRect + val rect = RectF(x, top.toFloat(), x + textWidth + startPadding + endPadding, y.toFloat() + extraVerticalSpace) val radius = rect.height() / 2 canvas.drawRoundRect(rect, radius, radius, backgroundPaint) paint.color = textColor @@ -69,24 +67,24 @@ class MentionSpan( canvas.drawText(mentionText, 0, mentionText.length, x + startPadding, y.toFloat(), paint) } - private fun getActualText(text: CharSequence?, start: Int, end: Int): CharSequence { + private fun getActualText(text: String): CharSequence { if (actualText != null) return actualText!! return buildString { val mentionText = text.orEmpty() when (type) { Type.USER -> { - if (start in mentionText.indices && mentionText[start] != '@') { + if (text.firstOrNull() != '@') { append("@") } } Type.ROOM -> { - if (start in mentionText.indices && mentionText[start] != '#') { + if (text.firstOrNull() != '#') { append("#") } } } - append(mentionText.substring(start, min(end, start + MAX_LENGTH))) - if (end - start > MAX_LENGTH) { + append(mentionText.substring(0, min(mentionText.length, MAX_LENGTH))) + if (mentionText.length > MAX_LENGTH) { append("…") } actualText = this diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt index 7d8bfd34ce..e5c9f4793c 100644 --- a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/MentionSpanProvider.kt @@ -84,6 +84,8 @@ class MentionSpanProvider( permalinkData is PermalinkData.UserLink -> { val isCurrentUser = permalinkData.userId == currentSessionId MentionSpan( + text = text, + rawValue = permalinkData.userId.toString(), type = MentionSpan.Type.USER, backgroundColor = if (isCurrentUser) currentUserBackgroundColor else otherBackgroundColor, textColor = if (isCurrentUser) currentUserTextColor else otherTextColor, @@ -94,6 +96,8 @@ class MentionSpanProvider( } text == "@room" && permalinkData is PermalinkData.FallbackLink -> { MentionSpan( + text = text, + rawValue = "@room", type = MentionSpan.Type.USER, backgroundColor = otherBackgroundColor, textColor = otherTextColor, @@ -102,8 +106,22 @@ class MentionSpanProvider( typeface = typeface.value, ) } + permalinkData is PermalinkData.RoomLink -> { + MentionSpan( + text = text, + rawValue = permalinkData.roomIdOrAlias.toString(), + type = MentionSpan.Type.ROOM, + backgroundColor = otherBackgroundColor, + textColor = otherTextColor, + startPadding = startPaddingPx, + endPadding = endPaddingPx, + typeface = typeface.value, + ) + } else -> { MentionSpan( + text = text, + rawValue = text, type = MentionSpan.Type.ROOM, backgroundColor = otherBackgroundColor, textColor = otherTextColor, @@ -146,7 +164,7 @@ internal fun MentionSpanPreview() { eventId = null, viaParameters = persistentListOf(), ) - else -> TODO() + else -> throw AssertionError("Unexpected value $uriString") } } }, @@ -155,8 +173,8 @@ internal fun MentionSpanPreview() { provider.setup() val textColor = ElementTheme.colors.textPrimary.toArgb() - fun mentionSpanMe() = provider.getMentionSpanFor("me", "https://matrix.to/#/@me:matrix.org") - fun mentionSpanOther() = provider.getMentionSpanFor("other", "https://matrix.to/#/@other:matrix.org") + fun mentionSpanMe() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@me:matrix.org") + fun mentionSpanOther() = provider.getMentionSpanFor("mention", "https://matrix.to/#/@other:matrix.org") fun mentionSpanRoom() = provider.getMentionSpanFor("room", "https://matrix.to/#/#room:matrix.org") AndroidView(factory = { context -> TextView(context).apply { diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt similarity index 71% rename from features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt rename to libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt index b2977bd508..03bc48f53d 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/mentions/MentionSuggestion.kt +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/mentions/ResolvedMentionSuggestion.kt @@ -1,5 +1,5 @@ /* - * Copyright (c) 2023 New Vector Ltd + * Copyright (c) 2024 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. @@ -14,13 +14,13 @@ * limitations under the License. */ -package io.element.android.features.messages.impl.mentions +package io.element.android.libraries.textcomposer.mentions import androidx.compose.runtime.Immutable import io.element.android.libraries.matrix.api.room.RoomMember @Immutable -sealed interface MentionSuggestion { - data object Room : MentionSuggestion - data class Member(val roomMember: RoomMember) : MentionSuggestion +sealed interface ResolvedMentionSuggestion { + data object AtRoom : ResolvedMentionSuggestion + data class Member(val roomMember: RoomMember) : ResolvedMentionSuggestion } diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt new file mode 100644 index 0000000000..273aefa57b --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/MarkdownTextEditorState.kt @@ -0,0 +1,163 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.model + +import android.os.Parcelable +import android.text.Spannable +import android.text.SpannableString +import android.text.SpannableStringBuilder +import android.text.Spanned +import androidx.compose.runtime.Composable +import androidx.compose.runtime.Stable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.saveable.Saver +import androidx.compose.runtime.saveable.SaverScope +import androidx.compose.runtime.saveable.rememberSaveable +import androidx.compose.runtime.setValue +import androidx.core.text.getSpans +import io.element.android.libraries.matrix.api.core.UserId +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.textcomposer.components.markdown.StableCharSequence +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import kotlinx.parcelize.Parcelize + +@Stable +class MarkdownTextEditorState( + initialText: String?, + initialFocus: Boolean, +) { + var text by mutableStateOf(StableCharSequence(initialText ?: "")) + var selection by mutableStateOf(0..0) + var hasFocus by mutableStateOf(initialFocus) + var requestFocusAction by mutableStateOf({}) + var lineCount by mutableIntStateOf(1) + var currentMentionSuggestion by mutableStateOf(null) + + fun insertMention( + mention: ResolvedMentionSuggestion, + mentionSpanProvider: MentionSpanProvider, + permalinkBuilder: PermalinkBuilder, + ) { + val suggestion = currentMentionSuggestion ?: return + when (mention) { + is ResolvedMentionSuggestion.AtRoom -> { + val currentText = SpannableStringBuilder(text.value()) + val replaceText = "@room" + val roomPill = mentionSpanProvider.getMentionSpanFor(replaceText, "") + currentText.replace(suggestion.start, suggestion.end, ". ") + val end = suggestion.start + 1 + currentText.setSpan(roomPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + text.update(currentText, true) + selection = IntRange(end + 1, end + 1) + } + is ResolvedMentionSuggestion.Member -> { + val currentText = SpannableStringBuilder(text.value()) + val text = mention.roomMember.displayName?.prependIndent("@") ?: mention.roomMember.userId.value + val link = permalinkBuilder.permalinkForUser(mention.roomMember.userId).getOrNull() ?: return + val mentionPill = mentionSpanProvider.getMentionSpanFor(text, link) + currentText.replace(suggestion.start, suggestion.end, ". ") + val end = suggestion.start + 1 + currentText.setSpan(mentionPill, suggestion.start, end, Spannable.SPAN_EXCLUSIVE_EXCLUSIVE) + this.text.update(currentText, true) + this.selection = IntRange(end + 1, end + 1) + } + } + } + + fun getMessageMarkdown(permalinkBuilder: PermalinkBuilder): String { + val charSequence = text.value() + return if (charSequence is Spanned) { + val mentions = charSequence.getSpans(0, charSequence.length, MentionSpan::class.java) + buildString { + append(charSequence.toString()) + if (mentions != null && mentions.isNotEmpty()) { + for (mention in mentions.reversed()) { + val start = charSequence.getSpanStart(mention) + val end = charSequence.getSpanEnd(mention) + if (mention.type == MentionSpan.Type.USER) { + if (mention.rawValue == "@room") { + replace(start, end, "@room") + } else { + val link = permalinkBuilder.permalinkForUser(UserId(mention.rawValue)).getOrNull() ?: continue + replace(start, end, "[${mention.text}]($link)") + } + } + } + } + } + } else { + charSequence.toString() + } + } + + fun getMentions(): List { + val text = SpannableString(text.value()) + val mentionSpans = text.getSpans(0, text.length) + return mentionSpans.mapNotNull { mentionSpan -> + when (mentionSpan.type) { + MentionSpan.Type.USER -> { + if (mentionSpan.rawValue == "@room") { + Mention.AtRoom + } else { + Mention.User(UserId(mentionSpan.rawValue)) + } + } + else -> null + } + } + } + + @Parcelize + data class SavedState( + val text: CharSequence, + val selectionStart: Int, + val selectionEnd: Int, + ) : Parcelable +} + +object MarkdownTextEditorStateSaver : Saver { + override fun restore(value: MarkdownTextEditorState.SavedState): MarkdownTextEditorState { + return MarkdownTextEditorState( + initialText = "", + initialFocus = false, + ).apply { + text.update(value.text, true) + selection = value.selectionStart..value.selectionEnd + } + } + + override fun SaverScope.save(value: MarkdownTextEditorState): MarkdownTextEditorState.SavedState { + return MarkdownTextEditorState.SavedState( + text = value.text.value(), + selectionStart = value.selection.first, + selectionEnd = value.selection.last, + ) + } +} + +@Composable +fun rememberMarkdownTextEditorState( + initialText: String? = null, + initialFocus: Boolean = false, +): MarkdownTextEditorState { + return rememberSaveable(saver = MarkdownTextEditorStateSaver) { MarkdownTextEditorState(initialText, initialFocus) } +} diff --git a/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt new file mode 100644 index 0000000000..ae7a15fb65 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/kotlin/io/element/android/libraries/textcomposer/model/TextEditorState.kt @@ -0,0 +1,69 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.model + +import androidx.compose.runtime.Immutable +import io.element.android.libraries.matrix.api.permalink.PermalinkBuilder +import io.element.android.wysiwyg.compose.RichTextEditorState + +@Immutable +sealed interface TextEditorState { + data class Markdown( + val state: MarkdownTextEditorState, + ) : TextEditorState + + data class Rich( + val richTextEditorState: RichTextEditorState + ) : TextEditorState + + fun messageHtml(): String? = when (this) { + is Markdown -> null + is Rich -> richTextEditorState.messageHtml + } + + fun messageMarkdown(permalinkBuilder: PermalinkBuilder): String = when (this) { + is Markdown -> state.getMessageMarkdown(permalinkBuilder) + is Rich -> richTextEditorState.messageMarkdown + } + + fun hasFocus(): Boolean = when (this) { + is Markdown -> state.hasFocus + is Rich -> richTextEditorState.hasFocus + } + + suspend fun reset() { + when (this) { + is Markdown -> { + state.selection = IntRange.EMPTY + state.text.update("", true) + } + is Rich -> richTextEditorState.setHtml("") + } + } + + suspend fun requestFocus() { + when (this) { + is Markdown -> state.requestFocusAction() + is Rich -> richTextEditorState.requestFocus() + } + } + + val lineCount: Int get() = when (this) { + is Markdown -> state.lineCount + is Rich -> richTextEditorState.lineCount + } +} diff --git a/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml b/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..f79c0eb518 --- /dev/null +++ b/libraries/textcomposer/impl/src/main/res/values-ka/translations.xml @@ -0,0 +1,25 @@ + + + "დაამატეთ დანართი" + "პუნქტების სიის ჩართვა" + "ფორმატირების პარამეტრები დახურვა" + "კოდის ბლოკის ჩართვა" + "შეტყობინება…" + "ბმულის შექმნა" + "ბმულის რედაქტირება" + "თამამი შრიფტის გამოყენება" + "კურსიული შრიფტის გამოყენება" + "გადახაზული ფორმატის გამოყენება" + "ხაზგასმული ფორმატის გამოყენება" + "სრული ეკრანის რეჟიმის ჩართვა" + "აბზაცი" + "კოდის შიდა ფორმატის გამოყენება" + "ბმულის დაყენება" + "დანომრილი სიის ჩართვა" + "გახსენით შედგენის ვარიანტები" + "ციტატის ჩართვა" + "ბმულის წაშლა" + "აბზაცის გარეშე" + "Ბმული" + "ჩასაწერად დააჭირეთ" + diff --git a/libraries/textcomposer/impl/src/main/res/values-pt/translations.xml b/libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/textcomposer/impl/src/main/res/values-pt/translations.xml rename to libraries/textcomposer/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt new file mode 100644 index 0000000000..3ced6bf400 --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/components/markdown/MarkdownTextInputTest.kt @@ -0,0 +1,202 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.impl.components.markdown + +import android.widget.EditText +import androidx.activity.ComponentActivity +import androidx.compose.ui.test.junit4.AndroidComposeTestRule +import androidx.compose.ui.test.junit4.createAndroidComposeRule +import androidx.core.text.getSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.testtags.TestTags +import io.element.android.libraries.textcomposer.ElementRichTextEditorStyle +import io.element.android.libraries.textcomposer.components.markdown.MarkdownTextInput +import io.element.android.libraries.textcomposer.components.markdown.aMarkdownTextEditorState +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import io.element.android.tests.testutils.EnsureCalledOnceWithParam +import io.element.android.tests.testutils.EventsRecorder +import kotlinx.coroutines.test.runTest +import org.junit.Rule +import org.junit.Test +import org.junit.rules.TestRule +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextInputTest { + @get:Rule val rule = createAndroidComposeRule() + + @Test + fun `when user types onTyping is triggered with value 'true'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onTyping = EnsureCalledOnceWithParam(expectedParam = true, result = Unit) + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onTyping.assertSuccess() + } + + @Test + fun `when user removes text onTyping is triggered with value 'false'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onTyping = EventsRecorder() + rule.setMarkdownTextInput(state = state, onTyping = onTyping) + rule.activityRule.scenario.onActivity { + val editText = it.findEditor() + editText.setText("Test") + editText.setText("") + editText.setText(null) + } + rule.awaitIdle() + onTyping.assertList(listOf(true, false, false)) + } + + @Test + fun `when user types something that's not a mention onSuggestionReceived is triggered with 'null'`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("Test") + } + rule.awaitIdle() + onSuggestionReceived.assertSingle(null) + } + + @Test + fun `when user types something that's a mention onSuggestionReceived is triggered a real value`() = runTest { + val state = aMarkdownTextEditorState(initialFocus = true) + val onSuggestionReceived = EventsRecorder() + rule.setMarkdownTextInput(state = state, onSuggestionReceived = onSuggestionReceived) + rule.activityRule.scenario.onActivity { + it.findEditor().setText("@") + it.findEditor().setText("#") + it.findEditor().setText("/") + } + rule.awaitIdle() + onSuggestionReceived.assertList( + listOf( + // User mention suggestion + Suggestion(0, 1, SuggestionType.Mention, ""), + // Room suggestion + Suggestion(0, 1, SuggestionType.Room, ""), + // Slash command suggestion + Suggestion(0, 1, SuggestionType.Command, ""), + ) + ) + } + + @Test + fun `when the selection changes in the UI the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.setSelection(2) + } + rule.awaitIdle() + // Selection is updated + assertThat(state.selection).isEqualTo(2..2) + } + + @Test + fun `when the selection state changes in the view is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = true) + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.selection = 2..2 + } + rule.awaitIdle() + // Selection state is updated + assertThat(editor?.selectionStart).isEqualTo(2) + assertThat(editor?.selectionEnd).isEqualTo(2) + } + + @Test + fun `when the view focus changes the state is updated`() = runTest { + val state = aMarkdownTextEditorState(initialText = "Test", initialFocus = false) + rule.setMarkdownTextInput(state = state) + rule.activityRule.scenario.onActivity { + val editor = it.findEditor() + editor.requestFocus() + } + // Focus state is updated + assertThat(state.hasFocus).isTrue() + } + + @Test + fun `inserting a mention replaces the existing text with a span`() = runTest { + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(A_SESSION_ID) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$A_SESSION_ID") }) + val state = aMarkdownTextEditorState(initialText = "@", initialFocus = true) + state.currentMentionSuggestion = Suggestion(0, 1, SuggestionType.Mention, "") + rule.setMarkdownTextInput(state = state) + var editor: EditText? = null + rule.activityRule.scenario.onActivity { + editor = it.findEditor() + state.insertMention( + ResolvedMentionSuggestion.Member(roomMember = aRoomMember()), + MentionSpanProvider(currentSessionId = A_SESSION_ID, permalinkParser = permalinkParser), + permalinkBuilder, + ) + } + rule.awaitIdle() + + // Text is replaced with a placeholder + assertThat(editor?.editableText.toString()).isEqualTo(". ") + // The placeholder contains a MentionSpan + val mentionSpans = editor?.editableText?.getSpans(0, 2).orEmpty() + assertThat(mentionSpans).isNotEmpty() + } + + private fun AndroidComposeTestRule.setMarkdownTextInput( + state: MarkdownTextEditorState = aMarkdownTextEditorState(), + subcomposing: Boolean = false, + onTyping: (Boolean) -> Unit = {}, + onSuggestionReceived: (Suggestion?) -> Unit = {}, + ) { + rule.setContent { + val style = ElementRichTextEditorStyle.composerStyle(hasFocus = state.hasFocus) + MarkdownTextInput( + state = state, + subcomposing = subcomposing, + onTyping = onTyping, + onSuggestionReceived = onSuggestionReceived, + richTextEditorStyle = style, + onRichContentSelected = null, + ) + } + } + + private fun ComponentActivity.findEditor(): EditText { + return window.decorView.findViewWithTag(TestTags.plainTextEditor.value) + } +} diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt index 2b346ceeab..7245da04c6 100644 --- a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/mentions/MentionSpanProviderTest.kt @@ -17,6 +17,7 @@ package io.element.android.libraries.textcomposer.impl.mentions import android.graphics.Color +import android.net.Uri import com.google.common.truth.Truth.assertThat import io.element.android.libraries.matrix.api.core.RoomAlias import io.element.android.libraries.matrix.api.core.UserId @@ -66,6 +67,14 @@ class MentionSpanProviderTest { assertThat(mentionSpan.textColor).isEqualTo(otherColor) } + @Test + fun `getting mention span for everyone in the room`() { + permalinkParser.givenResult(PermalinkData.FallbackLink(uri = Uri.EMPTY)) + val mentionSpan = mentionSpanProvider.getMentionSpanFor("@room", "#") + assertThat(mentionSpan.backgroundColor).isEqualTo(otherColor) + assertThat(mentionSpan.textColor).isEqualTo(otherColor) + } + @Test fun `getting mention span for a room should return a MentionSpan with normal colors`() { permalinkParser.givenResult( diff --git a/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt new file mode 100644 index 0000000000..bd2b6785ed --- /dev/null +++ b/libraries/textcomposer/impl/src/test/kotlin/io/element/android/libraries/textcomposer/impl/model/MarkdownTextEditorStateTest.kt @@ -0,0 +1,171 @@ +/* + * Copyright (c) 2024 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.libraries.textcomposer.impl.model + +import android.net.Uri +import androidx.core.text.buildSpannedString +import androidx.core.text.inSpans +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.google.common.truth.Truth.assertThat +import io.element.android.libraries.matrix.api.core.SessionId +import io.element.android.libraries.matrix.api.permalink.PermalinkData +import io.element.android.libraries.matrix.api.room.Mention +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.permalink.FakePermalinkBuilder +import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser +import io.element.android.libraries.matrix.test.room.aRoomMember +import io.element.android.libraries.textcomposer.mentions.MentionSpan +import io.element.android.libraries.textcomposer.mentions.MentionSpanProvider +import io.element.android.libraries.textcomposer.mentions.ResolvedMentionSuggestion +import io.element.android.libraries.textcomposer.model.MarkdownTextEditorState +import io.element.android.libraries.textcomposer.model.Suggestion +import io.element.android.libraries.textcomposer.model.SuggestionType +import org.junit.Test +import org.junit.runner.RunWith + +@RunWith(AndroidJUnit4::class) +class MarkdownTextEditorStateTest { + @Test + fun `insertMention - with no currentMentionSuggestion does nothing`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + val member = aRoomMember() + val mention = ResolvedMentionSuggestion.Member(member) + val permalinkBuilder = FakePermalinkBuilder() + val mentionSpanProvider = aMentionSpanProvider() + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `insertMention - with member but failed PermalinkBuilder result`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val member = aRoomMember() + val mention = ResolvedMentionSuggestion.Member(member) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.failure(IllegalStateException("Failed")) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + val mentions = state.getMentions() + assertThat(mentions).isEmpty() + } + + @Test + fun `insertMention - with member`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val member = aRoomMember() + val mention = ResolvedMentionSuggestion.Member(member) + val permalinkParser = FakePermalinkParser(result = { PermalinkData.UserLink(member.userId) }) + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/${member.userId}") }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + val mentions = state.getMentions() + assertThat(mentions).isNotEmpty() + assertThat((mentions.firstOrNull() as? Mention.User)?.userId).isEqualTo(member.userId) + } + + @Test + fun `insertMention - with @room`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true).apply { + currentMentionSuggestion = Suggestion(start = 6, end = 7, type = SuggestionType.Mention, text = "") + } + val mention = ResolvedMentionSuggestion.AtRoom + val permalinkBuilder = FakePermalinkBuilder() + val permalinkParser = FakePermalinkParser(result = { PermalinkData.FallbackLink(Uri.EMPTY, false) }) + val mentionSpanProvider = aMentionSpanProvider(permalinkParser = permalinkParser) + + state.insertMention(mention, mentionSpanProvider, permalinkBuilder) + + val mentions = state.getMentions() + assertThat(mentions).isNotEmpty() + assertThat(mentions.firstOrNull()).isInstanceOf(Mention.AtRoom::class.java) + } + + @Test + fun `getMessageMarkdown - when there are no MentionSpans returns the same text`() { + val text = "No mentions here" + val state = MarkdownTextEditorState(initialText = text, initialFocus = true) + + val markdown = state.getMessageMarkdown(FakePermalinkBuilder()) + + assertThat(markdown).isEqualTo(text) + } + + @Test + fun `getMessageMarkdown - when there are MentionSpans returns the same text with links to the mentions`() { + val text = "No mentions here" + val permalinkBuilder = FakePermalinkBuilder(result = { Result.success("https://matrix.to/#/$it") }) + val state = MarkdownTextEditorState(initialText = text, initialFocus = true) + state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) + + val markdown = state.getMessageMarkdown(permalinkBuilder = permalinkBuilder) + + assertThat(markdown).isEqualTo( + "Hello [@Alice](https://matrix.to/#/@alice:matrix.org) and everyone in @room" + ) + } + + @Test + fun `getMentions - when there are no MentionSpans returns empty list of mentions`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + + assertThat(state.getMentions()).isEmpty() + } + + @Test + fun `getMentions - when there are MentionSpans returns a list of mentions`() { + val state = MarkdownTextEditorState(initialText = "Hello @", initialFocus = true) + state.text.update(aMarkdownTextWithMentions(), needsDisplaying = false) + + val mentions = state.getMentions() + + assertThat(mentions).isNotEmpty() + assertThat((mentions.firstOrNull() as? Mention.User)?.userId?.value).isEqualTo("@alice:matrix.org") + assertThat(mentions.lastOrNull()).isInstanceOf(Mention.AtRoom::class.java) + } + + private fun aMentionSpanProvider( + currentSessionId: SessionId = A_SESSION_ID, + permalinkParser: FakePermalinkParser = FakePermalinkParser(), + ): MentionSpanProvider { + return MentionSpanProvider(currentSessionId, permalinkParser) + } + + private fun aMarkdownTextWithMentions(): CharSequence { + val userMentionSpan = MentionSpan("@Alice", "@alice:matrix.org", MentionSpan.Type.USER, 0, 0, 0, 0) + val atRoomMentionSpan = MentionSpan("@room", "@room", MentionSpan.Type.USER, 0, 0, 0, 0) + return buildSpannedString { + append("Hello ") + inSpans(userMentionSpan) { + append("@") + } + append(" and everyone in ") + inSpans(atRoomMentionSpan) { + append("@") + } + } + } +} diff --git a/libraries/troubleshoot/impl/src/main/res/values-pt/translations.xml b/libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml similarity index 100% rename from libraries/troubleshoot/impl/src/main/res/values-pt/translations.xml rename to libraries/troubleshoot/impl/src/main/res/values-pt-rBR/translations.xml diff --git a/libraries/ui-strings/src/main/res/values-be/translations.xml b/libraries/ui-strings/src/main/res/values-be/translations.xml index 2e73190f21..d4de8db182 100644 --- a/libraries/ui-strings/src/main/res/values-be/translations.xml +++ b/libraries/ui-strings/src/main/res/values-be/translations.xml @@ -121,6 +121,7 @@ "Заблакіраваныя карыстальнікі" "Бурбалкі" "Ідзе выклік (не падтрымліваецца)" + "Выклік пачаўся" "Рэзервовае капіраванне чатаў" "Аўтарскае права" "Стварэнне пакоя…" @@ -143,7 +144,7 @@ "Файл захаваны ў папку Спампоўкі" "Перасылка паведамлення" "GIF" - "Выява" + "Відарыс" "У адказ на %1$s" "Усталяваць APK" "Гэты Matrix ID не знойдзены, таму запрашэнне можа быць не атрымана." @@ -153,7 +154,7 @@ "Загрузка…" "%1$d удзельнік" - "%1$d удзельніка" + "%1$d удзельнікі" "%1$d удзельнікаў" "Паведамленне" diff --git a/libraries/ui-strings/src/main/res/values-cs/translations.xml b/libraries/ui-strings/src/main/res/values-cs/translations.xml index 52b673e535..2a344bff38 100644 --- a/libraries/ui-strings/src/main/res/values-cs/translations.xml +++ b/libraries/ui-strings/src/main/res/values-cs/translations.xml @@ -121,6 +121,7 @@ "Blokovaní uživatelé" "Bubliny" "Probíhá hovor (nepodporováno)" + "Hovor zahájen" "Záloha chatu" "Autorská práva" "Vytváření místnosti…" diff --git a/libraries/ui-strings/src/main/res/values-fr/translations.xml b/libraries/ui-strings/src/main/res/values-fr/translations.xml index 484d4835ae..d4c1b3126c 100644 --- a/libraries/ui-strings/src/main/res/values-fr/translations.xml +++ b/libraries/ui-strings/src/main/res/values-fr/translations.xml @@ -34,8 +34,9 @@ "Accepter" "Ajouter à la discussion" "Retour" + "Appel" "Annuler" - "Choisissez une photo" + "Choisir une photo" "Effacer" "Fermer" "Terminer la vérification" @@ -72,6 +73,7 @@ "Voir plus" "Gérer le compte" "Gérez les sessions" + "Message" "Suivant" "Non" "Pas maintenant" @@ -117,6 +119,7 @@ "Utilisateurs bloqués" "Bulles" "Appel en cours (non supporté)" + "Appel démarré" "Sauvegarde des discussions" "Droits d’auteur" "Création du salon…" diff --git a/libraries/ui-strings/src/main/res/values-ka/translations.xml b/libraries/ui-strings/src/main/res/values-ka/translations.xml new file mode 100644 index 0000000000..fd0bc2c832 --- /dev/null +++ b/libraries/ui-strings/src/main/res/values-ka/translations.xml @@ -0,0 +1,221 @@ + + + "წაშლა" + + "%1$d ციფრი ჩაიწერა" + "%1$d ციფრი ჩაიწერა" + + "პაროლის დამალვა" + "მხოლოდ მოხსენიებები" + "დადუმებულია" + "პაუზა" + "PIN ველი" + "დაკვრა" + "გამოკითხვა" + "დასრულდა გამოკითხვა" + "ფაილების გაგზავნა" + "პაროლის ჩვენება" + "მომხმარებლის მენიუ" + "ხმოვანი შეტყობინების ჩაწერა." + "მიღება" + "დამატება ქრონოლოგიაში" + "უკან" + "გაუქმება" + "აირჩიეთ ფოტო" + "გასუფთავება" + "დახურვა" + "დადასტურების დასრულება" + "დადასტურება" + "გაგრძელება" + "კოპირება" + "ბმულის კოპირება" + "დააკოპირეთ შეტყობინების ბმული" + "შექმნა" + "ოთახის შექმნა" + "უარყოფა" + "გამორთვა" + "მზადაა" + "რედაქტირება" + "გამოკითხვის რედაქტირება" + "გამოკითხვის დასრულება" + "დაგავიწყდათ პაროლი?" + "გადაგზავნა" + "მოწვევა" + "ხალხის მოწვევა" + "ადამიანების დამატება %1$s" + "%1$s-ში ხალხის მოწვევა" + "მოწვევები" + "გაწევრიანება" + "შეიტყვეთ მეტი" + "დატოვება" + "ოთახის დატოვება" + "ანგარიშის მართვა" + "მოწყობილობების მართვა" + "შემდეგი" + "არა" + "ახლა არა" + "OK" + "პარამეტრები" + "გახსნა პროგრამით:" + "Სწრაფი პასუხი" + "ციტირება" + "რეაგირება" + "წაშლა" + "პასუხი" + "პასუხი თემაში" + "ხარვეზის შეტყობინება" + "კონტენტის რეპორტი" + "ხელახლა ცდა" + "გაშიფვრის ხელახლა ცდა" + "შენახვა" + "ძიება" + "გაგზავნა" + "შეტყობინების გაგზავნა" + "გაზიარება" + "ბმულის გაზიარება" + "ხელახლა შედით" + "გამოსვლა" + "მაინც გასვლა" + "გამოტოვება" + "დაწყება" + "ჩატის დაწყება" + "დადასტურების დაწყება" + "დააწკაპუნეთ რუკის ჩასატვირთად" + "ფოტოს გადაღება" + "წყაროს ნახვა" + "დიახ" + "შესახებ" + "მისაღები გამოყენების პოლიტიკა" + "გაფართოებული პარამეტრები" + "ანალიტიკა" + "აუდიო" + "ბუშტები" + "ჩატის სარეზერვო ასლი" + "საავტორო უფლება" + "ოთახის შექმნა…" + "დატოვა ოთახი" + "გაშიფვრის შეცდომა" + "დეველოპერის პარამეტრები" + "(რედაქტირებულია)" + "რედაქტირება" + "* %1$s %2$s" + "დაშიფვრა ჩართულია" + "შეიყვანეთ თქვენი PIN" + "შეცდომა" + "ყველა" + "ფაილი" + "ფაილი შენახულია ჩამოტვირთვებში" + "შეტყობინების გადაგზავნა" + "GIF" + "სურათი" + "%1$s-ს პასუხად" + "დააინსტალირეთ APK" + "ეს Matrix ID ვერ მოიძებნა, ამიტომ მოწვევა შეიძლება არ იყოს მიღებული." + "ოთახის დატოვება" + "ბმული კოპირებულია გაცვლის ბუფერში" + "იტვირთება…" + + "%1$d წევრი" + "%1$d წევრები" + + "შეტყობინება" + "შეტყობინებების ფორმა" + "მესიჯი წაშლილია" + "თანამედროვე" + "დადუმება" + "შედეგი არ არის" + "ხაზგარეშე" + "პაროლი" + "ხალხი" + "მუდმივი ბმული" + "ნებართვა" + "დარწმუნებული ხართ, რომ გსურთ ამ გამოკითხვის დასრულება?" + "გამოკითხვა: %1$s" + "სულ ხმები: %1$s" + "შედეგები გამოკითხვის დასრულების შემდეგ გამოჩნდება" + + "%d ხმა" + "%d ხმა" + + "კონფიდენციალურობის პოლიტიკა" + "რეაქცია" + "რეაქციები" + + "აღდგენის გასაღები" + + "განახლება…" + "პასუხი %1$s-ს" + "ხარვეზის შეტყობინება" + "რეპორტი გაგზავნილია" + "მდიდარი ტექსტის რედაქტორი" + "ოთახის სახელი" + "მაგ. თქვენი პროექტის სახელი" + "ეკრანის დაბლოკვა" + "ვიღაცის ძებნა" + "ძიების შედეგები" + "უსაფრთხოება" + "იგზავნება…" + "სერვერი არ არის მხარდაჭერილი" + "სერვერის ვებ-მისამართი" + "პარამეტრები" + "გაზიარებული მდებარეობა" + "ჩატის დაწყება…" + "სტიკერი" + "წარმატება" + "შეთავაზებები" + "სინქრონიზაცია" + "ტექსტი" + "მესამე პირის შენიშვნები" + "თემა" + "თემა" + "რა თემებს ეხება ეს ოთახი?" + "გაშიფვრა ვერ მოხერხდა" + "მოსაწვევები ვერ გაეგზავნა ერთ ან მეტ მომხმარებელს." + "მოწვევის (ების) გაგზავნა შეუძლებელია" + "განბლოკვა" + "დადუმების გაუქმება" + "მხარდაუჭერელი მოვლენა" + "მომხმარებლის სახელი" + "დადასტურება გაუქმდა" + "დადასტურება დასრულებულია" + "ვიდეო" + "ხმოვანი შეტყობინება" + "მოცდა…" + "დადასტურება" + "შეცდომა" + "წარმატება" + "გაფრთხილება" + "მუდმივი ბმულის შექმნა ვერ მოხერხდა" + "ვერ გამოვიდა რუკის %1$s ჩატვირთვა. გთხოვთ, მოგვიანებით სცადოთ." + "შეტყობინებების ჩატვირთვა ვერ მოხერხდა" + "%1$s ვერ მოახერხა თქვენი ადგილმდებარეობაზე წვდომა. გთხოვთ, მოგვიანებით სცადოთ." + "%1$s არ აქვს თქვენს ადგილმდებარეობაზე წვდომის ნებართვა. შეგიძლიათ ჩართოთ წვდომა პარამეტრებში." + "%1$s არ აქვს თქვენს ადგილმდებარეობაზე წვდომის ნებართვა. ჩართეთ წვდომა ქვემოთ" + "%1$s არ აქვს თქვენს მიკროფონზე წვდომის ნებართვა. ჩართეთ წვდომა ხმოვანი შეტყობინების ჩასაწერად." + "ზოგიერთი შეტყობინება არ გაიგზავნა" + "ბოდიშით, შეცდომა მოხდა" + "🔐️ შემომიერთდით %1$s" + "გაგიმარჯოს! მესაუბრე %1$s-ზე: %2$s" + "%1$s Android" + "შეცდომის შესატყობინებლად ტელეფონის შენჯღრევა" + "მედიის შერჩევა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა, გთხოვთ, სცადოთ ხელახლა." + "მედიის ატვირთვა ვერ მოხერხდა. გთხოვთ, სცადოთ ხელახლა." + "მომხმარებლის მონაცემების მოძიება ვერ მოხერხდა" + "დაბლოკვა" + "დაბლოკილი მომხმარებლები ვერ შეძლებენ თქვენთვის შეტყობინების გაგზავნას და ყველა მათი შეტყობინება თქვენთვის დამალული იქნება. თქვენ მათი განბლოკვა ნებისმეირ დროს შეგიძლიათ." + "მომხმარებლის დაბლოკვა" + "განბლოკვა" + "თქვენ კვლავ შეძლებთ მათგან ყველა შეტყობინების ნახვას." + "Მომხმარებლის განბლოკვა" + "მდებარეობის გაზიარება" + "ჩემი მდებარეობის გაზიარება" + "Apple Maps-ში გახსნა" + "Google Maps-ში გახსნა" + "OpenStreetMap-ში გახსნა" + "ამ ადგილის გაზიარება" + "ადგილმდებარეობა" + "ვერსია: %1$s (%2$s)" + "ka" + diff --git a/libraries/ui-strings/src/main/res/values-pt/translations.xml b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml similarity index 99% rename from libraries/ui-strings/src/main/res/values-pt/translations.xml rename to libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml index 072602a5da..f1ee60abe5 100644 --- a/libraries/ui-strings/src/main/res/values-pt/translations.xml +++ b/libraries/ui-strings/src/main/res/values-pt-rBR/translations.xml @@ -119,6 +119,7 @@ "Utilizadores bloqueados" "Bolhas" "Chamada em curso (não suportada)" + "Chamada iniciada" "Cópia de segurança das conversas" "Direitos de autor" "A criar sala…" diff --git a/libraries/ui-strings/src/main/res/values-ro/translations.xml b/libraries/ui-strings/src/main/res/values-ro/translations.xml index 2d12334d0e..a4c0eb8bfe 100644 --- a/libraries/ui-strings/src/main/res/values-ro/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ro/translations.xml @@ -75,6 +75,7 @@ "Încărcați mai mult" "Administrare cont" "Gestionare dispozitive" + "Mesaj" "Următorul" "Nu" "Nu acum" diff --git a/libraries/ui-strings/src/main/res/values-ru/translations.xml b/libraries/ui-strings/src/main/res/values-ru/translations.xml index 1932b987ae..5544635732 100644 --- a/libraries/ui-strings/src/main/res/values-ru/translations.xml +++ b/libraries/ui-strings/src/main/res/values-ru/translations.xml @@ -75,6 +75,7 @@ "Загрузить еще" "Настройки аккаунта" "Управление устройствами" + "Сообщение" "Далее" "Нет" "Не сейчас" @@ -120,6 +121,7 @@ "Заблокированные пользователи" "Пузыри" "Выполняется звонок (не поддерживается)" + "Звонок начат" "Резервная копия чатов" "Авторское право" "Создание комнаты…" diff --git a/libraries/ui-strings/src/main/res/values-sk/translations.xml b/libraries/ui-strings/src/main/res/values-sk/translations.xml index cfdd024d42..d9afe4ba65 100644 --- a/libraries/ui-strings/src/main/res/values-sk/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sk/translations.xml @@ -121,6 +121,7 @@ "Blokovaní používatelia" "Bubliny" "Prebieha hovor (nepodporované)" + "Hovor sa začal" "Záloha konverzácie" "Autorské práva" "Vytváranie miestnosti…" @@ -138,6 +139,7 @@ "Všetci" "Zlyhalo" "Obľúbené" + "Obľúbené" "Súbor" "Súbor bol uložený do priečinka Stiahnuté súbory" "Preposlať správu" diff --git a/libraries/ui-strings/src/main/res/values-sv/translations.xml b/libraries/ui-strings/src/main/res/values-sv/translations.xml index c311a0fc2e..d4003fce5e 100644 --- a/libraries/ui-strings/src/main/res/values-sv/translations.xml +++ b/libraries/ui-strings/src/main/res/values-sv/translations.xml @@ -111,6 +111,7 @@ "Analysdata" "Utseende" "Ljud" + "Blockerade användare" "Bubblor" "Chattsäkerhetskopia" "Upphovsrätt" @@ -127,7 +128,9 @@ "Ange din PIN-kod" "Fel" "Alla" + "Misslyckades" "Favorit" + "Favoriter" "Fil" "Fil sparad i Download" "Vidarebefordra meddelande" @@ -152,6 +155,7 @@ "Tysta" "Inga resultat" "Frånkopplad" + "eller" "Lösenord" "Personer" "Permalänk" diff --git a/libraries/ui-strings/src/main/res/values/localazy.xml b/libraries/ui-strings/src/main/res/values/localazy.xml index e667873318..8d46b6e5b1 100644 --- a/libraries/ui-strings/src/main/res/values/localazy.xml +++ b/libraries/ui-strings/src/main/res/values/localazy.xml @@ -119,6 +119,7 @@ "Blocked users" "Bubbles" "Call in progress (unsupported)" + "Call started" "Chat backup" "Copyright" "Creating room…" diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt index 9bb1ace7b1..022661bc11 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/impl/VoiceRecorderImplTest.kt @@ -28,7 +28,7 @@ import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig import io.element.android.libraries.voicerecorder.impl.audio.SampleRate import io.element.android.libraries.voicerecorder.impl.di.VoiceRecorderModule import io.element.android.libraries.voicerecorder.test.FakeAudioLevelCalculator -import io.element.android.libraries.voicerecorder.test.FakeAudioRecorderFactory +import io.element.android.libraries.voicerecorder.test.FakeAudioReaderFactory import io.element.android.libraries.voicerecorder.test.FakeEncoder import io.element.android.libraries.voicerecorder.test.FakeFileSystem import io.element.android.libraries.voicerecorder.test.FakeVoiceFileManager @@ -136,7 +136,7 @@ class VoiceRecorderImplTest { return VoiceRecorderImpl( dispatchers = testCoroutineDispatchers(), timeSource = timeSource, - audioReaderFactory = FakeAudioRecorderFactory( + audioReaderFactory = FakeAudioReaderFactory( audio = AUDIO, ), encoder = FakeEncoder(fakeFileSystem), diff --git a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt similarity index 97% rename from libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt rename to libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt index 02d8b4742c..657b8d6ee9 100644 --- a/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioRecorderFactory.kt +++ b/libraries/voicerecorder/impl/src/test/kotlin/io/element/android/libraries/voicerecorder/test/FakeAudioReaderFactory.kt @@ -21,7 +21,7 @@ import io.element.android.libraries.voicerecorder.impl.audio.Audio import io.element.android.libraries.voicerecorder.impl.audio.AudioConfig import io.element.android.libraries.voicerecorder.impl.audio.AudioReader -class FakeAudioRecorderFactory( +class FakeAudioReaderFactory( private val audio: List