Merge branch 'develop' into feature/bma/swipeAvatarAnnouncement

Account switching is now handled by RootFlowNode, through the onSwitchToAccount callback, instead
of HomeEvent.SwitchToAccount. To keep dismissing the swipe avatar announcement when the user
switches account by swiping their avatar, HomeView now also sends the new
HomeEvent.DismissSwipeAvatarAnnouncement.

Also opt in to ExperimentalCoroutinesApi in the HomePresenter tests using advanceTimeBy.

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 17:39:24 +02:00
138 changed files with 1205 additions and 597 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-debug
path: |
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-enterprise-debug
path: |
+2 -2
View File
@@ -63,7 +63,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-apk-maestro
path: |
@@ -138,7 +138,7 @@ jobs:
pip install requests
python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement
- name: Upload test results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: test-results
path: |
+4 -4
View File
@@ -48,17 +48,17 @@ jobs:
cache-read-only: false
- name: ⚙️ Run unit tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue
- name: 📸 Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: 📈 Generate kover report and verify coverage
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
- name: ✅ Upload kover report
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: kover-results
path: |
@@ -92,7 +92,7 @@ jobs:
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: dependency-analysis
path: build/reports/dependency-check-report.html
+6 -6
View File
@@ -120,7 +120,7 @@ jobs:
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: konsist-report
path: |
@@ -199,7 +199,7 @@ jobs:
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: linting-report
path: |
@@ -237,10 +237,10 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: detekt-report
path: |
@@ -278,10 +278,10 @@ jobs:
with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports
if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: ktlint-report
path: |
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-app-gplay-bundle-unsigned
path: |
@@ -105,7 +105,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: elementx-app-fdroid-apks-unsigned
path: |
+4 -4
View File
@@ -73,17 +73,17 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: ⚙️ Run unit tests
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue
- name: ⚙️ Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: ⚙️ Generate kover coverage report
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: 🚫 Upload kover failed coverage reports
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: kover-error-report
path: |
@@ -95,7 +95,7 @@ jobs:
- name: 🚫 Upload test results on error
if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with:
name: tests-and-screenshot-tests-results
path: |
@@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize
@@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
}
private val callback: Callback = callback()
@@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
}
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
}
@@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.ui.strings.CommonStrings
@@ -34,7 +34,7 @@ class LoggedInEventProcessor(
@Assisted private val snackbarDispatcher: SnackbarDispatcher,
private val roomMembershipObserver: RoomMembershipObserver,
private val sessionId: SessionId,
private val roomListService: RoomListService,
private val syncService: SyncService,
private val notificationCleaner: NotificationCleaner,
private val dispatchers: CoroutineDispatchers
) {
@@ -73,9 +73,8 @@ class LoggedInEventProcessor(
}
.launchIn(childScope)
// Use the room list service state as a 'heartbeat' update to check existing notifications
roomListService.state
.filter { it == RoomListService.State.Running }
// Use the sync service heartbeat as a 'heartbeat' update to check existing notifications
syncService.heartBeat
.onEach {
notificationCleaner.clearReadRoomsNotifications(sessionId)
}
@@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope
@@ -52,13 +51,13 @@ import io.element.android.features.callnative.api.NativeCallHost
import io.element.android.features.createroom.api.CreateRoomEntryPoint
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.SessionEnterpriseService
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.ftue.api.FtueEntryPoint
import io.element.android.features.ftue.api.state.FtueService
import io.element.android.features.ftue.api.state.FtueState
import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
import io.element.android.features.location.api.LocalMapTilerConfig
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.networkmonitor.api.NetworkMonitor
import io.element.android.features.networkmonitor.api.NetworkStatus
@@ -86,6 +85,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
import io.element.android.libraries.matrix.api.permalink.PermalinkData
@@ -153,7 +153,7 @@ class LoggedInFlowNode(
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
private val createRoomEntryPoint: CreateRoomEntryPoint,
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
backstack = BackStack(
@@ -170,6 +170,7 @@ class LoggedInFlowNode(
interface Callback : Plugin {
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
}
private val callback: Callback = callback()
@@ -357,6 +358,10 @@ class LoggedInFlowNode(
override fun navigateToAddAccount() {
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
}
homeEntryPoint.createNode(
parentNode = this,
@@ -449,6 +454,10 @@ class LoggedInFlowNode(
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
override fun navigateToLinkNewDevice() {
backstack.push(NavTarget.LinkNewDevice)
}
@@ -636,10 +645,7 @@ class LoggedInFlowNode(
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
}.collectAsState(SemanticColorsLightDark.default)
val currentMapTilerConfig = LocalMapTilerConfig.current
val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
ElementThemeApp(
appPreferencesStore = appPreferencesStore,
@@ -648,7 +654,7 @@ class LoggedInFlowNode(
compoundDark = colors.dark,
buildMeta = buildMeta,
) {
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) {
val isOnline by syncService.isOnline.collectAsState()
// Outside the connectivity indicator so the two strips stack rather than fight: an
// offline banner during a call belongs under the call bar, not over it.
@@ -416,6 +416,12 @@ class RootFlowNode(
override fun navigateToAddAccount() {
backstack.push(NavTarget.NotLoggedInFlow(null))
}
override fun navigateToSession(sessionId: SessionId) {
lifecycleScope.launch {
sessionStore.setLatestSession(sessionId.value)
}
}
}
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
val buildContext = if (savedNavState != null) {
@@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
import io.element.android.libraries.matrix.test.sync.FakeSyncService
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -155,24 +154,24 @@ class LoggedInEventProcessorTest {
}
@Test
fun `test notifications are cleared when room list service state is running`() = runTest {
fun `test notifications are cleared when sync heartbeat is emitted`() = runTest {
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
val snackbarDispatcher = SnackbarDispatcher()
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
val roomListService = FakeRoomListService()
val syncService = FakeSyncService()
val loggedInEventProcessor = createLoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
notificationCleaner = notificationCleaner,
roomListService = roomListService,
syncService = syncService,
)
loggedInEventProcessor.observeEvents(backgroundScope)
testScheduler.runCurrent()
// Simulate the room list service state changing to Running
roomListService.postState(RoomListService.State.Running)
// Simulate a sync heartbeat
syncService.emitHeartBeat()
testScheduler.runCurrent()
@@ -184,13 +183,13 @@ class LoggedInEventProcessorTest {
snackbarDispatcher: SnackbarDispatcher,
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
sessionId: SessionId = A_SESSION_ID,
roomListService: FakeRoomListService = FakeRoomListService(),
syncService: FakeSyncService = FakeSyncService(),
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
) = LoggedInEventProcessor(
snackbarDispatcher = snackbarDispatcher,
roomMembershipObserver = roomMembershipObserver,
sessionId = sessionId,
roomListService = roomListService,
syncService = syncService,
notificationCleaner = notificationCleaner,
dispatchers = testCoroutineDispatchers(),
)
+1
View File
@@ -32,6 +32,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.designsystem)
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiCommon)
@@ -20,16 +20,19 @@ import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.EncryptionService
import io.element.android.libraries.matrix.api.encryption.RecoveryState
@Inject
class ChooseSelfVerificationModePresenter(
private val matrixClient: MatrixClient,
private val encryptionService: EncryptionService,
private val directLogoutPresenter: Presenter<DirectLogoutState>,
) : Presenter<ChooseSelfVerificationModeState> {
@Composable
override fun present(): ChooseSelfVerificationModeState {
val currentUser by matrixClient.userProfile.collectAsState()
val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState()
val canUseRecoveryKey by produceState<AsyncData<Boolean>>(AsyncData.Uninitialized) {
encryptionService.recoveryStateStateFlow
@@ -72,6 +75,7 @@ class ChooseSelfVerificationModePresenter(
}
return ChooseSelfVerificationModeState(
currentUser = currentUser,
buttonsState = buttonsState,
directLogoutState = directLogoutState,
eventSink = ::handleEvent,
@@ -10,8 +10,10 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
data class ChooseSelfVerificationModeState(
val currentUser: MatrixUser,
val buttonsState: AsyncData<ButtonsState>,
val directLogoutState: DirectLogoutState,
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
@@ -11,6 +11,8 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.aMatrixUser
class ChooseSelfVerificationModeStatePreviewParam :
PreviewParameterProvider<ChooseSelfVerificationModeState> {
@@ -43,10 +45,13 @@ class ChooseSelfVerificationModeStatePreviewParam :
fun aChooseSelfVerificationModeState(
buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()),
currentUser: MatrixUser = aMatrixUser(),
eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {},
) = ChooseSelfVerificationModeState(
currentUser = currentUser,
buttonsState = buttonsState,
directLogoutState = aDirectLogoutState(),
eventSink = {},
eventSink = eventSink,
)
fun aButtonsState(
@@ -12,6 +12,7 @@ import androidx.activity.compose.BackHandler
import androidx.activity.compose.LocalActivity
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
@@ -35,12 +36,19 @@ import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextButton
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
import io.element.android.libraries.ui.strings.CommonStrings
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813
*/
@Composable
fun ChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState,
@@ -61,7 +69,7 @@ fun ChooseSelfVerificationModeView(
title = {},
actions = {
TextButton(
text = stringResource(CommonStrings.action_signout),
text = stringResource(R.string.screen_identity_confirmation_sign_out),
onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) }
)
}
@@ -75,6 +83,7 @@ fun ChooseSelfVerificationModeView(
subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
)
},
isScrollable = true,
footer = {
ChooseSelfVerificationModeButtons(
state = state,
@@ -84,20 +93,31 @@ fun ChooseSelfVerificationModeView(
)
}
) {
Row(
Column(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
verticalArrangement = Arrangement.spacedBy(24.dp),
) {
Text(
modifier = Modifier
.clickable(onClick = onLearnMore)
.padding(vertical = 4.dp, horizontal = 16.dp)
.semantics {
// Note: there is no Role.Link, so we use Role.Button for better accessibility support
role = Role.Button
},
text = stringResource(CommonStrings.action_learn_more),
style = ElementTheme.typography.fontBodyLgMedium
Row(
modifier = Modifier.fillMaxWidth(),
horizontalArrangement = Arrangement.Center,
) {
Text(
modifier = Modifier
.clickable(onClick = onLearnMore)
.padding(vertical = 4.dp, horizontal = 16.dp)
.semantics {
// Note: there is no Role.Link, so we use Role.Button for better accessibility support
role = Role.Button
},
text = stringResource(CommonStrings.action_learn_more),
style = ElementTheme.typography.fontBodyLgMedium
)
}
AccountInfoCard(
state = AccountInfoCardState(
matrixUser = state.currentUser,
mode = AccountInfoCardMode.Simple,
),
)
}
}
@@ -125,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons(
modifier = Modifier.fillMaxWidth(),
text = stringResource(R.string.screen_identity_use_another_device),
onClick = onUseAnotherDevice,
leadingIcon = IconSource.Vector(CompoundIcons.Devices()),
)
}
if (state.buttonsState.data.canUseRecoveryKey) {
@@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
<string name="screen_identity_confirmation_sign_out">"Sign out"</string>
<string name="screen_identity_confirmation_subtitle">"Choose how to verify to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm your digital identity"</string>
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
@@ -14,7 +14,13 @@ import io.element.android.features.logout.api.direct.DirectLogoutState
import io.element.android.features.logout.api.direct.aDirectLogoutState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.encryption.RecoveryState
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@@ -34,6 +40,26 @@ class ChooseSessionVerificationModePresenterTest {
}
}
@Test
fun `present - current user is exposed`() = runTest {
val presenter = createPresenter(
matrixClient = FakeMatrixClient(
sessionId = A_SESSION_ID,
userDisplayName = A_USER_NAME,
userAvatarUrl = AN_AVATAR_URL,
),
)
presenter.test {
assertThat(awaitItem().currentUser).isEqualTo(
MatrixUser(
userId = A_SESSION_ID,
displayName = A_USER_NAME,
avatarUrl = AN_AVATAR_URL,
)
)
}
}
@Test
fun `present - state is relayed from EncryptionService, order 1`() = runTest {
val encryptionService = FakeEncryptionService()
@@ -126,9 +152,11 @@ class ChooseSessionVerificationModePresenterTest {
}
private fun createPresenter(
matrixClient: MatrixClient = FakeMatrixClient(),
encryptionService: FakeEncryptionService = FakeEncryptionService(),
directLogoutPresenter: Presenter<DirectLogoutState> = Presenter<DirectLogoutState> { aDirectLogoutState() }
) = ChooseSelfVerificationModePresenter(
matrixClient = matrixClient,
encryptionService = encryptionService,
directLogoutPresenter = directLogoutPresenter,
)
@@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() {
}
}
@Test
fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ChooseSelfVerificationModeEvent>()
setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(eventSink = eventsRecorder),
)
clickOn(R.string.screen_identity_confirmation_sign_out)
eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut)
}
private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
@@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint {
@@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport()
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
}
}
@@ -8,10 +8,8 @@
package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent
data object DismissSwipeAvatarAnnouncement : HomeEvent
}
@@ -222,6 +222,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount,
onSwitchToAccount = callback::navigateToSession,
onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableIntStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.saveable.rememberSaveable
@@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.debounce
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.mapLatest
import kotlinx.coroutines.launch
import kotlin.time.Duration.Companion.milliseconds
@Inject
class HomePresenter(
@@ -53,10 +59,20 @@ class HomePresenter(
) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
@Composable
override fun present(): HomeState {
val coroutineState = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
syncService.heartBeat
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
.debounce(100.milliseconds)
.mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() }
.collect { value = it }
}
val currentUserAndNeighbors by remember {
combine(
client.userProfile,
@@ -110,11 +126,8 @@ class HomePresenter(
is HomeEvent.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal
}
is HomeEvent.SwitchToAccount -> coroutineState.launch {
// The user has switched account by swiping their avatar, so they know the gesture.
// Do it before switching, since it can stop this coroutine.
HomeEvent.DismissSwipeAvatarAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
sessionStore.setLatestSession(event.sessionId.value)
}
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
@@ -133,6 +146,7 @@ class HomePresenter(
snackbarMessage = snackbarMessage,
canReportBug = canReportBug,
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
totalUnreadCount = totalUnreadCount,
eventSink = ::handleEvent,
)
}
@@ -29,6 +29,7 @@ data class HomeState(
val snackbarMessage: SnackbarMessage?,
val canReportBug: Boolean,
val showMultiAccountAnnouncement: Boolean,
val totalUnreadCount: Int,
val eventSink: (HomeEvent) -> Unit,
) {
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
@@ -64,6 +64,7 @@ internal fun aHomeState(
homeSpacesState: HomeSpacesState = aHomeSpacesState(),
canReportBug: Boolean = true,
showMultiAccountAnnouncement: Boolean = false,
totalUnreadCount: Int = 0,
eventSink: (HomeEvent) -> Unit = {}
) = HomeState(
currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(),
@@ -75,5 +76,6 @@ internal fun aHomeState(
currentHomeNavigationBarItem = currentHomeNavigationBarItem,
roomListState = roomListState,
homeSpacesState = homeSpacesState,
totalUnreadCount = totalUnreadCount,
eventSink = eventSink,
)
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.launch
@@ -78,6 +79,7 @@ fun HomeView(
onRoomClick: (RoomId, EventId?) -> Unit,
onSettingsClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
onSetUpRecoveryClick: () -> Unit,
onConfirmRecoveryKeyClick: () -> Unit,
onStartChatClick: () -> Unit,
@@ -130,6 +132,7 @@ fun HomeView(
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
onMenuActionClick = onMenuActionClick,
onSwitchToAccount = onSwitchToAccount,
)
if (state.globalSearchState.isEnabled) {
@@ -167,6 +170,7 @@ private fun HomeScaffold(
onStartChatClick: () -> Unit,
onCreateSpaceClick: () -> Unit,
onMenuActionClick: (RoomListMenuAction) -> Unit,
onSwitchToAccount: (SessionId) -> Unit,
modifier: Modifier = Modifier,
) {
fun onRoomClick(room: RoomListRoomSummary) {
@@ -213,8 +217,10 @@ private fun HomeScaffold(
},
onMenuActionClick = onMenuActionClick,
onOpenSettings = onOpenSettings,
onAccountSwitch = {
state.eventSink(HomeEvent.SwitchToAccount(it))
onAccountSwitch = { sessionId ->
// The user has switched account by swiping their avatar, so they know the gesture.
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
onSwitchToAccount(sessionId)
},
scrollBehavior = scrollBehavior,
displayFilters = state.displayRoomListFilters,
@@ -259,6 +265,7 @@ private fun HomeScaffold(
}
}
},
totalUnreadCount = state.totalUnreadCount,
)
},
floatingActionButtonPosition = FabPosition.Center,
@@ -341,6 +348,7 @@ private fun HomeFloatingActionButton(
@Composable
private fun HomeBottomBar(
totalUnreadCount: Int,
currentHomeNavigationBarItem: HomeNavigationBarItem,
onItemClick: (HomeNavigationBarItem) -> Unit,
modifier: Modifier = Modifier,
@@ -361,6 +369,7 @@ private fun HomeBottomBar(
tooltipLabel = stringResource(item.labelRes),
isSelected = isSelected,
onClick = { onItemClick(item) },
counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 }
)
}
}
@@ -376,6 +385,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -397,6 +407,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onRoomClick = { _, _ -> },
onSettingsClick = {},
onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {},
onStartChatClick = {},
@@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint
import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.services.analytics.test.FakeAnalyticsService
@@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
}
val result = entryPoint.createNode(
parentNode = parentNode,
@@ -28,8 +28,6 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
@@ -43,10 +41,12 @@ import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.advanceTimeBy
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.milliseconds
class HomePresenterTest {
@get:Rule
@@ -121,6 +121,40 @@ class HomePresenterTest {
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - total unread count is updated on sync heartbeat`() = runTest {
var unreadCount = 0L
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }),
syncService = syncService,
)
presenter.test {
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
unreadCount = 5
syncService.emitHeartBeat()
advanceTimeBy(200.milliseconds)
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
}
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService()
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }),
syncService = syncService,
)
presenter.test {
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
syncService.emitHeartBeat()
advanceTimeBy(200.milliseconds)
expectNoEvents()
}
}
@Test
fun `present - should start with no user and then load user with error`() = runTest {
val matrixClient = FakeMatrixClient(
@@ -252,31 +286,29 @@ class HomePresenterTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - switching account dismisses the swipe avatar announcement`() = runTest {
fun `present - dismissing the swipe avatar announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val presenter = createHomePresenter(
client = FakeMatrixClient(sessionId = A_SESSION_ID),
sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
updateUserProfileResult = { _, _, _, _ -> },
setLatestSessionResult = setLatestSessionResult,
),
announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
),
)
presenter.test {
val state = expectMostRecentItem()
state.eventSink(HomeEvent.SwitchToAccount(A_SESSION_ID_2))
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
runCurrent()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.SwipeAvatarToSwitchAccount))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@Test
fun `present - total unread count is loaded in the initial state`() = runTest {
val presenter = createHomePresenter(
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }),
)
presenter.test {
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
}
}
}
@@ -331,6 +331,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick,
onAddAccountClick = onAddAccountClick,
onSwitchToAccount = EnsureNeverCalledWithParam(),
onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick,
+4 -9
View File
@@ -26,7 +26,7 @@ android {
defaultConfig {
buildConfigFieldStr(
name = "MAPTILER_BASE_URL",
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps"
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL,
)
buildConfigFieldStr(
name = "MAPTILER_API_KEY",
@@ -35,8 +35,7 @@ android {
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey")
}
?: ""
},
)
buildConfigFieldStr(
name = "MAPTILER_LIGHT_MAP_ID",
@@ -45,9 +44,7 @@ android {
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId")
}
// fall back to maptiler's default light map.
?: "basic-v2"
},
)
buildConfigFieldStr(
name = "MAPTILER_DARK_MAP_ID",
@@ -56,9 +53,7 @@ android {
} else {
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId")
}
// fall back to maptiler's default dark map.
?: "basic-v2-dark"
},
)
}
}
@@ -11,13 +11,21 @@ import androidx.compose.runtime.staticCompositionLocalOf
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Provides a [MapTilerConfig] for the MapLibre maps.
* Provides the [MapTilerConfig] for the MapLibre maps, or null if no map provider is configured.
*/
val LocalMapTilerConfig = staticCompositionLocalOf {
MapTilerConfig(
apiKey = BuildConfig.MAPTILER_API_KEY,
val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() }
/**
* The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided.
*/
fun buildTimeMapTilerConfig(): MapTilerConfig? {
// BuildConfig fields are null when not provided at build time.
val apiKey: String? = BuildConfig.MAPTILER_API_KEY
if (apiKey.isNullOrEmpty()) return null
return MapTilerConfig(
apiKey = apiKey,
lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
baseUrl = BuildConfig.MAPTILER_BASE_URL
baseUrl = BuildConfig.MAPTILER_BASE_URL,
)
}
@@ -8,10 +8,21 @@
package io.element.android.features.location.api
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.libraries.core.coroutine.mapState
import kotlinx.coroutines.flow.StateFlow
/**
* Tells whether the location features can be used at all, which depends on the build having a map provider configured.
* Tells whether the location features can be used at all, which depends on a map provider being configured,
* either by the homeserver or at build time.
*/
interface LocationService {
/**
* The [MapTilerConfig] to use to render the maps, or null if no map provider is configured.
* The configuration provided by the homeserver takes precedence over the one defined at build time.
*/
val mapTilerConfig: StateFlow<MapTilerConfig?>
/** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */
fun isServiceAvailable(): Boolean
fun isServiceAvailable(): StateFlow<Boolean> = mapTilerConfig.mapState { it != null }
}
@@ -50,11 +50,12 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables
/**
* Shows a static map image downloaded via a third party service's static maps API.
*
* Handles 4 distinct cases:
* Handles 5 distinct cases:
* 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching
* 2. Null location - shows blurred placeholder, no pin, no loading
* 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
* 4. Success (location != null, loaded) - shows actual map with pin
* 3. Null mapTilerConfig (no map provider configured) - shows blurred placeholder, no pin, no fetching
* 4. Loading (location != null, fetching) - shows blurred placeholder with loading indicator
* 5. Success (location != null, loaded) - shows actual map with pin
*/
@Composable
fun StaticMapView(
@@ -84,7 +85,8 @@ fun StaticMapView(
)
}
// Case 2: Null location - show blurred placeholder, no pin, no loading
location == null -> {
// Case 3: No map provider configured - show blurred placeholder, no pin, no fetching
location == null || mapTilerConfig == null && !LocalInspectionMode.current -> {
StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map),
canReload = false,
@@ -94,7 +96,7 @@ fun StaticMapView(
onLoadMapClick = {}
)
}
// Cases 3 & 4: Non-null location - fetch map
// Cases 4 & 5: Non-null location - fetch map
else -> LoadableMapContent(
mapTilerConfig = mapTilerConfig,
location = location,
@@ -150,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
Triple(painter, state, ContentScale.Crop)
} else {
val painter = rememberAsyncImagePainter(
model = if (constraints.isZero) {
// Avoid building a URL if any of the size constraints is zero
model = if (builder == null || constraints.isZero) {
// Avoid building a URL if there is no map provider or if any of the size constraints is zero
null
} else {
ImageRequest.Builder(context)
@@ -193,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
else -> {
StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map),
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription,
width = maxWidth,
height = maxHeight,
@@ -0,0 +1,33 @@
/*
* 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.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Public MapTiler defaults, used when a [MapTilerConfig] does not define a value.
* Values from another configuration (e.g. the build time one) must never be used as a fallback,
* to avoid sending an API key to a server it was not meant for.
*/
internal object MapTilerDefaults {
const val BASE_URL = "https://api.maptiler.com/maps"
const val LIGHT_MAP_ID = "basic-v2"
const val DARK_MAP_ID = "basic-v2-dark"
}
internal fun MapTilerConfig.baseUrlOrDefault(): String {
return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL
}
internal fun MapTilerConfig.lightMapIdOrDefault(): String {
return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID
}
internal fun MapTilerConfig.darkMapIdOrDefault(): String {
return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID
}
@@ -9,7 +9,6 @@
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt
/**
@@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder(
private val lightMapId: String,
private val darkMapId: String,
) : StaticMapUrlBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
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,
baseUrl = mapTilerConfig.baseUrlOrDefault(),
apiKey = mapTilerConfig.apiKey,
lightMapId = mapTilerConfig.lightMapIdOrDefault(),
darkMapId = mapTilerConfig.darkMapIdOrDefault(),
)
override fun build(
@@ -11,33 +11,18 @@
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder {
override fun build(
customMapTilerConfig: MapTilerConfig?,
mapTilerConfig: MapTilerConfig,
darkMode: Boolean,
): String {
val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl
val apiKey = customMapTilerConfig?.apiKey ?: apiKey
val baseUrl = mapTilerConfig.baseUrlOrDefault()
val mapId = if (darkMode) {
customMapTilerConfig?.darkStyleId ?: darkMapId
mapTilerConfig.darkMapIdOrDefault()
} else {
customMapTilerConfig?.lightStyleId ?: lightMapId
mapTilerConfig.lightMapIdOrDefault()
}
return "$baseUrl/$mapId/style.json?key=$apiKey"
return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}"
}
}
@@ -36,17 +36,13 @@ interface StaticMapUrlBuilder {
density: Float,
): String
/** Whether a map provider is configured for this build; the URLs are unusable when it is not. */
/** Whether an API key is configured; the URLs are unusable when it is not. */
fun isServiceAvailable(): Boolean
}
@Composable
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder {
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? {
return remember(mapTilerConfig) {
if (mapTilerConfig != null) {
MapTilerStaticMapUrlBuilder(mapTilerConfig)
} else {
MapTilerStaticMapUrlBuilder()
}
mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) }
}
}
@@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder {
/**
* Builds the style URI the map view loads its tiles from.
*
* @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available.
* @param mapTilerConfig the MapTiler configuration to use.
* @param darkMode whether to request the dark map style.
*/
fun build(
customMapTilerConfig: MapTilerConfig?,
mapTilerConfig: MapTilerConfig,
darkMode: Boolean,
): String
}
@@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder {
*/
@Composable
fun rememberTileStyleUrl(
customMapTileConfig: MapTilerConfig?,
mapTilerConfig: MapTilerConfig?,
): String {
val darkMode = !ElementTheme.isLightTheme
return remember(darkMode, customMapTileConfig) {
MapTilerTileServerStyleUriBuilder().build(
customMapTilerConfig = customMapTileConfig,
darkMode = darkMode,
)
return remember(darkMode, mapTilerConfig) {
// Without config, the map is not displayed, so the style URL is not used.
mapTilerConfig?.let {
MapTilerTileServerStyleUriBuilder().build(
mapTilerConfig = it,
darkMode = darkMode,
)
}.orEmpty()
}
}
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test
class MapTilerStaticMapUrlBuilderTest {
@@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest {
assertThat(builder.isServiceAvailable()).isTrue()
}
@Test
fun `builder created from a config only uses the config values`() {
val configBuilder = MapTilerStaticMapUrlBuilder(
MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/maps/",
)
)
assertThat(configBuilder.isServiceAvailable()).isTrue()
assertThat(
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f)
).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright")
}
@Test
fun `builder created from a partial config falls back to the public MapTiler defaults`() {
val configBuilder = MapTilerStaticMapUrlBuilder(
MapTilerConfig(
apiKey = "",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
)
)
assertThat(configBuilder.isServiceAvailable()).isFalse()
assertThat(
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f)
).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright")
}
@Test
fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
@@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder(
baseUrl = "https://base.url",
apiKey = "anApiKey",
lightMapId = "aLightMapId",
darkMapId = "aDarkMapId",
private val builder = MapTilerTileServerStyleUriBuilder()
private val config = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/maps/",
)
@Test
fun `light map uri`() {
assertThat(
builder.build(
customMapTilerConfig = null,
mapTilerConfig = config,
darkMode = false,
)
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY")
}
@Test
fun `dark map uri`() {
assertThat(
builder.build(
customMapTilerConfig = null,
mapTilerConfig = config,
darkMode = true,
)
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY")
}
@Test
fun `custom map uri light`() {
fun `missing values fall back to the public MapTiler defaults`() {
val partialConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = null,
darkStyleId = " ",
baseUrl = " ",
)
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
mapTilerConfig = partialConfig,
darkMode = false,
)
).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY")
}
@Test
fun `custom map uri dark`() {
).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY")
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
mapTilerConfig = partialConfig,
darkMode = true,
)
).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY")
).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY")
}
@Test
fun `blank custom map uri falls back to the built-in style`() {
fun `blank api key is kept as is`() {
assertThat(
builder.build(
customMapTilerConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = " "
),
mapTilerConfig = config.copy(apiKey = ""),
darkMode = false,
)
).isEqualTo("https://base.url/light/style.json?key=API_KEY")
).isEqualTo("https://custom.url/maps/light/style.json?key=")
}
}
@@ -8,14 +8,31 @@
package io.element.android.features.location.impl
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding
import io.element.android.features.location.api.BuildConfig
import dev.zacsweers.metro.SingleIn
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.buildTimeMapTilerConfig
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.flow.stateIn
@ContributesBinding(AppScope::class)
class DefaultLocationService : LocationService {
override fun isServiceAvailable(): Boolean {
return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
}
@SingleIn(SessionScope::class)
@ContributesBinding(SessionScope::class)
class DefaultLocationService(
customMapTilerConfigProvider: CustomMapTilerConfigProvider,
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
) : LocationService {
override val mapTilerConfig: StateFlow<MapTilerConfig?> = flow {
customMapTilerConfigProvider.get().getOrNull()?.let { emit(it) }
}.stateIn(
scope = sessionCoroutineScope,
started = SharingStarted.Eagerly,
initialValue = buildTimeMapTilerConfig(),
)
}
@@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.derivedStateOf
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.produceState
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
@@ -22,7 +21,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.CustomMapTilerConfigProvider
import io.element.android.features.location.api.LocationService
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
@@ -38,7 +37,6 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.messages.api.MessageComposerContext
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingState
import io.element.android.libraries.core.extensions.flatMap
@@ -73,7 +71,7 @@ class ShareLocationPresenter(
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val liveLocationStore: LiveLocationStore,
private val userLocationStateFactory: UserLocationState.Factory,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
) : Presenter<ShareLocationState> {
@AssistedFactory
fun interface Factory {
@@ -94,10 +92,7 @@ class ShareLocationPresenter(
var pendingLiveLocationShare by remember { mutableStateOf(false) }
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
val currentUser by client.userProfile.collectAsState()
val customMapConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
perms.sendLiveLocationPermissions()
}
@@ -201,7 +196,7 @@ class ShareLocationPresenter(
}
return ShareLocationState(
customMapTilerConfig = customMapConfig,
mapTilerConfig = mapTilerConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -12,12 +12,11 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.collections.immutable.ImmutableList
data class ShareLocationState(
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val mapTilerConfig: MapTilerConfig?,
val currentUser: MatrixUser,
val dialogState: Dialog,
val trackUserLocation: Boolean,
@@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.show.aPreviewMapTilerConfig
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 kotlinx.collections.immutable.persistentListOf
@@ -71,13 +71,13 @@ class ShareLocationStatePreviewParam : PreviewParameterProvider<ShareLocationSta
canShareLiveLocation = true,
),
aShareLocationState(
customMapTilerConfig = AsyncData.Loading(),
mapTilerConfig = null,
),
)
}
fun aShareLocationState(
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(),
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
trackUserPosition: Boolean = false,
@@ -88,7 +88,7 @@ fun aShareLocationState(
eventSink: (ShareLocationEvent) -> Unit = {},
): ShareLocationState {
return ShareLocationState(
customMapTilerConfig = customMapTilerConfig,
mapTilerConfig = mapTilerConfig,
currentUser = currentUser,
dialogState = dialogState,
trackUserLocation = trackUserPosition,
@@ -130,7 +130,7 @@ private fun ShareLocationViewContent(
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig)
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
@@ -181,7 +181,7 @@ private fun ShareLocationScaffold(
)
)
MapBottomSheetScaffold(
isReady = state.customMapTilerConfig.isReady(),
isReady = state.mapTilerConfig != null,
mapState = mapState,
modifier = modifier,
scaffoldState = scaffoldState,
@@ -22,8 +22,8 @@ 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.CustomMapTilerConfigProvider
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
import io.element.android.features.location.api.live.isCurrentlySharing
@@ -39,7 +39,6 @@ import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.meta.BuildMeta
@@ -68,7 +67,7 @@ class ShowLocationPresenter(
private val joinedRoom: JoinedRoom,
private val liveLocationShareManager: ActiveLiveLocationShareManager,
private val userLocationStateFactory: UserLocationState.Factory,
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
private val locationService: LocationService,
) : Presenter<ShowLocationState> {
@AssistedFactory fun interface Factory {
fun create(mode: ShowLocationMode): ShowLocationPresenter
@@ -86,10 +85,7 @@ class ShowLocationPresenter(
mutableStateOf(LocationConstraintsDialogState.None)
}
val customMapTilerConfig by produceState(AsyncData.Loading()) {
// Ignore errors
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
}
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
fun checkLocationConstraints() {
val locationConstraints = checkLocationConstraints(
@@ -221,7 +217,7 @@ class ShowLocationPresenter(
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
}
return ShowLocationState(
customMapTilerConfig = customMapTilerConfig,
mapTilerConfig = mapTilerConfig,
dialogState = dialogState,
locationShares = locationShares,
focusedLocation = focusedLocation,
@@ -13,7 +13,6 @@ import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.ui.LocationMarkerData
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData
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
@@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
import kotlinx.collections.immutable.ImmutableList
data class ShowLocationState(
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
val mapTilerConfig: MapTilerConfig?,
val isLive: Boolean,
val dialogState: LocationConstraintsDialogState,
val locationShares: ImmutableList<LocationShareItem>,
@@ -13,7 +13,6 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
@@ -37,14 +36,21 @@ class ShowLocationStatePreviewParam : PreviewParameterProvider<ShowLocationState
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
),
aShowLocationState(isTrackMyLocation = true),
aShowLocationState(customMapTilerConfig = AsyncData.Loading()),
aShowLocationState(mapTilerConfig = null),
)
}
private const val APP_NAME = "ApplicationName"
internal fun aPreviewMapTilerConfig() = MapTilerConfig(
apiKey = "preview_api_key",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
)
fun aShowLocationState(
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(),
isLive: Boolean = false,
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
@@ -56,7 +62,7 @@ fun aShowLocationState(
eventSink: (ShowLocationEvent) -> Unit = {},
): ShowLocationState {
return ShowLocationState(
customMapTilerConfig = customMapTilerConfig,
mapTilerConfig = mapTilerConfig,
dialogState = constraintsDialogState,
locationShares = locationShares.toImmutableList(),
focusedLocation = focusedLocation,
@@ -102,7 +102,7 @@ private fun ShowLocationViewContent(
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig)
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
@@ -178,7 +178,7 @@ private fun ShowLocationScaffold(
}
}
MapBottomSheetScaffold(
isReady = state.customMapTilerConfig.isReady(),
isReady = state.mapTilerConfig != null,
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -9,15 +9,55 @@
package io.element.android.features.location.impl
import com.google.common.truth.Truth.assertThat
import io.element.android.features.location.api.BuildConfig
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.buildTimeMapTilerConfig
import io.element.android.libraries.matrix.test.AN_EXCEPTION
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
@OptIn(ExperimentalCoroutinesApi::class)
class DefaultLocationServiceTest {
@Test
fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() {
val locationService = DefaultLocationService()
assertThat(locationService.isServiceAvailable()).isEqualTo(
BuildConfig.MAPTILER_API_KEY.isNotEmpty()
fun `when the homeserver does not provide a config, the build time config is used`() = runTest {
val locationService = createDefaultLocationService(
customMapTilerConfigProvider = { Result.success(null) },
)
runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null)
}
@Test
fun `when the homeserver config cannot be retrieved, the build time config is used`() = runTest {
val locationService = createDefaultLocationService(
customMapTilerConfigProvider = { Result.failure(AN_EXCEPTION) },
)
runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null)
}
@Test
fun `when the homeserver provides a config, it is used and the service is available`() = runTest {
val config = aMapTilerConfig(apiKey = "homeserver_api_key")
val locationService = createDefaultLocationService(
customMapTilerConfigProvider = { Result.success(config) },
)
runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(config)
assertThat(locationService.isServiceAvailable().value).isTrue()
}
private fun TestScope.createDefaultLocationService(
customMapTilerConfigProvider: CustomMapTilerConfigProvider,
sessionCoroutineScope: CoroutineScope = backgroundScope,
) = DefaultLocationService(
customMapTilerConfigProvider = customMapTilerConfigProvider,
sessionCoroutineScope = sessionCoroutineScope,
)
}
@@ -16,6 +16,7 @@ import io.element.android.features.location.impl.common.actions.FakeLocationActi
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.matrix.api.timeline.Timeline
@@ -58,7 +59,7 @@ class DefaultShareLocationEntryPointTest {
sessionId = room.sessionId,
),
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = { Result.success(null) },
locationService = FakeLocationService(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -19,9 +19,9 @@ 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.MapTilerConfig
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
import io.element.android.features.location.impl.common.actions.FakeLocationActions
@@ -31,6 +31,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.live.LiveLocationStore
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.messages.test.FakeMessageComposerContext
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
import io.element.android.libraries.matrix.api.core.EventId
@@ -89,7 +90,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
client: FakeMatrixClient = fakeMatrixClient,
customMapTilerConfigProvider: () -> Result<MapTilerConfig?> = { Result.success(null) },
locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()),
): ShareLocationPresenter = ShareLocationPresenter(
permissionsPresenterFactory = { fakePermissionsPresenter },
room = joinedRoom,
@@ -103,7 +104,7 @@ class ShareLocationPresenterTest {
liveLocationShareManager = liveLocationShareManager,
liveLocationStore = liveLocationStore,
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = customMapTilerConfigProvider,
locationService = locationService,
)
@Test
@@ -118,26 +119,24 @@ class ShareLocationPresenterTest {
val shareLocationPresenter = createShareLocationPresenter()
shareLocationPresenter.test {
val state = awaitFirstItem()
assertThat(state.customMapTilerConfig.isLoading()).isFalse()
assertThat(state.mapTilerConfig).isNotNull()
assertThat(state.trackUserLocation).isTrue()
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
}
}
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
fun `present - mapTilerConfig is provided by the location service`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShareLocationPresenter(
client = FakeMatrixClient(
sessionId = A_USER_ID,
),
customMapTilerConfigProvider = { Result.success(mapTilerConfig) }
locationService = FakeLocationService(mapTilerConfig = mapTilerConfig),
)
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig)
cancelAndIgnoreRemainingEvents()
}
}
@@ -18,6 +18,7 @@ import io.element.android.features.location.impl.common.FakeUserLocationStateFac
import io.element.android.features.location.impl.common.actions.FakeLocationActions
import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
import io.element.android.features.location.test.FakeLocationService
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.test.core.aBuildMeta
@@ -51,7 +52,7 @@ class DefaultShowLocationEntryPointTest {
joinedRoom = joinedRoom,
liveLocationShareManager = FakeActiveLiveLocationShareManager(),
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = { Result.success(null) },
locationService = FakeLocationService(),
)
},
analyticsService = FakeAnalyticsService(),
@@ -12,9 +12,9 @@ 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.api.remoteconfig.CustomMapTilerConfigProvider
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.api.ShowLocationMode
import io.element.android.features.location.impl.aPermissionsState
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
@@ -24,6 +24,7 @@ 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.location.test.FakeLocationService
import io.element.android.libraries.dateformatter.test.FakeDateFormatter
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.room.JoinedRoom
@@ -66,7 +67,7 @@ class ShowLocationPresenterTest {
locationActions: FakeLocationActions = fakeLocationActions,
joinedRoom: JoinedRoom = FakeJoinedRoom(),
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) },
locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()),
) = ShowLocationPresenter(
mode = mode,
permissionsPresenterFactory = { fakePermissionsPresenter },
@@ -77,18 +78,16 @@ class ShowLocationPresenterTest {
joinedRoom = joinedRoom,
liveLocationShareManager = liveLocationShareManager,
userLocationStateFactory = FakeUserLocationStateFactory(),
customMapTilerConfigProvider = customMapTilerConfigProvider,
locationService = locationService,
)
@Test
fun `present - non-null customMapStyleUrl`() = runTest {
fun `present - mapTilerConfig is provided by the location service`() = runTest {
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) })
shareLocationPresenter.test {
val state = awaitItem()
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
val finalState = awaitItem()
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
val showLocationPresenter = createShowLocationPresenter(locationService = FakeLocationService(mapTilerConfig = mapTilerConfig))
showLocationPresenter.test {
assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig)
cancelAndIgnoreRemainingEvents()
}
}
@@ -120,7 +119,7 @@ class ShowLocationPresenterTest {
fun `centers on user location`() = runTest {
fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted))
val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) })
val presenter = createShowLocationPresenter()
presenter.test {
val initialState = awaitItem()
@@ -407,9 +406,6 @@ 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)
+1
View File
@@ -16,6 +16,7 @@ android {
dependencies {
api(projects.features.location.api)
implementation(projects.features.enterprise.api)
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
implementation(projects.tests.testutils)
@@ -8,10 +8,21 @@
package io.element.android.features.location.test
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.LocationService
import kotlinx.coroutines.flow.MutableStateFlow
class FakeLocationService(
private val isServiceAvailable: Boolean = false,
mapTilerConfig: MapTilerConfig? = null,
) : LocationService {
override fun isServiceAvailable() = isServiceAvailable
constructor(isServiceAvailable: Boolean) : this(
MapTilerConfig(
apiKey = "test_api_key",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
).takeIf { isServiceAvailable }
)
override val mapTilerConfig = MutableStateFlow(mapTilerConfig)
}
@@ -56,6 +56,7 @@ class ConfirmAccountProviderNode(
ConfirmAccountProviderView(
state = state,
modifier = modifier,
onBackClick = ::navigateUp,
onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) },
@@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider<Co
)
}
private fun aConfirmAccountProviderState(
internal fun aConfirmAccountProviderState(
accountProviderInput: String = "matrix.org",
accountProviderSuggestion: String? = null,
isAccountCreation: Boolean = false,
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
@@ -50,12 +51,14 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags
@@ -71,6 +74,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable
fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onBackClick: () -> Unit,
onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit,
@@ -118,9 +122,15 @@ fun ConfirmAccountProviderView(
// Let the header and content scroll so the supporting text is not clipped by the footer when the
// keyboard is visible and vertical space is reduced.
isScrollable = true,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
header = {
IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp),
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource(
id = if (state.isAccountCreation) {
@@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview(
) = ElementPreview {
ConfirmAccountProviderView(
state = state,
onBackClick = {},
onOAuthDetails = {},
onNeedLoginPassword = {},
onLearnMoreClick = {},
@@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold(
)
}
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607
*/
@Composable
private fun AddOtherAccountScaffold(
loginView: @Composable () -> Unit,
@@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold(
) {
FlowStepPage(
modifier = modifier,
title = stringResource(CommonStrings.common_add_account),
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
title = stringResource(R.string.screen_onboarding_add_account_title),
subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle),
iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()),
buttons = { buttons() },
content = loginView,
onBackClick = onBackClick,
@@ -52,6 +52,8 @@
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
<string name="screen_onboarding_add_account_subtitle">"You can add multiple accounts to the app and switch between them easily."</string>
<string name="screen_onboarding_add_account_title">"Sign in to another account"</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_checking_account">"Checking account"</string>
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
@@ -0,0 +1,57 @@
/*
* 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.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class ConfirmAccountProviderViewTest : RobolectricTest() {
@Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<ConfirmAccountProviderEvent>(expectEvents = false)
ensureCalledOnce {
setConfirmAccountProviderView(
state = aConfirmAccountProviderState(
eventSink = eventSink,
),
onBackClick = it,
)
pressBack()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ConfirmAccountProviderView(
state = state,
onBackClick = onBackClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
)
}
}
}
@@ -776,7 +776,7 @@ class MessagesFlowNode(
assetType = event.content.assetType,
)
}
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() }
NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable().value }
} else {
displayVulkanNotSupportedError = true
null
@@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
import io.element.android.features.messages.impl.utils.TextPillificationHelper
import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
@@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.merge
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
@@ -145,6 +147,7 @@ class MessageComposerPresenter(
private val featureFlagService: FeatureFlagService,
private val contentScannerService: ContentScannerService,
private val contentValidationCache: EventContentValidationCache,
private val dispatchers: CoroutineDispatchers,
) : Presenter<MessageComposerState> {
@AssistedFactory
interface Factory {
@@ -184,31 +187,28 @@ class MessageComposerPresenter(
val cameraPermissionState = cameraPermissionPresenter.present()
val canShareLocation = remember { mutableStateOf(false) }
LaunchedEffect(Unit) {
canShareLocation.value = locationService.isServiceAvailable()
}
val canShareLocation by remember { locationService.isServiceAvailable() }.collectAsState()
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
.collectAsState(initial = false)
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
handlePickedMedia(uri, mimeType)
localCoroutineScope.handlePickedMedia(uri, mimeType)
}
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
handlePickedMediaList(uris)
localCoroutineScope.handlePickedMediaList(uris)
}
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
handlePickedMediaList(uris, sendAsFile = true)
localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true)
}
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
}
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Jpeg)
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg)
}
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
handlePickedMedia(uri, MimeTypes.Mp4)
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4)
}
val isFullScreen = rememberSaveable {
mutableStateOf(false)
@@ -286,17 +286,16 @@ class MessageComposerPresenter(
}
is MessageComposerEvent.SendUri -> {
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
sessionCoroutineScope.sendAttachment(
attachment = Attachment.Media(
localMedia = localMediaFactory.createFromUri(
uri = event.uri,
mimeType = null,
name = null,
formattedFileSize = null
),
),
inReplyToEventId = inReplyToEventId,
)
sessionCoroutineScope.launch {
val localMedia = createLocalMedia(
uri = event.uri,
mimeType = null,
)
sendAttachment(
attachment = Attachment.Media(localMedia),
inReplyToEventId = inReplyToEventId,
)
}
resetComposerModeAfterAttaching()
}
@@ -431,7 +430,7 @@ class MessageComposerPresenter(
isInThreadTimeline = isInThread,
showAttachmentSourcePicker = showAttachmentSourcePicker,
showTextFormatting = showTextFormatting,
canShareLocation = canShareLocation.value,
canShareLocation = canShareLocation,
suggestions = suggestions.toImmutableList(),
resolveMentionDisplay = resolveMentionDisplay,
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
@@ -640,17 +639,28 @@ class MessageComposerPresenter(
}
}
private fun handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) {
uri ?: return
val localMedia = localMediaFactory.createFromUri(
// Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread
private suspend fun createLocalMedia(
uri: Uri,
mimeType: String?,
) = withContext(dispatchers.io) {
localMediaFactory.createFromUri(
uri = uri,
mimeType = mimeType,
name = null,
formattedFileSize = null
formattedFileSize = null,
)
}
private fun CoroutineScope.handlePickedMedia(
uri: Uri?,
mimeType: String? = null,
sendAsFile: Boolean = false,
) = launch {
uri ?: return@launch
val localMedia = createLocalMedia(
uri = uri,
mimeType = mimeType,
)
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
@@ -659,7 +669,7 @@ class MessageComposerPresenter(
resetComposerModeAfterAttaching()
}
private fun handlePickedMediaList(
private fun CoroutineScope.handlePickedMediaList(
uris: List<Uri>,
sendAsFile: Boolean = false,
) {
@@ -668,19 +678,19 @@ class MessageComposerPresenter(
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return
}
val attachments = uris.map { uri ->
val localMedia = localMediaFactory.createFromUri(
uri = uri,
mimeType = null,
name = null,
formattedFileSize = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
launch {
val attachments = uris.map { uri ->
val localMedia = createLocalMedia(
uri = uri,
mimeType = null,
)
Attachment.Media(localMedia, sendAsFile = sendAsFile)
}.toImmutableList()
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
resetComposerModeAfterAttaching()
resetComposerModeAfterAttaching()
}
}
private fun resetComposerModeAfterAttaching() {
@@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest {
featureFlagService = featureFlagService,
contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
featureFlagService = featureFlagService,
contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply {
isTesting = true
showTextFormatting = isRichTextEditorEnabled
@@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
interface PreferencesEntryPoint : FeatureEntryPoint {
@@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin {
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToLinkNewDevice()
fun navigateToBugReport()
fun navigateToSecureBackup()
@@ -75,6 +75,7 @@ dependencies {
implementation(projects.features.analytics.api)
implementation(projects.features.enterprise.api)
implementation(projects.features.licenses.api)
implementation(projects.features.location.api)
implementation(projects.features.logout.api)
implementation(projects.features.deactivation.api)
implementation(projects.features.invite.api)
@@ -109,6 +110,7 @@ dependencies {
testImplementation(projects.features.enterprise.test)
testImplementation(projects.features.invite.test)
testImplementation(projects.features.licenses.test)
testImplementation(projects.features.location.test)
testImplementation(projects.features.lockscreen.test)
testImplementation(projects.features.logout.test)
testImplementation(projects.libraries.indicator.test)
@@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
@@ -141,6 +142,10 @@ class PreferencesFlowNode(
callback.navigateToAddAccount()
}
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
override fun navigateToAnalyticsSettings() {
backstack.push(NavTarget.AnalyticsSettings)
}
@@ -40,10 +40,7 @@ class PreferencesAccountPresenter(
@Composable
override fun present(): PreferencesAccountState {
val matrixUser = matrixClient.userProfile.collectAsState()
LaunchedEffect(Unit) {
// Force a refresh of the profile
matrixClient.getUserProfile()
}
// Note: no need to force a refresh of the profile here, PreferencesRootPresenter, which embeds this presenter, already does it.
val showLinkNewDevice by remember {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin)
}.collectAsState(initial = false)
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
import kotlin.time.Duration.Companion.seconds
@@ -76,6 +78,7 @@ class NotificationSettingsPresenter(
@SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
@@ -111,16 +114,19 @@ class NotificationSettingsPresenter(
}
// List of PushProvider -> Distributor
val distributors = remember {
pushService.getAvailablePushProviders()
.flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
// Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread
val distributors by produceState(emptyList<Pair<PushProvider, Distributor>>()) {
value = withContext(dispatchers.io) {
pushService.getAvailablePushProviders()
.flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
}
}
}
}
}
// List of Distributors
val availableDistributors = remember {
val availableDistributors = remember(distributors) {
distributors.map { it.second }.toImmutableList()
}
@@ -8,11 +8,8 @@
package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent
}
@@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -35,6 +36,7 @@ class PreferencesRootNode(
) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToAnalyticsSettings()
fun navigateToAbout()
fun navigateToDeveloperSettings()
@@ -72,6 +74,7 @@ class PreferencesRootNode(
modifier = modifier,
onBackClick = this::navigateUp,
onAddAccountClick = callback::navigateToAddAccount,
onSwitchToSession = callback::navigateToSession,
onOpenAnalytics = callback::navigateToAnalyticsSettings,
onOpenAbout = callback::navigateToAbout,
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
@@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
import dev.zacsweers.metro.Inject
import io.element.android.compound.theme.Theme
import io.element.android.compound.theme.mapToTheme
import io.element.android.features.location.api.LocationService
import io.element.android.features.preferences.impl.account.PreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.UserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
@@ -47,6 +48,7 @@ class PreferencesRootPresenter(
private val snackbarDispatcher: SnackbarDispatcher,
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
private val featureFlagService: FeatureFlagService,
private val locationService: LocationService,
private val sessionStore: SessionStore,
private val appPreferencesStore: AppPreferencesStore,
private val userStatusPresenter: Presenter<UserStatusState>,
@@ -115,14 +117,13 @@ class PreferencesRootPresenter(
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
val isLocationServiceAvailable by remember { locationService.isServiceAvailable() }.collectAsState()
fun handleEvent(event: PreferencesRootEvent) {
when (event) {
is PreferencesRootEvent.OnVersionInfoClick -> {
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
}
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
sessionStore.setLatestSession(event.sessionId.value)
}
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
}
@@ -150,6 +151,7 @@ class PreferencesRootPresenter(
showAnalyticsSettings = hasAnalyticsProviders,
showDeveloperSettings = showDeveloperSettings,
showLabsItem = showLabsItem,
showLocationSettings = isLocationServiceAvailable,
snackbarMessage = snackbarMessage,
eventSink = ::handleEvent,
)
@@ -32,6 +32,7 @@ data class PreferencesRootState(
val showAnalyticsSettings: Boolean,
val showDeveloperSettings: Boolean,
val showLabsItem: Boolean,
val showLocationSettings: Boolean,
val snackbarMessage: SnackbarMessage?,
val eventSink: (PreferencesRootEvent) -> Unit,
)
@@ -53,6 +53,8 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
// Minimal state
aPreferencesRootState(userStatusState = null),
// Location sharing not available
aPreferencesRootState(showLocationSettings = false),
)
}
@@ -68,6 +70,7 @@ fun aPreferencesRootState(
showAnalyticsSettings: Boolean = false,
showDeveloperSettings: Boolean = false,
showLabsItem: Boolean = false,
showLocationSettings: Boolean = true,
userStatusState: UserStatusState? = aUserStatusState(),
snackbarMessage: SnackbarMessage? = null,
eventSink: (PreferencesRootEvent) -> Unit = {},
@@ -83,6 +86,7 @@ fun aPreferencesRootState(
showAnalyticsSettings = showAnalyticsSettings,
showDeveloperSettings = showDeveloperSettings,
showLabsItem = showLabsItem,
showLocationSettings = showLocationSettings,
userStatusState = userStatusState,
snackbarMessage = snackbarMessage,
eventSink = eventSink,
@@ -65,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
import io.element.android.libraries.matrix.ui.model.getAvatarData
@@ -79,6 +80,7 @@ fun PreferencesRootView(
emojiPickerRenderer: EmojiPickerRenderer,
onBackClick: () -> Unit,
onAddAccountClick: () -> Unit,
onSwitchToSession: (SessionId) -> Unit,
onOpenAnalytics: () -> Unit,
onOpenLockScreenSettings: () -> Unit,
onOpenAbout: () -> Unit,
@@ -124,6 +126,7 @@ fun PreferencesRootView(
MultiAccountSection(
state = state,
onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
)
}
HorizontalDivider(
@@ -208,6 +211,7 @@ private fun ColumnScope.UserStatusSection(
private fun ColumnScope.MultiAccountSection(
state: PreferencesRootState,
onAddAccountClick: () -> Unit,
onSwitchToSession: (SessionId) -> Unit,
) {
HorizontalDivider(
thickness = 8.dp,
@@ -275,7 +279,7 @@ private fun ColumnScope.MultiAccountSection(
MatrixUserRow(
modifier = Modifier
.clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId))
onSwitchToSession(matrixUser.userId)
}
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = matrixUser,
@@ -330,11 +334,13 @@ private fun AppSettingsSection(
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())),
onClick = onOpenLockScreenSettings,
)
ListItem(
content = { Text(stringResource(id = CommonStrings.common_location_sharing)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
onClick = onOpenLocationSettings,
)
if (state.showLocationSettings) {
ListItem(
content = { Text(stringResource(id = CommonStrings.common_location_sharing)) },
leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())),
onClick = onOpenLocationSettings,
)
}
}
}
@@ -425,6 +431,7 @@ private fun ContentToPreview(state: PreferencesRootState) {
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = {},
onAddAccountClick = {},
onSwitchToSession = {},
onOpenAnalytics = {},
onOpenDeveloperSettings = {},
onOpenLocationSettings = {},
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
@@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest {
}
val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError()
@@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
@@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest {
stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope,
featureFlagService = featureFlagService,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
)
}
}
@@ -13,6 +13,8 @@ package io.element.android.features.preferences.impl.root
import app.cash.turbine.ReceiveTurbine
import com.google.common.truth.Truth.assertThat
import io.element.android.compound.theme.Theme
import io.element.android.features.location.api.LocationService
import io.element.android.features.location.test.FakeLocationService
import io.element.android.features.preferences.impl.account.aPreferencesAccountState
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
@@ -37,8 +39,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.CoroutineScope
@@ -78,10 +78,21 @@ class PreferencesRootPresenterTest {
assertThat(loadedState.showAnalyticsSettings).isFalse()
assertThat(loadedState.showDeveloperSettings).isTrue()
assertThat(loadedState.showLabsItem).isFalse()
assertThat(loadedState.showLocationSettings).isTrue()
assertThat(loadedState.snackbarMessage).isNull()
}
}
@Test
fun `present - location settings are hidden when the location service is not available`() = runTest {
createPreferencesRootPresenter(
locationService = FakeLocationService(isServiceAvailable = false),
).test {
assertThat(awaitItem().showLocationSettings).isFalse()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - user status state is null when the server does not support it`() = runTest {
createPreferencesRootPresenter(
@@ -166,24 +177,6 @@ class PreferencesRootPresenterTest {
}
}
@Test
fun `present - switch session invoke method on the session store`() = runTest {
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
val sessionStore = InMemorySessionStore(
initialList = listOf(
aSessionData(sessionId = A_SESSION_ID.value),
aSessionData(sessionId = A_SESSION_ID_2.value),
),
setLatestSessionResult = setLatestSessionResult,
)
createPreferencesRootPresenter(sessionStore = sessionStore).test {
val loadedState = awaitFirstItem()
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
setLatestSessionResult.assertions().isCalledOnce()
.with(value(A_SESSION_ID_2.value))
}
}
@Test
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
createPreferencesRootPresenter(
@@ -291,6 +284,7 @@ class PreferencesRootPresenterTest {
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
sessionStore: SessionStore = InMemorySessionStore(),
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
locationService: LocationService = FakeLocationService(isServiceAvailable = true),
) = PreferencesRootPresenter(
matrixClient = matrixClient,
analyticsService = FakeAnalyticsService(),
@@ -298,6 +292,7 @@ class PreferencesRootPresenterTest {
snackbarDispatcher = SnackbarDispatcher(),
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
featureFlagService = featureFlagService,
locationService = locationService,
sessionStore = sessionStore,
appPreferencesStore = appPreferencesStore,
userStatusPresenter = { aUserStatusState() },
@@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.preferences.impl.R
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.matrix.ui.components.aMatrixUser
@@ -69,23 +70,25 @@ class PreferencesRootViewTest : RobolectricTest() {
}
@Test
fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
ensureCalledOnceWithParam(A_USER_ID_2) { callback ->
setView(
aPreferencesRootState(
isMultiAccountEnabled = true,
isOtherAccountsSectionExpanded = true,
otherSessions = listOf(
aMatrixUser(
id = A_USER_ID_2.value,
displayName = "Bob",
)
),
eventSink = eventsRecorder,
),
eventSink = eventsRecorder,
),
)
onNodeWithText("Bob").performClick()
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
onSwitchToSession = callback,
)
onNodeWithText("Bob").performClick()
}
}
@Test
@@ -395,6 +398,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
state: PreferencesRootState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(),
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
onOpenAbout: () -> Unit = EnsureNeverCalled(),
@@ -418,6 +422,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = onBackClick,
onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
onOpenAnalytics = onOpenAnalytics,
onOpenLockScreenSettings = onOpenLockScreenSettings,
onOpenAbout = onOpenAbout,
@@ -2,6 +2,7 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
<string name="screen_identity_confirmation_sign_out">"Sign out"</string>
<string name="screen_identity_confirmation_subtitle">"Choose how to verify to set up secure messaging."</string>
<string name="screen_identity_confirmation_title">"Confirm your digital identity"</string>
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
+4 -4
View File
@@ -41,7 +41,7 @@ coil = "3.6.3"
# There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs.
# When upgrading this version, check state restoration still works fine.
appyx = "1.7.1"
sqldelight = "2.4.0"
sqldelight = "2.4.1"
wysiwyg = "2.42.1"
telephoto = "0.19.0"
maplibre-compose = "0.19.0"
@@ -62,7 +62,7 @@ detekt_compose_rules = "0.4.28"
# See https://github.com/pinterest/ktlint/releases/
ktlint = "1.8.0"
androidx-test-ext-junit = "1.3.0"
kover = "0.9.9"
kover = "0.9.11"
# The native MatrixRTC call component, https://github.com/element-hq/element-call-android
element_call = "0.1.0-rc.7"
@@ -186,7 +186,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version
# it as a plain `require`: without this, a library built against a newer SDK would silently drag this app
# onto it. With it, this pin always wins, and a library that needs something newer fails at configuration
# time naming both versions instead of at runtime with NoSuchMethodError.
matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.05-nightly" } }
matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.12-nightly" } }
rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0"
# Others
@@ -239,7 +239,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0"
metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" }
# Element Call
element_call_embedded = "io.element.android:element-call-embedded:0.27.0"
element_call_embedded = "io.element.android:element-call-embedded:0.28.0"
# The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together.
element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" }
element_call_api = { module = "io.element.android:element-call-api" }
@@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long {
Timber.v("Get size of ${it.absolutePath}")
true
}
.sumOf { it.length() }
// Only sum the actual size of files, not directories, to avoid double counting or worse,
// since the docs mention that `length()` for directories is not reliable.
// For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder,
// which is a common block size on many file systems.
.sumOf { if (it.isFile) it.length() else 4096L }
}
@@ -15,6 +15,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitLongPressOrCancellation
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
@@ -53,8 +55,7 @@ import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard
@@ -565,34 +566,18 @@ private fun Modifier.linkTapHandler(
val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture
down.consume()
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
// Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that.
// If we did, it's a tap, otherwise it's a long press.
// This warning is a false positive.
@Suppress("KotlinConstantConditions")
val isLongPress = try {
withTimeout(longPressTimeout) {
var receivedUp = false
while (!receivedUp) {
val event = awaitPointerEvent()
val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() }
if (up != null) {
receivedUp = true
up.consume()
}
}
!receivedUp
}
} catch (_: PointerEventTimeoutCancellationException) {
true
}
if (isLongPress) {
// Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event.
if (awaitLongPressOrCancellation(down.id) != null) {
context.onLinkLongClick(url, urlText)
// Swallow the rest of the gesture so parents don't react to the eventual release.
waitForUpOrCancellation()?.consume()
} else {
context.onLinkClick(url, urlText)
// Either a tap (the pointer was released) or a drag (the user probably wanted to scroll).
val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() }
if (up != null) {
up.consume()
context.onLinkClick(url, urlText)
}
}
}
}
@@ -410,7 +410,7 @@ interface MatrixClient : ClientUrlContentFetcher {
/**
* Deactivates the account on the homeserver, then destroys this client and deletes every local trace of the session.
* The password is only sent if the homeserver asks for re-authentication, and on failure the session is left untouched.
* On failure the session is left untouched.
*
* @param password the account password, required to confirm the deactivation.
* @param eraseData whether the homeserver should also erase the messages the user sent.
@@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher {
/** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
/**
* Returns the total number of unread notifications across all rooms, as currently known by the client.
* This is a snapshot and may be out of date if the sync is running.
*/
fun totalUnreadCount(): Result<Long>
}
/**
@@ -123,6 +123,11 @@ interface EncryptionService {
* A handle to reset the user's identity.
*/
sealed interface IdentityResetHandle {
/**
* The value of the session key given by the homeserver.
*/
val session: String
/**
* Cancel the reset process and drops the existing handle in the SDK.
*/
@@ -8,6 +8,7 @@
package io.element.android.libraries.matrix.api.sync
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.StateFlow
/**
@@ -37,4 +38,10 @@ interface SyncService {
* This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online.
*/
val isOnline: StateFlow<Boolean>
/**
* Flow that will emit every time the sync has received a response from the server, even if it was empty.
* Useful for triggering UI updates when the sync is running.
*/
val heartBeat: Flow<Unit>
}
+5 -1
View File
@@ -59,7 +59,11 @@ dependencies {
implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
implementation(libs.element.call.api)
implementation(projects.features.callnative.api)
implementation(libs.element.call.matrix)
implementation(libs.element.call.matrix) {
if (usesLocalRustSdk) {
exclude(group = "org.matrix.rustcomponents", module = "sdk-android")
}
}
implementation(projects.appconfig)
implementation(projects.features.enterprise.api)
@@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull
@@ -127,8 +128,6 @@ import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext
import kotlinx.coroutines.withTimeout
import org.matrix.rustcomponents.sdk.AuthData
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
import org.matrix.rustcomponents.sdk.BeaconInfoListener
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate
import org.matrix.rustcomponents.sdk.Client
@@ -322,7 +321,7 @@ class RustMatrixClient(
override fun onUpdate(profile: UserProfile) {
val matrixUser = profile.map()
_userProfile.tryEmit(matrixUser)
sessionCoroutineScope.launch {
sessionCoroutineScope.launch(sessionDispatcher) {
storeUserProfile(matrixUser)
}
}
@@ -530,7 +529,7 @@ class RustMatrixClient(
* Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar,
* so that the avatar can be rendered without having to use this client.
*/
private suspend fun storeUserProfile(matrixUser: MatrixUser) {
private suspend fun storeUserProfile(matrixUser: MatrixUser) = withContext(sessionDispatcher) {
val avatarUrl = matrixUser.avatarUrl
val storedSession = sessionStore.getSession(sessionId.value)
val avatarData = when {
@@ -794,33 +793,10 @@ class RustMatrixClient(
clientDelegateTaskHandle?.cancelAndDestroy()
clientDelegateTaskHandle = null
runCatchingExceptions {
// First call without AuthData, should fail
val firstAttempt = runCatchingExceptions {
innerClient.deactivateAccount(
authData = null,
eraseData = eraseData,
)
}
if (firstAttempt.isFailure) {
Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again")
// This is expected, try again with the password
runCatchingExceptions {
innerClient.deactivateAccount(
authData = AuthData.Password(
passwordDetails = AuthDataPasswordDetails(
identifier = sessionId.value,
password = password,
),
),
eraseData = eraseData,
)
}.onFailure {
Timber.e(it, "Failed to deactivate account")
// If the deactivation failed we need to restore the delegate
clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate)
throw it
}
}
innerClient.deactivateAccount(
password = password,
eraseData = eraseData,
)
destroy()
deleteSessionDirectory()
sessionStore.removeSession(sessionId.value)
@@ -1024,6 +1000,10 @@ class RustMatrixClient(
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
}
override fun totalUnreadCount(): Result<Long> = runCatchingExceptions {
innerClient.totalUnreadNotifications().toLong()
}
private fun scheduleBackgroundSearchBackfill() {
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch)
.distinctUntilChanged()
@@ -1034,6 +1014,8 @@ class RustMatrixClient(
searchBackfillService.cancelPeriodicSearchBackfill()
}
}
// Checking the pending work is a blocking call, do not do it on the main thread
.flowOn(sessionDispatcher)
.launchIn(sessionCoroutineScope)
}
}
@@ -345,7 +345,14 @@ class RustMatrixAuthenticationService(
it.scan(
qrCodeData = qrCodeData.rustQrCodeData,
progressListener = progressListener,
)
).use { task ->
try {
task.wait()
} catch (e: CancellationException) {
task.cancel()
throw e
}
}
}
// Ensure that the user is not already logged in with the same account
ensureNotAlreadyLoggedIn(client)
@@ -20,14 +20,14 @@ import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType
object RustIdentityResetHandleFactory {
fun create(
userId: UserId,
identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?
identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?,
): Result<IdentityResetHandle?> {
return runCatchingExceptions {
identityResetHandle?.let {
when (val authType = identityResetHandle.authType()) {
is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl)
is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl, authType.info.session)
// User interactive authentication (user + password)
CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle)
is CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle, authType.info.session)
}
}
}
@@ -37,9 +37,12 @@ object RustIdentityResetHandleFactory {
class RustPasswordIdentityResetHandle(
private val userId: UserId,
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
override val session: String,
) : IdentityPasswordResetHandle {
override suspend fun resetPassword(password: String): Result<Unit> {
return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) }
return runCatchingExceptions {
identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password, session)))
}
}
override suspend fun cancel() {
@@ -50,9 +53,10 @@ class RustPasswordIdentityResetHandle(
class RustIdentityOAuthResetHandle(
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
override val url: String,
override val session: String,
) : IdentityOAuthResetHandle {
override suspend fun resetOAuth(): Result<Unit> {
return runCatchingExceptions { identityResetHandle.reset(null) }
return runCatchingExceptions { identityResetHandle.reset(AuthData.OAuth(session)) }
}
override suspend fun cancel() {
@@ -12,6 +12,7 @@ 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 io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -53,8 +54,15 @@ class RustLinkDesktopHandler(
}
}
}
)
// We emit Done in case the progress listener was deallocated before scan() sent the Done
).use { task ->
try {
task.wait()
} catch (e: CancellationException) {
task.cancel()
throw e
}
}
// We emit Done in case the progress listener was deallocated before the task sent the Done
_linkDesktopStep.emit(LinkDesktopStep.Done)
} catch (e: QrCodeDecodeException) {
Timber.tag(tag.value).w(e, "Invalid QR code scanned")
@@ -11,6 +11,7 @@ import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler
import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep
import io.element.android.libraries.matrix.api.logs.LoggerTags
import kotlinx.coroutines.CancellationException
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
@@ -48,8 +49,15 @@ class RustLinkMobileHandler(
}
}
}
)
// We emit Done in case the progress listener was deallocated before generate() sent the Done
).use { task ->
try {
task.wait()
} catch (e: CancellationException) {
task.cancel()
throw e
}
}
// We emit Done in case the progress listener was deallocated before the task sent the Done
_linkMobileStep.emit(LinkMobileStep.Done)
} catch (e: HumanQrGrantLoginException.Expired) {
// Note: the SDK does not return this error, but is returning `.NotFound` instead when the QR code expires. We catch both just in case.
@@ -158,6 +158,7 @@ class JoinedRustRoom(
inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope,
roomDispatcher = roomDispatcher,
)
override val syncUpdateFlow = flow {
@@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.map
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart
@@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
class RustThreadsListService(
private val inner: InnerThreadListService,
private val roomCoroutineScope: CoroutineScope,
private val roomDispatcher: CoroutineDispatcher,
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
) : ThreadsListService {
private var itemSubscriptionJob: Job? = null
@@ -63,6 +66,8 @@ class RustThreadsListService(
updated.apply(diff, contentMapper)
items.value = updated
}
// Fetching and mapping the items can be costly, do not do it on the main thread
.flowOn(roomDispatcher)
.launchIn(roomCoroutineScope)
}
@@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.sync.SyncState
import io.element.android.libraries.matrix.impl.roomlist.stateFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.NonCancellable
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.SharingStarted
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.stateIn
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.RoomListServiceState
import org.matrix.rustcomponents.sdk.SyncServiceState
import timber.log.Timber
import java.util.concurrent.atomic.AtomicBoolean
@@ -78,4 +82,10 @@ class RustSyncService(
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }
// TODO actually use a heartbeat mechanism from the SDK, not simulate it with the room list service
override val heartBeat: Flow<Unit> = inner.roomListService()
.stateFlow()
.filter { it == RoomListServiceState.RUNNING }
.map {}
}
@@ -33,7 +33,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.matrix.rustcomponents.sdk.Client
@@ -41,6 +43,7 @@ import org.matrix.rustcomponents.sdk.ClientDelegate
import java.io.File
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
@OptIn(ExperimentalCoroutinesApi::class)
class RustMatrixClientFactoryTest {
@Test
fun test() = runTest {
@@ -51,6 +54,7 @@ class RustMatrixClientFactoryTest {
val result = sut.create(aSessionData())
assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org"))
runCurrent()
// 2 workers are scheduled: one for the search backfill and one for the vacuum
scheduleWorkersLambda.assertions().isCalledExactly(2)
result.destroy()
@@ -6,6 +6,8 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.libraries.matrix.impl.auth
import com.google.common.truth.Truth.assertThat
@@ -36,7 +38,11 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.awaitCancellation
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@@ -171,6 +177,39 @@ class RustMatrixAuthenticationServiceTest {
closeResult.assertions().isCalledOnce()
}
@Test
fun `loginWithQrCode cancels the login task when it is cancelled`() = runTest {
val cancelResult = lambdaRecorder<Unit> {}
val sut = createRustMatrixAuthenticationService(
clientBuilderProvider = FakeClientBuilderProvider(
provideResult = {
FakeFfiClientBuilder(
buildResult = {
FakeFfiClient(
newLoginWithQrCodeHandlerResult = {
FakeFfiLoginWithQrCodeHandler(
scanResult = { awaitCancellation() },
cancelResult = cancelResult,
)
},
closeResult = {},
)
},
)
},
),
)
val job = backgroundScope.launch {
sut.loginWithQrCode(aSdkQrCodeLoginData()) {}
}
runCurrent()
cancelResult.assertions().isNeverCalled()
job.cancel()
runCurrent()
cancelResult.assertions().isCalledOnce()
}
private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData(
FakeFfiQrCodeData(
baseUrlResult = { A_HOMESERVER_URL },

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