mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-03 06:55:41 +00:00
Merge branch 'develop' into feature/bma/swipeAvatarAnnouncement
Account switching is now handled by RootFlowNode, through the onSwitchToAccount callback, instead of HomeEvent.SwitchToAccount. To keep dismissing the swipe avatar announcement when the user switches account by swiping their avatar, HomeView now also sends the new HomeEvent.DismissSwipeAvatarAnnouncement. Also opt in to ExperimentalCoroutinesApi in the HomePresenter tests using advanceTimeBy. Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -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: |
|
||||
|
||||
@@ -73,17 +73,17 @@ jobs:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: ⚙️ Run unit tests
|
||||
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
|
||||
- name: ⚙️ Run screenshot tests
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
|
||||
- name: ⚙️ Generate kover coverage report
|
||||
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 🚫 Upload kover failed coverage reports
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: kover-error-report
|
||||
path: |
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: 🚫 Upload test results on error
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: tests-and-screenshot-tests-results
|
||||
path: |
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode(
|
||||
interface Callback : Plugin {
|
||||
fun navigateToBugReport()
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
@@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode(
|
||||
override fun navigateToAddAccount() {
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
}
|
||||
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
|
||||
}
|
||||
|
||||
@@ -18,7 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.timeline.item.event.MembershipChange
|
||||
import io.element.android.libraries.push.api.notifications.NotificationCleaner
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@@ -34,7 +34,7 @@ class LoggedInEventProcessor(
|
||||
@Assisted private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val roomMembershipObserver: RoomMembershipObserver,
|
||||
private val sessionId: SessionId,
|
||||
private val roomListService: RoomListService,
|
||||
private val syncService: SyncService,
|
||||
private val notificationCleaner: NotificationCleaner,
|
||||
private val dispatchers: CoroutineDispatchers
|
||||
) {
|
||||
@@ -73,9 +73,8 @@ class LoggedInEventProcessor(
|
||||
}
|
||||
.launchIn(childScope)
|
||||
|
||||
// Use the room list service state as a 'heartbeat' update to check existing notifications
|
||||
roomListService.state
|
||||
.filter { it == RoomListService.State.Running }
|
||||
// Use the sync service heartbeat as a 'heartbeat' update to check existing notifications
|
||||
syncService.heartBeat
|
||||
.onEach {
|
||||
notificationCleaner.clearReadRoomsNotifications(sessionId)
|
||||
}
|
||||
|
||||
@@ -14,7 +14,6 @@ import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.lifecycle.lifecycleScope
|
||||
@@ -52,13 +51,13 @@ import io.element.android.features.callnative.api.NativeCallHost
|
||||
import io.element.android.features.createroom.api.CreateRoomEntryPoint
|
||||
import io.element.android.features.enterprise.api.EnterpriseService
|
||||
import io.element.android.features.enterprise.api.SessionEnterpriseService
|
||||
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
|
||||
import io.element.android.features.ftue.api.FtueEntryPoint
|
||||
import io.element.android.features.ftue.api.state.FtueService
|
||||
import io.element.android.features.ftue.api.state.FtueState
|
||||
import io.element.android.features.home.api.HomeEntryPoint
|
||||
import io.element.android.features.linknewdevice.api.LinkNewDeviceEntryPoint
|
||||
import io.element.android.features.location.api.LocalMapTilerConfig
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.networkmonitor.api.NetworkMonitor
|
||||
import io.element.android.features.networkmonitor.api.NetworkStatus
|
||||
@@ -86,6 +85,7 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
@@ -153,7 +153,7 @@ class LoggedInFlowNode(
|
||||
private val analyticsRoomListStateWatcher: AnalyticsRoomListStateWatcher,
|
||||
private val createRoomEntryPoint: CreateRoomEntryPoint,
|
||||
private val activeLiveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
|
||||
private val locationService: LocationService,
|
||||
private val loggedInEventProcessorFactory: LoggedInEventProcessor.Factory,
|
||||
) : BaseFlowNode<LoggedInFlowNode.NavTarget>(
|
||||
backstack = BackStack(
|
||||
@@ -170,6 +170,7 @@ class LoggedInFlowNode(
|
||||
interface Callback : Plugin {
|
||||
fun navigateToBugReport()
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
@@ -357,6 +358,10 @@ class LoggedInFlowNode(
|
||||
override fun navigateToAddAccount() {
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
}
|
||||
homeEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
@@ -449,6 +454,10 @@ class LoggedInFlowNode(
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
|
||||
override fun navigateToLinkNewDevice() {
|
||||
backstack.push(NavTarget.LinkNewDevice)
|
||||
}
|
||||
@@ -636,10 +645,7 @@ class LoggedInFlowNode(
|
||||
enterpriseService.semanticColorsFlow(sessionId = matrixClient.sessionId)
|
||||
}.collectAsState(SemanticColorsLightDark.default)
|
||||
|
||||
val currentMapTilerConfig = LocalMapTilerConfig.current
|
||||
val updatedMapTilerConfig by produceState(currentMapTilerConfig) {
|
||||
value = customMapTilerConfigProvider.get().getOrNull() ?: currentMapTilerConfig
|
||||
}
|
||||
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
|
||||
|
||||
ElementThemeApp(
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
@@ -648,7 +654,7 @@ class LoggedInFlowNode(
|
||||
compoundDark = colors.dark,
|
||||
buildMeta = buildMeta,
|
||||
) {
|
||||
CompositionLocalProvider(LocalMapTilerConfig provides updatedMapTilerConfig) {
|
||||
CompositionLocalProvider(LocalMapTilerConfig provides mapTilerConfig) {
|
||||
val isOnline by syncService.isOnline.collectAsState()
|
||||
// Outside the connectivity indicator so the two strips stack rather than fight: an
|
||||
// offline banner during a call belongs under the call bar, not over it.
|
||||
|
||||
@@ -416,6 +416,12 @@ class RootFlowNode(
|
||||
override fun navigateToAddAccount() {
|
||||
backstack.push(NavTarget.NotLoggedInFlow(null))
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
lifecycleScope.launch {
|
||||
sessionStore.setLatestSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
|
||||
val buildContext = if (savedNavState != null) {
|
||||
|
||||
@@ -12,10 +12,9 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatch
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.CurrentUserMembership
|
||||
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
|
||||
import io.element.android.libraries.matrix.api.roomlist.RoomListService
|
||||
import io.element.android.libraries.matrix.test.A_ROOM_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.roomlist.FakeRoomListService
|
||||
import io.element.android.libraries.matrix.test.sync.FakeSyncService
|
||||
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -155,24 +154,24 @@ class LoggedInEventProcessorTest {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `test notifications are cleared when room list service state is running`() = runTest {
|
||||
fun `test notifications are cleared when sync heartbeat is emitted`() = runTest {
|
||||
val clearReadNotificationsLambda = lambdaRecorder { _: SessionId -> }
|
||||
val snackbarDispatcher = SnackbarDispatcher()
|
||||
val notificationCleaner = FakeNotificationCleaner(clearReadRoomsNotificationsLambda = clearReadNotificationsLambda)
|
||||
val roomListService = FakeRoomListService()
|
||||
val syncService = FakeSyncService()
|
||||
|
||||
val loggedInEventProcessor = createLoggedInEventProcessor(
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
notificationCleaner = notificationCleaner,
|
||||
roomListService = roomListService,
|
||||
syncService = syncService,
|
||||
)
|
||||
|
||||
loggedInEventProcessor.observeEvents(backgroundScope)
|
||||
|
||||
testScheduler.runCurrent()
|
||||
|
||||
// Simulate the room list service state changing to Running
|
||||
roomListService.postState(RoomListService.State.Running)
|
||||
// Simulate a sync heartbeat
|
||||
syncService.emitHeartBeat()
|
||||
|
||||
testScheduler.runCurrent()
|
||||
|
||||
@@ -184,13 +183,13 @@ class LoggedInEventProcessorTest {
|
||||
snackbarDispatcher: SnackbarDispatcher,
|
||||
roomMembershipObserver: RoomMembershipObserver = RoomMembershipObserver(),
|
||||
sessionId: SessionId = A_SESSION_ID,
|
||||
roomListService: FakeRoomListService = FakeRoomListService(),
|
||||
syncService: FakeSyncService = FakeSyncService(),
|
||||
notificationCleaner: FakeNotificationCleaner = FakeNotificationCleaner(),
|
||||
) = LoggedInEventProcessor(
|
||||
snackbarDispatcher = snackbarDispatcher,
|
||||
roomMembershipObserver = roomMembershipObserver,
|
||||
sessionId = sessionId,
|
||||
roomListService = roomListService,
|
||||
syncService = syncService,
|
||||
notificationCleaner = notificationCleaner,
|
||||
dispatchers = testCoroutineDispatchers(),
|
||||
)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+4
@@ -20,16 +20,19 @@ import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.EncryptionService
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
|
||||
@Inject
|
||||
class ChooseSelfVerificationModePresenter(
|
||||
private val matrixClient: MatrixClient,
|
||||
private val encryptionService: EncryptionService,
|
||||
private val directLogoutPresenter: Presenter<DirectLogoutState>,
|
||||
) : Presenter<ChooseSelfVerificationModeState> {
|
||||
@Composable
|
||||
override fun present(): ChooseSelfVerificationModeState {
|
||||
val currentUser by matrixClient.userProfile.collectAsState()
|
||||
val hasDevicesToVerifyAgainst by encryptionService.hasDevicesToVerifyAgainst.collectAsState()
|
||||
val canUseRecoveryKey by produceState<AsyncData<Boolean>>(AsyncData.Uninitialized) {
|
||||
encryptionService.recoveryStateStateFlow
|
||||
@@ -72,6 +75,7 @@ class ChooseSelfVerificationModePresenter(
|
||||
}
|
||||
|
||||
return ChooseSelfVerificationModeState(
|
||||
currentUser = currentUser,
|
||||
buttonsState = buttonsState,
|
||||
directLogoutState = directLogoutState,
|
||||
eventSink = ::handleEvent,
|
||||
|
||||
+2
@@ -10,8 +10,10 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||
|
||||
import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
|
||||
data class ChooseSelfVerificationModeState(
|
||||
val currentUser: MatrixUser,
|
||||
val buttonsState: AsyncData<ButtonsState>,
|
||||
val directLogoutState: DirectLogoutState,
|
||||
val eventSink: (ChooseSelfVerificationModeEvent) -> Unit,
|
||||
|
||||
+6
-1
@@ -11,6 +11,8 @@ package io.element.android.features.ftue.impl.sessionverification.choosemode
|
||||
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
|
||||
class ChooseSelfVerificationModeStatePreviewParam :
|
||||
PreviewParameterProvider<ChooseSelfVerificationModeState> {
|
||||
@@ -43,10 +45,13 @@ class ChooseSelfVerificationModeStatePreviewParam :
|
||||
|
||||
fun aChooseSelfVerificationModeState(
|
||||
buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()),
|
||||
currentUser: MatrixUser = aMatrixUser(),
|
||||
eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {},
|
||||
) = ChooseSelfVerificationModeState(
|
||||
currentUser = currentUser,
|
||||
buttonsState = buttonsState,
|
||||
directLogoutState = aDirectLogoutState(),
|
||||
eventSink = {},
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aButtonsState(
|
||||
|
||||
+34
-13
@@ -12,6 +12,7 @@ import androidx.activity.compose.BackHandler
|
||||
import androidx.activity.compose.LocalActivity
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
@@ -35,12 +36,19 @@ import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCard
|
||||
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardMode
|
||||
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813
|
||||
*/
|
||||
@Composable
|
||||
fun ChooseSelfVerificationModeView(
|
||||
state: ChooseSelfVerificationModeState,
|
||||
@@ -61,7 +69,7 @@ fun ChooseSelfVerificationModeView(
|
||||
title = {},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_signout),
|
||||
text = stringResource(R.string.screen_identity_confirmation_sign_out),
|
||||
onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) }
|
||||
)
|
||||
}
|
||||
@@ -75,6 +83,7 @@ fun ChooseSelfVerificationModeView(
|
||||
subTitle = stringResource(id = R.string.screen_identity_confirmation_subtitle)
|
||||
)
|
||||
},
|
||||
isScrollable = true,
|
||||
footer = {
|
||||
ChooseSelfVerificationModeButtons(
|
||||
state = state,
|
||||
@@ -84,20 +93,31 @@ fun ChooseSelfVerificationModeView(
|
||||
)
|
||||
}
|
||||
) {
|
||||
Row(
|
||||
Column(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
verticalArrangement = Arrangement.spacedBy(24.dp),
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onLearnMore)
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
.semantics {
|
||||
// Note: there is no Role.Link, so we use Role.Button for better accessibility support
|
||||
role = Role.Button
|
||||
},
|
||||
text = stringResource(CommonStrings.action_learn_more),
|
||||
style = ElementTheme.typography.fontBodyLgMedium
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
horizontalArrangement = Arrangement.Center,
|
||||
) {
|
||||
Text(
|
||||
modifier = Modifier
|
||||
.clickable(onClick = onLearnMore)
|
||||
.padding(vertical = 4.dp, horizontal = 16.dp)
|
||||
.semantics {
|
||||
// Note: there is no Role.Link, so we use Role.Button for better accessibility support
|
||||
role = Role.Button
|
||||
},
|
||||
text = stringResource(CommonStrings.action_learn_more),
|
||||
style = ElementTheme.typography.fontBodyLgMedium
|
||||
)
|
||||
}
|
||||
AccountInfoCard(
|
||||
state = AccountInfoCardState(
|
||||
matrixUser = state.currentUser,
|
||||
mode = AccountInfoCardMode.Simple,
|
||||
),
|
||||
)
|
||||
}
|
||||
}
|
||||
@@ -125,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_use_another_device),
|
||||
onClick = onUseAnotherDevice,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Devices()),
|
||||
)
|
||||
}
|
||||
if (state.buttonsState.data.canUseRecoveryKey) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
|
||||
<string name="screen_identity_confirmation_sign_out">"Sign out"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Choose how to verify to set up secure messaging."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirm your digital identity"</string>
|
||||
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
|
||||
|
||||
+28
@@ -14,7 +14,13 @@ import io.element.android.features.logout.api.direct.DirectLogoutState
|
||||
import io.element.android.features.logout.api.direct.aDirectLogoutState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.encryption.RecoveryState
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.libraries.matrix.test.encryption.FakeEncryptionService
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
@@ -34,6 +40,26 @@ class ChooseSessionVerificationModePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - current user is exposed`() = runTest {
|
||||
val presenter = createPresenter(
|
||||
matrixClient = FakeMatrixClient(
|
||||
sessionId = A_SESSION_ID,
|
||||
userDisplayName = A_USER_NAME,
|
||||
userAvatarUrl = AN_AVATAR_URL,
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
assertThat(awaitItem().currentUser).isEqualTo(
|
||||
MatrixUser(
|
||||
userId = A_SESSION_ID,
|
||||
displayName = A_USER_NAME,
|
||||
avatarUrl = AN_AVATAR_URL,
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - state is relayed from EncryptionService, order 1`() = runTest {
|
||||
val encryptionService = FakeEncryptionService()
|
||||
@@ -126,9 +152,11 @@ class ChooseSessionVerificationModePresenterTest {
|
||||
}
|
||||
|
||||
private fun createPresenter(
|
||||
matrixClient: MatrixClient = FakeMatrixClient(),
|
||||
encryptionService: FakeEncryptionService = FakeEncryptionService(),
|
||||
directLogoutPresenter: Presenter<DirectLogoutState> = Presenter<DirectLogoutState> { aDirectLogoutState() }
|
||||
) = ChooseSelfVerificationModePresenter(
|
||||
matrixClient = matrixClient,
|
||||
encryptionService = encryptionService,
|
||||
directLogoutPresenter = directLogoutPresenter,
|
||||
)
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
@@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<ChooseSelfVerificationModeEvent>()
|
||||
setChooseSelfVerificationModeView(
|
||||
aChooseSelfVerificationModeState(eventSink = eventsRecorder),
|
||||
)
|
||||
clickOn(R.string.screen_identity_confirmation_sign_out)
|
||||
eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut)
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView(
|
||||
state: ChooseSelfVerificationModeState,
|
||||
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||
|
||||
+2
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -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
|
||||
}
|
||||
|
||||
+1
@@ -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,
|
||||
|
||||
+18
-4
@@ -14,6 +14,7 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableIntStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.saveable.rememberSaveable
|
||||
@@ -34,9 +35,14 @@ import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.FlowPreview
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.debounce
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.mapLatest
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
@Inject
|
||||
class HomePresenter(
|
||||
@@ -53,10 +59,20 @@ class HomePresenter(
|
||||
) : Presenter<HomeState> {
|
||||
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
||||
|
||||
@OptIn(FlowPreview::class, ExperimentalCoroutinesApi::class)
|
||||
@Composable
|
||||
override fun present(): HomeState {
|
||||
val coroutineState = rememberCoroutineScope()
|
||||
val matrixUser by client.userProfile.collectAsState()
|
||||
|
||||
val totalUnreadCount by produceState(client.totalUnreadCount().getOrDefault(0).toInt()) {
|
||||
syncService.heartBeat
|
||||
// Delay the heartbeat so the SDK has time to update the unread count before we read it.
|
||||
.debounce(100.milliseconds)
|
||||
.mapLatest { client.totalUnreadCount().getOrDefault(0).toInt() }
|
||||
.collect { value = it }
|
||||
}
|
||||
|
||||
val currentUserAndNeighbors by remember {
|
||||
combine(
|
||||
client.userProfile,
|
||||
@@ -110,11 +126,8 @@ class HomePresenter(
|
||||
is HomeEvent.SelectHomeNavigationBarItem -> {
|
||||
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
||||
}
|
||||
is HomeEvent.SwitchToAccount -> coroutineState.launch {
|
||||
// The user has switched account by swiping their avatar, so they know the gesture.
|
||||
// Do it before switching, since it can stop this coroutine.
|
||||
HomeEvent.DismissSwipeAvatarAnnouncement -> coroutineState.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
|
||||
sessionStore.setLatestSession(event.sessionId.value)
|
||||
}
|
||||
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
|
||||
@@ -133,6 +146,7 @@ class HomePresenter(
|
||||
snackbarMessage = snackbarMessage,
|
||||
canReportBug = canReportBug,
|
||||
showMultiAccountAnnouncement = showMultiAccountAnnouncement,
|
||||
totalUnreadCount = totalUnreadCount,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
}
|
||||
|
||||
@@ -29,6 +29,7 @@ data class HomeState(
|
||||
val snackbarMessage: SnackbarMessage?,
|
||||
val canReportBug: Boolean,
|
||||
val showMultiAccountAnnouncement: Boolean,
|
||||
val totalUnreadCount: Int,
|
||||
val eventSink: (HomeEvent) -> Unit,
|
||||
) {
|
||||
val isBackHandlerEnabled = currentHomeNavigationBarItem != HomeNavigationBarItem.Chats || roomListState.spaceFiltersState is SpaceFiltersState.Selected
|
||||
|
||||
+2
@@ -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,
|
||||
)
|
||||
|
||||
+13
-2
@@ -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 = {},
|
||||
|
||||
+2
@@ -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,
|
||||
|
||||
+48
-16
@@ -28,8 +28,6 @@ import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID
|
||||
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.libraries.matrix.test.A_USER_NAME
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
@@ -43,10 +41,12 @@ import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
import kotlin.time.Duration.Companion.milliseconds
|
||||
|
||||
class HomePresenterTest {
|
||||
@get:Rule
|
||||
@@ -121,6 +121,40 @@ class HomePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - total unread count is updated on sync heartbeat`() = runTest {
|
||||
var unreadCount = 0L
|
||||
val syncService = FakeSyncService()
|
||||
val presenter = createHomePresenter(
|
||||
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(unreadCount) }),
|
||||
syncService = syncService,
|
||||
)
|
||||
presenter.test {
|
||||
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
|
||||
unreadCount = 5
|
||||
syncService.emitHeartBeat()
|
||||
advanceTimeBy(200.milliseconds)
|
||||
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
|
||||
val syncService = FakeSyncService()
|
||||
val presenter = createHomePresenter(
|
||||
client = FakeMatrixClient(totalUnreadCountResult = { Result.failure(AN_EXCEPTION) }),
|
||||
syncService = syncService,
|
||||
)
|
||||
presenter.test {
|
||||
assertThat(awaitItem().totalUnreadCount).isEqualTo(0)
|
||||
syncService.emitHeartBeat()
|
||||
advanceTimeBy(200.milliseconds)
|
||||
expectNoEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - should start with no user and then load user with error`() = runTest {
|
||||
val matrixClient = FakeMatrixClient(
|
||||
@@ -252,31 +286,29 @@ class HomePresenterTest {
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - switching account dismisses the swipe avatar announcement`() = runTest {
|
||||
fun `present - dismissing the swipe avatar announcement`() = runTest {
|
||||
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
|
||||
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
|
||||
val presenter = createHomePresenter(
|
||||
client = FakeMatrixClient(sessionId = A_SESSION_ID),
|
||||
sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(sessionId = A_SESSION_ID.value),
|
||||
aSessionData(sessionId = A_SESSION_ID_2.value),
|
||||
),
|
||||
updateUserProfileResult = { _, _, _, _ -> },
|
||||
setLatestSessionResult = setLatestSessionResult,
|
||||
),
|
||||
announcementService = FakeAnnouncementService(
|
||||
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
|
||||
),
|
||||
)
|
||||
presenter.test {
|
||||
val state = expectMostRecentItem()
|
||||
state.eventSink(HomeEvent.SwitchToAccount(A_SESSION_ID_2))
|
||||
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
|
||||
runCurrent()
|
||||
onAnnouncementDismissedResult.assertions().isCalledOnce()
|
||||
.with(value(Announcement.SwipeAvatarToSwitchAccount))
|
||||
setLatestSessionResult.assertions().isCalledOnce()
|
||||
.with(value(A_SESSION_ID_2.value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - total unread count is loaded in the initial state`() = runTest {
|
||||
val presenter = createHomePresenter(
|
||||
client = FakeMatrixClient(totalUnreadCountResult = { Result.success(5) }),
|
||||
)
|
||||
presenter.test {
|
||||
assertThat(awaitItem().totalUnreadCount).isEqualTo(5)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -331,6 +331,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
|
||||
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
|
||||
onSettingsClick = onSettingsClick,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSwitchToAccount = EnsureNeverCalledWithParam(),
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onStartChatClick = onCreateRoomClick,
|
||||
|
||||
@@ -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"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+13
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+13
-2
@@ -8,10 +8,21 @@
|
||||
|
||||
package io.element.android.features.location.api
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
* Tells whether the location features can be used at all, which depends on the build having a map provider configured.
|
||||
* Tells whether the location features can be used at all, which depends on a map provider being configured,
|
||||
* either by the homeserver or at build time.
|
||||
*/
|
||||
interface LocationService {
|
||||
/**
|
||||
* The [MapTilerConfig] to use to render the maps, or null if no map provider is configured.
|
||||
* The configuration provided by the homeserver takes precedence over the one defined at build time.
|
||||
*/
|
||||
val mapTilerConfig: StateFlow<MapTilerConfig?>
|
||||
|
||||
/** Whether location sharing and map rendering are available; `false` disables the whole feature in the UI. */
|
||||
fun isServiceAvailable(): Boolean
|
||||
fun isServiceAvailable(): StateFlow<Boolean> = mapTilerConfig.mapState { it != null }
|
||||
}
|
||||
|
||||
+10
-8
@@ -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,
|
||||
|
||||
+33
@@ -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
|
||||
}
|
||||
+4
-12
@@ -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(
|
||||
|
||||
+6
-21
@@ -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}"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -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) }
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -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()
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -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(
|
||||
|
||||
+25
-36
@@ -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=")
|
||||
}
|
||||
}
|
||||
|
||||
+24
-7
@@ -8,14 +8,31 @@
|
||||
|
||||
package io.element.android.features.location.impl
|
||||
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.features.location.api.BuildConfig
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.api.buildTimeMapTilerConfig
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.flow
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultLocationService : LocationService {
|
||||
override fun isServiceAvailable(): Boolean {
|
||||
return BuildConfig.MAPTILER_API_KEY.isNotEmpty()
|
||||
}
|
||||
@SingleIn(SessionScope::class)
|
||||
@ContributesBinding(SessionScope::class)
|
||||
class DefaultLocationService(
|
||||
customMapTilerConfigProvider: CustomMapTilerConfigProvider,
|
||||
@SessionCoroutineScope sessionCoroutineScope: CoroutineScope,
|
||||
) : LocationService {
|
||||
override val mapTilerConfig: StateFlow<MapTilerConfig?> = flow {
|
||||
customMapTilerConfigProvider.get().getOrNull()?.let { emit(it) }
|
||||
}.stateIn(
|
||||
scope = sessionCoroutineScope,
|
||||
started = SharingStarted.Eagerly,
|
||||
initialValue = buildTimeMapTilerConfig(),
|
||||
)
|
||||
}
|
||||
|
||||
+4
-9
@@ -14,7 +14,6 @@ import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.derivedStateOf
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.produceState
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
@@ -22,7 +21,7 @@ import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.location.impl.common.LocationConstraintsCheck
|
||||
import io.element.android.features.location.impl.common.MapDefaults
|
||||
@@ -38,7 +37,6 @@ import io.element.android.features.location.impl.common.userlocation.UserLocatio
|
||||
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||
import io.element.android.features.messages.api.MessageComposerContext
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingState
|
||||
import io.element.android.libraries.core.extensions.flatMap
|
||||
@@ -73,7 +71,7 @@ class ShareLocationPresenter(
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val liveLocationStore: LiveLocationStore,
|
||||
private val userLocationStateFactory: UserLocationState.Factory,
|
||||
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
|
||||
private val locationService: LocationService,
|
||||
) : Presenter<ShareLocationState> {
|
||||
@AssistedFactory
|
||||
fun interface Factory {
|
||||
@@ -94,10 +92,7 @@ class ShareLocationPresenter(
|
||||
var pendingLiveLocationShare by remember { mutableStateOf(false) }
|
||||
val startLiveLocationAction = remember { mutableStateOf<AsyncAction<Unit>>(AsyncAction.Uninitialized) }
|
||||
val currentUser by client.userProfile.collectAsState()
|
||||
val customMapConfig by produceState(AsyncData.Loading()) {
|
||||
// Ignore errors
|
||||
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
|
||||
}
|
||||
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
|
||||
val sendLiveLocationPermissions by room.permissionsAsState(SendLiveLocationPermissions.DEFAULT) { perms ->
|
||||
perms.sendLiveLocationPermissions()
|
||||
}
|
||||
@@ -201,7 +196,7 @@ class ShareLocationPresenter(
|
||||
}
|
||||
|
||||
return ShareLocationState(
|
||||
customMapTilerConfig = customMapConfig,
|
||||
mapTilerConfig = mapTilerConfig,
|
||||
currentUser = currentUser,
|
||||
dialogState = dialogState,
|
||||
trackUserLocation = trackUserPosition,
|
||||
|
||||
+1
-2
@@ -12,12 +12,11 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.common.userlocation.UserLocationState
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ShareLocationState(
|
||||
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
|
||||
val mapTilerConfig: MapTilerConfig?,
|
||||
val currentUser: MatrixUser,
|
||||
val dialogState: Dialog,
|
||||
val trackUserLocation: Boolean,
|
||||
|
||||
+4
-4
@@ -12,8 +12,8 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.common.userlocation.UserLocationState
|
||||
import io.element.android.features.location.impl.show.aPreviewMapTilerConfig
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
@@ -71,13 +71,13 @@ class ShareLocationStatePreviewParam : PreviewParameterProvider<ShareLocationSta
|
||||
canShareLiveLocation = true,
|
||||
),
|
||||
aShareLocationState(
|
||||
customMapTilerConfig = AsyncData.Loading(),
|
||||
mapTilerConfig = null,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
fun aShareLocationState(
|
||||
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
|
||||
mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(),
|
||||
currentUser: MatrixUser = MatrixUser(UserId("@user:matrix.org")),
|
||||
dialogState: ShareLocationState.Dialog = ShareLocationState.Dialog.None,
|
||||
trackUserPosition: Boolean = false,
|
||||
@@ -88,7 +88,7 @@ fun aShareLocationState(
|
||||
eventSink: (ShareLocationEvent) -> Unit = {},
|
||||
): ShareLocationState {
|
||||
return ShareLocationState(
|
||||
customMapTilerConfig = customMapTilerConfig,
|
||||
mapTilerConfig = mapTilerConfig,
|
||||
currentUser = currentUser,
|
||||
dialogState = dialogState,
|
||||
trackUserLocation = trackUserPosition,
|
||||
|
||||
+2
-2
@@ -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,
|
||||
|
||||
+4
-8
@@ -22,8 +22,8 @@ import androidx.compose.runtime.setValue
|
||||
import dev.zacsweers.metro.Assisted
|
||||
import dev.zacsweers.metro.AssistedFactory
|
||||
import dev.zacsweers.metro.AssistedInject
|
||||
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.api.ShowLocationMode
|
||||
import io.element.android.features.location.api.live.ActiveLiveLocationShareManager
|
||||
import io.element.android.features.location.api.live.isCurrentlySharing
|
||||
@@ -39,7 +39,6 @@ import io.element.android.features.location.impl.common.toDialogState
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.common.userlocation.UserLocationState
|
||||
import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.core.meta.BuildMeta
|
||||
@@ -68,7 +67,7 @@ class ShowLocationPresenter(
|
||||
private val joinedRoom: JoinedRoom,
|
||||
private val liveLocationShareManager: ActiveLiveLocationShareManager,
|
||||
private val userLocationStateFactory: UserLocationState.Factory,
|
||||
private val customMapTilerConfigProvider: CustomMapTilerConfigProvider,
|
||||
private val locationService: LocationService,
|
||||
) : Presenter<ShowLocationState> {
|
||||
@AssistedFactory fun interface Factory {
|
||||
fun create(mode: ShowLocationMode): ShowLocationPresenter
|
||||
@@ -86,10 +85,7 @@ class ShowLocationPresenter(
|
||||
mutableStateOf(LocationConstraintsDialogState.None)
|
||||
}
|
||||
|
||||
val customMapTilerConfig by produceState(AsyncData.Loading()) {
|
||||
// Ignore errors
|
||||
value = AsyncData.Success(customMapTilerConfigProvider.get().getOrNull())
|
||||
}
|
||||
val mapTilerConfig by locationService.mapTilerConfig.collectAsState()
|
||||
|
||||
fun checkLocationConstraints() {
|
||||
val locationConstraints = checkLocationConstraints(
|
||||
@@ -221,7 +217,7 @@ class ShowLocationPresenter(
|
||||
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
|
||||
}
|
||||
return ShowLocationState(
|
||||
customMapTilerConfig = customMapTilerConfig,
|
||||
mapTilerConfig = mapTilerConfig,
|
||||
dialogState = dialogState,
|
||||
locationShares = locationShares,
|
||||
focusedLocation = focusedLocation,
|
||||
|
||||
+1
-2
@@ -13,7 +13,6 @@ import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.common.ui.LocationMarkerData
|
||||
import io.element.android.features.location.impl.common.userlocation.UserLocationState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.PinVariant
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
@@ -21,7 +20,7 @@ import io.element.android.libraries.matrix.api.room.location.AssetType
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
data class ShowLocationState(
|
||||
val customMapTilerConfig: AsyncData<MapTilerConfig?>,
|
||||
val mapTilerConfig: MapTilerConfig?,
|
||||
val isLive: Boolean,
|
||||
val dialogState: LocationConstraintsDialogState,
|
||||
val locationShares: ImmutableList<LocationShareItem>,
|
||||
|
||||
+10
-4
@@ -13,7 +13,6 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.common.userlocation.UserLocationState
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarData
|
||||
import io.element.android.libraries.designsystem.components.avatar.AvatarSize
|
||||
import io.element.android.libraries.designsystem.preview.USER_NAME_ALICE
|
||||
@@ -37,14 +36,21 @@ class ShowLocationStatePreviewParam : PreviewParameterProvider<ShowLocationState
|
||||
constraintsDialogState = LocationConstraintsDialogState.LocationServiceDisabled,
|
||||
),
|
||||
aShowLocationState(isTrackMyLocation = true),
|
||||
aShowLocationState(customMapTilerConfig = AsyncData.Loading()),
|
||||
aShowLocationState(mapTilerConfig = null),
|
||||
)
|
||||
}
|
||||
|
||||
private const val APP_NAME = "ApplicationName"
|
||||
|
||||
internal fun aPreviewMapTilerConfig() = MapTilerConfig(
|
||||
apiKey = "preview_api_key",
|
||||
lightStyleId = null,
|
||||
darkStyleId = null,
|
||||
baseUrl = null,
|
||||
)
|
||||
|
||||
fun aShowLocationState(
|
||||
customMapTilerConfig: AsyncData<MapTilerConfig?> = AsyncData.Success(null),
|
||||
mapTilerConfig: MapTilerConfig? = aPreviewMapTilerConfig(),
|
||||
isLive: Boolean = false,
|
||||
constraintsDialogState: LocationConstraintsDialogState = LocationConstraintsDialogState.None,
|
||||
locationShares: List<LocationShareItem> = listOf(aLocationShareItem(isLive = isLive)),
|
||||
@@ -56,7 +62,7 @@ fun aShowLocationState(
|
||||
eventSink: (ShowLocationEvent) -> Unit = {},
|
||||
): ShowLocationState {
|
||||
return ShowLocationState(
|
||||
customMapTilerConfig = customMapTilerConfig,
|
||||
mapTilerConfig = mapTilerConfig,
|
||||
dialogState = constraintsDialogState,
|
||||
locationShares = locationShares.toImmutableList(),
|
||||
focusedLocation = focusedLocation,
|
||||
|
||||
+2
-2
@@ -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 {
|
||||
|
||||
+45
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+9
-10
@@ -19,9 +19,9 @@ import app.cash.turbine.ReceiveTurbine
|
||||
import app.cash.turbine.test
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import im.vector.app.features.analytics.plan.Composer
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
|
||||
import io.element.android.features.location.api.Location
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.impl.aPermissionsState
|
||||
import io.element.android.features.location.impl.common.FakeUserLocationStateFactory
|
||||
import io.element.android.features.location.impl.common.actions.FakeLocationActions
|
||||
@@ -31,6 +31,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
|
||||
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
|
||||
import io.element.android.features.location.impl.live.LiveLocationStore
|
||||
import io.element.android.features.location.test.FakeActiveLiveLocationShareManager
|
||||
import io.element.android.features.location.test.FakeLocationService
|
||||
import io.element.android.features.messages.test.FakeMessageComposerContext
|
||||
import io.element.android.libraries.dateformatter.test.FakeDurationFormatter
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -89,7 +90,7 @@ class ShareLocationPresenterTest {
|
||||
liveLocationShareManager: FakeActiveLiveLocationShareManager = FakeActiveLiveLocationShareManager(),
|
||||
liveLocationStore: LiveLocationStore = createLiveLocationStore(sessionId = joinedRoom.sessionId),
|
||||
client: FakeMatrixClient = fakeMatrixClient,
|
||||
customMapTilerConfigProvider: () -> Result<MapTilerConfig?> = { Result.success(null) },
|
||||
locationService: LocationService = FakeLocationService(mapTilerConfig = aMapTilerConfig()),
|
||||
): ShareLocationPresenter = ShareLocationPresenter(
|
||||
permissionsPresenterFactory = { fakePermissionsPresenter },
|
||||
room = joinedRoom,
|
||||
@@ -103,7 +104,7 @@ class ShareLocationPresenterTest {
|
||||
liveLocationShareManager = liveLocationShareManager,
|
||||
liveLocationStore = liveLocationStore,
|
||||
userLocationStateFactory = FakeUserLocationStateFactory(),
|
||||
customMapTilerConfigProvider = customMapTilerConfigProvider,
|
||||
locationService = locationService,
|
||||
)
|
||||
|
||||
@Test
|
||||
@@ -118,26 +119,24 @@ class ShareLocationPresenterTest {
|
||||
val shareLocationPresenter = createShareLocationPresenter()
|
||||
shareLocationPresenter.test {
|
||||
val state = awaitFirstItem()
|
||||
assertThat(state.customMapTilerConfig.isLoading()).isFalse()
|
||||
assertThat(state.mapTilerConfig).isNotNull()
|
||||
assertThat(state.trackUserLocation).isTrue()
|
||||
assertThat(state.dialogState).isEqualTo(ShareLocationState.Dialog.Constraints(LocationConstraintsDialogState.None))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - non-null customMapStyleUrl`() = runTest {
|
||||
fun `present - mapTilerConfig is provided by the location service`() = runTest {
|
||||
val mapTilerConfig = aMapTilerConfig(apiKey = "A KEY")
|
||||
val shareLocationPresenter = createShareLocationPresenter(
|
||||
client = FakeMatrixClient(
|
||||
sessionId = A_USER_ID,
|
||||
),
|
||||
customMapTilerConfigProvider = { Result.success(mapTilerConfig) }
|
||||
locationService = FakeLocationService(mapTilerConfig = mapTilerConfig),
|
||||
)
|
||||
shareLocationPresenter.test {
|
||||
val state = awaitItem()
|
||||
assertThat(state.customMapTilerConfig.isLoading()).isTrue()
|
||||
val finalState = awaitItem()
|
||||
assertThat(finalState.customMapTilerConfig.dataOrNull()).isEqualTo(mapTilerConfig)
|
||||
assertThat(awaitItem().mapTilerConfig).isEqualTo(mapTilerConfig)
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
+2
-1
@@ -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(),
|
||||
|
||||
+10
-14
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-2
@@ -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)
|
||||
}
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ class ConfirmAccountProviderNode(
|
||||
ConfirmAccountProviderView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = ::navigateUp,
|
||||
onOAuthDetails = callback::navigateToOAuth,
|
||||
onNeedLoginPassword = callback::navigateToLoginPassword,
|
||||
onLearnMoreClick = { openLearnMorePage(context) },
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider<Co
|
||||
)
|
||||
}
|
||||
|
||||
private fun aConfirmAccountProviderState(
|
||||
internal fun aConfirmAccountProviderState(
|
||||
accountProviderInput: String = "matrix.org",
|
||||
accountProviderSuggestion: String? = null,
|
||||
isAccountCreation: Boolean = false,
|
||||
|
||||
+12
-1
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -50,12 +51,14 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
@@ -71,6 +74,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
fun ConfirmAccountProviderView(
|
||||
state: ConfirmAccountProviderState,
|
||||
onBackClick: () -> Unit,
|
||||
onOAuthDetails: (OAuthDetails) -> Unit,
|
||||
onNeedLoginPassword: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
@@ -118,9 +122,15 @@ fun ConfirmAccountProviderView(
|
||||
// Let the header and content scroll so the supporting text is not clipped by the footer when the
|
||||
// keyboard is visible and vertical space is reduced.
|
||||
isScrollable = true,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
header = {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(top = 60.dp),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
|
||||
title = stringResource(
|
||||
id = if (state.isAccountCreation) {
|
||||
@@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview(
|
||||
) = ElementPreview {
|
||||
ConfirmAccountProviderView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onOAuthDetails = {},
|
||||
onNeedLoginPassword = {},
|
||||
onLearnMoreClick = {},
|
||||
|
||||
+6
-2
@@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607
|
||||
*/
|
||||
@Composable
|
||||
private fun AddOtherAccountScaffold(
|
||||
loginView: @Composable () -> Unit,
|
||||
@@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold(
|
||||
) {
|
||||
FlowStepPage(
|
||||
modifier = modifier,
|
||||
title = stringResource(CommonStrings.common_add_account),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
|
||||
title = stringResource(R.string.screen_onboarding_add_account_title),
|
||||
subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()),
|
||||
buttons = { buttons() },
|
||||
content = loginView,
|
||||
onBackClick = onBackClick,
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
|
||||
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
|
||||
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
|
||||
<string name="screen_onboarding_add_account_subtitle">"You can add multiple accounts to the app and switch between them easily."</string>
|
||||
<string name="screen_onboarding_add_account_title">"Sign in to another account"</string>
|
||||
<string name="screen_onboarding_app_version">"Version %1$s"</string>
|
||||
<string name="screen_onboarding_checking_account">"Checking account"</string>
|
||||
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
|
||||
class ConfirmAccountProviderViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventSink = EventsRecorder<ConfirmAccountProviderEvent>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
setConfirmAccountProviderView(
|
||||
state = aConfirmAccountProviderState(
|
||||
eventSink = eventSink,
|
||||
),
|
||||
onBackClick = it,
|
||||
)
|
||||
pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setConfirmAccountProviderView(
|
||||
state: ConfirmAccountProviderState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
|
||||
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
ConfirmAccountProviderView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onOAuthDetails = onOAuthDetails,
|
||||
onNeedLoginPassword = onNeedLoginPassword,
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+1
-1
@@ -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
|
||||
|
||||
+53
-43
@@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||
@@ -145,6 +147,7 @@ class MessageComposerPresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -184,31 +187,28 @@ class MessageComposerPresenter(
|
||||
|
||||
val cameraPermissionState = cameraPermissionPresenter.present()
|
||||
|
||||
val canShareLocation = remember { mutableStateOf(false) }
|
||||
LaunchedEffect(Unit) {
|
||||
canShareLocation.value = locationService.isServiceAvailable()
|
||||
}
|
||||
val canShareLocation by remember { locationService.isServiceAvailable() }.collectAsState()
|
||||
|
||||
val isSendGalleryMessagesEnabled by featureFlagService.isFeatureEnabledFlow(FeatureFlags.SendGalleryMessages)
|
||||
.collectAsState(initial = false)
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType)
|
||||
localCoroutineScope.handlePickedMedia(uri, mimeType)
|
||||
}
|
||||
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
|
||||
handlePickedMediaList(uris)
|
||||
localCoroutineScope.handlePickedMediaList(uris)
|
||||
}
|
||||
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
|
||||
handlePickedMediaList(uris, sendAsFile = true)
|
||||
localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true)
|
||||
}
|
||||
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
|
||||
localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
|
||||
}
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||
handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||
}
|
||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||
handlePickedMedia(uri, MimeTypes.Mp4)
|
||||
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4)
|
||||
}
|
||||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
@@ -286,17 +286,16 @@ class MessageComposerPresenter(
|
||||
}
|
||||
is MessageComposerEvent.SendUri -> {
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
sessionCoroutineScope.sendAttachment(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = localMediaFactory.createFromUri(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
),
|
||||
),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
sessionCoroutineScope.launch {
|
||||
val localMedia = createLocalMedia(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
)
|
||||
sendAttachment(
|
||||
attachment = Attachment.Media(localMedia),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
@@ -431,7 +430,7 @@ class MessageComposerPresenter(
|
||||
isInThreadTimeline = isInThread,
|
||||
showAttachmentSourcePicker = showAttachmentSourcePicker,
|
||||
showTextFormatting = showTextFormatting,
|
||||
canShareLocation = canShareLocation.value,
|
||||
canShareLocation = canShareLocation,
|
||||
suggestions = suggestions.toImmutableList(),
|
||||
resolveMentionDisplay = resolveMentionDisplay,
|
||||
resolveAtRoomMentionDisplay = resolveAtRoomMentionDisplay,
|
||||
@@ -640,17 +639,28 @@ class MessageComposerPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePickedMedia(
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
sendAsFile: Boolean = false,
|
||||
) {
|
||||
uri ?: return
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
// Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread
|
||||
private suspend fun createLocalMedia(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
) = withContext(dispatchers.io) {
|
||||
localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
formattedFileSize = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handlePickedMedia(
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
sendAsFile: Boolean = false,
|
||||
) = launch {
|
||||
uri ?: return@launch
|
||||
val localMedia = createLocalMedia(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
@@ -659,7 +669,7 @@ class MessageComposerPresenter(
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
|
||||
private fun handlePickedMediaList(
|
||||
private fun CoroutineScope.handlePickedMediaList(
|
||||
uris: List<Uri>,
|
||||
sendAsFile: Boolean = false,
|
||||
) {
|
||||
@@ -668,19 +678,19 @@ class MessageComposerPresenter(
|
||||
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
|
||||
return
|
||||
}
|
||||
val attachments = uris.map { uri ->
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = null,
|
||||
name = null,
|
||||
formattedFileSize = null,
|
||||
)
|
||||
Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
}.toImmutableList()
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
launch {
|
||||
val attachments = uris.map { uri ->
|
||||
val localMedia = createLocalMedia(
|
||||
uri = uri,
|
||||
mimeType = null,
|
||||
)
|
||||
Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
}.toImmutableList()
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
|
||||
resetComposerModeAfterAttaching()
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetComposerModeAfterAttaching() {
|
||||
|
||||
+1
@@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest {
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+1
@@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
@@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
fun navigateToLinkNewDevice()
|
||||
fun navigateToBugReport()
|
||||
fun navigateToSecureBackup()
|
||||
|
||||
@@ -75,6 +75,7 @@ dependencies {
|
||||
implementation(projects.features.analytics.api)
|
||||
implementation(projects.features.enterprise.api)
|
||||
implementation(projects.features.licenses.api)
|
||||
implementation(projects.features.location.api)
|
||||
implementation(projects.features.logout.api)
|
||||
implementation(projects.features.deactivation.api)
|
||||
implementation(projects.features.invite.api)
|
||||
@@ -109,6 +110,7 @@ dependencies {
|
||||
testImplementation(projects.features.enterprise.test)
|
||||
testImplementation(projects.features.invite.test)
|
||||
testImplementation(projects.features.licenses.test)
|
||||
testImplementation(projects.features.location.test)
|
||||
testImplementation(projects.features.lockscreen.test)
|
||||
testImplementation(projects.features.logout.test)
|
||||
testImplementation(projects.libraries.indicator.test)
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
|
||||
+1
-4
@@ -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)
|
||||
|
||||
+13
-7
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
@@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -76,6 +78,7 @@ class NotificationSettingsPresenter(
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<NotificationSettingsState> {
|
||||
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
|
||||
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
|
||||
@@ -111,16 +114,19 @@ class NotificationSettingsPresenter(
|
||||
}
|
||||
|
||||
// List of PushProvider -> Distributor
|
||||
val distributors = remember {
|
||||
pushService.getAvailablePushProviders()
|
||||
.flatMap { pushProvider ->
|
||||
pushProvider.getDistributors().map { distributor ->
|
||||
pushProvider to distributor
|
||||
// Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread
|
||||
val distributors by produceState(emptyList<Pair<PushProvider, Distributor>>()) {
|
||||
value = withContext(dispatchers.io) {
|
||||
pushService.getAvailablePushProviders()
|
||||
.flatMap { pushProvider ->
|
||||
pushProvider.getDistributors().map { distributor ->
|
||||
pushProvider to distributor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// List of Distributors
|
||||
val availableDistributors = remember {
|
||||
val availableDistributors = remember(distributors) {
|
||||
distributors.map { it.second }.toImmutableList()
|
||||
}
|
||||
|
||||
|
||||
-3
@@ -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
|
||||
}
|
||||
|
||||
+3
@@ -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,
|
||||
|
||||
+5
-3
@@ -19,6 +19,7 @@ import androidx.compose.runtime.rememberCoroutineScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.element.android.compound.theme.Theme
|
||||
import io.element.android.compound.theme.mapToTheme
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.preferences.impl.account.PreferencesAccountState
|
||||
import io.element.android.features.preferences.impl.userstatus.UserStatusState
|
||||
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||
@@ -47,6 +48,7 @@ class PreferencesRootPresenter(
|
||||
private val snackbarDispatcher: SnackbarDispatcher,
|
||||
private val showDeveloperSettingsProvider: ShowDeveloperSettingsProvider,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val locationService: LocationService,
|
||||
private val sessionStore: SessionStore,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val userStatusPresenter: Presenter<UserStatusState>,
|
||||
@@ -115,14 +117,13 @@ class PreferencesRootPresenter(
|
||||
|
||||
val showDeveloperSettings by showDeveloperSettingsProvider.showDeveloperSettings.collectAsState()
|
||||
|
||||
val isLocationServiceAvailable by remember { locationService.isServiceAvailable() }.collectAsState()
|
||||
|
||||
fun handleEvent(event: PreferencesRootEvent) {
|
||||
when (event) {
|
||||
is PreferencesRootEvent.OnVersionInfoClick -> {
|
||||
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
|
||||
}
|
||||
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
|
||||
sessionStore.setLatestSession(event.sessionId.value)
|
||||
}
|
||||
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
|
||||
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
|
||||
}
|
||||
@@ -150,6 +151,7 @@ class PreferencesRootPresenter(
|
||||
showAnalyticsSettings = hasAnalyticsProviders,
|
||||
showDeveloperSettings = showDeveloperSettings,
|
||||
showLabsItem = showLabsItem,
|
||||
showLocationSettings = isLocationServiceAvailable,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = ::handleEvent,
|
||||
)
|
||||
|
||||
+1
@@ -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,
|
||||
)
|
||||
|
||||
+4
@@ -53,6 +53,8 @@ open class PreferencesRootStatePreviewParam : PreviewParameterProvider<Preferenc
|
||||
aPreferencesRootState(userStatusState = aUserStatusState(pickerState = UserStatusPickerState.ShowingPicker)),
|
||||
// Minimal state
|
||||
aPreferencesRootState(userStatusState = null),
|
||||
// Location sharing not available
|
||||
aPreferencesRootState(showLocationSettings = false),
|
||||
)
|
||||
}
|
||||
|
||||
@@ -68,6 +70,7 @@ fun aPreferencesRootState(
|
||||
showAnalyticsSettings: Boolean = false,
|
||||
showDeveloperSettings: Boolean = false,
|
||||
showLabsItem: Boolean = false,
|
||||
showLocationSettings: Boolean = true,
|
||||
userStatusState: UserStatusState? = aUserStatusState(),
|
||||
snackbarMessage: SnackbarMessage? = null,
|
||||
eventSink: (PreferencesRootEvent) -> Unit = {},
|
||||
@@ -83,6 +86,7 @@ fun aPreferencesRootState(
|
||||
showAnalyticsSettings = showAnalyticsSettings,
|
||||
showDeveloperSettings = showDeveloperSettings,
|
||||
showLabsItem = showLabsItem,
|
||||
showLocationSettings = showLocationSettings,
|
||||
userStatusState = userStatusState,
|
||||
snackbarMessage = snackbarMessage,
|
||||
eventSink = eventSink,
|
||||
|
||||
+13
-6
@@ -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 = {},
|
||||
|
||||
+2
@@ -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()
|
||||
|
||||
+2
@@ -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),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+15
-20
@@ -13,6 +13,8 @@ package io.element.android.features.preferences.impl.root
|
||||
import app.cash.turbine.ReceiveTurbine
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.compound.theme.Theme
|
||||
import io.element.android.features.location.api.LocationService
|
||||
import io.element.android.features.location.test.FakeLocationService
|
||||
import io.element.android.features.preferences.impl.account.aPreferencesAccountState
|
||||
import io.element.android.features.preferences.impl.userstatus.aUserStatusState
|
||||
import io.element.android.features.preferences.impl.utils.ShowDeveloperSettingsProvider
|
||||
@@ -37,8 +39,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -78,10 +78,21 @@ class PreferencesRootPresenterTest {
|
||||
assertThat(loadedState.showAnalyticsSettings).isFalse()
|
||||
assertThat(loadedState.showDeveloperSettings).isTrue()
|
||||
assertThat(loadedState.showLabsItem).isFalse()
|
||||
assertThat(loadedState.showLocationSettings).isTrue()
|
||||
assertThat(loadedState.snackbarMessage).isNull()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - location settings are hidden when the location service is not available`() = runTest {
|
||||
createPreferencesRootPresenter(
|
||||
locationService = FakeLocationService(isServiceAvailable = false),
|
||||
).test {
|
||||
assertThat(awaitItem().showLocationSettings).isFalse()
|
||||
cancelAndIgnoreRemainingEvents()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - user status state is null when the server does not support it`() = runTest {
|
||||
createPreferencesRootPresenter(
|
||||
@@ -166,24 +177,6 @@ class PreferencesRootPresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - switch session invoke method on the session store`() = runTest {
|
||||
val setLatestSessionResult = lambdaRecorder<String, Unit> { }
|
||||
val sessionStore = InMemorySessionStore(
|
||||
initialList = listOf(
|
||||
aSessionData(sessionId = A_SESSION_ID.value),
|
||||
aSessionData(sessionId = A_SESSION_ID_2.value),
|
||||
),
|
||||
setLatestSessionResult = setLatestSessionResult,
|
||||
)
|
||||
createPreferencesRootPresenter(sessionStore = sessionStore).test {
|
||||
val loadedState = awaitFirstItem()
|
||||
loadedState.eventSink(PreferencesRootEvent.SwitchToSession(A_SESSION_ID_2))
|
||||
setLatestSessionResult.assertions().isCalledOnce()
|
||||
.with(value(A_SESSION_ID_2.value))
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
|
||||
createPreferencesRootPresenter(
|
||||
@@ -291,6 +284,7 @@ class PreferencesRootPresenterTest {
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
appPreferencesStore: AppPreferencesStore = InMemoryAppPreferencesStore(),
|
||||
locationService: LocationService = FakeLocationService(isServiceAvailable = true),
|
||||
) = PreferencesRootPresenter(
|
||||
matrixClient = matrixClient,
|
||||
analyticsService = FakeAnalyticsService(),
|
||||
@@ -298,6 +292,7 @@ class PreferencesRootPresenterTest {
|
||||
snackbarDispatcher = SnackbarDispatcher(),
|
||||
showDeveloperSettingsProvider = showDeveloperSettingsProvider,
|
||||
featureFlagService = featureFlagService,
|
||||
locationService = locationService,
|
||||
sessionStore = sessionStore,
|
||||
appPreferencesStore = appPreferencesStore,
|
||||
userStatusPresenter = { aUserStatusState() },
|
||||
|
||||
+21
-16
@@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
@@ -69,23 +70,25 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
|
||||
setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
isOtherAccountsSectionExpanded = true,
|
||||
otherSessions = listOf(
|
||||
aMatrixUser(
|
||||
id = A_USER_ID_2.value,
|
||||
displayName = "Bob",
|
||||
)
|
||||
fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnceWithParam(A_USER_ID_2) { callback ->
|
||||
setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
isOtherAccountsSectionExpanded = true,
|
||||
otherSessions = listOf(
|
||||
aMatrixUser(
|
||||
id = A_USER_ID_2.value,
|
||||
displayName = "Bob",
|
||||
)
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
onNodeWithText("Bob").performClick()
|
||||
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
|
||||
onSwitchToSession = callback,
|
||||
)
|
||||
onNodeWithText("Bob").performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -395,6 +398,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
|
||||
state: PreferencesRootState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
|
||||
onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAbout: () -> Unit = EnsureNeverCalled(),
|
||||
@@ -418,6 +422,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
onBackClick = onBackClick,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSwitchToSession = onSwitchToSession,
|
||||
onOpenAnalytics = onOpenAnalytics,
|
||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||
onOpenAbout = onOpenAbout,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
|
||||
<string name="screen_identity_confirmation_sign_out">"Sign out"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Choose how to verify to set up secure messaging."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirm your digital identity"</string>
|
||||
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
|
||||
|
||||
@@ -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" }
|
||||
|
||||
+5
-1
@@ -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 }
|
||||
}
|
||||
|
||||
+13
-28
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-1
@@ -410,7 +410,7 @@ interface MatrixClient : ClientUrlContentFetcher {
|
||||
|
||||
/**
|
||||
* Deactivates the account on the homeserver, then destroys this client and deletes every local trace of the session.
|
||||
* The password is only sent if the homeserver asks for re-authentication, and on failure the session is left untouched.
|
||||
* On failure the session is left untouched.
|
||||
*
|
||||
* @param password the account password, required to confirm the deactivation.
|
||||
* @param eraseData whether the homeserver should also erase the messages the user sent.
|
||||
@@ -505,6 +505,12 @@ interface MatrixClient : ClientUrlContentFetcher {
|
||||
|
||||
/** Returns a provider for the capabilities the homeserver advertises, such as whether the display name can be changed. */
|
||||
fun homeserverCapabilities(): HomeserverCapabilitiesProvider
|
||||
|
||||
/**
|
||||
* Returns the total number of unread notifications across all rooms, as currently known by the client.
|
||||
* This is a snapshot and may be out of date if the sync is running.
|
||||
*/
|
||||
fun totalUnreadCount(): Result<Long>
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
+5
@@ -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.
|
||||
*/
|
||||
|
||||
+7
@@ -8,6 +8,7 @@
|
||||
|
||||
package io.element.android.libraries.matrix.api.sync
|
||||
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
|
||||
/**
|
||||
@@ -37,4 +38,10 @@ interface SyncService {
|
||||
* This is `false` only while the state is [SyncState.Offline]; an idle or errored sync still counts as online.
|
||||
*/
|
||||
val isOnline: StateFlow<Boolean>
|
||||
|
||||
/**
|
||||
* Flow that will emit every time the sync has received a response from the server, even if it was empty.
|
||||
* Useful for triggering UI updates when the sync is running.
|
||||
*/
|
||||
val heartBeat: Flow<Unit>
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
|
||||
+13
-31
@@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
@@ -127,8 +128,6 @@ import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.runBlocking
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.coroutines.withTimeout
|
||||
import org.matrix.rustcomponents.sdk.AuthData
|
||||
import org.matrix.rustcomponents.sdk.AuthDataPasswordDetails
|
||||
import org.matrix.rustcomponents.sdk.BeaconInfoListener
|
||||
import org.matrix.rustcomponents.sdk.BeaconInfoUpdate
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
@@ -322,7 +321,7 @@ class RustMatrixClient(
|
||||
override fun onUpdate(profile: UserProfile) {
|
||||
val matrixUser = profile.map()
|
||||
_userProfile.tryEmit(matrixUser)
|
||||
sessionCoroutineScope.launch {
|
||||
sessionCoroutineScope.launch(sessionDispatcher) {
|
||||
storeUserProfile(matrixUser)
|
||||
}
|
||||
}
|
||||
@@ -530,7 +529,7 @@ class RustMatrixClient(
|
||||
* Store the user profile in the session storage, including a base64 encoded thumbnail of the avatar,
|
||||
* so that the avatar can be rendered without having to use this client.
|
||||
*/
|
||||
private suspend fun storeUserProfile(matrixUser: MatrixUser) {
|
||||
private suspend fun storeUserProfile(matrixUser: MatrixUser) = withContext(sessionDispatcher) {
|
||||
val avatarUrl = matrixUser.avatarUrl
|
||||
val storedSession = sessionStore.getSession(sessionId.value)
|
||||
val avatarData = when {
|
||||
@@ -794,33 +793,10 @@ class RustMatrixClient(
|
||||
clientDelegateTaskHandle?.cancelAndDestroy()
|
||||
clientDelegateTaskHandle = null
|
||||
runCatchingExceptions {
|
||||
// First call without AuthData, should fail
|
||||
val firstAttempt = runCatchingExceptions {
|
||||
innerClient.deactivateAccount(
|
||||
authData = null,
|
||||
eraseData = eraseData,
|
||||
)
|
||||
}
|
||||
if (firstAttempt.isFailure) {
|
||||
Timber.w(firstAttempt.exceptionOrNull(), "Expected failure, try again")
|
||||
// This is expected, try again with the password
|
||||
runCatchingExceptions {
|
||||
innerClient.deactivateAccount(
|
||||
authData = AuthData.Password(
|
||||
passwordDetails = AuthDataPasswordDetails(
|
||||
identifier = sessionId.value,
|
||||
password = password,
|
||||
),
|
||||
),
|
||||
eraseData = eraseData,
|
||||
)
|
||||
}.onFailure {
|
||||
Timber.e(it, "Failed to deactivate account")
|
||||
// If the deactivation failed we need to restore the delegate
|
||||
clientDelegateTaskHandle = innerClient.setDelegate(sessionDelegate)
|
||||
throw it
|
||||
}
|
||||
}
|
||||
innerClient.deactivateAccount(
|
||||
password = password,
|
||||
eraseData = eraseData,
|
||||
)
|
||||
destroy()
|
||||
deleteSessionDirectory()
|
||||
sessionStore.removeSession(sessionId.value)
|
||||
@@ -1024,6 +1000,10 @@ class RustMatrixClient(
|
||||
return RustHomeserverCapabilitiesProvider(innerClient.homeserverCapabilities())
|
||||
}
|
||||
|
||||
override fun totalUnreadCount(): Result<Long> = runCatchingExceptions {
|
||||
innerClient.totalUnreadNotifications().toLong()
|
||||
}
|
||||
|
||||
private fun scheduleBackgroundSearchBackfill() {
|
||||
featureFlagService.isFeatureEnabledFlow(FeatureFlags.MessageSearch)
|
||||
.distinctUntilChanged()
|
||||
@@ -1034,6 +1014,8 @@ class RustMatrixClient(
|
||||
searchBackfillService.cancelPeriodicSearchBackfill()
|
||||
}
|
||||
}
|
||||
// Checking the pending work is a blocking call, do not do it on the main thread
|
||||
.flowOn(sessionDispatcher)
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
+8
-1
@@ -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)
|
||||
|
||||
+9
-5
@@ -20,14 +20,14 @@ import org.matrix.rustcomponents.sdk.CrossSigningResetAuthType
|
||||
object RustIdentityResetHandleFactory {
|
||||
fun create(
|
||||
userId: UserId,
|
||||
identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?
|
||||
identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle?,
|
||||
): Result<IdentityResetHandle?> {
|
||||
return runCatchingExceptions {
|
||||
identityResetHandle?.let {
|
||||
when (val authType = identityResetHandle.authType()) {
|
||||
is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl)
|
||||
is CrossSigningResetAuthType.OAuth -> RustIdentityOAuthResetHandle(identityResetHandle, authType.info.approvalUrl, authType.info.session)
|
||||
// User interactive authentication (user + password)
|
||||
CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle)
|
||||
is CrossSigningResetAuthType.Uiaa -> RustPasswordIdentityResetHandle(userId, identityResetHandle, authType.info.session)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -37,9 +37,12 @@ object RustIdentityResetHandleFactory {
|
||||
class RustPasswordIdentityResetHandle(
|
||||
private val userId: UserId,
|
||||
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
|
||||
override val session: String,
|
||||
) : IdentityPasswordResetHandle {
|
||||
override suspend fun resetPassword(password: String): Result<Unit> {
|
||||
return runCatchingExceptions { identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password))) }
|
||||
return runCatchingExceptions {
|
||||
identityResetHandle.reset(AuthData.Password(AuthDataPasswordDetails(userId.value, password, session)))
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
@@ -50,9 +53,10 @@ class RustPasswordIdentityResetHandle(
|
||||
class RustIdentityOAuthResetHandle(
|
||||
private val identityResetHandle: org.matrix.rustcomponents.sdk.IdentityResetHandle,
|
||||
override val url: String,
|
||||
override val session: String,
|
||||
) : IdentityOAuthResetHandle {
|
||||
override suspend fun resetOAuth(): Result<Unit> {
|
||||
return runCatchingExceptions { identityResetHandle.reset(null) }
|
||||
return runCatchingExceptions { identityResetHandle.reset(AuthData.OAuth(session)) }
|
||||
}
|
||||
|
||||
override suspend fun cancel() {
|
||||
|
||||
+10
-2
@@ -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")
|
||||
|
||||
+10
-2
@@ -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.
|
||||
|
||||
+1
@@ -158,6 +158,7 @@ class JoinedRustRoom(
|
||||
inner = innerRoom.threadListService(),
|
||||
contentMapper = TimelineEventContentMapper(),
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
roomDispatcher = roomDispatcher,
|
||||
)
|
||||
|
||||
override val syncUpdateFlow = flow {
|
||||
|
||||
+5
@@ -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)
|
||||
}
|
||||
|
||||
|
||||
+10
@@ -12,16 +12,20 @@ import io.element.android.libraries.core.coroutine.mapState
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.matrix.api.sync.SyncService
|
||||
import io.element.android.libraries.matrix.api.sync.SyncState
|
||||
import io.element.android.libraries.matrix.impl.roomlist.stateFlow
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.NonCancellable
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.SharingStarted
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.RoomListServiceState
|
||||
import org.matrix.rustcomponents.sdk.SyncServiceState
|
||||
import timber.log.Timber
|
||||
import java.util.concurrent.atomic.AtomicBoolean
|
||||
@@ -78,4 +82,10 @@ class RustSyncService(
|
||||
.stateIn(sessionCoroutineScope, SharingStarted.Eagerly, SyncState.Idle)
|
||||
|
||||
override val isOnline: StateFlow<Boolean> = syncState.mapState { it != SyncState.Offline }
|
||||
|
||||
// TODO actually use a heartbeat mechanism from the SDK, not simulate it with the room list service
|
||||
override val heartBeat: Flow<Unit> = inner.roomListService()
|
||||
.stateFlow()
|
||||
.filter { it == RoomListServiceState.RUNNING }
|
||||
.map {}
|
||||
}
|
||||
|
||||
+4
@@ -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()
|
||||
|
||||
+39
@@ -6,6 +6,8 @@
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalCoroutinesApi::class)
|
||||
|
||||
package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
@@ -36,7 +38,11 @@ import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
|
||||
import io.element.android.libraries.workmanager.test.FakeWorkManagerScheduler
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.awaitCancellation
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -171,6 +177,39 @@ class RustMatrixAuthenticationServiceTest {
|
||||
closeResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `loginWithQrCode cancels the login task when it is cancelled`() = runTest {
|
||||
val cancelResult = lambdaRecorder<Unit> {}
|
||||
val sut = createRustMatrixAuthenticationService(
|
||||
clientBuilderProvider = FakeClientBuilderProvider(
|
||||
provideResult = {
|
||||
FakeFfiClientBuilder(
|
||||
buildResult = {
|
||||
FakeFfiClient(
|
||||
newLoginWithQrCodeHandlerResult = {
|
||||
FakeFfiLoginWithQrCodeHandler(
|
||||
scanResult = { awaitCancellation() },
|
||||
cancelResult = cancelResult,
|
||||
)
|
||||
},
|
||||
closeResult = {},
|
||||
)
|
||||
},
|
||||
)
|
||||
},
|
||||
),
|
||||
)
|
||||
|
||||
val job = backgroundScope.launch {
|
||||
sut.loginWithQrCode(aSdkQrCodeLoginData()) {}
|
||||
}
|
||||
runCurrent()
|
||||
cancelResult.assertions().isNeverCalled()
|
||||
job.cancel()
|
||||
runCurrent()
|
||||
cancelResult.assertions().isCalledOnce()
|
||||
}
|
||||
|
||||
private fun aSdkQrCodeLoginData() = SdkQrCodeLoginData(
|
||||
FakeFfiQrCodeData(
|
||||
baseUrlResult = { A_HOMESERVER_URL },
|
||||
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user