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

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