Merge branch 'release/0.4.12'

This commit is contained in:
Jorge Martín 2024-05-13 17:35:37 +02:00
commit 64b6dd1fca
2159 changed files with 15530 additions and 7504 deletions

View file

@ -9,8 +9,8 @@ on:
# Enrich gradle.properties for CI/CD # Enrich gradle.properties for CI/CD
env: env:
GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx8g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC GRADLE_OPTS: -Dorg.gradle.jvmargs="-Xmx7g -Dfile.encoding=UTF-8 -XX:+HeapDumpOnOutOfMemoryError" -Dkotlin.incremental=false -XX:+UseParallelGC
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8
jobs: jobs:
debug: debug:
@ -41,21 +41,28 @@ jobs:
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v3
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK - name: Assemble debug Gplay APK
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew :app:assembleGplayDebug :app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload APK APKs - name: Assemble debug Fdroid APK
if: ${{ matrix.variant == 'debug' }}
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 app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@v4 uses: actions/upload-artifact@v4
with: with:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/gplay/debug/*.apk app/build/outputs/apk/gplay/debug/*-universal-debug.apk
app/build/outputs/apk/fdroid/debug/*.apk app/build/outputs/apk/fdroid/debug/*-universal-debug.apk
- uses: rnkdsh/action-upload-diawi@v1.5.5 - uses: rnkdsh/action-upload-diawi@v1.5.5
id: diawi id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails

View file

@ -11,7 +11,7 @@ jobs:
- run: | - run: |
npm install --save-dev @babel/plugin-transform-flow-strip-types npm install --save-dev @babel/plugin-transform-flow-strip-types
- name: Danger - name: Danger
uses: danger/danger-js@11.3.1 uses: danger/danger-js@12.2.0
with: with:
args: "--dangerfile ./tools/danger/dangerfile.js" args: "--dangerfile ./tools/danger/dangerfile.js"
env: env:

View file

@ -22,10 +22,10 @@ jobs:
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v3
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.9 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.9 python-version: 3.12
- name: Run World screenshots generation script - name: Run World screenshots generation script
run: | run: |
./tools/test/generateWorldScreenshots.py ./tools/test/generateWorldScreenshots.py

View file

@ -12,12 +12,10 @@ env:
CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon CI_GRADLE_ARG_PROPERTIES: --stacktrace -PpreDexEnable=false --max-workers 8 --no-daemon
jobs: jobs:
maestro-cloud: build-apk:
name: Maestro test suite name: Build APK
runs-on: ubuntu-latest runs-on: ubuntu-latest
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Run-Maestro' if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Run-Maestro'
strategy:
fail-fast: false
# Allow one per PR. # Allow one per PR.
concurrency: concurrency:
group: ${{ format('maestro-{0}', github.ref) }} group: ${{ format('maestro-{0}', github.ref) }}
@ -41,19 +39,49 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Assemble debug APK - name: Assemble debug APK
run: ./gradlew :app:assembleDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug $CI_GRADLE_ARG_PROPERTIES
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact
uses: actions/upload-artifact@v4
with:
name: elementx-apk-maestro
path: |
app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk
retention-days: 5
overwrite: true
if-no-files-found: error
maestro-cloud:
name: Maestro test suite
runs-on: ubuntu-latest
needs: build-apk
if: github.event_name == 'workflow_dispatch' || github.event.label.name == 'Run-Maestro'
# Allow one per PR.
concurrency:
group: ${{ format('maestro-{0}', github.ref) }}
cancel-in-progress: true
steps:
- uses: actions/checkout@v4
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
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 APK artifact from previous job
uses: actions/download-artifact@v4
with:
name: elementx-apk-maestro
- uses: mobile-dev-inc/action-maestro-cloud@v1.8.1 - uses: mobile-dev-inc/action-maestro-cloud@v1.8.1
if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch' if: (github.event_name == 'pull_request' && github.event.pull_request.fork == null) || github.event_name == 'workflow_dispatch'
with: with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }} api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android): # Doc says (https://github.com/mobile-dev-inc/action-maestro-cloud#android):
# app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK). # app-file should point to an x86 compatible APK file, so upload the x86_64 one (much smaller than the universal APK).
app-file: app/build/outputs/apk/gplay/debug/app-gplay-x86_64-debug.apk app-file: app-gplay-x86_64-debug.apk
env: | env: |
MAESTRO_USERNAME=maestroelement MAESTRO_USERNAME=maestroelement
MAESTRO_PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }} MAESTRO_PASSWORD=${{ secrets.MATRIX_MAESTRO_ACCOUNT_PASSWORD }}

View file

@ -26,10 +26,10 @@ jobs:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.9 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.9 python-version: 3.12
- name: Search for invalid screenshot files - name: Search for invalid screenshot files
run: ./tools/test/checkInvalidScreenshots.py run: ./tools/test/checkInvalidScreenshots.py
@ -72,7 +72,7 @@ jobs:
yarn add danger-plugin-lint-report --dev yarn add danger-plugin-lint-report --dev
- name: Danger lint - name: Danger lint
if: always() if: always()
uses: danger/danger-js@11.3.1 uses: danger/danger-js@12.2.0
with: with:
args: "--dangerfile ./tools/danger/dangerfile-lint.js" args: "--dangerfile ./tools/danger/dangerfile-lint.js"
env: env:

View file

@ -21,10 +21,10 @@ jobs:
uses: gradle/actions/setup-gradle@v3 uses: gradle/actions/setup-gradle@v3
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Set up Python 3.9 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.9 python-version: 3.12
- name: Setup Localazy - name: Setup Localazy
run: | run: |
curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg curl -sS https://dist.localazy.com/debian/pubkey.gpg | sudo gpg --dearmor -o /etc/apt/trusted.gpg.d/localazy.gpg
@ -33,6 +33,7 @@ jobs:
- name: Run Localazy script - name: Run Localazy script
run: | run: |
./tools/localazy/downloadStrings.sh --all ./tools/localazy/downloadStrings.sh --all
./tools/localazy/importSupportedLocalesFromLocalazy.py
./tools/test/generateAllScreenshots.py ./tools/test/generateAllScreenshots.py
- name: Create Pull Request for Strings - name: Create Pull Request for Strings
uses: peter-evans/create-pull-request@v6 uses: peter-evans/create-pull-request@v6

View file

@ -13,10 +13,10 @@ jobs:
# No concurrency required, runs every time on a schedule. # No concurrency required, runs every time on a schedule.
steps: steps:
- uses: actions/checkout@v4 - uses: actions/checkout@v4
- name: Set up Python 3.9 - name: Set up Python 3.12
uses: actions/setup-python@v5 uses: actions/setup-python@v5
with: with:
python-version: 3.9 python-version: 3.12
- name: Install Prerequisite dependencies - name: Install Prerequisite dependencies
run: | run: |
pip install requests pip install requests

View file

@ -16,7 +16,7 @@ appId: ${MAESTRO_APP_ID}
- tapOn: "Create" - tapOn: "Create"
- takeScreenshot: build/maestro/320-createAndDeleteRoom - takeScreenshot: build/maestro/320-createAndDeleteRoom
- tapOn: "aRoomName" - tapOn: "aRoomName"
- tapOn: "Invite people" - tapOn: "Invite"
# assert there's 1 member and 1 invitee # assert there's 1 member and 1 invitee
- tapOn: "Search for someone" - tapOn: "Search for someone"
- inputText: ${MAESTRO_INVITEE2_MXID} - inputText: ${MAESTRO_INVITEE2_MXID}

View file

@ -1,3 +1,41 @@
Changes in Element X v0.4.12 (2024-05-13)
=========================================
Features ✨
----------
- Add support for expected decryption errors due to membership (UX and analytics). ([#2754](https://github.com/element-hq/element-x-android/issues/2754))
- Handle permalink navigation to Events. ([#2759](https://github.com/element-hq/element-x-android/issues/2759))
- Pretty-print event JSON in debug viewer ([#2771](https://github.com/element-hq/element-x-android/issues/2771))
- Add support for external permalinks. ([#2776](https://github.com/element-hq/element-x-android/issues/2776))
- Enable support for Android per-app language preferences ([#2795](https://github.com/element-hq/element-x-android/issues/2795))
Bugfixes 🐛
----------
- Fix session verification being asked again for already verified users. ([#2718](https://github.com/element-hq/element-x-android/issues/2718))
- Instead of displaying 'create new recovery key' on the session verification screen when there is no other session active, display it always under the 'enter recovery key' screen. ([#2740](https://github.com/element-hq/element-x-android/issues/2740))
- Adjust the typography used in the selected user component so a user's display name fits better. ([#2760](https://github.com/element-hq/element-x-android/issues/2760))
- User display name overflows in timeline messages when it's way too long. ([#2761](https://github.com/element-hq/element-x-android/issues/2761))
- Ensure the application open the room when a notification is clicked. ([#2778](https://github.com/element-hq/element-x-android/issues/2778))
- Enforce mandatory session verification only for new logins. ([#2810](https://github.com/element-hq/element-x-android/issues/2810))
- Make log less verbose, make sure we upload as many log files as possible before reaching the request size limit of the bug reporting service, discard older logs if they don't fit. ([#2825](https://github.com/element-hq/element-x-android/issues/2825))
- Remove 'Join' button in room directory search results. ([#2827](https://github.com/element-hq/element-x-android/issues/2827))
- Add missing `app_id` and `Version` properties to bug reports. ([#2829](https://github.com/element-hq/element-x-android/issues/2829))
Other changes
-------------
- RoomMember screen: fallback to userProfile data, if the member is not a user of the room. ([#2721](https://github.com/element-hq/element-x-android/issues/2721))
- Migrate application data. ([#2749](https://github.com/element-hq/element-x-android/issues/2749))
- Let the SDK manage the file log cleanup, and keep one week of log. ([#2758](https://github.com/element-hq/element-x-android/issues/2758))
- UX cleanup: reorder options in the main settings screen. ([#2801](https://github.com/element-hq/element-x-android/issues/2801))
- Analytics: Add support to report current session verification and recovery state ([#2806](https://github.com/element-hq/element-x-android/issues/2806))
- UX cleanup: room details screen, add new CTA buttons for Invite and Call actions. ([#2814](https://github.com/element-hq/element-x-android/issues/2814))
- UX cleanup: user profile. Move send DM to a call to action button, add 'Call' CTA too. ([#2818](https://github.com/element-hq/element-x-android/issues/2818))
- Add room badges to room details screen. ([#2822](https://github.com/element-hq/element-x-android/issues/2822))
Security
-------------
- Bump the Rust SDK to `v0.2.18` to remediate [CVE-2024-34353 / GHSA-9ggc-845v-gcgv](https://github.com/matrix-org/matrix-rust-sdk/security/advisories/GHSA-9ggc-845v-gcgv).
Changes in Element X v0.4.10 (2024-04-17) Changes in Element X v0.4.10 (2024-04-17)
========================================= =========================================
@ -20,6 +58,10 @@ Changes in Element X v0.4.9 (2024-04-12)
- Synchronize Localazy Strings. - Synchronize Localazy Strings.
Security
----------
- Fix crash while processing a room message containing a malformed pill.
Changes in Element X v0.4.8 (2024-04-10) Changes in Element X v0.4.8 (2024-04-10)
======================================== ========================================

View file

@ -23,6 +23,7 @@ import extension.allServicesImpl
import extension.gitBranchName import extension.gitBranchName
import extension.gitRevision import extension.gitRevision
import extension.koverDependencies import extension.koverDependencies
import extension.locales
import extension.setupKover import extension.setupKover
plugins { plugins {
@ -74,6 +75,10 @@ android {
isUniversalApk = true isUniversalApk = true
} }
} }
defaultConfig {
resourceConfigurations += locales
}
} }
signingConfigs { signingConfigs {
@ -219,6 +224,7 @@ dependencies {
allServicesImpl() allServicesImpl()
allFeaturesImpl(rootDir, logger) allFeaturesImpl(rootDir, logger)
implementation(projects.features.call) implementation(projects.features.call)
implementation(projects.features.migration.api)
implementation(projects.anvilannotations) implementation(projects.anvilannotations)
implementation(projects.appnav) implementation(projects.appnav)
implementation(projects.appconfig) implementation(projects.appconfig)

View file

@ -29,6 +29,7 @@
android:fullBackupContent="@xml/backup_rules" android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:label="@string/app_name" android:label="@string/app_name"
android:localeConfig="@xml/locales_config"
android:networkSecurityConfig="@xml/network_security_config" android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
@ -74,6 +75,51 @@
<data android:scheme="io.element" /> <data android:scheme="io.element" />
</intent-filter> </intent-filter>
<!--
Element web links
-->
<intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<!-- Note: we can't use "*.element.io" here because it'll intercept the "mas.element.io" domain too. -->
<!-- Matching asset file: https://app.element.io/.well-known/assetlinks.json -->
<data android:host="app.element.io" />
<!-- Matching asset file: https://develop.element.io/.well-known/assetlinks.json -->
<data android:host="develop.element.io" />
<!-- Matching asset file: https://staging.element.io/.well-known/assetlinks.json -->
<data android:host="staging.element.io" />
</intent-filter>
<!--
matrix.to links
Note: On Android 12 and higher clicking a web link (that is not an Android App Link) always shows content in a web browser
https://developer.android.com/training/app-links#web-links
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" />
<data android:host="matrix.to" />
</intent-filter>
<!--
links from matrix.to website
-->
<intent-filter>
<action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="element" />
<data android:host="user" />
<data android:host="room" />
</intent-filter>
</activity> </activity>
<provider <provider

View file

@ -86,6 +86,7 @@ class MainActivity : NodeActivity() {
appBindings.preferencesStore().getThemeFlow().mapToTheme() appBindings.preferencesStore().getThemeFlow().mapToTheme()
} }
.collectAsState(initial = Theme.System) .collectAsState(initial = Theme.System)
val migrationState = appBindings.migrationEntryPoint().present()
ElementTheme( ElementTheme(
darkTheme = theme.isDark() darkTheme = theme.isDark()
) { ) {
@ -98,6 +99,21 @@ class MainActivity : NodeActivity() {
.fillMaxSize() .fillMaxSize()
.background(MaterialTheme.colorScheme.background), .background(MaterialTheme.colorScheme.background),
) { ) {
if (migrationState.migrationAction.isSuccess()) {
MainNodeHost()
} else {
appBindings.migrationEntryPoint().Render(
state = migrationState,
modifier = Modifier,
)
}
}
}
}
}
@Composable
private fun MainNodeHost() {
NodeHost(integrationPoint = appyxIntegrationPoint) { NodeHost(integrationPoint = appyxIntegrationPoint) {
MainNode( MainNode(
it, it,
@ -114,14 +130,12 @@ class MainActivity : NodeActivity() {
) )
} }
} }
}
}
}
/** /**
* Called when: * Called when:
* - the launcher icon is clicked (if the app is already running); * - the launcher icon is clicked (if the app is already running);
* - a notification is clicked. * - a notification is clicked.
* - a deep link have been clicked
* - the app is going to background (<- this is strange) * - the app is going to background (<- this is strange)
*/ */
override fun onNewIntent(intent: Intent) { override fun onNewIntent(intent: Intent) {

View file

@ -17,6 +17,7 @@
package io.element.android.x.di package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.api.MigrationEntryPoint
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
@ -35,4 +36,6 @@ interface AppBindings {
fun lockScreenService(): LockScreenService fun lockScreenService(): LockScreenService
fun preferencesStore(): AppPreferencesStore fun preferencesStore(): AppPreferencesStore
fun migrationEntryPoint(): MigrationEntryPoint
} }

View file

@ -56,11 +56,13 @@ class TracingInitializer : Initializer<Unit> {
writesToLogcat = false, writesToLogcat = false,
writesToFilesConfiguration = WriteToFilesConfiguration.Enabled( writesToFilesConfiguration = WriteToFilesConfiguration.Enabled(
directory = bugReporter.logDirectory().absolutePath, directory = bugReporter.logDirectory().absolutePath,
filenamePrefix = "logs" filenamePrefix = "logs",
filenameSuffix = null,
// Keep a minimum of 1 week of log files.
numberOfFiles = 7 * 24,
) )
) )
} }
bugReporter.cleanLogDirectoryIfNeeded()
bugReporter.setCurrentTracingFilter(tracingConfiguration.filterConfiguration.filter) bugReporter.setCurrentTracingFilter(tracingConfiguration.filterConfiguration.filter)
tracingService.setupTracing(tracingConfiguration) tracingService.setupTracing(tracingConfiguration)
// Also set env variable for rust back trace // Also set env variable for rust back trace

View file

@ -45,11 +45,4 @@ class IntentProviderImpl @Inject constructor(
data = deepLinkCreator.room(sessionId, roomId, threadId).toUri() data = deepLinkCreator.room(sessionId, roomId, threadId).toUri()
} }
} }
override fun getInviteListIntent(sessionId: SessionId): Intent {
return Intent(context, MainActivity::class.java).apply {
action = Intent.ACTION_VIEW
data = deepLinkCreator.inviteList(sessionId).toUri()
}
}
} }

View file

@ -0,0 +1,17 @@
#
# 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.
#
unqualifiedResLocale=en

View file

@ -0,0 +1,19 @@
<!-- File generated by importSupportedLocalesFromLocalazy.py, do not edit -->
<locale-config xmlns:android="http://schemas.android.com/apk/res/android">
<locale android:name="be"/>
<locale android:name="bg"/>
<locale android:name="cs"/>
<locale android:name="de"/>
<locale android:name="en"/>
<locale android:name="es"/>
<locale android:name="fr"/>
<locale android:name="hu"/>
<locale android:name="in"/>
<locale android:name="it"/>
<locale android:name="ro"/>
<locale android:name="ru"/>
<locale android:name="sk"/>
<locale android:name="sv"/>
<locale android:name="uk"/>
<locale android:name="zh-TW"/>
</locale-config>

View file

@ -67,16 +67,6 @@ class IntentProviderImplTest {
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId") assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/!aRoomId:domain/\$aThreadId")
} }
@Test
fun `test getInviteListIntent`() {
val sut = createIntentProviderImpl()
val result = sut.getInviteListIntent(
sessionId = A_SESSION_ID,
)
result.commonAssertions()
assertThat(result.data.toString()).isEqualTo("elementx://open/@alice:server.org/invites")
}
private fun createIntentProviderImpl(): IntentProviderImpl { private fun createIntentProviderImpl(): IntentProviderImpl {
return IntentProviderImpl( return IntentProviderImpl(
context = RuntimeEnvironment.getApplication() as Context, context = RuntimeEnvironment.getApplication() as Context,

View file

@ -40,4 +40,9 @@ object ApplicationConfig {
* For Element, the value is "Element". We use the same name for desktop and mobile for now. * For Element, the value is "Element". We use the same name for desktop and mobile for now.
*/ */
const val DESKTOP_APPLICATION_NAME: String = "Element" const val DESKTOP_APPLICATION_NAME: String = "Element"
/**
* The maximum size of the upload request. Default value is just below CloudFlare's max request size.
*/
const val MAX_LOG_UPLOAD_SIZE = 50 * 1024 * 1024L
} }

View file

@ -72,6 +72,7 @@ dependencies {
testImplementation(projects.features.rageshake.test) testImplementation(projects.features.rageshake.test)
testImplementation(projects.features.rageshake.impl) testImplementation(projects.features.rageshake.impl)
testImplementation(projects.services.appnavstate.test) testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.analytics.test)
testImplementation(libs.test.appyx.junit) testImplementation(libs.test.appyx.junit)
testImplementation(libs.test.arch.core) testImplementation(libs.test.arch.core)

View file

@ -33,10 +33,8 @@ import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
import com.bumble.appyx.navmodel.backstack.BackStack import com.bumble.appyx.navmodel.backstack.BackStack
import com.bumble.appyx.navmodel.backstack.operation.pop
import com.bumble.appyx.navmodel.backstack.operation.push import com.bumble.appyx.navmodel.backstack.operation.push
import com.bumble.appyx.navmodel.backstack.operation.replace import com.bumble.appyx.navmodel.backstack.operation.replace
import com.bumble.appyx.navmodel.backstack.operation.singleTop
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
@ -48,7 +46,6 @@ import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint 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.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.invite.api.InviteListEntryPoint
import io.element.android.features.lockscreen.api.LockScreenEntryPoint import io.element.android.features.lockscreen.api.LockScreenEntryPoint
import io.element.android.features.lockscreen.api.LockScreenLockState import io.element.android.features.lockscreen.api.LockScreenLockState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
@ -59,20 +56,23 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint import io.element.android.features.roomdirectory.api.RoomDirectoryEntryPoint
import io.element.android.features.roomlist.api.RoomListEntryPoint import io.element.android.features.roomlist.api.RoomListEntryPoint
import io.element.android.features.securebackup.api.SecureBackupEntryPoint import io.element.android.features.securebackup.api.SecureBackupEntryPoint
import io.element.android.features.userprofile.api.UserProfileEntryPoint
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.waitForChildAttached import io.element.android.libraries.architecture.waitForNavTargetAttached
import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomList 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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.sync.SyncState import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.FlowPreview
@ -81,7 +81,6 @@ import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
@ -95,11 +94,10 @@ class LoggedInFlowNode @AssistedInject constructor(
private val createRoomEntryPoint: CreateRoomEntryPoint, private val createRoomEntryPoint: CreateRoomEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val secureBackupEntryPoint: SecureBackupEntryPoint, private val secureBackupEntryPoint: SecureBackupEntryPoint,
private val inviteListEntryPoint: InviteListEntryPoint, private val userProfileEntryPoint: UserProfileEntryPoint,
private val ftueEntryPoint: FtueEntryPoint, private val ftueEntryPoint: FtueEntryPoint,
private val coroutineScope: CoroutineScope, private val coroutineScope: CoroutineScope,
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val notificationDrawerManager: NotificationDrawerManager,
private val ftueService: FtueService, private val ftueService: FtueService,
private val lockScreenEntryPoint: LockScreenEntryPoint, private val lockScreenEntryPoint: LockScreenEntryPoint,
private val lockScreenStateService: LockScreenService, private val lockScreenStateService: LockScreenService,
@ -160,23 +158,6 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
) )
observeSyncStateAndNetworkStatus() observeSyncStateAndNetworkStatus()
observeInvitesLoadingState()
}
private fun observeInvitesLoadingState() {
lifecycleScope.launch {
repeatOnLifecycle(Lifecycle.State.STARTED) {
matrixClient.roomListService.invites.loadingState
.collect { inviteState ->
when (inviteState) {
is RoomList.LoadingState.Loaded -> if (inviteState.numberOfRooms == 0) {
backstack.removeLast(NavTarget.InviteList)
}
RoomList.LoadingState.NotLoaded -> Unit
}
}
}
}
} }
@OptIn(FlowPreview::class) @OptIn(FlowPreview::class)
@ -215,9 +196,14 @@ class LoggedInFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data class Room( data class Room(
val roomId: RoomId, val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: RoomDescription? = null, val roomDescription: RoomDescription? = null,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages()
) : NavTarget
@Parcelize
data class UserProfile(
val userId: UserId,
) : NavTarget ) : NavTarget
@Parcelize @Parcelize
@ -233,9 +219,6 @@ class LoggedInFlowNode @AssistedInject constructor(
val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root val initialElement: SecureBackupEntryPoint.InitialTarget = SecureBackupEntryPoint.InitialTarget.Root
) : NavTarget ) : NavTarget
@Parcelize
data object InviteList : NavTarget
@Parcelize @Parcelize
data object Ftue : NavTarget data object Ftue : NavTarget
@ -257,7 +240,7 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.RoomList -> { NavTarget.RoomList -> {
val callback = object : RoomListEntryPoint.Callback { val callback = object : RoomListEntryPoint.Callback {
override fun onRoomClicked(roomId: RoomId) { override fun onRoomClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
override fun onSettingsClicked() { override fun onSettingsClicked() {
@ -272,12 +255,8 @@ class LoggedInFlowNode @AssistedInject constructor(
backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey)) backstack.push(NavTarget.SecureBackup(initialElement = SecureBackupEntryPoint.InitialTarget.EnterRecoveryKey))
} }
override fun onInvitesClicked() {
backstack.push(NavTarget.InviteList)
}
override fun onRoomSettingsClicked(roomId: RoomId) { override fun onRoomSettingsClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.Details)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.Details))
} }
override fun onReportBugClicked() { override fun onReportBugClicked() {
@ -296,11 +275,33 @@ class LoggedInFlowNode @AssistedInject constructor(
is NavTarget.Room -> { is NavTarget.Room -> {
val callback = object : JoinedRoomLoadedFlowNode.Callback { val callback = object : JoinedRoomLoadedFlowNode.Callback {
override fun onOpenRoom(roomId: RoomId) { override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
coroutineScope.launch { attachRoom(roomId) } coroutineScope.launch { attachRoom(roomId.toRoomIdOrAlias()) }
}
override fun onPermalinkClicked(data: PermalinkData) {
when (data) {
is PermalinkData.UserLink -> {
// Should not happen (handled by MessagesNode)
Timber.e("User link clicked: ${data.userId}.")
}
is PermalinkData.RoomLink -> {
backstack.push(
NavTarget.Room(
roomIdOrAlias = data.roomIdOrAlias,
initialElement = RoomNavigationTarget.Messages(data.eventId),
// TODO Use the viaParameters
)
)
}
is PermalinkData.FallbackLink,
is PermalinkData.RoomEmailInviteLink -> {
// Should not happen (handled by MessagesNode)
}
}
} }
override fun onOpenGlobalNotificationSettings() { override fun onOpenGlobalNotificationSettings() {
@ -308,12 +309,23 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
val inputs = RoomFlowNode.Inputs( val inputs = RoomFlowNode.Inputs(
roomId = navTarget.roomId, roomIdOrAlias = navTarget.roomIdOrAlias,
roomDescription = Optional.ofNullable(navTarget.roomDescription), roomDescription = Optional.ofNullable(navTarget.roomDescription),
initialElement = navTarget.initialElement initialElement = navTarget.initialElement
) )
createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<RoomFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
is NavTarget.UserProfile -> {
val callback = object : UserProfileEntryPoint.Callback {
override fun onOpenRoom(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
}
}
userProfileEntryPoint.nodeBuilder(this, buildContext)
.params(UserProfileEntryPoint.Params(userId = navTarget.userId))
.callback(callback)
.build()
}
is NavTarget.Settings -> { is NavTarget.Settings -> {
val callback = object : PreferencesEntryPoint.Callback { val callback = object : PreferencesEntryPoint.Callback {
override fun onOpenBugReport() { override fun onOpenBugReport() {
@ -325,11 +337,11 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
override fun onOpenRoomNotificationSettings(roomId: RoomId) { override fun onOpenRoomNotificationSettings(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId, initialElement = RoomNavigationTarget.NotificationSettings)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias(), initialElement = RoomNavigationTarget.NotificationSettings))
} }
} }
val inputs = PreferencesEntryPoint.Params(navTarget.initialElement) val inputs = PreferencesEntryPoint.Params(navTarget.initialElement)
return preferencesEntryPoint.nodeBuilder(this, buildContext) preferencesEntryPoint.nodeBuilder(this, buildContext)
.params(inputs) .params(inputs)
.callback(callback) .callback(callback)
.build() .build()
@ -337,7 +349,7 @@ class LoggedInFlowNode @AssistedInject constructor(
NavTarget.CreateRoom -> { NavTarget.CreateRoom -> {
val callback = object : CreateRoomEntryPoint.Callback { val callback = object : CreateRoomEntryPoint.Callback {
override fun onSuccess(roomId: RoomId) { override fun onSuccess(roomId: RoomId) {
backstack.replace(NavTarget.Room(roomId)) backstack.replace(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
} }
@ -351,43 +363,19 @@ class LoggedInFlowNode @AssistedInject constructor(
.params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement)) .params(SecureBackupEntryPoint.Params(initialElement = navTarget.initialElement))
.build() .build()
} }
NavTarget.InviteList -> {
val callback = object : InviteListEntryPoint.Callback {
override fun onBackClicked() {
backstack.pop()
}
override fun onInviteClicked(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
override fun onInviteAccepted(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId))
}
}
inviteListEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.build()
}
NavTarget.Ftue -> { NavTarget.Ftue -> {
ftueEntryPoint.nodeBuilder(this, buildContext) ftueEntryPoint.nodeBuilder(this, buildContext)
.callback(object : FtueEntryPoint.Callback {
override fun onFtueFlowFinished() {
lifecycleScope.launch { attachRoomList() }
}
})
.build() .build()
} }
NavTarget.RoomDirectorySearch -> { NavTarget.RoomDirectorySearch -> {
roomDirectoryEntryPoint.nodeBuilder(this, buildContext) roomDirectoryEntryPoint.nodeBuilder(this, buildContext)
.callback(object : RoomDirectoryEntryPoint.Callback { .callback(object : RoomDirectoryEntryPoint.Callback {
override fun onRoomJoined(roomId: RoomId) { override fun onRoomJoined(roomId: RoomId) {
backstack.push(NavTarget.Room(roomId)) backstack.push(NavTarget.Room(roomId.toRoomIdOrAlias()))
} }
override fun onResultClicked(roomDescription: RoomDescription) { override fun onResultClicked(roomDescription: RoomDescription) {
backstack.push(NavTarget.Room(roomDescription.roomId, roomDescription)) backstack.push(NavTarget.Room(roomDescription.roomId.toRoomIdOrAlias(), roomDescription))
} }
}) })
.build() .build()
@ -395,42 +383,42 @@ class LoggedInFlowNode @AssistedInject constructor(
} }
} }
suspend fun attachRoomList() { suspend fun attachRoom(roomIdOrAlias: RoomIdOrAlias, eventId: EventId? = null) {
if (!canShowRoomList()) return waitForNavTargetAttached { navTarget ->
attachChild<Node> { navTarget is NavTarget.RoomList
backstack.singleTop(NavTarget.RoomList)
} }
}
suspend fun attachRoom(roomId: RoomId) {
if (!canShowRoomList()) return
attachChild<RoomFlowNode> { attachChild<RoomFlowNode> {
backstack.singleTop(NavTarget.RoomList) backstack.push(
backstack.push(NavTarget.Room(roomId)) NavTarget.Room(
roomIdOrAlias = roomIdOrAlias,
initialElement = RoomNavigationTarget.Messages(
focusedEventId = eventId
)
)
)
} }
} }
internal suspend fun attachInviteList(deeplinkData: DeeplinkData.InviteList) = withContext(lifecycleScope.coroutineContext) { suspend fun attachUser(userId: UserId) {
if (!canShowRoomList()) return@withContext waitForNavTargetAttached { navTarget ->
notificationDrawerManager.clearMembershipNotificationForSession(deeplinkData.sessionId) navTarget is NavTarget.RoomList
backstack.singleTop(NavTarget.RoomList)
backstack.push(NavTarget.InviteList)
waitForChildAttached<Node, NavTarget> { navTarget ->
navTarget is NavTarget.InviteList
} }
attachChild<Node> {
backstack.push(
NavTarget.UserProfile(
userId = userId,
)
)
} }
private fun canShowRoomList(): Boolean {
return ftueService.state.value is FtueState.Complete
} }
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
Box(modifier = modifier) { Box(modifier = modifier) {
val lockScreenState by lockScreenStateService.lockState.collectAsState() val lockScreenState by lockScreenStateService.lockState.collectAsState()
val isFtueDisplayed by ftueService.state.collectAsState() val ftueState by ftueService.state.collectAsState()
BackstackView() BackstackView()
if (isFtueDisplayed is FtueState.Complete) { if (ftueState is FtueState.Complete) {
PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent) PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.LoggedInPermanent)
} }
if (lockScreenState == LockScreenLockState.Locked) { if (lockScreenState == LockScreenLockState.Locked) {

View file

@ -55,6 +55,8 @@ import io.element.android.libraries.designsystem.theme.components.CircularProgre
import io.element.android.libraries.di.AppScope import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.sessionstorage.api.LoggedInState import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
@ -279,18 +281,37 @@ class RootFlowNode @AssistedInject constructor(
when (resolvedIntent) { when (resolvedIntent) {
is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData) is ResolvedIntent.Navigation -> navigateTo(resolvedIntent.deeplinkData)
is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction) is ResolvedIntent.Oidc -> onOidcAction(resolvedIntent.oidcAction)
is ResolvedIntent.Permalink -> navigateTo(resolvedIntent.permalinkData)
}
}
private suspend fun navigateTo(permalinkData: PermalinkData) {
Timber.d("Navigating to $permalinkData")
attachSession(null)
.apply {
when (permalinkData) {
is PermalinkData.FallbackLink -> Unit
is PermalinkData.RoomEmailInviteLink -> Unit
is PermalinkData.RoomLink -> {
attachRoom(
roomIdOrAlias = permalinkData.roomIdOrAlias,
eventId = permalinkData.eventId,
)
}
is PermalinkData.UserLink -> {
attachUser(permalinkData.userId)
}
}
} }
} }
private suspend fun navigateTo(deeplinkData: DeeplinkData) { private suspend fun navigateTo(deeplinkData: DeeplinkData) {
Timber.d("Navigating to $deeplinkData") Timber.d("Navigating to $deeplinkData")
attachSession(deeplinkData.sessionId) attachSession(deeplinkData.sessionId)
.attachSession()
.apply { .apply {
when (deeplinkData) { when (deeplinkData) {
is DeeplinkData.Root -> attachRoomList() is DeeplinkData.Root -> Unit // The room list will always be shown, observing FtueState
is DeeplinkData.Room -> attachRoom(deeplinkData.roomId) is DeeplinkData.Room -> attachRoom(deeplinkData.roomId.toRoomIdOrAlias())
is DeeplinkData.InviteList -> attachInviteList(deeplinkData)
} }
} }
} }
@ -299,10 +320,12 @@ class RootFlowNode @AssistedInject constructor(
oidcActionFlow.post(oidcAction) oidcActionFlow.post(oidcAction)
} }
private suspend fun attachSession(sessionId: SessionId): LoggedInAppScopeFlowNode { // [sessionId] will be null for permalink.
private suspend fun attachSession(sessionId: SessionId?): LoggedInFlowNode {
// TODO handle multi-session // TODO handle multi-session
return waitForChildAttached { navTarget -> return waitForChildAttached<LoggedInAppScopeFlowNode, NavTarget> { navTarget ->
navTarget is NavTarget.LoggedInFlow && navTarget.sessionId == sessionId navTarget is NavTarget.LoggedInFlow && (sessionId == null || navTarget.sessionId == sessionId)
} }
.attachSession()
} }
} }

View file

@ -21,27 +21,41 @@ import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcIntentResolver import io.element.android.features.login.api.oidc.OidcIntentResolver
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import timber.log.Timber import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
sealed interface ResolvedIntent { sealed interface ResolvedIntent {
data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent data class Navigation(val deeplinkData: DeeplinkData) : ResolvedIntent
data class Oidc(val oidcAction: OidcAction) : ResolvedIntent data class Oidc(val oidcAction: OidcAction) : ResolvedIntent
data class Permalink(val permalinkData: PermalinkData) : ResolvedIntent
} }
class IntentResolver @Inject constructor( class IntentResolver @Inject constructor(
private val deeplinkParser: DeeplinkParser, private val deeplinkParser: DeeplinkParser,
private val oidcIntentResolver: OidcIntentResolver private val oidcIntentResolver: OidcIntentResolver,
private val permalinkParser: PermalinkParser,
) { ) {
fun resolve(intent: Intent): ResolvedIntent? { fun resolve(intent: Intent): ResolvedIntent? {
if (intent.canBeIgnored()) return null if (intent.canBeIgnored()) return null
// Coming from a notification?
val deepLinkData = deeplinkParser.getFromIntent(intent) val deepLinkData = deeplinkParser.getFromIntent(intent)
if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData) if (deepLinkData != null) return ResolvedIntent.Navigation(deepLinkData)
// Coming during login using Oidc?
val oidcAction = oidcIntentResolver.resolve(intent) val oidcAction = oidcIntentResolver.resolve(intent)
if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction) if (oidcAction != null) return ResolvedIntent.Oidc(oidcAction)
// External link clicked? (matrix.to, element.io, etc.)
val permalinkData = intent
.takeIf { it.action == Intent.ACTION_VIEW }
?.dataString
?.let { permalinkParser.parse(it) }
?.takeIf { it !is PermalinkData.FallbackLink }
if (permalinkData != null) return ResolvedIntent.Permalink(permalinkData)
// Unknown intent // Unknown intent
Timber.w("Unknown intent") Timber.w("Unknown intent")
return null return null

View file

@ -0,0 +1,59 @@
/*
* 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.appnav.loggedin
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
fun SessionVerifiedStatus.toAnalyticsUserPropertyValue(): UserProperties.VerificationState? {
return when (this) {
// we don't need to report transient states
SessionVerifiedStatus.Unknown -> null
SessionVerifiedStatus.NotVerified -> UserProperties.VerificationState.NotVerified
SessionVerifiedStatus.Verified -> UserProperties.VerificationState.Verified
}
}
fun RecoveryState.toAnalyticsUserPropertyValue(): UserProperties.RecoveryState? {
return when (this) {
RecoveryState.ENABLED -> UserProperties.RecoveryState.Enabled
RecoveryState.DISABLED -> UserProperties.RecoveryState.Disabled
RecoveryState.INCOMPLETE -> UserProperties.RecoveryState.Incomplete
// we don't need to report transient states
else -> null
}
}
fun SessionVerifiedStatus.toAnalyticsStateChangeValue(): CryptoSessionStateChange.VerificationState? {
return when (this) {
// we don't need to report transient states
SessionVerifiedStatus.Unknown -> null
SessionVerifiedStatus.NotVerified -> CryptoSessionStateChange.VerificationState.NotVerified
SessionVerifiedStatus.Verified -> CryptoSessionStateChange.VerificationState.Verified
}
}
fun RecoveryState.toAnalyticsStateChangeValue(): CryptoSessionStateChange.RecoveryState? {
return when (this) {
RecoveryState.ENABLED -> CryptoSessionStateChange.RecoveryState.Enabled
RecoveryState.DISABLED -> CryptoSessionStateChange.RecoveryState.Disabled
RecoveryState.INCOMPLETE -> CryptoSessionStateChange.RecoveryState.Incomplete
// we don't need to report transient states
else -> null
}
}

View file

@ -22,14 +22,19 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.remember import androidx.compose.runtime.remember
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.push.api.PushService import io.element.android.libraries.push.api.PushService
import io.element.android.services.analytics.api.AnalyticsService
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import javax.inject.Inject import javax.inject.Inject
@ -38,6 +43,8 @@ class LoggedInPresenter @Inject constructor(
private val networkMonitor: NetworkMonitor, private val networkMonitor: NetworkMonitor,
private val pushService: PushService, private val pushService: PushService,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val analyticsService: AnalyticsService,
private val encryptionService: EncryptionService,
) : Presenter<LoggedInState> { ) : Presenter<LoggedInState> {
@Composable @Composable
override fun present(): LoggedInState { override fun present(): LoggedInState {
@ -62,8 +69,36 @@ class LoggedInPresenter @Inject constructor(
networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show networkStatus == NetworkStatus.Online && syncIndicator == RoomListService.SyncIndicator.Show
} }
} }
val verificationState by sessionVerificationService.sessionVerifiedStatus.collectAsState()
val recoveryState by encryptionService.recoveryStateStateFlow.collectAsState()
LaunchedEffect(verificationState, recoveryState) {
reportCryptoStatusToAnalytics(verificationState, recoveryState)
}
return LoggedInState( return LoggedInState(
showSyncSpinner = showSyncSpinner, showSyncSpinner = showSyncSpinner,
) )
} }
private fun reportCryptoStatusToAnalytics(verificationState: SessionVerifiedStatus, recoveryState: RecoveryState) {
// Update first the user property, to store the current status for that posthog user
val userVerificationState = verificationState.toAnalyticsUserPropertyValue()
val userRecoveryState = recoveryState.toAnalyticsUserPropertyValue()
if (userRecoveryState != null && userVerificationState != null) {
// we want to report when both value are known (if one is unknown we wait until we have them both)
analyticsService.updateUserProperties(
UserProperties(
verificationState = userVerificationState,
recoveryState = userRecoveryState
)
)
}
// Also report when there is a change in the state, to be able to track the changes
val changeVerificationState = verificationState.toAnalyticsStateChangeValue()
val changeRecoveryState = recoveryState.toAnalyticsStateChangeValue()
if (changeVerificationState != null && changeRecoveryState != null) {
analyticsService.capture(CryptoSessionStateChange(changeRecoveryState, changeVerificationState))
}
}
} }

View file

@ -17,10 +17,9 @@
package io.element.android.appnav.room package io.element.android.appnav.room
import android.os.Parcelable import android.os.Parcelable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
@ -36,22 +35,30 @@ import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.appnav.room.joined.JoinedRoomFlowNode import io.element.android.appnav.room.joined.JoinedRoomFlowNode
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.appnav.room.joined.LoadingRoomNodeView
import io.element.android.appnav.room.joined.LoadingRoomState
import io.element.android.features.joinroom.api.JoinRoomEntryPoint import io.element.android.features.joinroom.api.JoinRoomEntryPoint
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.roomaliasesolver.api.RoomAliasResolverEntryPoint
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.BackstackView import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.core.bool.orFalse
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
import timber.log.Timber import timber.log.Timber
import java.util.Optional import java.util.Optional
@ -62,8 +69,9 @@ class RoomFlowNode @AssistedInject constructor(
@Assisted val buildContext: BuildContext, @Assisted val buildContext: BuildContext,
@Assisted plugins: List<Plugin>, @Assisted plugins: List<Plugin>,
private val client: MatrixClient, private val client: MatrixClient,
private val roomMembershipObserver: RoomMembershipObserver,
private val joinRoomEntryPoint: JoinRoomEntryPoint, private val joinRoomEntryPoint: JoinRoomEntryPoint,
private val roomAliasResolverEntryPoint: RoomAliasResolverEntryPoint,
private val networkMonitor: NetworkMonitor,
) : BaseFlowNode<RoomFlowNode.NavTarget>( ) : BaseFlowNode<RoomFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = NavTarget.Loading, initialElement = NavTarget.Loading,
@ -73,9 +81,9 @@ class RoomFlowNode @AssistedInject constructor(
plugins = plugins plugins = plugins
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>, val roomDescription: Optional<RoomDescription>,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -85,54 +93,108 @@ class RoomFlowNode @AssistedInject constructor(
data object Loading : NavTarget data object Loading : NavTarget
@Parcelize @Parcelize
data object JoinRoom : NavTarget data class Resolving(val roomAlias: RoomAlias) : NavTarget
@Parcelize @Parcelize
data object JoinedRoom : NavTarget data class JoinRoom(val roomId: RoomId) : NavTarget
@Parcelize
data class JoinedRoom(val roomId: RoomId) : NavTarget
} }
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
client.getRoomInfoFlow( resolveRoomId()
inputs.roomId
).onEach { roomInfo ->
Timber.d("Room membership: ${roomInfo.map { it.currentUserMembership }}")
if (roomInfo.getOrNull()?.currentUserMembership == CurrentUserMembership.JOINED) {
backstack.newRoot(NavTarget.JoinedRoom)
} else {
backstack.newRoot(NavTarget.JoinRoom)
} }
}
.launchIn(lifecycleScope)
// When leaving the room from this session only, navigate up. private fun resolveRoomId() {
roomMembershipObserver.updates lifecycleScope.launch {
.filter { update -> update.roomId == inputs.roomId && !update.isUserInRoom } when (val i = inputs.roomIdOrAlias) {
.onEach { is RoomIdOrAlias.Alias -> {
backstack.newRoot(NavTarget.Resolving(i.roomAlias))
}
is RoomIdOrAlias.Id -> {
subscribeToRoomInfoFlow(i.roomId)
}
}
}
}
private fun subscribeToRoomInfoFlow(roomId: RoomId) {
val roomInfoFlow = client.getRoomInfoFlow(
roomId = roomId
).map { it.getOrNull() }
val isSpaceFlow = roomInfoFlow.map { it?.isSpace.orFalse() }.distinctUntilChanged()
val currentMembershipFlow = roomInfoFlow.map { it?.currentUserMembership }.distinctUntilChanged()
combine(currentMembershipFlow, isSpaceFlow) { membership, isSpace ->
Timber.d("Room membership: $membership")
when (membership) {
CurrentUserMembership.JOINED -> {
if (isSpace) {
// It should not happen, but probably due to an issue in the sliding sync,
// we can have a space here in case the space has just been joined.
// So navigate to the JoinRoom target for now, which will
// handle the space not supported screen
backstack.newRoot(NavTarget.JoinRoom(roomId))
} else {
backstack.newRoot(NavTarget.JoinedRoom(roomId))
}
}
CurrentUserMembership.LEFT -> {
// Left the room, navigate out of this flow
navigateUp() navigateUp()
} }
.launchIn(lifecycleScope) else -> {
// Was invited or the room is not known, display the join room screen
backstack.newRoot(NavTarget.JoinRoom(roomId))
}
}
}.launchIn(lifecycleScope)
} }
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Loading -> loadingNode(buildContext) is NavTarget.Loading -> loadingNode(buildContext)
NavTarget.JoinRoom -> { is NavTarget.Resolving -> {
val inputs = JoinRoomEntryPoint.Inputs(inputs.roomId, roomDescription = inputs.roomDescription) val callback = object : RoomAliasResolverEntryPoint.Callback {
override fun onAliasResolved(roomId: RoomId) {
subscribeToRoomInfoFlow(roomId)
}
}
val params = RoomAliasResolverEntryPoint.Params(navTarget.roomAlias)
roomAliasResolverEntryPoint.nodeBuilder(this, buildContext)
.callback(callback)
.params(params)
.build()
}
is NavTarget.JoinRoom -> {
val inputs = JoinRoomEntryPoint.Inputs(
roomId = navTarget.roomId,
roomIdOrAlias = inputs.roomIdOrAlias,
roomDescription = inputs.roomDescription,
)
joinRoomEntryPoint.createNode(this, buildContext, inputs) joinRoomEntryPoint.createNode(this, buildContext, inputs)
} }
NavTarget.JoinedRoom -> { is NavTarget.JoinedRoom -> {
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>() val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val inputs = JoinedRoomFlowNode.Inputs(inputs.roomId, initialElement = inputs.initialElement) val inputs = JoinedRoomFlowNode.Inputs(
roomId = navTarget.roomId,
initialElement = inputs.initialElement
)
createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} }
} }
} }
private fun loadingNode(buildContext: BuildContext) = node(buildContext) { private fun loadingNode(buildContext: BuildContext) = node(buildContext) { modifier ->
Box(modifier = it.fillMaxSize(), contentAlignment = Alignment.Center) { val networkStatus by networkMonitor.connectivity.collectAsState()
CircularProgressIndicator() LoadingRoomNodeView(
} state = LoadingRoomState.Loading,
hasNetworkConnection = networkStatus == NetworkStatus.Online,
onBackClicked = { navigateUp() },
modifier = modifier,
)
} }
@Composable @Composable

View file

@ -16,8 +16,17 @@
package io.element.android.appnav.room package io.element.android.appnav.room
enum class RoomNavigationTarget { import android.os.Parcelable
Messages, import io.element.android.libraries.matrix.api.core.EventId
Details, import kotlinx.parcelize.Parcelize
NotificationSettings,
sealed interface RoomNavigationTarget : Parcelable {
@Parcelize
data class Messages(val focusedEventId: EventId? = null) : RoomNavigationTarget
@Parcelize
data object Details : RoomNavigationTarget
@Parcelize
data object NotificationSettings : RoomNavigationTarget
} }

View file

@ -69,7 +69,7 @@ class JoinedRoomFlowNode @AssistedInject constructor(
) { ) {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -106,7 +106,10 @@ class JoinedRoomFlowNode @AssistedInject constructor(
val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>() val roomFlowNodeCallback = plugins<JoinedRoomLoadedFlowNode.Callback>()
val awaitRoomState = loadingRoomStateStateFlow.value val awaitRoomState = loadingRoomStateStateFlow.value
if (awaitRoomState is LoadingRoomState.Loaded) { if (awaitRoomState is LoadingRoomState.Loaded) {
val inputs = JoinedRoomLoadedFlowNode.Inputs(awaitRoomState.room, initialElement = inputs.initialElement) val inputs = JoinedRoomLoadedFlowNode.Inputs(
room = awaitRoomState.room,
initialElement = inputs.initialElement
)
createNode<JoinedRoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback) createNode<JoinedRoomLoadedFlowNode>(buildContext, plugins = listOf(inputs) + roomFlowNodeCallback)
} else { } else {
loadingNode(buildContext, this::navigateUp) loadingNode(buildContext, this::navigateUp)

View file

@ -42,8 +42,10 @@ import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DaggerComponentOwner import io.element.android.libraries.di.DaggerComponentOwner
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.core.UserId 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.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@ -63,8 +65,8 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>( ) : BaseFlowNode<JoinedRoomLoadedFlowNode.NavTarget>(
backstack = BackStack( backstack = BackStack(
initialElement = when (plugins.filterIsInstance(Inputs::class.java).first().initialElement) { initialElement = when (val input = plugins.filterIsInstance(Inputs::class.java).first().initialElement) {
RoomNavigationTarget.Messages -> NavTarget.Messages is RoomNavigationTarget.Messages -> NavTarget.Messages(input.focusedEventId)
RoomNavigationTarget.Details -> NavTarget.RoomDetails RoomNavigationTarget.Details -> NavTarget.RoomDetails
RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings RoomNavigationTarget.NotificationSettings -> NavTarget.RoomNotificationSettings
}, },
@ -75,13 +77,14 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
), DaggerComponentOwner { ), DaggerComponentOwner {
interface Callback : Plugin { interface Callback : Plugin {
fun onOpenRoom(roomId: RoomId) fun onOpenRoom(roomId: RoomId)
fun onPermalinkClicked(data: PermalinkData)
fun onForwardedToSingleRoom(roomId: RoomId) fun onForwardedToSingleRoom(roomId: RoomId)
fun onOpenGlobalNotificationSettings() fun onOpenGlobalNotificationSettings()
} }
data class Inputs( data class Inputs(
val room: MatrixRoom, val room: MatrixRoom,
val initialElement: RoomNavigationTarget = RoomNavigationTarget.Messages, val initialElement: RoomNavigationTarget,
) : NodeInputs ) : NodeInputs
private val inputs: Inputs = inputs() private val inputs: Inputs = inputs()
@ -139,7 +142,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node { override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) { return when (navTarget) {
NavTarget.Messages -> { is NavTarget.Messages -> {
val callback = object : MessagesEntryPoint.Callback { val callback = object : MessagesEntryPoint.Callback {
override fun onRoomDetailsClicked() { override fun onRoomDetailsClicked() {
backstack.push(NavTarget.RoomDetails) backstack.push(NavTarget.RoomDetails)
@ -149,11 +152,18 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
backstack.push(NavTarget.RoomMemberDetails(userId)) backstack.push(NavTarget.RoomMemberDetails(userId))
} }
override fun onPermalinkClicked(data: PermalinkData) {
callbacks.forEach { it.onPermalinkClicked(data) }
}
override fun onForwardedToSingleRoom(roomId: RoomId) { override fun onForwardedToSingleRoom(roomId: RoomId) {
callbacks.forEach { it.onForwardedToSingleRoom(roomId) } callbacks.forEach { it.onForwardedToSingleRoom(roomId) }
} }
} }
messagesEntryPoint.createNode(this, buildContext, callback) messagesEntryPoint.nodeBuilder(this, buildContext)
.params(MessagesEntryPoint.Params(navTarget.focusedEventId))
.callback(callback)
.build()
} }
NavTarget.RoomDetails -> { NavTarget.RoomDetails -> {
createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails) createRoomDetailsNode(buildContext, RoomDetailsEntryPoint.InitialTarget.RoomDetails)
@ -169,7 +179,7 @@ class JoinedRoomLoadedFlowNode @AssistedInject constructor(
sealed interface NavTarget : Parcelable { sealed interface NavTarget : Parcelable {
@Parcelize @Parcelize
data object Messages : NavTarget data class Messages(val focusedEventId: EventId? = null) : NavTarget
@Parcelize @Parcelize
data object RoomDetails : NavTarget data object RoomDetails : NavTarget

View file

@ -68,7 +68,7 @@ fun RootView(
@PreviewsDayNight @PreviewsDayNight
@Composable @Composable
internal fun RootPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview { internal fun RootViewPreview(@PreviewParameter(RootStateProvider::class) rootState: RootState) = ElementPreview {
RootView( RootView(
state = rootState, state = rootState,
onOpenBugReport = {}, onOpenBugReport = {},

View file

@ -27,6 +27,7 @@ import com.bumble.appyx.testing.junit4.util.MainDispatcherRule
import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper import com.bumble.appyx.testing.unit.common.helper.parentNodeTestHelper
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.appnav.di.RoomComponentFactory import io.element.android.appnav.di.RoomComponentFactory
import io.element.android.appnav.room.RoomNavigationTarget
import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode import io.element.android.appnav.room.joined.JoinedRoomLoadedFlowNode
import io.element.android.features.messages.api.MessagesEntryPoint import io.element.android.features.messages.api.MessagesEntryPoint
import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint import io.element.android.features.roomdetails.api.RoomDetailsEntryPoint
@ -47,14 +48,30 @@ class JoinRoomLoadedFlowNodeTest {
@get:Rule @get:Rule
val mainDispatcherRule = MainDispatcherRule() val mainDispatcherRule = MainDispatcherRule()
private class FakeMessagesEntryPoint : MessagesEntryPoint { private class FakeMessagesEntryPoint : MessagesEntryPoint, MessagesEntryPoint.NodeBuilder {
var buildContext: BuildContext? = null
var nodeId: String? = null var nodeId: String? = null
var parameters: MessagesEntryPoint.Params? = null
var callback: MessagesEntryPoint.Callback? = null var callback: MessagesEntryPoint.Callback? = null
override fun createNode(parentNode: Node, buildContext: BuildContext, callback: MessagesEntryPoint.Callback): Node { override fun nodeBuilder(parentNode: Node, buildContext: BuildContext): MessagesEntryPoint.NodeBuilder {
return node(buildContext) {}.also { this.buildContext = buildContext
nodeId = it.id return this
}
override fun params(params: MessagesEntryPoint.Params): MessagesEntryPoint.NodeBuilder {
parameters = params
return this
}
override fun callback(callback: MessagesEntryPoint.Callback): MessagesEntryPoint.NodeBuilder {
this.callback = callback this.callback = callback
return this
}
override fun build(): Node {
return node(buildContext!!) {}.also {
nodeId = it.id
} }
} }
} }
@ -108,7 +125,7 @@ class JoinRoomLoadedFlowNodeTest {
// GIVEN // GIVEN
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room) val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val roomFlowNode = createJoinedRoomLoadedFlowNode( val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs), plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint, messagesEntryPoint = fakeMessagesEntryPoint,
@ -118,9 +135,9 @@ class JoinRoomLoadedFlowNodeTest {
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// THEN // THEN
assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages) assertThat(roomFlowNode.backstack.activeElement).isEqualTo(JoinedRoomLoadedFlowNode.NavTarget.Messages())
roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages, Lifecycle.State.CREATED) roomFlowNodeTestHelper.assertChildHasLifecycle(JoinedRoomLoadedFlowNode.NavTarget.Messages(), Lifecycle.State.CREATED)
val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages)!! val messagesNode = roomFlowNode.childNode(JoinedRoomLoadedFlowNode.NavTarget.Messages())!!
assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId) assertThat(messagesNode.id).isEqualTo(fakeMessagesEntryPoint.nodeId)
} }
@ -130,7 +147,7 @@ class JoinRoomLoadedFlowNodeTest {
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = JoinedRoomLoadedFlowNode.Inputs(room) val inputs = JoinedRoomLoadedFlowNode.Inputs(room, RoomNavigationTarget.Messages())
val roomFlowNode = createJoinedRoomLoadedFlowNode( val roomFlowNode = createJoinedRoomLoadedFlowNode(
plugins = listOf(inputs), plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint, messagesEntryPoint = fakeMessagesEntryPoint,

View file

@ -18,6 +18,7 @@ package io.element.android.appnav.intent
import android.app.Activity import android.app.Activity
import android.content.Intent import android.content.Intent
import android.net.Uri
import androidx.core.net.toUri import androidx.core.net.toUri
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcAction
@ -26,9 +27,12 @@ import io.element.android.features.login.impl.oidc.OidcUrlParser
import io.element.android.libraries.deeplink.DeepLinkCreator import io.element.android.libraries.deeplink.DeepLinkCreator
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.deeplink.DeeplinkParser import io.element.android.libraries.deeplink.DeeplinkParser
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.permalink.PermalinkData
import io.element.android.libraries.matrix.test.A_ROOM_ID 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_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import org.junit.Assert.assertThrows import org.junit.Assert.assertThrows
import org.junit.Test import org.junit.Test
import org.junit.runner.RunWith import org.junit.runner.RunWith
@ -162,9 +166,60 @@ class IntentResolverTest {
} }
} }
@Test
fun `test resolve external permalink`() {
val permalinkData = PermalinkData.UserLink(
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
permalinkParserResult = { permalinkData }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "https://matrix.to/#/@alice:matrix.org".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isEqualTo(
ResolvedIntent.Permalink(
permalinkData = permalinkData
)
)
}
@Test
fun `test resolve external permalink, FallbackLink should be ignored`() {
val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW
data = "https://matrix.to/#/@alice:matrix.org".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test
fun `test resolve external permalink, invalid action`() {
val permalinkData = PermalinkData.UserLink(
userId = UserId("@alice:matrix.org")
)
val sut = createIntentResolver(
permalinkParserResult = { permalinkData }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_SEND
data = "https://matrix.to/invalid".toUri()
}
val result = sut.resolve(intent)
assertThat(result).isNull()
}
@Test @Test
fun `test resolve invalid`() { fun `test resolve invalid`() {
val sut = createIntentResolver() val sut = createIntentResolver(
permalinkParserResult = { PermalinkData.FallbackLink(Uri.parse("https://matrix.org")) }
)
val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply { val intent = Intent(RuntimeEnvironment.getApplication(), Activity::class.java).apply {
action = Intent.ACTION_VIEW action = Intent.ACTION_VIEW
data = "io.element:/invalid".toUri() data = "io.element:/invalid".toUri()
@ -173,12 +228,17 @@ class IntentResolverTest {
assertThat(result).isNull() assertThat(result).isNull()
} }
private fun createIntentResolver(): IntentResolver { private fun createIntentResolver(
permalinkParserResult: () -> PermalinkData = { throw NotImplementedError() }
): IntentResolver {
return IntentResolver( return IntentResolver(
deeplinkParser = DeeplinkParser(), deeplinkParser = DeeplinkParser(),
oidcIntentResolver = DefaultOidcIntentResolver( oidcIntentResolver = DefaultOidcIntentResolver(
oidcUrlParser = OidcUrlParser() oidcUrlParser = OidcUrlParser()
), ),
permalinkParser = FakePermalinkParser(
result = permalinkParserResult
),
) )
} }
} }

View file

@ -0,0 +1,70 @@
/*
* 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.appnav.loggedin
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class AnalyticsVerificationStateMappingTests {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `Test verification Mappings`() = runTest {
assertThat(SessionVerifiedStatus.Verified.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.VerificationState.Verified)
assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.VerificationState.NotVerified)
assertThat(SessionVerifiedStatus.Verified.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.VerificationState.Verified)
assertThat(SessionVerifiedStatus.NotVerified.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.VerificationState.NotVerified)
}
@Test
fun `Test recovery state Mappings`() = runTest {
assertThat(RecoveryState.UNKNOWN.toAnalyticsUserPropertyValue())
.isNull()
assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsUserPropertyValue())
.isNull()
assertThat(RecoveryState.INCOMPLETE.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(RecoveryState.ENABLED.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.RecoveryState.Enabled)
assertThat(RecoveryState.DISABLED.toAnalyticsUserPropertyValue())
.isEqualTo(UserProperties.RecoveryState.Disabled)
assertThat(RecoveryState.UNKNOWN.toAnalyticsStateChangeValue())
.isNull()
assertThat(RecoveryState.WAITING_FOR_SYNC.toAnalyticsStateChangeValue())
.isNull()
assertThat(RecoveryState.INCOMPLETE.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.RecoveryState.Incomplete)
assertThat(RecoveryState.ENABLED.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.RecoveryState.Enabled)
assertThat(RecoveryState.DISABLED.toAnalyticsStateChangeValue())
.isEqualTo(CryptoSessionStateChange.RecoveryState.Disabled)
}
}

View file

@ -20,13 +20,19 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.CryptoSessionStateChange
import im.vector.app.features.analytics.plan.UserProperties
import io.element.android.features.networkmonitor.api.NetworkStatus import io.element.android.features.networkmonitor.api.NetworkStatus
import io.element.android.features.networkmonitor.test.FakeNetworkMonitor import io.element.android.features.networkmonitor.test.FakeNetworkMonitor
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.push.test.FakePushService import io.element.android.libraries.push.test.FakePushService
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.consumeItemsUntilPredicate
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@ -64,15 +70,56 @@ class LoggedInPresenterTest {
} }
} }
@Test
fun `present - report crypto status analytics`() = runTest {
val analyticsService = FakeAnalyticsService()
val roomListService = FakeRoomListService()
val verificationService = FakeSessionVerificationService()
val encryptionService = FakeEncryptionService()
val presenter = LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService, encryptionService = encryptionService),
networkMonitor = FakeNetworkMonitor(NetworkStatus.Online),
pushService = FakePushService(),
sessionVerificationService = verificationService,
analyticsService = analyticsService,
encryptionService = encryptionService
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
encryptionService.emitRecoveryState(RecoveryState.UNKNOWN)
encryptionService.emitRecoveryState(RecoveryState.INCOMPLETE)
verificationService.emitVerifiedStatus(SessionVerifiedStatus.Verified)
skipItems(4)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
assertThat(analyticsService.capturedEvents[0]).isInstanceOf(CryptoSessionStateChange::class.java)
assertThat(analyticsService.capturedUserProperties.size).isEqualTo(1)
assertThat(analyticsService.capturedUserProperties[0].recoveryState).isEqualTo(UserProperties.RecoveryState.Incomplete)
assertThat(analyticsService.capturedUserProperties[0].verificationState).isEqualTo(UserProperties.VerificationState.Verified)
// ensure a sync status change does not trigger a new capture
roomListService.postSyncIndicator(RoomListService.SyncIndicator.Show)
skipItems(1)
assertThat(analyticsService.capturedEvents.size).isEqualTo(1)
}
}
private fun createLoggedInPresenter( private fun createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline networkStatus: NetworkStatus = NetworkStatus.Offline,
analyticsService: FakeAnalyticsService = FakeAnalyticsService(),
encryptionService: FakeEncryptionService = FakeEncryptionService(),
): LoggedInPresenter { ): LoggedInPresenter {
return LoggedInPresenter( return LoggedInPresenter(
matrixClient = FakeMatrixClient(roomListService = roomListService), matrixClient = FakeMatrixClient(roomListService = roomListService),
networkMonitor = FakeNetworkMonitor(networkStatus), networkMonitor = FakeNetworkMonitor(networkStatus),
pushService = FakePushService(), pushService = FakePushService(),
sessionVerificationService = FakeSessionVerificationService(), sessionVerificationService = FakeSessionVerificationService(),
analyticsService = analyticsService,
encryptionService = encryptionService
) )
} }
} }

View file

@ -118,6 +118,9 @@ dependencyAnalysis {
onUnusedDependencies { onUnusedDependencies {
exclude("com.jakewharton.timber:timber") exclude("com.jakewharton.timber:timber")
} }
onUnusedAnnotationProcessors {}
onRedundantPlugins {}
onIncorrectConfiguration {}
} }
} }
} }

71
docs/deeplink.md Normal file
View file

@ -0,0 +1,71 @@
# Element X Android deeplink
<!--- TOC -->
* [Introduction](#introduction)
* [Asset Links](#asset-links)
* [Supported links](#supported-links)
* [Developer tools](#developer-tools)
<!--- END -->
## Introduction
Element X Android supports deep linking to specific screens in the application. This document explains how to use deep links in Element X Android.
### Asset Links
The asset links file is available at https://element.io/.well-known/assetlinks.json
### Supported links
Element Call link:
> https://call.element.io/Example
Link to a user:
> https://app.element.io/#/user/@alice:matrix.org
Link to a room by id or alias:
> https://app.element.io/#/room/!roomid:matrix.org
> https://app.element.io/#/room/#element-x-android:matrix.org
Link to a room with a specific event:
> https://app.element.io/#/room/!roomid:matrix.org/$eventid
Note that it will also work with other domain such as:
> https://mobile.element.io
> https://develop.element.io
> https://staging.element.io
## Developer tools
Using an Android 12 or higher emulator
Ensure links verification is enabled
```bash
adb shell am compat enable 175408749 io.element.android.x.debug
```
Reset link verifications for the given package id
```bash
adb shell pm set-app-links --package io.element.android.x.debug 0 all
```
Force the package id links to be verified
```bash
adb shell pm verify-app-links --re-verify io.element.android.x.debug
```
Print the link verification of the package id
```bash
adb shell pm get-app-links io.element.android.x.debug
```
```
io.element.android.x.debug:
ID: e2ece472-c266-4bf0-829c-be79959a6270
Signatures: [B0:B0:51:DC:56:5C:81:2F:E1:7F:6F:3E:94:5B:4D:79:04:71:23:AB:0D:A6:12:86:76:9E:B2:94:91:97:13:0E]
Domain verification state:
*.element.io: 1024
```

View file

@ -0,0 +1,10 @@
Main changes in this version:
- Added support for opening matrix URLs inside the app and navigating to replied to messages.
- Added per-app language support for Android 13+.
- Session verification is no longer mandatory for already logged in users.
- Better log handling.
- Fixed CVE-2024-34353 / GHSA-9ggc-845v-gcgv.
- UX improvements.
Full changelog: https://github.com/element-hq/element-x-android/releases

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_settings_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string> <string name="screen_analytics_settings_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_settings_read_terms">"Du kannst alle unsere Bedingungen lesen %1$s."</string> <string name="screen_analytics_settings_read_terms">"Weitere Informationen findest du %1$s ."</string>
<string name="screen_analytics_settings_read_terms_content_link">"hier"</string> <string name="screen_analytics_settings_read_terms_content_link">"hier"</string>
<string name="screen_analytics_settings_share_data">"Analysedaten teilen"</string> <string name="screen_analytics_settings_share_data">"Analysedaten teilen"</string>
</resources> </resources>

View file

@ -43,8 +43,8 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_analytics_prompt_data_usage">"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."</string> <string name="screen_analytics_prompt_data_usage">"Wir zeichnen keine persönlichen Daten auf und erstellen keine Profile."</string>
<string name="screen_analytics_prompt_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string> <string name="screen_analytics_prompt_help_us_improve">"Teile anonyme Nutzungsdaten, um uns bei der Identifizierung von Problemen zu helfen."</string>
<string name="screen_analytics_prompt_read_terms">"Du kannst alle unsere Bedingungen lesen %1$s."</string> <string name="screen_analytics_prompt_read_terms">"Weitere Informationen findest du %1$s ."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string> <string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Du kannst diese Funktion jederzeit deaktivieren"</string> <string name="screen_analytics_prompt_settings">"Du kannst diese Funktion jederzeit deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben deine Daten nicht an Dritte weiter"</string> <string name="screen_analytics_prompt_third_party_sharing">"Wir geben deine Daten nicht an Dritte weiter"</string>

View file

@ -16,8 +16,12 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-feature android:name="android.hardware.camera" android:required="false" /> <uses-feature
<uses-feature android:name="android.hardware.microphone" android:required="false" /> android:name="android.hardware.camera"
android:required="false" />
<uses-feature
android:name="android.hardware.microphone"
android:required="false" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.CAMERA" /> <uses-permission android:name="android.permission.CAMERA" />
@ -28,24 +32,27 @@
<application> <application>
<activity <activity
android:name=".ui.ElementCallActivity" android:name=".ui.ElementCallActivity"
android:label="@string/element_call"
android:exported="true"
android:taskAffinity="io.element.android.features.call"
android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode" android:configChanges="screenSize|screenLayout|orientation|keyboardHidden|keyboard|navigation|uiMode"
android:launchMode="singleTask"> android:exported="true"
android:label="@string/element_call"
android:launchMode="singleTask"
android:taskAffinity="io.element.android.features.call">
<intent-filter android:autoVerify="true"> <intent-filter android:autoVerify="true">
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
<data android:scheme="https" /> <data android:scheme="https" />
<!-- Matching asset file: https://call.element.io/.well-known/assetlinks.json -->
<data android:host="call.element.io" /> <data android:host="call.element.io" />
</intent-filter> </intent-filter>
<!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io --> <!-- Custom scheme to handle urls from other domains in the format: element://call?url=https%3A%2F%2Felement.io -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
@ -55,6 +62,7 @@
<!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io --> <!-- Custom scheme to handle urls from other domains in the format: io.element.call:/?url=https%3A%2F%2Felement.io -->
<intent-filter> <intent-filter>
<action android:name="android.intent.action.VIEW" /> <action android:name="android.intent.action.VIEW" />
<category android:name="android.intent.category.DEFAULT" /> <category android:name="android.intent.category.DEFAULT" />
<category android:name="android.intent.category.BROWSABLE" /> <category android:name="android.intent.category.BROWSABLE" />
@ -62,7 +70,10 @@
</intent-filter> </intent-filter>
</activity> </activity>
<service android:name=".CallForegroundService" android:enabled="true" android:foregroundServiceType="mediaPlayback" /> <service
android:name=".CallForegroundService"
android:enabled="true"
android:foregroundServiceType="mediaPlayback" />
</application> </application>
</manifest> </manifest>

View file

@ -18,7 +18,6 @@ package io.element.android.features.call.utils
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.AppPreferencesStore import io.element.android.features.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.featureflag.test.InMemoryAppPreferencesStore
import io.element.android.libraries.matrix.api.MatrixClientProvider import io.element.android.libraries.matrix.api.MatrixClientProvider
import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider import io.element.android.libraries.matrix.api.widget.CallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -28,6 +27,7 @@ import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom 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.FakeCallWidgetSettingsProvider
import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver import io.element.android.libraries.matrix.test.widget.FakeWidgetDriver
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
import org.junit.Test import org.junit.Test

View file

@ -2,8 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_create_room_action_create_room">"Neuer Raum"</string> <string name="screen_create_room_action_create_room">"Neuer Raum"</string>
<string name="screen_create_room_add_people_title">"Personen einladen"</string> <string name="screen_create_room_add_people_title">"Personen einladen"</string>
<string name="screen_create_room_error_creating_room">"Beim Erstellen des Raums ist ein Fehler aufgetreten"</string> <string name="screen_create_room_error_creating_room">"Beim Erstellen des Chats ist ein Fehler aufgetreten"</string>
<string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Raum sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string> <string name="screen_create_room_private_option_description">"Die Nachrichten in diesem Chat sind verschlüsselt. Die Verschlüsselung kann nicht nachträglich deaktiviert werden."</string>
<string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string> <string name="screen_create_room_private_option_title">"Privater Raum (nur auf Einladung)"</string>
<string name="screen_create_room_public_option_description">"Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."</string> <string name="screen_create_room_public_option_description">"Die Nachrichten sind nicht verschlüsselt und können von jedem gelesen werden. Die Verschlüsselung kann zu einem späteren Zeitpunkt aktiviert werden."</string>
<string name="screen_create_room_public_option_title">"Öffentlicher Raum (für alle)"</string> <string name="screen_create_room_public_option_title">"Öffentlicher Raum (für alle)"</string>

View file

@ -18,18 +18,12 @@ package io.element.android.features.ftue.api
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
interface FtueEntryPoint : FeatureEntryPoint { interface FtueEntryPoint : FeatureEntryPoint {
fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder fun nodeBuilder(parentNode: Node, buildContext: BuildContext): NodeBuilder
interface NodeBuilder { interface NodeBuilder {
fun callback(callback: Callback): NodeBuilder
fun build(): Node fun build(): Node
} }
interface Callback : Plugin {
fun onFtueFlowFinished()
}
} }

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.matrix.api) implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui) implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem) implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.features.analytics.api) implementation(projects.features.analytics.api)
@ -60,6 +61,7 @@ dependencies {
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.libraries.permissions.impl) testImplementation(projects.libraries.permissions.impl)
testImplementation(projects.libraries.permissions.test) testImplementation(projects.libraries.permissions.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.lockscreen.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -31,11 +31,6 @@ class DefaultFtueEntryPoint @Inject constructor() : FtueEntryPoint {
val plugins = ArrayList<Plugin>() val plugins = ArrayList<Plugin>()
return object : FtueEntryPoint.NodeBuilder { return object : FtueEntryPoint.NodeBuilder {
override fun callback(callback: FtueEntryPoint.Callback): FtueEntryPoint.NodeBuilder {
plugins += callback
return this
}
override fun build(): Node { override fun build(): Node {
return parentNode.createNode<FtueFlowNode>(buildContext, plugins) return parentNode.createNode<FtueFlowNode>(buildContext, plugins)
} }

View file

@ -32,7 +32,6 @@ import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.anvilannotations.ContributesNode import io.element.android.anvilannotations.ContributesNode
import io.element.android.features.analytics.api.AnalyticsEntryPoint import io.element.android.features.analytics.api.AnalyticsEntryPoint
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode import io.element.android.features.ftue.impl.notifications.NotificationsOptInNode
import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode import io.element.android.features.ftue.impl.sessionverification.FtueSessionVerificationFlowNode
import io.element.android.features.ftue.impl.state.DefaultFtueService import io.element.android.features.ftue.impl.state.DefaultFtueService
@ -86,8 +85,6 @@ class FtueFlowNode @AssistedInject constructor(
data object LockScreenSetup : NavTarget data object LockScreenSetup : NavTarget
} }
private val callback = plugins.filterIsInstance<FtueEntryPoint.Callback>().firstOrNull()
override fun onBuilt() { override fun onBuilt() {
super.onBuilt() super.onBuilt()
@ -143,7 +140,7 @@ class FtueFlowNode @AssistedInject constructor(
} }
} }
private fun moveToNextStep() { private fun moveToNextStep() = lifecycleScope.launch {
when (ftueState.getNextStep()) { when (ftueState.getNextStep()) {
FtueStep.SessionVerification -> { FtueStep.SessionVerification -> {
backstack.newRoot(NavTarget.SessionVerification) backstack.newRoot(NavTarget.SessionVerification)
@ -157,7 +154,7 @@ class FtueFlowNode @AssistedInject constructor(
FtueStep.LockscreenSetup -> { FtueStep.LockscreenSetup -> {
backstack.newRoot(NavTarget.LockScreenSetup) backstack.newRoot(NavTarget.LockScreenSetup)
} }
null -> callback?.onFtueFlowFinished() null -> Unit
} }
} }

View file

@ -41,8 +41,8 @@ import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.ftue.impl.R import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.OnboardingBackground
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.OnboardingBackground
import io.element.android.libraries.designsystem.components.PageTitle import io.element.android.libraries.designsystem.components.PageTitle
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData

View file

@ -58,9 +58,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
@Parcelize @Parcelize
data object EnterRecoveryKey : NavTarget data object EnterRecoveryKey : NavTarget
@Parcelize
data object CreateNewRecoveryKey : NavTarget
} }
interface Callback : Plugin { interface Callback : Plugin {
@ -68,10 +65,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
} }
private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback { private val secureBackupEntryPointCallback = object : SecureBackupEntryPoint.Callback {
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() { override fun onDone() {
lifecycleScope.launch { lifecycleScope.launch {
// Move to the completed state view in the verification flow // Move to the completed state view in the verification flow
@ -89,10 +82,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
backstack.push(NavTarget.EnterRecoveryKey) backstack.push(NavTarget.EnterRecoveryKey)
} }
override fun onCreateNewRecoveryKey() {
backstack.push(NavTarget.CreateNewRecoveryKey)
}
override fun onDone() { override fun onDone() {
plugins<Callback>().forEach { it.onDone() } plugins<Callback>().forEach { it.onDone() }
} }
@ -105,12 +94,6 @@ class FtueSessionVerificationFlowNode @AssistedInject constructor(
.callback(secureBackupEntryPointCallback) .callback(secureBackupEntryPointCallback)
.build() .build()
} }
is NavTarget.CreateNewRecoveryKey -> {
secureBackupEntryPoint.nodeBuilder(this, buildContext)
.params(SecureBackupEntryPoint.Params(SecureBackupEntryPoint.InitialTarget.CreateNewRecoveryKey))
.callback(secureBackupEntryPointCallback)
.build()
}
} }
} }

View file

@ -23,18 +23,26 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.lockscreen.api.LockScreenService import io.element.android.features.lockscreen.api.LockScreenService
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.verification.SessionVerificationService import io.element.android.libraries.matrix.api.verification.SessionVerificationService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.permissions.api.PermissionStateProvider import io.element.android.libraries.permissions.api.PermissionStateProvider
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.timeout
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import timber.log.Timber
import javax.inject.Inject import javax.inject.Inject
import kotlin.time.Duration.Companion.seconds
@ContributesBinding(SessionScope::class) @ContributesBinding(SessionScope::class)
class DefaultFtueService @Inject constructor( class DefaultFtueService @Inject constructor(
@ -44,6 +52,7 @@ class DefaultFtueService @Inject constructor(
private val permissionStateProvider: PermissionStateProvider, private val permissionStateProvider: PermissionStateProvider,
private val lockScreenService: LockScreenService, private val lockScreenService: LockScreenService,
private val sessionVerificationService: SessionVerificationService, private val sessionVerificationService: SessionVerificationService,
private val sessionPreferencesStore: SessionPreferencesStore,
) : FtueService { ) : FtueService {
override val state = MutableStateFlow<FtueState>(FtueState.Unknown) override val state = MutableStateFlow<FtueState>(FtueState.Unknown)
@ -55,7 +64,7 @@ class DefaultFtueService @Inject constructor(
} }
init { init {
sessionVerificationService.needsVerificationFlow sessionVerificationService.sessionVerifiedStatus
.onEach { updateState() } .onEach { updateState() }
.launchIn(coroutineScope) .launchIn(coroutineScope)
@ -64,7 +73,7 @@ class DefaultFtueService @Inject constructor(
.launchIn(coroutineScope) .launchIn(coroutineScope)
} }
fun getNextStep(currentStep: FtueStep? = null): FtueStep? = suspend fun getNextStep(currentStep: FtueStep? = null): FtueStep? =
when (currentStep) { when (currentStep) {
null -> if (isSessionNotVerified()) { null -> if (isSessionNotVerified()) {
FtueStep.SessionVerification FtueStep.SessionVerification
@ -89,8 +98,8 @@ class DefaultFtueService @Inject constructor(
FtueStep.AnalyticsOptIn -> null FtueStep.AnalyticsOptIn -> null
} }
private fun isAnyStepIncomplete(): Boolean { private suspend fun isAnyStepIncomplete(): Boolean {
return listOf( return listOf<suspend () -> Boolean>(
{ isSessionNotVerified() }, { isSessionNotVerified() },
{ shouldAskNotificationPermissions() }, { shouldAskNotificationPermissions() },
{ needsAnalyticsOptIn() }, { needsAnalyticsOptIn() },
@ -98,16 +107,28 @@ class DefaultFtueService @Inject constructor(
).any { it() } ).any { it() }
} }
private fun isSessionNotVerified(): Boolean { @OptIn(FlowPreview::class)
return sessionVerificationService.needsVerificationFlow.value private suspend fun isSessionNotVerified(): Boolean {
// Wait for the first known (or ready) verification status
val readyVerifiedSessionStatus = sessionVerificationService.sessionVerifiedStatus
.filter { it != SessionVerifiedStatus.Unknown }
// This is not ideal, but there are some very rare cases when reading the flow seems to get stuck
.timeout(5.seconds)
.catch {
Timber.e(it, "Failed to get session verification status, assume it's not verified")
emit(SessionVerifiedStatus.NotVerified)
}
.first()
val skipVerification = suspend { sessionPreferencesStore.isSessionVerificationSkipped().first() }
return readyVerifiedSessionStatus == SessionVerifiedStatus.NotVerified && !skipVerification()
} }
private fun needsAnalyticsOptIn(): Boolean { private suspend fun needsAnalyticsOptIn(): Boolean {
// We need this function to not be suspend, so we need to load the value through runBlocking // We need this function to not be suspend, so we need to load the value through runBlocking
return runBlocking { analyticsService.didAskUserConsent().first().not() } return analyticsService.didAskUserConsent().first().not()
} }
private fun shouldAskNotificationPermissions(): Boolean { private suspend fun shouldAskNotificationPermissions(): Boolean {
return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) { return if (sdkVersionProvider.isAtLeast(Build.VERSION_CODES.TIRAMISU)) {
val permission = Manifest.permission.POST_NOTIFICATIONS val permission = Manifest.permission.POST_NOTIFICATIONS
val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() } val isPermissionDenied = runBlocking { permissionStateProvider.isPermissionDenied(permission).first() }
@ -118,14 +139,12 @@ class DefaultFtueService @Inject constructor(
} }
} }
private fun shouldDisplayLockscreenSetup(): Boolean { private suspend fun shouldDisplayLockscreenSetup(): Boolean {
return runBlocking { return lockScreenService.isSetupRequired().first()
lockScreenService.isSetupRequired().first()
}
} }
@VisibleForTesting(otherwise = VisibleForTesting.PRIVATE) @VisibleForTesting(otherwise = VisibleForTesting.PRIVATE)
internal fun updateState() { internal suspend fun updateState() {
state.value = when { state.value = when {
isAnyStepIncomplete() -> FtueState.Incomplete isAnyStepIncomplete() -> FtueState.Incomplete
else -> FtueState.Complete else -> FtueState.Complete

View file

@ -2,27 +2,26 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string> <string name="screen_notification_optin_subtitle">"Вы можаце змяніць налады пазней."</string>
<string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string> <string name="screen_notification_optin_title">"Дазвольце апавяшчэнні і ніколі не прапускайце іх"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Устанаўленне злучэння"</string> <string name="screen_qr_code_login_connecting_subtitle">"Ўсталяванне бяспечнага злучэння"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"Не атрымалася ўсталяваць бяспечнае злучэнне з новай прыладай. Існуючыя прылады па-ранейшаму ў бяспецы, і вам не трэба турбавацца пра іх."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Што зараз?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Што зараз?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Паспрабуйце зноў увайсці ў сістэму з дапамогай QR-кода, калі гэта была сеткавая праблема"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi."</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Калі вы сутыкнуліся з той жа праблемай, паспрабуйце іншую сетку Wi-Fi або скарыстайцеся мабільнымі дадзенымі замест Wi-Fi."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Калі гэта не дапамагло, увайдзіце ўручную"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Калі гэта не дапамагло, увайдзіце ўручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Злучэнне небяспечнае"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Злучэнне небяспечнае"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Вам будзе прапанавана ўвесці дзве лічбы, паказаныя ніжэй."</string> <string name="screen_qr_code_login_device_code_subtitle">"Вам будзе прапанавана ўвесці дзве лічбы, паказаныя на гэтай прыладзе."</string>
<string name="screen_qr_code_login_device_code_title">"Увядзіце нумар на прыладзе"</string> <string name="screen_qr_code_login_device_code_title">"Увядзіце наступны нумар на іншай прыладзе."</string>
<string name="screen_qr_code_login_initial_state_item_1">"Адкрыйце %1$s на настольнай прыладзе"</string> <string name="screen_qr_code_login_initial_state_item_1">"Адкрыйце %1$s на настольнай прыладзе"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Націсніце на свой аватар"</string> <string name="screen_qr_code_login_initial_state_item_2">"Націсніце на свой аватар"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Выберыце %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Звязаць новую прыладу”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выберыце %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Выконвайце паказаныя інструкцыі"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Паказаць QR-код”"</string>
<string name="screen_qr_code_login_initial_state_title">"Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код"</string> <string name="screen_qr_code_login_initial_state_title">"Адкрыйце %1$s на іншай прыладзе, каб атрымаць QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Выкарыстоўвайце QR-код, паказаны на іншай прыладзе."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Паўтарыць спробу"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Няправільны QR-код"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Няправільны QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перайсці ў налады камеры"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Перайсці ў налады камеры"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць Element выкарыстоўваць камеру вашай прылады."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"Каб працягнуць, вам неабходна дазволіць %1$s выкарыстоўваць камеру вашай прылады."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Дазвольце доступ да камеры для сканавання QR-кода"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Дазвольце доступ да камеры для сканавання QR-кода"</string>
<string name="screen_qr_code_login_scanning_state_title">"Сканаваць QR-код"</string> <string name="screen_qr_code_login_scanning_state_title">"Сканаваць QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Пачаць спачатку"</string> <string name="screen_qr_code_login_start_over_button">"Пачаць спачатку"</string>

View file

@ -2,7 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Nastavení můžete později změnit."</string> <string name="screen_notification_optin_subtitle">"Nastavení můžete později změnit."</string>
<string name="screen_notification_optin_title">"Povolte oznámení a nezmeškejte žádnou zprávu"</string> <string name="screen_notification_optin_title">"Povolte oznámení a nezmeškejte žádnou zprávu"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Navazování spojení"</string> <string name="screen_qr_code_login_connecting_subtitle">"Navazování zabezpečeného spojení"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zařízení se nepodařilo navázat bezpečné připojení. Vaše stávající zařízení jsou stále v bezpečí a nemusíte se o ně obávat."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Co teď?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Co teď?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Zkuste se znovu přihlásit pomocí QR kódu v případě, že se jednalo o problém se sítí"</string>
@ -10,19 +10,18 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Pokud to nefunguje, přihlaste se ručně"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Pokud to nefunguje, přihlaste se ručně"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Připojení není zabezpečené"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Připojení není zabezpečené"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Budete požádáni o zadání dvou níže uvedených číslic."</string> <string name="screen_qr_code_login_device_code_subtitle">"Budete požádáni o zadání dvou níže uvedených číslic."</string>
<string name="screen_qr_code_login_device_code_title">"Zadejte číslo na svém zařízení"</string> <string name="screen_qr_code_login_device_code_title">"Zadejte níže uvedené číslo na svém dalším zařízení"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Otevřete %1$s na stolním počítači"</string> <string name="screen_qr_code_login_initial_state_item_1">"Otevřete %1$s na stolním počítači"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klikněte na svůj avatar"</string> <string name="screen_qr_code_login_initial_state_item_2">"Klikněte na svůj avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Vybrat %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Vybrat %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Připojit nové zařízení\""</string> <string name="screen_qr_code_login_initial_state_item_3_action">"\"Připojit nové zařízení\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Vybrat %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Postupujte podle uvedených pokynů"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"\"Zobrazit QR kód\""</string>
<string name="screen_qr_code_login_initial_state_title">"Otevřete %1$s na jiném zařízení pro získání QR kódu"</string> <string name="screen_qr_code_login_initial_state_title">"Otevřete %1$s na jiném zařízení pro získání QR kódu"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Použijte QR kód zobrazený na druhém zařízení."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Použijte QR kód zobrazený na druhém zařízení."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Zkusit znovu"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Špatný QR kód"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Špatný QR kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Přejděte na nastavení fotoaparátu"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Přejděte na nastavení fotoaparátu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci Element udělit povolení k použití kamery vašeho zařízení."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"Abyste mohli pokračovat, musíte aplikaci %1$s udělit povolení k použití kamery vašeho zařízení."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povolte přístup k fotoaparátu a naskenujte QR kód"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Povolte přístup k fotoaparátu a naskenujte QR kód"</string>
<string name="screen_qr_code_login_scanning_state_title">"Naskenujte QR kód"</string> <string name="screen_qr_code_login_scanning_state_title">"Naskenujte QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začít znovu"</string> <string name="screen_qr_code_login_start_over_button">"Začít znovu"</string>

View file

@ -2,29 +2,33 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Du kannst deine Einstellungen später ändern."</string> <string name="screen_notification_optin_subtitle">"Du kannst deine Einstellungen später ändern."</string>
<string name="screen_notification_optin_title">"Erlaube Benachrichtigungen und verpasse keine Nachricht"</string> <string name="screen_notification_optin_title">"Erlaube Benachrichtigungen und verpasse keine Nachricht"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Verbindung aufbauen"</string> <string name="screen_qr_code_login_connecting_subtitle">"Sichere Verbindung aufbauen"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"Es konnte keine sichere Verbindung zu dem neuen Gerät hergestellt werden."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Und jetzt?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Und jetzt?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war."</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Versuche, dich erneut mit einem QR-Code anzumelden, falls dies ein Netzwerkproblem war."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Wenn das Problem bestehen bleibt, versuche es mit einem anderen WLAN-Netzwerk oder verwende deine mobilen Daten statt WLAN."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Wenn das nicht funktioniert, melde dich manuell an"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Wenn das nicht funktioniert, melde dich manuell an"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Die Verbindung ist nicht sicher"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Die Verbindung ist nicht sicher"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Du wirst aufgefordert, die beiden unten abgebildeten Ziffern einzugeben."</string>
<string name="screen_qr_code_login_device_code_title">"Trage die unten angezeigte Zahl auf einem anderen Device ein"</string>
<string name="screen_qr_code_login_initial_state_item_1">"%1$s auf einem Desktop-Gerät öffnen"</string> <string name="screen_qr_code_login_initial_state_item_1">"%1$s auf einem Desktop-Gerät öffnen"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klick auf deinen Avatar"</string> <string name="screen_qr_code_login_initial_state_item_2">"Klick auf deinen Avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Wähle %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Neues Gerät verknüpfen\""</string> <string name="screen_qr_code_login_initial_state_item_3_action">"\"Neues Gerät verknüpfen\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Wähle %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Befolge die angezeigten Anweisungen"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"\"QR-Code anzeigen\""</string>
<string name="screen_qr_code_login_initial_state_title">"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"</string> <string name="screen_qr_code_login_initial_state_title">"Öffne %1$s auf einem anderen Gerät, um den QR-Code zu erhalten"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Verwende den QR-Code, der auf dem anderen Gerät angezeigt wird."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Erneut versuchen"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Falscher QR-Code"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Falscher QR-Code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Gehe zu den Kameraeinstellungen"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Gehe zu den Kameraeinstellungen"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst Element die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"Du musst %1$s die Erlaubnis erteilen, die Kamera deines Geräts zu verwenden, um fortzufahren."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Erlaube Zugriff auf die Kamera zum Scannen des QR-Codes"</string>
<string name="screen_qr_code_login_scanning_state_title">"QR-Code scannen"</string> <string name="screen_qr_code_login_scanning_state_title">"QR-Code scannen"</string>
<string name="screen_qr_code_login_start_over_button">"Neu beginnen"</string> <string name="screen_qr_code_login_start_over_button">"Neu beginnen"</string>
<string name="screen_qr_code_login_unknown_error_description">"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."</string> <string name="screen_qr_code_login_unknown_error_description">"Ein unerwarteter Fehler ist aufgetreten. Bitte versuche es erneut."</string>
<string name="screen_qr_code_login_verify_code_loading">"Warten auf dein anderes Gerät"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Dein Account-Provider kann nach dem folgenden Code fragen, um die Anmeldung zu bestätigen."</string>
<string name="screen_qr_code_login_verify_code_title">"Dein Verifizierungscode"</string>
<string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."</string> <string name="screen_welcome_bullet_1">"Anrufe, Umfragen, Suchfunktionen und mehr werden im Laufe des Jahres hinzugefügt."</string>
<string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string> <string name="screen_welcome_bullet_2">"Der Nachrichtenverlauf für verschlüsselte Räume wird in diesem Update nicht verfügbar sein."</string>
<string name="screen_welcome_bullet_3">"Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit."</string> <string name="screen_welcome_bullet_3">"Wir würden uns freuen, von dir zu hören. Teile uns deine Meinung über die Einstellungsseite mit."</string>

View file

@ -2,18 +2,33 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Vous pourrez modifier vos paramètres ultérieurement."</string> <string name="screen_notification_optin_subtitle">"Vous pourrez modifier vos paramètres ultérieurement."</string>
<string name="screen_notification_optin_title">"Autorisez les notifications et ne manquez aucun message"</string> <string name="screen_notification_optin_title">"Autorisez les notifications et ne manquez aucun message"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Établissement de la connexion"</string> <string name="screen_qr_code_login_connecting_subtitle">"Établissement dune connexion sécurisée"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Aucune connexion sécurisée na pu être établie avec la nouvelle session. Vos sessions existantes sont toujours en sécurité et vous navez pas à vous en soucier."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Et maintenant ?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Essayez de vous connecter à nouveau à laide du code QR au cas où il sagirait dun problème réseau"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Si vous rencontrez le même problème, essayez un autre réseau wifi ou utilisez vos données mobiles au lieu du wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Si cela ne fonctionne pas, connectez-vous manuellement"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"La connexion nest pas sécurisée"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Il vous sera demandé de saisir les deux chiffres affichés sur cet appareil."</string>
<string name="screen_qr_code_login_device_code_title">"Saisissez le nombre ci-dessous sur votre autre appareil"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Ouvrez %1$s sur un ordinateur"</string> <string name="screen_qr_code_login_initial_state_item_1">"Ouvrez %1$s sur un ordinateur"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Cliquez sur votre image de profil"</string> <string name="screen_qr_code_login_initial_state_item_2">"Cliquez sur votre image de profil"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Choisissez %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Choisissez %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Associer une nouvelle session”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Associer une nouvelle session”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Choisissez %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Suivez les instructions affichées"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Afficher le QR code”"</string>
<string name="screen_qr_code_login_initial_state_title">"Ouvrez %1$s sur un autre appareil pour obtenir le QR code"</string> <string name="screen_qr_code_login_initial_state_title">"Ouvrez %1$s sur un autre appareil pour obtenir le QR code"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Scannez le QR code affiché sur lautre appareil."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Scannez le QR code affiché sur lautre appareil."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Essayer à nouveau"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Essayer à nouveau"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"QR code erroné"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"QR code erroné"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Accéder aux paramètres de lappareil photo"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Vous devez autoriser %1$s à utiliser la camera de votre appareil pour continuer."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Autoriser lusage de la caméra pour scanner le code QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Scannez le QR code"</string> <string name="screen_qr_code_login_scanning_state_title">"Scannez le QR code"</string>
<string name="screen_qr_code_login_start_over_button">"Recommencer"</string>
<string name="screen_qr_code_login_unknown_error_description">"Une erreur inattendue sest produite. Veuillez réessayer."</string>
<string name="screen_qr_code_login_verify_code_loading">"En attente de votre autre session"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Votre fournisseur de compte peut vous demander le code suivant pour vérifier la connexion."</string>
<string name="screen_qr_code_login_verify_code_title">"Votre code de vérification"</string>
<string name="screen_welcome_bullet_1">"Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année."</string> <string name="screen_welcome_bullet_1">"Les appels, les sondages, les recherches et plus encore seront ajoutés plus tard cette année."</string>
<string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string> <string name="screen_welcome_bullet_2">"Lhistorique des messages pour les salons chiffrés ne sera pas disponible dans cette mise à jour."</string>
<string name="screen_welcome_bullet_3">"Nhésitez pas à nous faire part de vos commentaires via lécran des paramètres."</string> <string name="screen_welcome_bullet_3">"Nhésitez pas à nous faire part de vos commentaires via lécran des paramètres."</string>

View file

@ -2,29 +2,33 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"A beállításokat később is módosíthatja."</string> <string name="screen_notification_optin_subtitle">"A beállításokat később is módosíthatja."</string>
<string name="screen_notification_optin_title">"Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem"</string> <string name="screen_notification_optin_title">"Értesítések engedélyezése, hogy soha ne maradjon le egyetlen üzenetről sem"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Kapcsolat létesítése"</string> <string name="screen_qr_code_login_connecting_subtitle">"Biztonságos kapcsolat létesítése"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"Nem sikerült biztonságos kapcsolatot létesíteni az új eszközzel. A meglévő eszközei továbbra is biztonságban vannak, és nem kell aggódnia miattuk."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Most mi lesz?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Most mi lesz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Próbáljon meg újra bejelentkezni egy QR-kóddal, ha ez hálózati probléma volt."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ha ugyanezzel a problémával találkozik, próbálkozzon másik Wi-Fi-hálózattal, vagy a Wi-Fi helyett használja a mobil-adatkapcsolatát"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ha ez nem működik, jelentkezzen be kézileg"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ha ez nem működik, jelentkezzen be kézileg"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"A kapcsolat nem biztonságos"</string>
<string name="screen_qr_code_login_device_code_subtitle">"A rendszer kérni fogja, hogy adja meg az alábbi két számjegyet az eszközén."</string>
<string name="screen_qr_code_login_device_code_title">"Adja meg az alábbi számot a másik eszközén"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Nyissa meg az %1$set egy asztali eszközön"</string> <string name="screen_qr_code_login_initial_state_item_1">"Nyissa meg az %1$set egy asztali eszközön"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Kattintson a profilképére"</string> <string name="screen_qr_code_login_initial_state_item_2">"Kattintson a profilképére"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Válassza ezt: %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Új eszköz összekapcsolása”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"„Új eszköz összekapcsolása”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Válassza ezt: %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Kövesse a látható utasításokat"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"„QR-kód megjelenítése”"</string>
<string name="screen_qr_code_login_initial_state_title">"Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez."</string> <string name="screen_qr_code_login_initial_state_title">"Nyissa meg az %1$set egy másik eszközön a QR-kód lekéréséhez."</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Használja a másik eszközön látható QR-kódot."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Használja a másik eszközön látható QR-kódot."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Próbálja újra"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Hibás QR-kód"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Hibás QR-kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Ugrás a kamerabeállításokhoz"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Ugrás a kamerabeállításokhoz"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az Element használhassa az eszköz kameráját."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"A folytatáshoz engedélyeznie kell, hogy az %1$s használhassa az eszköz kameráját."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Engedélyezze a kamera elérését a QR-kód beolvasásához"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Engedélyezze a kamera elérését a QR-kód beolvasásához"</string>
<string name="screen_qr_code_login_scanning_state_title">"Olvassa be a QR-kódot"</string> <string name="screen_qr_code_login_scanning_state_title">"Olvassa be a QR-kódot"</string>
<string name="screen_qr_code_login_start_over_button">"Újrakezdés"</string> <string name="screen_qr_code_login_start_over_button">"Újrakezdés"</string>
<string name="screen_qr_code_login_unknown_error_description">"Váratlan hiba történt. Próbálja meg újra."</string> <string name="screen_qr_code_login_unknown_error_description">"Váratlan hiba történt. Próbálja meg újra."</string>
<string name="screen_qr_code_login_verify_code_loading">"Várakozás a másik eszközre"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"A fiókszolgáltatója kérheti a következő kódot a bejelentkezése ellenőrzéséhez."</string>
<string name="screen_qr_code_login_verify_code_title">"Az Ön ellenőrzőkódja"</string>
<string name="screen_welcome_bullet_1">"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."</string> <string name="screen_welcome_bullet_1">"A hívások, szavazások, keresések és egyebek az év további részében kerülnek hozzáadásra."</string>
<string name="screen_welcome_bullet_2">"A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben."</string> <string name="screen_welcome_bullet_2">"A titkosított szobák üzenetelőzményei nem lesznek elérhetők ebben a frissítésben."</string>
<string name="screen_welcome_bullet_3">"Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon."</string> <string name="screen_welcome_bullet_3">"Szeretnénk hallani a véleményét, ossza meg velünk a beállítások oldalon."</string>

View file

@ -3,17 +3,31 @@
<string name="screen_notification_optin_subtitle">"Anda dapat mengubah pengaturan Anda nanti."</string> <string name="screen_notification_optin_subtitle">"Anda dapat mengubah pengaturan Anda nanti."</string>
<string name="screen_notification_optin_title">"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"</string> <string name="screen_notification_optin_title">"Izinkan pemberitahuan dan jangan pernah melewatkan pesan"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Membuat koneksi"</string> <string name="screen_qr_code_login_connecting_subtitle">"Membuat koneksi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"Koneksi aman tidak dapat dibuat ke perangkat baru. Perangkat Anda yang ada masih aman dan Anda tidak perlu khawatir tentang mereka."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Apa sekarang?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Coba masuk lagi dengan kode QR jika ini adalah masalah jaringan"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Jika Anda mengalami masalah yang sama, coba jaringan Wi-Fi yang berbeda atau gunakan data seluler Anda daripada Wi-Fi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Jika tidak berhasil, masuk secara manual"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Koneksi tidak aman"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Anda akan diminta untuk memasukkan dua digit yang ditunjukkan di bawah ini."</string>
<string name="screen_qr_code_login_device_code_title">"Masukkan nomor di perangkat Anda"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Buka %1$s di perangkat desktop"</string> <string name="screen_qr_code_login_initial_state_item_1">"Buka %1$s di perangkat desktop"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klik pada avatar Anda"</string> <string name="screen_qr_code_login_initial_state_item_2">"Klik pada avatar Anda"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Pilih %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Tautkan perangkat baru”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Tautkan perangkat baru”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Pilih %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Tampilkan kode QR”"</string>
<string name="screen_qr_code_login_initial_state_title">"Buka %1$s di perangkat lain untuk mendapatkan kode QR"</string> <string name="screen_qr_code_login_initial_state_title">"Buka %1$s di perangkat lain untuk mendapatkan kode QR"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Gunakan kode QR yang ditampilkan di perangkat lain."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Coba lagi"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Kode QR salah"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Kode QR salah"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Pergi ke pengaturan kamera"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Anda perlu memberikan izin ke %1$s untuk menggunakan kamera perangkat Anda untuk melanjutkan."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Izinkan akses kamera untuk memindai kode QR"</string>
<string name="screen_qr_code_login_scanning_state_title">"Pindai kode QR"</string> <string name="screen_qr_code_login_scanning_state_title">"Pindai kode QR"</string>
<string name="screen_qr_code_login_start_over_button">"Mulai dari awal"</string>
<string name="screen_qr_code_login_unknown_error_description">"Terjadi kesalahan tak terduga. Silakan coba lagi."</string>
<string name="screen_qr_code_login_verify_code_loading">"Menunggu perangkat Anda yang lain"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Penyedia akun Anda mungkin meminta kode berikut untuk memverifikasi proses masuk."</string>
<string name="screen_qr_code_login_verify_code_title">"Kode verifikasi Anda"</string>
<string name="screen_welcome_bullet_1">"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."</string> <string name="screen_welcome_bullet_1">"Panggilan, pemungutan suara, pencarian, dan lainnya akan ditambahkan di tahun ini."</string>
<string name="screen_welcome_bullet_2">"Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini."</string> <string name="screen_welcome_bullet_2">"Riwayat pesan untuk ruangan terenkripsi tidak akan tersedia dalam pembaruan ini."</string>
<string name="screen_welcome_bullet_3">"Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan."</string> <string name="screen_welcome_bullet_3">"Kami ingin mendengar dari Anda, beri tahu kami pendapat Anda melalui halaman pengaturan."</string>

View file

@ -9,22 +9,25 @@
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Если вы столкнулись с той же проблемой, попробуйте сменить точку доступа Wi-Fi или используйте мобильные данные"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Если это не помогло, войдите вручную"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Соединение не защищено"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Вам будет предложено ввести две цифры, показанные ниже."</string>
<string name="screen_qr_code_login_device_code_title">"Введите номер на своем устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на настольном устройстве"</string> <string name="screen_qr_code_login_initial_state_item_1">"Откройте %1$s на настольном устройстве"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string> <string name="screen_qr_code_login_initial_state_item_2">"Нажмите на свое изображение"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string> <string name="screen_qr_code_login_initial_state_item_3_action">"\"Привязать новое устройство\""</string>
<string name="screen_qr_code_login_initial_state_item_4">"Выбрать %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"\"Показать QR-код\""</string>
<string name="screen_qr_code_login_initial_state_title">"Откройте %1$s на другом устройстве, чтобы получить QR-код"</string> <string name="screen_qr_code_login_initial_state_title">"Откройте %1$s на другом устройстве, чтобы получить QR-код"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Используйте QR-код, показанный на другом устройстве."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Используйте QR-код, показанный на другом устройстве."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Повторить попытку"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неверный QR-код"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Неверный QR-код"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Перейдите в настройки камеры"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Перейдите в настройки камеры"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить Element использовать камеру вашего устройства."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"Чтобы продолжить, вам необходимо разрешить %1$s использовать камеру вашего устройства."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Разрешите доступ к камере для сканирования QR-кода"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Разрешите доступ к камере для сканирования QR-кода"</string>
<string name="screen_qr_code_login_scanning_state_title">"Сканировать QR-код"</string> <string name="screen_qr_code_login_scanning_state_title">"Сканировать QR-код"</string>
<string name="screen_qr_code_login_start_over_button">"Начать заново"</string> <string name="screen_qr_code_login_start_over_button">"Начать заново"</string>
<string name="screen_qr_code_login_unknown_error_description">"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."</string> <string name="screen_qr_code_login_unknown_error_description">"Произошла непредвиденная ошибка. Пожалуйста, попробуйте еще раз."</string>
<string name="screen_qr_code_login_verify_code_loading">"В ожидании другого устройства"</string>
<string name="screen_qr_code_login_verify_code_subtitle">"Поставщик учетной записи может запросить следующий код для подтверждения входа."</string>
<string name="screen_qr_code_login_verify_code_title">"Ваш код подтверждения"</string>
<string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string> <string name="screen_welcome_bullet_1">"Звонки, опросы, поиск и многое другое будут добавлены позже в этом году."</string>
<string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string> <string name="screen_welcome_bullet_2">"История сообщений для зашифрованных комнат в этом обновлении будет недоступна."</string>
<string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string> <string name="screen_welcome_bullet_3">"Мы будем рады услышать ваше мнение, сообщите нам об этом через страницу настроек."</string>

View file

@ -2,27 +2,26 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Svoje nastavenia môžete neskôr zmeniť."</string> <string name="screen_notification_optin_subtitle">"Svoje nastavenia môžete neskôr zmeniť."</string>
<string name="screen_notification_optin_title">"Povoľte oznámenia a nikdy nezmeškajte žiadnu správu"</string> <string name="screen_notification_optin_title">"Povoľte oznámenia a nikdy nezmeškajte žiadnu správu"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Nadväzovanie spojenia"</string> <string name="screen_qr_code_login_connecting_subtitle">"Nadväzovanie bezpečného spojenia"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"K novému zariadeniu sa nepodarilo vytvoriť bezpečné pripojenie. Vaše existujúce zariadenia sú stále v bezpečí a nemusíte sa o ne obávať."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"Čo teraz?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"Čo teraz?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Skúste sa znova prihlásiť pomocou QR kódu v prípade, že ide o problém so sieťou"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"Ak narazíte na rovnaký problém, vyskúšajte inú sieť Wi-Fi alebo namiesto siete Wi-Fi použite mobilné dáta"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ak to nefunguje, prihláste sa manuálne"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"Ak to nefunguje, prihláste sa manuálne"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Pripojenie nie je bezpečené"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Pripojenie nie je bezpečené"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Budete vyzvaní na zadanie dvoch číslic uvedených nižšie."</string> <string name="screen_qr_code_login_device_code_subtitle">"Budete požiadaní o zadanie dvoch číslic zobrazených na tomto zariadení."</string>
<string name="screen_qr_code_login_device_code_title">"Zadajte číslo na svojom zariadení"</string> <string name="screen_qr_code_login_device_code_title">"Zadajte nižšie uvedené číslo na vašom druhom zariadení"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Otvorte %1$s na stolnom zariadení"</string> <string name="screen_qr_code_login_initial_state_item_1">"Otvorte %1$s na stolnom zariadení"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Kliknite na svoj obrázok"</string> <string name="screen_qr_code_login_initial_state_item_2">"Kliknite na svoj obrázok"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Vyberte %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Vyberte %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"„Prepojiť nové zariadenie“"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"„Prepojiť nové zariadenie“"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Vyberte %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Postupujte podľa zobrazených pokynov"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"„Zobraziť QR kód“"</string>
<string name="screen_qr_code_login_initial_state_title">"Ak chcete získať QR kód, otvorte %1$s na inom zariadení"</string> <string name="screen_qr_code_login_initial_state_title">"Ak chcete získať QR kód, otvorte %1$s na inom zariadení"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Použite QR kód zobrazený na druhom zariadení."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Použite QR kód zobrazený na druhom zariadení."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Skúste to znova"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Nesprávny QR kód"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Nesprávny QR kód"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Prejsť na nastavenia fotoaparátu"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Prejsť na nastavenia fotoaparátu"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii Element používať fotoaparát vášho zariadenia."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"Ak chcete pokračovať, musíte udeliť povolenie aplikácii %1$s používať fotoaparát vášho zariadenia."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Povoľte prístup k fotoaparátu na naskenovanie QR kódu"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Povoľte prístup k fotoaparátu na naskenovanie QR kódu"</string>
<string name="screen_qr_code_login_scanning_state_title">"Naskenovať QR kód"</string> <string name="screen_qr_code_login_scanning_state_title">"Naskenovať QR kód"</string>
<string name="screen_qr_code_login_start_over_button">"Začať odznova"</string> <string name="screen_qr_code_login_start_over_button">"Začať odznova"</string>

View file

@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"Du kan ändra dina inställningar senare."</string> <string name="screen_notification_optin_subtitle">"Du kan ändra dina inställningar senare."</string>
<string name="screen_notification_optin_title">"Tillåt aviseringar och missa aldrig ett meddelande"</string> <string name="screen_notification_optin_title">"Tillåt aviseringar och missa aldrig ett meddelande"</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Försök igen"</string>
<string name="screen_welcome_bullet_1">"Samtal, omröstningar, sökning och mer kommer att läggas till senare i år."</string> <string name="screen_welcome_bullet_1">"Samtal, omröstningar, sökning och mer kommer att läggas till senare i år."</string>
<string name="screen_welcome_bullet_2">"Meddelandehistorik för krypterade rum är inte tillgänglig än."</string> <string name="screen_welcome_bullet_2">"Meddelandehistorik för krypterade rum är inte tillgänglig än."</string>
<string name="screen_welcome_bullet_3">"Vi vill gärna höra från dig, låt oss veta vad du tycker via inställningssidan."</string> <string name="screen_welcome_bullet_3">"Vi vill gärna höra från dig, låt oss veta vad du tycker via inställningssidan."</string>

View file

@ -2,27 +2,26 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_notification_optin_subtitle">"You can change your settings later."</string> <string name="screen_notification_optin_subtitle">"You can change your settings later."</string>
<string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string> <string name="screen_notification_optin_title">"Allow notifications and never miss a message"</string>
<string name="screen_qr_code_login_connecting_subtitle">"Establishing connection"</string> <string name="screen_qr_code_login_connecting_subtitle">"Establishing a secure connection"</string>
<string name="screen_qr_code_login_connection_note_secure_state_description">"A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."</string> <string name="screen_qr_code_login_connection_note_secure_state_description">"A secure connection could not be made to the new device. Your existing devices are still safe and you don\'t need to worry about them."</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_header">"What now?"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_header">"What now?"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Try signing in again with a QR code in case this was a network problem"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_1">"Try signing in again with a QR code in case this was a network problem"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_2">"If you encounter the same problem, try a different wifi network or use your mobile data instead of wifi"</string>
<string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"If that doesnt work, sign in manually"</string> <string name="screen_qr_code_login_connection_note_secure_state_list_item_3">"If that doesnt work, sign in manually"</string>
<string name="screen_qr_code_login_connection_note_secure_state_title">"Connection not secure"</string> <string name="screen_qr_code_login_connection_note_secure_state_title">"Connection not secure"</string>
<string name="screen_qr_code_login_device_code_subtitle">"Youll be asked to enter the two digits shown below."</string> <string name="screen_qr_code_login_device_code_subtitle">"Youll be asked to enter the two digits shown on this device."</string>
<string name="screen_qr_code_login_device_code_title">"Enter number on your device"</string> <string name="screen_qr_code_login_device_code_title">"Enter the number below on your other device"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Open %1$s on a desktop device"</string> <string name="screen_qr_code_login_initial_state_item_1">"Open %1$s on a desktop device"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string> <string name="screen_qr_code_login_initial_state_item_2">"Click on your avatar"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string> <string name="screen_qr_code_login_initial_state_item_3">"Select %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string> <string name="screen_qr_code_login_initial_state_item_3_action">"“Link new device”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Select %1$s"</string> <string name="screen_qr_code_login_initial_state_item_4">"Follow the instructions shown"</string>
<string name="screen_qr_code_login_initial_state_item_4_action">"“Show QR code”"</string>
<string name="screen_qr_code_login_initial_state_title">"Open %1$s on another device to get the QR code"</string> <string name="screen_qr_code_login_initial_state_title">"Open %1$s on another device to get the QR code"</string>
<string name="screen_qr_code_login_invalid_scan_state_description">"Use the QR code shown on the other device."</string> <string name="screen_qr_code_login_invalid_scan_state_description">"Use the QR code shown on the other device."</string>
<string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string> <string name="screen_qr_code_login_invalid_scan_state_retry_button">"Try again"</string>
<string name="screen_qr_code_login_invalid_scan_state_subtitle">"Wrong QR code"</string> <string name="screen_qr_code_login_invalid_scan_state_subtitle">"Wrong QR code"</string>
<string name="screen_qr_code_login_no_camera_permission_button">"Go to camera settings"</string> <string name="screen_qr_code_login_no_camera_permission_button">"Go to camera settings"</string>
<string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for Element to use your devices camera in order to continue."</string> <string name="screen_qr_code_login_no_camera_permission_state_description">"You need to give permission for %1$s to use your devices camera in order to continue."</string>
<string name="screen_qr_code_login_no_camera_permission_state_title">"Allow camera access to scan the QR code"</string> <string name="screen_qr_code_login_no_camera_permission_state_title">"Allow camera access to scan the QR code"</string>
<string name="screen_qr_code_login_scanning_state_title">"Scan the QR code"</string> <string name="screen_qr_code_login_scanning_state_title">"Scan the QR code"</string>
<string name="screen_qr_code_login_start_over_button">"Start over"</string> <string name="screen_qr_code_login_start_over_button">"Start over"</string>

View file

@ -27,6 +27,7 @@ import io.element.android.features.lockscreen.test.FakeLockScreenService
import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus import io.element.android.libraries.matrix.api.verification.SessionVerifiedStatus
import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService import io.element.android.libraries.matrix.test.verification.FakeSessionVerificationService
import io.element.android.libraries.permissions.impl.FakePermissionStateProvider import io.element.android.libraries.permissions.impl.FakePermissionStateProvider
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@ -90,7 +91,6 @@ class DefaultFtueServiceTests {
fun `traverse flow`() = runTest { fun `traverse flow`() = runTest {
val sessionVerificationService = FakeSessionVerificationService().apply { val sessionVerificationService = FakeSessionVerificationService().apply {
givenVerifiedStatus(SessionVerifiedStatus.NotVerified) givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
givenNeedsVerification(true)
} }
val analyticsService = FakeAnalyticsService() val analyticsService = FakeAnalyticsService()
val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false) val permissionStateProvider = FakePermissionStateProvider(permissionGranted = false)
@ -108,7 +108,7 @@ class DefaultFtueServiceTests {
// Session verification // Session verification
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
sessionVerificationService.givenNeedsVerification(false) sessionVerificationService.givenVerifiedStatus(SessionVerifiedStatus.NotVerified)
// Notifications opt in // Notifications opt in
steps.add(state.getNextStep(steps.lastOrNull())) steps.add(state.getNextStep(steps.lastOrNull()))
@ -200,6 +200,7 @@ class DefaultFtueServiceTests {
analyticsService: AnalyticsService = FakeAnalyticsService(), analyticsService: AnalyticsService = FakeAnalyticsService(),
permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false), permissionStateProvider: FakePermissionStateProvider = FakePermissionStateProvider(permissionGranted = false),
lockScreenService: LockScreenService = FakeLockScreenService(), lockScreenService: LockScreenService = FakeLockScreenService(),
sessionPreferencesStore: InMemorySessionPreferencesStore = InMemorySessionPreferencesStore(),
// First version where notification permission is required // First version where notification permission is required
sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU, sdkIntVersion: Int = Build.VERSION_CODES.TIRAMISU,
) = DefaultFtueService( ) = DefaultFtueService(
@ -209,5 +210,6 @@ class DefaultFtueServiceTests {
analyticsService = analyticsService, analyticsService = analyticsService,
permissionStateProvider = permissionStateProvider, permissionStateProvider = permissionStateProvider,
lockScreenService = lockScreenService, lockScreenService = lockScreenService,
sessionPreferencesStore = sessionPreferencesStore,
) )
} }

View file

@ -50,7 +50,6 @@ dependencies {
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.push.test) testImplementation(projects.libraries.push.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.services.analytics.test) testImplementation(projects.services.analytics.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)

View file

@ -1,195 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.invite.impl.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.width
import androidx.compose.material3.MaterialTheme
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
import androidx.compose.ui.text.AnnotatedString
import androidx.compose.ui.text.SpanStyle
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invite.impl.R
import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.features.invite.impl.model.InviteListInviteSummaryProvider
import io.element.android.features.invite.impl.model.InviteSender
import io.element.android.libraries.designsystem.atomic.atoms.UnreadIndicatorAtom
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.ui.strings.CommonStrings
private val minHeight = 72.dp
@Composable
internal fun InviteSummaryRow(
invite: InviteListInviteSummary,
onAcceptClicked: () -> Unit,
onDeclineClicked: () -> Unit,
modifier: Modifier = Modifier,
) {
Box(
modifier = modifier
.fillMaxWidth()
.heightIn(min = minHeight)
) {
DefaultInviteSummaryRow(
invite = invite,
onAcceptClicked = onAcceptClicked,
onDeclineClicked = onDeclineClicked,
)
}
}
@Composable
private fun DefaultInviteSummaryRow(
invite: InviteListInviteSummary,
onAcceptClicked: () -> Unit,
onDeclineClicked: () -> Unit,
) {
Row(
modifier = Modifier
.fillMaxWidth()
.padding(16.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.Top
) {
Avatar(
invite.roomAvatarData,
)
Column(
modifier = Modifier
.padding(start = 16.dp, end = 4.dp)
.alignByBaseline()
.weight(1f)
) {
val bonusPadding = if (invite.isNew) 12.dp else 0.dp
// Name
Text(
text = invite.roomName,
color = MaterialTheme.colorScheme.primary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgMedium,
modifier = Modifier.padding(end = bonusPadding),
)
// ID or Alias
invite.roomAlias?.let {
Text(
style = ElementTheme.typography.fontBodyMdRegular,
text = it,
color = MaterialTheme.colorScheme.secondary,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
modifier = Modifier.padding(end = bonusPadding),
)
}
// Sender
invite.sender?.let { sender ->
SenderRow(sender = sender)
}
// CTAs
Row(Modifier.padding(top = 12.dp)) {
OutlinedButton(
text = stringResource(CommonStrings.action_decline),
onClick = onDeclineClicked,
modifier = Modifier.weight(1f),
size = ButtonSize.Medium,
)
Spacer(modifier = Modifier.width(12.dp))
Button(
text = stringResource(CommonStrings.action_accept),
onClick = onAcceptClicked,
modifier = Modifier.weight(1f),
size = ButtonSize.Medium,
)
}
}
UnreadIndicatorAtom(isVisible = invite.isNew)
}
}
@Composable
private fun SenderRow(sender: InviteSender) {
Row(
horizontalArrangement = Arrangement.spacedBy(4.dp),
modifier = Modifier.padding(top = 6.dp),
) {
Avatar(
avatarData = sender.avatarData,
)
Text(
text = stringResource(R.string.screen_invites_invited_you, sender.displayName, sender.userId.value).let { text ->
val senderNameStart = LocalContext.current.getString(R.string.screen_invites_invited_you).indexOf("%1\$s")
AnnotatedString(
text = text,
spanStyles = listOf(
AnnotatedString.Range(
SpanStyle(
fontWeight = FontWeight.Medium,
color = MaterialTheme.colorScheme.primary
),
start = senderNameStart,
end = senderNameStart + sender.displayName.length
)
)
)
},
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
@PreviewsDayNight
@Composable
internal fun InviteSummaryRowPreview(@PreviewParameter(InviteListInviteSummaryProvider::class) data: InviteListInviteSummary) = ElementPreview {
InviteSummaryRow(
invite = data,
onAcceptClicked = {},
onDeclineClicked = {},
)
}

View file

@ -1,157 +0,0 @@
/*
* 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.invite.impl.invitelist
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.features.invite.impl.model.InviteSender
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import kotlinx.collections.immutable.toPersistentList
import kotlinx.coroutines.flow.first
import javax.inject.Inject
class InviteListPresenter @Inject constructor(
private val client: MatrixClient,
private val store: SeenInvitesStore,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
) : Presenter<InviteListState> {
@Composable
override fun present(): InviteListState {
val invites by client
.roomListService
.invites
.summaries
.collectAsState(initial = emptyList())
var seenInvites by remember { mutableStateOf<Set<RoomId>>(emptySet()) }
LaunchedEffect(Unit) {
seenInvites = store.seenRoomIds().first()
}
LaunchedEffect(invites) {
store.markAsSeen(
invites
.filterIsInstance<RoomSummary.Filled>()
.map { it.details.roomId }
.toSet()
)
}
val acceptDeclineInviteState = acceptDeclineInvitePresenter.present()
fun handleEvent(event: InviteListEvents) {
when (event) {
is InviteListEvents.AcceptInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(event.invite.toInviteData())
)
}
is InviteListEvents.DeclineInvite -> {
acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(event.invite.toInviteData())
)
}
}
}
val inviteList = remember(seenInvites, invites) {
invites
.filterIsInstance<RoomSummary.Filled>()
.map {
it.toInviteSummary(seenInvites.contains(it.details.roomId))
}
.toPersistentList()
}
return InviteListState(
inviteList = inviteList,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = ::handleEvent
)
}
private fun RoomSummary.Filled.toInviteSummary(seen: Boolean) = details.run {
val i = inviter
val avatarData = if (isDirect && i != null) {
AvatarData(
id = i.userId.value,
name = i.displayName,
url = i.avatarUrl,
size = AvatarSize.RoomInviteItem,
)
} else {
AvatarData(
id = roomId.value,
name = name,
url = avatarUrl,
size = AvatarSize.RoomInviteItem,
)
}
val alias = if (isDirect) {
inviter?.userId?.value
} else {
canonicalAlias
}
InviteListInviteSummary(
roomId = roomId,
roomName = name,
roomAlias = alias,
roomAvatarData = avatarData,
isDirect = isDirect,
isNew = !seen,
sender = inviter
?.takeIf { !isDirect }
?.run {
InviteSender(
userId = userId,
displayName = displayName ?: "",
avatarData = AvatarData(
id = userId.value,
name = displayName,
url = avatarUrl,
size = AvatarSize.InviteSender,
),
)
},
)
}
private fun InviteListInviteSummary.toInviteData() = InviteData(
roomId = roomId,
roomName = roomName,
isDirect = isDirect,
)
}

View file

@ -1,77 +0,0 @@
/*
* 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.invite.impl.invitelist
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.impl.model.InviteListInviteSummary
import io.element.android.features.invite.impl.model.InviteSender
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
open class InviteListStateProvider : PreviewParameterProvider<InviteListState> {
private val acceptDeclineInviteStateProvider = AcceptDeclineInviteStateProvider()
override val values: Sequence<InviteListState>
get() = sequenceOf(
anInviteListState(),
anInviteListState(inviteList = persistentListOf()),
) + acceptDeclineInviteStateProvider.values.map { acceptDeclineInviteState ->
anInviteListState(acceptDeclineInviteState = acceptDeclineInviteState)
}
}
internal fun anInviteListState(
inviteList: ImmutableList<InviteListInviteSummary> = aInviteListInviteSummaryList(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
eventSink: (InviteListEvents) -> Unit = {}
) = InviteListState(
inviteList = inviteList,
acceptDeclineInviteState = acceptDeclineInviteState,
eventSink = eventSink,
)
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> {
return persistentListOf(
InviteListInviteSummary(
roomId = RoomId("!id1:example.com"),
roomName = "Room 1",
roomAlias = "#room:example.org",
sender = InviteSender(
userId = UserId("@alice:example.org"),
displayName = "Alice"
),
),
InviteListInviteSummary(
roomId = RoomId("!id2:example.com"),
roomName = "Room 2",
sender = InviteSender(
userId = UserId("@bob:example.org"),
displayName = "Bob"
),
),
InviteListInviteSummary(
roomId = RoomId("!id3:example.com"),
roomName = "Alice",
roomAlias = "@alice:example.com"
),
)
}

View file

@ -1,148 +0,0 @@
/*
* 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.invite.impl.invitelist
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.invite.impl.R
import io.element.android.features.invite.impl.components.InviteSummaryRow
import io.element.android.features.invite.impl.response.AcceptDeclineInviteView
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.aliasScreenTitle
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Scaffold
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun InviteListView(
state: InviteListState,
onBackClicked: () -> Unit,
onInviteAccepted: (RoomId) -> Unit,
onInviteDeclined: (RoomId) -> Unit,
onInviteClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
InviteListContent(
state = state,
modifier = modifier,
onInviteClicked = onInviteClicked,
onBackClicked = onBackClicked,
)
AcceptDeclineInviteView(
state = state.acceptDeclineInviteState,
onInviteAccepted = onInviteAccepted,
onInviteDeclined = onInviteDeclined,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun InviteListContent(
state: InviteListState,
onBackClicked: () -> Unit,
onInviteClicked: (RoomId) -> Unit,
modifier: Modifier = Modifier,
) {
Scaffold(
modifier = modifier,
topBar = {
TopAppBar(
navigationIcon = {
BackButton(onClick = onBackClicked)
},
title = {
Text(
text = stringResource(CommonStrings.action_invites_list),
style = ElementTheme.typography.aliasScreenTitle,
)
}
)
},
content = { padding ->
Column(
modifier = Modifier
.padding(padding)
.consumeWindowInsets(padding)
) {
if (state.inviteList.isEmpty()) {
Spacer(Modifier.size(80.dp))
Text(
text = stringResource(R.string.screen_invites_empty_list),
textAlign = TextAlign.Center,
color = MaterialTheme.colorScheme.tertiary,
modifier = Modifier.fillMaxWidth()
)
} else {
LazyColumn(
modifier = Modifier.weight(1f)
) {
itemsIndexed(
items = state.inviteList,
) { index, invite ->
InviteSummaryRow(
modifier = Modifier.clickable(
onClick = { onInviteClicked(invite.roomId) }
),
invite = invite,
onAcceptClicked = { state.eventSink(InviteListEvents.AcceptInvite(invite)) },
onDeclineClicked = { state.eventSink(InviteListEvents.DeclineInvite(invite)) },
)
if (index != state.inviteList.lastIndex) {
HorizontalDivider()
}
}
}
}
}
}
)
}
@PreviewsDayNight
@Composable
internal fun InviteListViewPreview(@PreviewParameter(InviteListStateProvider::class) state: InviteListState) = ElementPreview {
InviteListView(
state = state,
onBackClicked = {},
onInviteAccepted = {},
onInviteDeclined = {},
onInviteClicked = {},
)
}

View file

@ -1,40 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.invite.impl.model
import androidx.compose.runtime.Immutable
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
@Immutable
data class InviteListInviteSummary(
val roomId: RoomId,
val roomName: String = "",
val roomAlias: String? = null,
val roomAvatarData: AvatarData = AvatarData(roomId.value, roomName, size = AvatarSize.RoomInviteItem),
val sender: InviteSender? = null,
val isDirect: Boolean = false,
val isNew: Boolean = false,
)
data class InviteSender(
val userId: UserId,
val displayName: String,
val avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
)

View file

@ -1,41 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.invite.impl.model
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
open class InviteListInviteSummaryProvider : PreviewParameterProvider<InviteListInviteSummary> {
override val values: Sequence<InviteListInviteSummary>
get() = sequenceOf(
aInviteListInviteSummary(),
aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com"),
aInviteListInviteSummary().copy(roomAlias = "#someroom-with-a-long-alias:example.com", isNew = true),
aInviteListInviteSummary().copy(roomName = "Alice", sender = null),
aInviteListInviteSummary().copy(isNew = true)
)
}
fun aInviteListInviteSummary() = InviteListInviteSummary(
roomId = RoomId("!room1:example.com"),
roomName = "Some room with a long name that will truncate",
sender = InviteSender(
userId = UserId("@alice-with-a-long-mxid:example.org"),
displayName = "Alice with a long name"
),
)

View file

@ -102,12 +102,14 @@ class AcceptDeclineInvitePresenter @Inject constructor(
private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncAction<RoomId>>) = launch { private fun CoroutineScope.acceptInvite(roomId: RoomId, acceptedAction: MutableState<AsyncAction<RoomId>>) = launch {
acceptedAction.runUpdatingState { acceptedAction.runUpdatingState {
client.joinRoom(roomId).onSuccess { client.joinRoom(roomId)
.onSuccess {
notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true) notificationDrawerManager.clearMembershipNotificationForRoom(client.sessionId, roomId, doRender = true)
client.getRoom(roomId)?.use { room -> client.getRoom(roomId)?.use { room ->
analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite)) analyticsService.capture(room.toAnalyticsJoinedRoom(JoinedRoom.Trigger.Invite))
} }
} }
.map { roomId }
} }
} }

View file

@ -20,7 +20,6 @@ import androidx.compose.foundation.layout.Box
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider import io.element.android.features.invite.api.response.AcceptDeclineInviteStateProvider
@ -29,6 +28,7 @@ import io.element.android.features.invite.impl.R
import io.element.android.libraries.designsystem.components.async.AsyncActionView import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog import io.element.android.libraries.designsystem.components.dialogs.ConfirmationDialog
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlin.jvm.optionals.getOrNull import kotlin.jvm.optionals.getOrNull
@ -102,9 +102,9 @@ private fun DeclineConfirmationDialog(
) )
} }
@PreviewLightDark @PreviewsDayNight
@Composable @Composable
internal fun AcceptDeclineInviteViewLightPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) = internal fun AcceptDeclineInviteViewPreview(@PreviewParameter(AcceptDeclineInviteStateProvider::class) state: AcceptDeclineInviteState) =
ElementPreview { ElementPreview {
AcceptDeclineInviteView( AcceptDeclineInviteView(
state = state, state = state,

View file

@ -1,266 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.invite.impl.invitelist
import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.TurbineTestContext
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.invite.test.FakeSeenInvitesStore
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.roomlist.RoomSummary
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.test.room.aRoomSummaryDetails
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.tests.testutils.WarmUpRule
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class InviteListPresenterTests {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - starts empty, adds invites when received`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createInviteListPresenter(
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val initialState = awaitItem()
assertThat(initialState.inviteList).isEmpty()
roomListService.postInviteRooms(listOf(aRoomSummary()))
val withInviteState = awaitItem()
assertThat(withInviteState.inviteList.size).isEqualTo(1)
assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
}
}
@Test
fun `present - uses user ID and avatar for direct invites`() = runTest {
val roomListService = FakeRoomListService().withDirectChatInvitation()
val presenter = createInviteListPresenter(
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val withInviteState = awaitInitialItem()
assertThat(withInviteState.inviteList.size).isEqualTo(1)
assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(withInviteState.inviteList[0].roomAlias).isEqualTo(A_USER_ID.value)
assertThat(withInviteState.inviteList[0].roomName).isEqualTo(A_ROOM_NAME)
assertThat(withInviteState.inviteList[0].roomAvatarData).isEqualTo(
AvatarData(
id = A_USER_ID.value,
name = A_USER_NAME,
url = AN_AVATAR_URL,
size = AvatarSize.RoomInviteItem,
)
)
assertThat(withInviteState.inviteList[0].sender).isNull()
}
}
@Test
fun `present - includes sender details for room invites`() = runTest {
val roomListService = FakeRoomListService().withRoomInvitation()
val presenter = createInviteListPresenter(
FakeMatrixClient(roomListService = roomListService)
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
val withInviteState = awaitInitialItem()
assertThat(withInviteState.inviteList.size).isEqualTo(1)
assertThat(withInviteState.inviteList[0].sender?.displayName).isEqualTo(A_USER_NAME)
assertThat(withInviteState.inviteList[0].sender?.userId).isEqualTo(A_USER_ID)
assertThat(withInviteState.inviteList[0].sender?.avatarData).isEqualTo(
AvatarData(
id = A_USER_ID.value,
name = A_USER_NAME,
url = AN_AVATAR_URL,
size = AvatarSize.InviteSender,
)
)
}
}
@Test
fun `present - stores seen invites when received`() = runTest {
val roomListService = FakeRoomListService()
val store = FakeSeenInvitesStore()
val presenter = createInviteListPresenter(
FakeMatrixClient(
roomListService = roomListService,
),
store,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem()
// When one invite is received, that ID is saved
roomListService.postInviteRooms(listOf(aRoomSummary()))
awaitItem()
assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID))
// When a second is added, both are saved
roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
awaitItem()
assertThat(store.getProvidedRoomIds()).isEqualTo(setOf(A_ROOM_ID, A_ROOM_ID_2))
// When they're both dismissed, an empty set is saved
roomListService.postInviteRooms(listOf())
awaitItem()
assertThat(store.getProvidedRoomIds()).isEmpty()
}
}
@Test
fun `present - marks invite as new if they're unseen`() = runTest {
val roomListService = FakeRoomListService()
val store = FakeSeenInvitesStore()
store.publishRoomIds(setOf(A_ROOM_ID))
val presenter = createInviteListPresenter(
FakeMatrixClient(
roomListService = roomListService,
),
store,
)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
awaitItem()
roomListService.postInviteRooms(listOf(aRoomSummary(), aRoomSummary(A_ROOM_ID_2)))
skipItems(1)
val withInviteState = awaitItem()
assertThat(withInviteState.inviteList.size).isEqualTo(2)
assertThat(withInviteState.inviteList[0].roomId).isEqualTo(A_ROOM_ID)
assertThat(withInviteState.inviteList[0].isNew).isFalse()
assertThat(withInviteState.inviteList[1].roomId).isEqualTo(A_ROOM_ID_2)
assertThat(withInviteState.inviteList[1].isNew).isTrue()
}
}
private suspend fun FakeRoomListService.withRoomInvitation(): FakeRoomListService {
postInviteRooms(
listOf(
RoomSummary.Filled(
aRoomSummaryDetails(
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
avatarUrl = null,
isDirect = false,
lastMessage = null,
inviter = aRoomMember(
userId = A_USER_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
)
)
)
)
return this
}
private suspend fun FakeRoomListService.withDirectChatInvitation(): FakeRoomListService {
postInviteRooms(
listOf(
RoomSummary.Filled(
aRoomSummaryDetails(
roomId = A_ROOM_ID,
name = A_ROOM_NAME,
avatarUrl = null,
isDirect = true,
lastMessage = null,
inviter = aRoomMember(
userId = A_USER_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
membership = RoomMembershipState.JOIN,
isNameAmbiguous = false,
powerLevel = 0,
normalizedPowerLevel = 0,
isIgnored = false,
)
)
)
)
)
return this
}
private fun aRoomSummary(id: RoomId = A_ROOM_ID) = RoomSummary.Filled(
aRoomSummaryDetails(
roomId = id,
name = A_ROOM_NAME,
avatarUrl = null,
isDirect = false,
lastMessage = null,
)
)
private suspend fun TurbineTestContext<InviteListState>.awaitInitialItem(): InviteListState {
skipItems(1)
return awaitItem()
}
private fun createInviteListPresenter(
client: MatrixClient,
seenInvitesStore: SeenInvitesStore = FakeSeenInvitesStore(),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() },
) = InviteListPresenter(
client,
seenInvitesStore,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
)
}

View file

@ -164,7 +164,7 @@ class AcceptDeclineInvitePresenterTest {
@Test @Test
fun `present - accepting invite error flow`() = runTest { fun `present - accepting invite error flow`() = runTest {
val joinRoomFailure = lambdaRecorder { roomId: RoomId -> val joinRoomFailure = lambdaRecorder { roomId: RoomId ->
Result.failure<RoomId>(RuntimeException("Failed to join room $roomId")) Result.failure<Unit>(RuntimeException("Failed to join room $roomId"))
} }
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomFailure joinRoomLambda = joinRoomFailure
@ -197,8 +197,8 @@ class AcceptDeclineInvitePresenterTest {
@Test @Test
fun `present - accepting invite success flow`() = runTest { fun `present - accepting invite success flow`() = runTest {
val joinRoomSuccess = lambdaRecorder { roomId: RoomId -> val joinRoomSuccess = lambdaRecorder { _: RoomId ->
Result.success(roomId) Result.success(Unit)
} }
val client = FakeMatrixClient().apply { val client = FakeMatrixClient().apply {
joinRoomLambda = joinRoomSuccess joinRoomLambda = joinRoomSuccess

View file

@ -1,39 +0,0 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.features.invite.test
import io.element.android.features.invite.api.SeenInvitesStore
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeSeenInvitesStore : SeenInvitesStore {
private val existing = MutableStateFlow(emptySet<RoomId>())
private var provided: Set<RoomId>? = null
fun publishRoomIds(invites: Set<RoomId>) {
existing.value = invites
}
fun getProvidedRoomIds() = provided
override fun seenRoomIds(): Flow<Set<RoomId>> = existing
override suspend fun markAsSeen(roomIds: Set<RoomId>) {
provided = roomIds.toSet()
}
}

View file

@ -22,6 +22,7 @@ import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import java.util.Optional import java.util.Optional
interface JoinRoomEntryPoint : FeatureEntryPoint { interface JoinRoomEntryPoint : FeatureEntryPoint {
@ -29,6 +30,7 @@ interface JoinRoomEntryPoint : FeatureEntryPoint {
data class Inputs( data class Inputs(
val roomId: RoomId, val roomId: RoomId,
val roomIdOrAlias: RoomIdOrAlias,
val roomDescription: Optional<RoomDescription>, val roomDescription: Optional<RoomDescription>,
) : NodeInputs ) : NodeInputs
} }

View file

@ -23,6 +23,11 @@ plugins {
android { android {
namespace = "io.element.android.features.joinroom.impl" namespace = "io.element.android.features.joinroom.impl"
testOptions {
unitTests {
isIncludeAndroidResources = true
}
}
} }
anvil { anvil {
@ -46,11 +51,13 @@ dependencies {
testImplementation(libs.test.junit) testImplementation(libs.test.junit)
testImplementation(libs.coroutines.test) testImplementation(libs.coroutines.test)
testImplementation(libs.molecule.runtime) testImplementation(libs.molecule.runtime)
testImplementation(libs.test.robolectric)
testImplementation(libs.test.truth) testImplementation(libs.test.truth)
testImplementation(libs.test.turbine) testImplementation(libs.test.turbine)
testImplementation(projects.libraries.matrix.test) testImplementation(projects.libraries.matrix.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.tests.testutils) testImplementation(projects.tests.testutils)
testImplementation(libs.androidx.compose.ui.test.junit)
testReleaseImplementation(libs.androidx.compose.ui.test.manifest)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -17,7 +17,10 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
sealed interface JoinRoomEvents { sealed interface JoinRoomEvents {
data object RetryFetchingContent : JoinRoomEvents
data object JoinRoom : JoinRoomEvents data object JoinRoom : JoinRoomEvents
data object KnockRoom : JoinRoomEvents
data object ClearError : JoinRoomEvents
data object AcceptInvite : JoinRoomEvents data object AcceptInvite : JoinRoomEvents
data object DeclineInvite : JoinRoomEvents data object DeclineInvite : JoinRoomEvents
} }

View file

@ -37,7 +37,11 @@ class JoinRoomNode @AssistedInject constructor(
private val acceptDeclineInviteView: AcceptDeclineInviteView, private val acceptDeclineInviteView: AcceptDeclineInviteView,
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
private val inputs: JoinRoomEntryPoint.Inputs = inputs() private val inputs: JoinRoomEntryPoint.Inputs = inputs()
private val presenter = presenterFactory.create(inputs.roomId, inputs.roomDescription) private val presenter = presenterFactory.create(
inputs.roomId,
inputs.roomIdOrAlias,
inputs.roomDescription,
)
@Composable @Composable
override fun View(modifier: Modifier) { override fun View(modifier: Modifier) {
@ -45,6 +49,7 @@ class JoinRoomNode @AssistedInject constructor(
JoinRoomView( JoinRoomView(
state = state, state = state,
onBackPressed = ::navigateUp, onBackPressed = ::navigateUp,
onKnockSuccess = ::navigateUp,
modifier = modifier modifier = modifier
) )
acceptDeclineInviteView.Render( acceptDeclineInviteView.Render(

View file

@ -16,47 +16,91 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
import androidx.annotation.VisibleForTesting
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.collectAsState import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dagger.assisted.Assisted import dagger.assisted.Assisted
import dagger.assisted.AssistedInject import dagger.assisted.AssistedInject
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.InviteData import io.element.android.features.invite.api.response.InviteData
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient 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.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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.MatrixRoomInfo import io.element.android.libraries.matrix.api.room.MatrixRoomInfo
import org.jetbrains.annotations.VisibleForTesting import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
import io.element.android.libraries.matrix.ui.model.toInviteSender
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
import java.util.Optional import java.util.Optional
class JoinRoomPresenter @AssistedInject constructor( class JoinRoomPresenter @AssistedInject constructor(
@Assisted private val roomId: RoomId, @Assisted private val roomId: RoomId,
@Assisted private val roomIdOrAlias: RoomIdOrAlias,
@Assisted private val roomDescription: Optional<RoomDescription>, @Assisted private val roomDescription: Optional<RoomDescription>,
private val matrixClient: MatrixClient, private val matrixClient: MatrixClient,
private val knockRoom: KnockRoom,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val buildMeta: BuildMeta,
) : Presenter<JoinRoomState> { ) : Presenter<JoinRoomState> {
interface Factory { interface Factory {
fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter fun create(
roomId: RoomId,
roomIdOrAlias: RoomIdOrAlias,
roomDescription: Optional<RoomDescription>,
): JoinRoomPresenter
} }
@Composable @Composable
override fun present(): JoinRoomState { override fun present(): JoinRoomState {
val coroutineScope = rememberCoroutineScope()
var retryCount by remember { mutableIntStateOf(0) }
val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty()) val roomInfo by matrixClient.getRoomInfoFlow(roomId).collectAsState(initial = Optional.empty())
val contentState by produceState<ContentState>(initialValue = ContentState.Loading(roomId), key1 = roomInfo) { val knockAction: MutableState<AsyncAction<Unit>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
value = when { val contentState by produceState<ContentState>(
initialValue = ContentState.Loading(roomIdOrAlias),
key1 = roomInfo,
key2 = retryCount,
) {
when {
roomInfo.isPresent -> { roomInfo.isPresent -> {
roomInfo.get().toContentState() value = roomInfo.get().toContentState()
} }
roomDescription.isPresent -> { roomDescription.isPresent -> {
roomDescription.get().toContentState() value = roomDescription.get().toContentState()
} }
else -> { else -> {
ContentState.Loading(roomId) value = ContentState.Loading(roomIdOrAlias)
val result = matrixClient.getRoomPreview(roomId.toRoomIdOrAlias())
value = result.fold(
onSuccess = { roomPreview ->
roomPreview.toContentState()
},
onFailure = { throwable ->
if (throwable.message?.contains("403") == true) {
ContentState.UnknownRoom(roomIdOrAlias)
} else {
ContentState.Failure(roomIdOrAlias, throwable)
}
}
)
} }
} }
} }
@ -64,27 +108,65 @@ class JoinRoomPresenter @AssistedInject constructor(
fun handleEvents(event: JoinRoomEvents) { fun handleEvents(event: JoinRoomEvents) {
when (event) { when (event) {
JoinRoomEvents.AcceptInvite, JoinRoomEvents.JoinRoom -> { JoinRoomEvents.AcceptInvite,
JoinRoomEvents.JoinRoom -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.AcceptInvite(inviteData) AcceptDeclineInviteEvents.AcceptInvite(inviteData)
) )
} }
JoinRoomEvents.KnockRoom -> {
coroutineScope.knockRoom(roomId, knockAction)
}
JoinRoomEvents.DeclineInvite -> { JoinRoomEvents.DeclineInvite -> {
val inviteData = contentState.toInviteData() ?: return val inviteData = contentState.toInviteData() ?: return
acceptDeclineInviteState.eventSink( acceptDeclineInviteState.eventSink(
AcceptDeclineInviteEvents.DeclineInvite(inviteData) AcceptDeclineInviteEvents.DeclineInvite(inviteData)
) )
} }
JoinRoomEvents.RetryFetchingContent -> {
retryCount++
}
JoinRoomEvents.ClearError -> {
knockAction.value = AsyncAction.Uninitialized
}
} }
} }
return JoinRoomState( return JoinRoomState(
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
knockAction = knockAction.value,
applicationName = buildMeta.applicationName,
eventSink = ::handleEvents eventSink = ::handleEvents
) )
} }
private fun CoroutineScope.knockRoom(roomId: RoomId, knockAction: MutableState<AsyncAction<Unit>>) = launch {
knockAction.runUpdatingState {
knockRoom(roomId)
}
}
}
private fun RoomPreview.toContentState(): ContentState {
return ContentState.Loaded(
roomId = roomId,
name = name,
topic = topic,
alias = canonicalAlias,
numberOfMembers = numberOfJoinedMembers,
isDirect = false,
roomType = roomType,
roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when {
// Note when isInvited, roomInfo will be used, so if this happen, it will be temporary.
isInvited -> JoinAuthorisationStatus.IsInvited(null)
canKnock -> JoinAuthorisationStatus.CanKnock
isPublic -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown
}
)
} }
@VisibleForTesting @VisibleForTesting
@ -96,6 +178,7 @@ internal fun RoomDescription.toContentState(): ContentState {
alias = alias, alias = alias,
numberOfMembers = numberOfMembers, numberOfMembers = numberOfMembers,
isDirect = false, isDirect = false,
roomType = RoomType.Room,
roomAvatarUrl = avatarUrl, roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when (joinRule) { joinAuthorisationStatus = when (joinRule) {
RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock RoomDescription.JoinRule.KNOCK -> JoinAuthorisationStatus.CanKnock
@ -108,15 +191,18 @@ internal fun RoomDescription.toContentState(): ContentState {
@VisibleForTesting @VisibleForTesting
internal fun MatrixRoomInfo.toContentState(): ContentState { internal fun MatrixRoomInfo.toContentState(): ContentState {
return ContentState.Loaded( return ContentState.Loaded(
roomId = RoomId(id), roomId = id,
name = name, name = name,
topic = topic, topic = topic,
alias = canonicalAlias, alias = canonicalAlias,
numberOfMembers = activeMembersCount, numberOfMembers = activeMembersCount,
isDirect = isDirect, isDirect = isDirect,
roomType = if (isSpace) RoomType.Space else RoomType.Room,
roomAvatarUrl = avatarUrl, roomAvatarUrl = avatarUrl,
joinAuthorisationStatus = when { joinAuthorisationStatus = when {
currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited currentUserMembership == CurrentUserMembership.INVITED -> JoinAuthorisationStatus.IsInvited(
inviteSender = inviter?.toInviteSender()
)
isPublic -> JoinAuthorisationStatus.CanJoin isPublic -> JoinAuthorisationStatus.CanJoin
else -> JoinAuthorisationStatus.Unknown else -> JoinAuthorisationStatus.Unknown
} }
@ -128,7 +214,8 @@ internal fun ContentState.toInviteData(): InviteData? {
return when (this) { return when (this) {
is ContentState.Loaded -> InviteData( is ContentState.Loaded -> InviteData(
roomId = roomId, roomId = roomId,
roomName = computedTitle, // Note: name should not be null at this point, but use Id just in case...
roomName = name ?: roomId.value,
isDirect = isDirect isDirect = isDirect
) )
else -> null else -> null

View file

@ -18,14 +18,21 @@ package io.element.android.features.joinroom.impl
import androidx.compose.runtime.Immutable import androidx.compose.runtime.Immutable
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
@Immutable @Immutable
data class JoinRoomState( data class JoinRoomState(
val contentState: ContentState, val contentState: ContentState,
val acceptDeclineInviteState: AcceptDeclineInviteState, val acceptDeclineInviteState: AcceptDeclineInviteState,
val knockAction: AsyncAction<Unit>,
val applicationName: String,
val eventSink: (JoinRoomEvents) -> Unit val eventSink: (JoinRoomEvents) -> Unit
) { ) {
val joinAuthorisationStatus = when (contentState) { val joinAuthorisationStatus = when (contentState) {
@ -35,26 +42,20 @@ data class JoinRoomState(
} }
sealed interface ContentState { sealed interface ContentState {
data class Loading(val roomId: RoomId) : ContentState data class Loading(val roomIdOrAlias: RoomIdOrAlias) : ContentState
data class UnknownRoom(val roomId: RoomId) : ContentState data class Failure(val roomIdOrAlias: RoomIdOrAlias, val error: Throwable) : ContentState
data class UnknownRoom(val roomIdOrAlias: RoomIdOrAlias) : ContentState
data class Loaded( data class Loaded(
val roomId: RoomId, val roomId: RoomId,
val name: String?, val name: String?,
val topic: String?, val topic: String?,
val alias: String?, val alias: RoomAlias?,
val numberOfMembers: Long?, val numberOfMembers: Long?,
val isDirect: Boolean, val isDirect: Boolean,
val roomType: RoomType,
val roomAvatarUrl: String?, val roomAvatarUrl: String?,
val joinAuthorisationStatus: JoinAuthorisationStatus, val joinAuthorisationStatus: JoinAuthorisationStatus,
) : ContentState { ) : ContentState {
val computedTitle = name ?: roomId.value
val computedSubtitle = when {
alias != null -> alias
name == null -> ""
else -> roomId.value
}
val showMemberCount = numberOfMembers != null val showMemberCount = numberOfMembers != null
fun avatarData(size: AvatarSize): AvatarData { fun avatarData(size: AvatarSize): AvatarData {
@ -68,9 +69,9 @@ sealed interface ContentState {
} }
} }
enum class JoinAuthorisationStatus { sealed interface JoinAuthorisationStatus {
IsInvited, data class IsInvited(val inviteSender: InviteSender?) : JoinAuthorisationStatus
CanKnock, data object CanKnock : JoinAuthorisationStatus
CanJoin, data object CanJoin : JoinAuthorisationStatus
Unknown, data object Unknown : JoinAuthorisationStatus
} }

View file

@ -19,7 +19,16 @@ package io.element.android.features.joinroom.impl
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
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.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.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.model.InviteSender
open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> { open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
override val values: Sequence<JoinRoomState> override val values: Sequence<JoinRoomState>
@ -30,29 +39,75 @@ open class JoinRoomStateProvider : PreviewParameterProvider<JoinRoomState> {
aJoinRoomState( aJoinRoomState(
contentState = anUnknownContentState() contentState = anUnknownContentState()
), ),
aJoinRoomState(
contentState = aLoadedContentState(
name = null,
alias = null,
topic = null,
)
),
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin) contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin)
), ),
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock) contentState = aLoadedContentState(
joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock,
topic = "lorem ipsum dolor sit amet consectetur adipiscing elit sed do eiusmod tempor incididunt" +
" ut labore et dolore magna aliqua ut enim ad minim veniam quis nostrud exercitation ullamco" +
" laboris nisi ut aliquip ex ea commodo consequat duis aute irure dolor in reprehenderit in" +
" voluptate velit esse cillum dolore eu fugiat nulla pariatur excepteur sint occaecat cupidatat" +
" non proident sunt in culpa qui officia deserunt mollit anim id est laborum",
numberOfMembers = 888,
)
), ),
aJoinRoomState( aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited) contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null))
),
aJoinRoomState(
contentState = aLoadedContentState(
numberOfMembers = 123,
joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(anInviteSender()),
)
),
aJoinRoomState(
contentState = aFailureContentState()
),
aJoinRoomState(
contentState = aFailureContentState(roomIdOrAlias = A_ROOM_ALIAS.toRoomIdOrAlias())
),
aJoinRoomState(
contentState = aLoadedContentState(
roomId = RoomId("!aSpaceId:domain"),
name = "A space",
alias = null,
topic = "This is the topic of a space",
roomType = RoomType.Space,
)
), ),
) )
} }
fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId) fun aFailureContentState(
roomIdOrAlias: RoomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias()
): ContentState {
return ContentState.Failure(
roomIdOrAlias = roomIdOrAlias,
error = Exception("Error"),
)
}
fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId) fun anUnknownContentState(roomId: RoomId = A_ROOM_ID) = ContentState.UnknownRoom(roomId.toRoomIdOrAlias())
fun aLoadingContentState(roomId: RoomId = A_ROOM_ID) = ContentState.Loading(roomId.toRoomIdOrAlias())
fun aLoadedContentState( fun aLoadedContentState(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String = "Element X android", name: String? = "Element X android",
alias: String? = "#exa:matrix.org", alias: RoomAlias? = RoomAlias("#exa:matrix.org"),
topic: String? = "Element X is a secure, private and decentralized messenger.", topic: String? = "Element X is a secure, private and decentralized messenger.",
numberOfMembers: Long? = null, numberOfMembers: Long? = null,
isDirect: Boolean = false, isDirect: Boolean = false,
roomType: RoomType = RoomType.Room,
roomAvatarUrl: String? = null, roomAvatarUrl: String? = null,
joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown joinAuthorisationStatus: JoinAuthorisationStatus = JoinAuthorisationStatus.Unknown
) = ContentState.Loaded( ) = ContentState.Loaded(
@ -62,6 +117,7 @@ fun aLoadedContentState(
topic = topic, topic = topic,
numberOfMembers = numberOfMembers, numberOfMembers = numberOfMembers,
isDirect = isDirect, isDirect = isDirect,
roomType = roomType,
roomAvatarUrl = roomAvatarUrl, roomAvatarUrl = roomAvatarUrl,
joinAuthorisationStatus = joinAuthorisationStatus joinAuthorisationStatus = joinAuthorisationStatus
) )
@ -69,11 +125,25 @@ fun aLoadedContentState(
fun aJoinRoomState( fun aJoinRoomState(
contentState: ContentState = aLoadedContentState(), contentState: ContentState = aLoadedContentState(),
acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(), acceptDeclineInviteState: AcceptDeclineInviteState = anAcceptDeclineInviteState(),
knockAction: AsyncAction<Unit> = AsyncAction.Uninitialized,
eventSink: (JoinRoomEvents) -> Unit = {} eventSink: (JoinRoomEvents) -> Unit = {}
) = JoinRoomState( ) = JoinRoomState(
contentState = contentState, contentState = contentState,
acceptDeclineInviteState = acceptDeclineInviteState, acceptDeclineInviteState = acceptDeclineInviteState,
knockAction = knockAction,
applicationName = "AppName",
eventSink = eventSink eventSink = eventSink
) )
internal fun anInviteSender(
userId: UserId = UserId("@bob:domain"),
displayName: String = "Bob",
avatarData: AvatarData = AvatarData(userId.value, displayName, size = AvatarSize.InviteSender),
) = InviteSender(
userId = userId,
displayName = displayName,
avatarData = avatarData,
)
private val A_ROOM_ID = RoomId("!exa:matrix.org") private val A_ROOM_ID = RoomId("!exa:matrix.org")
private val A_ROOM_ALIAS = RoomAlias("#exa:matrix.org")

View file

@ -16,62 +16,78 @@
package io.element.android.features.joinroom.impl package io.element.android.features.joinroom.impl
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material3.ExperimentalMaterial3Api import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.font.FontStyle
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewLightDark
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewSubtitleAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
import io.element.android.libraries.designsystem.atomic.molecules.RoomPreviewMembersCountMolecule
import io.element.android.libraries.designsystem.atomic.organisms.RoomPreviewOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.background.LightGradientBackground
import io.element.android.libraries.designsystem.components.async.AsyncActionView
import io.element.android.libraries.designsystem.components.avatar.Avatar import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.components.button.BackButton import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.button.SuperButton
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.ButtonSize import io.element.android.libraries.designsystem.theme.components.ButtonSize
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.ui.components.InviteSenderView
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun JoinRoomView( fun JoinRoomView(
state: JoinRoomState, state: JoinRoomState,
onBackPressed: () -> Unit, onBackPressed: () -> Unit,
onKnockSuccess: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Box(
modifier = modifier.fillMaxSize(),
) {
LightGradientBackground()
HeaderFooterPage( HeaderFooterPage(
modifier = modifier, containerColor = Color.Transparent,
paddingValues = PaddingValues(16.dp), paddingValues = PaddingValues(16.dp),
topBar = { topBar = {
JoinRoomTopBar(onBackClicked = onBackPressed) JoinRoomTopBar(onBackClicked = onBackPressed)
}, },
content = { content = {
JoinRoomContent(contentState = state.contentState) JoinRoomContent(
contentState = state.contentState,
applicationName = state.applicationName,
)
}, },
footer = { footer = {
JoinRoomFooter( JoinRoomFooter(
joinAuthorisationStatus = state.joinAuthorisationStatus, state = state,
onAcceptInvite = { onAcceptInvite = {
state.eventSink(JoinRoomEvents.AcceptInvite) state.eventSink(JoinRoomEvents.AcceptInvite)
}, },
@ -81,100 +97,173 @@ fun JoinRoomView(
onJoinRoom = { onJoinRoom = {
state.eventSink(JoinRoomEvents.JoinRoom) state.eventSink(JoinRoomEvents.JoinRoom)
}, },
onKnockRoom = {
state.eventSink(JoinRoomEvents.KnockRoom)
},
onRetry = {
state.eventSink(JoinRoomEvents.RetryFetchingContent)
},
onGoBack = onBackPressed,
) )
} }
) )
} }
AsyncActionView(
async = state.knockAction,
onSuccess = { onKnockSuccess() },
onErrorDismiss = { state.eventSink(JoinRoomEvents.ClearError) },
)
}
@Composable @Composable
private fun JoinRoomFooter( private fun JoinRoomFooter(
joinAuthorisationStatus: JoinAuthorisationStatus, state: JoinRoomState,
onAcceptInvite: () -> Unit, onAcceptInvite: () -> Unit,
onDeclineInvite: () -> Unit, onDeclineInvite: () -> Unit,
onJoinRoom: () -> Unit, onJoinRoom: () -> Unit,
onKnockRoom: () -> Unit,
onRetry: () -> Unit,
onGoBack: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
if (state.contentState is ContentState.Failure) {
Button(
text = stringResource(CommonStrings.action_retry),
onClick = onRetry,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Large,
)
} else if (state.contentState is ContentState.Loaded && state.contentState.roomType == RoomType.Space) {
Button(
text = stringResource(CommonStrings.action_go_back),
onClick = onGoBack,
modifier = modifier.fillMaxWidth(),
size = ButtonSize.Large,
)
} else {
val joinAuthorisationStatus = state.joinAuthorisationStatus
when (joinAuthorisationStatus) { when (joinAuthorisationStatus) {
JoinAuthorisationStatus.IsInvited -> { is JoinAuthorisationStatus.IsInvited -> {
ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) { ButtonRowMolecule(modifier = modifier, horizontalArrangement = Arrangement.spacedBy(20.dp)) {
OutlinedButton( OutlinedButton(
text = stringResource(CommonStrings.action_decline), text = stringResource(CommonStrings.action_decline),
onClick = onDeclineInvite, onClick = onDeclineInvite,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
size = ButtonSize.Medium, size = ButtonSize.Large,
) )
Button( Button(
text = stringResource(CommonStrings.action_accept), text = stringResource(CommonStrings.action_accept),
onClick = onAcceptInvite, onClick = onAcceptInvite,
modifier = Modifier.weight(1f), modifier = Modifier.weight(1f),
size = ButtonSize.Medium, size = ButtonSize.Large,
) )
} }
} }
JoinAuthorisationStatus.CanJoin -> { JoinAuthorisationStatus.CanJoin -> {
Button( SuperButton(
text = stringResource(R.string.screen_join_room_join_action),
onClick = onJoinRoom, onClick = onJoinRoom,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium, buttonSize = ButtonSize.Large,
) {
Text(
text = stringResource(R.string.screen_join_room_join_action),
) )
} }
}
JoinAuthorisationStatus.CanKnock -> { JoinAuthorisationStatus.CanKnock -> {
Button( Button(
text = stringResource(R.string.screen_join_room_knock_action), text = stringResource(R.string.screen_join_room_knock_action),
onClick = onJoinRoom, onClick = onKnockRoom,
modifier = modifier.fillMaxWidth(), modifier = modifier.fillMaxWidth(),
size = ButtonSize.Medium, size = ButtonSize.Large,
) )
} }
JoinAuthorisationStatus.Unknown -> Unit JoinAuthorisationStatus.Unknown -> Unit
} }
} }
}
@Composable @Composable
private fun JoinRoomContent( private fun JoinRoomContent(
contentState: ContentState, contentState: ContentState,
applicationName: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
when (contentState) { when (contentState) {
is ContentState.Loaded -> { is ContentState.Loaded -> {
ContentScaffold( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
Avatar(contentState.avatarData(AvatarSize.RoomHeader)) Avatar(contentState.avatarData(AvatarSize.RoomHeader))
}, },
title = { title = {
Title(contentState.computedTitle) if (contentState.name != null) {
RoomPreviewTitleAtom(
title = contentState.name,
)
} else {
RoomPreviewTitleAtom(
title = stringResource(id = CommonStrings.common_no_room_name),
fontStyle = FontStyle.Italic
)
}
}, },
subtitle = { subtitle = {
Subtitle(contentState.computedSubtitle) if (contentState.alias != null) {
RoomPreviewSubtitleAtom(contentState.alias.value)
}
}, },
description = { description = {
Description(contentState.topic ?: "") Column(
horizontalAlignment = Alignment.CenterHorizontally,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
val inviteSender = (contentState.joinAuthorisationStatus as? JoinAuthorisationStatus.IsInvited)?.inviteSender
if (inviteSender != null) {
InviteSenderView(inviteSender = inviteSender)
}
RoomPreviewDescriptionAtom(contentState.topic ?: "")
if (contentState.roomType == RoomType.Space) {
Spacer(modifier = Modifier.height(24.dp))
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_title),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyLgMedium,
color = MaterialTheme.colorScheme.primary,
)
Text(
text = stringResource(R.string.screen_join_room_space_not_supported_description, applicationName),
textAlign = TextAlign.Center,
style = ElementTheme.typography.fontBodyMdRegular,
color = MaterialTheme.colorScheme.secondary,
)
}
}
}, },
memberCount = { memberCount = {
if (contentState.showMemberCount) { if (contentState.showMemberCount) {
MembersCount(memberCount = contentState.numberOfMembers ?: 0) RoomPreviewMembersCountMolecule(memberCount = contentState.numberOfMembers ?: 0)
} }
} }
) )
} }
is ContentState.UnknownRoom -> { is ContentState.UnknownRoom -> {
ContentScaffold( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
}, },
title = { title = {
Title(stringResource(R.string.screen_join_room_title_no_preview)) RoomPreviewTitleAtom(stringResource(R.string.screen_join_room_title_no_preview))
}, },
subtitle = { subtitle = {
Subtitle(stringResource(R.string.screen_join_room_subtitle_no_preview)) RoomPreviewSubtitleAtom(stringResource(R.string.screen_join_room_subtitle_no_preview))
}, },
) )
} }
is ContentState.Loading -> { is ContentState.Loading -> {
ContentScaffold( RoomPreviewOrganism(
modifier = modifier, modifier = modifier,
avatar = { avatar = {
PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp) PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
@ -187,94 +276,31 @@ private fun JoinRoomContent(
}, },
) )
} }
} is ContentState.Failure -> {
} RoomPreviewOrganism(
@Composable
private fun ContentScaffold(
avatar: @Composable () -> Unit,
title: @Composable () -> Unit,
subtitle: @Composable () -> Unit,
modifier: Modifier = Modifier,
description: @Composable (() -> Unit)? = null,
memberCount: @Composable (() -> Unit)? = null,
) {
Column(
modifier = modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally
) {
avatar()
Spacer(modifier = Modifier.height(16.dp))
title()
Spacer(modifier = Modifier.height(8.dp))
subtitle()
Spacer(modifier = Modifier.height(8.dp))
if (memberCount != null) {
memberCount()
}
Spacer(modifier = Modifier.height(8.dp))
if (description != null) {
description()
}
Spacer(modifier = Modifier.height(24.dp))
}
}
@Composable
private fun Title(title: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier, modifier = modifier,
text = title, avatar = {
style = ElementTheme.typography.fontHeadingMdBold, PlaceholderAtom(width = AvatarSize.RoomHeader.dp, height = AvatarSize.RoomHeader.dp)
},
title = {
when (contentState.roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
RoomPreviewTitleAtom(contentState.roomIdOrAlias.identifier)
}
is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp)
}
}
},
subtitle = {
Text(
text = stringResource(id = CommonStrings.error_unknown),
textAlign = TextAlign.Center, textAlign = TextAlign.Center,
color = ElementTheme.colors.textPrimary, color = MaterialTheme.colorScheme.error,
)
},
) )
} }
@Composable
private fun Subtitle(subtitle: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = subtitle,
style = ElementTheme.typography.fontBodyLgRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
)
}
@Composable
private fun Description(description: String, modifier: Modifier = Modifier) {
Text(
modifier = modifier,
text = description,
style = ElementTheme.typography.fontBodySmRegular,
textAlign = TextAlign.Center,
color = ElementTheme.colors.textSecondary,
maxLines = 3,
overflow = TextOverflow.Ellipsis,
)
}
@Composable
private fun MembersCount(memberCount: Long) {
Row(
modifier = Modifier
.background(color = ElementTheme.colors.bgSubtleSecondary, shape = CircleShape)
.widthIn(min = 48.dp)
.padding(all = 2.dp),
verticalAlignment = Alignment.CenterVertically,
horizontalArrangement = Arrangement.spacedBy(4.dp)
) {
Icon(
imageVector = CompoundIcons.UserProfile(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
Text(
text = "$memberCount",
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textSecondary,
)
} }
} }
@ -291,11 +317,12 @@ private fun JoinRoomTopBar(
) )
} }
@PreviewLightDark @PreviewsDayNight
@Composable @Composable
internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview { internal fun JoinRoomViewPreview(@PreviewParameter(JoinRoomStateProvider::class) state: JoinRoomState) = ElementPreview {
JoinRoomView( JoinRoomView(
state = state, state = state,
onBackPressed = { } onBackPressed = { },
onKnockSuccess = { },
) )
} }

View file

@ -23,9 +23,11 @@ import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.joinroom.impl.JoinRoomPresenter import io.element.android.features.joinroom.impl.JoinRoomPresenter
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient 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.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import java.util.Optional import java.util.Optional
@Module @Module
@ -34,15 +36,24 @@ object JoinRoomModule {
@Provides @Provides
fun providesJoinRoomPresenterFactory( fun providesJoinRoomPresenterFactory(
client: MatrixClient, client: MatrixClient,
knockRoom: KnockRoom,
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>, acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
buildMeta: BuildMeta,
): JoinRoomPresenter.Factory { ): JoinRoomPresenter.Factory {
return object : JoinRoomPresenter.Factory { return object : JoinRoomPresenter.Factory {
override fun create(roomId: RoomId, roomDescription: Optional<RoomDescription>): JoinRoomPresenter { override fun create(
roomId: RoomId,
roomIdOrAlias: RoomIdOrAlias,
roomDescription: Optional<RoomDescription>,
): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
roomIdOrAlias = roomIdOrAlias,
roomDescription = roomDescription, roomDescription = roomDescription,
matrixClient = client, matrixClient = client,
knockRoom = knockRoom,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter, acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
buildMeta = buildMeta,
) )
} }
} }

View file

@ -0,0 +1,32 @@
/*
* 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.joinroom.impl.di
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.RoomId
import javax.inject.Inject
interface KnockRoom {
suspend operator fun invoke(roomId: RoomId): Result<Unit>
}
@ContributesBinding(SessionScope::class)
class DefaultKnockRoom @Inject constructor(private val client: MatrixClient) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = client.knockRoom(roomId)
}

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Далучыцца да пакоя"</string> <string name="screen_join_room_join_action">"Далучыцца да пакоя"</string>
<string name="screen_join_room_knock_action">"Націсніце, каб далучыцца"</string> <string name="screen_join_room_knock_action">"Націсніце, каб далучыцца"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s пакуль не падтрымлівае прасторы. Вы можаце атрымаць доступ да прастор праз вэб-старонку."</string>
<string name="screen_join_room_space_not_supported_title">"Прасторы пакуль не падтрымліваюцца"</string>
<string name="screen_join_room_subtitle_knock">"Націсніце кнопку ніжэй, і адміністратар пакоя атрымае апавяшчэнне. Вы зможаце далучыцца да размовы пасля зацвярджэння."</string> <string name="screen_join_room_subtitle_knock">"Націсніце кнопку ніжэй, і адміністратар пакоя атрымае апавяшчэнне. Вы зможаце далучыцца да размовы пасля зацвярджэння."</string>
<string name="screen_join_room_subtitle_no_preview">"Вы павінны быць удзельнікам гэтага пакоя каб прагледзець гісторыю паведамленняў."</string> <string name="screen_join_room_subtitle_no_preview">"Вы павінны быць удзельнікам гэтага пакоя каб прагледзець гісторыю паведамленняў."</string>
<string name="screen_join_room_title_knock">"Вы хочаце далучыцца да гэтага пакоя?"</string> <string name="screen_join_room_title_knock">"Вы хочаце далучыцца да гэтага пакоя?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Připojit se do místnosti"</string> <string name="screen_join_room_join_action">"Připojit se do místnosti"</string>
<string name="screen_join_room_knock_action">"Zaklepejte a připojte se"</string> <string name="screen_join_room_knock_action">"Zaklepejte a připojte se"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s zatím nepodporuje prostory. Prostory můžete používat na webu."</string>
<string name="screen_join_room_space_not_supported_title">"Prostory zatím nejsou podporovány"</string>
<string name="screen_join_room_subtitle_knock">"Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."</string> <string name="screen_join_room_subtitle_knock">"Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."</string>
<string name="screen_join_room_subtitle_no_preview">"Pro zobrazení historie zpráv musíte být členem této místnosti."</string> <string name="screen_join_room_subtitle_no_preview">"Pro zobrazení historie zpráv musíte být členem této místnosti."</string>
<string name="screen_join_room_title_knock">"Chcete se připojit k této místnosti?"</string> <string name="screen_join_room_title_knock">"Chcete se připojit k této místnosti?"</string>

View file

@ -2,7 +2,9 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Raum beitreten"</string> <string name="screen_join_room_join_action">"Raum beitreten"</string>
<string name="screen_join_room_knock_action">"Anklopfen"</string> <string name="screen_join_room_knock_action">"Anklopfen"</string>
<string name="screen_join_room_subtitle_knock">"Klopfe an um einen Raumadministrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."</string> <string name="screen_join_room_space_not_supported_description">"%1$s unterstützt noch keine Spaces. Du kannst auf Spaces im Web zugreifen."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces werden noch nicht unterstützt"</string>
<string name="screen_join_room_subtitle_knock">"Klopfe an um einen Administrator zu benachrichtigen. Nach der Freigabe kannst du dich an der Unterhaltung beteiligen."</string>
<string name="screen_join_room_subtitle_no_preview">"Du musst Mitglied in diesem Raum sein, um den Nachrichtenverlauf zu sehen."</string> <string name="screen_join_room_subtitle_no_preview">"Du musst Mitglied in diesem Raum sein, um den Nachrichtenverlauf zu sehen."</string>
<string name="screen_join_room_title_knock">"Willst du diesem Raum beitreten?"</string> <string name="screen_join_room_title_knock">"Willst du diesem Raum beitreten?"</string>
<string name="screen_join_room_title_no_preview">"Vorschau nicht verfügbar"</string> <string name="screen_join_room_title_no_preview">"Vorschau nicht verfügbar"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Rejoindre"</string> <string name="screen_join_room_join_action">"Rejoindre"</string>
<string name="screen_join_room_knock_action">"Demander à joindre"</string> <string name="screen_join_room_knock_action">"Demander à joindre"</string>
<string name="screen_join_room_space_not_supported_description">"Les Spaces ne sont pas encore pris en charge par %1$s . Vous pouvez voir les Spaces sur le Web."</string>
<string name="screen_join_room_space_not_supported_title">"Les Spaces ne sont pas encore pris en charge"</string>
<string name="screen_join_room_subtitle_knock">"Cliquez ci-dessous et un administrateur sera prévenu. Une fois votre demande approuvée, pour pourrez rejoindre la discussion."</string> <string name="screen_join_room_subtitle_knock">"Cliquez ci-dessous et un administrateur sera prévenu. Une fois votre demande approuvée, pour pourrez rejoindre la discussion."</string>
<string name="screen_join_room_subtitle_no_preview">"Vous devez être un membre du salon pour pouvoir lire lhistorique des messages."</string> <string name="screen_join_room_subtitle_no_preview">"Vous devez être un membre du salon pour pouvoir lire lhistorique des messages."</string>
<string name="screen_join_room_title_knock">"Vous souhaitez rejoindre ce salon?"</string> <string name="screen_join_room_title_knock">"Vous souhaitez rejoindre ce salon?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Csatlakozás a szobához"</string> <string name="screen_join_room_join_action">"Csatlakozás a szobához"</string>
<string name="screen_join_room_knock_action">"Kopogtasson a csatlakozáshoz"</string> <string name="screen_join_room_knock_action">"Kopogtasson a csatlakozáshoz"</string>
<string name="screen_join_room_space_not_supported_description">"Az %1$s még nem támogatja a tereket. A tereket a weben érheti el."</string>
<string name="screen_join_room_space_not_supported_title">"A terek még nem támogatottak"</string>
<string name="screen_join_room_subtitle_knock">"Kattintson az alábbi gombra, és a szoba adminisztrátora értesítést kap. A jóváhagyást követően csatlakozhat a beszélgetéshez."</string> <string name="screen_join_room_subtitle_knock">"Kattintson az alábbi gombra, és a szoba adminisztrátora értesítést kap. A jóváhagyást követően csatlakozhat a beszélgetéshez."</string>
<string name="screen_join_room_subtitle_no_preview">"Az üzenetelőzmények megtekintéséhez a szoba tagjának kell lennie."</string> <string name="screen_join_room_subtitle_no_preview">"Az üzenetelőzmények megtekintéséhez a szoba tagjának kell lennie."</string>
<string name="screen_join_room_title_knock">"Csatlakozna ehhez a szobához?"</string> <string name="screen_join_room_title_knock">"Csatlakozna ehhez a szobához?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Pripojiť sa do miestnosti"</string> <string name="screen_join_room_join_action">"Pripojiť sa do miestnosti"</string>
<string name="screen_join_room_knock_action">"Zaklopaním sa pripojíte"</string> <string name="screen_join_room_knock_action">"Zaklopaním sa pripojíte"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s zatiaľ nepodporuje priestory. K priestorom môžete pristupovať na webe."</string>
<string name="screen_join_room_space_not_supported_title">"Priestory zatiaľ nie sú podporované"</string>
<string name="screen_join_room_subtitle_knock">"Kliknite na tlačidlo nižšie a správca miestnosti bude informovaný. Po schválení sa budete môcť pripojiť ku konverzácii."</string> <string name="screen_join_room_subtitle_knock">"Kliknite na tlačidlo nižšie a správca miestnosti bude informovaný. Po schválení sa budete môcť pripojiť ku konverzácii."</string>
<string name="screen_join_room_subtitle_no_preview">"Ak chcete zobraziť históriu správ, musíte byť členom tejto miestnosti."</string> <string name="screen_join_room_subtitle_no_preview">"Ak chcete zobraziť históriu správ, musíte byť členom tejto miestnosti."</string>
<string name="screen_join_room_title_knock">"Chcete sa pripojiť do tejto miestnosti?"</string> <string name="screen_join_room_title_knock">"Chcete sa pripojiť do tejto miestnosti?"</string>

View file

@ -2,6 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_join_room_join_action">"Join room"</string> <string name="screen_join_room_join_action">"Join room"</string>
<string name="screen_join_room_knock_action">"Knock to join"</string> <string name="screen_join_room_knock_action">"Knock to join"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s does not support spaces yet. You can access spaces on web."</string>
<string name="screen_join_room_space_not_supported_title">"Spaces are not supported yet"</string>
<string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string> <string name="screen_join_room_subtitle_knock">"Click the button below and a room administrator will be notified. Youll be able to join the conversation once approved."</string>
<string name="screen_join_room_subtitle_no_preview">"You must be a member of this room to view the message history."</string> <string name="screen_join_room_subtitle_no_preview">"You must be a member of this room to view the message history."</string>
<string name="screen_join_room_title_knock">"Want to join this room?"</string> <string name="screen_join_room_title_knock">"Want to join this room?"</string>

View file

@ -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"); * Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License. * you may not use this file except in compliance with the License.
@ -14,12 +14,13 @@
* limitations under the License. * limitations under the License.
*/ */
package io.element.android.features.invite.api package io.element.android.features.joinroom.impl
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
interface SeenInvitesStore { class FakeKnockRoom(
fun seenRoomIds(): Flow<Set<RoomId>> var lambda: (RoomId) -> Result<Unit> = { Result.success(Unit) }
suspend fun markAsSeen(roomIds: Set<RoomId>) ) : KnockRoom {
override suspend fun invoke(roomId: RoomId) = lambda(roomId)
} }

View file

@ -20,15 +20,27 @@ import com.google.common.truth.Truth.assertThat
import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents import io.element.android.features.invite.api.response.AcceptDeclineInviteEvents
import io.element.android.features.invite.api.response.AcceptDeclineInviteState import io.element.android.features.invite.api.response.AcceptDeclineInviteState
import io.element.android.features.invite.api.response.anAcceptDeclineInviteState import io.element.android.features.invite.api.response.anAcceptDeclineInviteState
import io.element.android.features.joinroom.impl.di.KnockRoom
import io.element.android.features.roomdirectory.api.RoomDescription import io.element.android.features.roomdirectory.api.RoomDescription
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
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.RoomId
import io.element.android.libraries.matrix.api.core.UserId
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.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomType
import io.element.android.libraries.matrix.api.room.preview.RoomPreview
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_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_NAME import io.element.android.libraries.matrix.test.A_ROOM_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.aRoomInfo import io.element.android.libraries.matrix.test.room.aRoomInfo
import io.element.android.libraries.matrix.test.room.aRoomMember
import io.element.android.libraries.matrix.ui.model.toInviteSender
import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert import io.element.android.tests.testutils.lambda.assert
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
@ -49,9 +61,11 @@ class JoinRoomPresenterTest {
val presenter = createJoinRoomPresenter() val presenter = createJoinRoomPresenter()
presenter.test { presenter.test {
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID)) assertThat(state.contentState).isEqualTo(ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias()))
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.Unknown)
assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState()) assertThat(state.acceptDeclineInviteState).isEqualTo(anAcceptDeclineInviteState())
assertThat(state.applicationName).isEqualTo("AppName")
cancelAndIgnoreRemainingEvents()
} }
} }
} }
@ -96,7 +110,31 @@ class JoinRoomPresenterTest {
presenter.test { presenter.test {
skipItems(1) skipItems(1)
awaitItem().also { state -> awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited) assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(null))
}
}
}
@Test
fun `present - when room is invited then join authorization is equal to invited, an inviter is provided`() = runTest {
val inviter = aRoomMember(userId = UserId("@bob:example.com"), displayName = "Bob")
val expectedInviteSender = inviter.toInviteSender()
val roomInfo = aRoomInfo(
currentUserMembership = CurrentUserMembership.INVITED,
inviter = inviter,
)
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { _ ->
flowOf(Optional.of(roomInfo))
}
}
val presenter = createJoinRoomPresenter(
matrixClient = matrixClient
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.joinAuthorisationStatus).isEqualTo(JoinAuthorisationStatus.IsInvited(expectedInviteSender))
} }
} }
} }
@ -237,16 +275,158 @@ class JoinRoomPresenterTest {
} }
} }
@Test
fun `present - emit knock room event`() = runTest {
val knockRoomSuccess = lambdaRecorder { _: RoomId ->
Result.success(Unit)
}
val knockRoomFailure = lambdaRecorder { roomId: RoomId ->
Result.failure<Unit>(RuntimeException("Failed to knock room $roomId"))
}
val fakeKnockRoom = FakeKnockRoom(knockRoomSuccess)
val presenter = createJoinRoomPresenter(knockRoom = fakeKnockRoom)
presenter.test {
skipItems(1)
awaitItem().also { state ->
state.eventSink(JoinRoomEvents.KnockRoom)
}
awaitItem().also { state ->
assertThat(state.knockAction).isEqualTo(AsyncAction.Success(Unit))
fakeKnockRoom.lambda = knockRoomFailure
state.eventSink(JoinRoomEvents.KnockRoom)
}
awaitItem().also { state ->
assertThat(state.knockAction).isInstanceOf(AsyncAction.Failure::class.java)
}
}
assert(knockRoomSuccess)
.isCalledOnce()
.with(value(A_ROOM_ID))
assert(knockRoomFailure)
.isCalledOnce()
.with(value(A_ROOM_ID))
}
@Test
fun `present - when room is not known RoomPreview is loaded`() = runTest {
val client = FakeMatrixClient(
getRoomPreviewResult = {
Result.success(
RoomPreview(
roomId = A_ROOM_ID,
canonicalAlias = RoomAlias("#alias:matrix.org"),
name = "Room name",
topic = "Room topic",
avatarUrl = "avatarUrl",
numberOfJoinedMembers = 2,
roomType = RoomType.Room,
isHistoryWorldReadable = false,
isJoined = false,
isInvited = false,
isPublic = true,
canKnock = false,
)
)
}
)
val presenter = createJoinRoomPresenter(
matrixClient = client
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.contentState).isEqualTo(
ContentState.Loaded(
roomId = A_ROOM_ID,
name = "Room name",
topic = "Room topic",
alias = RoomAlias("#alias:matrix.org"),
numberOfMembers = 2,
isDirect = false,
roomType = RoomType.Room,
roomAvatarUrl = "avatarUrl",
joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin
)
)
}
}
}
@Test
fun `present - when room is not known RoomPreview is loaded with error`() = runTest {
val client = FakeMatrixClient(
getRoomPreviewResult = {
Result.failure(AN_EXCEPTION)
}
)
val presenter = createJoinRoomPresenter(
matrixClient = client
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.contentState).isEqualTo(
ContentState.Failure(
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
error = AN_EXCEPTION
)
)
state.eventSink(JoinRoomEvents.RetryFetchingContent)
}
skipItems(1)
awaitItem().also { state ->
assertThat(state.contentState).isEqualTo(
ContentState.Loading(A_ROOM_ID.toRoomIdOrAlias())
)
}
awaitItem().also { state ->
assertThat(state.contentState).isEqualTo(
ContentState.Failure(
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
error = AN_EXCEPTION
)
)
}
}
}
@Test
fun `present - when room is not known RoomPreview is loaded with error 403`() = runTest {
val client = FakeMatrixClient(
getRoomPreviewResult = {
Result.failure(Exception("403"))
}
)
val presenter = createJoinRoomPresenter(
matrixClient = client
)
presenter.test {
skipItems(1)
awaitItem().also { state ->
assertThat(state.contentState).isEqualTo(
ContentState.UnknownRoom(
roomIdOrAlias = A_ROOM_ID.toRoomIdOrAlias(),
)
)
}
}
}
private fun createJoinRoomPresenter( private fun createJoinRoomPresenter(
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
roomDescription: Optional<RoomDescription> = Optional.empty(), roomDescription: Optional<RoomDescription> = Optional.empty(),
matrixClient: MatrixClient = FakeMatrixClient(), matrixClient: MatrixClient = FakeMatrixClient(),
knockRoom: KnockRoom = FakeKnockRoom(),
buildMeta: BuildMeta = aBuildMeta(applicationName = "AppName"),
acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() } acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState> = Presenter { anAcceptDeclineInviteState() }
): JoinRoomPresenter { ): JoinRoomPresenter {
return JoinRoomPresenter( return JoinRoomPresenter(
roomId = roomId, roomId = roomId,
roomIdOrAlias = roomId.toRoomIdOrAlias(),
roomDescription = roomDescription, roomDescription = roomDescription,
matrixClient = matrixClient, matrixClient = matrixClient,
knockRoom = knockRoom,
buildMeta = buildMeta,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter acceptDeclineInvitePresenter = acceptDeclineInvitePresenter
) )
} }
@ -255,7 +435,7 @@ class JoinRoomPresenterTest {
roomId: RoomId = A_ROOM_ID, roomId: RoomId = A_ROOM_ID,
name: String? = A_ROOM_NAME, name: String? = A_ROOM_NAME,
topic: String? = "A room about something", topic: String? = "A room about something",
alias: String? = "#alias:matrix.org", alias: RoomAlias? = RoomAlias("#alias:matrix.org"),
avatarUrl: String? = null, avatarUrl: String? = null,
joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN, joinRule: RoomDescription.JoinRule = RoomDescription.JoinRule.UNKNOWN,
numberOfMembers: Long = 2L numberOfMembers: Long = 2L

View file

@ -0,0 +1,161 @@
/*
* 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.joinroom.impl
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.junit4.AndroidComposeTestRule
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.test.ext.junit.runners.AndroidJUnit4
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.room.RoomType
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
@RunWith(AndroidJUnit4::class)
class JoinRoomViewTest {
@get:Rule val rule = createAndroidComposeRule<ComponentActivity>()
@Test
fun `clicking on back invoke the expected callback`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce {
rule.setJoinRoomView(
aJoinRoomState(
eventSink = eventsRecorder,
),
onBackPressed = it
)
rule.pressBack()
}
}
@Test
fun `clicking on Join room on CanJoin room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanJoin),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_join_room_join_action)
eventsRecorder.assertSingle(JoinRoomEvents.JoinRoom)
}
@Test
fun `clicking on Knock room on CanKnock room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
eventSink = eventsRecorder,
),
)
rule.clickOn(R.string.screen_join_room_knock_action)
eventsRecorder.assertSingle(JoinRoomEvents.KnockRoom)
}
@Test
fun `clicking on closing Knock error emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.CanKnock),
knockAction = AsyncAction.Failure(Exception("Error")),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_ok)
eventsRecorder.assertSingle(JoinRoomEvents.ClearError)
}
@Test
fun `clicking on Accept invitation IsInvited room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_accept)
eventsRecorder.assertSingle(JoinRoomEvents.AcceptInvite)
}
@Test
fun `clicking on Decline invitation on IsInvited room emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(joinAuthorisationStatus = JoinAuthorisationStatus.IsInvited(null)),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_decline)
eventsRecorder.assertSingle(JoinRoomEvents.DeclineInvite)
}
@Test
fun `clicking on Retry when an error occurs emits the expected Event`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>()
rule.setJoinRoomView(
aJoinRoomState(
contentState = aFailureContentState(),
eventSink = eventsRecorder,
),
)
rule.clickOn(CommonStrings.action_retry)
eventsRecorder.assertSingle(JoinRoomEvents.RetryFetchingContent)
}
@Test
fun `clicking on Go back when a space is displayed invokes the expected callback`() {
val eventsRecorder = EventsRecorder<JoinRoomEvents>(expectEvents = false)
ensureCalledOnce {
rule.setJoinRoomView(
aJoinRoomState(
contentState = aLoadedContentState(roomType = RoomType.Space),
eventSink = eventsRecorder,
),
onBackPressed = it
)
rule.clickOn(CommonStrings.action_go_back)
}
}
}
private fun <R : TestRule> AndroidComposeTestRule<R, ComponentActivity>.setJoinRoomView(
state: JoinRoomState,
onBackPressed: () -> Unit = EnsureNeverCalled(),
onKnockSuccess: () -> Unit = EnsureNeverCalled(),
) {
setContent {
JoinRoomView(
state = state,
onBackPressed = onBackPressed,
onKnockSuccess = onKnockSuccess,
)
}
}

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Bist du sicher, dass du diese Unterhaltung verlassen willst? Diese Unterhaltung ist nicht öffentlich und du kannst ihr ohne Einladung nicht wieder beitreten."</string> <string name="leave_conversation_alert_subtitle">"Bist du sicher, dass du diese Unterhaltung verlassen willst? Diese Unterhaltung ist nicht öffentlich und du kannst ihr ohne Einladung nicht wieder beitreten."</string>
<string name="leave_room_alert_empty_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Du bist die einzige Person hier. Wenn du austritst, kann in Zukunft niemand mehr eintreten, auch du nicht."</string> <string name="leave_room_alert_empty_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Du bist die einzige Person hier. Wenn du gehst, kann in Zukunft niemand mehr eintreten, auch du nicht."</string>
<string name="leave_room_alert_private_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne Einladung nicht erneut beitreten."</string> <string name="leave_room_alert_private_subtitle">"Bist du sicher, dass du diesen Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne Einladung nicht erneut beitreten."</string>
<string name="leave_room_alert_subtitle">"Bist du sicher, dass du den Raum verlassen willst?"</string> <string name="leave_room_alert_subtitle">"Bist du sicher, dass du den Raum verlassen willst?"</string>
</resources> </resources>

View file

@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="leave_conversation_alert_subtitle">"Är du säker på att du vill lämna den här konversationen? Den här konversationen är inte offentlig och du kommer inte att kunna gå med igen utan en inbjudan."</string>
<string name="leave_room_alert_empty_subtitle">"Är du säker på att du vill lämna det här rummet? Du är den enda personen här. Om du lämnar kommer ingen att kunna gå med i framtiden, inklusive du."</string> <string name="leave_room_alert_empty_subtitle">"Är du säker på att du vill lämna det här rummet? Du är den enda personen här. Om du lämnar kommer ingen att kunna gå med i framtiden, inklusive du."</string>
<string name="leave_room_alert_private_subtitle">"Är du säker på att du vill lämna det här rummet? Detta rum är inte offentligt och du kommer inte att kunna gå med igen utan en inbjudan."</string> <string name="leave_room_alert_private_subtitle">"Är du säker på att du vill lämna det här rummet? Detta rum är inte offentligt och du kommer inte att kunna gå med igen utan en inbjudan."</string>
<string name="leave_room_alert_subtitle">"Är du säker på att du vill lämna rummet?"</string> <string name="leave_room_alert_subtitle">"Är du säker på att du vill lämna rummet?"</string>

View file

@ -358,7 +358,7 @@ private fun PinUnlockFooter(
@Composable @Composable
@PreviewsDayNight @PreviewsDayNight
internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { internal fun PinUnlockViewInAppPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview { ElementPreview {
PinUnlockView( PinUnlockView(
state = state, state = state,
@ -369,7 +369,7 @@ internal fun PinUnlockInAppViewPreview(@PreviewParameter(PinUnlockStateProvider:
@Composable @Composable
@PreviewsDayNight @PreviewsDayNight
internal fun PinUnlockDefaultViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) { internal fun PinUnlockViewPreview(@PreviewParameter(PinUnlockStateProvider::class) state: PinUnlockState) {
ElementPreview { ElementPreview {
PinUnlockView( PinUnlockView(
state = state, state = state,

View file

@ -16,7 +16,7 @@
<string name="screen_app_lock_setup_confirm_pin">"PIN bestätigen"</string> <string name="screen_app_lock_setup_confirm_pin">"PIN bestätigen"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Aus Sicherheitsgründen kann dieser PIN-Code nicht verwendet werden."</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Aus Sicherheitsgründen kann dieser PIN-Code nicht verwendet werden."</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Bitte eine andere PIN verwenden."</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Bitte eine andere PIN verwenden."</string>
<string name="screen_app_lock_setup_pin_context">"Sperre %1$s mit einem PIN Code, um den Zugriff auf Deine Chats zu beschränken. <string name="screen_app_lock_setup_pin_context">"Sperre %1$s mit einem PIN Code, um den Zugriff auf deine Chats zu beschränken.
Wähle etwas Einprägsames. Bei falscher Eingabe wirst du aus der App ausgeloggt."</string> Wähle etwas Einprägsames. Bei falscher Eingabe wirst du aus der App ausgeloggt."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Bitte gib die gleiche PIN wie zuvor ein."</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Bitte gib die gleiche PIN wie zuvor ein."</string>

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_biometric_authentication">"Authentification biométrique"</string> <string name="screen_app_lock_biometric_authentication">"authentification biométrique"</string>
<string name="screen_app_lock_biometric_unlock">"Déverrouillage biométrique"</string> <string name="screen_app_lock_biometric_unlock">"déverrouillage biométrique"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string> <string name="screen_app_lock_biometric_unlock_title_android">"Déverrouiller avec la biométrie"</string>
<string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string> <string name="screen_app_lock_forgot_pin">"Code PIN oublié?"</string>
<string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string> <string name="screen_app_lock_settings_change_pin">"Modifier le code PIN"</string>

View file

@ -1,11 +1,27 @@
<?xml version="1.0" encoding="utf-8"?> <?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2"> <resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_app_lock_biometric_authentication">"biometrisk autentisering"</string>
<string name="screen_app_lock_biometric_unlock">"biometrisk upplåsning"</string>
<string name="screen_app_lock_biometric_unlock_title_android">"Lås upp med biometri"</string>
<string name="screen_app_lock_forgot_pin">"Glömt PIN-kod?"</string> <string name="screen_app_lock_forgot_pin">"Glömt PIN-kod?"</string>
<string name="screen_app_lock_settings_change_pin">"Byt PIN-kod"</string> <string name="screen_app_lock_settings_change_pin">"Byt PIN-kod"</string>
<string name="screen_app_lock_settings_enable_biometric_unlock">"Tillåt biometrisk upplåsning"</string> <string name="screen_app_lock_settings_enable_biometric_unlock">"Tillåt biometrisk upplåsning"</string>
<string name="screen_app_lock_settings_remove_pin">"Ta bort PIN-kod"</string> <string name="screen_app_lock_settings_remove_pin">"Ta bort PIN-kod"</string>
<string name="screen_app_lock_settings_remove_pin_alert_message">"Är du säker på att du vill ta bort PIN-koden?"</string> <string name="screen_app_lock_settings_remove_pin_alert_message">"Är du säker på att du vill ta bort PIN-koden?"</string>
<string name="screen_app_lock_settings_remove_pin_alert_title">"Ta bort PIN-koden?"</string> <string name="screen_app_lock_settings_remove_pin_alert_title">"Ta bort PIN-koden?"</string>
<string name="screen_app_lock_setup_biometric_unlock_allow_title">"Tillåt %1$s"</string>
<string name="screen_app_lock_setup_biometric_unlock_skip">"Jag vill hellre använda PIN-kod"</string>
<string name="screen_app_lock_setup_biometric_unlock_subtitle">"Bespara dig själv lite tid och använd %1$s för att låsa upp appen varje gång"</string>
<string name="screen_app_lock_setup_choose_pin">"Välj PIN-kod"</string>
<string name="screen_app_lock_setup_confirm_pin">"Bekräfta PIN-kod"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"Du kan inte välja detta som din PIN-kod av säkerhetsskäl"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"Välj en annan PIN-kod"</string>
<string name="screen_app_lock_setup_pin_context">"Lås %1$s för att lägga till extra säkerhet i dina chattar.
Välj något minnesvärt. Om du glömmer den här PIN-koden loggas du ut från appen."</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"Ange samma PIN-kod två gånger"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN-koder matchar inte"</string>
<string name="screen_app_lock_signout_alert_message">"Du måste logga in igen och skapa en ny PIN-kod för att fortsätta"</string>
<string name="screen_app_lock_signout_alert_title">"Du blir utloggad"</string> <string name="screen_app_lock_signout_alert_title">"Du blir utloggad"</string>
<plurals name="screen_app_lock_subtitle"> <plurals name="screen_app_lock_subtitle">
<item quantity="one">"Du har %1$d försök att låsa upp"</item> <item quantity="one">"Du har %1$d försök att låsa upp"</item>
@ -15,5 +31,7 @@
<item quantity="one">"Fel PIN-kod. Du har %1$d försök kvar"</item> <item quantity="one">"Fel PIN-kod. Du har %1$d försök kvar"</item>
<item quantity="other">"Fel PIN-kod. Du har %1$d försök kvar"</item> <item quantity="other">"Fel PIN-kod. Du har %1$d försök kvar"</item>
</plurals> </plurals>
<string name="screen_app_lock_use_biometric_android">"Använd biometri"</string>
<string name="screen_app_lock_use_pin_android">"Använd PIN-kod"</string>
<string name="screen_signout_in_progress_dialog_content">"Loggar ut …"</string> <string name="screen_signout_in_progress_dialog_content">"Loggar ut …"</string>
</resources> </resources>

View file

@ -15,6 +15,9 @@
<string name="screen_app_lock_setup_confirm_pin">"確認 PIN 碼"</string> <string name="screen_app_lock_setup_confirm_pin">"確認 PIN 碼"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"基於安全性的考量,您選的 PIN 碼無法使用"</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_content">"基於安全性的考量,您選的 PIN 碼無法使用"</string>
<string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"選擇不一樣的 PIN 碼"</string> <string name="screen_app_lock_setup_pin_blacklisted_dialog_title">"選擇不一樣的 PIN 碼"</string>
<string name="screen_app_lock_setup_pin_context">"將 %1$s 上鎖,為你的聊天室添加一層防護。
請選擇好記憶的數字。如果忘記 PIN 碼,您會被登出。"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_content">"請輸入相同的 PIN 碼兩次"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_content">"請輸入相同的 PIN 碼兩次"</string>
<string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN 碼不一樣"</string> <string name="screen_app_lock_setup_pin_mismatch_dialog_title">"PIN 碼不一樣"</string>
<string name="screen_app_lock_signout_alert_message">"您需要重新登入並建立新的 PIN 碼才能繼續"</string> <string name="screen_app_lock_signout_alert_message">"您需要重新登入並建立新的 PIN 碼才能繼續"</string>

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