Merge branch 'release/0.2.4' into main

This commit is contained in:
Benoit Marty 2023-10-12 11:05:42 +02:00
commit 58aefa88ed
518 changed files with 6096 additions and 2344 deletions

View file

@ -38,7 +38,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
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 APK
@ -55,7 +55,7 @@ jobs:
name: elementx-debug name: elementx-debug
path: | path: |
app/build/outputs/apk/debug/*.apk app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.5.2 - uses: rnkdsh/action-upload-diawi@v1.5.3
id: diawi id: diawi
# Do not fail the whole build if Diawi upload fails # Do not fail the whole build if Diawi upload fails
continue-on-error: true continue-on-error: true

View file

@ -40,7 +40,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- uses: mobile-dev-inc/action-maestro-cloud@v1.5.0 - uses: mobile-dev-inc/action-maestro-cloud@v1.6.0
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):

View file

@ -62,7 +62,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis - name: Dependency analysis

View file

@ -40,7 +40,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run code quality check suite - name: Run code quality check suite

View file

@ -24,7 +24,7 @@ jobs:
java-version: '17' java-version: '17'
# Add gradle cache, this should speed up the process # Add gradle cache, this should speed up the process
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Record screenshots - name: Record screenshots

View file

@ -25,7 +25,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
- name: Create app bundle - name: Create app bundle
env: env:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }} ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}

View file

@ -32,7 +32,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: 🔊 Publish results to Sonar - name: 🔊 Publish results to Sonar

View file

@ -44,7 +44,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17' java-version: '17'
- name: Configure gradle - name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1 uses: gradle/gradle-build-action@v2.9.0
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}

View file

@ -4,6 +4,7 @@
<w>backstack</w> <w>backstack</w>
<w>ftue</w> <w>ftue</w>
<w>homeserver</w> <w>homeserver</w>
<w>konsist</w>
<w>kover</w> <w>kover</w>
<w>measurables</w> <w>measurables</w>
<w>onboarding</w> <w>onboarding</w>

View file

@ -10,9 +10,9 @@ appId: ${APP_ID}
- tapOn: - tapOn:
id: "change_server-server" id: "change_server-server"
# Test server that does not support sliding sync. # Test server that does not support sliding sync.
- inputText: "gnuradio" - inputText: "https://kieranml.ems-support.element.dev"
- hideKeyboard - hideKeyboard
- tapOn: "gnuradio.org" - tapOn: "kieranml.ems-support.element.dev"
- extendedWaitUntil: - extendedWaitUntil:
visible: "This server currently doesnt support sliding sync." visible: "This server currently doesnt support sliding sync."
timeout: 10000 timeout: 10000

View file

@ -1,3 +1,24 @@
Changes in Element X v0.2.4 (2023-10-12)
========================================
Features ✨
----------
- [Rich text editor] Add full screen mode ([#1447](https://github.com/vector-im/element-x-android/issues/1447))
- Improve rendering of m.emote. ([#1497](https://github.com/vector-im/element-x-android/issues/1497))
- Improve deleted session behavior. ([#1520](https://github.com/vector-im/element-x-android/issues/1520))
Bugfixes 🐛
----------
- WebP images can't be sent as media. ([#1483](https://github.com/vector-im/element-x-android/issues/1483))
- Fix back button not working in bottom sheets. ([#1517](https://github.com/vector-im/element-x-android/issues/1517))
- Render body of unknown msgtype in the timeline and in the room list ([#1539](https://github.com/vector-im/element-x-android/issues/1539))
Other changes
-------------
- Room : makes subscribeToSync/unsubscribeFromSync suspendable. ([#1457](https://github.com/vector-im/element-x-android/issues/1457))
- Add some Konsist tests. ([#1526](https://github.com/vector-im/element-x-android/issues/1526))
Changes in Element X v0.2.3 (2023-09-27) Changes in Element X v0.2.3 (2023-09-27)
======================================== ========================================

View file

@ -18,6 +18,7 @@
* [knit](#knit) * [knit](#knit)
* [lint](#lint) * [lint](#lint)
* [Unit tests](#unit-tests) * [Unit tests](#unit-tests)
* [konsist](#konsist)
* [Tests](#tests) * [Tests](#tests)
* [Accessibility](#accessibility) * [Accessibility](#accessibility)
* [Jetpack Compose](#jetpack-compose) * [Jetpack Compose](#jetpack-compose)
@ -156,6 +157,10 @@ Make sure the following commands execute without any error:
./gradlew test ./gradlew test
</pre> </pre>
#### konsist
[konsist](https://github.com/LemonAppDev/konsist) is setup in the project to check that the architecture and the naming rules are followed. Konsist tests are classical Unit tests.
### Tests ### Tests
Element X is currently supported on Android Marshmallow (API 23+): please test your change on an Android device (or Android emulator) running with API 23. Many issues can happen (including crashes) on older devices. Element X is currently supported on Android Marshmallow (API 23+): please test your change on an Android device (or Android emulator) running with API 23. Many issues can happen (including crashes) on older devices.

View file

@ -230,6 +230,7 @@ dependencies {
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(libs.test.konsist)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)
} }

View file

@ -20,14 +20,16 @@
<!-- To be able to install APK from the application --> <!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Do not enable enableOnBackInvokedCallback until https://issuetracker.google.com/issues/271303558 is fixed -->
<application <application
android:name=".ElementXApplication" android:name=".ElementXApplication"
android:allowBackup="false" android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true" android:enableOnBackInvokedCallback="false"
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:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round" android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.ElementX" android:theme="@style/Theme.ElementX"
@ -81,11 +83,12 @@
tools:node="remove" /> tools:node="remove" />
<provider <provider
android:authorities="${applicationId}.fileprovider"
android:name="androidx.core.content.FileProvider" android:name="androidx.core.content.FileProvider"
android:grantUriPermissions="true" android:authorities="${applicationId}.fileprovider"
android:exported="false"> android:exported="false"
<meta-data android:name="android.support.FILE_PROVIDER_PATHS" android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_providers" /> android:resource="@xml/file_providers" />
</provider> </provider>

Binary file not shown.

Before

Width:  |  Height:  |  Size: 260 KiB

After

Width:  |  Height:  |  Size: 263 KiB

Before After
Before After

View file

@ -34,7 +34,7 @@ import com.bumble.appyx.core.integrationpoint.NodeComponentActivity
import com.bumble.appyx.core.plugin.NodeReadyObserver import com.bumble.appyx.core.plugin.NodeReadyObserver
import io.element.android.libraries.architecture.bindings import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.designsystem.utils.LocalSnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.LocalSnackbarDispatcher
import io.element.android.libraries.theme.ElementTheme import io.element.android.libraries.theme.ElementTheme
import io.element.android.x.di.AppBindings import io.element.android.x.di.AppBindings
import io.element.android.x.intent.SafeUriHandler import io.element.android.x.intent.SafeUriHandler

View file

@ -18,7 +18,7 @@ package io.element.android.x.di
import com.squareup.anvil.annotations.ContributesTo import com.squareup.anvil.annotations.ContributesTo
import io.element.android.features.rageshake.api.reporter.BugReporter import io.element.android.features.rageshake.api.reporter.BugReporter
import io.element.android.libraries.designsystem.utils.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.matrix.api.tracing.TracingService import io.element.android.libraries.matrix.api.tracing.TracingService

View file

@ -28,7 +28,7 @@ import io.element.android.features.messages.impl.timeline.components.customreact
import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.meta.BuildMeta import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.designsystem.utils.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.ApplicationContext import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.DefaultPreferences import io.element.android.libraries.di.DefaultPreferences

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.9 KiB

After

Width:  |  Height:  |  Size: 4 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 7.8 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 5.5 KiB

After

Width:  |  Height:  |  Size: 5.5 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 4.6 KiB

After

Width:  |  Height:  |  Size: 4.7 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 3.2 KiB

After

Width:  |  Height:  |  Size: 3.2 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 6 KiB

After

Width:  |  Height:  |  Size: 6 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 13 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 8.1 KiB

After

Width:  |  Height:  |  Size: 8.1 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

After

Width:  |  Height:  |  Size: 26 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 14 KiB

After

Width:  |  Height:  |  Size: 14 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 43 KiB

After

Width:  |  Height:  |  Size: 45 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 22 KiB

Before After
Before After

View file

@ -0,0 +1,35 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<!-- Ref: https://developer.android.com/training/articles/security-config.html -->
<!-- By default, do not allow clearText traffic -->
<base-config cleartextTrafficPermitted="false" />
<!-- Allow clearText traffic on some specified host -->
<domain-config cleartextTrafficPermitted="true">
<!-- Localhost -->
<domain includeSubdomains="true">localhost</domain>
<domain includeSubdomains="true">127.0.0.1</domain>
<!-- Localhost for Android emulator -->
<domain includeSubdomains="true">10.0.2.2</domain>
<!-- Onion services -->
<domain includeSubdomains="true">onion</domain>
<!-- Domains that are used for LANs -->
<!-- These are IANA recognized special use domain names, see https://www.iana.org/assignments/special-use-domain-names/special-use-domain-names.xhtml -->
<domain includeSubdomains="true">home.arpa</domain>
<domain includeSubdomains="true">local</domain> <!-- Note this has been reserved for use with mDNS -->
<domain includeSubdomains="true">test</domain>
<!-- These are observed in the wild either by convention or RFCs that have not been accepted, and are not currently TLDs -->
<domain includeSubdomains="true">home</domain>
<domain includeSubdomains="true">lan</domain>
<domain includeSubdomains="true">localdomain</domain>
</domain-config>
<debug-overrides>
<trust-anchors>
<certificates src="system" />
<certificates src="user" />
</trust-anchors>
</debug-overrides>
</network-security-config>

View file

@ -0,0 +1,139 @@
/*
* 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.app
import androidx.compose.runtime.Composable
import com.lemonappdev.konsist.api.KoModifier
import com.lemonappdev.konsist.api.Konsist
import com.lemonappdev.konsist.api.ext.list.constructors
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutModifier
import com.lemonappdev.konsist.api.ext.list.modifierprovider.withoutOverrideModifier
import com.lemonappdev.konsist.api.ext.list.parameters
import com.lemonappdev.konsist.api.ext.list.properties
import com.lemonappdev.konsist.api.ext.list.withAllAnnotationsOf
import com.lemonappdev.konsist.api.ext.list.withAllParentsOf
import com.lemonappdev.konsist.api.ext.list.withNameEndingWith
import com.lemonappdev.konsist.api.ext.list.withReturnType
import com.lemonappdev.konsist.api.ext.list.withTopLevel
import com.lemonappdev.konsist.api.ext.list.withoutName
import com.lemonappdev.konsist.api.ext.list.withoutNameEndingWith
import com.lemonappdev.konsist.api.verify.assertFalse
import com.lemonappdev.konsist.api.verify.assertTrue
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import org.junit.Test
class KonsistTest {
@Test
fun `Classes extending 'Presenter' should have 'Presenter' suffix`() {
Konsist.scopeFromProject()
.classes()
.withAllParentsOf(Presenter::class)
.assertTrue {
it.name.endsWith("Presenter")
}
}
@Test
fun `Functions with '@PreviewsDayNight' annotation should have 'Preview' suffix`() {
Konsist
.scopeFromProject()
.functions()
.withAllAnnotationsOf(PreviewsDayNight::class)
.assertTrue {
it.hasNameEndingWith("Preview") &&
it.hasNameEndingWith("LightPreview").not() &&
it.hasNameEndingWith("DarkPreview").not()
}
}
@Test
fun `Top level function with '@Composable' annotation starting with a upper case should be placed in a file with the same name`() {
Konsist
.scopeFromProject()
.functions()
.withTopLevel()
.withoutModifier(KoModifier.PRIVATE)
.withoutNameEndingWith("Preview")
.withAllAnnotationsOf(Composable::class)
.withoutName(
// Add some exceptions...
"OutlinedButton",
"TextButton",
"SimpleAlertDialogContent",
)
.assertTrue(
additionalMessage =
"""
Please check the filename. It should match the top level Composable function. If the filename is correct:
- consider making the Composable private or moving it to its own file
- at last resort, you can add an exception in the Konsist test
""".trimIndent()
) {
if (it.name.first().isLowerCase()) {
true
} else {
val fileName = it.containingFile.name.removeSuffix(".kt")
fileName == it.name
}
}
}
@Test
fun `Data class state MUST not have default value`() {
Konsist
.scopeFromProject()
.classes()
.withNameEndingWith("State")
.withoutName(
"CameraPositionState",
)
.constructors
.parameters
.assertTrue { parameterDeclaration ->
parameterDeclaration.defaultValue == null &&
// Using parameterDeclaration.defaultValue == null is not enough apparently,
// Also check that the text does not contain an equal sign
parameterDeclaration.text.contains("=").not()
}
}
@Test
fun `Function which creates Presenter in test MUST be named 'createPresenterName'`() {
Konsist
.scopeFromTest()
.functions()
.withReturnType { it.name.endsWith("Presenter") }
.withoutOverrideModifier()
.assertTrue { functionDeclaration ->
functionDeclaration.name == "create${functionDeclaration.returnType?.name}"
}
}
@Test
fun `no field should have 'm' prefix`() {
Konsist
.scopeFromProject()
.classes()
.properties()
.assertFalse {
val secondCharacterIsUppercase = it.name.getOrNull(1)?.isUpperCase() ?: false
it.name.startsWith('m') && secondCharacterIsUppercase
}
}
}

View file

@ -16,8 +16,8 @@
package io.element.android.appnav package io.element.android.appnav
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
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.VerificationFlowState import io.element.android.libraries.matrix.api.verification.VerificationFlowState

View file

@ -26,8 +26,10 @@ import androidx.lifecycle.Lifecycle
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.lifecycle.repeatOnLifecycle import androidx.lifecycle.repeatOnLifecycle
import com.bumble.appyx.core.composable.Children import com.bumble.appyx.core.composable.Children
import com.bumble.appyx.core.composable.PermanentChild
import com.bumble.appyx.core.lifecycle.subscribe import com.bumble.appyx.core.lifecycle.subscribe
import com.bumble.appyx.core.modality.BuildContext import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin import com.bumble.appyx.core.plugin.Plugin
import com.bumble.appyx.core.plugin.plugins import com.bumble.appyx.core.plugin.plugins
@ -56,7 +58,7 @@ import io.element.android.libraries.architecture.animation.rememberDefaultTransi
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.waitForChildAttached
import io.element.android.libraries.deeplink.DeeplinkData import io.element.android.libraries.deeplink.DeeplinkData
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
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.MAIN_SPACE import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@ -95,6 +97,10 @@ class LoggedInFlowNode @AssistedInject constructor(
initialElement = NavTarget.RoomList, initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap, savedStateMap = buildContext.savedStateMap,
), ),
permanentNavModel = PermanentNavModel(
NavTarget.Permanent,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext, buildContext = buildContext,
plugins = plugins plugins = plugins
) { ) {
@ -328,7 +334,7 @@ class LoggedInFlowNode @AssistedInject constructor(
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState() val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
if (!isFtueDisplayed) { if (!isFtueDisplayed) {
PermanentChild(navTarget = NavTarget.Permanent) PermanentChild(permanentNavModel = permanentNavModel, navTarget = NavTarget.Permanent)
} }
} }
} }

View file

@ -45,6 +45,7 @@ import io.element.android.appnav.root.RootView
import io.element.android.features.login.api.oidc.OidcAction import io.element.android.features.login.api.oidc.OidcAction
import io.element.android.features.login.api.oidc.OidcActionFlow import io.element.android.features.login.api.oidc.OidcActionFlow
import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint import io.element.android.features.rageshake.api.bugreport.BugReportEntryPoint
import io.element.android.features.signedout.api.SignedOutEntryPoint
import io.element.android.libraries.architecture.BackstackNode import io.element.android.libraries.architecture.BackstackNode
import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler import io.element.android.libraries.architecture.animation.rememberDefaultTransitionHandler
import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.createNode
@ -54,6 +55,7 @@ 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.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -69,6 +71,7 @@ class RootFlowNode @AssistedInject constructor(
private val matrixClientsHolder: MatrixClientsHolder, private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter, private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint, private val bugReportEntryPoint: BugReportEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint,
private val intentResolver: IntentResolver, private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow, private val oidcActionFlow: OidcActionFlow,
) : BackstackNode<RootFlowNode.NavTarget>( ) : BackstackNode<RootFlowNode.NavTarget>(
@ -97,13 +100,20 @@ class RootFlowNode @AssistedInject constructor(
.distinctUntilChanged() .distinctUntilChanged()
.onEach { navState -> .onEach { navState ->
Timber.v("navState=$navState") Timber.v("navState=$navState")
if (navState.isLoggedIn) { when (navState.loggedInState) {
tryToRestoreLatestSession( is LoggedInState.LoggedIn -> {
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) }, if (navState.loggedInState.isTokenValid) {
onFailure = { switchToNotLoggedInFlow() } tryToRestoreLatestSession(
) onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
} else { onFailure = { switchToNotLoggedInFlow() }
switchToNotLoggedInFlow() )
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow()
}
} }
} }
.launchIn(lifecycleScope) .launchIn(lifecycleScope)
@ -118,6 +128,10 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.NotLoggedInFlow) backstack.safeRoot(NavTarget.NotLoggedInFlow)
} }
private fun switchToSignedOutFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
}
private suspend fun restoreSessionIfNeeded( private suspend fun restoreSessionIfNeeded(
sessionId: SessionId, sessionId: SessionId,
onFailure: () -> Unit = {}, onFailure: () -> Unit = {},
@ -179,6 +193,11 @@ class RootFlowNode @AssistedInject constructor(
val navId: Int val navId: Int
) : NavTarget ) : NavTarget
@Parcelize
data class SignedOutFlow(
val sessionId: SessionId
) : NavTarget
@Parcelize @Parcelize
data object BugReport : NavTarget data object BugReport : NavTarget
} }
@ -198,6 +217,15 @@ class RootFlowNode @AssistedInject constructor(
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback)) createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
} }
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext) NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext)
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext)
.params(
SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId
)
)
.build()
}
NavTarget.SplashScreen -> splashNode(buildContext) NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.BugReport -> { NavTarget.BugReport -> {
val callback = object : BugReportEntryPoint.Callback { val callback = object : BugReportEntryPoint.Callback {

View file

@ -46,6 +46,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.MatrixRoom import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
@ -60,6 +61,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint, private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint, private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService, private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope,
roomComponentFactory: RoomComponentFactory, roomComponentFactory: RoomComponentFactory,
roomMembershipObserver: RoomMembershipObserver, roomMembershipObserver: RoomMembershipObserver,
) : BackstackNode<RoomLoadedFlowNode.NavTarget>( ) : BackstackNode<RoomLoadedFlowNode.NavTarget>(
@ -91,6 +93,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId) appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers() fetchRoomMembers()
}, },
onResume = {
appCoroutineScope.launch {
inputs.room.subscribeToSync()
}
},
onPause = {
appCoroutineScope.launch {
inputs.room.unsubscribeFromSync()
}
},
onDestroy = { onDestroy = {
Timber.v("OnDestroy") Timber.v("OnDestroy")
appNavigationStateService.onLeavingRoom(id) appNavigationStateService.onLeavingRoom(id)
@ -162,9 +174,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
// because this node enters 'onDestroy' before his children, so it can leads to // because this node enters 'onDestroy' before his children, so it can leads to
// using the room in a child node where it's already closed. // using the room in a child node where it's already closed.
DisposableEffect(Unit) { DisposableEffect(Unit) {
inputs.room.subscribeToSync()
onDispose { onDispose {
inputs.room.unsubscribeFromSync()
if (lifecycle.currentState == Lifecycle.State.DESTROYED) { if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
inputs.room.destroy() inputs.room.destroy()
} }

View file

@ -16,6 +16,8 @@
package io.element.android.appnav.root package io.element.android.appnav.root
import io.element.android.libraries.sessionstorage.api.LoggedInState
/** /**
* [RootNavState] produced by [RootNavStateFlowFactory]. * [RootNavState] produced by [RootNavStateFlowFactory].
*/ */
@ -26,7 +28,7 @@ data class RootNavState(
*/ */
val cacheIndex: Int, val cacheIndex: Int,
/** /**
* true if we are currently loggedIn. * LoggedInState.
*/ */
val isLoggedIn: Boolean val loggedInState: LoggedInState,
) )

View file

@ -22,9 +22,9 @@ import io.element.android.appnav.di.MatrixClientsHolder
import io.element.android.features.login.api.LoginUserStory import io.element.android.features.login.api.LoginUserStory
import io.element.android.features.preferences.api.CacheService import io.element.android.features.preferences.api.CacheService
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.sessionstorage.api.LoggedInState
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import javax.inject.Inject import javax.inject.Inject
@ -47,9 +47,14 @@ class RootNavStateFlowFactory @Inject constructor(
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> { fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine( return combine(
cacheIndexFlow(savedStateMap), cacheIndexFlow(savedStateMap),
isUserLoggedInFlow(), authenticationService.loggedInStateFlow(),
) { cacheIndex, isLoggedIn -> loginUserStory.loginFlowIsDone,
RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn) ) { cacheIndex, loggedInState, loginFlowIsDone ->
if (loginFlowIsDone) {
RootNavState(cacheIndex = cacheIndex, loggedInState = loggedInState)
} else {
RootNavState(cacheIndex = cacheIndex, loggedInState = LoggedInState.NotLoggedIn)
}
} }
} }
@ -72,16 +77,6 @@ class RootNavStateFlowFactory @Inject constructor(
} }
} }
private fun isUserLoggedInFlow(): Flow<Boolean> {
return combine(
authenticationService.isLoggedIn(),
loginUserStory.loginFlowIsDone
) { isLoggedIn, loginFlowIsDone ->
isLoggedIn && loginFlowIsDone
}
.distinctUntilChanged()
}
/** /**
* @return a flow of integer that increments the value by one each time a new element is emitted upstream. * @return a flow of integer that increments the value by one each time a new element is emitted upstream.
*/ */

View file

@ -35,6 +35,8 @@ import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule import org.junit.Rule
import org.junit.Test import org.junit.Test
@ -85,6 +87,7 @@ class RoomFlowNodeTest {
plugins: List<Plugin>, plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(), messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(), roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
coroutineScope: CoroutineScope,
) = RoomLoadedFlowNode( ) = RoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null), buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins, plugins = plugins,
@ -92,16 +95,21 @@ class RoomFlowNodeTest {
roomDetailsEntryPoint = roomDetailsEntryPoint, roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(), appNavigationStateService = FakeAppNavigationStateService(),
roomMembershipObserver = RoomMembershipObserver(), roomMembershipObserver = RoomMembershipObserver(),
appCoroutineScope = coroutineScope,
roomComponentFactory = FakeRoomComponentFactory(), roomComponentFactory = FakeRoomComponentFactory(),
) )
@Test @Test
fun `given a room flow node when initialized then it loads messages entry point`() { fun `given a room flow node when initialized then it loads messages entry point`() = runTest {
// GIVEN // GIVEN
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = RoomLoadedFlowNode.Inputs(room) val inputs = RoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint) val roomFlowNode = aRoomFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
coroutineScope = this
)
// WHEN // WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
@ -113,13 +121,18 @@ class RoomFlowNodeTest {
} }
@Test @Test
fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() { fun `given a room flow node when callback on room details is triggered then it loads room details entry point`() = runTest {
// GIVEN // GIVEN
val room = FakeMatrixRoom() val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint() val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint() val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
val inputs = RoomLoadedFlowNode.Inputs(room) val inputs = RoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint, fakeRoomDetailsEntryPoint) val roomFlowNode = aRoomFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
roomDetailsEntryPoint = fakeRoomDetailsEntryPoint,
coroutineScope = this
)
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper() val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
// WHEN // WHEN
fakeMessagesEntryPoint.callback?.onRoomDetailsClicked() fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()

View file

@ -42,7 +42,7 @@ class RootPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createRootPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -54,7 +54,7 @@ class RootPresenterTest {
@Test @Test
fun `present - passes app error state`() = runTest { fun `present - passes app error state`() = runTest {
val presenter = createPresenter( val presenter = createRootPresenter(
appErrorService = DefaultAppErrorStateService().apply { appErrorService = DefaultAppErrorStateService().apply {
showError("Bad news", "Something bad happened") showError("Bad news", "Something bad happened")
} }
@ -75,7 +75,7 @@ class RootPresenterTest {
} }
} }
private fun createPresenter( private fun createRootPresenter(
appErrorService: AppErrorStateService = DefaultAppErrorStateService() appErrorService: AppErrorStateService = DefaultAppErrorStateService()
): RootPresenter { ): RootPresenter {
val crashDataStore = FakeCrashDataStore() val crashDataStore = FakeCrashDataStore()

View file

@ -0,0 +1,93 @@
/*
* 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.appnav.di
import com.bumble.appyx.core.state.MutableSavedStateMapImpl
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeAuthenticationService
import kotlinx.coroutines.test.runTest
import org.junit.Test
class MatrixClientsHolderTest {
@Test
fun `test getOrNull`() {
val fakeAuthenticationService = FakeAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test getOrRestore`() = runTest {
val fakeAuthenticationService = FakeAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
// Do it again to it the cache
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
@Test
fun `test remove`() = runTest {
val fakeAuthenticationService = FakeAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove
matrixClientsHolder.remove(A_SESSION_ID)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test remove all`() = runTest {
val fakeAuthenticationService = FakeAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrRestore(A_SESSION_ID).getOrNull()).isEqualTo(fakeMatrixClient)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
// Remove all
matrixClientsHolder.removeAll()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
}
@Test
fun `test save and restore`() = runTest {
val fakeAuthenticationService = FakeAuthenticationService()
val matrixClientsHolder = MatrixClientsHolder(fakeAuthenticationService)
val fakeMatrixClient = FakeMatrixClient()
fakeAuthenticationService.givenMatrixClient(fakeMatrixClient)
matrixClientsHolder.getOrRestore(A_SESSION_ID)
val savedStateMap = MutableSavedStateMapImpl { true }
matrixClientsHolder.saveIntoSavedState(savedStateMap)
assertThat(savedStateMap.size).isEqualTo(1)
// Test Restore with non-empty map
matrixClientsHolder.restoreWithSavedState(savedStateMap)
// Empty the map
matrixClientsHolder.removeAll()
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isNull()
// Restore again
matrixClientsHolder.restoreWithSavedState(savedStateMap)
assertThat(matrixClientsHolder.getOrNull(A_SESSION_ID)).isEqualTo(fakeMatrixClient)
}
}

View file

@ -42,7 +42,7 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - initial state`() = runTest { fun `present - initial state`() = runTest {
val presenter = createPresenter() val presenter = createLoggedInPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -54,7 +54,7 @@ class LoggedInPresenterTest {
@Test @Test
fun `present - show sync spinner`() = runTest { fun `present - show sync spinner`() = runTest {
val roomListService = FakeRoomListService() val roomListService = FakeRoomListService()
val presenter = createPresenter(roomListService, NetworkStatus.Online) val presenter = createLoggedInPresenter(roomListService, NetworkStatus.Online)
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -66,7 +66,7 @@ class LoggedInPresenterTest {
} }
} }
private fun createPresenter( private fun createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(), roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline networkStatus: NetworkStatus = NetworkStatus.Offline
): LoggedInPresenter { ): LoggedInPresenter {

View file

@ -45,7 +45,7 @@ plugins {
} }
tasks.register<Delete>("clean").configure { tasks.register<Delete>("clean").configure {
delete(rootProject.buildDir) delete(rootProject.layout.buildDirectory)
} }
allprojects { allprojects {
@ -86,7 +86,7 @@ allprojects {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE) reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
} }
filter { filter {
exclude { element -> element.file.path.contains("$buildDir/generated/") } exclude { element -> element.file.path.contains("${layout.buildDirectory.asFile.get()}/generated/") }
} }
} }
// Dependency check // Dependency check
@ -176,10 +176,13 @@ koverMerged {
"*_ModuleKt", "*_ModuleKt",
"anvil.hint.binding.io.element.*", "anvil.hint.binding.io.element.*",
"anvil.hint.merge.*", "anvil.hint.merge.*",
"anvil.hint.multibinding.io.element.*",
"anvil.module.*", "anvil.module.*",
"com.airbnb.android.showkase*", "com.airbnb.android.showkase*",
"io.element.android.libraries.designsystem.showkase.*", "io.element.android.libraries.designsystem.showkase.*",
"io.element.android.x.di.DaggerAppComponent*",
"*_Factory", "*_Factory",
"*_Factory_Impl",
"*_Factory$*", "*_Factory$*",
"*_Module", "*_Module",
"*_Module$*", "*_Module$*",
@ -228,11 +231,11 @@ koverMerged {
name = "Global minimum code coverage." name = "Global minimum code coverage."
target = kotlinx.kover.api.VerificationTarget.ALL target = kotlinx.kover.api.VerificationTarget.ALL
bound { bound {
minValue = 55 minValue = 60
// Setting a max value, so that if coverage is bigger, it means that we have to change minValue. // Setting a max value, so that if coverage is bigger, it means that we have to change minValue.
// For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update // For instance if we have minValue = 20 and maxValue = 30, and current code coverage is now 31.32%, update
// minValue to 25 and maxValue to 35. // minValue to 25 and maxValue to 35.
maxValue = 65 maxValue = 70
counter = kotlinx.kover.api.CounterType.INSTRUCTION counter = kotlinx.kover.api.CounterType.INSTRUCTION
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
} }
@ -354,7 +357,7 @@ subprojects {
subprojects { subprojects {
tasks.withType<KspTask>() { tasks.withType<KspTask>() {
doLast { doLast {
fileTree(buildDir).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file -> fileTree(layout.buildDirectory).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file ->
ReplaceRegExp().apply { ReplaceRegExp().apply {
setMatch("^public fun Showkase.getMetadata") setMatch("^public fun Showkase.getMetadata")
setReplace("@Suppress(\"DEPRECATION\") public fun Showkase.getMetadata") setReplace("@Suppress(\"DEPRECATION\") public fun Showkase.getMetadata")

View file

@ -120,18 +120,9 @@ You can also have access to the aars through the [release](https://github.com/ma
If you need to locally build the sdk-android you can use If you need to locally build the sdk-android you can use
the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script. the [build](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/scripts/build.sh) script.
For this, you first need to ensure to setup : For this please check the [prerequisites](https://github.com/matrix-org/matrix-rust-components-kotlin/blob/main/README.md#prerequisites) from the repo.
- rust environment (check https://rust-lang.github.io/rustup/ if needed) Checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories
- cargo-ndk < 2.12.0
```shell
cargo install cargo-ndk --version 2.11.0
```
- android targets
```shell
rustup target add aarch64-linux-android armv7-linux-androideabi x86_64-linux-android i686-linux-android
```
- checkout both [matrix-rust-sdk](https://github.com/matrix-org/matrix-rust-sdk) and [matrix-rust-components-kotlin](https://github.com/matrix-org/matrix-rust-components-kotlin) repositories
```shell ```shell
git clone git@github.com:matrix-org/matrix-rust-sdk.git git clone git@github.com:matrix-org/matrix-rust-sdk.git
git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git git clone git@github.com:matrix-org/matrix-rust-components-kotlin.git
@ -151,6 +142,11 @@ So for example to build the sdk against aarch64-linux-android target and copy th
./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar ./scripts/build.sh -p [YOUR MATRIX RUST SDK PATH] -t aarch64-linux-android -o [YOUR element-x-android PATH]/libraries/rustsdk/matrix-rust-sdk.aar
``` ```
Troubleshooting:
- You may need to set `ANDROID_NDK_HOME` e.g `export ANDROID_NDK_HOME=~/Library/Android/sdk/ndk`.
- If you get the error `thread 'main' panicked at 'called `Option::unwrap()` on a `None` value', .cargo/registry/src/index.crates.io-6f17d22bba15001f/cargo-ndk-2.11.0/src/cli.rs:345:18` try updating your Cargo NDK version. In this case, 2.11.0 is too old so `cargo install cargo-ndk` to install a newer version.
- If you get the error `Unsupported class file major version 64` try changing your JVM version. In this case, Java 20 is not supported in Gradle yet, so downgrade to an earlier version (Java 17 worked in this case).
Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`: Finally let the `matrix/impl` module use this aar by changing the dependencies from `libs.matrix.sdk` to `projects.libraries.rustsdk`:
```groovy ```groovy
@ -280,11 +276,12 @@ Follow these steps to install and configure the plugin and templates:
1. Install the AS plugin for generating modules : 1. Install the AS plugin for generating modules :
[Generate Module from Template](https://plugins.jetbrains.com/plugin/13586-generate-module-from-template) [Generate Module from Template](https://plugins.jetbrains.com/plugin/13586-generate-module-from-template)
2. Import file templates in AS : 2. Run the script `tools/templates/generate_templates.sh` to generate the template zip file
3. Import file templates in AS :
- Navigate to File/Manage IDE Settings/Import Settings - Navigate to File/Manage IDE Settings/Import Settings
- Pick the `tools/templates/file_templates.zip` files - Pick the `tmp/file_templates.zip` files
- Click on OK - Click on OK
3. Configure generate-module-from-template plugin : 4. Configure generate-module-from-template plugin :
- Navigate to AS/Settings/Tools/Module Template Settings - Navigate to AS/Settings/Tools/Module Template Settings
- Click on + / Import From File - Click on + / Import From File
- Pick the `tools/templates/FeatureModule.json` - Pick the `tools/templates/FeatureModule.json`

22
docs/debug_proxying.md Normal file
View file

@ -0,0 +1,22 @@
# Setup a debug mitm proxy to inspect all the app's network traffic
1) Install mitmproxy: `brew install mitmproxy`.
1) Launch `mitmweb` from a terminal. It will pop up mitmproxy's web interface in a web browser.
1) Configure Android Emulator.
1) Launch your android emulator.
1) Open its settings page and go to Settings -> Proxy (nb this tab isn't visible when running the emu inside the Android Studio window, you need to set it so it runs in its own window).
1) Disable "Use Android Studio HTTP proxy settings" and pick "Manual proxy configuration".
1) Set `127.0.0.1` as "Host name" and `8080` as "Port number".
1) Click "Apply" and verify that "Proxy status" is "Success" and close the settings window.
<img width="932" alt="Screenshot 2023-10-04 at 14 48 47" src="https://github.com/vector-im/element-x-android/assets/1273124/bf99a053-20b0-42a4-91d3-9602f709f684">
1) Install the mitmproxy CA cert (this is needed to see traffic from java/kotlin code, it's not needed for traffic coming from native code e.g. the matrix-rust-sdk).
1) Open the emulator Chrome browser app
1) Go to the url `mitm.it`
1) Follow the instructions to install the CA cert on Android devices.
<img width="606" alt="Screenshot 2023-10-04 at 14 51 27" src="https://github.com/vector-im/element-x-android/assets/1273124/5f2b6f27-6958-4ea7-97fe-c7f06d105da5">
1) Slightly modify the Element X app source code.
1) Go to the `RustMatrixClientFactory.create()` method.
1) Add `.disableSslVerification()` in the `ClientBuilder` method chain.
1) Build and run the Element X app.
1) Enjoy, you will see all the traffic in mitmproxy's web interface.
<img width="1110" alt="Screenshot 2023-10-04 at 14 50 03" src="https://github.com/vector-im/element-x-android/assets/1273124/5d039efd-448d-426c-a384-dbbceb9f33ac">

View file

@ -0,0 +1,2 @@
Main changes in this version: bugfixes.
Full changelog: https://github.com/vector-im/element-x-android/releases

Binary file not shown.

Before

Width:  |  Height:  |  Size: 22 KiB

After

Width:  |  Height:  |  Size: 391 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 98 KiB

After

Width:  |  Height:  |  Size: 221 KiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 694 KiB

After

Width:  |  Height:  |  Size: 2 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 1,024 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 934 KiB

After

Width:  |  Height:  |  Size: 1.4 MiB

Before After
Before After

Binary file not shown.

Before

Width:  |  Height:  |  Size: 675 KiB

After

Width:  |  Height:  |  Size: 1.3 MiB

Before After
Before After

View file

@ -44,8 +44,8 @@ import io.element.android.features.analytics.api.AnalyticsOptInEvents
import io.element.android.features.analytics.api.Config import io.element.android.features.analytics.api.Config
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.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.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.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview

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_prompt_data_usage">"我們不會紀錄或剖繪您的個人資料"</string> <string name="screen_analytics_prompt_data_usage">"我們不會紀錄或剖繪您的個人資料"</string>
<string name="screen_analytics_prompt_help_us_improve">"分享匿名的使用數據以協助我們釐清問題"</string> <string name="screen_analytics_prompt_help_us_improve">"分享匿名的使用數據以協助我們釐清問題"</string>
<string name="screen_analytics_prompt_read_terms">"您可以到%1$s閱讀我們的條款。"</string> <string name="screen_analytics_prompt_read_terms">"您可以到%1$s閱讀我們的條款。"</string>
<string name="screen_analytics_prompt_read_terms_content_link">"這裡"</string> <string name="screen_analytics_prompt_read_terms_content_link">"這裡"</string>
<string name="screen_analytics_prompt_settings">"您可以在任何時候關閉它"</string> <string name="screen_analytics_prompt_settings">"您可以在任何時候關閉它"</string>

View file

@ -54,21 +54,50 @@ class CallIntentDataParser @Inject constructor() {
} }
/** /**
* Ensure the uri has the following parameters and value: * Ensure the uri has the following parameters and value in the fragment:
* - appPrompt=false * - appPrompt=false
* - confineToRoom=true * - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview. * to ensure that the rendering will bo correct on the embedded Webview.
*/ */
private fun Uri.withCustomParameters(): String { private fun Uri.withCustomParameters(): String {
val builder = buildUpon() val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery() builder.clearQuery()
queryParameterNames.forEach { queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it)) builder.appendQueryParameter(it, getQueryParameter(it))
} }
builder.appendQueryParameter(APP_PROMPT_PARAMETER, "false") // Remove the existing fragment parameters, and build the new fragment
builder.appendQueryParameter(CONFINE_TO_ROOM_PARAMETER, "true") val currentFragment = fragment ?: ""
return builder.build().toString() // Reset the current fragment
builder.fragment("")
val queryFragmentPosition = currentFragment.lastIndexOf("?")
val newFragment = if (queryFragmentPosition == -1) {
// No existing query, build it.
"$currentFragment?$APP_PROMPT_PARAMETER=false&$CONFINE_TO_ROOM_PARAMETER=true"
} else {
buildString {
append(currentFragment.substring(0, queryFragmentPosition + 1))
val queryFragment = currentFragment.substring(queryFragmentPosition + 1)
// Replace the existing parameters
val newQueryFragment = queryFragment
.replace("$APP_PROMPT_PARAMETER=true", "$APP_PROMPT_PARAMETER=false")
.replace("$CONFINE_TO_ROOM_PARAMETER=false", "$CONFINE_TO_ROOM_PARAMETER=true")
append(newQueryFragment)
// Ensure the parameters are there
if (!newQueryFragment.contains("$APP_PROMPT_PARAMETER=false")) {
if (newQueryFragment.isNotEmpty()) {
append("&")
}
append("$APP_PROMPT_PARAMETER=false")
}
if (!newQueryFragment.contains("$CONFINE_TO_ROOM_PARAMETER=true")) {
append("&$CONFINE_TO_ROOM_PARAMETER=true")
}
}
}
// We do not want to encode the Fragment part, so append it manually
return builder.build().toString() + "#" + newFragment
} }
private const val APP_PROMPT_PARAMETER = "appPrompt" private const val APP_PROMPT_PARAMETER = "appPrompt"

View file

@ -35,92 +35,51 @@ class CallIntentDataParserTests {
@Test @Test
fun `empty data returns null`() { fun `empty data returns null`() {
val url = "" doTest("", null)
assertThat(callIntentDataParser.parse(url)).isNull()
} }
@Test @Test
fun `invalid data returns null`() { fun `invalid data returns null`() {
val url = "!" doTest("!", null)
assertThat(callIntentDataParser.parse(url)).isNull()
} }
@Test @Test
fun `data with no scheme returns null`() { fun `data with no scheme returns null`() {
val url = "test" doTest("test", null)
assertThat(callIntentDataParser.parse(url)).isNull()
} }
@Test @Test
fun `Element Call http urls returns null`() { fun `Element Call http urls returns null`() {
val httpBaseUrl = "http://call.element.io" doTest("http://call.element.io", null)
val httpCallUrl = "http://call.element.io/some-actual-call?with=parameters" doTest("http://call.element.io/some-actual-call?with=parameters", null)
assertThat(callIntentDataParser.parse(httpBaseUrl)).isNull()
assertThat(callIntentDataParser.parse(httpCallUrl)).isNull()
} }
@Test @Test
fun `Element Call urls will be returned as is`() { fun `Element Call urls will be returned as is`() {
val httpsBaseUrl = "https://call.element.io" doTest(
val httpsCallUrl = VALID_CALL_URL_WITH_PARAM url = "https://call.element.io",
assertThat(callIntentDataParser.parse(httpsBaseUrl)).isEqualTo("$httpsBaseUrl?$EXTRA_PARAMS") expectedResult = "https://call.element.io#?$EXTRA_PARAMS"
assertThat(callIntentDataParser.parse(httpsCallUrl)).isEqualTo("$httpsCallUrl&$EXTRA_PARAMS") )
}
@Test
fun `Element Call url with url param gets url extracted`() {
doTest(
url = VALID_CALL_URL_WITH_PARAM,
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
} }
@Test @Test
fun `HTTP and HTTPS urls that don't come from EC return null`() { fun `HTTP and HTTPS urls that don't come from EC return null`() {
val httpBaseUrl = "http://app.element.io" doTest("http://app.element.io", null)
val httpsBaseUrl = "https://app.element.io" doTest("https://app.element.io", null, testEmbedded = false)
val httpInvalidUrl = "http://" doTest("http://", null)
val httpsInvalidUrl = "http://" doTest("https://", null)
assertThat(callIntentDataParser.parse(httpBaseUrl)).isNull()
assertThat(callIntentDataParser.parse(httpsBaseUrl)).isNull()
assertThat(callIntentDataParser.parse(httpInvalidUrl)).isNull()
assertThat(callIntentDataParser.parse(httpsInvalidUrl)).isNull()
} }
@Test @Test
fun `element scheme with call host and url with http will returns null`() { fun `Element Call url with no url returns null`() {
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme with call host and url param gets url extracted`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isEqualTo("$VALID_CALL_URL_WITH_PARAM&$EXTRA_PARAMS")
}
@Test
fun `element scheme 2 with url param with http returns null`() {
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme 2 with url param gets url extracted`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isEqualTo("$VALID_CALL_URL_WITH_PARAM&$EXTRA_PARAMS")
}
@Test
fun `element scheme with call host and no url param returns null`() {
val embeddedUrl = "http://call.element.io/some-actual-call?with=parameters"
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "element://call?no-url=$encodedUrl"
assertThat(callIntentDataParser.parse(url)).isNull()
}
@Test
fun `element scheme 2 with no url returns null`() {
val embeddedUrl = VALID_CALL_URL_WITH_PARAM val embeddedUrl = VALID_CALL_URL_WITH_PARAM
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8")
val url = "io.element.call:/?no_url=$encodedUrl" val url = "io.element.call:/?no_url=$encodedUrl"
@ -142,7 +101,7 @@ class CallIntentDataParserTests {
} }
@Test @Test
fun `element scheme 2 with no data returns null`() { fun `Element Call url with no data returns null`() {
val url = "io.element.call:/?url=" val url = "io.element.call:/?url="
assertThat(callIntentDataParser.parse(url)).isNull() assertThat(callIntentDataParser.parse(url)).isNull()
} }
@ -156,29 +115,108 @@ class CallIntentDataParserTests {
} }
@Test @Test
fun `element scheme 2 with url extra param appPrompt gets url extracted`() { fun `Element Call url with url extra param appPrompt gets url extracted`() {
val embeddedUrl = "${VALID_CALL_URL_WITH_PARAM}&appPrompt=true" doTest(
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") url = "${VALID_CALL_URL_WITH_PARAM}&appPrompt=true",
val url = "io.element.call:/?url=$encodedUrl" expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
assertThat(callIntentDataParser.parse(url)).isEqualTo("$VALID_CALL_URL_WITH_PARAM&$EXTRA_PARAMS") )
} }
@Test @Test
fun `element scheme 2 with url extra param confineToRoom gets url extracted`() { fun `Element Call url with url extra param in fragment appPrompt gets url extracted`() {
val embeddedUrl = "${VALID_CALL_URL_WITH_PARAM}&confineToRoom=false" doTest(
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") url = "${VALID_CALL_URL_WITH_PARAM}#?appPrompt=true",
val url = "io.element.call:/?url=$encodedUrl" expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&confineToRoom=true"
assertThat(callIntentDataParser.parse(url)).isEqualTo("$VALID_CALL_URL_WITH_PARAM&$EXTRA_PARAMS") )
} }
@Test @Test
fun `element scheme 2 with url fragment gets url extracted`() { fun `Element Call url with url extra param in fragment appPrompt and other gets url extracted`() {
val embeddedUrl = "${VALID_CALL_URL_WITH_PARAM}#fragment" doTest(
val encodedUrl = URLEncoder.encode(embeddedUrl, "utf-8") url = "${VALID_CALL_URL_WITH_PARAM}#?appPrompt=true&otherParam=maybe",
val url = "io.element.call:/?url=$encodedUrl" expectedResult = "$VALID_CALL_URL_WITH_PARAM#?appPrompt=false&otherParam=maybe&confineToRoom=true"
assertThat(callIntentDataParser.parse(url)).isEqualTo("$VALID_CALL_URL_WITH_PARAM&$EXTRA_PARAMS#fragment") )
} }
@Test
fun `Element Call url with url extra param confineToRoom gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}&confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?confineToRoom=false",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&appPrompt=false"
)
}
@Test
fun `Element Call url with url extra param in fragment confineToRoom and more gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?confineToRoom=false&otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?confineToRoom=true&otherParam=maybe&appPrompt=false"
)
}
@Test
fun `Element Call url with url fragment gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#fragment",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with params gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#fragment?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#fragment?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with url fragment with other params gets url extracted`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?otherParam=maybe",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?otherParam=maybe&$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
@Test
fun `Element Call url with empty fragment query`() {
doTest(
url = "${VALID_CALL_URL_WITH_PARAM}#?",
expectedResult = "$VALID_CALL_URL_WITH_PARAM#?$EXTRA_PARAMS"
)
}
private fun doTest(url: String, expectedResult: String?, testEmbedded: Boolean = true) {
// Test direct parsing
assertThat(callIntentDataParser.parse(url)).isEqualTo(expectedResult)
if (testEmbedded) {
// Test embedded url, scheme 1
val encodedUrl = URLEncoder.encode(url, "utf-8")
val urlScheme1 = "element://call?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme1)).isEqualTo(expectedResult)
// Test embedded url, scheme 2
val urlScheme2 = "io.element.call:/?url=$encodedUrl"
assertThat(callIntentDataParser.parse(urlScheme2)).isEqualTo(expectedResult)
}
}
companion object { companion object {
const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters" const val VALID_CALL_URL_WITH_PARAM = "https://call.element.io/some-actual-call?with=parameters"

View file

@ -84,7 +84,7 @@ fun AddPeopleView(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun AddPeopleViewTopBar( private fun AddPeopleViewTopBar(
hasSelectedUsers: Boolean, hasSelectedUsers: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},

View file

@ -181,7 +181,7 @@ fun ConfigureRoomView(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun ConfigureRoomToolbar( private fun ConfigureRoomToolbar(
isNextActionEnabled: Boolean, isNextActionEnabled: Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackPressed: () -> Unit = {}, onBackPressed: () -> Unit = {},
@ -207,7 +207,7 @@ fun ConfigureRoomToolbar(
} }
@Composable @Composable
fun RoomNameWithAvatar( private fun RoomNameWithAvatar(
avatarUri: Uri?, avatarUri: Uri?,
roomName: String, roomName: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
@ -235,7 +235,7 @@ fun RoomNameWithAvatar(
} }
@Composable @Composable
fun RoomTopic( private fun RoomTopic(
topic: String, topic: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onTopicChanged: (String) -> Unit = {}, onTopicChanged: (String) -> Unit = {},
@ -254,7 +254,7 @@ fun RoomTopic(
} }
@Composable @Composable
fun RoomPrivacyOptions( private fun RoomPrivacyOptions(
selected: RoomPrivacy?, selected: RoomPrivacy?,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onOptionSelected: (RoomPrivacyItem) -> Unit = {}, onOptionSelected: (RoomPrivacyItem) -> Unit = {},

View file

@ -126,7 +126,7 @@ fun CreateRoomRootView(
@OptIn(ExperimentalMaterial3Api::class) @OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun CreateRoomRootViewTopBar( private fun CreateRoomRootViewTopBar(
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onClosePressed: () -> Unit = {}, onClosePressed: () -> Unit = {},
) { ) {
@ -148,7 +148,7 @@ fun CreateRoomRootViewTopBar(
} }
@Composable @Composable
fun CreateRoomActionButtonsList( private fun CreateRoomActionButtonsList(
state: CreateRoomRootState, state: CreateRoomRootState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onNewRoomClicked: () -> Unit = {}, onNewRoomClicked: () -> Unit = {},
@ -169,7 +169,7 @@ fun CreateRoomActionButtonsList(
} }
@Composable @Composable
fun CreateRoomActionButton( private fun CreateRoomActionButton(
@DrawableRes iconRes: Int, @DrawableRes iconRes: Int,
text: String, text: String,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,

View file

@ -4,6 +4,10 @@
<string name="screen_create_room_action_invite_people">"邀請朋友使用 Element"</string> <string name="screen_create_room_action_invite_people">"邀請朋友使用 Element"</string>
<string name="screen_create_room_add_people_title">"邀請夥伴"</string> <string name="screen_create_room_add_people_title">"邀請夥伴"</string>
<string name="screen_create_room_error_creating_room">"建立聊天室時發生錯誤"</string> <string name="screen_create_room_error_creating_room">"建立聊天室時發生錯誤"</string>
<string name="screen_create_room_private_option_description">"聊天室裡的訊息會被加密。聊天室建立後,無法停用加密功能。"</string>
<string name="screen_create_room_private_option_title">"私密聊天室(僅限邀請)"</string>
<string name="screen_create_room_public_option_description">"訊息未加密,任何人都可以查看。您可以在之後啟用加密功能。"</string>
<string name="screen_create_room_public_option_title">"公開聊天室(任何人)"</string>
<string name="screen_create_room_room_name_label">"聊天室名稱"</string> <string name="screen_create_room_room_name_label">"聊天室名稱"</string>
<string name="screen_create_room_topic_label">"主題(非必填)"</string> <string name="screen_create_room_topic_label">"主題(非必填)"</string>
<string name="screen_create_room_title">"建立聊天室"</string> <string name="screen_create_room_title">"建立聊天室"</string>

View file

@ -38,8 +38,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.features.ftue.impl.R import io.element.android.features.ftue.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtom
import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize import io.element.android.libraries.designsystem.atomic.atoms.ElementLogoAtomSize
import io.element.android.libraries.designsystem.atomic.molecules.InfoListItem import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.molecules.InfoListOrganism import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview

View file

@ -1,5 +1,11 @@
<?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_migration_title">"設定您的帳號"</string> <string name="screen_migration_message">"這是一次性的程序,感謝您耐心等候。"</string>
<string name="screen_migration_title">"正在設定您的帳號。"</string>
<string name="screen_welcome_bullet_1">"通話、投票、搜尋等更多功能將在今年登場。"</string>
<string name="screen_welcome_bullet_2">"在這次的更新,您無法查看聊天室內被加密的歷史訊息。"</string>
<string name="screen_welcome_bullet_3">"我們很樂意聽取您的意見,請到設定頁面告訴我們您的想法。"</string>
<string name="screen_welcome_button">"開始吧!"</string> <string name="screen_welcome_button">"開始吧!"</string>
<string name="screen_welcome_subtitle">"我們有些事想告訴您:"</string>
<string name="screen_welcome_title">"歡迎使用 %1$s"</string>
</resources> </resources>

View file

@ -14,8 +14,6 @@
* limitations under the License. * limitations under the License.
*/ */
// TODO: Remove once https://youtrack.jetbrains.com/issue/KTIJ-19369 is fixed
@Suppress("DSL_SCOPE_VIOLATION")
plugins { plugins {
id("io.element.android-compose-library") id("io.element.android-compose-library")
alias(libs.plugins.anvil) alias(libs.plugins.anvil)

View file

@ -25,10 +25,10 @@ import kotlinx.collections.immutable.ImmutableList
@Immutable @Immutable
data class InviteListState( data class InviteListState(
val inviteList: ImmutableList<InviteListInviteSummary>, val inviteList: ImmutableList<InviteListInviteSummary>,
val declineConfirmationDialog: InviteDeclineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden, val declineConfirmationDialog: InviteDeclineConfirmationDialog,
val acceptedAction: Async<RoomId> = Async.Uninitialized, val acceptedAction: Async<RoomId>,
val declinedAction: Async<Unit> = Async.Uninitialized, val declinedAction: Async<Unit>,
val eventSink: (InviteListEvents) -> Unit = {} val eventSink: (InviteListEvents) -> Unit
) )
sealed interface InviteDeclineConfirmationDialog { sealed interface InviteDeclineConfirmationDialog {

View file

@ -39,6 +39,10 @@ open class InviteListStateProvider : PreviewParameterProvider<InviteListState> {
internal fun aInviteListState() = InviteListState( internal fun aInviteListState() = InviteListState(
inviteList = aInviteListInviteSummaryList(), inviteList = aInviteListInviteSummaryList(),
declineConfirmationDialog = InviteDeclineConfirmationDialog.Hidden,
acceptedAction = Async.Uninitialized,
declinedAction = Async.Uninitialized,
eventSink = {},
) )
internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> { internal fun aInviteListInviteSummaryList(): ImmutableList<InviteListInviteSummary> {

View file

@ -111,7 +111,7 @@ fun InviteListView(
@OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class) @OptIn(ExperimentalMaterial3Api::class, ExperimentalLayoutApi::class)
@Composable @Composable
fun InviteListContent( private fun InviteListContent(
state: InviteListState, state: InviteListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
onBackClicked: () -> Unit = {}, onBackClicked: () -> Unit = {},

View file

@ -77,7 +77,7 @@ internal fun InviteSummaryRow(
} }
@Composable @Composable
internal fun DefaultInviteSummaryRow( private fun DefaultInviteSummaryRow(
invite: InviteListInviteSummary, invite: InviteListInviteSummary,
onAcceptClicked: () -> Unit = {}, onAcceptClicked: () -> Unit = {},
onDeclineClicked: () -> Unit = {}, onDeclineClicked: () -> Unit = {},

View file

@ -19,10 +19,10 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
data class LeaveRoomState( data class LeaveRoomState(
val confirmation: Confirmation = Confirmation.Hidden, val confirmation: Confirmation,
val progress: Progress = Progress.Hidden, val progress: Progress,
val error: Error = Error.Hidden, val error: Error,
val eventSink: (LeaveRoomEvent) -> Unit = {}, val eventSink: (LeaveRoomEvent) -> Unit,
) { ) {
sealed interface Confirmation { sealed interface Confirmation {
data object Hidden : Confirmation data object Hidden : Confirmation

View file

@ -22,32 +22,32 @@ import io.element.android.libraries.matrix.api.core.RoomId
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> { class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
override val values: Sequence<LeaveRoomState> override val values: Sequence<LeaveRoomState>
get() = sequenceOf( get() = sequenceOf(
LeaveRoomState( aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden, confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden, progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden, error = LeaveRoomState.Error.Hidden,
), ),
LeaveRoomState( aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID), confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden, progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden, error = LeaveRoomState.Error.Hidden,
), ),
LeaveRoomState( aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID), confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden, progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden, error = LeaveRoomState.Error.Hidden,
), ),
LeaveRoomState( aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID), confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden, progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden, error = LeaveRoomState.Error.Hidden,
), ),
LeaveRoomState( aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden, confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Shown, progress = LeaveRoomState.Progress.Shown,
error = LeaveRoomState.Error.Hidden, error = LeaveRoomState.Error.Hidden,
), ),
LeaveRoomState( aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden, confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden, progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Shown, error = LeaveRoomState.Error.Shown,
@ -56,3 +56,14 @@ class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
} }
private val A_ROOM_ID = RoomId("!aRoomId:aDomain") private val A_ROOM_ID = RoomId("!aRoomId:aDomain")
fun aLeaveRoomState(
confirmation: LeaveRoomState.Confirmation = LeaveRoomState.Confirmation.Hidden,
progress: LeaveRoomState.Progress = LeaveRoomState.Progress.Hidden,
error: LeaveRoomState.Error = LeaveRoomState.Error.Hidden,
) = LeaveRoomState(
confirmation = confirmation,
progress = progress,
error = error,
eventSink = {},
)

View file

@ -44,7 +44,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - initial state hides all dialogs`() = runTest { fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter() val presenter = createLeaveRoomPresenter()
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
}.test { }.test {
@ -57,7 +57,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - show generic confirmation`() = runTest { fun `present - show generic confirmation`() = runTest {
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -77,7 +77,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - show private room confirmation`() = runTest { fun `present - show private room confirmation`() = runTest {
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -97,7 +97,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - show last user in room confirmation`() = runTest { fun `present - show last user in room confirmation`() = runTest {
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -118,7 +118,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - leaving a room leaves the room`() = runTest { fun `present - leaving a room leaves the room`() = runTest {
val roomMembershipObserver = RoomMembershipObserver() val roomMembershipObserver = RoomMembershipObserver()
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -140,7 +140,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - show error if leave room fails`() = runTest { fun `present - show error if leave room fails`() = runTest {
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -164,7 +164,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - show progress indicator while leaving a room`() = runTest { fun `present - show progress indicator while leaving a room`() = runTest {
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -186,7 +186,7 @@ class LeaveRoomPresenterImplTest {
@Test @Test
fun `present - hide error hides the error`() = runTest { fun `present - hide error hides the error`() = runTest {
val presenter = createPresenter( val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply { client = FakeMatrixClient().apply {
givenGetRoomResult( givenGetRoomResult(
roomId = A_ROOM_ID, roomId = A_ROOM_ID,
@ -212,7 +212,7 @@ class LeaveRoomPresenterImplTest {
} }
} }
private fun TestScope.createPresenter( private fun TestScope.createLeaveRoomPresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
): LeaveRoomPresenter = LeaveRoomPresenterImpl( ): LeaveRoomPresenter = LeaveRoomPresenterImpl(

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2022 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.
*/
plugins {
id("io.element.android-compose-library")
}
android {
namespace = "io.element.android.features.leaveroom.test"
}
dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
api(projects.features.leaveroom.api)
}

View file

@ -20,9 +20,8 @@ import androidx.compose.runtime.Composable
import io.element.android.features.leaveroom.api.LeaveRoomEvent import io.element.android.features.leaveroom.api.LeaveRoomEvent
import io.element.android.features.leaveroom.api.LeaveRoomPresenter import io.element.android.features.leaveroom.api.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState import io.element.android.features.leaveroom.api.LeaveRoomState
import javax.inject.Inject
class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter { class FakeLeaveRoomPresenter : LeaveRoomPresenter {
val events = mutableListOf<LeaveRoomEvent>() val events = mutableListOf<LeaveRoomEvent>()
@ -30,7 +29,12 @@ class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
events += event events += event
} }
private var state = LeaveRoomState(eventSink = ::handleEvent) private var state = LeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
eventSink = ::handleEvent,
)
set(value) { set(value) {
field = value.copy(eventSink = ::handleEvent) field = value.copy(eventSink = ::handleEvent)
} }

View file

@ -17,9 +17,9 @@
package io.element.android.features.location.impl.common.permissions package io.element.android.features.location.impl.common.permissions
data class PermissionsState( data class PermissionsState(
val permissions: Permissions = Permissions.NoneGranted, val permissions: Permissions,
val shouldShowRationale: Boolean = false, val shouldShowRationale: Boolean,
val eventSink: (PermissionsEvents) -> Unit = {}, val eventSink: (PermissionsEvents) -> Unit,
) { ) {
sealed interface Permissions { sealed interface Permissions {
data object AllGranted : Permissions data object AllGranted : Permissions

View file

@ -17,11 +17,11 @@
package io.element.android.features.location.impl.send package io.element.android.features.location.impl.send
data class SendLocationState( data class SendLocationState(
val permissionDialog: Dialog = Dialog.None, val permissionDialog: Dialog,
val mode: Mode = Mode.PinLocation, val mode: Mode,
val hasLocationPermission: Boolean = false, val hasLocationPermission: Boolean,
val appName: String = "AppName", val appName: String,
val eventSink: (SendLocationEvents) -> Unit = {}, val eventSink: (SendLocationEvents) -> Unit,
) { ) {
sealed interface Mode { sealed interface Mode {
data object SenderLocation : Mode data object SenderLocation : Mode

View file

@ -23,35 +23,44 @@ private const val APP_NAME = "ApplicationName"
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> { class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState> override val values: Sequence<SendLocationState>
get() = sequenceOf( get() = sequenceOf(
SendLocationState( aSendLocationState(
permissionDialog = SendLocationState.Dialog.None, permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation, mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false, hasLocationPermission = false,
appName = APP_NAME,
), ),
SendLocationState( aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionDenied, permissionDialog = SendLocationState.Dialog.PermissionDenied,
mode = SendLocationState.Mode.PinLocation, mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false, hasLocationPermission = false,
appName = APP_NAME,
), ),
SendLocationState( aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionRationale, permissionDialog = SendLocationState.Dialog.PermissionRationale,
mode = SendLocationState.Mode.PinLocation, mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false, hasLocationPermission = false,
appName = APP_NAME,
), ),
SendLocationState( aSendLocationState(
permissionDialog = SendLocationState.Dialog.None, permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation, mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = true, hasLocationPermission = true,
appName = APP_NAME,
), ),
SendLocationState( aSendLocationState(
permissionDialog = SendLocationState.Dialog.None, permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.SenderLocation, mode = SendLocationState.Mode.SenderLocation,
hasLocationPermission = true, hasLocationPermission = true,
appName = APP_NAME,
), ),
) )
} }
private fun aSendLocationState(
permissionDialog: SendLocationState.Dialog,
mode: SendLocationState.Mode,
hasLocationPermission: Boolean,
): SendLocationState {
return SendLocationState(
permissionDialog = permissionDialog,
mode = mode,
hasLocationPermission = hasLocationPermission,
appName = APP_NAME,
eventSink = {}
)
}

View file

@ -0,0 +1,30 @@
/*
* 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.location.impl
import io.element.android.features.location.impl.common.permissions.PermissionsState
fun aPermissionsState(
permissions: PermissionsState.Permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale: Boolean = false,
): PermissionsState {
return PermissionsState(
permissions = permissions,
shouldShowRationale = shouldShowRationale,
eventSink = {},
)
}

View file

@ -26,7 +26,11 @@ class PermissionsPresenterFake : PermissionsPresenter {
events += event events += event
} }
private var state = PermissionsState(eventSink = ::handleEvent) private var state = PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
eventSink = ::handleEvent
)
set(value) { set(value) {
field = value.copy(eventSink = ::handleEvent) field = value.copy(eventSink = ::handleEvent)
} }

View file

@ -22,6 +22,7 @@ import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import im.vector.app.features.analytics.plan.Composer import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
@ -65,7 +66,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `initial state with permissions granted`() = runTest { fun `initial state with permissions granted`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted, permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -92,7 +93,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `initial state with permissions partially granted`() = runTest { fun `initial state with permissions partially granted`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.SomeGranted, permissions = PermissionsState.Permissions.SomeGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -119,7 +120,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `initial state with permissions denied`() = runTest { fun `initial state with permissions denied`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -145,7 +146,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `initial state with permissions denied once`() = runTest { fun `initial state with permissions denied once`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true, shouldShowRationale = true,
) )
@ -171,7 +172,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `rationale dialog dismiss`() = runTest { fun `rationale dialog dismiss`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true, shouldShowRationale = true,
) )
@ -202,7 +203,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `rationale dialog continue`() = runTest { fun `rationale dialog continue`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true, shouldShowRationale = true,
) )
@ -230,7 +231,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `permission denied dialog dismiss`() = runTest { fun `permission denied dialog dismiss`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -261,7 +262,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `share sender location`() = runTest { fun `share sender location`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted, permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -317,7 +318,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `share pin location`() = runTest { fun `share pin location`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -373,7 +374,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `composer context passes through analytics`() = runTest { fun `composer context passes through analytics`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -419,7 +420,7 @@ class SendLocationPresenterTest {
@Test @Test
fun `open settings activity`() = runTest { fun `open settings activity`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )

View file

@ -21,6 +21,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test import app.cash.turbine.test
import com.google.common.truth.Truth import com.google.common.truth.Truth
import io.element.android.features.location.api.Location import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.PermissionsEvents import io.element.android.features.location.impl.common.permissions.PermissionsEvents
import io.element.android.features.location.impl.common.permissions.PermissionsPresenter import io.element.android.features.location.impl.common.permissions.PermissionsPresenter
@ -55,7 +56,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `emits initial state with no location permission`() = runTest { fun `emits initial state with no location permission`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -75,7 +76,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `emits initial state location permission denied once`() = runTest { fun `emits initial state location permission denied once`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true, shouldShowRationale = true,
) )
@ -94,7 +95,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `emits initial state with location permission`() = runTest { fun `emits initial state with location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted)) permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -109,7 +110,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `emits initial state with partial location permission`() = runTest { fun `emits initial state with partial location permission`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.SomeGranted)) permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.SomeGranted))
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -137,7 +138,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `centers on user location`() = runTest { fun `centers on user location`() = runTest {
permissionsPresenterFake.givenState(PermissionsState(permissions = PermissionsState.Permissions.AllGranted)) permissionsPresenterFake.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
moleculeFlow(RecompositionMode.Immediate) { moleculeFlow(RecompositionMode.Immediate) {
presenter.present() presenter.present()
@ -166,7 +167,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `rationale dialog dismiss`() = runTest { fun `rationale dialog dismiss`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true, shouldShowRationale = true,
) )
@ -197,7 +198,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `rationale dialog continue`() = runTest { fun `rationale dialog continue`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = true, shouldShowRationale = true,
) )
@ -225,7 +226,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `permission denied dialog dismiss`() = runTest { fun `permission denied dialog dismiss`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -256,7 +257,7 @@ class ShowLocationPresenterTest {
@Test @Test
fun `open settings activity`() = runTest { fun `open settings activity`() = runTest {
permissionsPresenterFake.givenState( permissionsPresenterFake.givenState(
PermissionsState( aPermissionsState(
permissions = PermissionsState.Permissions.NoneGranted, permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false, shouldShowRationale = false,
) )
@ -290,7 +291,6 @@ class ShowLocationPresenterTest {
} }
} }
companion object { companion object {
private const val A_DESCRIPTION = "My happy place" private const val A_DESCRIPTION = "My happy place"
} }

View file

@ -16,8 +16,9 @@
package io.element.android.features.login.impl.accountprovider package io.element.android.features.login.impl.accountprovider
data class AccountProvider constructor( data class AccountProvider(
val title: String, val url: String,
val title: String = url.removePrefix("https://").removePrefix("http://"),
val subtitle: String? = null, val subtitle: String? = null,
val isPublic: Boolean = false, val isPublic: Boolean = false,
val isMatrixOrg: Boolean = false, val isMatrixOrg: Boolean = false,

View file

@ -17,6 +17,7 @@
package io.element.android.features.login.impl.accountprovider package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.util.LoginConstants
open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> { open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider> override val values: Sequence<AccountProvider>
@ -31,7 +32,7 @@ open class AccountProviderProvider : PreviewParameterProvider<AccountProvider> {
} }
fun anAccountProvider() = AccountProvider( fun anAccountProvider() = AccountProvider(
title = "matrix.org", url = LoginConstants.MATRIX_ORG_URL,
subtitle = "Matrix.org is an open network for secure, decentralized communication.", subtitle = "Matrix.org is an open network for secure, decentralized communication.",
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,

View file

@ -63,7 +63,7 @@ class ChangeServerPresenter @Inject constructor(
changeServerAction: MutableState<Async<Unit>>, changeServerAction: MutableState<Async<Unit>>,
) = launch { ) = launch {
suspend { suspend {
authenticationService.setHomeserver(data.title).map { authenticationService.setHomeserver(data.url).map {
authenticationService.getHomeserverDetails().value!! authenticationService.getHomeserverDetails().value!!
// Valid, remember user choice // Valid, remember user choice
accountProviderDataSource.userSelection(data) accountProviderDataSource.userSelection(data)

View file

@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R import io.element.android.features.login.impl.R
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.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
@ -39,3 +41,12 @@ internal fun SlidingSyncNotSupportedDialog(
content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message), content = stringResource(R.string.screen_change_server_error_no_sliding_sync_message),
) )
} }
@PreviewsDayNight
@Composable
internal fun SlidingSyncNotSupportedDialogPreview() = ElementPreview {
SlidingSyncNotSupportedDialog(
onLearnMoreClicked = {},
onDismiss = {},
)
}

View file

@ -19,6 +19,7 @@ package io.element.android.features.login.impl.screens.changeaccountprovider
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.changeserver.ChangeServerPresenter import io.element.android.features.login.impl.changeserver.ChangeServerPresenter
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import javax.inject.Inject import javax.inject.Inject
@ -33,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
// Just matrix.org by default for now // Just matrix.org by default for now
accountProviders = listOf( accountProviders = listOf(
AccountProvider( AccountProvider(
title = "matrix.org", url = LoginConstants.MATRIX_ORG_URL,
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,
isMatrixOrg = true, isMatrixOrg = true,

View file

@ -109,6 +109,7 @@ fun ChangeAccountProviderView(
// Other // Other
AccountProviderView( AccountProviderView(
item = AccountProvider( item = AccountProvider(
url = "",
title = stringResource(id = R.string.screen_change_account_provider_other), title = stringResource(id = R.string.screen_change_account_provider_other),
), ),
onClick = onOtherProviderClicked onClick = onOtherProviderClicked

View file

@ -76,7 +76,7 @@ class ConfirmAccountProviderPresenter @AssistedInject constructor(
fun handleEvents(event: ConfirmAccountProviderEvents) { fun handleEvents(event: ConfirmAccountProviderEvents) {
when (event) { when (event) {
ConfirmAccountProviderEvents.Continue -> { ConfirmAccountProviderEvents.Continue -> {
localCoroutineScope.submit(accountProvider.title, loginFlowAction) localCoroutineScope.submit(accountProvider.url, loginFlowAction)
} }
ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized ConfirmAccountProviderEvents.ClearError -> loginFlowAction.value = Async.Uninitialized
} }

View file

@ -27,7 +27,7 @@ data class ConfirmAccountProviderState(
val loginFlow: Async<LoginFlow>, val loginFlow: Async<LoginFlow>,
val eventSink: (ConfirmAccountProviderEvents) -> Unit val eventSink: (ConfirmAccountProviderEvents) -> Unit
) { ) {
val submitEnabled: Boolean get() = accountProvider.title.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading) val submitEnabled: Boolean get() = accountProvider.url.isNotEmpty() && (loginFlow is Async.Uninitialized || loginFlow is Async.Loading)
} }
sealed interface LoginFlow { sealed interface LoginFlow {

View file

@ -43,6 +43,7 @@ import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.matrix.api.auth.OidcDetails import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun ConfirmAccountProviderView( fun ConfirmAccountProviderView(
@ -86,7 +87,7 @@ fun ConfirmAccountProviderView(
footer = { footer = {
ButtonColumnMolecule { ButtonColumnMolecule {
Button( Button(
text = stringResource(id = R.string.screen_account_provider_continue), text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading, showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) }, onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled || isLoading, enabled = state.submitEnabled || isLoading,

View file

@ -139,7 +139,7 @@ fun LoginPasswordView(
Spacer(modifier = Modifier.weight(1f)) Spacer(modifier = Modifier.weight(1f))
// Submit // Submit
Button( Button(
text = stringResource(R.string.screen_login_submit), text = stringResource(CommonStrings.action_continue),
showProgress = isLoading, showProgress = isLoading,
onClick = ::submit, onClick = ::submit,
enabled = state.submitEnabled || isLoading, enabled = state.submitEnabled || isLoading,
@ -167,7 +167,7 @@ fun LoginPasswordView(
@OptIn(ExperimentalComposeUiApi::class) @OptIn(ExperimentalComposeUiApi::class)
@Composable @Composable
internal fun LoginForm( private fun LoginForm(
state: LoginPasswordState, state: LoginPasswordState,
isLoading: Boolean, isLoading: Boolean,
onSubmit: () -> Unit, onSubmit: () -> Unit,
@ -199,7 +199,7 @@ internal fun LoginForm(
eventSink(LoginPasswordEvents.SetLogin(it)) eventSink(LoginPasswordEvents.SetLogin(it))
}), }),
placeholder = { placeholder = {
Text(text = stringResource(R.string.screen_login_username_hint)) Text(text = stringResource(CommonStrings.common_username))
}, },
onValueChange = { onValueChange = {
loginFieldState = it loginFieldState = it
@ -246,7 +246,7 @@ internal fun LoginForm(
eventSink(LoginPasswordEvents.SetPassword(it)) eventSink(LoginPasswordEvents.SetPassword(it))
}, },
placeholder = { placeholder = {
Text(text = stringResource(R.string.screen_login_password_hint)) Text(text = stringResource(CommonStrings.common_password))
}, },
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = { trailingIcon = {
@ -272,7 +272,7 @@ internal fun LoginForm(
} }
@Composable @Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) { private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog( ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_error), title = stringResource(id = CommonStrings.dialog_title_error),
content = stringResource(loginError(error)), content = stringResource(loginError(error)),

View file

@ -19,6 +19,7 @@ package io.element.android.features.login.impl.screens.searchaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.changeserver.aChangeServerState import io.element.android.features.login.impl.changeserver.aChangeServerState
import io.element.android.features.login.impl.resolver.HomeserverData import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> { open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
@ -49,7 +50,7 @@ fun aHomeserverDataList(): List<HomeserverData> {
} }
fun aHomeserverData( fun aHomeserverData(
homeserverUrl: String = "https://matrix.org", homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
isWellknownValid: Boolean = true, isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true, supportSlidingSync: Boolean = true,
): HomeserverData { ): HomeserverData {

View file

@ -54,12 +54,13 @@ import io.element.android.features.login.impl.accountprovider.AccountProviderVie
import io.element.android.features.login.impl.changeserver.ChangeServerEvents import io.element.android.features.login.impl.changeserver.ChangeServerEvents
import io.element.android.features.login.impl.changeserver.ChangeServerView import io.element.android.features.login.impl.changeserver.ChangeServerView
import io.element.android.features.login.impl.resolver.HomeserverData import io.element.android.features.login.impl.resolver.HomeserverData
import io.element.android.features.login.impl.util.LoginConstants
import io.element.android.libraries.architecture.Async import io.element.android.libraries.architecture.Async
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
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.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton import io.element.android.libraries.designsystem.theme.components.IconButton
@ -195,9 +196,9 @@ fun SearchAccountProviderView(
@Composable @Composable
private fun HomeserverData.toAccountProvider(): AccountProvider { private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == "https://matrix.org" val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
return AccountProvider( return AccountProvider(
title = homeserverUrl.removePrefix("http://").removePrefix("https://"), url = homeserverUrl,
subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null, subtitle = if (isMatrixOrg) stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle) else null,
isPublic = isMatrixOrg, // There is no need to know for other servers right now isPublic = isMatrixOrg, // There is no need to know for other servers right now
isMatrixOrg = isMatrixOrg, isMatrixOrg = isMatrixOrg,

View file

@ -19,14 +19,14 @@ package io.element.android.features.login.impl.util
import io.element.android.features.login.impl.accountprovider.AccountProvider import io.element.android.features.login.impl.accountprovider.AccountProvider
object LoginConstants { object LoginConstants {
const val MATRIX_ORG_URL = "matrix.org" const val MATRIX_ORG_URL = "https://matrix.org"
const val DEFAULT_HOMESERVER_URL = "matrix.org" const val DEFAULT_HOMESERVER_URL = "https://matrix.org"
const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md" const val SLIDING_SYNC_READ_MORE_URL = "https://github.com/matrix-org/sliding-sync/blob/main/docs/Landing.md"
} }
val defaultAccountProvider = AccountProvider( val defaultAccountProvider = AccountProvider(
title = LoginConstants.DEFAULT_HOMESERVER_URL, url = LoginConstants.DEFAULT_HOMESERVER_URL,
subtitle = null, subtitle = null,
isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, isPublic = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,
isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL, isMatrixOrg = LoginConstants.DEFAULT_HOMESERVER_URL == LoginConstants.MATRIX_ORG_URL,

View file

@ -1,13 +1,28 @@
<?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_account_provider_change">"更改帳號提供者"</string>
<string name="screen_account_provider_form_hint">"家伺服器位址"</string>
<string name="screen_account_provider_form_notice">"輸入關鍵字或網域名稱。"</string>
<string name="screen_account_provider_form_subtitle">"搜尋公司、社群、私有伺服器"</string>
<string name="screen_account_provider_form_title">"尋找帳號提供者"</string>
<string name="screen_account_provider_signin_subtitle">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>
<string name="screen_account_provider_signin_title">"您即將登入 %s"</string> <string name="screen_account_provider_signin_title">"您即將登入 %s"</string>
<string name="screen_account_provider_signup_subtitle">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>
<string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string> <string name="screen_account_provider_signup_title">"您即將在 %s 建立帳號"</string>
<string name="screen_change_account_provider_matrix_org_subtitle">"Matrix.org 由 Matrix.org 基金會營運,是用於安全、去中心化通訊的公共 Matrix 網路上的大型免費伺服器。"</string>
<string name="screen_change_account_provider_other">"其他"</string> <string name="screen_change_account_provider_other">"其他"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"此伺服器目前不支援 sliding sync。"</string> <string name="screen_change_account_provider_subtitle">"使用不同的帳戶提供者,例如您自己的伺服器或工作帳號。"</string>
<string name="screen_change_account_provider_title">"更改帳號提供者"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"此伺服器目前不支援滑動同步sliding sync。"</string>
<string name="screen_change_server_form_header">"家伺服器 URL"</string> <string name="screen_change_server_form_header">"家伺服器 URL"</string>
<string name="screen_login_error_deactivated_account">"這個帳號已被停用。"</string>
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
<string name="screen_login_form_header">"輸入您的詳細資料"</string> <string name="screen_login_form_header">"輸入您的詳細資料"</string>
<string name="screen_login_title">"歡迎回來!"</string> <string name="screen_login_title">"歡迎回來!"</string>
<string name="screen_login_title_with_homeserver">"登入 %1$s"</string> <string name="screen_login_title_with_homeserver">"登入 %1$s"</string>
<string name="screen_server_confirmation_change_server">"更改帳號提供者"</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"</string>
<string name="screen_server_confirmation_message_register">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</string>
<string name="screen_server_confirmation_title_login">"您即將登入 %1$s"</string> <string name="screen_server_confirmation_title_login">"您即將登入 %1$s"</string>
<string name="screen_server_confirmation_title_register">"您即將在 %1$s 建立帳號"</string> <string name="screen_server_confirmation_title_register">"您即將在 %1$s 建立帳號"</string>
<string name="screen_waitlist_message_success">"歡迎使用 %1$s"</string> <string name="screen_waitlist_message_success">"歡迎使用 %1$s"</string>
@ -16,5 +31,6 @@
<string name="screen_change_server_title">"選擇您的伺服器"</string> <string name="screen_change_server_title">"選擇您的伺服器"</string>
<string name="screen_login_password_hint">"密碼"</string> <string name="screen_login_password_hint">"密碼"</string>
<string name="screen_login_submit">"繼續"</string> <string name="screen_login_submit">"繼續"</string>
<string name="screen_login_subtitle">"Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"</string>
<string name="screen_login_username_hint">"使用者名稱"</string> <string name="screen_login_username_hint">"使用者名稱"</string>
</resources> </resources>

View file

@ -63,7 +63,7 @@ class ChangeServerPresenterTest {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
authenticationService.givenHomeserver(A_HOMESERVER) authenticationService.givenHomeserver(A_HOMESERVER)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem() val successState = awaitItem()
@ -83,7 +83,7 @@ class ChangeServerPresenterTest {
}.test { }.test {
val initialState = awaitItem() val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized) assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(A_HOMESERVER_URL))) initialState.eventSink.invoke(ChangeServerEvents.ChangeServer(AccountProvider(url = A_HOMESERVER_URL)))
val loadingState = awaitItem() val loadingState = awaitItem()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java) assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val failureState = awaitItem() val failureState = awaitItem()

View file

@ -50,6 +50,7 @@ class ChangeAccountProviderPresenterTest {
assertThat(initialState.accountProviders).isEqualTo( assertThat(initialState.accountProviders).isEqualTo(
listOf( listOf(
AccountProvider( AccountProvider(
url = "https://matrix.org",
title = "matrix.org", title = "matrix.org",
subtitle = null, subtitle = null,
isPublic = true, isPublic = true,

View file

@ -74,7 +74,7 @@ fun LogoutPreferenceView(
} }
@Composable @Composable
fun LogoutPreferenceContent( private fun LogoutPreferenceContent(
onClick: () -> Unit = {}, onClick: () -> Unit = {},
) { ) {
PreferenceText( PreferenceText(

View file

@ -38,7 +38,6 @@ dependencies {
implementation(projects.libraries.testtags) implementation(projects.libraries.testtags)
implementation(projects.libraries.uiStrings) implementation(projects.libraries.uiStrings)
implementation(projects.libraries.dateformatter.api) implementation(projects.libraries.dateformatter.api)
implementation(libs.accompanist.placeholder)
api(projects.features.logout.api) api(projects.features.logout.api)
ksp(libs.showkase.processor) ksp(libs.showkase.processor)

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