diff --git a/.github/workflows/build.yml b/.github/workflows/build.yml index e997e1cbdd..b1ba9dd010 100644 --- a/.github/workflows/build.yml +++ b/.github/workflows/build.yml @@ -74,7 +74,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-debug path: | diff --git a/.github/workflows/build_enterprise.yml b/.github/workflows/build_enterprise.yml index 294b94a872..589f0a329d 100644 --- a/.github/workflows/build_enterprise.yml +++ b/.github/workflows/build_enterprise.yml @@ -79,7 +79,7 @@ jobs: run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES - name: Upload debug Enterprise APKs if: ${{ matrix.variant == 'debug' }} - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-enterprise-debug path: | diff --git a/.github/workflows/maestro-local.yml b/.github/workflows/maestro-local.yml index 89413a4d2d..25aa0f6eac 100644 --- a/.github/workflows/maestro-local.yml +++ b/.github/workflows/maestro-local.yml @@ -63,7 +63,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} - name: Upload APK as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-apk-maestro path: | @@ -138,7 +138,7 @@ jobs: pip install requests python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement - name: Upload test results - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: test-results path: | diff --git a/.github/workflows/nightlyReports.yml b/.github/workflows/nightlyReports.yml index 88b267acc4..5c3e33f577 100644 --- a/.github/workflows/nightlyReports.yml +++ b/.github/workflows/nightlyReports.yml @@ -48,17 +48,17 @@ jobs: cache-read-only: false - name: ⚙️ Run unit tests, debug and release - run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue - name: 📸 Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: 📈 Generate kover report and verify coverage run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES - name: ✅ Upload kover report if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-results path: | @@ -92,7 +92,7 @@ jobs: run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES - name: Upload dependency analysis if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: dependency-analysis path: build/reports/dependency-check-report.html diff --git a/.github/workflows/quality.yml b/.github/workflows/quality.yml index 006b0101f6..1fa443c0f5 100644 --- a/.github/workflows/quality.yml +++ b/.github/workflows/quality.yml @@ -120,7 +120,7 @@ jobs: run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: konsist-report path: | @@ -199,7 +199,7 @@ jobs: run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: linting-report path: | @@ -237,10 +237,10 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Detekt - run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon + run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: detekt-report path: | @@ -278,10 +278,10 @@ jobs: with: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: Run Ktlint check - run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue - name: Upload reports if: always() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: ktlint-report path: | diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml index cd1bba77f4..316fcc27ff 100644 --- a/.github/workflows/release.yml +++ b/.github/workflows/release.yml @@ -59,7 +59,7 @@ jobs: ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload bundle as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-gplay-bundle-unsigned path: | @@ -105,7 +105,7 @@ jobs: ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES - name: Upload apks as artifact - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: elementx-app-fdroid-apks-unsigned path: | diff --git a/.github/workflows/tests.yml b/.github/workflows/tests.yml index b54741c566..6222ac024c 100644 --- a/.github/workflows/tests.yml +++ b/.github/workflows/tests.yml @@ -73,17 +73,17 @@ jobs: cache-read-only: ${{ github.ref != 'refs/heads/develop' }} - name: ⚙️ Run unit tests - run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Run screenshot tests - run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES + run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue - name: ⚙️ Generate kover coverage report run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES - name: 🚫 Upload kover failed coverage reports if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: kover-error-report path: | @@ -95,7 +95,7 @@ jobs: - name: 🚫 Upload test results on error if: failure() - uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 + uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2 with: name: tests-and-screenshot-tests-results path: | diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt index ee8398186d..90aaeadefc 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInAppScopeFlowNode.kt @@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode import io.element.android.libraries.architecture.inputs import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.matrix.api.MatrixClient +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import kotlinx.parcelize.Parcelize @@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } return createNode(buildContext, listOf(callback)) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt index ac08795f67..7c944764fd 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInEventProcessor.kt @@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.roomlist.RoomListService +import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.ui.strings.CommonStrings @@ -34,7 +34,7 @@ class LoggedInEventProcessor( @Assisted private val snackbarDispatcher: SnackbarDispatcher, private val roomMembershipObserver: RoomMembershipObserver, private val sessionId: SessionId, - private val roomListService: RoomListService, + private val syncService: SyncService, private val notificationCleaner: NotificationCleaner, private val dispatchers: CoroutineDispatchers ) { @@ -73,9 +73,8 @@ class LoggedInEventProcessor( } .launchIn(childScope) - // Use the room list service state as a 'heartbeat' update to check existing notifications - roomListService.state - .filter { it == RoomListService.State.Running } + // Use the sync service heartbeat as a 'heartbeat' update to check existing notifications + syncService.heartBeat .onEach { notificationCleaner.clearReadRoomsNotifications(sessionId) } diff --git a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt index fed49bdeab..27d727afa9 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/LoggedInFlowNode.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.runtime.collectAsState import androidx.compose.runtime.getValue -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope @@ -52,13 +51,13 @@ import io.element.android.features.callnative.api.NativeCallHost import io.element.android.features.createroom.api.CreateRoomEntryPoint import io.element.android.features.enterprise.api.EnterpriseService import io.element.android.features.enterprise.api.SessionEnterpriseService -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.ftue.api.FtueEntryPoint import io.element.android.features.ftue.api.state.FtueService import io.element.android.features.ftue.api.state.FtueState import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint import io.element.android.features.location.api.LocalMapTilerConfig +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.networkmonitor.api.NetworkMonitor import io.element.android.features.networkmonitor.api.NetworkStatus @@ -86,6 +85,7 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomIdOrAlias +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias import io.element.android.libraries.matrix.api.permalink.PermalinkData @@ -153,7 +153,7 @@ class LoggedInFlowNode( private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher, private val createRoomEntryPoint: CreateRoomEntryPoint, private val activeLiveLocationShareManager: ActiveLiveLocationShareManager, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory, ) : BaseFlowNode( backstack = BackStack( @@ -170,6 +170,7 @@ class LoggedInFlowNode( interface Callback : Plugin { fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } private val callback: Callback = callback() @@ -357,6 +358,10 @@ class LoggedInFlowNode( override fun navigateToAddAccount() { callback.navigateToAddAccount() } + + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } } homeEntryPoint.createNode( parentNode = this, @@ -449,6 +454,10 @@ class LoggedInFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToLinkNewDevice() { backstack.push(NavTarget.LinkNewDevice) } @@ -636,10 +645,7 @@ class LoggedInFlowNode( enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId) }.collectAsState(SemanticColorsLightDark.default) - val currentMapTilerConfig = LocalMapTilerConfig.current - val updatedMapTilerConfig by produceState(currentMapTilerConfig) { - value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() ElementThemeApp( appPreferencesStore = appPreferencesStore, @@ -648,7 +654,7 @@ class LoggedInFlowNode( compoundDark = colors.dark, buildMeta = buildMeta, ) { - CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) { + CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) { val isOnline by syncService.isOnline.collectAsState() // Outside the connectivity indicator so the two strips stack rather than fight: an // offline banner during a call belongs under the call bar, not over it. diff --git a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt index 673bdc1388..5fb7996a0e 100644 --- a/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt +++ b/appnav/src/main/kotlin/io/element/android/appnav/RootFlowNode.kt @@ -416,6 +416,12 @@ class RootFlowNode( override fun navigateToAddAccount() { backstack.push(NavTarget.NotLoggedInFlow(null)) } + + override fun navigateToSession(sessionId: SessionId) { + lifecycleScope.launch { + sessionStore.setLatestSession(sessionId.value) + } + } } val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap) val buildContext = if (savedNavState != null) { diff --git a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt index 4d5cfe06f3..fd1f1b9d11 100644 --- a/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt +++ b/appnav/src/test/kotlin/io/element/android/appnav/LoggedInEventProcessorTest.kt @@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.CurrentUserMembership import io.element.android.libraries.matrix.api.room.RoomMembershipObserver -import io.element.android.libraries.matrix.api.roomlist.RoomListService import io.element.android.libraries.matrix.test.A_ROOM_ID import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService +import io.element.android.libraries.matrix.test.sync.FakeSyncService import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -155,24 +154,24 @@ class LoggedInEventProcessorTest { } @Test - fun `test notifications are cleared when room list service state is running`() = runTest { + fun `test notifications are cleared when sync heartbeat is emitted`() = runTest { val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> } val snackbarDispatcher = SnackbarDispatcher() val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda) - val roomListService = FakeRoomListService() + val syncService = FakeSyncService() val loggedInEventProcessor = createLoggedInEventProcessor( snackbarDispatcher = snackbarDispatcher, notificationCleaner = notificationCleaner, - roomListService = roomListService, + syncService = syncService, ) loggedInEventProcessor.observeEvents(backgroundScope) testScheduler.runCurrent() - // Simulate the room list service state changing to Running - roomListService.postState(RoomListService.State.Running) + // Simulate a sync heartbeat + syncService.emitHeartBeat() testScheduler.runCurrent() @@ -184,13 +183,13 @@ class LoggedInEventProcessorTest { snackbarDispatcher: SnackbarDispatcher, roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), sessionId: SessionId = A_SESSION_ID, - roomListService: FakeRoomListService = FakeRoomListService(), + syncService: FakeSyncService = FakeSyncService(), notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(), ) = LoggedInEventProcessor( snackbarDispatcher = snackbarDispatcher, roomMembershipObserver = roomMembershipObserver, sessionId = sessionId, - roomListService = roomListService, + syncService = syncService, notificationCleaner = notificationCleaner, dispatchers = testCoroutineDispatchers(), ) diff --git a/features/ftue/impl/build.gradle.kts b/features/ftue/impl/build.gradle.kts index f6d1970a5a..1ef41bcae2 100644 --- a/features/ftue/impl/build.gradle.kts +++ b/features/ftue/impl/build.gradle.kts @@ -32,6 +32,7 @@ dependencies { implementation(projects.libraries.core) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) + implementation(projects.libraries.matrixui) implementation(projects.libraries.designsystem) implementation(projects.libraries.preferences.api) implementation(projects.libraries.uiCommon) diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt index f19c323d52..7fd2770fb3 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModePresenter.kt @@ -20,16 +20,19 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.EncryptionService import io.element.android.libraries.matrix.api.encryption.RecoveryState @Inject class ChooseSelfVerificationModePresenter( + private val matrixClient: MatrixClient, private val encryptionService: EncryptionService, private val directLogoutPresenter: Presenter, ) : Presenter { @Composable override fun present(): ChooseSelfVerificationModeState { + val currentUser by matrixClient.userProfile.collectAsState() val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState() val canUseRecoveryKey by produceState>(AsyncData.Uninitialized) { encryptionService.recoveryStateStateFlow @@ -72,6 +75,7 @@ class ChooseSelfVerificationModePresenter( } return ChooseSelfVerificationModeState( + currentUser = currentUser, buttonsState = buttonsState, directLogoutState = directLogoutState, eventSink = ::handleEvent, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt index 0512636fca..98dbc340e4 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeState.kt @@ -10,8 +10,10 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.user.MatrixUser data class ChooseSelfVerificationModeState( + val currentUser: MatrixUser, val buttonsState: AsyncData, val directLogoutState: DirectLogoutState, val eventSink: (ChooseSelfVerificationModeEvent) -> Unit, diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt index 1af6786289..34125cb50b 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeStatePreviewParam.kt @@ -11,6 +11,8 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.architecture.AsyncData +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.ui.components.aMatrixUser class ChooseSelfVerificationModeStatePreviewParam : PreviewParameterProvider { @@ -43,10 +45,13 @@ class ChooseSelfVerificationModeStatePreviewParam : fun aChooseSelfVerificationModeState( buttonsState: AsyncData = AsyncData.Success(aButtonsState()), + currentUser: MatrixUser = aMatrixUser(), + eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {}, ) = ChooseSelfVerificationModeState( + currentUser = currentUser, buttonsState = buttonsState, directLogoutState = aDirectLogoutState(), - eventSink = {}, + eventSink = eventSink, ) fun aButtonsState( diff --git a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt index f6f1094d1b..d58f83200c 100644 --- a/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt +++ b/features/ftue/impl/src/main/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSelfVerificationModeView.kt @@ -12,6 +12,7 @@ import androidx.activity.compose.BackHandler import androidx.activity.compose.LocalActivity import androidx.compose.foundation.clickable import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Row import androidx.compose.foundation.layout.fillMaxWidth import androidx.compose.foundation.layout.padding @@ -35,12 +36,19 @@ import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.theme.components.Button +import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.OutlinedButton import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.TextButton import io.element.android.libraries.designsystem.theme.components.TopAppBar +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode +import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState import io.element.android.libraries.ui.strings.CommonStrings +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813 + */ @Composable fun ChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, @@ -61,7 +69,7 @@ fun ChooseSelfVerificationModeView( title = {}, actions = { TextButton( - text = stringResource(CommonStrings.action_signout), + text = stringResource(R.string.screen_identity_confirmation_sign_out), onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) } ) } @@ -75,6 +83,7 @@ fun ChooseSelfVerificationModeView( subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle) ) }, + isScrollable = true, footer = { ChooseSelfVerificationModeButtons( state = state, @@ -84,20 +93,31 @@ fun ChooseSelfVerificationModeView( ) } ) { - Row( + Column( modifier = Modifier.fillMaxWidth(), - horizontalArrangement = Arrangement.Center, + verticalArrangement = Arrangement.spacedBy(24.dp), ) { - Text( - modifier = Modifier - .clickable(onClick = onLearnMore) - .padding(vertical = 4.dp, horizontal = 16.dp) - .semantics { - // Note: there is no Role.Link, so we use Role.Button for better accessibility support - role = Role.Button - }, - text = stringResource(CommonStrings.action_learn_more), - style = ElementTheme.typography.fontBodyLgMedium + Row( + modifier = Modifier.fillMaxWidth(), + horizontalArrangement = Arrangement.Center, + ) { + Text( + modifier = Modifier + .clickable(onClick = onLearnMore) + .padding(vertical = 4.dp, horizontal = 16.dp) + .semantics { + // Note: there is no Role.Link, so we use Role.Button for better accessibility support + role = Role.Button + }, + text = stringResource(CommonStrings.action_learn_more), + style = ElementTheme.typography.fontBodyLgMedium + ) + } + AccountInfoCard( + state = AccountInfoCardState( + matrixUser = state.currentUser, + mode = AccountInfoCardMode.Simple, + ), ) } } @@ -125,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons( modifier = Modifier.fillMaxWidth(), text = stringResource(R.string.screen_identity_use_another_device), onClick = onUseAnotherDevice, + leadingIcon = IconSource.Vector(CompoundIcons.Devices()), ) } if (state.buttonsState.data.canUseRecoveryKey) { diff --git a/features/ftue/impl/src/main/res/values/localazy.xml b/features/ftue/impl/src/main/res/values/localazy.xml index 8e208278a0..6cd117eb59 100644 --- a/features/ftue/impl/src/main/res/values/localazy.xml +++ b/features/ftue/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt index 8ceb683993..78f257daf0 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModePresenterTest.kt @@ -14,7 +14,13 @@ import io.element.android.features.logout.api.direct.DirectLogoutState import io.element.android.features.logout.api.direct.aDirectLogoutState import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.encryption.RecoveryState +import io.element.android.libraries.matrix.api.user.MatrixUser +import io.element.android.libraries.matrix.test.AN_AVATAR_URL +import io.element.android.libraries.matrix.test.A_SESSION_ID +import io.element.android.libraries.matrix.test.A_USER_NAME +import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value @@ -34,6 +40,26 @@ class ChooseSessionVerificationModePresenterTest { } } + @Test + fun `present - current user is exposed`() = runTest { + val presenter = createPresenter( + matrixClient = FakeMatrixClient( + sessionId = A_SESSION_ID, + userDisplayName = A_USER_NAME, + userAvatarUrl = AN_AVATAR_URL, + ), + ) + presenter.test { + assertThat(awaitItem().currentUser).isEqualTo( + MatrixUser( + userId = A_SESSION_ID, + displayName = A_USER_NAME, + avatarUrl = AN_AVATAR_URL, + ) + ) + } + } + @Test fun `present - state is relayed from EncryptionService, order 1`() = runTest { val encryptionService = FakeEncryptionService() @@ -126,9 +152,11 @@ class ChooseSessionVerificationModePresenterTest { } private fun createPresenter( + matrixClient: MatrixClient = FakeMatrixClient(), encryptionService: FakeEncryptionService = FakeEncryptionService(), directLogoutPresenter: Presenter = Presenter { aDirectLogoutState() } ) = ChooseSelfVerificationModePresenter( + matrixClient = matrixClient, encryptionService = encryptionService, directLogoutPresenter = directLogoutPresenter, ) diff --git a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt index 66e2eb38fe..419c8d3371 100644 --- a/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt +++ b/features/ftue/impl/src/test/kotlin/io/element/android/features/ftue/impl/sessionverification/choosemode/ChooseSessionVerificationModeViewTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.tests.testutils.EnsureNeverCalled +import io.element.android.tests.testutils.EventsRecorder import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.robolectric.RobolectricTest @@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() { } } + @Test + fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder() + setChooseSelfVerificationModeView( + aChooseSelfVerificationModeState(eventSink = eventsRecorder), + ) + clickOn(R.string.screen_identity_confirmation_sign_out) + eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut) + } + private fun AndroidComposeUiTest.setChooseSelfVerificationModeView( state: ChooseSelfVerificationModeState, onLearnMoreClick: () -> Unit = EnsureNeverCalled(), diff --git a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt index 486a8bafce..81c467246f 100644 --- a/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt +++ b/features/home/api/src/main/kotlin/io/element/android/features/home/api/HomeEntryPoint.kt @@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom interface HomeEntryPoint : FeatureEntryPoint { @@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint { fun navigateToRoomSettings(roomId: RoomId) fun navigateToBugReport() fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) } } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt index 6d1eec3dff..7d071cf90d 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeEvent.kt @@ -8,10 +8,8 @@ package io.element.android.features.home.impl -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface HomeEvent { data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent - data class SwitchToAccount(val sessionId: SessionId) : HomeEvent data object DismissMultiAccountAnnouncement : HomeEvent + data object DismissSwipeAvatarAnnouncement : HomeEvent } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt index a481171e97..fda28e0fa0 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeFlowNode.kt @@ -222,6 +222,7 @@ class HomeFlowNode( onRoomClick = ::navigateToRoom, onSettingsClick = callback::navigateToSettings, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToAccount = callback::navigateToSession, onStartChatClick = callback::navigateToCreateRoom, onCreateSpaceClick = callback::navigateToCreateSpace, onSetUpRecoveryClick = callback::navigateToSetUpRecovery, diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt index abeb7f890f..3f4901d5f2 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomePresenter.kt @@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableIntStateOf +import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable @@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.sessionstorage.api.SessionStore import kotlinx.collections.immutable.persistentListOf +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.FlowPreview import kotlinx.coroutines.flow.combine +import kotlinx.coroutines.flow.debounce import kotlinx.coroutines.flow.filter +import kotlinx.coroutines.flow.mapLatest import kotlinx.coroutines.launch +import kotlin.time.Duration.Companion.milliseconds @Inject class HomePresenter( @@ -53,10 +59,20 @@ class HomePresenter( ) : Presenter { private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() + @OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class) @Composable override fun present(): HomeState { val coroutineState = rememberCoroutineScope() val matrixUser by client.userProfile.collectAsState() + + val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) { + syncService.heartBeat + // Delay the heartbeat so the SDK has time to update the unread count before we read it. + .debounce(100.milliseconds) + .mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() } + .collect { value = it } + } + val currentUserAndNeighbors by remember { combine( client.userProfile, @@ -110,11 +126,8 @@ class HomePresenter( is HomeEvent.SelectHomeNavigationBarItem -> { currentHomeNavigationBarItemOrdinal = event.item.ordinal } - is HomeEvent.SwitchToAccount -> coroutineState.launch { - // The user has switched account by swiping their avatar, so they know the gesture. - // Do it before switching, since it can stop this coroutine. + HomeEvent.DismissSwipeAvatarAnnouncement -> coroutineState.launch { announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount) - sessionStore.setLatestSession(event.sessionId.value) } HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { announcementService.onAnnouncementDismissed(Announcement.MultiAccount) @@ -133,6 +146,7 @@ class HomePresenter( snackbarMessage = snackbarMessage, canReportBug = canReportBug, showMultiAccountAnnouncement = showMultiAccountAnnouncement, + totalUnreadCount = totalUnreadCount, eventSink = ::handleEvent, ) } diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt index a49d3876e9..eb8e3ff2f1 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeState.kt @@ -29,6 +29,7 @@ data class HomeState( val snackbarMessage: SnackbarMessage?, val canReportBug: Boolean, val showMultiAccountAnnouncement: Boolean, + val totalUnreadCount: Int, val eventSink: (HomeEvent) -> Unit, ) { val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt index f86c32c532..4a82166d3e 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeStatePreviewParam.kt @@ -64,6 +64,7 @@ internal fun aHomeState( homeSpacesState: HomeSpacesState = aHomeSpacesState(), canReportBug: Boolean = true, showMultiAccountAnnouncement: Boolean = false, + totalUnreadCount: Int = 0, eventSink: (HomeEvent) -> Unit = {} ) = HomeState( currentUserAndNeighbors = currentUserAndNeighbors.toImmutableList(), @@ -75,5 +76,6 @@ internal fun aHomeState( currentHomeNavigationBarItem = currentHomeNavigationBarItem, roomListState = roomListState, homeSpacesState = homeSpacesState, + totalUnreadCount = totalUnreadCount, eventSink = eventSink, ) diff --git a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt index 6ff31a7c12..6640120614 100644 --- a/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt +++ b/features/home/impl/src/main/kotlin/io/element/android/features/home/impl/HomeView.kt @@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.ui.strings.CommonStrings import kotlinx.coroutines.launch @@ -78,6 +79,7 @@ fun HomeView( onRoomClick: (RoomId, EventId?) -> Unit, onSettingsClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToAccount: (SessionId) -> Unit, onSetUpRecoveryClick: () -> Unit, onConfirmRecoveryKeyClick: () -> Unit, onStartChatClick: () -> Unit, @@ -130,6 +132,7 @@ fun HomeView( onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() }, onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() }, onMenuActionClick = onMenuActionClick, + onSwitchToAccount = onSwitchToAccount, ) if (state.globalSearchState.isEnabled) { @@ -167,6 +170,7 @@ private fun HomeScaffold( onStartChatClick: () -> Unit, onCreateSpaceClick: () -> Unit, onMenuActionClick: (RoomListMenuAction) -> Unit, + onSwitchToAccount: (SessionId) -> Unit, modifier: Modifier = Modifier, ) { fun onRoomClick(room: RoomListRoomSummary) { @@ -213,8 +217,10 @@ private fun HomeScaffold( }, onMenuActionClick = onMenuActionClick, onOpenSettings = onOpenSettings, - onAccountSwitch = { - state.eventSink(HomeEvent.SwitchToAccount(it)) + onAccountSwitch = { sessionId -> + // The user has switched account by swiping their avatar, so they know the gesture. + state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement) + onSwitchToAccount(sessionId) }, scrollBehavior = scrollBehavior, displayFilters = state.displayRoomListFilters, @@ -259,6 +265,7 @@ private fun HomeScaffold( } } }, + totalUnreadCount = state.totalUnreadCount, ) }, floatingActionButtonPosition = FabPosition.Center, @@ -341,6 +348,7 @@ private fun HomeFloatingActionButton( @Composable private fun HomeBottomBar( + totalUnreadCount: Int, currentHomeNavigationBarItem: HomeNavigationBarItem, onItemClick: (HomeNavigationBarItem) -> Unit, modifier: Modifier = Modifier, @@ -361,6 +369,7 @@ private fun HomeBottomBar( tooltipLabel = stringResource(item.labelRes), isSelected = isSelected, onClick = { onItemClick(item) }, + counter = totalUnreadCount.takeIf { item == HomeNavigationBarItem.Chats && it > 0 } ) } } @@ -376,6 +385,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta onRoomClick = { _, _ -> }, onSettingsClick = {}, onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, @@ -397,6 +407,7 @@ internal fun HomeViewA11yPreview() = ElementPreview { onRoomClick = { _, _ -> }, onSettingsClick = {}, onAddAccountClick = {}, + onSwitchToAccount = {}, onSetUpRecoveryClick = {}, onConfirmRecoveryKeyClick = {}, onStartChatClick = {}, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt index 643de32ecb..6e962f6fe0 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/DefaultHomeEntryPointTest.kt @@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.test.FakeMatrixClient import io.element.android.services.analytics.test.FakeAnalyticsService @@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() { override fun navigateToRoomSettings(roomId: RoomId) = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() } val result = entryPoint.createNode( parentNode = parentNode, diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt index 9ff324f7b8..6af53d2ace 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/HomePresenterTest.kt @@ -28,8 +28,6 @@ import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.AN_AVATAR_URL import io.element.android.libraries.matrix.test.AN_EXCEPTION -import io.element.android.libraries.matrix.test.A_SESSION_ID -import io.element.android.libraries.matrix.test.A_SESSION_ID_2 import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_NAME import io.element.android.libraries.matrix.test.FakeMatrixClient @@ -43,10 +41,12 @@ import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.flowOf +import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test +import kotlin.time.Duration.Companion.milliseconds class HomePresenterTest { @get:Rule @@ -121,6 +121,40 @@ class HomePresenterTest { } } + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - total unread count is updated on sync heartbeat`() = runTest { + var unreadCount = 0L + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + unreadCount = 5 + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) + } + } + + @OptIn(ExperimentalCoroutinesApi::class) + @Test + fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { + val syncService = FakeSyncService() + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }), + syncService = syncService, + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(0) + syncService.emitHeartBeat() + advanceTimeBy(200.milliseconds) + expectNoEvents() + } + } + @Test fun `present - should start with no user and then load user with error`() = runTest { val matrixClient = FakeMatrixClient( @@ -252,31 +286,29 @@ class HomePresenterTest { @OptIn(ExperimentalCoroutinesApi::class) @Test - fun `present - switching account dismisses the swipe avatar announcement`() = runTest { + fun `present - dismissing the swipe avatar announcement`() = runTest { val onAnnouncementDismissedResult = lambdaRecorder { } - val setLatestSessionResult = lambdaRecorder { } val presenter = createHomePresenter( - client = FakeMatrixClient(sessionId = A_SESSION_ID), - sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData(sessionId = A_SESSION_ID.value), - aSessionData(sessionId = A_SESSION_ID_2.value), - ), - updateUserProfileResult = { _, _, _, _ -> }, - setLatestSessionResult = setLatestSessionResult, - ), announcementService = FakeAnnouncementService( onAnnouncementDismissedResult = onAnnouncementDismissedResult, ), ) presenter.test { val state = expectMostRecentItem() - state.eventSink(HomeEvent.SwitchToAccount(A_SESSION_ID_2)) + state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement) runCurrent() onAnnouncementDismissedResult.assertions().isCalledOnce() .with(value(Announcement.SwipeAvatarToSwitchAccount)) - setLatestSessionResult.assertions().isCalledOnce() - .with(value(A_SESSION_ID_2.value)) + } + } + + @Test + fun `present - total unread count is loaded in the initial state`() = runTest { + val presenter = createHomePresenter( + client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }), + ) + presenter.test { + assertThat(awaitItem().totalUnreadCount).isEqualTo(5) } } } diff --git a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt index eb41331202..61ac7b8dc6 100644 --- a/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt +++ b/features/home/impl/src/test/kotlin/io/element/android/features/home/impl/roomlist/RoomListViewTest.kt @@ -331,6 +331,7 @@ private fun AndroidComposeUiTest.setRoomListView( onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onSettingsClick = onSettingsClick, onAddAccountClick = onAddAccountClick, + onSwitchToAccount = EnsureNeverCalledWithParam(), onSetUpRecoveryClick = onSetUpRecoveryClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onStartChatClick = onCreateRoomClick, diff --git a/features/location/api/build.gradle.kts b/features/location/api/build.gradle.kts index 36dbfdb139..811c0d89b7 100644 --- a/features/location/api/build.gradle.kts +++ b/features/location/api/build.gradle.kts @@ -26,7 +26,7 @@ android { defaultConfig { buildConfigFieldStr( name = "MAPTILER_BASE_URL", - value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" + value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL, ) buildConfigFieldStr( name = "MAPTILER_API_KEY", @@ -35,8 +35,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") ?: readLocalProperty("services.maptiler.apikey") - } - ?: "" + }, ) buildConfigFieldStr( name = "MAPTILER_LIGHT_MAP_ID", @@ -45,9 +44,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") ?: readLocalProperty("services.maptiler.lightMapId") - } - // fall back to maptiler's default light map. - ?: "basic-v2" + }, ) buildConfigFieldStr( name = "MAPTILER_DARK_MAP_ID", @@ -56,9 +53,7 @@ android { } else { System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") ?: readLocalProperty("services.maptiler.darkMapId") - } - // fall back to maptiler's default dark map. - ?: "basic-v2-dark" + }, ) } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt index 752dd2401c..bcaa5ac49c 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocalMapTilerConfig.kt @@ -11,13 +11,21 @@ import androidx.compose.runtime.staticCompositionLocalOf import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig /** - * Provides a [MapTilerConfig] for the MapLibre maps. + * Provides the [MapTilerConfig] for the MapLibre maps, or null if no map provider is configured. */ -val LocalMapTilerConfig = staticCompositionLocalOf { - MapTilerConfig( - apiKey = BuildConfig.MAPTILER_API_KEY, +val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() } + +/** + * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. + */ +fun buildTimeMapTilerConfig(): MapTilerConfig? { + // BuildConfig fields are null when not provided at build time. + val apiKey: String? = BuildConfig.MAPTILER_API_KEY + if (apiKey.isNullOrEmpty()) return null + return MapTilerConfig( + apiKey = apiKey, lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID, darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID, - baseUrl = BuildConfig.MAPTILER_BASE_URL + baseUrl = BuildConfig.MAPTILER_BASE_URL, ) } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt index 41c58c396f..02cb7bba14 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/LocationService.kt @@ -8,10 +8,21 @@ package io.element.android.features.location.api +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig +import io.element.android.libraries.core.coroutine.mapState +import kotlinx.coroutines.flow.StateFlow + /** - * Tells whether the location features can be used at all, which depends on the build having a map provider configured. + * Tells whether the location features can be used at all, which depends on a map provider being configured, + * either by the homeserver or at build time. */ interface LocationService { + /** + * The [MapTilerConfig] to use to render the maps, or null if no map provider is configured. + * The configuration provided by the homeserver takes precedence over the one defined at build time. + */ + val mapTilerConfig: StateFlow + /** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */ - fun isServiceAvailable(): Boolean + fun isServiceAvailable(): StateFlow = mapTilerConfig.mapState { it != null } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt index b3149964e4..2e8e8b5a60 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/StaticMapView.kt @@ -50,11 +50,12 @@ import io.element.android.libraries.designsystem.utils.CommonDrawables /** * Shows a static map image downloaded via a third party service's static maps API. * - * Handles 4 distinct cases: + * Handles 5 distinct cases: * 1. Stale location (pinVariant is StaleLocation) - shows stale map with stale pin, no fetching * 2. Null location - shows blurred placeholder, no pin, no loading - * 3. Loading (location != null, fetching) - shows blurred placeholder with loading indicator - * 4. Success (location != null, loaded) - shows actual map with pin + * 3. Null mapTilerConfig (no map provider configured) - shows blurred placeholder, no pin, no fetching + * 4. Loading (location != null, fetching) - shows blurred placeholder with loading indicator + * 5. Success (location != null, loaded) - shows actual map with pin */ @Composable fun StaticMapView( @@ -84,7 +85,8 @@ fun StaticMapView( ) } // Case 2: Null location - show blurred placeholder, no pin, no loading - location == null -> { + // Case 3: No map provider configured - show blurred placeholder, no pin, no fetching + location == null || mapTilerConfig == null && !LocalInspectionMode.current -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), canReload = false, @@ -94,7 +96,7 @@ fun StaticMapView( onLoadMapClick = {} ) } - // Cases 3 & 4: Non-null location - fetch map + // Cases 4 & 5: Non-null location - fetch map else -> LoadableMapContent( mapTilerConfig = mapTilerConfig, location = location, @@ -150,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent( Triple(painter, state, ContentScale.Crop) } else { val painter = rememberAsyncImagePainter( - model = if (constraints.isZero) { - // Avoid building a URL if any of the size constraints is zero + model = if (builder == null || constraints.isZero) { + // Avoid building a URL if there is no map provider or if any of the size constraints is zero null } else { ImageRequest.Builder(context) @@ -193,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent( else -> { StaticMapPlaceholder( painter = painterResource(R.drawable.blurred_map), - canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, + canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error, contentDescription = contentDescription, width = maxWidth, height = maxHeight, diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt new file mode 100644 index 0000000000..509278b3dd --- /dev/null +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerDefaults.kt @@ -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 +} diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt index cd339f67b0..c19d305f3d 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilder.kt @@ -9,7 +9,6 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig import kotlin.math.roundToInt /** @@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder( private val lightMapId: String, private val darkMapId: String, ) : StaticMapUrlBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) - constructor(mapTilerConfig: MapTilerConfig) : this( - baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"), - apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY, - lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID, + baseUrl = mapTilerConfig.baseUrlOrDefault(), + apiKey = mapTilerConfig.apiKey, + lightMapId = mapTilerConfig.lightMapIdOrDefault(), + darkMapId = mapTilerConfig.darkMapIdOrDefault(), ) override fun build( diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt index d817a3307f..fc7ddc3fe0 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilder.kt @@ -11,33 +11,18 @@ package io.element.android.features.location.api.internal import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig -import io.element.android.features.location.api.BuildConfig - -internal class MapTilerTileServerStyleUriBuilder( - private val baseUrl: String, - private val apiKey: String, - private val lightMapId: String, - private val darkMapId: String, -) : TileServerStyleUriBuilder { - constructor() : this( - baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"), - apiKey = BuildConfig.MAPTILER_API_KEY, - lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID, - darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID, - ) +internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder { override fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String { - val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl - val apiKey = customMapTilerConfig?.apiKey ?: apiKey + val baseUrl = mapTilerConfig.baseUrlOrDefault() val mapId = if (darkMode) { - customMapTilerConfig?.darkStyleId ?: darkMapId + mapTilerConfig.darkMapIdOrDefault() } else { - customMapTilerConfig?.lightStyleId ?: lightMapId + mapTilerConfig.lightMapIdOrDefault() } - - return "$baseUrl/$mapId/style.json?key=$apiKey" + return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}" } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt index 31992b1dd6..553a4ed160 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/StaticMapUrlBuilder.kt @@ -36,17 +36,13 @@ interface StaticMapUrlBuilder { density: Float, ): String - /** Whether a map provider is configured for this build; the URLs are unusable when it is not. */ + /** Whether an API key is configured; the URLs are unusable when it is not. */ fun isServiceAvailable(): Boolean } @Composable -fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder { +fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? { return remember(mapTilerConfig) { - if (mapTilerConfig != null) { - MapTilerStaticMapUrlBuilder(mapTilerConfig) - } else { - MapTilerStaticMapUrlBuilder() - } + mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) } } } diff --git a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt index 67936525e5..6195f8fad3 100644 --- a/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt +++ b/features/location/api/src/main/kotlin/io/element/android/features/location/api/internal/TileServerStyleUriBuilder.kt @@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder { /** * Builds the style URI the map view loads its tiles from. * - * @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available. + * @param mapTilerConfig the MapTiler configuration to use. * @param darkMode whether to request the dark map style. */ fun build( - customMapTilerConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig, darkMode: Boolean, ): String } @@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder { */ @Composable fun rememberTileStyleUrl( - customMapTileConfig: MapTilerConfig?, + mapTilerConfig: MapTilerConfig?, ): String { val darkMode = !ElementTheme.isLightTheme - return remember(darkMode, customMapTileConfig) { - MapTilerTileServerStyleUriBuilder().build( - customMapTilerConfig = customMapTileConfig, - darkMode = darkMode, - ) + return remember(darkMode, mapTilerConfig) { + // Without config, the map is not displayed, so the style URL is not used. + mapTilerConfig?.let { + MapTilerTileServerStyleUriBuilder().build( + mapTilerConfig = it, + darkMode = darkMode, + ) + }.orEmpty() } } diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt index 3d17107104..c46b7a41d5 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerStaticMapUrlBuilderTest.kt @@ -9,6 +9,7 @@ package io.element.android.features.location.api.internal import com.google.common.truth.Truth.assertThat +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerStaticMapUrlBuilderTest { @@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest { assertThat(builder.isServiceAvailable()).isTrue() } + @Test + fun `builder created from a config only uses the config values`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", + ) + ) + assertThat(configBuilder.isServiceAvailable()).isTrue() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f) + ).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright") + } + + @Test + fun `builder created from a partial config falls back to the public MapTiler defaults`() { + val configBuilder = MapTilerStaticMapUrlBuilder( + MapTilerConfig( + apiKey = "", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ) + ) + assertThat(configBuilder.isServiceAvailable()).isFalse() + assertThat( + configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f) + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright") + } + @Test fun `isServiceAvailable returns false if api key is empty`() { val builderWithoutKey = MapTilerStaticMapUrlBuilder( diff --git a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt index c153ab1ce4..12c3a7ec63 100644 --- a/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt +++ b/features/location/api/src/test/kotlin/io/element/android/features/location/api/internal/MapTilerTileServerStyleUriBuilderTest.kt @@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import org.junit.Test class MapTilerTileServerStyleUriBuilderTest { - private val builder = MapTilerTileServerStyleUriBuilder( - baseUrl = "https://base.url", - apiKey = "anApiKey", - lightMapId = "aLightMapId", - darkMapId = "aDarkMapId", + private val builder = MapTilerTileServerStyleUriBuilder() + + private val config = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = "light", + darkStyleId = "dark", + baseUrl = "https://custom.url/maps/", ) @Test fun `light map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = false, ) - ).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY") } @Test fun `dark map uri`() { assertThat( builder.build( - customMapTilerConfig = null, + mapTilerConfig = config, darkMode = true, ) - ).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") + ).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY") } @Test - fun `custom map uri light`() { + fun `missing values fall back to the public MapTiler defaults`() { + val partialConfig = MapTilerConfig( + apiKey = "API_KEY", + lightStyleId = null, + darkStyleId = " ", + baseUrl = " ", + ) assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = false, ) - ).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY") - } - - @Test - fun `custom map uri dark`() { + ).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY") assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = "https://custom.url/style.json" - ), + mapTilerConfig = partialConfig, darkMode = true, ) - ).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY") + ).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY") } @Test - fun `blank custom map uri falls back to the built-in style`() { + fun `blank api key is kept as is`() { assertThat( builder.build( - customMapTilerConfig = MapTilerConfig( - apiKey = "API_KEY", - lightStyleId = "light", - darkStyleId = "dark", - baseUrl = " " - ), + mapTilerConfig = config.copy(apiKey = ""), darkMode = false, ) - ).isEqualTo("https://base.url/light/style.json?key=API_KEY") + ).isEqualTo("https://custom.url/maps/light/style.json?key=") } } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt index 9f2c3fa70b..aa14e6505b 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/DefaultLocationService.kt @@ -8,14 +8,31 @@ package io.element.android.features.location.impl -import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding -import io.element.android.features.location.api.BuildConfig +import dev.zacsweers.metro.SingleIn +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.LocationService +import io.element.android.features.location.api.buildTimeMapTilerConfig +import io.element.android.libraries.di.SessionScope +import io.element.android.libraries.di.annotations.SessionCoroutineScope +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.StateFlow +import kotlinx.coroutines.flow.flow +import kotlinx.coroutines.flow.stateIn -@ContributesBinding(AppScope::class) -class DefaultLocationService : LocationService { - override fun isServiceAvailable(): Boolean { - return BuildConfig.MAPTILER_API_KEY.isNotEmpty() - } +@SingleIn(SessionScope::class) +@ContributesBinding(SessionScope::class) +class DefaultLocationService( + customMapTilerConfigProvider: CustomMapTilerConfigProvider, + @SessionCoroutineScope sessionCoroutineScope: CoroutineScope, +) : LocationService { + override val mapTilerConfig: StateFlow = flow { + customMapTilerConfigProvider.get().getOrNull()?.let { emit(it) } + }.stateIn( + scope = sessionCoroutineScope, + started = SharingStarted.Eagerly, + initialValue = buildTimeMapTilerConfig(), + ) } diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt index 05af96fe5e..4dbc2fac54 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenter.kt @@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState import androidx.compose.runtime.derivedStateOf import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf -import androidx.compose.runtime.produceState import androidx.compose.runtime.remember import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.setValue @@ -22,7 +21,7 @@ import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.impl.common.LocationConstraintsCheck import io.element.android.features.location.impl.common.MapDefaults @@ -38,7 +37,6 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.messages.api.MessageComposerContext import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingState import io.element.android.libraries.core.extensions.flatMap @@ -73,7 +71,7 @@ class ShareLocationPresenter( private val liveLocationShareManager: ActiveLiveLocationShareManager, private val liveLocationStore: LiveLocationStore, private val userLocationStateFactory: UserLocationState.Factory, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, ) : Presenter { @AssistedFactory fun interface Factory { @@ -94,10 +92,7 @@ class ShareLocationPresenter( var pendingLiveLocationShare by remember { mutableStateOf(false) } val startLiveLocationAction = remember { mutableStateOf>(AsyncAction.Uninitialized) } val currentUser by client.userProfile.collectAsState() - val customMapConfig by produceState(AsyncData.Loading()) { - // Ignore errors - value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms -> perms.sendLiveLocationPermissions() } @@ -201,7 +196,7 @@ class ShareLocationPresenter( } return ShareLocationState( - customMapTilerConfig = customMapConfig, + mapTilerConfig = mapTilerConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt index b29228f866..38747b866f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationState.kt @@ -12,12 +12,11 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.ImmutableList data class ShareLocationState( - val customMapTilerConfig: AsyncData, + val mapTilerConfig: MapTilerConfig?, val currentUser: MatrixUser, val dialogState: Dialog, val trackUserLocation: Boolean, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt index 908c7f0641..7a192fc4a8 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationStatePreviewParam.kt @@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState +import io.element.android.features.location.impl.show.aPreviewMapTilerConfig import io.element.android.libraries.architecture.AsyncAction -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.user.MatrixUser import kotlinx.collections.immutable.persistentListOf @@ -71,13 +71,13 @@ class ShareLocationStatePreviewParam : PreviewParameterProvider = AsyncData.Success(null), + mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(), currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")), dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None, trackUserPosition: Boolean = false, @@ -88,7 +88,7 @@ fun aShareLocationState( eventSink: (ShareLocationEvent) -> Unit = {}, ): ShareLocationState { return ShareLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, currentUser = currentUser, dialogState = dialogState, trackUserLocation = trackUserPosition, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt index 6baa7bf3d2..d19d3fe0f0 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/share/ShareLocationView.kt @@ -130,7 +130,7 @@ private fun ShareLocationViewContent( navigateUp: () -> Unit, modifier: Modifier = Modifier, ) { - val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull()) + val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig) val mapState = rememberMapState( baseStyle = BaseStyle.Uri(tileStyleUrl), initialCameraPosition = MapDefaults.defaultCameraPosition, @@ -181,7 +181,7 @@ private fun ShareLocationScaffold( ) ) MapBottomSheetScaffold( - isReady = state.customMapTilerConfig.isReady(), + isReady = state.mapTilerConfig != null, mapState = mapState, modifier = modifier, scaffoldState = scaffoldState, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt index 51ed1d6c68..0a9911c350 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenter.kt @@ -22,8 +22,8 @@ import androidx.compose.runtime.setValue import dev.zacsweers.metro.Assisted import dev.zacsweers.metro.AssistedFactory import dev.zacsweers.metro.AssistedInject -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.api.live.ActiveLiveLocationShareManager import io.element.android.features.location.api.live.isCurrentlySharing @@ -39,7 +39,6 @@ import io.element.android.features.location.impl.common.toDialogState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.meta.BuildMeta @@ -68,7 +67,7 @@ class ShowLocationPresenter( private val joinedRoom: JoinedRoom, private val liveLocationShareManager: ActiveLiveLocationShareManager, private val userLocationStateFactory: UserLocationState.Factory, - private val customMapTilerConfigProvider: CustomMapTilerConfigProvider, + private val locationService: LocationService, ) : Presenter { @AssistedFactory fun interface Factory { fun create(mode: ShowLocationMode): ShowLocationPresenter @@ -86,10 +85,7 @@ class ShowLocationPresenter( mutableStateOf(LocationConstraintsDialogState.None) } - val customMapTilerConfig by produceState(AsyncData.Loading()) { - // Ignore errors - value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull()) - } + val mapTilerConfig by locationService.mapTilerConfig.collectAsState() fun checkLocationConstraints() { val locationConstraints = checkLocationConstraints( @@ -221,7 +217,7 @@ class ShowLocationPresenter( userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted) } return ShowLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, dialogState = dialogState, locationShares = locationShares, focusedLocation = focusedLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt index 0472eb94e9..79dacdad1f 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationState.kt @@ -13,7 +13,6 @@ import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.ui.LocationMarkerData import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.PinVariant import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.matrix.api.core.UserId @@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType import kotlinx.collections.immutable.ImmutableList data class ShowLocationState( - val customMapTilerConfig: AsyncData, + val mapTilerConfig: MapTilerConfig?, val isLive: Boolean, val dialogState: LocationConstraintsDialogState, val locationShares: ImmutableList, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt index 8473eaeb04..4c106c76a5 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationStatePreviewParam.kt @@ -13,7 +13,6 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.Location import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.common.userlocation.UserLocationState -import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.designsystem.components.avatar.AvatarData import io.element.android.libraries.designsystem.components.avatar.AvatarSize import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE @@ -37,14 +36,21 @@ class ShowLocationStatePreviewParam : PreviewParameterProvider = AsyncData.Success(null), + mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(), isLive: Boolean = false, constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None, locationShares: List = listOf(aLocationShareItem(isLive = isLive)), @@ -56,7 +62,7 @@ fun aShowLocationState( eventSink: (ShowLocationEvent) -> Unit = {}, ): ShowLocationState { return ShowLocationState( - customMapTilerConfig = customMapTilerConfig, + mapTilerConfig = mapTilerConfig, dialogState = constraintsDialogState, locationShares = locationShares.toImmutableList(), focusedLocation = focusedLocation, diff --git a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt index 8a797ad1a6..483310a78a 100644 --- a/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt +++ b/features/location/impl/src/main/kotlin/io/element/android/features/location/impl/show/ShowLocationView.kt @@ -102,7 +102,7 @@ private fun ShowLocationViewContent( onBackClick: () -> Unit, modifier: Modifier = Modifier, ) { - val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull()) + val tileStyleUrl = rememberTileStyleUrl(state.mapTilerConfig) val mapState = rememberMapState( baseStyle = BaseStyle.Uri(tileStyleUrl), initialCameraPosition = MapDefaults.defaultCameraPosition, @@ -178,7 +178,7 @@ private fun ShowLocationScaffold( } } MapBottomSheetScaffold( - isReady = state.customMapTilerConfig.isReady(), + isReady = state.mapTilerConfig != null, sheetDragHandle = if (state.isSheetDraggable) { { BottomSheetDefaults.DragHandle() } } else { diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt index b0e899730b..8a81895e1f 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/DefaultLocationServiceTest.kt @@ -9,15 +9,55 @@ package io.element.android.features.location.impl import com.google.common.truth.Truth.assertThat -import io.element.android.features.location.api.BuildConfig +import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider +import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig +import io.element.android.features.location.api.buildTimeMapTilerConfig +import io.element.android.libraries.matrix.test.AN_EXCEPTION +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent +import kotlinx.coroutines.test.runTest import org.junit.Test +@OptIn(ExperimentalCoroutinesApi::class) class DefaultLocationServiceTest { @Test - fun `isServiceAvailable should return value depending on BuildConfig MAPTILER_API_KEY`() { - val locationService = DefaultLocationService() - assertThat(locationService.isServiceAvailable()).isEqualTo( - BuildConfig.MAPTILER_API_KEY.isNotEmpty() + fun `when the homeserver does not provide a config, the build time config is used`() = runTest { + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.success(null) }, ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) } + + @Test + fun `when the homeserver config cannot be retrieved, the build time config is used`() = runTest { + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.failure(AN_EXCEPTION) }, + ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) + assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null) + } + + @Test + fun `when the homeserver provides a config, it is used and the service is available`() = runTest { + val config = aMapTilerConfig(apiKey = "homeserver_api_key") + val locationService = createDefaultLocationService( + customMapTilerConfigProvider = { Result.success(config) }, + ) + runCurrent() + assertThat(locationService.mapTilerConfig.value).isEqualTo(config) + assertThat(locationService.isServiceAvailable().value).isTrue() + } + + private fun TestScope.createDefaultLocationService( + customMapTilerConfigProvider: CustomMapTilerConfigProvider, + sessionCoroutineScope: CoroutineScope = backgroundScope, + ) = DefaultLocationService( + customMapTilerConfigProvider = customMapTilerConfigProvider, + sessionCoroutineScope = sessionCoroutineScope, + ) } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt index a37011e1d9..e57fafa61b 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/DefaultShareLocationEntryPointTest.kt @@ -16,6 +16,7 @@ import io.element.android.features.location.impl.common.actions.FakeLocationActi import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.timeline.Timeline @@ -58,7 +59,7 @@ class DefaultShareLocationEntryPointTest { sessionId = room.sessionId, ), userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = { Result.success(null) }, + locationService = FakeLocationService(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt index 129ea1e4cd..3dcb358cf1 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/share/ShareLocationPresenterTest.kt @@ -19,9 +19,9 @@ import app.cash.turbine.ReceiveTurbine import app.cash.turbine.test import com.google.common.truth.Truth.assertThat import im.vector.app.features.analytics.plan.Composer -import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory import io.element.android.features.location.impl.common.actions.FakeLocationActions @@ -31,6 +31,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.impl.live.LiveLocationStore import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.messages.test.FakeMessageComposerContext import io.element.android.libraries.dateformatter.test.FakeDurationFormatter import io.element.android.libraries.matrix.api.core.EventId @@ -89,7 +90,7 @@ class ShareLocationPresenterTest { liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId), client: FakeMatrixClient = fakeMatrixClient, - customMapTilerConfigProvider: () -> Result = { Result.success(null) }, + locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()), ): ShareLocationPresenter = ShareLocationPresenter( permissionsPresenterFactory = { fakePermissionsPresenter }, room = joinedRoom, @@ -103,7 +104,7 @@ class ShareLocationPresenterTest { liveLocationShareManager = liveLocationShareManager, liveLocationStore = liveLocationStore, userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = customMapTilerConfigProvider, + locationService = locationService, ) @Test @@ -118,26 +119,24 @@ class ShareLocationPresenterTest { val shareLocationPresenter = createShareLocationPresenter() shareLocationPresenter.test { val state = awaitFirstItem() - assertThat(state.customMapTilerConfig.isLoading()).isFalse() + assertThat(state.mapTilerConfig).isNotNull() assertThat(state.trackUserLocation).isTrue() assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None)) } } @Test - fun `present - non-null customMapStyleUrl`() = runTest { + fun `present - mapTilerConfig is provided by the location service`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") val shareLocationPresenter = createShareLocationPresenter( client = FakeMatrixClient( sessionId = A_USER_ID, ), - customMapTilerConfigProvider = { Result.success(mapTilerConfig) } + locationService = FakeLocationService(mapTilerConfig = mapTilerConfig), ) shareLocationPresenter.test { - val state = awaitItem() - assertThat(state.customMapTilerConfig.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) + assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig) + cancelAndIgnoreRemainingEvents() } } diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt index db5c6b9f9f..83172d1079 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/DefaultShowLocationEntryPointTest.kt @@ -18,6 +18,7 @@ import io.element.android.features.location.impl.common.FakeUserLocationStateFac import io.element.android.features.location.impl.common.actions.FakeLocationActions import io.element.android.features.location.impl.common.permissions.FakePermissionsPresenter import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.test.core.aBuildMeta @@ -51,7 +52,7 @@ class DefaultShowLocationEntryPointTest { joinedRoom = joinedRoom, liveLocationShareManager = FakeActiveLiveLocationShareManager(), userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = { Result.success(null) }, + locationService = FakeLocationService(), ) }, analyticsService = FakeAnalyticsService(), diff --git a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt index b2a84d24e1..ab1a1af734 100644 --- a/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt +++ b/features/location/impl/src/test/kotlin/io/element/android/features/location/impl/show/ShowLocationPresenterTest.kt @@ -12,9 +12,9 @@ import app.cash.molecule.RecompositionMode import app.cash.molecule.moleculeFlow import app.cash.turbine.test import com.google.common.truth.Truth.assertThat -import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig import io.element.android.features.location.api.Location +import io.element.android.features.location.api.LocationService import io.element.android.features.location.api.ShowLocationMode import io.element.android.features.location.impl.aPermissionsState import io.element.android.features.location.impl.common.FakeUserLocationStateFactory @@ -24,6 +24,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsE import io.element.android.features.location.impl.common.permissions.PermissionsState import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState import io.element.android.features.location.test.FakeActiveLiveLocationShareManager +import io.element.android.features.location.test.FakeLocationService import io.element.android.libraries.dateformatter.test.FakeDateFormatter import io.element.android.libraries.matrix.api.core.UserId import io.element.android.libraries.matrix.api.room.JoinedRoom @@ -66,7 +67,7 @@ class ShowLocationPresenterTest { locationActions: FakeLocationActions = fakeLocationActions, joinedRoom: JoinedRoom = FakeJoinedRoom(), liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(), - customMapTilerConfigProvider: CustomMapTilerConfigProvider = { Result.success(null) }, + locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()), ) = ShowLocationPresenter( mode = mode, permissionsPresenterFactory = { fakePermissionsPresenter }, @@ -77,18 +78,16 @@ class ShowLocationPresenterTest { joinedRoom = joinedRoom, liveLocationShareManager = liveLocationShareManager, userLocationStateFactory = FakeUserLocationStateFactory(), - customMapTilerConfigProvider = customMapTilerConfigProvider, + locationService = locationService, ) @Test - fun `present - non-null customMapStyleUrl`() = runTest { + fun `present - mapTilerConfig is provided by the location service`() = runTest { val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY") - val shareLocationPresenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(mapTilerConfig) }) - shareLocationPresenter.test { - val state = awaitItem() - assertThat(state.customMapTilerConfig.isLoading()).isTrue() - val finalState = awaitItem() - assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig) + val showLocationPresenter = createShowLocationPresenter(locationService = FakeLocationService(mapTilerConfig = mapTilerConfig)) + showLocationPresenter.test { + assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig) + cancelAndIgnoreRemainingEvents() } } @@ -120,7 +119,7 @@ class ShowLocationPresenterTest { fun `centers on user location`() = runTest { fakePermissionsPresenter.givenState(aPermissionsState(permissions = PermissionsState.Permissions.AllGranted)) - val presenter = createShowLocationPresenter(customMapTilerConfigProvider = { Result.success(aMapTilerConfig()) }) + val presenter = createShowLocationPresenter() presenter.test { val initialState = awaitItem() @@ -407,9 +406,6 @@ class ShowLocationPresenterTest { val initialState = awaitItem() assertThat(initialState.locationShares).isEmpty() - // Wait until we receive the custom map style URL result - skipItems(1) - // Emit a new live share liveSharesFlow.value = listOf( aLiveLocationShare(userId = userId) diff --git a/features/location/test/build.gradle.kts b/features/location/test/build.gradle.kts index e51737d40c..e08b702f46 100644 --- a/features/location/test/build.gradle.kts +++ b/features/location/test/build.gradle.kts @@ -16,6 +16,7 @@ android { dependencies { api(projects.features.location.api) + implementation(projects.features.enterprise.api) implementation(projects.libraries.architecture) implementation(projects.libraries.matrix.api) implementation(projects.tests.testutils) diff --git a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt index f961a81c9b..0ff1545131 100644 --- a/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt +++ b/features/location/test/src/main/kotlin/io/element/android/features/location/test/FakeLocationService.kt @@ -8,10 +8,21 @@ package io.element.android.features.location.test +import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.location.api.LocationService +import kotlinx.coroutines.flow.MutableStateFlow class FakeLocationService( - private val isServiceAvailable: Boolean = false, + mapTilerConfig: MapTilerConfig? = null, ) : LocationService { - override fun isServiceAvailable() = isServiceAvailable + constructor(isServiceAvailable: Boolean) : this( + MapTilerConfig( + apiKey = "test_api_key", + lightStyleId = null, + darkStyleId = null, + baseUrl = null, + ).takeIf { isServiceAvailable } + ) + + override val mapTilerConfig = MutableStateFlow(mapTilerConfig) } diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt index e1a2d85412..e60358c7f5 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderNode.kt @@ -56,6 +56,7 @@ class ConfirmAccountProviderNode( ConfirmAccountProviderView( state = state, modifier = modifier, + onBackClick = ::navigateUp, onOAuthDetails = callback::navigateToOAuth, onNeedLoginPassword = callback::navigateToLoginPassword, onLearnMoreClick = { openLearnMorePage(context) }, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt index be85a306ba..4657f4c1d7 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderStatePreviewParam.kt @@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider Unit, onOAuthDetails: (OAuthDetails) -> Unit, onNeedLoginPassword: () -> Unit, onLearnMoreClick: () -> Unit, @@ -118,9 +122,15 @@ fun ConfirmAccountProviderView( // Let the header and content scroll so the supporting text is not clipped by the footer when the // keyboard is visible and vertical space is reduced. isScrollable = true, + topBar = { + TopAppBar( + title = {}, + navigationIcon = { BackButton(onClick = onBackClick) }, + colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent), + ) + }, header = { IconTitleSubtitleMolecule( - modifier = Modifier.padding(top = 60.dp), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), title = stringResource( id = if (state.isAccountCreation) { @@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview( ) = ElementPreview { ConfirmAccountProviderView( state = state, + onBackClick = {}, onOAuthDetails = {}, onNeedLoginPassword = {}, onLearnMoreClick = {}, diff --git a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt index 018d55ab09..59d3863b4f 100644 --- a/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt +++ b/features/login/impl/src/main/kotlin/io/element/android/features/login/impl/screens/onboarding/OnBoardingView.kt @@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold( ) } +/** + * Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607 + */ @Composable private fun AddOtherAccountScaffold( loginView: @Composable () -> Unit, @@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold( ) { FlowStepPage( modifier = modifier, - title = stringResource(CommonStrings.common_add_account), - iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), + title = stringResource(R.string.screen_onboarding_add_account_title), + subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle), + iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()), buttons = { buttons() }, content = loginView, onBackClick = onBackClick, diff --git a/features/login/impl/src/main/res/values/localazy.xml b/features/login/impl/src/main/res/values/localazy.xml index d77081a70f..0d716537f3 100644 --- a/features/login/impl/src/main/res/values/localazy.xml +++ b/features/login/impl/src/main/res/values/localazy.xml @@ -52,6 +52,8 @@ "Follow the instructions to enable your key storage" "Come back to %1$s" "Enable your key storage before proceeding to %1$s" + "You can add multiple accounts to the app and switch between them easily." + "Sign in to another account" "Version %1$s" "Checking account" "Sign in manually" diff --git a/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt new file mode 100644 index 0000000000..61f22a1fec --- /dev/null +++ b/features/login/impl/src/test/kotlin/io/element/android/features/login/impl/screens/confirmaccountprovider/ConfirmAccountProviderViewTest.kt @@ -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(expectEvents = false) + ensureCalledOnce { + setConfirmAccountProviderView( + state = aConfirmAccountProviderState( + eventSink = eventSink, + ), + onBackClick = it, + ) + pressBack() + } + } + + private fun AndroidComposeUiTest.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, + ) + } + } +} diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt index 503d30c6d9..c81691b004 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/MessagesFlowNode.kt @@ -776,7 +776,7 @@ class MessagesFlowNode( assetType = event.content.assetType, ) } - NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable() } + NavTarget.LocationViewer(mode = mode).takeIf { locationService.isServiceAvailable().value } } else { displayVulkanNotSupportedError = true null diff --git a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt index 2974b99696..2c922db2e4 100644 --- a/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt +++ b/features/messages/impl/src/main/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenter.kt @@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController import io.element.android.features.messages.impl.utils.TextPillificationHelper import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.Presenter +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher @@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.merge import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes @@ -145,6 +147,7 @@ class MessageComposerPresenter( private val featureFlagService: FeatureFlagService, private val contentScannerService: ContentScannerService, private val contentValidationCache: EventContentValidationCache, + private val dispatchers: CoroutineDispatchers, ) : Presenter { @AssistedFactory interface Factory { @@ -184,31 +187,28 @@ class MessageComposerPresenter( val cameraPermissionState = cameraPermissionPresenter.present() - val canShareLocation = remember { mutableStateOf(false) } - LaunchedEffect(Unit) { - canShareLocation.value = locationService.isServiceAvailable() - } + val canShareLocation by remember { locationService.isServiceAvailable() }.collectAsState() val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages) .collectAsState(initial = false) val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType -> - handlePickedMedia(uri, mimeType) + localCoroutineScope.handlePickedMedia(uri, mimeType) } val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris -> - handlePickedMediaList(uris) + localCoroutineScope.handlePickedMediaList(uris) } val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris -> - handlePickedMediaList(uris, sendAsFile = true) + localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true) } val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType -> - handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) + localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true) } val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Jpeg) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg) } val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri -> - handlePickedMedia(uri, MimeTypes.Mp4) + localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4) } val isFullScreen = rememberSaveable { mutableStateOf(false) @@ -286,17 +286,16 @@ class MessageComposerPresenter( } is MessageComposerEvent.SendUri -> { val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - sessionCoroutineScope.sendAttachment( - attachment = Attachment.Media( - localMedia = localMediaFactory.createFromUri( - uri = event.uri, - mimeType = null, - name = null, - formattedFileSize = null - ), - ), - inReplyToEventId = inReplyToEventId, - ) + sessionCoroutineScope.launch { + val localMedia = createLocalMedia( + uri = event.uri, + mimeType = null, + ) + sendAttachment( + attachment = Attachment.Media(localMedia), + inReplyToEventId = inReplyToEventId, + ) + } resetComposerModeAfterAttaching() } @@ -431,7 +430,7 @@ class MessageComposerPresenter( isInThreadTimeline = isInThread, showAttachmentSourcePicker = showAttachmentSourcePicker, showTextFormatting = showTextFormatting, - canShareLocation = canShareLocation.value, + canShareLocation = canShareLocation, suggestions = suggestions.toImmutableList(), resolveMentionDisplay = resolveMentionDisplay, resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay, @@ -640,17 +639,28 @@ class MessageComposerPresenter( } } - private fun handlePickedMedia( - uri: Uri?, - mimeType: String? = null, - sendAsFile: Boolean = false, - ) { - uri ?: return - val localMedia = localMediaFactory.createFromUri( + // Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread + private suspend fun createLocalMedia( + uri: Uri, + mimeType: String?, + ) = withContext(dispatchers.io) { + localMediaFactory.createFromUri( uri = uri, mimeType = mimeType, name = null, - formattedFileSize = null + formattedFileSize = null, + ) + } + + private fun CoroutineScope.handlePickedMedia( + uri: Uri?, + mimeType: String? = null, + sendAsFile: Boolean = false, + ) = launch { + uri ?: return@launch + val localMedia = createLocalMedia( + uri = uri, + mimeType = mimeType, ) val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile) val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId @@ -659,7 +669,7 @@ class MessageComposerPresenter( resetComposerModeAfterAttaching() } - private fun handlePickedMediaList( + private fun CoroutineScope.handlePickedMediaList( uris: List, sendAsFile: Boolean = false, ) { @@ -668,19 +678,19 @@ class MessageComposerPresenter( handlePickedMedia(uris.first(), sendAsFile = sendAsFile) return } - val attachments = uris.map { uri -> - val localMedia = localMediaFactory.createFromUri( - uri = uri, - mimeType = null, - name = null, - formattedFileSize = null, - ) - Attachment.Media(localMedia, sendAsFile = sendAsFile) - }.toImmutableList() - val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId - navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) + launch { + val attachments = uris.map { uri -> + val localMedia = createLocalMedia( + uri = uri, + mimeType = null, + ) + Attachment.Media(localMedia, sendAsFile = sendAsFile) + }.toImmutableList() + val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId + navigator.navigateToPreviewAttachments(attachments, inReplyToEventId) - resetComposerModeAfterAttaching() + resetComposerModeAfterAttaching() + } } private fun resetComposerModeAfterAttaching() { diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt index a8f6c338f5..4535607cb6 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterSlashCommandTest.kt @@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt index 3253e6088d..74b8047449 100644 --- a/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt +++ b/features/messages/impl/src/test/kotlin/io/element/android/features/messages/impl/messagecomposer/MessageComposerPresenterTest.kt @@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() { featureFlagService = featureFlagService, contentScannerService = { _, _ -> }, contentValidationCache = InMemoryEventContentValidationCache(), + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ).apply { isTesting = true showTextFormatting = isRichTextEditorEnabled diff --git a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt index e7fbe6069f..a54fc98438 100644 --- a/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt +++ b/features/preferences/api/src/main/kotlin/io/element/android/features/preferences/api/PreferencesEntryPoint.kt @@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import kotlinx.parcelize.Parcelize interface PreferencesEntryPoint : FeatureEntryPoint { @@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint { interface Callback : Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToLinkNewDevice() fun navigateToBugReport() fun navigateToSecureBackup() diff --git a/features/preferences/impl/build.gradle.kts b/features/preferences/impl/build.gradle.kts index c137d6be06..277d78b50c 100644 --- a/features/preferences/impl/build.gradle.kts +++ b/features/preferences/impl/build.gradle.kts @@ -75,6 +75,7 @@ dependencies { implementation(projects.features.analytics.api) implementation(projects.features.enterprise.api) implementation(projects.features.licenses.api) + implementation(projects.features.location.api) implementation(projects.features.logout.api) implementation(projects.features.deactivation.api) implementation(projects.features.invite.api) @@ -109,6 +110,7 @@ dependencies { testImplementation(projects.features.enterprise.test) testImplementation(projects.features.invite.test) testImplementation(projects.features.licenses.test) + testImplementation(projects.features.location.test) testImplementation(projects.features.lockscreen.test) testImplementation(projects.features.logout.test) testImplementation(projects.libraries.indicator.test) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt index d9db0219d4..a49053546d 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/PreferencesFlowNode.kt @@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.di.SessionScope import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint @@ -141,6 +142,10 @@ class PreferencesFlowNode( callback.navigateToAddAccount() } + override fun navigateToSession(sessionId: SessionId) { + callback.navigateToSession(sessionId) + } + override fun navigateToAnalyticsSettings() { backstack.push(NavTarget.AnalyticsSettings) } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt index 4e04cd0ab6..e20deb91cb 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/account/PreferencesAccountPresenter.kt @@ -40,10 +40,7 @@ class PreferencesAccountPresenter( @Composable override fun present(): PreferencesAccountState { val matrixUser = matrixClient.userProfile.collectAsState() - LaunchedEffect(Unit) { - // Force a refresh of the profile - matrixClient.getUserProfile() - } + // Note: no need to force a refresh of the profile here, PreferencesRootPresenter, which embeds this presenter, already does it. val showLinkNewDevice by remember { featureFlagService.isFeatureEnabledFlow(FeatureFlags.QrCodeLogin) }.collectAsState(initial = false) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt index d2e5fc5b50..c08427f387 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenter.kt @@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.runUpdatingStateNoSuccess +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.featureflag.api.FeatureFlagService @@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.launch import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber import kotlin.time.Duration.Companion.seconds @@ -76,6 +78,7 @@ class NotificationSettingsPresenter( @SessionCoroutineScope private val sessionCoroutineScope: CoroutineScope, private val featureFlagService: FeatureFlagService, + private val dispatchers: CoroutineDispatchers, ) : Presenter { // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // its `.tmp` file, but the persist+recreate window outside the copier is unprotected; @@ -111,16 +114,19 @@ class NotificationSettingsPresenter( } // List of PushProvider -> Distributor - val distributors = remember { - pushService.getAvailablePushProviders() - .flatMap { pushProvider -> - pushProvider.getDistributors().map { distributor -> - pushProvider to distributor + // Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread + val distributors by produceState(emptyList>()) { + value = withContext(dispatchers.io) { + pushService.getAvailablePushProviders() + .flatMap { pushProvider -> + pushProvider.getDistributors().map { distributor -> + pushProvider to distributor + } } - } + } } // List of Distributors - val availableDistributors = remember { + val availableDistributors = remember(distributors) { distributors.map { it.second }.toImmutableList() } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt index be2c873a3b..6498c2464b 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootEvent.kt @@ -8,11 +8,8 @@ package io.element.android.features.preferences.impl.root -import io.element.android.libraries.matrix.api.core.SessionId - sealed interface PreferencesRootEvent { data object OnVersionInfoClick : PreferencesRootEvent - data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent data object ToggleOtherAccountsExpanded : PreferencesRootEvent data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent } diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt index c4416ad61e..93d6d25cbd 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootNode.kt @@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa import io.element.android.libraries.architecture.callback import io.element.android.libraries.di.SessionScope import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId @ContributesNode(SessionScope::class) @AssistedInject @@ -35,6 +36,7 @@ class PreferencesRootNode( ) : Node(buildContext, plugins = plugins) { interface Callback : PreferencesAccountCallback, Plugin { fun navigateToAddAccount() + fun navigateToSession(sessionId: SessionId) fun navigateToAnalyticsSettings() fun navigateToAbout() fun navigateToDeveloperSettings() @@ -72,6 +74,7 @@ class PreferencesRootNode( modifier = modifier, onBackClick = this::navigateUp, onAddAccountClick = callback::navigateToAddAccount, + onSwitchToSession = callback::navigateToSession, onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAbout = callback::navigateToAbout, onOpenDeveloperSettings = callback::navigateToDeveloperSettings, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt index 53234c3723..7d12fd8f51 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenter.kt @@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope import dev.zacsweers.metro.Inject import io.element.android.compound.theme.Theme import io.element.android.compound.theme.mapToTheme +import io.element.android.features.location.api.LocationService import io.element.android.features.preferences.impl.account.PreferencesAccountState import io.element.android.features.preferences.impl.userstatus.UserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -47,6 +48,7 @@ class PreferencesRootPresenter( private val snackbarDispatcher: SnackbarDispatcher, private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider, private val featureFlagService: FeatureFlagService, + private val locationService: LocationService, private val sessionStore: SessionStore, private val appPreferencesStore: AppPreferencesStore, private val userStatusPresenter: Presenter, @@ -115,14 +117,13 @@ class PreferencesRootPresenter( val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState() + val isLocationServiceAvailable by remember { locationService.isServiceAvailable() }.collectAsState() + fun handleEvent(event: PreferencesRootEvent) { when (event) { is PreferencesRootEvent.OnVersionInfoClick -> { showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope) } - is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch { - sessionStore.setLatestSession(event.sessionId.value) - } PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch { appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded) } @@ -150,6 +151,7 @@ class PreferencesRootPresenter( showAnalyticsSettings = hasAnalyticsProviders, showDeveloperSettings = showDeveloperSettings, showLabsItem = showLabsItem, + showLocationSettings = isLocationServiceAvailable, snackbarMessage = snackbarMessage, eventSink = ::handleEvent, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt index 67b75a84a8..b51d83592f 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootState.kt @@ -32,6 +32,7 @@ data class PreferencesRootState( val showAnalyticsSettings: Boolean, val showDeveloperSettings: Boolean, val showLabsItem: Boolean, + val showLocationSettings: Boolean, val snackbarMessage: SnackbarMessage?, val eventSink: (PreferencesRootEvent) -> Unit, ) diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt index 57b7493dd5..1bbd9e0548 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootStatePreviewParam.kt @@ -53,6 +53,8 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider Unit = {}, @@ -83,6 +86,7 @@ fun aPreferencesRootState( showAnalyticsSettings = showAnalyticsSettings, showDeveloperSettings = showDeveloperSettings, showLabsItem = showLabsItem, + showLocationSettings = showLocationSettings, userStatusState = userStatusState, snackbarMessage = snackbarMessage, eventSink = eventSink, diff --git a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt index 1449ece700..a509dbfbd0 100644 --- a/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt +++ b/features/preferences/impl/src/main/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootView.kt @@ -65,6 +65,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.ui.components.MatrixUserRow import io.element.android.libraries.matrix.ui.model.getAvatarData @@ -79,6 +80,7 @@ fun PreferencesRootView( emojiPickerRenderer: EmojiPickerRenderer, onBackClick: () -> Unit, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, onOpenAnalytics: () -> Unit, onOpenLockScreenSettings: () -> Unit, onOpenAbout: () -> Unit, @@ -124,6 +126,7 @@ fun PreferencesRootView( MultiAccountSection( state = state, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, ) } HorizontalDivider( @@ -208,6 +211,7 @@ private fun ColumnScope.UserStatusSection( private fun ColumnScope.MultiAccountSection( state: PreferencesRootState, onAddAccountClick: () -> Unit, + onSwitchToSession: (SessionId) -> Unit, ) { HorizontalDivider( thickness = 8.dp, @@ -275,7 +279,7 @@ private fun ColumnScope.MultiAccountSection( MatrixUserRow( modifier = Modifier .clickable { - state.eventSink(PreferencesRootEvent.SwitchToSession(matrixUser.userId)) + onSwitchToSession(matrixUser.userId) } .padding(top = 2.dp, bottom = 2.dp, end = 8.dp), matrixUser = matrixUser, @@ -330,11 +334,13 @@ private fun AppSettingsSection( leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.Lock())), onClick = onOpenLockScreenSettings, ) - ListItem( - content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, - leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), - onClick = onOpenLocationSettings, - ) + if (state.showLocationSettings) { + ListItem( + content = { Text(stringResource(id = CommonStrings.common_location_sharing)) }, + leadingContent = ListItemContent.Icon(IconSource.Vector(CompoundIcons.LocationPin())), + onClick = onOpenLocationSettings, + ) + } } } @@ -425,6 +431,7 @@ private fun ContentToPreview(state: PreferencesRootState) { emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = {}, onAddAccountClick = {}, + onSwitchToSession = {}, onOpenAnalytics = {}, onOpenDeveloperSettings = {}, onOpenLocationSettings = {}, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt index afc3801c88..80490255a6 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/DefaultPreferencesEntryPointTest.kt @@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.RoomId +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint @@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest { } val callback = object : PreferencesEntryPoint.Callback { override fun navigateToAddAccount() = lambdaError() + override fun navigateToSession(sessionId: SessionId) = lambdaError() override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToBugReport() = lambdaError() override fun navigateToSecureBackup() = lambdaError() diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt index f189949cdc..ed65f9a9f3 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/notifications/NotificationSettingsPresenterTest.kt @@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test +import io.element.android.tests.testutils.testCoroutineDispatchers import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.flow.first import kotlinx.coroutines.test.TestScope @@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest { stringProvider = FakeStringProvider(), sessionCoroutineScope = backgroundScope, featureFlagService = featureFlagService, + dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true), ) } } diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt index 085b74e323..45a0f577eb 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootPresenterTest.kt @@ -13,6 +13,8 @@ package io.element.android.features.preferences.impl.root import app.cash.turbine.ReceiveTurbine import com.google.common.truth.Truth.assertThat import io.element.android.compound.theme.Theme +import io.element.android.features.location.api.LocationService +import io.element.android.features.location.test.FakeLocationService import io.element.android.features.preferences.impl.account.aPreferencesAccountState import io.element.android.features.preferences.impl.userstatus.aUserStatusState import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider @@ -37,8 +39,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.tests.testutils.WarmUpRule import io.element.android.tests.testutils.consumeItemsUntilPredicate -import io.element.android.tests.testutils.lambda.lambdaRecorder -import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.test import kotlinx.collections.immutable.toImmutableList import kotlinx.coroutines.CoroutineScope @@ -78,10 +78,21 @@ class PreferencesRootPresenterTest { assertThat(loadedState.showAnalyticsSettings).isFalse() assertThat(loadedState.showDeveloperSettings).isTrue() assertThat(loadedState.showLabsItem).isFalse() + assertThat(loadedState.showLocationSettings).isTrue() assertThat(loadedState.snackbarMessage).isNull() } } + @Test + fun `present - location settings are hidden when the location service is not available`() = runTest { + createPreferencesRootPresenter( + locationService = FakeLocationService(isServiceAvailable = false), + ).test { + assertThat(awaitItem().showLocationSettings).isFalse() + cancelAndIgnoreRemainingEvents() + } + } + @Test fun `present - user status state is null when the server does not support it`() = runTest { createPreferencesRootPresenter( @@ -166,24 +177,6 @@ class PreferencesRootPresenterTest { } } - @Test - fun `present - switch session invoke method on the session store`() = runTest { - val setLatestSessionResult = lambdaRecorder { } - val sessionStore = InMemorySessionStore( - initialList = listOf( - aSessionData(sessionId = A_SESSION_ID.value), - aSessionData(sessionId = A_SESSION_ID_2.value), - ), - setLatestSessionResult = setLatestSessionResult, - ) - createPreferencesRootPresenter(sessionStore = sessionStore).test { - val loadedState = awaitFirstItem() - loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2)) - setLatestSessionResult.assertions().isCalledOnce() - .with(value(A_SESSION_ID_2.value)) - } - } - @Test fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest { createPreferencesRootPresenter( @@ -291,6 +284,7 @@ class PreferencesRootPresenterTest { featureFlagService: FeatureFlagService = FakeFeatureFlagService(), sessionStore: SessionStore = InMemorySessionStore(), appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(), + locationService: LocationService = FakeLocationService(isServiceAvailable = true), ) = PreferencesRootPresenter( matrixClient = matrixClient, analyticsService = FakeAnalyticsService(), @@ -298,6 +292,7 @@ class PreferencesRootPresenterTest { snackbarDispatcher = SnackbarDispatcher(), showDeveloperSettingsProvider = showDeveloperSettingsProvider, featureFlagService = featureFlagService, + locationService = locationService, sessionStore = sessionStore, appPreferencesStore = appPreferencesStore, userStatusPresenter = { aUserStatusState() }, diff --git a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt index 3afd6cde9d..6b5ba45109 100644 --- a/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt +++ b/features/preferences/impl/src/test/kotlin/io/element/android/features/preferences/impl/root/PreferencesRootViewTest.kt @@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo import androidx.compose.ui.test.v2.runAndroidComposeUiTest import io.element.android.features.preferences.impl.R import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer +import io.element.android.libraries.matrix.api.core.SessionId import io.element.android.libraries.matrix.api.user.MatrixUser import io.element.android.libraries.matrix.test.A_USER_ID_2 import io.element.android.libraries.matrix.ui.components.aMatrixUser @@ -69,23 +70,25 @@ class PreferencesRootViewTest : RobolectricTest() { } @Test - fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest { - val eventsRecorder = EventsRecorder() - setView( - aPreferencesRootState( - isMultiAccountEnabled = true, - isOtherAccountsSectionExpanded = true, - otherSessions = listOf( - aMatrixUser( - id = A_USER_ID_2.value, - displayName = "Bob", - ) + fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest { + val eventsRecorder = EventsRecorder(expectEvents = false) + ensureCalledOnceWithParam(A_USER_ID_2) { callback -> + setView( + aPreferencesRootState( + isMultiAccountEnabled = true, + isOtherAccountsSectionExpanded = true, + otherSessions = listOf( + aMatrixUser( + id = A_USER_ID_2.value, + displayName = "Bob", + ) + ), + eventSink = eventsRecorder, ), - eventSink = eventsRecorder, - ), - ) - onNodeWithText("Bob").performClick() - eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2)) + onSwitchToSession = callback, + ) + onNodeWithText("Bob").performClick() + } } @Test @@ -395,6 +398,7 @@ private fun AndroidComposeUiTest.setView( state: PreferencesRootState, onBackClick: () -> Unit = EnsureNeverCalled(), onAddAccountClick: () -> Unit = EnsureNeverCalled(), + onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(), onOpenAnalytics: () -> Unit = EnsureNeverCalled(), onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(), onOpenAbout: () -> Unit = EnsureNeverCalled(), @@ -418,6 +422,7 @@ private fun AndroidComposeUiTest.setView( emojiPickerRenderer = NoOpEmojiPickerRenderer, onBackClick = onBackClick, onAddAccountClick = onAddAccountClick, + onSwitchToSession = onSwitchToSession, onOpenAnalytics = onOpenAnalytics, onOpenLockScreenSettings = onOpenLockScreenSettings, onOpenAbout = onOpenAbout, diff --git a/features/verifysession/impl/src/main/res/values/localazy.xml b/features/verifysession/impl/src/main/res/values/localazy.xml index 012437792e..5803cc2a95 100644 --- a/features/verifysession/impl/src/main/res/values/localazy.xml +++ b/features/verifysession/impl/src/main/res/values/localazy.xml @@ -2,6 +2,7 @@ "Can\'t confirm?" "Create a new recovery key" + "Sign out" "Choose how to verify to set up secure messaging." "Confirm your digital identity" "Use another device" diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 965aee55c0..da17b68281 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -41,7 +41,7 @@ coil = "3.6.3" # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # When upgrading this version, check state restoration still works fine. appyx = "1.7.1" -sqldelight = "2.4.0" +sqldelight = "2.4.1" wysiwyg = "2.42.1" telephoto = "0.19.0" maplibre-compose = "0.19.0" @@ -62,7 +62,7 @@ detekt_compose_rules = "0.4.28" # See https://github.com/pinterest/ktlint/releases/ ktlint = "1.8.0" androidx-test-ext-junit = "1.3.0" -kover = "0.9.9" +kover = "0.9.11" # The native MatrixRTC call component, https://github.com/element-hq/element-call-android element_call = "0.1.0-rc.7" @@ -186,7 +186,7 @@ test_detekt_test = { module = "io.gitlab.arturbosch.detekt:detekt-test", version # it as a plain `require`: without this, a library built against a newer SDK would silently drag this app # onto it. With it, this pin always wins, and a library that needs something newer fails at configuration # time naming both versions instead of at runtime with NoSuchMethodError. -matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.05-nightly" } } +matrix_sdk = { module = "org.matrix.rustcomponents:sdk-android", version = { strictly = "26.10.12-nightly" } } rustls_platform_verifier = "org.rustls:rustls-platform-verifier:0.2.0" # Others @@ -239,7 +239,7 @@ sigpwned_emoji4j = "com.sigpwned:emoji4j-core:16.0.0" metro_runtime = { module = "dev.zacsweers.metro:runtime", version.ref = "metro" } # Element Call -element_call_embedded = "io.element.android:element-call-embedded:0.27.0" +element_call_embedded = "io.element.android:element-call-embedded:0.28.0" # The native MatrixRTC call component. Versions come from the BOM, so every artifact moves together. element_call_bom = { module = "io.element.android:element-call-bom", version.ref = "element_call" } element_call_api = { module = "io.element.android:element-call-api" } diff --git a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt index 7c5f0450e9..9023a8f935 100644 --- a/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt +++ b/libraries/androidutils/src/main/kotlin/io/element/android/libraries/androidutils/file/File.kt @@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long { Timber.v("Get size of ${it.absolutePath}") true } - .sumOf { it.length() } + // Only sum the actual size of files, not directories, to avoid double counting or worse, + // since the docs mention that `length()` for directories is not reliable. + // For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder, + // which is a common block size on many file systems. + .sumOf { if (it.isFile) it.length() else 4096L } } diff --git a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt index e779b5086f..283aa356be 100644 --- a/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt +++ b/libraries/htmlrenderer/api/src/main/kotlin/io/element/android/libraries/htmlrenderer/api/ui/HtmlMessageContent.kt @@ -15,6 +15,8 @@ import androidx.compose.foundation.clickable import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitFirstDown +import androidx.compose.foundation.gestures.awaitLongPressOrCancellation +import androidx.compose.foundation.gestures.waitForUpOrCancellation import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Box @@ -53,8 +55,7 @@ import androidx.compose.ui.graphics.drawOutline import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.translate -import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException -import androidx.compose.ui.input.pointer.changedToUp +import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.LocalClipboard @@ -565,34 +566,18 @@ private fun Modifier.linkTapHandler( val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture down.consume() - val longPressTimeout = viewConfiguration.longPressTimeoutMillis - - // Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that. - // If we did, it's a tap, otherwise it's a long press. - // This warning is a false positive. - @Suppress("KotlinConstantConditions") - val isLongPress = try { - withTimeout(longPressTimeout) { - var receivedUp = false - while (!receivedUp) { - val event = awaitPointerEvent() - val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() } - if (up != null) { - receivedUp = true - up.consume() - } - } - - !receivedUp - } - } catch (_: PointerEventTimeoutCancellationException) { - true - } - - if (isLongPress) { + // Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event. + if (awaitLongPressOrCancellation(down.id) != null) { context.onLinkLongClick(url, urlText) + // Swallow the rest of the gesture so parents don't react to the eventual release. + waitForUpOrCancellation()?.consume() } else { - context.onLinkClick(url, urlText) + // Either a tap (the pointer was released) or a drag (the user probably wanted to scroll). + val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() } + if (up != null) { + up.consume() + context.onLinkClick(url, urlText) + } } } } diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt index 3b4fd40885..51579a88d4 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/MatrixClient.kt @@ -410,7 +410,7 @@ interface MatrixClient : ClientUrlContentFetcher { /** * Deactivates the account on the homeserver, then destroys this client and deletes every local trace of the session. - * The password is only sent if the homeserver asks for re-authentication, and on failure the session is left untouched. + * On failure the session is left untouched. * * @param password the account password, required to confirm the deactivation. * @param eraseData whether the homeserver should also erase the messages the user sent. @@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher { /** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */ fun homeserverCapabilities(): HomeserverCapabilitiesProvider + + /** + * Returns the total number of unread notifications across all rooms, as currently known by the client. + * This is a snapshot and may be out of date if the sync is running. + */ + fun totalUnreadCount(): Result } /** diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt index e036a198df..40f6ef20a2 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/encryption/EncryptionService.kt @@ -123,6 +123,11 @@ interface EncryptionService { * A handle to reset the user's identity. */ sealed interface IdentityResetHandle { + /** + * The value of the session key given by the homeserver. + */ + val session: String + /** * Cancel the reset process and drops the existing handle in the SDK. */ diff --git a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt index 92f60352d7..25fd1475f1 100644 --- a/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt +++ b/libraries/matrix/api/src/main/kotlin/io/element/android/libraries/matrix/api/sync/SyncService.kt @@ -8,6 +8,7 @@ package io.element.android.libraries.matrix.api.sync +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.StateFlow /** @@ -37,4 +38,10 @@ interface SyncService { * This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online. */ val isOnline: StateFlow + + /** + * 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 } diff --git a/libraries/matrix/impl/build.gradle.kts b/libraries/matrix/impl/build.gradle.kts index 9d025fc9a8..91b0b9c5c3 100644 --- a/libraries/matrix/impl/build.gradle.kts +++ b/libraries/matrix/impl/build.gradle.kts @@ -59,7 +59,11 @@ dependencies { implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) implementation(libs.element.call.api) implementation(projects.features.callnative.api) - implementation(libs.element.call.matrix) + implementation(libs.element.call.matrix) { + if (usesLocalRustSdk) { + exclude(group = "org.matrix.rustcomponents", module = "sdk-android") + } + } implementation(projects.appconfig) implementation(projects.features.enterprise.api) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt index a9eb22cc8b..43b339e06c 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClient.kt @@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.first +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.mapNotNull @@ -127,8 +128,6 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.runBlocking import kotlinx.coroutines.withContext import kotlinx.coroutines.withTimeout -import org.matrix.rustcomponents.sdk.AuthData -import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails import org.matrix.rustcomponents.sdk.BeaconInfoListener import org.matrix.rustcomponents.sdk.BeaconInfoUpdate import org.matrix.rustcomponents.sdk.Client @@ -322,7 +321,7 @@ class RustMatrixClient( override fun onUpdate(profile: UserProfile) { val matrixUser = profile.map() _userProfile.tryEmit(matrixUser) - sessionCoroutineScope.launch { + sessionCoroutineScope.launch(sessionDispatcher) { storeUserProfile(matrixUser) } } @@ -530,7 +529,7 @@ class RustMatrixClient( * Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar, * so that the avatar can be rendered without having to use this client. */ - private suspend fun storeUserProfile(matrixUser: MatrixUser) { + private suspend fun storeUserProfile(matrixUser: MatrixUser) = withContext(sessionDispatcher) { val avatarUrl = matrixUser.avatarUrl val storedSession = sessionStore.getSession(sessionId.value) val avatarData = when { @@ -794,33 +793,10 @@ class RustMatrixClient( clientDelegateTaskHandle?.cancelAndDestroy() clientDelegateTaskHandle = null runCatchingExceptions { - // First call without AuthData, should fail - val firstAttempt = runCatchingExceptions { - innerClient.deactivateAccount( - authData = null, - eraseData = eraseData, - ) - } - if (firstAttempt.isFailure) { - Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again") - // This is expected, try again with the password - runCatchingExceptions { - innerClient.deactivateAccount( - authData = AuthData.Password( - passwordDetails = AuthDataPasswordDetails( - identifier = sessionId.value, - password = password, - ), - ), - eraseData = eraseData, - ) - }.onFailure { - Timber.e(it, "Failed to deactivate account") - // If the deactivation failed we need to restore the delegate - clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate) - throw it - } - } + innerClient.deactivateAccount( + password = password, + eraseData = eraseData, + ) destroy() deleteSessionDirectory() sessionStore.removeSession(sessionId.value) @@ -1024,6 +1000,10 @@ class RustMatrixClient( return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities()) } + override fun totalUnreadCount(): Result = runCatchingExceptions { + innerClient.totalUnreadNotifications().toLong() + } + private fun scheduleBackgroundSearchBackfill() { featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch) .distinctUntilChanged() @@ -1034,6 +1014,8 @@ class RustMatrixClient( searchBackfillService.cancelPeriodicSearchBackfill() } } + // Checking the pending work is a blocking call, do not do it on the main thread + .flowOn(sessionDispatcher) .launchIn(sessionCoroutineScope) } } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt index 90d640e82e..c3644eb959 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationService.kt @@ -345,7 +345,14 @@ class RustMatrixAuthenticationService( it.scan( qrCodeData = qrCodeData.rustQrCodeData, progressListener = progressListener, - ) + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } } // Ensure that the user is not already logged in with the same account ensureNotAlreadyLoggedIn(client) diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt index 7ee9c7d0b3..6bbc8887d4 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/encryption/RustIdentityResetHandle.kt @@ -20,14 +20,14 @@ import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType object RustIdentityResetHandleFactory { fun create( userId: UserId, - identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle? + identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?, ): Result { return runCatchingExceptions { identityResetHandle?.let { when (val authType = identityResetHandle.authType()) { - is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl) + is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl, authType.info.session) // User interactive authentication (user + password) - CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle) + is CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle, authType.info.session) } } } @@ -37,9 +37,12 @@ object RustIdentityResetHandleFactory { class RustPasswordIdentityResetHandle( private val userId: UserId, private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, + override val session: String, ) : IdentityPasswordResetHandle { override suspend fun resetPassword(password: String): Result { - return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) } + return runCatchingExceptions { + identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password, session))) + } } override suspend fun cancel() { @@ -50,9 +53,10 @@ class RustPasswordIdentityResetHandle( class RustIdentityOAuthResetHandle( private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle, override val url: String, + override val session: String, ) : IdentityOAuthResetHandle { override suspend fun resetOAuth(): Result { - return runCatchingExceptions { identityResetHandle.reset(null) } + return runCatchingExceptions { identityResetHandle.reset(AuthData.OAuth(session)) } } override suspend fun cancel() { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt index 8c9695ae4b..e2b417870e 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandler.kt @@ -12,6 +12,7 @@ import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkDesktopStep import io.element.android.libraries.matrix.api.logs.LoggerTags import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.MutableStateFlow @@ -53,8 +54,15 @@ class RustLinkDesktopHandler( } } } - ) - // We emit Done in case the progress listener was deallocated before scan() sent the Done + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } + // We emit Done in case the progress listener was deallocated before the task sent the Done _linkDesktopStep.emit(LinkDesktopStep.Done) } catch (e: QrCodeDecodeException) { Timber.tag(tag.value).w(e, "Invalid QR code scanned") diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt index 33f1ad736d..868721ced9 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandler.kt @@ -11,6 +11,7 @@ import io.element.android.libraries.core.log.logger.LoggerTag import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileHandler import io.element.android.libraries.matrix.api.linknewdevice.LinkMobileStep import io.element.android.libraries.matrix.api.logs.LoggerTags +import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.flow.Flow @@ -48,8 +49,15 @@ class RustLinkMobileHandler( } } } - ) - // We emit Done in case the progress listener was deallocated before generate() sent the Done + ).use { task -> + try { + task.wait() + } catch (e: CancellationException) { + task.cancel() + throw e + } + } + // We emit Done in case the progress listener was deallocated before the task sent the Done _linkMobileStep.emit(LinkMobileStep.Done) } catch (e: HumanQrGrantLoginException.Expired) { // Note: the SDK does not return this error, but is returning `.NotFound` instead when the QR code expires. We catch both just in case. diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt index 17a3910c98..3be63f4f97 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/JoinedRustRoom.kt @@ -158,6 +158,7 @@ class JoinedRustRoom( inner = innerRoom.threadListService(), contentMapper = TimelineEventContentMapper(), roomCoroutineScope = roomCoroutineScope, + roomDispatcher = roomDispatcher, ) override val syncUpdateFlow = flow { diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt index a74c5bc378..98248b183b 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListService.kt @@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.map import io.element.android.libraries.matrix.impl.util.mxCallbackFlow +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.flowOn import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onStart @@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService class RustThreadsListService( private val inner: InnerThreadListService, private val roomCoroutineScope: CoroutineScope, + private val roomDispatcher: CoroutineDispatcher, private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), ) : ThreadsListService { private var itemSubscriptionJob: Job? = null @@ -63,6 +66,8 @@ class RustThreadsListService( updated.apply(diff, contentMapper) items.value = updated } + // Fetching and mapping the items can be costly, do not do it on the main thread + .flowOn(roomDispatcher) .launchIn(roomCoroutineScope) } diff --git a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt index f7732cd9b7..d08e561fb5 100644 --- a/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt +++ b/libraries/matrix/impl/src/main/kotlin/io/element/android/libraries/matrix/impl/sync/RustSyncService.kt @@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import io.element.android.libraries.matrix.impl.roomlist.stateFlow import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.NonCancellable +import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.SharingStarted import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.distinctUntilChanged +import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.stateIn import kotlinx.coroutines.withContext +import org.matrix.rustcomponents.sdk.RoomListServiceState import org.matrix.rustcomponents.sdk.SyncServiceState import timber.log.Timber import java.util.concurrent.atomic.AtomicBoolean @@ -78,4 +82,10 @@ class RustSyncService( .stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle) override val isOnline: StateFlow = 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 = inner.roomListService() + .stateFlow() + .filter { it == RoomListServiceState.RUNNING } + .map {} } diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt index 0ed71e3bfe..d5d8c62515 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/RustMatrixClientFactoryTest.kt @@ -33,7 +33,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.toolbox.test.systemclock.FakeSystemClock import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Test import org.matrix.rustcomponents.sdk.Client @@ -41,6 +43,7 @@ import org.matrix.rustcomponents.sdk.ClientDelegate import java.io.File import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService +@OptIn(ExperimentalCoroutinesApi::class) class RustMatrixClientFactoryTest { @Test fun test() = runTest { @@ -51,6 +54,7 @@ class RustMatrixClientFactoryTest { val result = sut.create(aSessionData()) assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org")) + runCurrent() // 2 workers are scheduled: one for the search backfill and one for the vacuum scheduleWorkersLambda.assertions().isCalledExactly(2) result.destroy() diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt index df564ca710..95047ea452 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/auth/RustMatrixAuthenticationServiceTest.kt @@ -6,6 +6,8 @@ * Please see LICENSE files in the repository root for full details. */ +@file:OptIn(ExperimentalCoroutinesApi::class) + package io.element.android.libraries.matrix.impl.auth import com.google.common.truth.Truth.assertThat @@ -36,7 +38,11 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.testCoroutineDispatchers +import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation +import kotlinx.coroutines.launch import kotlinx.coroutines.test.TestScope +import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest import org.junit.Rule import org.junit.Test @@ -171,6 +177,39 @@ class RustMatrixAuthenticationServiceTest { closeResult.assertions().isCalledOnce() } + @Test + fun `loginWithQrCode cancels the login task when it is cancelled`() = runTest { + val cancelResult = lambdaRecorder {} + val sut = createRustMatrixAuthenticationService( + clientBuilderProvider = FakeClientBuilderProvider( + provideResult = { + FakeFfiClientBuilder( + buildResult = { + FakeFfiClient( + newLoginWithQrCodeHandlerResult = { + FakeFfiLoginWithQrCodeHandler( + scanResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + }, + closeResult = {}, + ) + }, + ) + }, + ), + ) + + val job = backgroundScope.launch { + sut.loginWithQrCode(aSdkQrCodeLoginData()) {} + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData( FakeFfiQrCodeData( baseUrlResult = { A_HOMESERVER_URL }, diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt index 0899b16325..c205010ba4 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeHandler.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgress import org.matrix.rustcomponents.sdk.GrantGeneratedQrLoginProgressListener import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeTask import org.matrix.rustcomponents.sdk.GrantQrLoginProgress import org.matrix.rustcomponents.sdk.GrantQrLoginProgressListener import org.matrix.rustcomponents.sdk.NoHandle @@ -18,21 +19,28 @@ import org.matrix.rustcomponents.sdk.QrCodeData class FakeFfiGrantLoginWithQrCodeHandler( private val generateResult: suspend () -> Unit = {}, private val scanResult: suspend (QrCodeData) -> Unit = {}, + private val cancelResult: () -> Unit = {}, ) : GrantLoginWithQrCodeHandler(NoHandle) { private var generateProgressListener: GrantGeneratedQrLoginProgressListener? = null private var scanProgressListener: GrantQrLoginProgressListener? = null - override suspend fun generate(progressListener: GrantGeneratedQrLoginProgressListener) { + override fun generate(progressListener: GrantGeneratedQrLoginProgressListener): GrantLoginWithQrCodeTask { generateProgressListener = progressListener - generateResult() + return FakeFfiGrantLoginWithQrCodeTask( + waitResult = generateResult, + cancelResult = cancelResult, + ) } fun emitGenerateProgress(progress: GrantGeneratedQrLoginProgress) { generateProgressListener?.onUpdate(progress) } - override suspend fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener) { + override fun scan(qrCodeData: QrCodeData, progressListener: GrantQrLoginProgressListener): GrantLoginWithQrCodeTask { scanProgressListener = progressListener - scanResult(qrCodeData) + return FakeFfiGrantLoginWithQrCodeTask( + waitResult = { scanResult(qrCodeData) }, + cancelResult = cancelResult, + ) } fun emitScanProgress(progress: GrantQrLoginProgress) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt new file mode 100644 index 0000000000..016267365a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiGrantLoginWithQrCodeTask.kt @@ -0,0 +1,20 @@ +/* + * 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.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.GrantLoginWithQrCodeTask +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiGrantLoginWithQrCodeTask( + private val waitResult: suspend () -> Unit = {}, + private val cancelResult: () -> Unit = {}, +) : GrantLoginWithQrCodeTask(NoHandle) { + override suspend fun wait() = waitResult() + override fun cancel() = cancelResult() + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt index 6e6b750643..457b080154 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeHandler.kt @@ -9,6 +9,7 @@ package io.element.android.libraries.matrix.impl.fixtures.fakes import org.matrix.rustcomponents.sdk.GeneratedQrLoginProgressListener import org.matrix.rustcomponents.sdk.LoginWithQrCodeHandler +import org.matrix.rustcomponents.sdk.LoginWithQrCodeTask import org.matrix.rustcomponents.sdk.NoHandle import org.matrix.rustcomponents.sdk.QrCodeData import org.matrix.rustcomponents.sdk.QrLoginProgress @@ -17,16 +18,23 @@ import org.matrix.rustcomponents.sdk.QrLoginProgressListener class FakeFfiLoginWithQrCodeHandler( private val generateResult: suspend () -> Unit = {}, private val scanResult: suspend (QrCodeData) -> Unit = {}, + private val cancelResult: () -> Unit = {}, ) : LoginWithQrCodeHandler(NoHandle) { private var scanProgressListener: QrLoginProgressListener? = null - override suspend fun generate(progressListener: GeneratedQrLoginProgressListener) { - generateResult() + override fun generate(progressListener: GeneratedQrLoginProgressListener): LoginWithQrCodeTask { + return FakeFfiLoginWithQrCodeTask( + waitResult = generateResult, + cancelResult = cancelResult, + ) } - override suspend fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener) { + override fun scan(qrCodeData: QrCodeData, progressListener: QrLoginProgressListener): LoginWithQrCodeTask { scanProgressListener = progressListener - scanResult(qrCodeData) + return FakeFfiLoginWithQrCodeTask( + waitResult = { scanResult(qrCodeData) }, + cancelResult = cancelResult, + ) } fun emitScanProgress(progress: QrLoginProgress) { diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt new file mode 100644 index 0000000000..c3fe5e930a --- /dev/null +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/fixtures/fakes/FakeFfiLoginWithQrCodeTask.kt @@ -0,0 +1,20 @@ +/* + * 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.libraries.matrix.impl.fixtures.fakes + +import org.matrix.rustcomponents.sdk.LoginWithQrCodeTask +import org.matrix.rustcomponents.sdk.NoHandle + +class FakeFfiLoginWithQrCodeTask( + private val waitResult: suspend () -> Unit = {}, + private val cancelResult: () -> Unit = {}, +) : LoginWithQrCodeTask(NoHandle) { + override suspend fun wait() = waitResult() + override fun cancel() = cancelResult() + override fun close() = Unit +} diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt index 8a0a046946..7c8bd09416 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkDesktopHandlerTest.kt @@ -17,9 +17,11 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiContinuati import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLoginWithQrCodeHandler import io.element.android.libraries.matrix.test.QR_CODE_DATA import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -140,6 +142,26 @@ class RustLinkDesktopHandlerTest { } } + @Test + fun `when handleScannedQrCode is cancelled, the task is cancelled`() = runTest { + val cancelResult = lambdaRecorder { } + val handler = FakeFfiGrantLoginWithQrCodeHandler( + scanResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + val sut = createRustLinkDesktopHandler( + handler, + ) + val job = backgroundScope.launch { + sut.handleScannedQrCode(QR_CODE_DATA) + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun TestScope.createRustLinkDesktopHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkDesktopHandler( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt index f1f568e3ca..912c79c0ac 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/linknewdevice/RustLinkMobileHandlerTest.kt @@ -19,9 +19,11 @@ import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiGrantLogin import io.element.android.libraries.matrix.impl.fixtures.fakes.FakeFfiQrCodeData import io.element.android.libraries.matrix.test.QR_CODE_DATA_RECIPROCATE import io.element.android.tests.testutils.ExpectedResult +import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.match import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.awaitCancellation import kotlinx.coroutines.launch import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope @@ -176,6 +178,26 @@ class RustLinkMobileHandlerTest { } } + @Test + fun `when start is cancelled, the task is cancelled`() = runTest { + val cancelResult = lambdaRecorder { } + val handler = FakeFfiGrantLoginWithQrCodeHandler( + generateResult = { awaitCancellation() }, + cancelResult = cancelResult, + ) + val sut = createRustLinkMobileHandler( + handler, + ) + val job = backgroundScope.launch { + sut.start() + } + runCurrent() + cancelResult.assertions().isNeverCalled() + job.cancel() + runCurrent() + cancelResult.assertions().isCalledOnce() + } + private fun TestScope.createRustLinkMobileHandler( handler: FakeFfiGrantLoginWithQrCodeHandler = FakeFfiGrantLoginWithQrCodeHandler(), ) = RustLinkMobileHandler( diff --git a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt index 0fc9ad0603..fd9f311b95 100644 --- a/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt +++ b/libraries/matrix/impl/src/test/kotlin/io/element/android/libraries/matrix/impl/room/threads/RustThreadsListServiceTest.kt @@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_TIMESTAMP import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.tests.testutils.lambda.lambdaRecorder import kotlinx.coroutines.ExperimentalCoroutinesApi +import kotlinx.coroutines.test.StandardTestDispatcher import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runTest @@ -122,6 +123,7 @@ class RustThreadsListServiceTest { ) = RustThreadsListService( inner = inner, roomCoroutineScope = backgroundScope, + roomDispatcher = StandardTestDispatcher(testScheduler), ) private fun aRustThreadListUpdate() = ThreadListUpdate.Append( diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt index 6d45db5041..b864daefcc 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/FakeMatrixClient.kt @@ -102,6 +102,7 @@ class FakeMatrixClient( override val mediaPreviewService: MediaPreviewService = FakeMediaPreviewService(), override val roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(), private val homeserverCapabilitiesProvider: FakeHomeserverCapabilitiesProvider = FakeHomeserverCapabilitiesProvider(), + private val totalUnreadCountResult: () -> Result = { Result.success(0L) }, private val accountManagementUrlResult: (AccountManagementAction?) -> Result = { lambdaError() }, private val resolveRoomAliasResult: suspend (RoomAlias) -> Result> = { Result.success( @@ -468,4 +469,8 @@ class FakeMatrixClient( override fun homeserverCapabilities(): HomeserverCapabilitiesProvider { return homeserverCapabilitiesProvider } + + override fun totalUnreadCount(): Result { + return totalUnreadCountResult() + } } diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt index d65328d7fe..4248bc62d4 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/encryption/FakeIdentityResetHandle.kt @@ -13,6 +13,7 @@ import io.element.android.libraries.matrix.api.encryption.IdentityPasswordResetH class FakeIdentityOAuthResetHandle( override val url: String = "", + override val session: String = "", var resetOAuthLambda: () -> Result = { error("Not implemented") }, var cancelLambda: () -> Unit = { error("Not implemented") }, ) : IdentityOAuthResetHandle { @@ -26,6 +27,7 @@ class FakeIdentityOAuthResetHandle( } class FakeIdentityPasswordResetHandle( + override val session: String = "", var resetPasswordLambda: (String) -> Result = { _ -> error("Not implemented") }, var cancelLambda: () -> Unit = { error("Not implemented") }, ) : IdentityPasswordResetHandle { diff --git a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt index 5bb85fa488..174ec4ce62 100644 --- a/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt +++ b/libraries/matrix/test/src/main/kotlin/io/element/android/libraries/matrix/test/sync/FakeSyncService.kt @@ -11,6 +11,8 @@ package io.element.android.libraries.matrix.test.sync import io.element.android.libraries.core.coroutine.mapState import io.element.android.libraries.matrix.api.sync.SyncService import io.element.android.libraries.matrix.api.sync.SyncState +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -33,6 +35,14 @@ class FakeSyncService( override val isOnline: StateFlow = syncState.mapState { it != SyncState.Offline } + private val heartBeatFlow = MutableSharedFlow(extraBufferCapacity = 1) + + override val heartBeat: Flow = heartBeatFlow + + suspend fun emitHeartBeat() { + heartBeatFlow.emit(Unit) + } + suspend fun emitSyncState(syncState: SyncState) { syncStateFlow.emit(syncState) } diff --git a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt index daaf922ce7..025bf606f5 100644 --- a/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt +++ b/libraries/network/src/main/kotlin/io/element/android/libraries/network/interceptors/DynamicHttpLoggingInterceptor.kt @@ -10,6 +10,7 @@ package io.element.android.libraries.network.interceptors import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import io.element.android.libraries.core.mimetype.MimeTypes import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.preferences.api.store.AppPreferencesStore import kotlinx.coroutines.flow.first @@ -31,7 +32,12 @@ class DynamicHttpLoggingInterceptor( override fun intercept(chain: Interceptor.Chain): Response { // This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() } - loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE + // If the request body is binary, we log only the headers to avoid logging binary data + val isBinaryBody = chain.request().headers["Content-Type"] == MimeTypes.OctetStream + loggingInterceptor.level = when { + logLevel >= LogLevel.DEBUG -> if (isBinaryBody) Level.HEADERS else Level.BODY + else -> Level.NONE + } return loggingInterceptor.intercept(chain) } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt index ab6f01e423..f1c4c9d9a3 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/DefaultPushHistoryService.kt @@ -14,6 +14,7 @@ import android.os.PowerManager import androidx.core.content.getSystemService import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesBinding +import io.element.android.libraries.core.coroutine.CoroutineDispatchers import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.matrix.api.core.EventId @@ -23,23 +24,25 @@ import io.element.android.libraries.push.impl.PushDatabase import io.element.android.libraries.push.impl.db.PushHistory import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.services.toolbox.api.systemclock.SystemClock +import kotlinx.coroutines.withContext import kotlin.time.Instant @ContributesBinding(AppScope::class) class DefaultPushHistoryService( private val pushDatabase: PushDatabase, private val systemClock: SystemClock, + private val dispatchers: CoroutineDispatchers, @ApplicationContext context: Context, ) : PushHistoryService { private val powerManager = context.getSystemService() private val packageName = context.packageName - override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result { - return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } + override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } } - override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result { - return runCatchingExceptions { + override suspend fun insertOrUpdatePushRequests(pushRequests: List): Result = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transaction { for (request in pushRequests) { pushDatabase.pushRequestQueries.insertPushRequest(request) @@ -48,8 +51,8 @@ class DefaultPushHistoryService( } } - override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> { - return runCatchingExceptions { + override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result> = withContext(dispatchers.io) { + runCatchingExceptions { pushDatabase.transactionWithResult { val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() @@ -57,14 +60,14 @@ class DefaultPushHistoryService( } } - override suspend fun removeOldPushRequests(sessionId: SessionId): Result { - return runCatchingExceptions { + override suspend fun removeOldPushRequests(sessionId: SessionId): Result = withContext(dispatchers.io) { + runCatchingExceptions { val keepAmount = 100L pushDatabase.pushRequestQueries.removeOldest(keepAmount) } } - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -91,19 +94,21 @@ class DefaultPushHistoryService( } }.takeIf { it.isNotEmpty() } - pushDatabase.pushHistoryQueries.insertPushHistory( - PushHistory( - pushDate = systemClock.epochMillis(), - providerInfo = providerInfo, - eventId = eventId?.value, - roomId = roomId?.value, - sessionId = sessionId?.value, - hasBeenResolved = if (hasBeenResolved) 1 else 0, - comment = finalComment, + withContext(dispatchers.io) { + pushDatabase.pushHistoryQueries.insertPushHistory( + PushHistory( + pushDate = systemClock.epochMillis(), + providerInfo = providerInfo, + eventId = eventId?.value, + roomId = roomId?.value, + sessionId = sessionId?.value, + hasBeenResolved = if (hasBeenResolved) 1 else 0, + comment = finalComment, + ) ) - ) - // Keep only the last 1_000 events - pushDatabase.pushHistoryQueries.removeOldest(1_000) + // Keep only the last 1_000 events + pushDatabase.pushHistoryQueries.removeOldest(1_000) + } } } diff --git a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt index 3996924322..298abbd441 100644 --- a/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt +++ b/libraries/push/impl/src/main/kotlin/io/element/android/libraries/push/impl/history/PushHistoryService.kt @@ -20,7 +20,7 @@ interface PushHistoryService { * Create a new push history entry. * Do not use directly, prefer using the extension functions. */ - fun onPushResult( + suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, @@ -52,7 +52,7 @@ interface PushHistoryService { suspend fun removeOldPushRequests(sessionId: SessionId): Result } -fun PushHistoryService.onInvalidPushReceived( +suspend fun PushHistoryService.onInvalidPushReceived( providerInfo: String, data: String, ) = onPushResult( @@ -65,7 +65,7 @@ fun PushHistoryService.onInvalidPushReceived( comment = "Invalid or ignored push data:\n$data", ) -fun PushHistoryService.onUnableToRetrieveSession( +suspend fun PushHistoryService.onUnableToRetrieveSession( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -80,7 +80,7 @@ fun PushHistoryService.onUnableToRetrieveSession( comment = "Unable to retrieve session: $reason", ) -fun PushHistoryService.onUnableToResolveEvent( +suspend fun PushHistoryService.onUnableToResolveEvent( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -96,7 +96,7 @@ fun PushHistoryService.onUnableToResolveEvent( comment = "Unable to resolve event: $reason", ) -fun PushHistoryService.onSuccess( +suspend fun PushHistoryService.onSuccess( providerInfo: String, eventId: EventId, roomId: RoomId, @@ -117,7 +117,7 @@ fun PushHistoryService.onSuccess( }, ) -fun PushHistoryService.onDiagnosticPush( +suspend fun PushHistoryService.onDiagnosticPush( providerInfo: String, ) = onPushResult( providerInfo = providerInfo, diff --git a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt index aa9612717a..e7cd342514 100644 --- a/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt +++ b/libraries/push/impl/src/test/kotlin/io/element/android/libraries/push/impl/history/FakePushHistoryService.kt @@ -30,7 +30,7 @@ class FakePushHistoryService( private val getPendingPushRequests: (SessionId, Instant?) -> Result> = { _, _ -> lambdaError() }, private val removeOldPushRequests: (SessionId) -> Result = { lambdaError() }, ) : PushHistoryService { - override fun onPushResult( + override suspend fun onPushResult( providerInfo: String, eventId: EventId?, roomId: RoomId?, diff --git a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt index f183c42225..304a969f9d 100644 --- a/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt +++ b/libraries/session-storage/impl/src/main/kotlin/io/element/android/libraries/sessionstorage/impl/DatabaseSessionStore.kt @@ -23,6 +23,7 @@ import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.map import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.withLock +import kotlinx.coroutines.withContext import timber.log.Timber @SingleIn(AppScope::class) @@ -51,85 +52,93 @@ class DatabaseSessionStore( } override suspend fun addSession(sessionData: SessionData) { - sessionDataMutex.withLock { - val lastUsageIndex = getLastUsageIndex() - database.sessionDataQueries.insertSessionData( - sessionData - .copy( - // position value does not really matter, so just use lastUsageIndex + 1 to ensure that - // the value is always greater than value of any existing account - position = lastUsageIndex + 1, - lastUsageIndex = lastUsageIndex + 1, - ) - .toDbModel() - ) + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val lastUsageIndex = getLastUsageIndex() + database.sessionDataQueries.insertSessionData( + sessionData + .copy( + // position value does not really matter, so just use lastUsageIndex + 1 to ensure that + // the value is always greater than value of any existing account + position = lastUsageIndex + 1, + lastUsageIndex = lastUsageIndex + 1, + ) + .toDbModel() + ) + } } } override suspend fun updateData(sessionData: SessionData) { - sessionDataMutex.withLock { - val result = database.sessionDataQueries.selectByUserId(sessionData.userId) - .executeAsOneOrNull() - ?.toApiModel() + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionData.userId) + .executeAsOneOrNull() + ?.toApiModel() - if (result == null) { - Timber.e("User ${sessionData.userId} not found in session database") - return + if (result == null) { + Timber.e("User ${sessionData.userId} not found in session database") + return@withContext + } + // Copy new data from SDK, but keep application data + database.sessionDataQueries.updateSession( + sessionData.copy( + loginTimestamp = result.loginTimestamp, + position = result.position, + lastUsageIndex = result.lastUsageIndex, + userDisplayName = result.userDisplayName, + userAvatarUrl = result.userAvatarUrl, + userAvatarData = result.userAvatarData, + ).toDbModel() + ) } - // Copy new data from SDK, but keep application data - database.sessionDataQueries.updateSession( - sessionData.copy( - loginTimestamp = result.loginTimestamp, - position = result.position, - lastUsageIndex = result.lastUsageIndex, - userDisplayName = result.userDisplayName, - userAvatarUrl = result.userAvatarUrl, - userAvatarData = result.userAvatarData, - ).toDbModel() - ) } } override suspend fun updateUserProfile(sessionId: String, displayName: String?, avatarUrl: String?, avatarData: String?) { - sessionDataMutex.withLock { + withContext(dispatchers.io) { + sessionDataMutex.withLock { + val result = database.sessionDataQueries.selectByUserId(sessionId) + .executeAsOneOrNull() + ?.toApiModel() + if (result == null) { + Timber.e("User $sessionId not found in session database") + return@withContext + } + database.sessionDataQueries.updateSession( + result.copy( + userDisplayName = displayName, + userAvatarUrl = avatarUrl, + userAvatarData = avatarData, + ).toDbModel() + ) + } + } + } + + override suspend fun setLatestSession(sessionId: String) { + withContext(dispatchers.io) { + val latestSession = getLatestSession() + if (latestSession?.userId == sessionId) { + // Already the latest session + return@withContext + } + val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 val result = database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() if (result == null) { Timber.e("User $sessionId not found in session database") - return + return@withContext + } + sessionDataMutex.withLock { + // Update lastUsageIndex of the session + database.sessionDataQueries.updateSession( + result.copy( + lastUsageIndex = lastUsageIndex + 1, + ).toDbModel() + ) } - database.sessionDataQueries.updateSession( - result.copy( - userDisplayName = displayName, - userAvatarUrl = avatarUrl, - userAvatarData = avatarData, - ).toDbModel() - ) - } - } - - override suspend fun setLatestSession(sessionId: String) { - val latestSession = getLatestSession() - if (latestSession?.userId == sessionId) { - // Already the latest session - return - } - val lastUsageIndex = latestSession?.lastUsageIndex ?: 0 - val result = database.sessionDataQueries.selectByUserId(sessionId) - .executeAsOneOrNull() - ?.toApiModel() - if (result == null) { - Timber.e("User $sessionId not found in session database") - return - } - sessionDataMutex.withLock { - // Update lastUsageIndex of the session - database.sessionDataQueries.updateSession( - result.copy( - lastUsageIndex = lastUsageIndex + 1, - ).toDbModel() - ) } } @@ -140,32 +149,32 @@ class DatabaseSessionStore( ?: -1L } - override suspend fun getLatestSession(): SessionData? { - return sessionDataMutex.withLock { + override suspend fun getLatestSession(): SessionData? = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectLatest() .executeAsOneOrNull() ?.toApiModel() } } - override suspend fun getSession(sessionId: String): SessionData? { - return sessionDataMutex.withLock { + override suspend fun getSession(sessionId: String): SessionData? = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectByUserId(sessionId) .executeAsOneOrNull() ?.toApiModel() } } - override suspend fun getAllSessions(): List { - return sessionDataMutex.withLock { + override suspend fun getAllSessions(): List = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.selectAll() .executeAsList() .map { it.toApiModel() } } } - override suspend fun numberOfSessions(): Int { - return sessionDataMutex.withLock { + override suspend fun numberOfSessions(): Int = withContext(dispatchers.io) { + sessionDataMutex.withLock { database.sessionDataQueries.count() .executeAsOneOrNull() ?.toInt() @@ -181,8 +190,10 @@ class DatabaseSessionStore( } override suspend fun removeSession(sessionId: String) { - sessionDataMutex.withLock { - database.sessionDataQueries.removeSession(sessionId) + withContext(dispatchers.io) { + sessionDataMutex.withLock { + database.sessionDataQueries.removeSession(sessionId) + } } } } diff --git a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt index ee43b88154..6ae99db5e5 100644 --- a/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt +++ b/plugins/src/main/kotlin/extension/VariantDimensionExtension.kt @@ -10,14 +10,17 @@ package extension import com.android.build.api.dsl.VariantDimension +/** + * Adds a String field to the generated BuildConfig class. If [value] is null, the field will be null. + */ fun VariantDimension.buildConfigFieldStr( name: String, - value: String, + value: String?, ) { buildConfigField( type = "String", name = name, - value = "\"$value\"" + value = value?.let { "\"$it\"" } ?: "null" ) } diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png index a7d3c7fbf1..a2fb238031 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:89fa873be57a757cf75383f982407025c6709fb12bf3adce35e09b4801e8ab72 -size 33146 +oid sha256:54394615b9621785f54b1974802f9c2a4808f07abb5f4e4c32a390421e513af3 +size 37567 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png index 7048ba3cfa..f34eec28bf 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:d3e6d3204236f994ed9865eb7f5dec9000b34a3eda7cb0bd85d075a260b5c990 -size 28413 +oid sha256:93b596bdc0e7f228709ac9d06f6e183ca398ea55b3d08d95d24f0f5fa7a42be5 +size 32704 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png index 12289bc905..460c026aae 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:4655a0726c3c763baed203a7d4fdab652dbf87150190b53319d201a5b5279880 -size 37800 +oid sha256:e8ba95c046cbd87f77a575f4b05e6f8dfe456a3072cea0f7b9d6318302555696 +size 42473 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png index afc0332791..22ab1da7d7 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:fce9c2511b6f92bc70db299f6a3b76afd2cebf8c7cea5dfaa83f13119b642370 -size 33265 +oid sha256:ed2510e7bbbd746381d3c6c031adfeef970b4b7002b80ea642df4aa106068aa6 +size 37891 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png index f172017b45..d74e427dd2 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Day_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:88daf59cbe7785f4d70c4e6416382efbabd53129c5ae23997ef3b636fc301143 -size 27053 +oid sha256:b436b82845778365f2f419ca4a0da2b856ca7f424153bda1e1904bc675906ea2 +size 31387 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png index d8f1c03f96..c2d0e7137d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:cfd5fd8b652fd75e8d41d6b4152ff8fa539ecc9d4b7d4e16c6fff02b26a65594 -size 32101 +oid sha256:2c313aba8c31160b759102755059257f082b98abe72ec138fdbdb3c9bde1409c +size 36566 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png index a491f58cea..6197904ce6 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e14f0a6effa9c8ea2819347db478634d5142a4dbd7aa5a4445e77c71815cd4ea -size 27628 +oid sha256:3010901ea3daddd0022ebce001f236f1b76946edf922de64c8fb10e3e63e043e +size 31941 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png index 6cb91d8b3b..f1c966131d 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:efc658d0af41513ba7fec310f908bda4e552a19fb435817928362bf6814d5d8a -size 36586 +oid sha256:4d84fb04e7c4444fdaa942d4ebda166f8b76dcf5fdf14ebc8ff0eabdbbb6d850 +size 41302 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png index e657bc15ca..a9e7ae415f 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:e1ae34e30535c8f313a166d3a8f70f67a63fb7c1b6f8e6a7dcf7c3721861d9d4 -size 32139 +oid sha256:d357c719b4b315da87352e676d9d0d5affff06eff29f8409e2f31dbe76bfec66 +size 36816 diff --git a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png index 8d2548a01d..6d18750ccb 100644 --- a/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png +++ b/tests/uitests/src/test/snapshots/images/features.ftue.impl.sessionverification.choosemode_ChooseSelfVerificationModeView_Night_4_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:6fe07ea37942541f24e5ad3282ea75f85536a8bdf5c02cee0cbbe88b5fe4c30f -size 26268 +oid sha256:7a9578d6356ee7aab713fc67978aa0a5d74cba5f5094d9f589dc05598925a990 +size 30578 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png index 6037f8376f..3400dba011 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1658bb9275f540a293117d7e387948d0fe2b8ed8c3c61bdba94bf4229bc4403b -size 35906 +oid sha256:366574d116e3fa7a822bcb1e3fb73dadcd4830c8b065c59c020a0c87e4f0b43d +size 35927 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png index f5631fc5b4..7a4b958eab 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5df9a053734bd8e175d28174e9ccb14cdd8403322c727b791b7323adcf1b711e -size 35786 +oid sha256:983e3b175aef6a5ed53609634dc9c8734cee1193fc20f34ed4bd82d78e518de6 +size 35892 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png index f1a3b38270..54a8fa84d4 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:73ef55cee2a287ba1ba9f46c0cd902724cd42ac88a36c63195b4d245bd5ac061 -size 39639 +oid sha256:f0bccfc71f3fe376b587128826aa11bba87afa1af2d0736469bcc048a1315607 +size 39566 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png index 1e6c5df990..d709499232 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Day_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:ecb9ad5d977a95e30dde4bc29352f692fda9f73406785dd9a578906173219460 -size 32518 +oid sha256:703e28ef45cba713b32a65ed73ccd594738bcee7121447b1d4521605e2bfb77d +size 32968 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png index 5ce7683510..283e1d3e71 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_0_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:0a46bc1ee0e63d6785a0c9b4ff739f5694432de9cd49c7db6884bf19eceac545 -size 35040 +oid sha256:283d80c3d4656ab9c9137cd3a5c2730d73850ce57711a464cdb54f8d6f070820 +size 35084 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png index 5a99714595..dacc3ee7b8 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_1_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1e1119b78f27ff7c7eac03d7742edf22f73c9e5a7f9c5c8b467c2f2e823da09e -size 34934 +oid sha256:edc5f89c33428fdb6a6fc33d479f9e791dda0554c623360399458d4d2b9521d4 +size 35058 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png index 52597c381d..82b0c9f339 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_2_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:1c9489d83c2ef0f6aeacff1926594ca17784481fa542daa16bbe6bf2749c2122 -size 38823 +oid sha256:2c00a26b76909b5863278c3d9ac001b34630dae9ba39ab47a01c3ec8a3b7e799 +size 38707 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png index e4455a669a..df2d65517d 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.confirmaccountprovider_ConfirmAccountProviderView_Night_3_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:b0cc47f7a7008742d0239cb3a2e239c38d6a2eb581d49c0cd3f99f0a71f5c9ff -size 30670 +oid sha256:2bdb861840006eb27eedf85b0f344624dc611983d8c91310124d29be0c6e5f40 +size 30827 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png index ff3ef1f7f6..2e4348101d 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Day_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:5e70863623c70a3aed1fc947dfb26f19bc31abf563e793f97cf02f1f15e12bb0 -size 21797 +oid sha256:25447835e040327d9f7e257ab5488912ca140e44e55c43201490955c50a17d37 +size 33651 diff --git a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png index 71e0095992..c1f37d1503 100644 --- a/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png +++ b/tests/uitests/src/test/snapshots/images/features.login.impl.screens.onboarding_OnBoardingView_Night_7_en.png @@ -1,3 +1,3 @@ version https://git-lfs.github.com/spec/v1 -oid sha256:76120a9ba1378b8ac2c8963157a6c3d3124114bfef1c5dbac200f7d68bf730db -size 20816 +oid sha256:05eddb1c42f5b7ec5b8dcae7512e17d2ef93f82e6736ec1513df9fe731692435 +size 32379 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png new file mode 100644 index 0000000000..918410e681 --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewDark_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:5316814d8343fece0a09899382353aeb6284a9dc8dab3142242e7bcd58345950 +size 31866 diff --git a/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png new file mode 100644 index 0000000000..2827c76f3c --- /dev/null +++ b/tests/uitests/src/test/snapshots/images/features.preferences.impl.root_PreferencesRootViewLight_7_en.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:84bced32601b0f128981d69f841ed3173befb62b34d67317f94c39e153c76191 +size 33108