Merge develop into feature/account-provider-selection

This commit is contained in:
Jenna Vassar
2026-08-14 07:51:17 -07:00
1632 changed files with 11623 additions and 5960 deletions
@@ -39,7 +39,7 @@ fun AnalyticsPreferencesView(
)
Column(modifier) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_analytics_settings_share_data))
},
supportingContent = {
@@ -12,15 +12,34 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import kotlinx.coroutines.flow.Flow
/**
* Queues the full-screen announcements shown to the user once, such as the notice about a new feature.
*
* A dismissed announcement is remembered so it is not shown again.
*/
interface AnnouncementService {
/**
* Queues an announcement, unless the user has already dismissed it.
*
* @param announcement the announcement to show.
*/
suspend fun showAnnouncement(announcement: Announcement)
/**
* Records that the user dismissed an announcement, so it is not queued again.
*
* @param announcement the announcement that was dismissed.
*/
suspend fun onAnnouncementDismissed(announcement: Announcement)
/** The announcements still waiting to be shown, in the order they should appear. */
fun announcementsToShowFlow(): Flow<List<Announcement>>
/**
* Use this composable to render the announcement UI in Fullscreen.
* Draws nothing while there is no announcement to show.
*
* @param modifier layout modifier for the container.
*/
@Composable
fun Render(
@@ -8,6 +8,9 @@
package io.element.android.features.cachecleaner.api
/**
* Removes the temporary files holding decrypted content, so plaintext media does not linger on disk.
*/
interface CacheCleaner {
/**
* Clear the cache subdirs holding temporarily decrypted content (such as media and voice messages).
@@ -10,6 +10,9 @@ package io.element.android.features.call.api
import kotlinx.coroutines.flow.StateFlow
/**
* Tracks the call the user is currently in on this device, so unrelated parts of the app can react to it.
*/
interface CurrentCallService {
/**
* The current call state flow, which will be updated when the active call changes.
@@ -18,5 +18,11 @@ import io.element.android.libraries.matrix.ui.media.contentvalidation.ContentVal
* This will only process media when a [ContentScanner] is provided when building the client in Pro.
*/
fun interface ContentScannerService {
/**
* Starts scanning the given media, reporting the verdict of each one through [contentValidationState] rather than by returning.
*
* @param mediaSources the media to scan.
* @param contentValidationState where the outcome of each scan is published for the UI to observe.
*/
fun scan(mediaSources: List<MediaSource>, contentValidationState: ContentValidationState)
}
@@ -15,9 +15,21 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.RoomId
interface CreateRoomEntryPoint : FeatureEntryPoint {
/**
* Configures the create-room flow before it is started, since it serves rooms and spaces from the same screens.
*/
interface Builder {
/**
* @param isSpace true to create a space rather than a room.
*/
fun setIsSpace(isSpace: Boolean): Builder
/**
* @param parentSpaceId the space the new room should be added to once created.
*/
fun setParentSpace(parentSpaceId: RoomId): Builder
/** Builds the node with the options configured so far. */
fun build(): Node
}
@@ -312,7 +312,7 @@ private fun RoomJoinRuleOptions(
backgroundTint = Color.Transparent,
)
},
headlineContent = {
content = {
val title = when (item) {
JoinRuleItem.PublicVisibility.Public -> stringResource(R.string.screen_create_room_room_access_section_public_option_title)
is JoinRuleItem.PrivateVisibility.Restricted -> stringResource(R.string.screen_create_room_room_access_section_restricted_option_title)
@@ -58,7 +58,7 @@ internal fun SelectParentSpaceOptions(
modifier = modifier
) {
ListItem(
headlineContent = {
content = {
Text(
text = selectedSpace?.displayName
?: stringResource(R.string.screen_create_room_space_selection_no_space_title),
@@ -126,7 +126,7 @@ private fun SelectParentSpaceBottomSheet(
LazyColumn(modifier = Modifier.fillMaxWidth()) {
item {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(R.string.screen_create_room_space_selection_no_space_option),
maxLines = 1
@@ -141,7 +141,7 @@ private fun SelectParentSpaceBottomSheet(
for (space in spaces) {
item {
ListItem(
headlineContent = {
content = {
Text(
text = space.displayName,
maxLines = 1
@@ -1,14 +1,14 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要停用您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_confirmation_dialog_content">"請確認您想要刪除您的帳號。此動作無法還原。"</string>
<string name="screen_deactivate_account_delete_all_messages">"刪除我所有的訊息"</string>
<string name="screen_deactivate_account_delete_all_messages_notice">"警告:未來的使用者可能會看到不完整的對話。"</string>
<string name="screen_deactivate_account_description">"停用您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description">"刪除您的帳號為 %1$s,它將:"</string>
<string name="screen_deactivate_account_description_bold_part">"不可逆"</string>
<string name="screen_deactivate_account_list_item_1">"%1$s 您的帳號(您將無法重新登入,也無法重用您的 ID)。"</string>
<string name="screen_deactivate_account_list_item_1_bold_part">"永久停用"</string>
<string name="screen_deactivate_account_list_item_2">"將您從所有聊天室移除。"</string>
<string name="screen_deactivate_account_list_item_3">"從我們的身份伺服器將您的帳號資訊刪除。"</string>
<string name="screen_deactivate_account_list_item_4">"註冊使用者仍可看到您的訊息,但如果您選擇刪除,新使用者與未註冊的使用者將看不到它們。"</string>
<string name="screen_deactivate_account_title">"停用帳號"</string>
<string name="screen_deactivate_account_title">"刪除帳號"</string>
</resources>
@@ -10,15 +10,46 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
/**
* Exposes the restrictions and customisations an enterprise deployment can impose: allowed homeservers, branding, push gateways and more.
*
* On a standard build this reports permissive defaults, so callers do not need to branch on the build type themselves.
*/
interface EnterpriseService {
/** Whether this build is an enterprise one; `false` for the public app. */
val isEnterpriseBuild: Boolean
/**
* Whether the given session belongs to an enterprise deployment, which can be true even on a standard build.
*
* @param sessionId the session to check.
*/
suspend fun isEnterpriseUser(sessionId: SessionId): Boolean
suspend fun tweakMasUrl(url: String, homeserver: String): String
/**
* Rewrites the authentication server URL when the deployment requires a different one from the one advertised.
*
* @param url the URL to rewrite.
* @param homeserver the homeserver the URL belongs to.
* @param urlContentFetcher used to read the deployment configuration; a client that is not authenticated yet also works.
*/
suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String
/**
* The homeservers the user is allowed to sign in to; an empty list, or one containing [ANY_ACCOUNT_PROVIDER], means no restriction.
*/
fun defaultHomeserverList(): List<String>
/**
* Whether the user is allowed to sign in to a given homeserver, according to [defaultHomeserverList].
*
* @param homeserverUrl the server the user is trying to use.
*/
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
/**
@@ -28,17 +59,38 @@ interface EnterpriseService {
*/
suspend fun overrideBrandColor(sessionId: SessionId?, brandColor: String?)
/**
* The brand color to apply, or `null` when the default theme color should be used.
*
* @param sessionId the session to read the color of, or `null` for the color used when no session is active.
*/
fun brandColorsFlow(sessionId: SessionId?): Flow<Color?>
/**
* The full light and dark color palettes derived from the brand color.
*
* @param sessionId the session to read the colors of, or `null` for the colors used when no session is active.
*/
fun semanticColorsFlow(sessionId: SessionId?): Flow<SemanticColorsLightDark>
/** The push gateway to register with when using Firebase, or `null` to use the app default. */
fun firebasePushGateway(): String?
/** The push gateway to register with when using UnifiedPush, or `null` to use the app default. */
fun unifiedPushDefaultPushGateway(): String?
/**
* Where bug reports should be submitted, which an enterprise deployment can redirect or disable entirely.
*
* @param sessionId the session to read the setting of, or `null` for the setting used when no session is active.
*/
fun bugReportUrlFlow(sessionId: SessionId?): Flow<BugReportUrl>
/**
* Gets Notification Channel to use for the noisy notifications of the provided session.
* Returns `null` when the session has no dedicated channel and the app-wide one should be used.
*
* @param sessionId the session whose channel is requested.
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
@@ -47,6 +99,14 @@ interface EnterpriseService {
*/
fun overriddenElementWellKnown(): ElementWellKnown?
/**
* Gets the Element Server Suite (ESS) config endpoint URL for the given domain.
* Returns `null` when this build does not read its configuration from an ESS deployment.
*
* @param domain the server whose configuration endpoint is requested.
*/
fun essConfigEndpointUrl(domain: String): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
@@ -8,11 +8,25 @@
package io.element.android.features.enterprise.api
/**
* The session-scoped counterpart of [EnterpriseService], answering the same kind of questions for the session it belongs to.
*
* [init] must have run before the other members return meaningful answers.
*/
interface SessionEnterpriseService {
/** Whether Element Call may be used on this session, which a deployment can turn off. */
suspend fun isElementCallAvailable(): Boolean
/**
* Rewrites the authentication server URL for this session; see [EnterpriseService.tweakMasUrl].
*
* @param url the URL to rewrite.
*/
suspend fun tweakMasUrl(url: String): String
/** Whether the homeserver forbids creating encrypted rooms, in which case the app must not offer the option. */
suspend fun isEncryptionDisabledByHomeserver(): Boolean
/** Loads the deployment configuration of this session; to be called once when the session starts. */
suspend fun init()
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.api.remoteconfig
import io.element.android.libraries.wellknown.api.ElementWellKnown
/**
* Typealias for the well-known configuration used for enterprise features.
*
* This will allow for easier refactoring in the future when we'll want to move as much of its logic as possible to this module.
*/
typealias RemoteEnterpriseConfig = ElementWellKnown
@@ -0,0 +1,35 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.api.remoteconfig
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
/**
* Wrapper to fetch the remote enterprise configuration for a given [SessionId], either from a local cache or from a remote source.
*/
interface RemoteEnterpriseConfigProvider {
/**
* Returns the configuration of a session, served from the cache when possible.
* A stale entry is reported as outdated while a refresh runs in the background, so this rarely waits on the network.
*
* @param sessionId the session whose configuration is requested.
*/
suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig>
/**
* Creates a provider, which needs a fetcher because the configuration is read over HTTP.
*/
interface Factory {
/**
* @param urlContentFetcher used to perform the request; a client that is not authenticated yet also works.
*/
fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider
}
}
@@ -14,6 +14,7 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import kotlinx.coroutines.flow.Flow
@@ -24,7 +25,7 @@ class DefaultEnterpriseService : EnterpriseService {
override val isEnterpriseBuild = false
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, homeserver: String) = url
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher) = url
override fun defaultHomeserverList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
@@ -48,4 +49,6 @@ class DefaultEnterpriseService : EnterpriseService {
override fun getNoisyNotificationChannelId(sessionId: SessionId): String? = null
override fun overriddenElementWellKnown(): ElementWellKnown? = null
override fun essConfigEndpointUrl(domain: String): String? = null
}
@@ -0,0 +1,30 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.impl.remoteconfig
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
@ContributesBinding(AppScope::class)
class DefaultRemoteEnterpriseConfigProvider : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return WellknownRetrieverResult.NotFound
}
@ContributesBinding(AppScope::class)
class Factory : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider {
return DefaultRemoteEnterpriseConfigProvider()
}
}
}
@@ -0,0 +1,29 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.impl.scanner
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.scanner.ContentScannerUrlProvider
/**
* Default FOSS implementation of [ContentScannerUrlProvider] that returns `null` for the content scanner URL.
*/
@ContributesBinding(AppScope::class)
class DefaultContentScannerUrlProvider : ContentScannerUrlProvider {
override suspend fun getContentScannerUrl(sessionId: SessionId): Result<String?> = Result.success(null)
@ContributesBinding(AppScope::class)
class Factory : ContentScannerUrlProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): ContentScannerUrlProvider {
return DefaultContentScannerUrlProvider()
}
}
}
@@ -12,6 +12,7 @@ import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.UrlContentFetcher
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.ElementWellKnown
import io.element.android.tests.testutils.lambda.lambdaError
@@ -31,8 +32,9 @@ class FakeEnterpriseService(
private val firebasePushGatewayResult: () -> String? = { lambdaError() },
private val unifiedPushDefaultPushGatewayResult: () -> String? = { lambdaError() },
private val getNoisyNotificationChannelIdResult: (SessionId?) -> String? = { lambdaError() },
private val tweakMasUrlResult: (String, String) -> String = { _, _ -> lambdaError() },
private val tweakMasUrlResult: (String, String, UrlContentFetcher) -> String = { _, _, _ -> lambdaError() },
private val overrideWellKnownResult: () -> ElementWellKnown? = { lambdaError() },
private val essConfigEndpointUrlResult: (String) -> String = { lambdaError() },
) : EnterpriseService {
private val brandColorState = MutableStateFlow(initialBrandColor)
private val semanticColorsState = MutableStateFlow(initialSemanticColors)
@@ -41,8 +43,8 @@ class FakeEnterpriseService(
isEnterpriseUserResult(sessionId)
}
override suspend fun tweakMasUrl(url: String, homeserver: String): String = simulateLongTask {
tweakMasUrlResult(url, homeserver)
override suspend fun tweakMasUrl(url: String, homeserver: String, urlContentFetcher: UrlContentFetcher): String = simulateLongTask {
tweakMasUrlResult(url, homeserver, urlContentFetcher)
}
override fun defaultHomeserverList(): List<String> {
@@ -85,4 +87,8 @@ class FakeEnterpriseService(
override fun overriddenElementWellKnown(): ElementWellKnown? {
return overrideWellKnownResult()
}
override fun essConfigEndpointUrl(domain: String): String {
return essConfigEndpointUrlResult(domain)
}
}
@@ -0,0 +1,21 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
class FakeRemoteEnterpriseConfigProvider(
private val getResult: (SessionId) -> WellknownRetrieverResult<RemoteEnterpriseConfig> = { WellknownRetrieverResult.NotFound }
) : RemoteEnterpriseConfigProvider {
override suspend fun get(sessionId: SessionId): WellknownRetrieverResult<RemoteEnterpriseConfig> {
return getResult(sessionId)
}
}
@@ -0,0 +1,17 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.enterprise.test.remoteconfig
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.libraries.matrix.api.UrlContentFetcher
class FakeRemoteEnterpriseConfigProviderFactory(
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) : RemoteEnterpriseConfigProvider.Factory {
override fun create(urlContentFetcher: UrlContentFetcher): RemoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider
}
@@ -14,12 +14,15 @@ import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.calculateEndPadding
import androidx.compose.foundation.layout.calculateStartPadding
import androidx.compose.foundation.layout.consumeWindowInsets
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.plus
import androidx.compose.foundation.layout.windowInsetsPadding
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
@@ -204,6 +207,9 @@ private fun HomeScaffold(
floatingActionButton = {
val coroutineScope = rememberCoroutineScope()
HomeBottomBar(
// The Scaffold uses top-only insets so the scrollable content can go edge-to-edge behind the
// navigation bar, so the floating toolbar has to apply the bottom inset itself to avoid overlapping it.
modifier = Modifier.windowInsetsPadding(WindowInsets.navigationBars),
currentHomeNavigationBarItem = state.currentHomeNavigationBarItem,
onItemClick = { item ->
// scroll to top if selecting the same item
@@ -8,6 +8,8 @@
package io.element.android.features.home.impl.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.RowScope
@@ -15,9 +17,11 @@ import androidx.compose.foundation.layout.WindowInsets
import androidx.compose.foundation.layout.WindowInsetsSides
import androidx.compose.foundation.layout.asPaddingValues
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.offset
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.statusBarsPadding
import androidx.compose.foundation.pager.VerticalPager
import androidx.compose.foundation.pager.rememberPagerState
@@ -25,8 +29,11 @@ import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.material3.TopAppBarScrollBehavior
import androidx.compose.material3.minimumInteractiveComponentSize
import androidx.compose.material3.rememberTopAppBarState
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
@@ -37,10 +44,20 @@ import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.drawWithContent
import androidx.compose.ui.geometry.Offset
import androidx.compose.ui.graphics.BlendMode
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.CompositingStrategy
import androidx.compose.ui.graphics.graphicsLayer
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.platform.LocalLayoutDirection
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.heading
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Density
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.LayoutDirection
import androidx.compose.ui.unit.dp
import io.element.android.appconfig.RoomListConfig
import io.element.android.compound.theme.ElementTheme
@@ -71,10 +88,13 @@ import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.DisplayedStatus
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.user.UserStatus
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.matrix.ui.model.toEmojiText
import io.element.android.libraries.testtags.TestTags
import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
@@ -151,7 +171,7 @@ fun HomeTopBar(
},
// We want a 16dp left padding for the navigationIcon :
// 4dp from default TopAppBarHorizontalPadding
// 8dp from AccountIcon default padding (because of IconButton)
// 8dp from AccountIcon default padding (48dp touch target around 32dp avatar)
// 4dp extra padding using left insets
windowInsets = WindowInsets(left = 4.dp),
)
@@ -316,19 +336,36 @@ private fun AccountIcon(
modifier: Modifier = Modifier,
) {
val testTag = if (isCurrentAccount) Modifier.testTag(TestTags.homeScreenSettings) else Modifier
IconButton(
modifier = modifier.then(testTag),
onClick = onClick,
val interactionSource = remember { MutableInteractionSource() }
Box(
modifier = modifier
.then(testTag)
.minimumInteractiveComponentSize()
.clickable(
interactionSource = interactionSource,
onClick = onClick,
indication = ripple(bounded = false),
),
contentAlignment = Alignment.Center,
) {
Box {
val avatarData by remember(matrixUser) {
derivedStateOf {
matrixUser.getAvatarData(size = AvatarSize.CurrentUserTopBar)
}
}
val statusEmoji = matrixUser.displayedStatus?.toEmojiText()
val avatarModifier = if (statusEmoji != null) {
Modifier.eraseStatusEmojiBackground(
parentSize = AvatarSize.CurrentUserTopBar.dp,
layoutDirection = LocalLayoutDirection.current,
)
} else {
Modifier
}
Avatar(
avatarData = avatarData,
avatarType = AvatarType.User,
modifier = avatarModifier,
contentDescription = if (isCurrentAccount) {
if (showAvatarIndicator) {
stringResource(CommonStrings.a11y_settings_with_required_action)
@@ -339,11 +376,69 @@ private fun AccountIcon(
null
},
)
if (statusEmoji != null) {
StatusEmojiBadge(
emoji = statusEmoji,
modifier = Modifier.align(Alignment.BottomEnd),
)
}
if (showAvatarIndicator) {
RedIndicatorAtom(
modifier = Modifier.align(Alignment.TopEnd)
)
}
}
}
private val statusEmojiBadgeSize = 20.dp
private val statusEmojiBadgeRadius = statusEmojiBadgeSize / 2
private val statusEmojiBadgeOffset = 8.dp
private fun Modifier.eraseStatusEmojiBackground(
parentSize: Dp,
layoutDirection: LayoutDirection,
): Modifier = this
.graphicsLayer {
compositingStrategy = CompositingStrategy.Offscreen
}
.drawWithContent {
drawContent()
drawCircle(
color = Color.Black,
center = Offset(
x = if (layoutDirection == LayoutDirection.Ltr) {
(parentSize - statusEmojiBadgeRadius + statusEmojiBadgeOffset).toPx()
} else {
(statusEmojiBadgeRadius - statusEmojiBadgeOffset).toPx()
},
y = size.height - statusEmojiBadgeRadius.toPx(),
),
radius = statusEmojiBadgeRadius.toPx(),
blendMode = BlendMode.Clear,
)
}
@Composable
private fun StatusEmojiBadge(
emoji: String,
modifier: Modifier = Modifier,
) {
val density = LocalDensity.current
// Ensure the emoji size is not affected by the user's font scale settings
val fixedFontScaleDensity = remember(density.density) {
Density(density = density.density, fontScale = 1f)
}
Box(
modifier = modifier
.offset(x = statusEmojiBadgeOffset)
.size(statusEmojiBadgeSize),
contentAlignment = Alignment.Center,
) {
CompositionLocalProvider(LocalDensity provides fixedFontScaleDensity) {
Text(
text = emoji,
style = ElementTheme.typography.fontBodyMdRegular,
)
}
}
}
@@ -432,6 +527,33 @@ internal fun HomeTopBarWithIndicatorPreview() = ElementPreview {
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
internal fun HomeTopBarWithStatusPreview() = ElementPreview {
HomeTopBar(
selectedNavigationItem = HomeNavigationBarItem.Chats,
currentUserAndNeighbors = persistentListOf(
aMatrixUser(
id = "@id:domain",
displayName = USER_NAME_ALICE,
displayedStatus = DisplayedStatus.UserSet(UserStatus(emoji = "🌴", text = "Away")),
)
),
showAvatarIndicator = false,
areSearchResultsDisplayed = false,
scrollBehavior = TopAppBarDefaults.pinnedScrollBehavior(rememberTopAppBarState()),
onOpenSettings = {},
onAccountSwitch = {},
onToggleSearch = {},
canReportBug = true,
displayFilters = true,
filtersState = aRoomListFiltersState(),
spaceFiltersState = anUnselectedSpaceFiltersState(),
onMenuActionClick = {},
)
}
@OptIn(ExperimentalMaterial3Api::class)
@PreviewsDayNight
@Composable
@@ -22,6 +22,7 @@ class DefaultFilterSelectionStrategy : FilterSelectionStrategy {
override val filterSelectionStates = MutableStateFlow(buildFilters())
override fun select(filter: RoomListFilter) {
if (selectedFilters.any { it in filter.incompatibleFilters }) return
selectedFilters.add(filter)
filterSelectionStates.value = buildFilters()
}
@@ -94,7 +94,7 @@ private fun RoomListModalBottomSheetContent(
.verticalScroll(rememberScrollState())
) {
ListItem(
headlineContent = {
content = {
Text(
text = contextMenu.roomName ?: stringResource(id = CommonStrings.common_no_room_name),
style = ElementTheme.typography.fontBodyLgMedium,
@@ -104,7 +104,7 @@ private fun RoomListModalBottomSheetContent(
)
if (contextMenu.hasNewContent) {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = CommonStrings.action_mark_as_read),
style = MaterialTheme.typography.bodyLarge,
@@ -117,7 +117,7 @@ private fun RoomListModalBottomSheetContent(
)
} else {
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = R.string.screen_roomlist_mark_as_unread),
style = MaterialTheme.typography.bodyLarge,
@@ -135,7 +135,7 @@ private fun RoomListModalBottomSheetContent(
CommonStrings.common_favourite to CompoundIcons.Favourite()
}
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = textResId),
style = MaterialTheme.typography.bodyLarge,
@@ -154,7 +154,7 @@ private fun RoomListModalBottomSheetContent(
},
)
ListItem(
headlineContent = {
content = {
Text(
text = stringResource(id = CommonStrings.common_settings),
style = MaterialTheme.typography.bodyLarge,
@@ -169,7 +169,7 @@ private fun RoomListModalBottomSheetContent(
)
if (canReportRoom) {
ListItem(
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_report_room))
},
modifier = Modifier.clickable { onReportRoomClick() },
@@ -182,7 +182,7 @@ private fun RoomListModalBottomSheetContent(
)
}
ListItem(
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_leave_room))
},
modifier = Modifier.clickable { onLeaveRoomClick() },
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Powiadomienia nie dochodzą?"</string>
<string name="banner_new_sound_message">"Sygnał powiadomień został zaktualizowany — jest wyraźniejszy, szybszy i mniej uciążliwy."</string>
<string name="banner_new_sound_title">"Odświeżyliśmy Twoje dźwięki"</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić tę kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_content">"Twoje czaty są automatycznie archiwizowane za pomocą szyfrowania end-to-end. Aby przywrócić kopię zapasową i swoją tożsamość cyfrową, wymagany będzie klucz przywracania."</string>
<string name="banner_set_up_recovery_submit">"Uzyskaj klucz przywracania"</string>
<string name="banner_set_up_recovery_title">"Utwórz kopię zapasową swoich czatów"</string>
<string name="confirm_recovery_key_banner_message">"Potwierdź klucz przywracania, aby zachować dostęp do magazynu kluczy i historii wiadomości."</string>
@@ -5,7 +5,7 @@
<string name="banner_battery_optimization_title_android">"Nu primiți notificări?"</string>
<string name="banner_new_sound_message">"Sunetul pentru notificări a fost actualizat — mai clar, mai rapid și mai puțin perturbatoar."</string>
<string name="banner_new_sound_title">"Am reîmprospătat sunetele"</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura această copie de rezervă și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_content">"Chaturile dumneavoastră sunt salvate automat cu criptare end-to-end. Pentru a restaura aceast backup și a vă păstra identitatea digitală atunci când pierzdeți accesul la toate dispozitivele dumneavoastră, veți avea nevoie de cheia de recuperare."</string>
<string name="banner_set_up_recovery_submit">"Obțineți cheia de recuperare"</string>
<string name="banner_set_up_recovery_title">"Faceți un backup al mesajelor"</string>
<string name="confirm_recovery_key_banner_message">"Backup-ul pentru chat nu este sincronizat. Trebuie să confirmați cheia de recuperare pentru a menține accesul la backup."</string>
@@ -72,6 +72,49 @@ class RoomListFiltersPresenterTest {
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible filters in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Favourites))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Invites))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.Favourites)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.Favourites, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.People, false),
filterSelectionState(RoomListFilter.Rooms, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - toggling two incompatible categories in the same frame keeps the first one`() = runTest {
val presenter = createRoomListFiltersPresenter()
presenter.test {
awaitItem().let { state ->
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.People))
state.eventSink.invoke(RoomListFiltersEvent.ToggleFilter(RoomListFilter.Rooms))
}
advanceUntilIdle()
awaitLastSequentialItem().let { state ->
assertThat(state.selectedFilters()).containsExactly(RoomListFilter.People)
assertThat(state.filterSelectionStates).containsExactly(
filterSelectionState(RoomListFilter.People, true),
filterSelectionState(RoomListFilter.Unread, false),
filterSelectionState(RoomListFilter.Favourites, false),
).inOrder()
}
}
}
@Test
@OptIn(ExperimentalCoroutinesApi::class)
fun `present - clear filters event`() = runTest {
@@ -11,6 +11,9 @@ package io.element.android.features.invite.api
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.Flow
/**
* Remembers which invitations the user has already looked at, so that only genuinely new ones are highlighted in the room list.
*/
interface SeenInvitesStore {
/**
* Returns a flow of seen room IDs of invitation.
@@ -32,7 +35,7 @@ interface SeenInvitesStore {
suspend fun markAsUnSeen(roomId: RoomId)
/**
* Delete the store.
* Delete the store, so every invitation counts as unseen again.
*/
suspend fun clear()
}
@@ -10,6 +10,9 @@ package io.element.android.features.invite.api.acceptdecline
import io.element.android.features.invite.api.InviteData
/**
* Events sent to the accept/decline invite presenter, from whichever screen is showing the invitation.
*/
interface AcceptDeclineInviteEvents {
data class AcceptInvite(val invite: InviteData) : AcceptDeclineInviteEvents
data class DeclineInvite(val invite: InviteData, val blockUser: Boolean, val shouldConfirm: Boolean) : AcceptDeclineInviteEvents
@@ -12,7 +12,20 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
/**
* Renders the confirmation dialogs and error states of accepting or declining an invitation; it draws nothing until such an event is sent.
*
* Embedded by every screen that can act on an invitation, so the behaviour stays identical between the invite list and a room preview.
*/
fun interface AcceptDeclineInviteView {
/**
* Draws whichever dialog the current state calls for.
*
* @param state the state produced by the accept/decline presenter.
* @param onAcceptInviteSuccess called once the room has been joined, so the host screen can navigate into it.
* @param onDeclineInviteSuccess called once the invitation has been declined.
* @param modifier layout modifier for the container.
*/
@Composable
fun Render(
state: AcceptDeclineInviteState,
@@ -81,7 +81,7 @@ fun DeclineAndBlockView(
) {
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
content = {
Text(text = stringResource(R.string.screen_decline_and_block_block_user_option_title))
},
supportingContent = {
@@ -96,7 +96,7 @@ fun DeclineAndBlockView(
Spacer(modifier = Modifier.height(24.dp))
ListItem(
modifier = Modifier.padding(end = 8.dp),
headlineContent = {
content = {
Text(text = stringResource(CommonStrings.action_report_room))
},
supportingContent = {
@@ -8,6 +8,9 @@
package io.element.android.features.invitepeople.api
/**
* Events the invite people UI sends to its presenter through [InvitePeopleState.eventSink].
*/
interface InvitePeopleEvents {
data object SendInvites : InvitePeopleEvents
data object CloseSearch : InvitePeopleEvents
@@ -13,7 +13,14 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface InvitePeoplePresenter : Presenter<InvitePeopleState> {
/**
* Creates a presenter for one invite flow.
*/
interface Factory {
/**
* @param joinedRoom the room to invite into, or `null` when the room does not exist yet, as when inviting from a direct message.
* @param roomId the id of the room to invite into.
*/
fun create(
joinedRoom: JoinedRoom?,
roomId: RoomId,
@@ -11,7 +11,16 @@ package io.element.android.features.invitepeople.api
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Renders the invite people UI, so that other screens can embed it without depending on its implementation.
*/
interface InvitePeopleRenderer {
/**
* Draws the invite list and its search field.
*
* @param state the state produced by [InvitePeoplePresenter].
* @param modifier layout modifier for the container.
*/
@Composable
fun Render(
state: InvitePeopleState,
@@ -11,10 +11,22 @@ package io.element.android.features.invitepeople.api
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.RoomId
/**
* State of the invite people UI, produced by an [InvitePeoplePresenter] and consumed by an [InvitePeopleRenderer].
*/
interface InvitePeopleState {
/** Whether the send button should be enabled, i.e. at least one user is selected and the user has the permission. */
val canInvite: Boolean
/** Whether the search field currently has focus, which the host screen uses to adapt its own layout. */
val isSearchActive: Boolean
/** Progress of the invite request, so the UI can show a loader and surface failures. */
val sendInvitesAction: AsyncAction<Unit>
/** Progress of turning a direct message into a room, which happens when inviting a third person into a DM. */
val createRoomFromDmAction: AsyncAction<RoomId>
/** Where the UI sends its events. */
val eventSink: (InvitePeopleEvents) -> Unit
}
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"Bereits Mitglied"</string>
<string name="screen_invite_users_already_invited">"Bereits eingeladen"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"Du hast derzeit keine Chats mit diesen Kontakten. Bestätige, dass du sie in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"Du hast derzeit keine Chats mit diesem Kontakt. Bestätige, dass du ihn in diesen Chat einladen möchtest, bevor du fortfährst."</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"Möchtest du neue Kontakte in diesen Chat einladen?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"Willst du einen neuen Kontakt in diesen Chat einladen?"</string>
</resources>
@@ -2,4 +2,8 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_invite_users_already_a_member">"已是成員"</string>
<string name="screen_invite_users_already_invited">"已邀請"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_multiple_users">"您目前與這些聯絡人沒有任何聊天紀錄。請確認是否要邀請他們加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_subtitle_one_user">"您目前與此聯絡人沒有任何聊天紀錄。請確認是否要邀請對方加入此聊天室,然後再繼續。"</string>
<string name="screen_invite_users_confirm_dialog_title_mutiple_users">"要邀請新聯絡人加入此聊天室嗎?"</string>
<string name="screen_invite_users_confirm_dialog_title_one_user">"要邀請新聯絡人加入此聊天室嗎?"</string>
</resources>
@@ -11,7 +11,16 @@ package io.element.android.features.knockrequests.api.banner
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
/**
* Renders the banner shown at the top of a room when people are waiting to join it.
*/
interface KnockRequestsBannerRenderer {
/**
* Draws the banner, or nothing when there is no pending request or the user cannot moderate the room.
*
* @param modifier layout modifier for the container.
* @param onViewRequestsClick called when the user asks to see the full list of requests.
*/
@Composable
fun View(modifier: Modifier, onViewRequestsClick: () -> Unit)
}
@@ -10,6 +10,9 @@ package io.element.android.features.leaveroom.api
import io.element.android.libraries.matrix.api.core.RoomId
/**
* Events sent to the leave room presenter through [LeaveRoomState.eventSink].
*/
interface LeaveRoomEvent {
data class LeaveRoom(val roomId: RoomId, val needsConfirmation: Boolean) : LeaveRoomEvent
}
@@ -12,7 +12,17 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import io.element.android.libraries.matrix.api.core.RoomId
/**
* Renders the confirmation dialogs of the leave room flow; it draws nothing until the host screen sends a leave event.
*/
fun interface LeaveRoomRenderer {
/**
* Draws whichever dialog the current state calls for.
*
* @param state the state produced by the leave room presenter.
* @param onSelectNewOwners called when the user is the last owner of a room that still has other members, and must promote someone before leaving.
* @param modifier layout modifier for the container.
*/
@Composable
fun Render(
state: LeaveRoomState,
@@ -10,7 +10,11 @@ package io.element.android.features.leaveroom.api
import androidx.compose.runtime.Immutable
/**
* State of the leave room flow, which is only the confirmation dialogs: the screen it is hosted in owns the rest of the UI.
*/
@Immutable
interface LeaveRoomState {
/** Where the host screen sends its events to start or confirm leaving a room. */
val eventSink: (LeaveRoomEvent) -> Unit
}
@@ -62,7 +62,7 @@ fun DependenciesDetailsView(
}
}
ListItem(
headlineContent = {
content = {
ClickableLinkText(
text = text,
interactionSource = remember { MutableInteractionSource() },
@@ -100,7 +100,7 @@ fun DependencyLicensesListView(
}
is AsyncData.Success -> items(state.licenses.data) { license ->
ListItem(
headlineContent = { Text(license.safeName) },
content = { Text(license.safeName) },
supportingContent = {
Text(
buildString {
@@ -12,10 +12,12 @@ import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.linknewdevice.ErrorType
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.asStateFlow
@@ -23,6 +25,7 @@ import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.minutes
private val loggerTag = LoggerTag("LinkNewDesktopHandler", LoggerTags.linkNewDevice)
@@ -40,25 +43,34 @@ class LinkNewDesktopHandler(
get() = linkDesktopStepFlow.asStateFlow()
private var currentJob: Job? = null
private var timerJob: Job? = null
private var handler: LinkDesktopHandler? = null
fun createNewHandler() {
private fun resetJobs() {
currentJob?.cancel()
currentJob = null
timerJob?.cancel()
timerJob = null
}
fun createNewHandler() {
resetJobs()
handler = matrixClient.createLinkDesktopHandler().getOrNull()
timerJob = sessionScope.launch {
delay(2.minutes)
linkDesktopStepFlow.emit(LinkDesktopStep.Error(ErrorType.Expired("Scanning QrCode took too long.")))
}
}
fun reset() {
currentJob?.cancel()
currentJob = null
resetJobs()
sessionScope.launch {
linkDesktopStepFlow.emit(LinkDesktopStep.Uninitialized)
}
}
fun onScannedCode(data: ByteArray) {
currentJob?.cancel()
currentJob = null
resetJobs()
val currentHandler = handler
if (currentHandler == null) {
Timber.tag(loggerTag.value).e("onScannedCode: Handler is not initialized. Call createNewHandler() first.")
@@ -56,6 +56,7 @@ import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.parcelize.Parcelize
import timber.log.Timber
@@ -102,6 +103,7 @@ class LinkNewDeviceFlowNode(
@Suppress("AssignedValueIsNeverRead")
linkDesktopHandlerJob = observeLinkNewDesktopHandler()
},
onResume = ::onResume,
onDestroy = {
linkMobileHandlerJob?.cancel()
linkDesktopHandlerJob?.cancel()
@@ -209,6 +211,18 @@ class LinkNewDeviceFlowNode(
.launchIn(sessionCoroutineScope)
}
private fun onResume() = sessionCoroutineScope.launch {
// Application is resumed, if the step is waiting for auth, send the confirmation
(linkNewMobileHandler.stepFlow.value as? LinkMobileStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on mobile, sending confirmation")
it.continuationMessageSender.confirm()
}
(linkNewDesktopHandler.stepFlow.value as? LinkDesktopStep.WaitingForAuth)?.let {
Timber.tag(tag.value).d("Resuming while waiting for auth on desktop, sending confirmation")
it.continuationMessageSender.confirm()
}
}
private fun navigateToError(errorType: ErrorType) {
// Map the error to an error screen
val error = when (errorType) {
@@ -20,6 +20,13 @@ import io.element.android.libraries.architecture.callback
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.SessionScope
/**
* Max number of QrCode rotation.
* When QrCode is rotated this number of time, a TimeOut error is emitted.
* Set to 0 to disable QrCode rotation.
*/
private const val MAX_QR_CODE_ROTATION = 0
@ContributesNode(SessionScope::class)
@AssistedInject
class ShowQrCodeNode(
@@ -39,6 +46,7 @@ class ShowQrCodeNode(
private val callback: Callback = callback()
private val showQrCodePresenter: ShowQrCodePresenter = showQrCodePresenterFactory.create(
initialData = inputs.data,
maxQrCodeRotation = MAX_QR_CODE_ROTATION,
)
@Composable
@@ -26,24 +26,29 @@ import kotlinx.coroutines.Job
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
private val tag = LoggerTag("ShowQrCodePresenter", LoggerTags.linkNewDevice)
@AssistedInject
class ShowQrCodePresenter(
@Assisted private val initialData: String,
@Assisted private val maxQrCodeRotation: Int,
private val linkNewMobileHandler: LinkNewMobileHandler,
) : Presenter<ShowQrCodeState> {
@AssistedFactory
interface Factory {
fun create(initialData: String): ShowQrCodePresenter
fun create(
initialData: String,
maxQrCodeRotation: Int,
): ShowQrCodePresenter
}
private var loadingJob: Job? = null
@Composable
override fun present(): ShowQrCodeState {
var qrCodeRotationCounter by remember { mutableIntStateOf(MAX_QR_CODE_ROTATION) }
var qrCodeRotationCounter by remember { mutableIntStateOf(maxQrCodeRotation) }
val state by produceState(
initialValue = ShowQrCodeState(
data = AsyncData.Success(initialData),
@@ -63,7 +68,7 @@ class ShowQrCodePresenter(
linkNewMobileHandler.rotateQrCode()
// Ensure that outdated data is not rendered too long while rotating QR code
loadingJob = launch {
delay(1000)
delay(1.seconds)
value = ShowQrCodeState(
data = AsyncData.Loading(),
)
@@ -80,8 +85,4 @@ class ShowQrCodePresenter(
return state
}
companion object {
const val MAX_QR_CODE_ROTATION = 10
}
}
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skaneeri QR-koodi selle seadmega"</string>
<string name="screen_link_new_device_desktop_submit">"Skaneerimiseks valmis"</string>
<string name="screen_link_new_device_desktop_title">"QR-koodi laadimiseks ava %1$s süle- või lauaarvutis"</string>
<string name="screen_link_new_device_desktop_warning">"Skaneeri QR-koodi peale järgnevate sammude läbitegemist oma seadmes. Ära iialgi skaneeri QR-koodi, mille on saatnud keegi teine."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numbrid ei klapi"</string>
<string name="screen_link_new_device_enter_number_notice">"Sisesta kahekohaline kood"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Sellega verifitseerime, et ühendus sinu teise seadmega on turvaline."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Skenirajte QR kod ovim uređajem"</string>
<string name="screen_link_new_device_desktop_submit">"Spremno za skeniranje"</string>
<string name="screen_link_new_device_desktop_title">"Otvorite %1$s na stolnom računalu kako biste dobili QR kod"</string>
<string name="screen_link_new_device_desktop_warning">"QR kod skenirajte tek nakon što ste na vlastitom uređaju slijedili gore navedene korake. Nikada ne skenirajte kod koji vam je poslala druga osoba."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Brojevi se ne podudaraju"</string>
<string name="screen_link_new_device_enter_number_notice">"Unesite dvoznamenkasti kod"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Time ćete potvrditi da je veza s vašim drugim uređajem sigurna."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Olvassa be a QR-kódot ezzel az eszközzel"</string>
<string name="screen_link_new_device_desktop_submit">"Készen áll a beolvasásra"</string>
<string name="screen_link_new_device_desktop_title">"Nyissa meg az asztali %1$s alkalmazást, hogy megkapja a QR-kódot"</string>
<string name="screen_link_new_device_desktop_warning">"Csak a fenti lépések végrehajtása után olvass be QR-kódot a saját eszközén. Soha ne olvasson be más által küldött kódot."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"A számok nem egyeznek"</string>
<string name="screen_link_new_device_enter_number_notice">"Írja be a kétjegyű kódot"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Ezzel ellenőrizni fogja, hogy a másik eszközzel való kapcsolat biztonságos-e."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"Scanați codul QR cu acest dispozitiv"</string>
<string name="screen_link_new_device_desktop_submit">"Gata de scanare"</string>
<string name="screen_link_new_device_desktop_title">"Deschide %1$s pe un computer desktop pentru a obține codul QR"</string>
<string name="screen_link_new_device_desktop_warning">"Scanați un cod QR doar după ce urmați pașii de mai sus pe propriul dispozitiv. Nu scanați niciodată un cod trimis de o altă persoană."</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"Numerele nu se potrivesc"</string>
<string name="screen_link_new_device_enter_number_notice">"Introduceți codul de 2 cifre"</string>
<string name="screen_link_new_device_enter_number_subtitle">"Aceasta va verifica dacă conexiunea cu celălalt dispozitiv este sigură."</string>
@@ -5,6 +5,7 @@
<string name="screen_link_new_device_desktop_step3">"使用此裝置掃描 QR code"</string>
<string name="screen_link_new_device_desktop_submit">"準備掃描"</string>
<string name="screen_link_new_device_desktop_title">"在桌上型電腦上開啟 %1$s 以取得 QR code"</string>
<string name="screen_link_new_device_desktop_warning">"請務必先在您的裝置上完成上述步驟,之後才掃描 QR code。切勿掃描他人傳送的 QR code。"</string>
<string name="screen_link_new_device_enter_number_error_numbers_do_not_match">"數字不符"</string>
<string name="screen_link_new_device_enter_number_notice">"輸入兩位數代碼"</string>
<string name="screen_link_new_device_enter_number_subtitle">"這將確認您與另一台裝置之間的連線是否安全。"</string>
@@ -91,10 +91,36 @@ class ShowQrCodePresenterTest {
}
}
@Test
fun `present - when handler emits QrRotating, the presenter does not emit anything when rotation is disabled`() = runTest {
val linkMobileHandler = FakeLinkMobileHandler(
startResult = {},
)
val matrixClient = FakeMatrixClient(
sessionCoroutineScope = backgroundScope,
createLinkMobileHandlerResult = { Result.success(linkMobileHandler) },
)
val linkNewMobileHandler = LinkNewMobileHandler(matrixClient)
linkNewMobileHandler.createAndStartNewHandler()
createPresenter(
linkNewMobileHandler = linkNewMobileHandler,
maxQrCodeRotation = 0,
).test {
awaitItem()
linkMobileHandler.emitStep(
LinkMobileStep.QrRotating
)
runCurrent()
expectNoEvents()
}
}
private fun createPresenter(
linkNewMobileHandler: LinkNewMobileHandler = LinkNewMobileHandler(FakeMatrixClient()),
maxQrCodeRotation: Int = 10,
) = ShowQrCodePresenter(
initialData = "DATA",
maxQrCodeRotation = maxQrCodeRotation,
linkNewMobileHandler = linkNewMobileHandler,
)
}
+1
View File
@@ -70,6 +70,7 @@ dependencies {
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.wellknown.api)
implementation(libs.coil.compose)
implementation(libs.datetime)
@@ -0,0 +1,23 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api
import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.libraries.wellknown.api.MapTilerConfig
/**
* Provides a [MapTilerConfig] for the MapLibre maps.
*/
val LocalMapTilerConfig = staticCompositionLocalOf {
MapTilerConfig(
apiKey = BuildConfig.MAPTILER_API_KEY,
lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
baseUrl = BuildConfig.MAPTILER_BASE_URL
)
}
@@ -8,6 +8,10 @@
package io.element.android.features.location.api
/**
* Tells whether the location features can be used at all, which depends on the build having a map provider configured.
*/
interface LocationService {
/** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */
fun isServiceAvailable(): Boolean
}
@@ -38,13 +38,14 @@ import coil3.request.ImageRequest
import coil3.request.SuccessResult
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.StaticMapPlaceholder
import io.element.android.features.location.api.internal.StaticMapUrlBuilder
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberStaticMapBuilder
import io.element.android.libraries.designsystem.components.LocationPin
import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.wellknown.api.MapTilerConfig
/**
* Shows a static map image downloaded via a third party service's static maps API.
@@ -57,6 +58,7 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
*/
@Composable
fun StaticMapView(
mapTilerConfig: MapTilerConfig?,
location: Location?,
zoom: Double,
pinVariant: PinVariant,
@@ -94,6 +96,7 @@ fun StaticMapView(
}
// Cases 3 & 4: Non-null location - fetch map
else -> LoadableMapContent(
mapTilerConfig = mapTilerConfig,
location = location,
zoom = zoom,
pinVariant = pinVariant,
@@ -124,6 +127,7 @@ private fun BoxWithConstraintsScope.StaleMapContent(
@Composable
private fun BoxWithConstraintsScope.LoadableMapContent(
mapTilerConfig: MapTilerConfig?,
location: Location,
zoom: Double,
pinVariant: PinVariant,
@@ -132,7 +136,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
) {
val context = LocalContext.current
var retryHash by remember { mutableIntStateOf(0) }
val builder = remember { StaticMapUrlBuilder() }
val builder = rememberStaticMapBuilder(mapTilerConfig)
val (painter, state, contentScale) = if (LocalInspectionMode.current) {
val painter = painterResource(CommonDrawables.sample_map)
@@ -203,6 +207,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
@Composable
internal fun StaticMapViewPreview() = ElementPreview {
StaticMapView(
mapTilerConfig = null,
location = Location(0.0, 0.0),
zoom = 0.0,
contentDescription = null,
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal
import io.element.android.features.location.api.BuildConfig
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlin.math.roundToInt
/**
@@ -29,6 +30,13 @@ internal class MapTilerStaticMapUrlBuilder(
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
constructor(mapTilerConfig: MapTilerConfig) : this(
baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"),
apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY,
lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID,
)
override fun build(
lat: Double,
lon: Double,
@@ -11,6 +11,7 @@
package io.element.android.features.location.api.internal
import io.element.android.features.location.api.BuildConfig
import io.element.android.libraries.wellknown.api.MapTilerConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
@@ -26,17 +27,17 @@ internal class MapTilerTileServerStyleUriBuilder(
)
override fun build(
customMapStyleUrl: String?,
customMapTilerConfig: MapTilerConfig?,
darkMode: Boolean,
): String {
return buildString {
if (customMapStyleUrl.isNullOrBlank()) {
val mapId = if (darkMode) darkMapId else lightMapId
append("$baseUrl/$mapId/style.json")
} else {
append(customMapStyleUrl)
}
append("?key=$apiKey")
val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl
val apiKey = customMapTilerConfig?.apiKey ?: apiKey
val mapId = if (darkMode) {
customMapTilerConfig?.darkStyleId ?: darkMapId
} else {
customMapTilerConfig?.lightStyleId ?: lightMapId
}
return "$baseUrl/$mapId/style.json?key=$apiKey"
}
}
@@ -8,10 +8,25 @@
package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.libraries.wellknown.api.MapTilerConfig
/**
* Builds an URL for a 3rd party service provider static maps API.
*/
interface StaticMapUrlBuilder {
/**
* Builds the URL of a map image centred on the given coordinates.
*
* @param lat latitude of the centre of the map.
* @param lon longitude of the centre of the map.
* @param zoom zoom level to render at.
* @param darkMode whether to request the dark map style.
* @param width width of the image in density independent pixels.
* @param height height of the image in density independent pixels.
* @param density screen density, used to request a matching pixel size.
*/
fun build(
lat: Double,
lon: Double,
@@ -22,7 +37,17 @@ interface StaticMapUrlBuilder {
density: Float,
): String
/** Whether a map provider is configured for this build; the URLs are unusable when it is not. */
fun isServiceAvailable(): Boolean
}
fun StaticMapUrlBuilder(): StaticMapUrlBuilder = MapTilerStaticMapUrlBuilder()
@Composable
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder {
return remember(mapTilerConfig) {
if (mapTilerConfig != null) {
MapTilerStaticMapUrlBuilder(mapTilerConfig)
} else {
MapTilerStaticMapUrlBuilder()
}
}
}
@@ -11,6 +11,7 @@ package io.element.android.features.location.api.internal
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.wellknown.api.MapTilerConfig
/**
* Builds a style URI for a MapLibre compatible tile server.
@@ -18,8 +19,14 @@ import io.element.android.compound.theme.ElementTheme
* Used for rendering dynamic maps.
*/
interface TileServerStyleUriBuilder {
/**
* Builds the style URI the map view loads its tiles from.
*
* @param customMapStyleUrl the style advertised by the homeserver, or `null` to use the one built into the app.
* @param darkMode whether to request the dark map style.
*/
fun build(
customMapStyleUrl: String?,
customMapTilerConfig: MapTilerConfig?,
darkMode: Boolean,
): String
}
@@ -31,12 +38,12 @@ interface TileServerStyleUriBuilder {
*/
@Composable
fun rememberTileStyleUrl(
customMapStyleUrl: String?,
customMapTileConfig: MapTilerConfig?,
): String {
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode, customMapStyleUrl) {
return remember(darkMode, customMapTileConfig) {
MapTilerTileServerStyleUriBuilder().build(
customMapStyleUrl = customMapStyleUrl,
customMapTilerConfig = customMapTileConfig,
darkMode = darkMode,
)
}
@@ -12,6 +12,9 @@ import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.coroutines.flow.StateFlow
import kotlin.time.Duration
/**
* Owns the live location shares running on this device, across every room, and the foreground service that feeds them GPS updates.
*/
interface ActiveLiveLocationShareManager {
/** All rooms currently sharing live location on this device. */
val sharingRoomIds: StateFlow<Set<RoomId>>
@@ -27,6 +30,9 @@ interface ActiveLiveLocationShareManager {
* Starts live location sharing in the given room.
* Calls room.startLiveLocationShare() on the SDK, registers the share,
* and starts the foreground GPS service if not already running.
*
* @param roomId the room to start sharing the location in.
* @param duration how long the share should last before it expires on its own.
*/
suspend fun startShare(roomId: RoomId, duration: Duration): Result<Unit>
@@ -34,6 +40,8 @@ interface ActiveLiveLocationShareManager {
* Stops live location sharing in the given room.
* Calls room.stopLiveLocationShare() on the SDK, removes the share,
* and stops the foreground service if no shares remain.
*
* @param roomId the room to stop sharing the location in.
*/
suspend fun stopShare(roomId: RoomId): Result<Unit>
}
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.wellknown.api.MapTilerConfig
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
@@ -23,7 +24,7 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `light map uri`() {
assertThat(
builder.build(
customMapStyleUrl = null,
customMapTilerConfig = null,
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
@@ -33,7 +34,7 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `dark map uri`() {
assertThat(
builder.build(
customMapStyleUrl = null,
customMapTilerConfig = null,
darkMode = true,
)
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
@@ -43,19 +44,44 @@ class MapTilerTileServerStyleUriBuilderTest {
fun `custom map uri light`() {
assertThat(
builder.build(
customMapStyleUrl = "https://custom.url/style.json",
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
darkMode = false,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY")
}
@Test
fun `custom map uri dark`() {
assertThat(
builder.build(
customMapStyleUrl = "https://custom.url/style.json",
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
darkMode = true,
)
).isEqualTo("https://custom.url/style.json?key=anApiKey")
).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = " "
),
darkMode = false,
)
).isEqualTo("https://base.url/light/style.json?key=API_KEY")
}
}
+4
View File
@@ -26,6 +26,7 @@ setupDependencyInjection()
dependencies {
api(projects.features.location.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.messages.api)
implementation(libs.maplibre.compose)
implementation(libs.coil)
@@ -45,6 +46,7 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.push.api)
implementation(projects.libraries.sessionStorage.api)
implementation(projects.libraries.wellknown.api)
implementation(libs.androidx.datastore.preferences)
implementation(libs.datetime)
@@ -58,5 +60,7 @@ dependencies {
testImplementation(projects.libraries.featureflag.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.sessionStorage.test)
testImplementation(projects.libraries.wellknown.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.enterprise.test)
}
@@ -50,6 +50,7 @@ import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import io.element.android.libraries.wellknown.api.MapTilerConfig
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
@@ -66,7 +67,7 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning
*
* @param customMapStyleUrl Optional custom style URL for the map
* @param customMapTilerConfig Optional custom style URL for the map
* @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map
@@ -83,7 +84,7 @@ import kotlin.math.roundToInt
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapBottomSheetScaffold(
customMapStyleUrl: AsyncData<String?>,
customMapTilerConfig: AsyncData<MapTilerConfig?>,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(
@@ -137,11 +138,11 @@ fun MapBottomSheetScaffold(
val ornamentOptions = mapOptions.ornamentOptions.copy(padding = sheetPadding)
val mapOptions = mapOptions.copy(ornamentOptions = ornamentOptions)
Box {
when (customMapStyleUrl) {
when (customMapTilerConfig) {
is AsyncData.Success -> {
MaplibreMap(
options = mapOptions,
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapStyleUrl.data)),
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
@@ -22,6 +22,7 @@ import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.impl.common.LocationConstraintsCheck
import io.element.android.features.location.impl.common.MapDefaults
@@ -72,6 +73,7 @@ class ShareLocationPresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
@@ -92,9 +94,9 @@ class ShareLocationPresenter(
var pendingLiveLocationShare by remember { mutableStateOf(false) }
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
val customMapStyleUrl by produceState(AsyncData.Loading()) {
val customMapConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
value = AsyncData.Success(remoteEnterpriseConfigProvider.get(client.sessionId).dataOrNull()?.mapTilerConfig)
}
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
@@ -199,7 +201,7 @@ class ShareLocationPresenter(
}
return ShareLocationState(
customMapStyleUrl = customMapStyleUrl,
customMapTilerConfig = customMapConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -13,10 +13,11 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
val customMapStyleUrl: AsyncData<String?>,
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
@@ -15,6 +15,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.persistentListOf
import kotlin.time.Duration.Companion.hours
import kotlin.time.Duration.Companion.minutes
@@ -70,13 +71,13 @@ class ShareLocationStateProvider : PreviewParameterProvider<ShareLocationState>
canShareLiveLocation = true,
),
aShareLocationState(
customMapStyleUrl = AsyncData.Loading(),
customMapTilerConfig = AsyncData.Loading(),
),
)
}
fun aShareLocationState(
customMapStyleUrl: AsyncData<String?> = AsyncData.Success(null),
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
@@ -87,7 +88,7 @@ fun aShareLocationState(
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
customMapStyleUrl = customMapStyleUrl,
customMapTilerConfig = customMapTilerConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -95,6 +96,6 @@ fun aShareLocationState(
canShareLiveLocation = canShareLiveLocation,
appName = appName,
startLiveLocationAction = startLiveLocationAction,
eventSink = eventSink
eventSink = eventSink,
)
}
@@ -118,7 +118,7 @@ fun ShareLocationView(
}
MapBottomSheetScaffold(
customMapStyleUrl = state.customMapStyleUrl,
customMapTilerConfig = state.customMapTilerConfig,
cameraState = cameraState,
modifier = modifier,
scaffoldState = scaffoldState,
@@ -261,7 +261,7 @@ private fun ShareCurrentLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.screen_share_my_location_action))
},
onClick = onClick,
@@ -276,7 +276,7 @@ private fun SharePinLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.screen_share_this_location_action))
},
onClick = onClick,
@@ -291,7 +291,7 @@ private fun ShareLiveLocationItem(
onClick: () -> Unit,
) {
ListItem(
headlineContent = {
content = {
Text(stringResource(CommonStrings.action_share_live_location))
},
onClick = onClick,
@@ -22,6 +22,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
@@ -46,7 +47,7 @@ import io.element.android.libraries.dateformatter.api.DateFormatter
import io.element.android.libraries.dateformatter.api.DateFormatterMode
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.getBestName
import io.element.android.libraries.matrix.api.room.joinedRoomMembers
@@ -64,10 +65,11 @@ import kotlinx.coroutines.launch
private val buildMeta: BuildMeta,
private val dateFormatter: DateFormatter,
private val stringProvider: StringProvider,
private val client: MatrixClient,
private val sessionId: SessionId,
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
private val remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider,
) : Presenter<ShowLocationState> {
@AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
@@ -85,9 +87,9 @@ import kotlinx.coroutines.launch
mutableStateOf(LocationConstraintsDialogState.None)
}
val customMapStyleUrl by produceState(AsyncData.Loading()) {
val customMapTilerConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(client.getMapStyleUrl().getOrNull())
value = AsyncData.Success(remoteEnterpriseConfigProvider.get(sessionId).dataOrNull()?.mapTilerConfig)
}
fun checkLocationConstraints() {
@@ -220,7 +222,7 @@ import kotlinx.coroutines.launch
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
}
return ShowLocationState(
customMapStyleUrl = customMapStyleUrl,
customMapTilerConfig = customMapTilerConfig,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
@@ -17,10 +17,11 @@ import io.element.android.libraries.designsystem.components.PinVariant
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
val customMapStyleUrl: AsyncData<String?>,
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>,
@@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.wellknown.api.MapTilerConfig
import kotlinx.collections.immutable.toImmutableList
class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
@@ -36,14 +37,14 @@ class ShowLocationStateProvider : PreviewParameterProvider<ShowLocationState> {
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
),
aShowLocationState(isTrackMyLocation = true),
aShowLocationState(customMapStyleUrl = AsyncData.Loading()),
aShowLocationState(customMapTilerConfig = AsyncData.Loading()),
)
}
private const val APP_NAME = "ApplicationName"
fun aShowLocationState(
customMapStyleUrl: AsyncData<String?> = AsyncData.Success(null),
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
@@ -55,7 +56,7 @@ fun aShowLocationState(
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
customMapStyleUrl = customMapStyleUrl,
customMapTilerConfig = customMapTilerConfig,
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
@@ -102,7 +102,7 @@ fun ShowLocationView(
}
}
MapBottomSheetScaffold(
customMapStyleUrl = state.customMapStyleUrl,
customMapTilerConfig = state.customMapTilerConfig,
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -1,4 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Der Verlauf deines Live-Standorts wird im Chat gespeichert und bleibt für die Mitglieder sichtbar, auch nachdem du die Freigabe beendet hast."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Wie lange soll der Live-Standort geteilt werden?"</string>
</resources>
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"您的即時位置歷史將儲存於聊天室中,並在工作階段結束後對其他成員可見。"</string>
<string name="screen_share_location_live_location_duration_picker_title">"選擇分享即時位置的時間長度。"</string>
<string name="screen_share_location_live_location_missing_permissions">"您無權在此聊天室分享您的即時位置"</string>
</resources>
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.share
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
@@ -58,6 +59,7 @@ class DefaultShareLocationEntryPointTest {
sessionId = room.sessionId,
),
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -19,6 +19,8 @@ import app.cash.turbine.ReceiveTurbine
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import im.vector.app.features.analytics.plan.Composer
import io.element.android.features.enterprise.api.remoteconfig.RemoteEnterpriseConfigProvider
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
@@ -30,6 +32,8 @@ import io.element.android.features.location.impl.common.ui.LocationConstraintsDi
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.features.wellknown.test.aMapTilerConfig
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
@@ -51,6 +55,7 @@ import io.element.android.libraries.matrix.test.room.powerlevels.FakeRoomPermiss
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.assert
@@ -60,7 +65,6 @@ import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runTest
import org.junit.Rule
@@ -77,20 +81,18 @@ class ShareLocationPresenterTest {
private val fakeMessageComposerContext = FakeMessageComposerContext()
private val fakeLocationActions = FakeLocationActions()
private val fakeBuildMeta = aBuildMeta(applicationName = "app name")
private val fakeMatrixClient = FakeMatrixClient(
sessionId = A_USER_ID,
getMapStyleUrlResult = { Result.success(null) },
)
private val fakeMatrixClient = FakeMatrixClient(sessionId = A_USER_ID)
private val durationFormatter = FakeDurationFormatter()
private fun TestScope.createShareLocationPresenter(
private fun createShareLocationPresenter(
joinedRoom: JoinedRoom = FakeJoinedRoom(),
timelineMode: Timeline.Mode = Timeline.Mode.Live,
locationActions: FakeLocationActions = fakeLocationActions,
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
client: FakeMatrixClient = fakeMatrixClient,
remoteEnterpriseConfigProvider: RemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@@ -104,6 +106,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider,
)
@Test
@@ -118,7 +121,7 @@ class ShareLocationPresenterTest {
val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.test {
val state = awaitFirstItem()
assertThat(state.customMapStyleUrl.isLoading()).isFalse()
assertThat(state.customMapTilerConfig.isLoading()).isFalse()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
@@ -126,18 +129,20 @@ class ShareLocationPresenterTest {
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShareLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
getMapStyleUrlResult = { Result.success("aUrl") },
),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(
getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) }
)
)
shareLocationPresenter.test {
skipItems(1)
val state = awaitItem()
assertThat(state.customMapStyleUrl.isLoading()).isTrue()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
}
}
@@ -709,7 +714,7 @@ class ShareLocationPresenterTest {
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(2)
skipItems(1)
return awaitItem()
}
}
@@ -11,6 +11,7 @@ package io.element.android.features.location.impl.show
import androidx.arch.core.executor.testing.InstantTaskExecutorRule
import com.bumble.appyx.core.modality.BuildContext
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationEntryPoint
import io.element.android.features.location.api.ShowLocationMode
@@ -20,7 +21,7 @@ import io.element.android.features.location.impl.common.permissions.FakePermissi
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -41,8 +42,8 @@ class DefaultShowLocationEntryPointTest {
ShowLocationNode(
buildContext = buildContext,
plugins = plugins,
presenterFactory = object : ShowLocationPresenter.Factory {
override fun create(mode: ShowLocationMode) = ShowLocationPresenter(
presenterFactory = { mode ->
ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { FakePermissionsPresenter() },
locationActions = FakeLocationActions(),
@@ -50,9 +51,10 @@ class DefaultShowLocationEntryPointTest {
dateFormatter = FakeDateFormatter(),
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
client = FakeMatrixClient(),
sessionId = A_SESSION_ID,
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -12,6 +12,7 @@ import app.cash.molecule.RecompositionMode
import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.test.remoteconfig.FakeRemoteEnterpriseConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
@@ -22,17 +23,20 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE
import io.element.android.features.location.impl.common.permissions.PermissionsState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.wellknown.test.aMapTilerConfig
import io.element.android.features.wellknown.test.anElementWellKnown
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.api.room.location.AssetType
import io.element.android.libraries.matrix.api.room.location.LiveLocationShare
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeJoinedRoom
import io.element.android.libraries.matrix.test.room.location.aLiveLocationShare
import io.element.android.libraries.wellknown.api.WellknownRetrieverResult
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.test
@@ -65,8 +69,9 @@ class ShowLocationPresenterTest {
),
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
client: MatrixClient = FakeMatrixClient(),
sessionId: SessionId = A_SESSION_ID,
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
remoteEnterpriseConfigProvider: FakeRemoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@@ -75,24 +80,26 @@ class ShowLocationPresenterTest {
dateFormatter = fakeDateFormatter,
stringProvider = FakeStringProvider(),
joinedRoom = joinedRoom,
client = client,
sessionId = sessionId,
liveLocationShareManager = liveLocationShareManager,
userLocationStateFactory = FakeUserLocationStateFactory(),
remoteEnterpriseConfigProvider = remoteEnterpriseConfigProvider,
)
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShowLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
getMapStyleUrlResult = { Result.success("aUrl") },
sessionId = A_SESSION_ID,
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(
getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = mapTilerConfig)) }
)
)
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapStyleUrl.isLoading()).isTrue()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapStyleUrl.dataOrNull()).isEqualTo("aUrl")
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
}
}
@@ -115,6 +122,8 @@ class ShowLocationPresenterTest {
initialState.eventSink(ShowLocationEvent.Share(location))
assertThat(fakeLocationActions.sharedLocation).isEqualTo(location)
cancelAndIgnoreRemainingEvents()
}
}
@@ -123,10 +132,12 @@ class ShowLocationPresenterTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
val presenter = createShowLocationPresenter(
client = FakeMatrixClient(
getMapStyleUrlResult = { Result.success(null) }
sessionId = A_SESSION_ID,
remoteEnterpriseConfigProvider = FakeRemoteEnterpriseConfigProvider(
getResult = { WellknownRetrieverResult.Success(anElementWellKnown(mapTilerConfig = aMapTilerConfig())) }
)
)
presenter.test {
val initialState = awaitItem()
assertThat(initialState.isTrackMyLocation).isFalse()
@@ -281,6 +292,8 @@ class ShowLocationPresenterTest {
}.test {
val initialState = awaitItem()
assertThat(initialState.appName).isEqualTo("app name")
cancelAndIgnoreRemainingEvents()
}
}
@@ -334,6 +347,8 @@ class ShowLocationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
assertThat(initialState.isSheetDraggable).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@@ -408,6 +423,9 @@ class ShowLocationPresenterTest {
val initialState = awaitItem()
assertThat(initialState.locationShares).isEmpty()
// Wait until we receive the custom map style URL result
skipItems(1)
// Emit a new live share
liveSharesFlow.value = listOf(
aLiveLocationShare(userId = userId)
@@ -447,6 +465,8 @@ class ShowLocationPresenterTest {
assertThat(item.avatarData.id).isEqualTo(senderId.value)
assertThat(item.avatarData.name).isEqualTo(senderName)
assertThat(item.avatarData.url).isEqualTo(avatarUrl)
cancelAndIgnoreRemainingEvents()
}
}
@@ -456,6 +476,7 @@ class ShowLocationPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.isSheetDraggable).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@@ -465,6 +486,8 @@ class ShowLocationPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@@ -476,6 +499,8 @@ class ShowLocationPresenterTest {
presenter.test {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@@ -17,6 +17,9 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
/**
* Exposes the app lock state: whether a PIN or biometric lock is set up, whether it is mandatory, and whether the app is locked right now.
*/
interface LockScreenService {
/**
* The current lock state of the app.
@@ -59,7 +59,7 @@ class DefaultBiometricAuthentication(
private var cryptoObject: CryptoObject? = null
override suspend fun setup() {
if (cryptoObject != null) return
Timber.d("Setup biometric authentication")
try {
val secretKey = ensureKey()
val cipher = encryptionDecryptionService.createEncryptionCipher(secretKey)
@@ -126,6 +126,9 @@ private class AuthenticationCallback(
if (this == null) return false
return runCatchingExceptions {
doFinal("biometric_challenge".toByteArray())
}.isSuccess
}.onFailure {
Timber.e(it, "Failed to validate cipher")
}
.isSuccess
}
}
@@ -38,7 +38,7 @@ fun LockScreenSettingsView(
) {
PreferenceCategory(showTopDivider = false) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_app_lock_settings_change_pin))
},
onClick = onChangePinClick,
@@ -46,7 +46,7 @@ fun LockScreenSettingsView(
PreferenceDivider()
if (state.showRemovePinOption) {
ListItem(
headlineContent = {
content = {
Text(stringResource(id = R.string.screen_app_lock_settings_remove_pin))
},
style = ListItemStyle.Destructive,
@@ -8,6 +8,13 @@
package io.element.android.features.login.api
/**
* Extracts the login parameters from a link, so that a deployment can hand out a URL that pre-fills the sign-in screen.
*/
interface LoginIntentResolver {
/**
* @param uriString the link the app was opened with.
* @return the account provider and login hint to pre-fill, or `null` when the link is not a login link.
*/
fun parse(uriString: String): LoginParams?
}
@@ -8,6 +8,14 @@
package io.element.android.features.login.api.accesscontrol
/**
* Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to.
*/
interface AccountProviderAccessControl {
/**
* Whether sign-in to this provider is permitted; `true` on a build with no such restriction.
*
* @param accountProviderUrl the server the user is trying to use.
*/
suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean
}
@@ -35,7 +35,6 @@ import io.element.android.features.login.impl.qrcode.QrCodeLoginFlowNode
import io.element.android.features.login.impl.screens.chooseaccountprovider.ChooseAccountProviderNode
import io.element.android.features.login.impl.screens.classic.ClassicFlowNode
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderNode
import io.element.android.features.login.impl.screens.createaccount.CreateAccountNode
import io.element.android.features.login.impl.screens.loginpassword.LoginPasswordNode
import io.element.android.features.login.impl.screens.onboarding.OnBoardingNode
import io.element.android.features.preferences.api.PreferencesEntryPoint
@@ -131,9 +130,6 @@ class LoginFlowNode(
data class LoginPassword(
val initialLogin: String = "",
) : NavTarget
@Parcelize
data class CreateAccount(val url: String) : NavTarget
}
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
@@ -155,10 +151,6 @@ class LoginFlowNode(
override fun navigateToOAuth(oAuthDetails: OAuthDetails) {
navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
}
createNode<ClassicFlowNode>(buildContext, listOf(callback))
}
@@ -192,10 +184,6 @@ class LoginFlowNode(
navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun navigateToDeveloperSettings() {
backstack.push(NavTarget.AppDeveloperSettings)
}
@@ -238,10 +226,6 @@ class LoginFlowNode(
navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
@@ -265,10 +249,6 @@ class LoginFlowNode(
navigateToMas(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
backstack.push(NavTarget.CreateAccount(url))
}
override fun navigateToLoginPassword() {
backstack.push(NavTarget.LoginPassword())
}
@@ -281,12 +261,6 @@ class LoginFlowNode(
)
createNode<LoginPasswordNode>(buildContext, plugins = listOf(inputs))
}
is NavTarget.CreateAccount -> {
val inputs = CreateAccountNode.Inputs(
url = navTarget.url,
)
createNode<CreateAccountNode>(buildContext, listOf(inputs))
}
}
}
@@ -13,13 +13,15 @@ import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.wellknown.api.EnterpriseRemoteConfigSource
import io.element.android.libraries.wellknown.api.WellknownRetriever
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl(
private val enterpriseService: EnterpriseService,
private val wellknownRetriever: WellknownRetriever,
private val wellknownRetrieverFactory: WellknownRetriever.Factory,
private val temporaryMatrixClientFactory: TemporaryMatrixClientFactory,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
assertIsAllowedToConnectToAccountProvider(
@@ -38,14 +40,21 @@ class DefaultAccountProviderAccessControl(
) {
if (enterpriseService.isEnterpriseBuild.not()) {
// Ensure that Element Pro is not required for this account provider
val wellKnown = wellknownRetriever.getElementWellKnown(
baseUrl = accountProviderUrl.ensureProtocol(),
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
val temporaryMatrixClient = temporaryMatrixClientFactory.create(accountProviderUrl).getOrThrow()
temporaryMatrixClient.use {
val wellknownRetriever = wellknownRetrieverFactory.create(temporaryMatrixClient)
val wellKnown = wellknownRetriever.getElementWellKnown(
host = accountProviderUrl,
// It's expected this is hardcoded to the well-known endpoint
source = EnterpriseRemoteConfigSource.WELLKNOWN_ENDPOINT,
).dataOrNull()
if (wellKnown?.enforceElementPro == true) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
}
}
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
@@ -21,7 +21,6 @@ import io.element.android.features.login.impl.screens.classic.loginwithclassic.L
import io.element.android.features.login.impl.screens.confirmaccountprovider.ConfirmAccountProviderPresenter
import io.element.android.features.login.impl.screens.createaccount.AccountCreationNotSupported
import io.element.android.features.login.impl.screens.onboarding.OnBoardingPresenter
import io.element.android.features.login.impl.web.WebClientUrlForAuthenticationRetriever
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
@@ -40,7 +39,6 @@ import io.element.android.libraries.oauth.api.OAuthActionFlow
class LoginModePresenter(
private val oAuthActionFlow: OAuthActionFlow,
private val authenticationService: MatrixAuthenticationService,
private val webClientUrlForAuthenticationRetriever: WebClientUrlForAuthenticationRetriever,
private val localNetworkPermissionGate: LocalNetworkPermissionGate,
private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter<LoginModeState> {
@@ -92,11 +90,9 @@ class LoginModePresenter(
authenticationService.getOAuthUrl(prompt = oAuthPrompt, loginHint = request.loginHint).getOrThrow()
)
}
request.isAccountCreation -> LoginMode.AccountCreation(
webClientUrlForAuthenticationRetriever.retrieve(request.homeserverUrl)
)
request.isAccountCreation -> throw AccountCreationNotSupported()
matrixHomeServerDetails.supportsPasswordLogin -> LoginMode.PasswordLogin
else -> error("Unsupported login flow")
else -> error("Unsupported authentication flow")
}
}.getOrThrow()
}.runCatchingUpdatingState(
@@ -20,5 +20,4 @@ data class LoginModeState(
sealed interface LoginMode {
data object PasswordLogin : LoginMode
data class OAuth(val oAuthDetails: OAuthDetails) : LoginMode
data class AccountCreation(val url: String) : LoginMode
}
@@ -34,7 +34,6 @@ fun LoginModeView(
onLearnMoreClick: () -> Unit,
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit
) {
val context = LocalContext.current
when (loginMode) {
@@ -120,7 +119,6 @@ fun LoginModeView(
when (val loginModeData = loginMode.data) {
is LoginMode.OAuth -> onOAuthDetails(loginModeData.oAuthDetails)
LoginMode.PasswordLogin -> onNeedLoginPassword()
is LoginMode.AccountCreation -> onCreateAccountContinue(loginModeData.url)
}
// Also clear the data, to let the next screen be able to go back
onClearError()
@@ -139,7 +137,6 @@ internal fun LoginModeViewPreview(@PreviewParameter(LoginModeViewErrorProvider::
onLearnMoreClick = {},
onOAuthDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {}
)
}
}
@@ -32,7 +32,6 @@ class ChooseAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
}
private val callback: Callback = callback()
@@ -48,7 +47,6 @@ class ChooseAccountProviderNode(
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
)
}
}
@@ -57,7 +57,6 @@ fun ChooseAccountProviderView(
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginModeState.loginMode) {
@@ -137,7 +136,6 @@ fun ChooseAccountProviderView(
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
}
}
@@ -161,6 +159,5 @@ internal fun ChooseAccountProviderViewPreview(@PreviewParameter(ChooseAccountPro
onLearnMoreClick = { },
onOAuthDetails = { },
onNeedLoginPassword = { },
onCreateAccountContinue = { },
)
}
@@ -55,7 +55,6 @@ class ClassicFlowNode(
fun navigateToOnBoarding(allowBackNavigation: Boolean)
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
}
sealed interface NavTarget : Parcelable {
@@ -115,10 +114,6 @@ class ClassicFlowNode(
callback.navigateToOAuth(oAuthDetails)
}
override fun navigateToCreateAccount(url: String) {
callback.navigateToCreateAccount(url)
}
override fun navigateToMissingKeyBackup() {
backstack.push(NavTarget.MissingKeyBackup)
}
@@ -36,7 +36,6 @@ class LoginWithClassicNode(
fun navigateToOtherOptions()
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
fun navigateToMissingKeyBackup()
}
@@ -63,7 +62,6 @@ class LoginWithClassicNode(
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
onCreateAccountContinue = callback::navigateToCreateAccount,
)
}
}
@@ -64,7 +64,6 @@ fun LoginWithClassicView(
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val isLoading by remember(state.loginModeState.loginMode) {
@@ -204,7 +203,6 @@ fun LoginWithClassicView(
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
dialog = state.loginModeState.localNetworkPermissionDialog,
@@ -226,6 +224,5 @@ internal fun LoginWithClassicViewPreview(@PreviewParameter(LoginWithClassicState
onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
onCreateAccountContinue = {},
)
}
@@ -45,7 +45,6 @@ class ConfirmAccountProviderNode(
interface Callback : Plugin {
fun navigateToLoginPassword()
fun navigateToOAuth(oAuthDetails: OAuthDetails)
fun navigateToCreateAccount(url: String)
}
private val callback: Callback = callback()
@@ -59,7 +58,6 @@ class ConfirmAccountProviderNode(
modifier = modifier,
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onCreateAccountContinue = callback::navigateToCreateAccount,
onLearnMoreClick = { openLearnMorePage(context) },
)
}
@@ -73,7 +73,6 @@ fun ConfirmAccountProviderView(
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
onCreateAccountContinue: (url: String) -> Unit,
modifier: Modifier = Modifier,
) {
val eventSink = state.eventSink
@@ -207,7 +206,6 @@ fun ConfirmAccountProviderView(
onLearnMoreClick = onLearnMoreClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onCreateAccountContinue = onCreateAccountContinue,
)
LocalNetworkPermissionDialogView(
@@ -253,7 +251,6 @@ internal fun ConfirmAccountProviderViewPreview(
state = state,
onOAuthDetails = {},
onNeedLoginPassword = {},
onCreateAccountContinue = {},
onLearnMoreClick = {},
)
}
@@ -1,14 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
sealed interface CreateAccountEvents {
data class SetPageProgress(val progress: Int) : CreateAccountEvents
data class OnMessageReceived(val message: String) : CreateAccountEvents
}
@@ -1,58 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import android.app.Activity
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import com.bumble.appyx.core.modality.BuildContext
import com.bumble.appyx.core.node.Node
import com.bumble.appyx.core.plugin.Plugin
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedInject
import io.element.android.annotations.ContributesNode
import io.element.android.compound.theme.ElementTheme
import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTab
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.architecture.inputs
@ContributesNode(AppScope::class)
@AssistedInject
class CreateAccountNode(
@Assisted buildContext: BuildContext,
@Assisted plugins: List<Plugin>,
presenterFactory: CreateAccountPresenter.Factory,
) : Node(buildContext, plugins = plugins) {
data class Inputs(
val url: String,
) : NodeInputs
private val presenter = presenterFactory.create(inputs<Inputs>().url)
private fun onOpenExternalUrl(activity: Activity, darkTheme: Boolean, url: String) {
activity.openUrlInChromeCustomTab(null, darkTheme, url)
}
@Composable
override fun View(modifier: Modifier) {
val activity = requireNotNull(LocalActivity.current)
val isDark = ElementTheme.isLightTheme.not()
val state = presenter.present()
CreateAccountView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOpenExternalUrl = {
onOpenExternalUrl(activity, isDark, it)
},
)
}
}
@@ -1,85 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import androidx.compose.runtime.Composable
import androidx.compose.runtime.MutableState
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.features.login.impl.accountprovider.SaveAccountProviderToHistory
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.extensions.flatMap
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.launch
@AssistedInject
class CreateAccountPresenter(
@Assisted private val url: String,
private val authenticationService: MatrixAuthenticationService,
private val messageParser: MessageParser,
private val buildMeta: BuildMeta,
private val saveAccountProviderToHistory: SaveAccountProviderToHistory,
) : Presenter<CreateAccountState> {
@AssistedFactory
interface Factory {
fun create(url: String): CreateAccountPresenter
}
@Composable
override fun present(): CreateAccountState {
val coroutineScope = rememberCoroutineScope()
val pageProgress: MutableState<Int> = remember { mutableIntStateOf(0) }
val createAction: MutableState<AsyncAction<SessionId>> = remember { mutableStateOf(AsyncAction.Uninitialized) }
fun handleEvent(event: CreateAccountEvents) {
when (event) {
is CreateAccountEvents.SetPageProgress -> {
pageProgress.value = event.progress
}
is CreateAccountEvents.OnMessageReceived -> {
// Ignore unexpected message
if (event.message.contains("isTrusted")) return
coroutineScope.importSession(event.message, createAction)
}
}
}
return CreateAccountState(
url = url,
pageProgress = pageProgress.value,
isDebugBuild = buildMeta.isDebuggable,
createAction = createAction.value,
eventSink = ::handleEvent,
)
}
private fun CoroutineScope.importSession(message: String, loggedInState: MutableState<AsyncAction<SessionId>>) = launch {
loggedInState.value = AsyncAction.Loading
runCatchingExceptions {
messageParser.parse(message)
}.flatMap { externalSession ->
authenticationService.importCreatedSession(externalSession)
}.onSuccess { sessionId ->
saveAccountProviderToHistory()
loggedInState.value = AsyncAction.Success(sessionId)
}.onFailure { failure ->
loggedInState.value = AsyncAction.Failure(failure)
}
}
}
@@ -1,20 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2024, 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.SessionId
data class CreateAccountState(
val url: String,
val pageProgress: Int,
val createAction: AsyncAction<SessionId>,
val isDebugBuild: Boolean,
val eventSink: (CreateAccountEvents) -> Unit
)
@@ -1,34 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.login.impl.screens.createaccount
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.matrix.api.core.SessionId
open class CreateAccountStateProvider : PreviewParameterProvider<CreateAccountState> {
override val values: Sequence<CreateAccountState>
get() = sequenceOf(
aCreateAccountState(),
aCreateAccountState(pageProgress = 33),
aCreateAccountState(createAction = AsyncAction.Loading),
aCreateAccountState(createAction = AsyncAction.Failure(RuntimeException("Failed to create account"))),
)
}
private fun aCreateAccountState(
pageProgress: Int = 100,
createAction: AsyncAction<SessionId> = AsyncAction.Uninitialized,
) = CreateAccountState(
url = "https://example.com",
isDebugBuild = true,
pageProgress = pageProgress,
createAction = createAction,
eventSink = {}
)

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