Merge branch 'release/0.2.4' into main

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

View file

@ -38,7 +38,7 @@ jobs:
distribution: 'temurin' # See 'Supported distributions' for available options
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1
uses: gradle/gradle-build-action@v2.9.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Assemble debug APK
@ -55,7 +55,7 @@ jobs:
name: elementx-debug
path: |
app/build/outputs/apk/debug/*.apk
- uses: rnkdsh/action-upload-diawi@v1.5.2
- uses: rnkdsh/action-upload-diawi@v1.5.3
id: diawi
# Do not fail the whole build if Diawi upload fails
continue-on-error: true

View file

@ -40,7 +40,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_API_KEY: ${{ secrets.MAPTILER_KEY }}
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- uses: mobile-dev-inc/action-maestro-cloud@v1.5.0
- uses: mobile-dev-inc/action-maestro-cloud@v1.6.0
with:
api-key: ${{ secrets.MAESTRO_CLOUD_API_KEY }}
# 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
java-version: '17'
- name: Configure gradle
uses: gradle/gradle-build-action@v2.8.1
uses: gradle/gradle-build-action@v2.9.0
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Dependency analysis

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -10,9 +10,9 @@ appId: ${APP_ID}
- tapOn:
id: "change_server-server"
# Test server that does not support sliding sync.
- inputText: "gnuradio"
- inputText: "https://kieranml.ems-support.element.dev"
- hideKeyboard
- tapOn: "gnuradio.org"
- tapOn: "kieranml.ems-support.element.dev"
- extendedWaitUntil:
visible: "This server currently doesnt support sliding sync."
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)
========================================

View file

@ -18,6 +18,7 @@
* [knit](#knit)
* [lint](#lint)
* [Unit tests](#unit-tests)
* [konsist](#konsist)
* [Tests](#tests)
* [Accessibility](#accessibility)
* [Jetpack Compose](#jetpack-compose)
@ -156,6 +157,10 @@ Make sure the following commands execute without any error:
./gradlew test
</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
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.turbine)
testImplementation(projects.libraries.matrix.test)
testImplementation(libs.test.konsist)
ksp(libs.showkase.processor)
}

View file

@ -20,14 +20,16 @@
<!-- To be able to install APK from the application -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- Do not enable enableOnBackInvokedCallback until https://issuetracker.google.com/issues/271303558 is fixed -->
<application
android:name=".ElementXApplication"
android:allowBackup="false"
android:dataExtractionRules="@xml/data_extraction_rules"
android:enableOnBackInvokedCallback="true"
android:enableOnBackInvokedCallback="false"
android:fullBackupContent="@xml/backup_rules"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.ElementX"
@ -81,11 +83,12 @@
tools:node="remove" />
<provider
android:authorities="${applicationId}.fileprovider"
android:name="androidx.core.content.FileProvider"
android:grantUriPermissions="true"
android:exported="false">
<meta-data android:name="android.support.FILE_PROVIDER_PATHS"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_providers" />
</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 io.element.android.libraries.architecture.bindings
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.x.di.AppBindings
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 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.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.meta.BuildMeta
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.ApplicationContext
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
import io.element.android.libraries.designsystem.utils.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.SnackbarMessage
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
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.verification.SessionVerificationService
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.repeatOnLifecycle
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.modality.BuildContext
import com.bumble.appyx.core.navigation.model.permanent.PermanentNavModel
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
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.waitForChildAttached
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.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.MAIN_SPACE
@ -95,6 +97,10 @@ class LoggedInFlowNode @AssistedInject constructor(
initialElement = NavTarget.RoomList,
savedStateMap = buildContext.savedStateMap,
),
permanentNavModel = PermanentNavModel(
NavTarget.Permanent,
savedStateMap = buildContext.savedStateMap,
),
buildContext = buildContext,
plugins = plugins
) {
@ -328,7 +334,7 @@ class LoggedInFlowNode @AssistedInject constructor(
val isFtueDisplayed by ftueState.shouldDisplayFlow.collectAsState()
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.OidcActionFlow
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.animation.rememberDefaultTransitionHandler
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.matrix.api.auth.MatrixAuthenticationService
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.launchIn
import kotlinx.coroutines.flow.onEach
@ -69,6 +71,7 @@ class RootFlowNode @AssistedInject constructor(
private val matrixClientsHolder: MatrixClientsHolder,
private val presenter: RootPresenter,
private val bugReportEntryPoint: BugReportEntryPoint,
private val signedOutEntryPoint: SignedOutEntryPoint,
private val intentResolver: IntentResolver,
private val oidcActionFlow: OidcActionFlow,
) : BackstackNode<RootFlowNode.NavTarget>(
@ -97,13 +100,20 @@ class RootFlowNode @AssistedInject constructor(
.distinctUntilChanged()
.onEach { navState ->
Timber.v("navState=$navState")
if (navState.isLoggedIn) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow() }
)
} else {
switchToNotLoggedInFlow()
when (navState.loggedInState) {
is LoggedInState.LoggedIn -> {
if (navState.loggedInState.isTokenValid) {
tryToRestoreLatestSession(
onSuccess = { sessionId -> switchToLoggedInFlow(sessionId, navState.cacheIndex) },
onFailure = { switchToNotLoggedInFlow() }
)
} else {
switchToSignedOutFlow(SessionId(navState.loggedInState.sessionId))
}
}
LoggedInState.NotLoggedIn -> {
switchToNotLoggedInFlow()
}
}
}
.launchIn(lifecycleScope)
@ -118,6 +128,10 @@ class RootFlowNode @AssistedInject constructor(
backstack.safeRoot(NavTarget.NotLoggedInFlow)
}
private fun switchToSignedOutFlow(sessionId: SessionId) {
backstack.safeRoot(NavTarget.SignedOutFlow(sessionId))
}
private suspend fun restoreSessionIfNeeded(
sessionId: SessionId,
onFailure: () -> Unit = {},
@ -179,6 +193,11 @@ class RootFlowNode @AssistedInject constructor(
val navId: Int
) : NavTarget
@Parcelize
data class SignedOutFlow(
val sessionId: SessionId
) : NavTarget
@Parcelize
data object BugReport : NavTarget
}
@ -198,6 +217,15 @@ class RootFlowNode @AssistedInject constructor(
createNode<LoggedInAppScopeFlowNode>(buildContext, plugins = listOf(inputs, callback))
}
NavTarget.NotLoggedInFlow -> createNode<NotLoggedInFlowNode>(buildContext)
is NavTarget.SignedOutFlow -> {
signedOutEntryPoint.nodeBuilder(this, buildContext)
.params(
SignedOutEntryPoint.Params(
sessionId = navTarget.sessionId
)
)
.build()
}
NavTarget.SplashScreen -> splashNode(buildContext)
NavTarget.BugReport -> {
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.RoomMembershipObserver
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
@ -60,6 +61,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
private val messagesEntryPoint: MessagesEntryPoint,
private val roomDetailsEntryPoint: RoomDetailsEntryPoint,
private val appNavigationStateService: AppNavigationStateService,
private val appCoroutineScope: CoroutineScope,
roomComponentFactory: RoomComponentFactory,
roomMembershipObserver: RoomMembershipObserver,
) : BackstackNode<RoomLoadedFlowNode.NavTarget>(
@ -91,6 +93,16 @@ class RoomLoadedFlowNode @AssistedInject constructor(
appNavigationStateService.onNavigateToRoom(id, inputs.room.roomId)
fetchRoomMembers()
},
onResume = {
appCoroutineScope.launch {
inputs.room.subscribeToSync()
}
},
onPause = {
appCoroutineScope.launch {
inputs.room.unsubscribeFromSync()
}
},
onDestroy = {
Timber.v("OnDestroy")
appNavigationStateService.onLeavingRoom(id)
@ -162,9 +174,7 @@ class RoomLoadedFlowNode @AssistedInject constructor(
// 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.
DisposableEffect(Unit) {
inputs.room.subscribeToSync()
onDispose {
inputs.room.unsubscribeFromSync()
if (lifecycle.currentState == Lifecycle.State.DESTROYED) {
inputs.room.destroy()
}

View file

@ -16,6 +16,8 @@
package io.element.android.appnav.root
import io.element.android.libraries.sessionstorage.api.LoggedInState
/**
* [RootNavState] produced by [RootNavStateFlowFactory].
*/
@ -26,7 +28,7 @@ data class RootNavState(
*/
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.preferences.api.CacheService
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.combine
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.onEach
import javax.inject.Inject
@ -47,9 +47,14 @@ class RootNavStateFlowFactory @Inject constructor(
fun create(savedStateMap: SavedStateMap?): Flow<RootNavState> {
return combine(
cacheIndexFlow(savedStateMap),
isUserLoggedInFlow(),
) { cacheIndex, isLoggedIn ->
RootNavState(cacheIndex = cacheIndex, isLoggedIn = isLoggedIn)
authenticationService.loggedInStateFlow(),
loginUserStory.loginFlowIsDone,
) { 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.
*/

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.test.room.FakeMatrixRoom
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@ -85,6 +87,7 @@ class RoomFlowNodeTest {
plugins: List<Plugin>,
messagesEntryPoint: MessagesEntryPoint = FakeMessagesEntryPoint(),
roomDetailsEntryPoint: RoomDetailsEntryPoint = FakeRoomDetailsEntryPoint(),
coroutineScope: CoroutineScope,
) = RoomLoadedFlowNode(
buildContext = BuildContext.root(savedStateMap = null),
plugins = plugins,
@ -92,16 +95,21 @@ class RoomFlowNodeTest {
roomDetailsEntryPoint = roomDetailsEntryPoint,
appNavigationStateService = FakeAppNavigationStateService(),
roomMembershipObserver = RoomMembershipObserver(),
appCoroutineScope = coroutineScope,
roomComponentFactory = FakeRoomComponentFactory(),
)
@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
val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val inputs = RoomLoadedFlowNode.Inputs(room)
val roomFlowNode = aRoomFlowNode(listOf(inputs), fakeMessagesEntryPoint)
val roomFlowNode = aRoomFlowNode(
plugins = listOf(inputs),
messagesEntryPoint = fakeMessagesEntryPoint,
coroutineScope = this
)
// WHEN
val roomFlowNodeTestHelper = roomFlowNode.parentNodeTestHelper()
@ -113,13 +121,18 @@ class RoomFlowNodeTest {
}
@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
val room = FakeMatrixRoom()
val fakeMessagesEntryPoint = FakeMessagesEntryPoint()
val fakeRoomDetailsEntryPoint = FakeRoomDetailsEntryPoint()
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()
// WHEN
fakeMessagesEntryPoint.callback?.onRoomDetailsClicked()

View file

@ -42,7 +42,7 @@ class RootPresenterTest {
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter()
val presenter = createRootPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -54,7 +54,7 @@ class RootPresenterTest {
@Test
fun `present - passes app error state`() = runTest {
val presenter = createPresenter(
val presenter = createRootPresenter(
appErrorService = DefaultAppErrorStateService().apply {
showError("Bad news", "Something bad happened")
}
@ -75,7 +75,7 @@ class RootPresenterTest {
}
}
private fun createPresenter(
private fun createRootPresenter(
appErrorService: AppErrorStateService = DefaultAppErrorStateService()
): RootPresenter {
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
fun `present - initial state`() = runTest {
val presenter = createPresenter()
val presenter = createLoggedInPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -54,7 +54,7 @@ class LoggedInPresenterTest {
@Test
fun `present - show sync spinner`() = runTest {
val roomListService = FakeRoomListService()
val presenter = createPresenter(roomListService, NetworkStatus.Online)
val presenter = createLoggedInPresenter(roomListService, NetworkStatus.Online)
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -66,7 +66,7 @@ class LoggedInPresenterTest {
}
}
private fun createPresenter(
private fun createLoggedInPresenter(
roomListService: RoomListService = FakeRoomListService(),
networkStatus: NetworkStatus = NetworkStatus.Offline
): LoggedInPresenter {

View file

@ -45,7 +45,7 @@ plugins {
}
tasks.register<Delete>("clean").configure {
delete(rootProject.buildDir)
delete(rootProject.layout.buildDirectory)
}
allprojects {
@ -86,7 +86,7 @@ allprojects {
reporter(org.jlleitschuh.gradle.ktlint.reporter.ReporterType.CHECKSTYLE)
}
filter {
exclude { element -> element.file.path.contains("$buildDir/generated/") }
exclude { element -> element.file.path.contains("${layout.buildDirectory.asFile.get()}/generated/") }
}
}
// Dependency check
@ -176,10 +176,13 @@ koverMerged {
"*_ModuleKt",
"anvil.hint.binding.io.element.*",
"anvil.hint.merge.*",
"anvil.hint.multibinding.io.element.*",
"anvil.module.*",
"com.airbnb.android.showkase*",
"io.element.android.libraries.designsystem.showkase.*",
"io.element.android.x.di.DaggerAppComponent*",
"*_Factory",
"*_Factory_Impl",
"*_Factory$*",
"*_Module",
"*_Module$*",
@ -228,11 +231,11 @@ koverMerged {
name = "Global minimum code coverage."
target = kotlinx.kover.api.VerificationTarget.ALL
bound {
minValue = 55
minValue = 60
// 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
// minValue to 25 and maxValue to 35.
maxValue = 65
maxValue = 70
counter = kotlinx.kover.api.CounterType.INSTRUCTION
valueType = kotlinx.kover.api.VerificationValueType.COVERED_PERCENTAGE
}
@ -354,7 +357,7 @@ subprojects {
subprojects {
tasks.withType<KspTask>() {
doLast {
fileTree(buildDir).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file ->
fileTree(layout.buildDirectory).apply { include("**/*ShowkaseExtension*.kt") }.files.forEach { file ->
ReplaceRegExp().apply {
setMatch("^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
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)
- 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
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
git clone git@github.com:matrix-org/matrix-rust-sdk.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
```
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`:
```groovy
@ -280,11 +276,12 @@ Follow these steps to install and configure the plugin and templates:
1. Install the AS plugin for generating modules :
[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
- Pick the `tools/templates/file_templates.zip` files
- Pick the `tmp/file_templates.zip` files
- 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
- Click on + / Import From File
- 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.libraries.designsystem.atomic.molecules.ButtonColumnMolecule
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.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview

View file

@ -1,7 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<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_content_link">"這裡"</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
* - confineToRoom=true
* to ensure that the rendering will bo correct on the embedded Webview.
*/
private fun Uri.withCustomParameters(): String {
val builder = buildUpon()
// Remove the existing query parameters
builder.clearQuery()
queryParameterNames.forEach {
if (it == APP_PROMPT_PARAMETER || it == CONFINE_TO_ROOM_PARAMETER) return@forEach
builder.appendQueryParameter(it, getQueryParameter(it))
}
builder.appendQueryParameter(APP_PROMPT_PARAMETER, "false")
builder.appendQueryParameter(CONFINE_TO_ROOM_PARAMETER, "true")
return builder.build().toString()
// Remove the existing fragment parameters, and build the new fragment
val currentFragment = fragment ?: ""
// 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"

View file

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

View file

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

View file

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

View file

@ -4,6 +4,10 @@
<string name="screen_create_room_action_invite_people">"邀請朋友使用 Element"</string>
<string name="screen_create_room_add_people_title">"邀請夥伴"</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_topic_label">"主題(非必填)"</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.libraries.designsystem.atomic.atoms.ElementLogoAtom
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.molecules.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.organisms.InfoListItem
import io.element.android.libraries.designsystem.atomic.organisms.InfoListOrganism
import io.element.android.libraries.designsystem.atomic.pages.OnBoardingPage
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.preview.ElementPreview

View file

@ -1,5 +1,11 @@
<?xml version="1.0" encoding="utf-8"?>
<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_subtitle">"我們有些事想告訴您:"</string>
<string name="screen_welcome_title">"歡迎使用 %1$s"</string>
</resources>

View file

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

View file

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

View file

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

View file

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

View file

@ -77,7 +77,7 @@ internal fun InviteSummaryRow(
}
@Composable
internal fun DefaultInviteSummaryRow(
private fun DefaultInviteSummaryRow(
invite: InviteListInviteSummary,
onAcceptClicked: () -> 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
data class LeaveRoomState(
val confirmation: Confirmation = Confirmation.Hidden,
val progress: Progress = Progress.Hidden,
val error: Error = Error.Hidden,
val eventSink: (LeaveRoomEvent) -> Unit = {},
val confirmation: Confirmation,
val progress: Progress,
val error: Error,
val eventSink: (LeaveRoomEvent) -> Unit,
) {
sealed interface Confirmation {
data object Hidden : Confirmation

View file

@ -22,32 +22,32 @@ import io.element.android.libraries.matrix.api.core.RoomId
class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
override val values: Sequence<LeaveRoomState>
get() = sequenceOf(
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Generic(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.PrivateRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.LastUserInRoom(A_ROOM_ID),
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Shown,
error = LeaveRoomState.Error.Hidden,
),
LeaveRoomState(
aLeaveRoomState(
confirmation = LeaveRoomState.Confirmation.Hidden,
progress = LeaveRoomState.Progress.Hidden,
error = LeaveRoomState.Error.Shown,
@ -56,3 +56,14 @@ class LeaveRoomStateProvider : PreviewParameterProvider<LeaveRoomState> {
}
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
fun `present - initial state hides all dialogs`() = runTest {
val presenter = createPresenter()
val presenter = createLeaveRoomPresenter()
moleculeFlow(RecompositionMode.Immediate) {
presenter.present()
}.test {
@ -57,7 +57,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show generic confirmation`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -77,7 +77,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show private room confirmation`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -97,7 +97,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show last user in room confirmation`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -118,7 +118,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - leaving a room leaves the room`() = runTest {
val roomMembershipObserver = RoomMembershipObserver()
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -140,7 +140,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show error if leave room fails`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -164,7 +164,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - show progress indicator while leaving a room`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -186,7 +186,7 @@ class LeaveRoomPresenterImplTest {
@Test
fun `present - hide error hides the error`() = runTest {
val presenter = createPresenter(
val presenter = createLeaveRoomPresenter(
client = FakeMatrixClient().apply {
givenGetRoomResult(
roomId = A_ROOM_ID,
@ -212,7 +212,7 @@ class LeaveRoomPresenterImplTest {
}
}
private fun TestScope.createPresenter(
private fun TestScope.createLeaveRoomPresenter(
client: MatrixClient = FakeMatrixClient(),
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
): 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.LeaveRoomPresenter
import io.element.android.features.leaveroom.api.LeaveRoomState
import javax.inject.Inject
class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
class FakeLeaveRoomPresenter : LeaveRoomPresenter {
val events = mutableListOf<LeaveRoomEvent>()
@ -30,7 +29,12 @@ class LeaveRoomPresenterFake @Inject constructor() : LeaveRoomPresenter {
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) {
field = value.copy(eventSink = ::handleEvent)
}

View file

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

View file

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

View file

@ -23,35 +23,44 @@ private const val APP_NAME = "ApplicationName"
class SendLocationStateProvider : PreviewParameterProvider<SendLocationState> {
override val values: Sequence<SendLocationState>
get() = sequenceOf(
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionDenied,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.PermissionRationale,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = false,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.PinLocation,
hasLocationPermission = true,
appName = APP_NAME,
),
SendLocationState(
aSendLocationState(
permissionDialog = SendLocationState.Dialog.None,
mode = SendLocationState.Mode.SenderLocation,
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
}
private var state = PermissionsState(eventSink = ::handleEvent)
private var state = PermissionsState(
permissions = PermissionsState.Permissions.NoneGranted,
shouldShowRationale = false,
eventSink = ::handleEvent
)
set(value) {
field = value.copy(eventSink = ::handleEvent)
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -21,6 +21,8 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.login.impl.R
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
@Composable
@ -39,3 +41,12 @@ internal fun SlidingSyncNotSupportedDialog(
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 io.element.android.features.login.impl.accountprovider.AccountProvider
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 javax.inject.Inject
@ -33,7 +34,7 @@ class ChangeAccountProviderPresenter @Inject constructor(
// Just matrix.org by default for now
accountProviders = listOf(
AccountProvider(
title = "matrix.org",
url = LoginConstants.MATRIX_ORG_URL,
subtitle = null,
isPublic = true,
isMatrixOrg = true,

View file

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

View file

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

View file

@ -27,7 +27,7 @@ data class ConfirmAccountProviderState(
val loginFlow: Async<LoginFlow>,
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 {

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.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ConfirmAccountProviderView(
@ -86,7 +87,7 @@ fun ConfirmAccountProviderView(
footer = {
ButtonColumnMolecule {
Button(
text = stringResource(id = R.string.screen_account_provider_continue),
text = stringResource(id = CommonStrings.action_continue),
showProgress = isLoading,
onClick = { eventSink.invoke(ConfirmAccountProviderEvents.Continue) },
enabled = state.submitEnabled || isLoading,

View file

@ -139,7 +139,7 @@ fun LoginPasswordView(
Spacer(modifier = Modifier.weight(1f))
// Submit
Button(
text = stringResource(R.string.screen_login_submit),
text = stringResource(CommonStrings.action_continue),
showProgress = isLoading,
onClick = ::submit,
enabled = state.submitEnabled || isLoading,
@ -167,7 +167,7 @@ fun LoginPasswordView(
@OptIn(ExperimentalComposeUiApi::class)
@Composable
internal fun LoginForm(
private fun LoginForm(
state: LoginPasswordState,
isLoading: Boolean,
onSubmit: () -> Unit,
@ -199,7 +199,7 @@ internal fun LoginForm(
eventSink(LoginPasswordEvents.SetLogin(it))
}),
placeholder = {
Text(text = stringResource(R.string.screen_login_username_hint))
Text(text = stringResource(CommonStrings.common_username))
},
onValueChange = {
loginFieldState = it
@ -246,7 +246,7 @@ internal fun LoginForm(
eventSink(LoginPasswordEvents.SetPassword(it))
},
placeholder = {
Text(text = stringResource(R.string.screen_login_password_hint))
Text(text = stringResource(CommonStrings.common_password))
},
visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(),
trailingIcon = {
@ -272,7 +272,7 @@ internal fun LoginForm(
}
@Composable
internal fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
private fun LoginErrorDialog(error: Throwable, onDismiss: () -> Unit) {
ErrorDialog(
title = stringResource(id = CommonStrings.dialog_title_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 io.element.android.features.login.impl.changeserver.aChangeServerState
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
open class SearchAccountProviderStateProvider : PreviewParameterProvider<SearchAccountProviderState> {
@ -49,7 +50,7 @@ fun aHomeserverDataList(): List<HomeserverData> {
}
fun aHomeserverData(
homeserverUrl: String = "https://matrix.org",
homeserverUrl: String = LoginConstants.MATRIX_ORG_URL,
isWellknownValid: Boolean = true,
supportSlidingSync: Boolean = true,
): 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.ChangeServerView
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.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.components.button.BackButton
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.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
@ -195,9 +196,9 @@ fun SearchAccountProviderView(
@Composable
private fun HomeserverData.toAccountProvider(): AccountProvider {
val isMatrixOrg = homeserverUrl == "https://matrix.org"
val isMatrixOrg = homeserverUrl == LoginConstants.MATRIX_ORG_URL
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,
isPublic = isMatrixOrg, // There is no need to know for other servers right now
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
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"
}
val defaultAccountProvider = AccountProvider(
title = LoginConstants.DEFAULT_HOMESERVER_URL,
url = LoginConstants.DEFAULT_HOMESERVER_URL,
subtitle = null,
isPublic = 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"?>
<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_signup_subtitle">"您的所有對話將保存於此,就如同您的電子郵件供應商會保存您的電子郵件一樣。"</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_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_login_error_deactivated_account">"這個帳號已被停用。"</string>
<string name="screen_login_error_invalid_credentials">"不正確的使用者名稱或密碼"</string>
<string name="screen_login_form_header">"輸入您的詳細資料"</string>
<string name="screen_login_title">"歡迎回來!"</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_register">"您即將在 %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_login_password_hint">"密碼"</string>
<string name="screen_login_submit">"繼續"</string>
<string name="screen_login_subtitle">"Matrix 是一個開放網路,為了安全、去中心化的通訊而生。"</string>
<string name="screen_login_username_hint">"使用者名稱"</string>
</resources>

View file

@ -63,7 +63,7 @@ class ChangeServerPresenterTest {
val initialState = awaitItem()
assertThat(initialState.changeServerAction).isEqualTo(Async.Uninitialized)
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()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val successState = awaitItem()
@ -83,7 +83,7 @@ class ChangeServerPresenterTest {
}.test {
val initialState = awaitItem()
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()
assertThat(loadingState.changeServerAction).isInstanceOf(Async.Loading::class.java)
val failureState = awaitItem()

View file

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

View file

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

View file

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

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