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