Merge branch 'develop' into feature/bma/otherAccountUnreadCount

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
Benoit Marty
2026-10-14 20:15:02 +02:00
131 changed files with 1007 additions and 468 deletions
+1 -1
View File
@@ -74,7 +74,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug APKs - name: Upload debug APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: elementx-debug name: elementx-debug
path: | path: |
+1 -1
View File
@@ -79,7 +79,7 @@ jobs:
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
- name: Upload debug Enterprise APKs - name: Upload debug Enterprise APKs
if: ${{ matrix.variant == 'debug' }} if: ${{ matrix.variant == 'debug' }}
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: elementx-enterprise-debug name: elementx-enterprise-debug
path: | path: |
+2 -2
View File
@@ -63,7 +63,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }} ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
- name: Upload APK as artifact - name: Upload APK as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: elementx-apk-maestro name: elementx-apk-maestro
path: | path: |
@@ -138,7 +138,7 @@ jobs:
pip install requests pip install requests
python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement
- name: Upload test results - name: Upload test results
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: test-results name: test-results
path: | path: |
+4 -4
View File
@@ -48,17 +48,17 @@ jobs:
cache-read-only: false cache-read-only: false
- name: ⚙️ Run unit tests, debug and release - name: ⚙️ Run unit tests, debug and release
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue
- name: 📸 Run screenshot tests - name: 📸 Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: 📈 Generate kover report and verify coverage - name: 📈 Generate kover report and verify coverage
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
- name: ✅ Upload kover report - name: ✅ Upload kover report
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: kover-results name: kover-results
path: | path: |
@@ -92,7 +92,7 @@ jobs:
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
- name: Upload dependency analysis - name: Upload dependency analysis
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: dependency-analysis name: dependency-analysis
path: build/reports/dependency-check-report.html path: build/reports/dependency-check-report.html
+6 -6
View File
@@ -120,7 +120,7 @@ jobs:
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: konsist-report name: konsist-report
path: | path: |
@@ -199,7 +199,7 @@ jobs:
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: linting-report name: linting-report
path: | path: |
@@ -237,10 +237,10 @@ jobs:
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Detekt - name: Run Detekt
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: detekt-report name: detekt-report
path: | path: |
@@ -278,10 +278,10 @@ jobs:
with: with:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: Run Ktlint check - name: Run Ktlint check
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue
- name: Upload reports - name: Upload reports
if: always() if: always()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: ktlint-report name: ktlint-report
path: | path: |
+2 -2
View File
@@ -59,7 +59,7 @@ jobs:
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }} ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload bundle as artifact - name: Upload bundle as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: elementx-app-gplay-bundle-unsigned name: elementx-app-gplay-bundle-unsigned
path: | path: |
@@ -105,7 +105,7 @@ jobs:
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }} ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
- name: Upload apks as artifact - name: Upload apks as artifact
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: elementx-app-fdroid-apks-unsigned name: elementx-app-fdroid-apks-unsigned
path: | path: |
+4 -4
View File
@@ -73,17 +73,17 @@ jobs:
cache-read-only: ${{ github.ref != 'refs/heads/develop' }} cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
- name: ⚙️ Run unit tests - name: ⚙️ Run unit tests
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue
- name: ⚙️ Run screenshot tests - name: ⚙️ Run screenshot tests
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
- name: ⚙️ Generate kover coverage report - name: ⚙️ Generate kover coverage report
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
- name: 🚫 Upload kover failed coverage reports - name: 🚫 Upload kover failed coverage reports
if: failure() if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: kover-error-report name: kover-error-report
path: | path: |
@@ -95,7 +95,7 @@ jobs:
- name: 🚫 Upload test results on error - name: 🚫 Upload test results on error
if: failure() if: failure()
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1 uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
with: with:
name: tests-and-screenshot-tests-results name: tests-and-screenshot-tests-results
path: | path: |
@@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.architecture.inputs import io.element.android.libraries.architecture.inputs
import io.element.android.libraries.di.DependencyInjectionGraphOwner import io.element.android.libraries.di.DependencyInjectionGraphOwner
import io.element.android.libraries.matrix.api.MatrixClient import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
@@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode(
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
} }
private val callback: Callback = callback() private val callback: Callback = callback()
@@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode(
override fun navigateToAddAccount() { override fun navigateToAddAccount() {
callback.navigateToAddAccount() callback.navigateToAddAccount()
} }
override fun navigateToSession(sessionId: SessionId) {
callback.navigateToSession(sessionId)
}
} }
return createNode<LoggedInFlowNode>(buildContext, listOf(callback)) return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
} }
@@ -85,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
@@ -169,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()
@@ -356,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,
@@ -448,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)
} }
@@ -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) {
@@ -19,4 +19,6 @@ sealed interface Announcement {
data object NewNotificationSound : Announcement data object NewNotificationSound : Announcement
data object MultiAccount : Announcement data object MultiAccount : Announcement
data object SwipeAvatarToSwitchAccount : Announcement
} }
@@ -34,6 +34,7 @@ class DefaultAnnouncementService(
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
} }
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement) Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement)
} }
} }
@@ -46,7 +47,8 @@ class DefaultAnnouncementService(
flowOf(Unit), flowOf(Unit),
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound), announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
announcementStore.announcementStatusFlow(Announcement.MultiAccount), announcementStore.announcementStatusFlow(Announcement.MultiAccount),
) { _, newNotificationSoundStatus, multiAccountStatus -> announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount),
) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus ->
buildList { buildList {
if (newNotificationSoundStatus == AnnouncementStatus.Show) { if (newNotificationSoundStatus == AnnouncementStatus.Show) {
add(Announcement.NewNotificationSound) add(Announcement.NewNotificationSound)
@@ -56,6 +58,10 @@ class DefaultAnnouncementService(
if (multiAccountStatus != AnnouncementStatus.Shown) { if (multiAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.MultiAccount) add(Announcement.MultiAccount)
} }
// The swipe avatar announcement is pending until it is dismissed, or the user switches account by swiping.
if (swipeAvatarToSwitchAccountStatus != AnnouncementStatus.Shown) {
add(Announcement.SwipeAvatarToSwitchAccount)
}
} }
} }
} }
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.map
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement") private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound") private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement") private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement")
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultAnnouncementStore( class DefaultAnnouncementStore(
@@ -42,10 +43,13 @@ class DefaultAnnouncementStore(
// - NewNotificationSound is opted in by AppMigration08 // - NewNotificationSound is opted in by AppMigration08
// - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters // - MultiAccount is NeverShown by default, which means pending. It is set to Shown when the user enters
// the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement. // the login flow while the feature is enabled (see NotLoggedInFlowNode), or dismisses the announcement.
// - SwipeAvatarToSwitchAccount is NeverShown by default, which means pending. It is set to Shown when the user
// dismisses the banner, or switches account by swiping their avatar.
val defaultStatus = when (announcement) { val defaultStatus = when (announcement) {
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
Announcement.NewNotificationSound -> AnnouncementStatus.Shown Announcement.NewNotificationSound -> AnnouncementStatus.Shown
Announcement.MultiAccount -> AnnouncementStatus.NeverShown Announcement.MultiAccount -> AnnouncementStatus.NeverShown
Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown
} }
return store.data.map { prefs -> return store.data.map { prefs ->
val ordinal = prefs[key] ?: defaultStatus.ordinal val ordinal = prefs[key] ?: defaultStatus.ordinal
@@ -62,4 +66,5 @@ private fun Announcement.toKey() = when (this) {
Announcement.Fullscreen.Space -> spaceAnnouncementKey Announcement.Fullscreen.Space -> spaceAnnouncementKey
Announcement.NewNotificationSound -> newNotificationSoundKey Announcement.NewNotificationSound -> newNotificationSoundKey
Announcement.MultiAccount -> multiAccountKey Announcement.MultiAccount -> multiAccountKey
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey
} }
@@ -67,6 +67,22 @@ class DefaultAnnouncementServiceTest {
assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown) assertThat(announcementStore.announcementStatusFlow(Announcement.MultiAccount).first()).isEqualTo(AnnouncementStatus.Shown)
} }
@Test
fun `when showing SwipeAvatarToSwitchAccount announcement, announcement is set to show only if it was never shown`() = runTest {
val announcementStore = InMemoryAnnouncementStore()
val sut = createDefaultAnnouncementService(
announcementStore = announcementStore,
)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.NeverShown)
sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Show)
// Simulate user close the announcement
sut.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
// Calling again showAnnouncement should not change the value
sut.showAnnouncement(Announcement.SwipeAvatarToSwitchAccount)
assertThat(announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount).first()).isEqualTo(AnnouncementStatus.Shown)
}
@Test @Test
fun `test announcementsToShowFlow`() = runTest { fun `test announcementsToShowFlow`() = runTest {
val announcementStore = InMemoryAnnouncementStore() val announcementStore = InMemoryAnnouncementStore()
@@ -74,16 +90,18 @@ class DefaultAnnouncementServiceTest {
announcementStore = announcementStore, announcementStore = announcementStore,
) )
sut.announcementsToShowFlow().test { sut.announcementsToShowFlow().test {
// MultiAccount is pending by default // MultiAccount and SwipeAvatarToSwitchAccount are pending by default
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Show)
// Still pending // Still pending
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown) announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount) assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown) announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount)
announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown)
assertThat(awaitItem()).isEmpty() assertThat(awaitItem()).isEmpty()
} }
} }
@@ -17,10 +17,12 @@ class InMemoryAnnouncementStore(
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown, initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
) : AnnouncementStore { ) : AnnouncementStore {
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus) private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus) private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus) private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus)
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) { override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
announcement.toMutableStateFlow().value = status announcement.toMutableStateFlow().value = status
@@ -34,11 +36,13 @@ class InMemoryAnnouncementStore(
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown
} }
private fun Announcement.toMutableStateFlow() = when (this) { private fun Announcement.toMutableStateFlow() = when (this) {
is Announcement.Fullscreen -> fullScreenAnnouncement is Announcement.Fullscreen -> fullScreenAnnouncement
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
Announcement.MultiAccount -> multiAccountAnnouncement Announcement.MultiAccount -> multiAccountAnnouncement
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement
} }
} }
@@ -46,11 +46,12 @@ class ChooseSelfVerificationModeStatePreviewParam :
fun aChooseSelfVerificationModeState( fun aChooseSelfVerificationModeState(
buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()), buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()),
currentUser: MatrixUser = aMatrixUser(), currentUser: MatrixUser = aMatrixUser(),
eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {},
) = ChooseSelfVerificationModeState( ) = ChooseSelfVerificationModeState(
currentUser = currentUser, currentUser = currentUser,
buttonsState = buttonsState, buttonsState = buttonsState,
directLogoutState = aDirectLogoutState(), directLogoutState = aDirectLogoutState(),
eventSink = {}, eventSink = eventSink,
) )
fun aButtonsState( fun aButtonsState(
@@ -36,6 +36,7 @@ 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
@@ -45,6 +46,9 @@ import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfo
import io.element.android.libraries.matrix.ui.components.accountinfo.AccountInfoCardState 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,
@@ -65,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) }
) )
} }
@@ -141,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>
@@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureNeverCalled import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.robolectric.RobolectricTest import io.element.android.tests.testutils.robolectric.RobolectricTest
@@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() {
} }
} }
@Test
fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<ChooseSelfVerificationModeEvent>()
setChooseSelfVerificationModeView(
aChooseSelfVerificationModeState(eventSink = eventsRecorder),
)
clickOn(R.string.screen_identity_confirmation_sign_out)
eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut)
}
private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView( private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView(
state: ChooseSelfVerificationModeState, state: ChooseSelfVerificationModeState,
onLearnMoreClick: () -> Unit = EnsureNeverCalled(), onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
@@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin
import io.element.android.libraries.architecture.FeatureEntryPoint import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.room.JoinedRoom import io.element.android.libraries.matrix.api.room.JoinedRoom
interface HomeEntryPoint : FeatureEntryPoint { interface HomeEntryPoint : FeatureEntryPoint {
@@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
fun navigateToRoomSettings(roomId: RoomId) fun navigateToRoomSettings(roomId: RoomId)
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToAddAccount() fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
} }
} }
@@ -8,10 +8,8 @@
package io.element.android.features.home.impl package io.element.android.features.home.impl
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface HomeEvent { sealed interface HomeEvent {
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
data object DismissMultiAccountAnnouncement : HomeEvent data object DismissMultiAccountAnnouncement : HomeEvent
data object DismissSwipeAvatarAnnouncement : HomeEvent
} }
@@ -222,6 +222,7 @@ class HomeFlowNode(
onRoomClick = ::navigateToRoom, onRoomClick = ::navigateToRoom,
onSettingsClick = callback::navigateToSettings, onSettingsClick = callback::navigateToSettings,
onAddAccountClick = callback::navigateToAddAccount, onAddAccountClick = callback::navigateToAddAccount,
onSwitchToAccount = callback::navigateToSession,
onStartChatClick = callback::navigateToCreateRoom, onStartChatClick = callback::navigateToCreateRoom,
onCreateSpaceClick = callback::navigateToCreateSpace, onCreateSpaceClick = callback::navigateToCreateSpace,
onSetUpRecoveryClick = callback::navigateToSetUpRecovery, onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
@@ -27,6 +27,7 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState import io.element.android.libraries.designsystem.utils.snackbar.collectSnackbarMessageAsState
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
import io.element.android.libraries.featureflag.api.FeatureFlags import io.element.android.libraries.featureflag.api.FeatureFlags
import io.element.android.libraries.indicator.api.IndicatorService import io.element.android.libraries.indicator.api.IndicatorService
@@ -35,6 +36,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.totalUnreadCountFlow import io.element.android.libraries.matrix.api.totalUnreadCountFlow
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.CoroutineScope
import kotlinx.coroutines.flow.combine import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.filter
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
@@ -51,12 +53,14 @@ class HomePresenter(
private val sessionStore: SessionStore, private val sessionStore: SessionStore,
private val announcementService: AnnouncementService, private val announcementService: AnnouncementService,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
@AppCoroutineScope
private val appCoroutineScope: CoroutineScope,
) : Presenter<HomeState> { ) : Presenter<HomeState> {
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder() private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
@Composable @Composable
override fun present(): HomeState { override fun present(): HomeState {
val coroutineState = rememberCoroutineScope() val coroutineScope = rememberCoroutineScope()
val matrixUser by client.userProfile.collectAsState() val matrixUser by client.userProfile.collectAsState()
val totalUnreadCount by remember { client.totalUnreadCountFlow() } val totalUnreadCount by remember { client.totalUnreadCountFlow() }
// The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition. // The initial value is only used for the first composition, remember it to avoid calling the SDK on every recomposition.
@@ -114,10 +118,11 @@ class HomePresenter(
is HomeEvent.SelectHomeNavigationBarItem -> { is HomeEvent.SelectHomeNavigationBarItem -> {
currentHomeNavigationBarItemOrdinal = event.item.ordinal currentHomeNavigationBarItemOrdinal = event.item.ordinal
} }
is HomeEvent.SwitchToAccount -> coroutineState.launch { // The user is switching account, which destroys this presenter, so use the app scope to not be cancelled.
sessionStore.setLatestSession(event.sessionId.value) HomeEvent.DismissSwipeAvatarAnnouncement -> appCoroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
} }
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch { HomeEvent.DismissMultiAccountAnnouncement -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.MultiAccount) announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
} }
} }
@@ -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,
@@ -379,6 +385,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
onRoomClick = { _, _ -> }, onRoomClick = { _, _ -> },
onSettingsClick = {}, onSettingsClick = {},
onAddAccountClick = {}, onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {}, onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onStartChatClick = {}, onStartChatClick = {},
@@ -400,6 +407,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
onRoomClick = { _, _ -> }, onRoomClick = { _, _ -> },
onSettingsClick = {}, onSettingsClick = {},
onAddAccountClick = {}, onAddAccountClick = {},
onSwitchToAccount = {},
onSetUpRecoveryClick = {}, onSetUpRecoveryClick = {},
onConfirmRecoveryKeyClick = {}, onConfirmRecoveryKeyClick = {},
onStartChatClick = {}, onStartChatClick = {},
@@ -16,13 +16,11 @@ import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.components.AnnouncementType
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.push.api.battery.BatteryOptimizationEvent
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
@Composable @Composable
internal fun BatteryOptimizationBanner( internal fun BatteryOptimizationBanner(
state: BatteryOptimizationState, onActionClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
Announcement( Announcement(
@@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner(
description = stringResource(R.string.banner_battery_optimization_content_android), description = stringResource(R.string.banner_battery_optimization_content_android),
type = AnnouncementType.Actionable( type = AnnouncementType.Actionable(
actionText = stringResource(R.string.banner_battery_optimization_submit_android), actionText = stringResource(R.string.banner_battery_optimization_submit_android),
onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) }, onActionClick = onActionClick,
onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) }, onDismissClick = onDismissClick,
), ),
) )
} }
@@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner(
@Composable @Composable
internal fun BatteryOptimizationBannerPreview() = ElementPreview { internal fun BatteryOptimizationBannerPreview() = ElementPreview {
BatteryOptimizationBanner( BatteryOptimizationBanner(
state = aBatteryOptimizationState(), onActionClick = {},
onDismissClick = {},
) )
} }
@@ -16,14 +16,12 @@ import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType import io.element.android.libraries.designsystem.components.AnnouncementType
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.fullscreenintent.api.FullScreenIntentPermissionsEvent
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun FullScreenIntentPermissionBanner( fun FullScreenIntentPermissionBanner(
state: FullScreenIntentPermissionsState, onActionClick: () -> Unit,
onDismissClick: () -> Unit,
modifier: Modifier = Modifier modifier: Modifier = Modifier
) { ) {
Announcement( Announcement(
@@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner(
description = stringResource(R.string.full_screen_intent_banner_message), description = stringResource(R.string.full_screen_intent_banner_message),
type = AnnouncementType.Actionable( type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue), actionText = stringResource(CommonStrings.action_continue),
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) }, onDismissClick = onDismissClick,
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) }, onActionClick = onActionClick,
), ),
modifier = modifier.roomListBannerPadding(), modifier = modifier.roomListBannerPadding(),
) )
@@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner(
@Composable @Composable
internal fun FullScreenIntentPermissionBannerPreview() { internal fun FullScreenIntentPermissionBannerPreview() {
ElementPreview { ElementPreview {
FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState()) FullScreenIntentPermissionBanner(
onActionClick = {},
onDismissClick = {},
)
} }
} }
@@ -92,6 +92,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.flow.filter
@Composable @Composable
fun HomeTopBar( fun HomeTopBar(
@@ -307,9 +308,12 @@ private fun NavigationIcon(
// Listen to page changes and switch account if needed // Listen to page changes and switch account if needed
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch) val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
LaunchedEffect(pagerState) { LaunchedEffect(pagerState) {
snapshotFlow { pagerState.settledPage }.collect { page -> snapshotFlow { pagerState.settledPage }
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value)) // The current account is on page 1, so there is nothing to switch to
} .filter { page -> page != 1 }
.collect { page ->
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
}
} }
VerticalPager( VerticalPager(
state = pagerState, state = pagerState,
@@ -8,6 +8,12 @@
package io.element.android.features.home.impl.components package io.element.android.features.home.impl.components
import androidx.compose.animation.AnimatedContent
import androidx.compose.animation.SizeTransform
import androidx.compose.animation.core.tween
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.togetherWith
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Column import androidx.compose.foundation.layout.Column
@@ -15,7 +21,9 @@ import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.PaddingValues import androidx.compose.foundation.layout.PaddingValues
import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListState import androidx.compose.foundation.lazy.LazyListState
@@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.res.stringResource import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter import androidx.compose.ui.tooling.preview.PreviewParameter
@@ -39,10 +48,11 @@ import io.element.android.features.home.impl.filters.aRoomListFiltersState
import io.element.android.features.home.impl.filters.selection.FilterSelectionState import io.element.android.features.home.impl.filters.selection.FilterSelectionState
import io.element.android.features.home.impl.model.RoomListRoomSummary import io.element.android.features.home.impl.model.RoomListRoomSummary
import io.element.android.features.home.impl.model.RoomSummaryDisplayType import io.element.android.features.home.impl.model.RoomSummaryDisplayType
import io.element.android.features.home.impl.roomlist.RoomListBanner
import io.element.android.features.home.impl.roomlist.RoomListContentState import io.element.android.features.home.impl.roomlist.RoomListContentState
import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam import io.element.android.features.home.impl.roomlist.RoomListContentStatePreviewParam
import io.element.android.features.home.impl.roomlist.RoomListEvent import io.element.android.features.home.impl.roomlist.RoomListEvent
import io.element.android.features.home.impl.roomlist.SecurityBannerState import io.element.android.features.home.impl.roomlist.toRoomListBanner
import io.element.android.features.home.impl.spacefilters.SpaceFiltersState import io.element.android.features.home.impl.spacefilters.SpaceFiltersState
import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState import io.element.android.features.home.impl.spacefilters.anUnselectedSpaceFiltersState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -52,6 +62,8 @@ import io.element.android.libraries.designsystem.theme.components.HorizontalDivi
import io.element.android.libraries.designsystem.theme.components.IconSource import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.Text import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect import io.element.android.libraries.designsystem.utils.OnVisibleRangeChangeEffect
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvent
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvent
import io.element.android.libraries.ui.strings.CommonStrings import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList import kotlinx.collections.immutable.ImmutableList
@@ -158,22 +170,20 @@ private fun EmptyView(
}, },
modifier = Modifier.align(Alignment.Center), modifier = Modifier.align(Alignment.Center),
) )
Box { RoomListBannerContainer(
when (state.securityBannerState) { banner = state.securityBannerState.toRoomListBanner(),
SecurityBannerState.SetUpRecovery -> { ) { banner ->
SetUpRecoveryKeyBanner( RoomListBannerView(
onContinueClick = onSetUpRecoveryClick, banner = banner,
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, onActionClick = {
) when (banner) {
} RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
SecurityBannerState.RecoveryKeyConfirmation -> { RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
ConfirmRecoveryKeyBanner( else -> Unit
onContinueClick = onConfirmRecoveryKeyClick, }
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, },
) onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
} )
SecurityBannerState.None -> Unit
}
} }
} }
} }
@@ -227,54 +237,56 @@ private fun RoomsViewList(
lazyListState: LazyListState, lazyListState: LazyListState,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
) { ) {
val banner = state.toRoomListBanner()
// The banner slot must never be 0px high, else the LazyColumn considers that it is not visible and it is
// removed from the composition. A new banner would then be added above the visible area of the list.
val bannerSlotMinHeight = with(LocalDensity.current) { 1.toDp() }
OnVisibleRangeChangeEffect(lazyListState) { visibleRange -> OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
eventSink(RoomListEvent.UpdateVisibleRange(visibleRange)) // The first item of the list is the banner slot, remove it to get the range of the rooms
val roomsRange = (visibleRange.first - 1).coerceAtLeast(0)..<visibleRange.last
eventSink(RoomListEvent.UpdateVisibleRange(roomsRange))
} }
LazyColumn( LazyColumn(
state = lazyListState, state = lazyListState,
modifier = modifier, modifier = modifier,
contentPadding = contentPadding, contentPadding = contentPadding,
) { ) {
when (state.securityBannerState) { // The banner slot is always the first item of the list, even when there is no banner to display,
SecurityBannerState.SetUpRecovery -> { // so that the rooms keep the same index when a banner is added or removed. Since the rooms do not
item { // have a key, this is required to keep the scroll position.
SetUpRecoveryKeyBanner( item {
onContinueClick = onSetUpRecoveryClick, RoomListBannerContainer(
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, banner = banner,
) modifier = Modifier.heightIn(min = bannerSlotMinHeight),
} ) { displayedBanner ->
} RoomListBannerView(
SecurityBannerState.RecoveryKeyConfirmation -> { banner = displayedBanner,
item { onActionClick = {
ConfirmRecoveryKeyBanner( when (displayedBanner) {
onContinueClick = onConfirmRecoveryKeyClick, RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
onDismissClick = { eventSink(RoomListEvent.DismissBanner) }, RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
) RoomListBanner.FullScreenIntentPermission ->
} state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
} RoomListBanner.BatteryOptimization ->
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization > state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
// new-notification-sound > sound-unavailable. At most one renders at a time. RoomListBanner.NewNotificationSound,
SecurityBannerState.None -> when { RoomListBanner.SwipeAvatar -> Unit
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> { }
item { },
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState) onDismissClick = {
} when (displayedBanner) {
} RoomListBanner.SetUpRecovery,
state.batteryOptimizationState.shouldDisplayBanner -> { RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner)
item { RoomListBanner.FullScreenIntentPermission ->
BatteryOptimizationBanner(state = state.batteryOptimizationState) state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss)
} RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss)
} RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
state.showNewNotificationSoundBanner -> { RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner)
item { }
NewNotificationSoundBanner( },
onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) }, )
)
}
}
} }
} }
// Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room // Note: do not use a key for the LazyColumn, or the scroll will not behave as expected if a room
// is moved to the top of the list. // is moved to the top of the list.
itemsIndexed( itemsIndexed(
@@ -298,6 +310,73 @@ private fun RoomsViewList(
} }
} }
/**
* Displays the [banner] at the top of the room list, and animates every change: when the banner is
* dismissed, when it is replaced by another banner, and when a banner appears.
* The size of the slot is animated, so that the items below it are not moved abruptly.
*
* @param banner the banner to display, or null if there is no banner to display.
* @param modifier the modifier to apply to the slot.
* @param content renders the given banner.
*/
@Composable
private fun RoomListBannerContainer(
banner: RoomListBanner?,
modifier: Modifier = Modifier,
content: @Composable (RoomListBanner) -> Unit,
) {
AnimatedContent(
targetState = banner,
// Fill the width so that only the height is animated
modifier = modifier.fillMaxWidth(),
transitionSpec = {
fadeIn(tween(durationMillis = HALF_DURATION_MILLIS, delayMillis = HALF_DURATION_MILLIS)) togetherWith
fadeOut(tween(durationMillis = HALF_DURATION_MILLIS)) using
SizeTransform { _, _ -> tween(durationMillis = 2 * HALF_DURATION_MILLIS) }
},
contentAlignment = Alignment.Center,
label = "RoomListBannerContainer",
) { targetBanner ->
if (targetBanner != null) {
content(targetBanner)
}
}
}
private const val HALF_DURATION_MILLIS = 150
@Composable
private fun RoomListBannerView(
banner: RoomListBanner,
onActionClick: () -> Unit,
onDismissClick: () -> Unit,
) {
when (banner) {
RoomListBanner.SetUpRecovery -> SetUpRecoveryKeyBanner(
onContinueClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.RecoveryKeyConfirmation -> ConfirmRecoveryKeyBanner(
onContinueClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.FullScreenIntentPermission -> FullScreenIntentPermissionBanner(
onActionClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.BatteryOptimization -> BatteryOptimizationBanner(
onActionClick = onActionClick,
onDismissClick = onDismissClick,
)
RoomListBanner.NewNotificationSound -> NewNotificationSoundBanner(
onDismissClick = onDismissClick,
)
RoomListBanner.SwipeAvatar -> SwipeAvatarBanner(
onDismissClick = onDismissClick,
)
}
}
@Composable @Composable
private fun EmptyViewForFilterStates( private fun EmptyViewForFilterStates(
selectedFilters: ImmutableList<RoomListFilter>, selectedFilters: ImmutableList<RoomListFilter>,
@@ -0,0 +1,43 @@
/*
* 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.home.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.home.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun SwipeAvatarBanner(
onDismissClick: () -> Unit,
modifier: Modifier = Modifier,
) {
Announcement(
modifier = modifier.roomListBannerPadding(),
title = stringResource(R.string.banner_swipe_avatar_title),
description = stringResource(R.string.banner_swipe_avatar_content),
type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_ok),
onActionClick = onDismissClick,
onDismissClick = onDismissClick,
),
)
}
@PreviewsDayNight
@Composable
internal fun SwipeAvatarBannerPreview() = ElementPreview {
SwipeAvatarBanner(
onDismissClick = {},
)
}
@@ -0,0 +1,40 @@
/*
* 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.home.impl.roomlist
/**
* The banners which can be displayed at the top of the room list. At most one banner is displayed at a time.
*/
internal enum class RoomListBanner {
SetUpRecovery,
RecoveryKeyConfirmation,
FullScreenIntentPermission,
BatteryOptimization,
NewNotificationSound,
SwipeAvatar,
}
internal fun SecurityBannerState.toRoomListBanner(): RoomListBanner? = when (this) {
SecurityBannerState.SetUpRecovery -> RoomListBanner.SetUpRecovery
SecurityBannerState.RecoveryKeyConfirmation -> RoomListBanner.RecoveryKeyConfirmation
SecurityBannerState.None -> null
}
/**
* Banner precedence (top-to-bottom): security > full-screen-intent > battery-optimization >
* new-notification-sound > swipe-avatar. At most one renders at a time.
*/
internal fun RoomListContentState.Rooms.toRoomListBanner(): RoomListBanner? {
return securityBannerState.toRoomListBanner() ?: when {
fullScreenIntentPermissionsState.shouldDisplayBanner -> RoomListBanner.FullScreenIntentPermission
batteryOptimizationState.shouldDisplayBanner -> RoomListBanner.BatteryOptimization
showNewNotificationSoundBanner -> RoomListBanner.NewNotificationSound
showSwipeAvatarBanner -> RoomListBanner.SwipeAvatar
else -> null
}
}
@@ -30,12 +30,16 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider<RoomListC
aRoomsContentState( aRoomsContentState(
showNewNotificationSoundBanner = true, showNewNotificationSoundBanner = true,
), ),
aRoomsContentState(
showSwipeAvatarBanner = true,
),
) )
} }
internal fun aRoomsContentState( internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None, securityBannerState: SecurityBannerState = SecurityBannerState.None,
showNewNotificationSoundBanner: Boolean = false, showNewNotificationSoundBanner: Boolean = false,
showSwipeAvatarBanner: Boolean = false,
showUnreadCount: Boolean = false, showUnreadCount: Boolean = false,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(), summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(), fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
@@ -45,6 +49,7 @@ internal fun aRoomsContentState(
) = RoomListContentState.Rooms( ) = RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showSwipeAvatarBanner = showSwipeAvatarBanner,
showUnreadCount = showUnreadCount, showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState, fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState, batteryOptimizationState = batteryOptimizationState,
@@ -16,6 +16,7 @@ sealed interface RoomListEvent {
data object DismissRequestVerificationPrompt : RoomListEvent data object DismissRequestVerificationPrompt : RoomListEvent
data object DismissBanner : RoomListEvent data object DismissBanner : RoomListEvent
data object DismissNewNotificationSoundBanner : RoomListEvent data object DismissNewNotificationSoundBanner : RoomListEvent
data object DismissSwipeAvatarBanner : RoomListEvent
data object ToggleSearchResults : RoomListEvent data object ToggleSearchResults : RoomListEvent
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomList
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter import io.element.android.libraries.matrix.api.roomlist.RoomListFilter
import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar import io.element.android.libraries.matrix.ui.safety.rememberHideInvitesAvatar
import io.element.android.libraries.push.api.battery.BatteryOptimizationState import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher import io.element.android.services.analytics.api.watchers.AnalyticsColdStartWatcher
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@@ -64,6 +65,7 @@ import kotlinx.collections.immutable.toImmutableSet
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.collect import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.flatMapLatest import kotlinx.coroutines.flow.flatMapLatest
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
@@ -89,6 +91,7 @@ class RoomListPresenter(
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>, private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
private val globalSearchPresenter: Presenter<GlobalSearchState>, private val globalSearchPresenter: Presenter<GlobalSearchState>,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val sessionStore: SessionStore,
) : Presenter<RoomListState> { ) : Presenter<RoomListState> {
private val encryptionService = client.encryptionService private val encryptionService = client.encryptionService
@@ -112,6 +115,15 @@ class RoomListPresenter(
announcements.contains(Announcement.NewNotificationSound) announcements.contains(Announcement.NewNotificationSound)
} }
}.collectAsState(false) }.collectAsState(false)
val showSwipeAvatarBanner by remember {
combine(
announcementService.announcementsToShowFlow(),
sessionStore.sessionsFlow(),
) { announcements, sessions ->
// The swipe gesture on the avatar is only available when there are several accounts
sessions.size >= 2 && announcements.contains(Announcement.SwipeAvatarToSwitchAccount)
}
}.collectAsState(false)
// Avatar indicator // Avatar indicator
val hideInvitesAvatar by client.rememberHideInvitesAvatar() val hideInvitesAvatar by client.rememberHideInvitesAvatar()
@@ -133,6 +145,9 @@ class RoomListPresenter(
RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch { RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound) announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
} }
RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch {
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
}
RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility) RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
is RoomListEvent.ShowContextMenu -> { is RoomListEvent.ShowContextMenu -> {
coroutineScope.showContextMenu(event, contextMenu) coroutineScope.showContextMenu(event, contextMenu)
@@ -176,6 +191,7 @@ class RoomListPresenter(
val contentState = roomListContentState( val contentState = roomListContentState(
securityBannerDismissed, securityBannerDismissed,
showNewNotificationSoundBanner, showNewNotificationSoundBanner,
showSwipeAvatarBanner,
showUnreadCount, showUnreadCount,
showAllActivity, showAllActivity,
) )
@@ -235,6 +251,7 @@ class RoomListPresenter(
private fun roomListContentState( private fun roomListContentState(
securityBannerDismissed: Boolean, securityBannerDismissed: Boolean,
showNewNotificationSoundBanner: Boolean, showNewNotificationSoundBanner: Boolean,
showSwipeAvatarBanner: Boolean,
showUnreadCount: Boolean, showUnreadCount: Boolean,
showAllActivity: Boolean, showAllActivity: Boolean,
): RoomListContentState { ): RoomListContentState {
@@ -265,6 +282,7 @@ class RoomListPresenter(
RoomListContentState.Rooms( RoomListContentState.Rooms(
securityBannerState = securityBannerState, securityBannerState = securityBannerState,
showNewNotificationSoundBanner = showNewNotificationSoundBanner, showNewNotificationSoundBanner = showNewNotificationSoundBanner,
showSwipeAvatarBanner = showSwipeAvatarBanner,
showUnreadCount = showUnreadCount, showUnreadCount = showUnreadCount,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(), fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(), batteryOptimizationState = batteryOptimizationPresenter.present(),
@@ -73,6 +73,7 @@ sealed interface RoomListContentState {
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState, val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState, val batteryOptimizationState: BatteryOptimizationState,
val showNewNotificationSoundBanner: Boolean, val showNewNotificationSoundBanner: Boolean,
val showSwipeAvatarBanner: Boolean,
val showUnreadCount: Boolean, val showUnreadCount: Boolean,
val summaries: ImmutableList<RoomListRoomSummary>, val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>, val seenRoomInvites: ImmutableSet<RoomId>,
@@ -8,6 +8,8 @@
<string name="banner_set_up_recovery_content">"Your chats are automatically backed up with end-to-end encryption. To restore this backup and retain your digital identity when you lose access to all your devices, you will need your recovery key."</string> <string name="banner_set_up_recovery_content">"Your chats are automatically backed up with end-to-end encryption. To restore this backup and retain your digital identity when you lose access to all your devices, you will need your recovery key."</string>
<string name="banner_set_up_recovery_submit">"Get recovery key"</string> <string name="banner_set_up_recovery_submit">"Get recovery key"</string>
<string name="banner_set_up_recovery_title">"Back up your chats"</string> <string name="banner_set_up_recovery_title">"Back up your chats"</string>
<string name="banner_swipe_avatar_content">"Swipe up or down on your avatar to switch between your accounts without going to the app settings."</string>
<string name="banner_swipe_avatar_title">"Swipe your avatar to switch accounts"</string>
<string name="confirm_recovery_key_banner_message">"Confirm your recovery key to maintain access to your key storage and message history."</string> <string name="confirm_recovery_key_banner_message">"Confirm your recovery key to maintain access to your key storage and message history."</string>
<string name="confirm_recovery_key_banner_primary_button_title">"Enter your recovery key"</string> <string name="confirm_recovery_key_banner_primary_button_title">"Enter your recovery key"</string>
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your recovery key?"</string> <string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your recovery key?"</string>
@@ -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,
@@ -41,9 +41,12 @@ import io.element.android.tests.testutils.WarmUpRule
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 kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceTimeBy import kotlinx.coroutines.test.advanceTimeBy
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
@@ -122,6 +125,7 @@ class HomePresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - total unread count is updated on sync heartbeat`() = runTest { fun `present - total unread count is updated on sync heartbeat`() = runTest {
var unreadCount = 0L var unreadCount = 0L
@@ -142,6 +146,7 @@ class HomePresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test @Test
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest { fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
val syncService = FakeSyncService() val syncService = FakeSyncService()
@@ -289,6 +294,25 @@ class HomePresenterTest {
} }
} }
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `present - dismissing the swipe avatar announcement`() = runTest {
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val presenter = createHomePresenter(
announcementService = FakeAnnouncementService(
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
),
appCoroutineScope = this,
)
presenter.test {
val state = expectMostRecentItem()
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
runCurrent()
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.SwipeAvatarToSwitchAccount))
}
}
@Test @Test
fun `present - total unread count is loaded in the initial state`() = runTest { fun `present - total unread count is loaded in the initial state`() = runTest {
val presenter = createHomePresenter( val presenter = createHomePresenter(
@@ -300,7 +324,7 @@ class HomePresenterTest {
} }
} }
internal fun createHomePresenter( internal fun TestScope.createHomePresenter(
client: MatrixClient = FakeMatrixClient(), client: MatrixClient = FakeMatrixClient(),
syncService: SyncService = FakeSyncService(), syncService: SyncService = FakeSyncService(),
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(), snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
@@ -310,6 +334,7 @@ internal fun createHomePresenter(
sessionStore: SessionStore = InMemorySessionStore(), sessionStore: SessionStore = InMemorySessionStore(),
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
appCoroutineScope: CoroutineScope = backgroundScope,
) = HomePresenter( ) = HomePresenter(
client = client, client = client,
syncService = syncService, syncService = syncService,
@@ -321,4 +346,5 @@ internal fun createHomePresenter(
sessionStore = sessionStore, sessionStore = sessionStore,
announcementService = announcementService, announcementService = announcementService,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
appCoroutineScope = appCoroutineScope,
) )
@@ -56,6 +56,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2 import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID_3 import io.element.android.libraries.matrix.test.A_ROOM_ID_3
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.A_SESSION_ID_2
import io.element.android.libraries.matrix.test.FakeMatrixClient 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.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService import io.element.android.libraries.matrix.test.notificationsettings.FakeNotificationSettingsService
@@ -72,6 +73,9 @@ import io.element.android.libraries.preferences.test.InMemorySessionPreferencesS
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.sessionstorage.test.InMemorySessionStore
import io.element.android.libraries.sessionstorage.test.aSessionData
import io.element.android.services.analytics.api.AnalyticsService import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analytics.test.FakeAnalyticsService import io.element.android.services.analytics.test.FakeAnalyticsService
import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher import io.element.android.services.analytics.test.watchers.FakeAnalyticsColdStartWatcher
@@ -645,6 +649,48 @@ class RoomListPresenterTest {
} }
} }
@Test
fun `present - swipe avatar banner`() = runTest {
val roomList = FakeDynamicRoomList(
summaries = MutableStateFlow(listOf(aRoomSummary())),
loadingState = MutableStateFlow(RoomList.LoadingState.Loaded(1))
)
val roomListService = FakeRoomListService(
createRoomListLambda = { roomList },
subscribeToVisibleRoomsLambda = { },
)
val matrixClient = FakeMatrixClient(
roomListService = roomListService,
)
val onAnnouncementDismissedResult = lambdaRecorder<Announcement, Unit> { }
val announcementService = FakeAnnouncementService(
initialAnnouncementsToShowFlowValue = listOf(Announcement.SwipeAvatarToSwitchAccount),
onAnnouncementDismissedResult = onAnnouncementDismissedResult,
)
val sessionStore = InMemorySessionStore(
initialList = listOf(aSessionData(sessionId = A_SESSION_ID.value)),
)
val presenter = createRoomListPresenter(
client = matrixClient,
announcementService = announcementService,
sessionStore = sessionStore,
)
presenter.test {
skipItems(2) // Skip initial state and intermediate event for loading the value of the feature flags
val state = awaitItem()
// Only one account, the banner is not displayed
assertThat(state.contentAsRooms().showSwipeAvatarBanner).isFalse()
sessionStore.addSession(aSessionData(sessionId = A_SESSION_ID_2.value))
assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isTrue()
state.eventSink(RoomListEvent.DismissSwipeAvatarBanner)
onAnnouncementDismissedResult.assertions().isCalledOnce()
.with(value(Announcement.SwipeAvatarToSwitchAccount))
// Simulate service updating the value
announcementService.emitAnnouncementsToShow(emptyList())
assertThat(awaitItem().contentAsRooms().showSwipeAvatarBanner).isFalse()
}
}
@Test @Test
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest { fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
val roomList = FakeDynamicRoomList( val roomList = FakeDynamicRoomList(
@@ -724,6 +770,7 @@ class RoomListPresenterTest {
announcementService: AnnouncementService = FakeAnnouncementService(), announcementService: AnnouncementService = FakeAnnouncementService(),
featureFlagService: FeatureFlagService = FakeFeatureFlagService(), featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
markRoomAsRead: MarkRoomAsRead? = null, markRoomAsRead: MarkRoomAsRead? = null,
sessionStore: SessionStore = InMemorySessionStore(),
) = RoomListPresenter( ) = RoomListPresenter(
client = client, client = client,
leaveRoomPresenter = { leaveRoomState }, leaveRoomPresenter = { leaveRoomState },
@@ -756,5 +803,6 @@ class RoomListPresenterTest {
announcementService = announcementService, announcementService = announcementService,
coldStartWatcher = FakeAnalyticsColdStartWatcher(), coldStartWatcher = FakeAnalyticsColdStartWatcher(),
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
sessionStore = sessionStore,
) )
} }
@@ -51,9 +51,10 @@ class RoomListViewTest : RobolectricTest() {
) )
) )
// The banner is not included in the range, only the rooms are
eventsRecorder.assertList( eventsRecorder.assertList(
listOf( listOf(
RoomListEvent.UpdateVisibleRange(0..5), RoomListEvent.UpdateVisibleRange(0..4),
) )
) )
} }
@@ -64,13 +65,15 @@ class RoomListViewTest : RobolectricTest() {
setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.RecoveryKeyConfirmation),
eventSink = eventsRecorder, // Ignore the visible range updates, which are also emitted when the banner is shrinking
eventSink = { event ->
if (event !is RoomListEvent.UpdateVisibleRange) {
eventsRecorder(event)
}
},
) )
) )
// Remove automatic initial events
eventsRecorder.clear()
val close = activity!!.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
@@ -82,18 +85,41 @@ class RoomListViewTest : RobolectricTest() {
setRoomListView( setRoomListView(
state = aRoomListState( state = aRoomListState(
contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery), contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery),
eventSink = eventsRecorder, // Ignore the visible range updates, which are also emitted when the banner is shrinking
eventSink = { event ->
if (event !is RoomListEvent.UpdateVisibleRange) {
eventsRecorder(event)
}
},
) )
) )
// Remove automatic initial events
eventsRecorder.clear()
val close = activity!!.getString(CommonStrings.action_close) val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick() onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissBanner) eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
} }
@Test
fun `clicking on close swipe avatar banner emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>()
setRoomListView(
state = aRoomListState(
contentState = aRoomsContentState(showSwipeAvatarBanner = true),
// Ignore the visible range updates, which are also emitted when the banner is shrinking
eventSink = { event ->
if (event !is RoomListEvent.UpdateVisibleRange) {
eventsRecorder(event)
}
},
)
)
eventsRecorder.assertEmpty()
val close = activity!!.getString(CommonStrings.action_close)
onNodeWithContentDescription(close).performClick()
eventsRecorder.assertSingle(RoomListEvent.DismissSwipeAvatarBanner)
}
@Test @Test
fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest { fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<RoomListEvent>() val eventsRecorder = EventsRecorder<RoomListEvent>()
@@ -305,6 +331,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
onRoomClick = { roomId, _ -> onRoomClick(roomId) }, onRoomClick = { roomId, _ -> onRoomClick(roomId) },
onSettingsClick = onSettingsClick, onSettingsClick = onSettingsClick,
onAddAccountClick = onAddAccountClick, onAddAccountClick = onAddAccountClick,
onSwitchToAccount = EnsureNeverCalledWithParam(),
onSetUpRecoveryClick = onSetUpRecoveryClick, onSetUpRecoveryClick = onSetUpRecoveryClick,
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick, onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
onStartChatClick = onCreateRoomClick, onStartChatClick = onCreateRoomClick,
+4 -9
View File
@@ -26,7 +26,7 @@ android {
defaultConfig { defaultConfig {
buildConfigFieldStr( buildConfigFieldStr(
name = "MAPTILER_BASE_URL", name = "MAPTILER_BASE_URL",
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps" value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL,
) )
buildConfigFieldStr( buildConfigFieldStr(
name = "MAPTILER_API_KEY", name = "MAPTILER_API_KEY",
@@ -35,8 +35,7 @@ android {
} else { } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY") System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
?: readLocalProperty("services.maptiler.apikey") ?: readLocalProperty("services.maptiler.apikey")
} },
?: ""
) )
buildConfigFieldStr( buildConfigFieldStr(
name = "MAPTILER_LIGHT_MAP_ID", name = "MAPTILER_LIGHT_MAP_ID",
@@ -45,9 +44,7 @@ android {
} else { } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID") System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
?: readLocalProperty("services.maptiler.lightMapId") ?: readLocalProperty("services.maptiler.lightMapId")
} },
// fall back to maptiler's default light map.
?: "basic-v2"
) )
buildConfigFieldStr( buildConfigFieldStr(
name = "MAPTILER_DARK_MAP_ID", name = "MAPTILER_DARK_MAP_ID",
@@ -56,9 +53,7 @@ android {
} else { } else {
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID") System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
?: readLocalProperty("services.maptiler.darkMapId") ?: readLocalProperty("services.maptiler.darkMapId")
} },
// fall back to maptiler's default dark map.
?: "basic-v2-dark"
) )
} }
} }
@@ -19,14 +19,13 @@ val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() }
* The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided. * The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided.
*/ */
fun buildTimeMapTilerConfig(): MapTilerConfig? { fun buildTimeMapTilerConfig(): MapTilerConfig? {
return BuildConfig.MAPTILER_API_KEY // BuildConfig fields are null when not provided at build time.
.takeIf { it.isNotEmpty() } val apiKey: String? = BuildConfig.MAPTILER_API_KEY
?.let { apiKey -> if (apiKey.isNullOrEmpty()) return null
MapTilerConfig( return MapTilerConfig(
apiKey = apiKey, 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,
) )
}
} }
@@ -152,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)
@@ -195,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
else -> { else -> {
StaticMapPlaceholder( StaticMapPlaceholder(
painter = painterResource(R.drawable.blurred_map), painter = painterResource(R.drawable.blurred_map),
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error, canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error,
contentDescription = contentDescription, contentDescription = contentDescription,
width = maxWidth, width = maxWidth,
height = maxHeight, height = maxHeight,
@@ -0,0 +1,33 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
/**
* Public MapTiler defaults, used when a [MapTilerConfig] does not define a value.
* Values from another configuration (e.g. the build time one) must never be used as a fallback,
* to avoid sending an API key to a server it was not meant for.
*/
internal object MapTilerDefaults {
const val BASE_URL = "https://api.maptiler.com/maps"
const val LIGHT_MAP_ID = "basic-v2"
const val DARK_MAP_ID = "basic-v2-dark"
}
internal fun MapTilerConfig.baseUrlOrDefault(): String {
return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL
}
internal fun MapTilerConfig.lightMapIdOrDefault(): String {
return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID
}
internal fun MapTilerConfig.darkMapIdOrDefault(): String {
return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID
}
@@ -9,7 +9,6 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
import kotlin.math.roundToInt import kotlin.math.roundToInt
/** /**
@@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder(
private val lightMapId: String, private val lightMapId: String,
private val darkMapId: String, private val darkMapId: String,
) : StaticMapUrlBuilder { ) : StaticMapUrlBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
constructor(mapTilerConfig: MapTilerConfig) : this( constructor(mapTilerConfig: MapTilerConfig) : this(
baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"), baseUrl = mapTilerConfig.baseUrlOrDefault(),
apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY, apiKey = mapTilerConfig.apiKey,
lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID, lightMapId = mapTilerConfig.lightMapIdOrDefault(),
darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID, darkMapId = mapTilerConfig.darkMapIdOrDefault(),
) )
override fun build( override fun build(
@@ -11,33 +11,18 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.BuildConfig
internal class MapTilerTileServerStyleUriBuilder(
private val baseUrl: String,
private val apiKey: String,
private val lightMapId: String,
private val darkMapId: String,
) : TileServerStyleUriBuilder {
constructor() : this(
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
apiKey = BuildConfig.MAPTILER_API_KEY,
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
)
internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder {
override fun build( override fun build(
customMapTilerConfig: MapTilerConfig?, mapTilerConfig: MapTilerConfig,
darkMode: Boolean, darkMode: Boolean,
): String { ): String {
val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl val baseUrl = mapTilerConfig.baseUrlOrDefault()
val apiKey = customMapTilerConfig?.apiKey ?: apiKey
val mapId = if (darkMode) { val mapId = if (darkMode) {
customMapTilerConfig?.darkStyleId ?: darkMapId mapTilerConfig.darkMapIdOrDefault()
} else { } else {
customMapTilerConfig?.lightStyleId ?: lightMapId mapTilerConfig.lightMapIdOrDefault()
} }
return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}"
return "$baseUrl/$mapId/style.json?key=$apiKey"
} }
} }
@@ -36,17 +36,13 @@ interface StaticMapUrlBuilder {
density: Float, density: Float,
): String ): String
/** Whether a map provider is configured for this build; the URLs are unusable when it is not. */ /** Whether an API key is configured; the URLs are unusable when it is not. */
fun isServiceAvailable(): Boolean fun isServiceAvailable(): Boolean
} }
@Composable @Composable
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder { fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? {
return remember(mapTilerConfig) { return remember(mapTilerConfig) {
if (mapTilerConfig != null) { mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) }
MapTilerStaticMapUrlBuilder(mapTilerConfig)
} else {
MapTilerStaticMapUrlBuilder()
}
} }
} }
@@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder {
/** /**
* Builds the style URI the map view loads its tiles from. * Builds the style URI the map view loads its tiles from.
* *
* @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available. * @param mapTilerConfig the MapTiler configuration to use.
* @param darkMode whether to request the dark map style. * @param darkMode whether to request the dark map style.
*/ */
fun build( fun build(
customMapTilerConfig: MapTilerConfig?, mapTilerConfig: MapTilerConfig,
darkMode: Boolean, darkMode: Boolean,
): String ): String
} }
@@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder {
*/ */
@Composable @Composable
fun rememberTileStyleUrl( fun rememberTileStyleUrl(
customMapTileConfig: MapTilerConfig?, mapTilerConfig: MapTilerConfig?,
): String { ): String {
val darkMode = !ElementTheme.isLightTheme val darkMode = !ElementTheme.isLightTheme
return remember(darkMode, customMapTileConfig) { return remember(darkMode, mapTilerConfig) {
MapTilerTileServerStyleUriBuilder().build( // Without config, the map is not displayed, so the style URL is not used.
customMapTilerConfig = customMapTileConfig, mapTilerConfig?.let {
darkMode = darkMode, MapTilerTileServerStyleUriBuilder().build(
) mapTilerConfig = it,
darkMode = darkMode,
)
}.orEmpty()
} }
} }
@@ -9,6 +9,7 @@
package io.element.android.features.location.api.internal package io.element.android.features.location.api.internal
import com.google.common.truth.Truth.assertThat import com.google.common.truth.Truth.assertThat
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test import org.junit.Test
class MapTilerStaticMapUrlBuilderTest { class MapTilerStaticMapUrlBuilderTest {
@@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest {
assertThat(builder.isServiceAvailable()).isTrue() assertThat(builder.isServiceAvailable()).isTrue()
} }
@Test
fun `builder created from a config only uses the config values`() {
val configBuilder = MapTilerStaticMapUrlBuilder(
MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/maps/",
)
)
assertThat(configBuilder.isServiceAvailable()).isTrue()
assertThat(
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f)
).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright")
}
@Test
fun `builder created from a partial config falls back to the public MapTiler defaults`() {
val configBuilder = MapTilerStaticMapUrlBuilder(
MapTilerConfig(
apiKey = "",
lightStyleId = null,
darkStyleId = null,
baseUrl = null,
)
)
assertThat(configBuilder.isServiceAvailable()).isFalse()
assertThat(
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f)
).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright")
}
@Test @Test
fun `isServiceAvailable returns false if api key is empty`() { fun `isServiceAvailable returns false if api key is empty`() {
val builderWithoutKey = MapTilerStaticMapUrlBuilder( val builderWithoutKey = MapTilerStaticMapUrlBuilder(
@@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import org.junit.Test import org.junit.Test
class MapTilerTileServerStyleUriBuilderTest { class MapTilerTileServerStyleUriBuilderTest {
private val builder = MapTilerTileServerStyleUriBuilder( private val builder = MapTilerTileServerStyleUriBuilder()
baseUrl = "https://base.url",
apiKey = "anApiKey", private val config = MapTilerConfig(
lightMapId = "aLightMapId", apiKey = "API_KEY",
darkMapId = "aDarkMapId", lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/maps/",
) )
@Test @Test
fun `light map uri`() { fun `light map uri`() {
assertThat( assertThat(
builder.build( builder.build(
customMapTilerConfig = null, mapTilerConfig = config,
darkMode = false, darkMode = false,
) )
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey") ).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY")
} }
@Test @Test
fun `dark map uri`() { fun `dark map uri`() {
assertThat( assertThat(
builder.build( builder.build(
customMapTilerConfig = null, mapTilerConfig = config,
darkMode = true, darkMode = true,
) )
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey") ).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY")
} }
@Test @Test
fun `custom map uri light`() { fun `missing values fall back to the public MapTiler defaults`() {
val partialConfig = MapTilerConfig(
apiKey = "API_KEY",
lightStyleId = null,
darkStyleId = " ",
baseUrl = " ",
)
assertThat( assertThat(
builder.build( builder.build(
customMapTilerConfig = MapTilerConfig( mapTilerConfig = partialConfig,
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
darkMode = false, darkMode = false,
) )
).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY") ).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY")
}
@Test
fun `custom map uri dark`() {
assertThat( assertThat(
builder.build( builder.build(
customMapTilerConfig = MapTilerConfig( mapTilerConfig = partialConfig,
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = "https://custom.url/style.json"
),
darkMode = true, darkMode = true,
) )
).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY") ).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY")
} }
@Test @Test
fun `blank custom map uri falls back to the built-in style`() { fun `blank api key is kept as is`() {
assertThat( assertThat(
builder.build( builder.build(
customMapTilerConfig = MapTilerConfig( mapTilerConfig = config.copy(apiKey = ""),
apiKey = "API_KEY",
lightStyleId = "light",
darkStyleId = "dark",
baseUrl = " "
),
darkMode = false, darkMode = false,
) )
).isEqualTo("https://base.url/light/style.json?key=API_KEY") ).isEqualTo("https://custom.url/maps/light/style.json?key=")
} }
} }
@@ -11,7 +11,6 @@ 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.enterprise.api.remoteconfig.CustomMapTilerConfigProvider 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.BuildConfig
import io.element.android.features.location.api.buildTimeMapTilerConfig import io.element.android.features.location.api.buildTimeMapTilerConfig
import io.element.android.libraries.matrix.test.AN_EXCEPTION import io.element.android.libraries.matrix.test.AN_EXCEPTION
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
@@ -30,7 +29,7 @@ class DefaultLocationServiceTest {
) )
runCurrent() runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null)
} }
@Test @Test
@@ -40,7 +39,7 @@ class DefaultLocationServiceTest {
) )
runCurrent() runCurrent()
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig()) assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty()) assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null)
} }
@Test @Test
@@ -56,6 +56,7 @@ class ConfirmAccountProviderNode(
ConfirmAccountProviderView( ConfirmAccountProviderView(
state = state, state = state,
modifier = modifier, modifier = modifier,
onBackClick = ::navigateUp,
onOAuthDetails = callback::navigateToOAuth, onOAuthDetails = callback::navigateToOAuth,
onNeedLoginPassword = callback::navigateToLoginPassword, onNeedLoginPassword = callback::navigateToLoginPassword,
onLearnMoreClick = { openLearnMorePage(context) }, onLearnMoreClick = { openLearnMorePage(context) },
@@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider<Co
) )
} }
private fun aConfirmAccountProviderState( internal fun aConfirmAccountProviderState(
accountProviderInput: String = "matrix.org", accountProviderInput: String = "matrix.org",
accountProviderSuggestion: String? = null, accountProviderSuggestion: String? = null,
isAccountCreation: Boolean = false, isAccountCreation: Boolean = false,
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardActions
import androidx.compose.foundation.text.KeyboardOptions import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.material3.TopAppBarDefaults
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue import androidx.compose.runtime.getValue
@@ -50,12 +51,14 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
import io.element.android.libraries.designsystem.components.BigIcon import io.element.android.libraries.designsystem.components.BigIcon
import io.element.android.libraries.designsystem.components.button.BackButton
import io.element.android.libraries.designsystem.components.form.textFieldState import io.element.android.libraries.designsystem.components.form.textFieldState
import io.element.android.libraries.designsystem.preview.ElementPreview import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Button import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Icon import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.TextField import io.element.android.libraries.designsystem.theme.components.TextField
import io.element.android.libraries.designsystem.theme.components.TopAppBar
import io.element.android.libraries.matrix.api.auth.OAuthDetails import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
import io.element.android.libraries.testtags.TestTags import io.element.android.libraries.testtags.TestTags
@@ -71,6 +74,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
@Composable @Composable
fun ConfirmAccountProviderView( fun ConfirmAccountProviderView(
state: ConfirmAccountProviderState, state: ConfirmAccountProviderState,
onBackClick: () -> Unit,
onOAuthDetails: (OAuthDetails) -> Unit, onOAuthDetails: (OAuthDetails) -> Unit,
onNeedLoginPassword: () -> Unit, onNeedLoginPassword: () -> Unit,
onLearnMoreClick: () -> Unit, onLearnMoreClick: () -> Unit,
@@ -118,9 +122,15 @@ fun ConfirmAccountProviderView(
// Let the header and content scroll so the supporting text is not clipped by the footer when the // Let the header and content scroll so the supporting text is not clipped by the footer when the
// keyboard is visible and vertical space is reduced. // keyboard is visible and vertical space is reduced.
isScrollable = true, isScrollable = true,
topBar = {
TopAppBar(
title = {},
navigationIcon = { BackButton(onClick = onBackClick) },
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
)
},
header = { header = {
IconTitleSubtitleMolecule( IconTitleSubtitleMolecule(
modifier = Modifier.padding(top = 60.dp),
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()), iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource( title = stringResource(
id = if (state.isAccountCreation) { id = if (state.isAccountCreation) {
@@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview(
) = ElementPreview { ) = ElementPreview {
ConfirmAccountProviderView( ConfirmAccountProviderView(
state = state, state = state,
onBackClick = {},
onOAuthDetails = {}, onOAuthDetails = {},
onNeedLoginPassword = {}, onNeedLoginPassword = {},
onLearnMoreClick = {}, onLearnMoreClick = {},
@@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold(
) )
} }
/**
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607
*/
@Composable @Composable
private fun AddOtherAccountScaffold( private fun AddOtherAccountScaffold(
loginView: @Composable () -> Unit, loginView: @Composable () -> Unit,
@@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold(
) { ) {
FlowStepPage( FlowStepPage(
modifier = modifier, modifier = modifier,
title = stringResource(CommonStrings.common_add_account), title = stringResource(R.string.screen_onboarding_add_account_title),
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()), subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle),
iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()),
buttons = { buttons() }, buttons = { buttons() },
content = loginView, content = loginView,
onBackClick = onBackClick, onBackClick = onBackClick,
@@ -52,6 +52,8 @@
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string> <string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string> <string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string> <string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
<string name="screen_onboarding_add_account_subtitle">"You can add multiple accounts to the app and switch between them easily."</string>
<string name="screen_onboarding_add_account_title">"Sign in to another account"</string>
<string name="screen_onboarding_app_version">"Version %1$s"</string> <string name="screen_onboarding_app_version">"Version %1$s"</string>
<string name="screen_onboarding_checking_account">"Checking account"</string> <string name="screen_onboarding_checking_account">"Checking account"</string>
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string> <string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
@@ -0,0 +1,57 @@
/*
* Copyright (c) 2026 Element Creations Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalTestApi::class)
package io.element.android.features.login.impl.screens.confirmaccountprovider
import androidx.activity.ComponentActivity
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.libraries.matrix.api.auth.OAuthDetails
import io.element.android.tests.testutils.EnsureNeverCalled
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.robolectric.RobolectricTest
import org.junit.Test
class ConfirmAccountProviderViewTest : RobolectricTest() {
@Test
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
val eventSink = EventsRecorder<ConfirmAccountProviderEvent>(expectEvents = false)
ensureCalledOnce {
setConfirmAccountProviderView(
state = aConfirmAccountProviderState(
eventSink = eventSink,
),
onBackClick = it,
)
pressBack()
}
}
private fun AndroidComposeUiTest<ComponentActivity>.setConfirmAccountProviderView(
state: ConfirmAccountProviderState,
onBackClick: () -> Unit = EnsureNeverCalled(),
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
) {
setContent {
ConfirmAccountProviderView(
state = state,
onBackClick = onBackClick,
onOAuthDetails = onOAuthDetails,
onNeedLoginPassword = onNeedLoginPassword,
onLearnMoreClick = onLearnMoreClick,
)
}
}
}
@@ -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 {
@@ -190,22 +193,22 @@ class MessageComposerPresenter(
.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)
@@ -283,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()
} }
@@ -637,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
@@ -656,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,
) { ) {
@@ -665,19 +678,19 @@ class MessageComposerPresenter(
handlePickedMedia(uris.first(), sendAsFile = sendAsFile) handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
return return
} }
val attachments = uris.map { uri -> launch {
val localMedia = localMediaFactory.createFromUri( val attachments = uris.map { uri ->
uri = uri, val localMedia = createLocalMedia(
mimeType = null, uri = uri,
name = null, mimeType = null,
formattedFileSize = null, )
) Attachment.Media(localMedia, sendAsFile = sendAsFile)
Attachment.Media(localMedia, sendAsFile = sendAsFile) }.toImmutableList()
}.toImmutableList() val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
resetComposerModeAfterAttaching() resetComposerModeAfterAttaching()
}
} }
private fun resetComposerModeAfterAttaching() { private fun resetComposerModeAfterAttaching() {
@@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
contentScannerService = { _, _ -> }, contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(), contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled
@@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
contentScannerService = { _, _ -> }, contentScannerService = { _, _ -> },
contentValidationCache = InMemoryEventContentValidationCache(), contentValidationCache = InMemoryEventContentValidationCache(),
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
).apply { ).apply {
isTesting = true isTesting = true
showTextFormatting = isRichTextEditorEnabled showTextFormatting = isRichTextEditorEnabled
@@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
import io.element.android.libraries.architecture.NodeInputs import io.element.android.libraries.architecture.NodeInputs
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize import kotlinx.parcelize.Parcelize
interface PreferencesEntryPoint : FeatureEntryPoint { interface PreferencesEntryPoint : FeatureEntryPoint {
@@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
interface Callback : Plugin { interface Callback : Plugin {
fun navigateToAddAccount() fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToLinkNewDevice() fun navigateToLinkNewDevice()
fun navigateToBugReport() fun navigateToBugReport()
fun navigateToSecureBackup() fun navigateToSecureBackup()
@@ -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)
} }
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction
import io.element.android.libraries.architecture.AsyncData import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.SessionCoroutineScope import io.element.android.libraries.di.annotations.SessionCoroutineScope
import io.element.android.libraries.featureflag.api.FeatureFlagService import io.element.android.libraries.featureflag.api.FeatureFlagService
@@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.sync.Mutex import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber import timber.log.Timber
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@@ -76,6 +78,7 @@ class NotificationSettingsPresenter(
@SessionCoroutineScope @SessionCoroutineScope
private val sessionCoroutineScope: CoroutineScope, private val sessionCoroutineScope: CoroutineScope,
private val featureFlagService: FeatureFlagService, private val featureFlagService: FeatureFlagService,
private val dispatchers: CoroutineDispatchers,
) : Presenter<NotificationSettingsState> { ) : Presenter<NotificationSettingsState> {
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks // Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected; // its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
@@ -111,16 +114,19 @@ class NotificationSettingsPresenter(
} }
// List of PushProvider -> Distributor // List of PushProvider -> Distributor
val distributors = remember { // Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread
pushService.getAvailablePushProviders() val distributors by produceState(emptyList<Pair<PushProvider, Distributor>>()) {
.flatMap { pushProvider -> value = withContext(dispatchers.io) {
pushProvider.getDistributors().map { distributor -> pushService.getAvailablePushProviders()
pushProvider to distributor .flatMap { pushProvider ->
pushProvider.getDistributors().map { distributor ->
pushProvider to distributor
}
} }
} }
} }
// List of Distributors // List of Distributors
val availableDistributors = remember { val availableDistributors = remember(distributors) {
distributors.map { it.second }.toImmutableList() distributors.map { it.second }.toImmutableList()
} }
@@ -8,11 +8,8 @@
package io.element.android.features.preferences.impl.root package io.element.android.features.preferences.impl.root
import io.element.android.libraries.matrix.api.core.SessionId
sealed interface PreferencesRootEvent { sealed interface PreferencesRootEvent {
data object OnVersionInfoClick : PreferencesRootEvent data object OnVersionInfoClick : PreferencesRootEvent
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
data object ToggleOtherAccountsExpanded : PreferencesRootEvent data object ToggleOtherAccountsExpanded : PreferencesRootEvent
data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent
} }
@@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
import io.element.android.libraries.architecture.callback import io.element.android.libraries.architecture.callback
import io.element.android.libraries.di.SessionScope import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
import io.element.android.libraries.matrix.api.core.SessionId
@ContributesNode(SessionScope::class) @ContributesNode(SessionScope::class)
@AssistedInject @AssistedInject
@@ -35,6 +36,7 @@ class PreferencesRootNode(
) : Node(buildContext, plugins = plugins) { ) : Node(buildContext, plugins = plugins) {
interface Callback : PreferencesAccountCallback, Plugin { interface Callback : PreferencesAccountCallback, Plugin {
fun navigateToAddAccount() fun navigateToAddAccount()
fun navigateToSession(sessionId: SessionId)
fun navigateToAnalyticsSettings() fun navigateToAnalyticsSettings()
fun navigateToAbout() fun navigateToAbout()
fun navigateToDeveloperSettings() fun navigateToDeveloperSettings()
@@ -72,6 +74,7 @@ class PreferencesRootNode(
modifier = modifier, modifier = modifier,
onBackClick = this::navigateUp, onBackClick = this::navigateUp,
onAddAccountClick = callback::navigateToAddAccount, onAddAccountClick = callback::navigateToAddAccount,
onSwitchToSession = callback::navigateToSession,
onOpenAnalytics = callback::navigateToAnalyticsSettings, onOpenAnalytics = callback::navigateToAnalyticsSettings,
onOpenAbout = callback::navigateToAbout, onOpenAbout = callback::navigateToAbout,
onOpenDeveloperSettings = callback::navigateToDeveloperSettings, onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
@@ -142,9 +142,6 @@ class PreferencesRootPresenter(
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)
} }
@@ -66,6 +66,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
@@ -80,6 +81,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,
@@ -125,6 +127,7 @@ fun PreferencesRootView(
MultiAccountSection( MultiAccountSection(
state = state, state = state,
onAddAccountClick = onAddAccountClick, onAddAccountClick = onAddAccountClick,
onSwitchToSession = onSwitchToSession,
) )
} }
HorizontalDivider( HorizontalDivider(
@@ -209,6 +212,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,
@@ -276,7 +280,7 @@ private fun ColumnScope.MultiAccountSection(
MatrixUserRow( MatrixUserRow(
modifier = Modifier modifier = Modifier
.clickable { .clickable {
state.eventSink(PreferencesRootEvent.SwitchToSession(otherAccount.user.userId)) onSwitchToSession(otherAccount.user.userId)
} }
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp), .padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
matrixUser = otherAccount.user, matrixUser = otherAccount.user,
@@ -433,6 +437,7 @@ private fun ContentToPreview(state: PreferencesRootState) {
emojiPickerRenderer = NoOpEmojiPickerRenderer, emojiPickerRenderer = NoOpEmojiPickerRenderer,
onBackClick = {}, onBackClick = {},
onAddAccountClick = {}, onAddAccountClick = {},
onSwitchToSession = {},
onOpenAnalytics = {}, onOpenAnalytics = {},
onOpenDeveloperSettings = {}, onOpenDeveloperSettings = {},
onOpenLocationSettings = {}, onOpenLocationSettings = {},
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
import io.element.android.features.preferences.api.PreferencesEntryPoint import io.element.android.features.preferences.api.PreferencesEntryPoint
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
@@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest {
} }
val callback = object : PreferencesEntryPoint.Callback { val callback = object : PreferencesEntryPoint.Callback {
override fun navigateToAddAccount() = lambdaError() override fun navigateToAddAccount() = lambdaError()
override fun navigateToSession(sessionId: SessionId) = lambdaError()
override fun navigateToLinkNewDevice() = lambdaError() override fun navigateToLinkNewDevice() = lambdaError()
override fun navigateToBugReport() = lambdaError() override fun navigateToBugReport() = lambdaError()
override fun navigateToSecureBackup() = lambdaError() override fun navigateToSecureBackup() = lambdaError()
@@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value import io.element.android.tests.testutils.lambda.value
import io.element.android.tests.testutils.test import io.element.android.tests.testutils.test
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
@@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest {
stringProvider = FakeStringProvider(), stringProvider = FakeStringProvider(),
sessionCoroutineScope = backgroundScope, sessionCoroutineScope = backgroundScope,
featureFlagService = featureFlagService, featureFlagService = featureFlagService,
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
) )
} }
} }
@@ -44,8 +44,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
@@ -184,24 +182,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(
@@ -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,25 +70,27 @@ 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,
anOtherAccount( otherSessions = listOf(
user = aMatrixUser( anOtherAccount(
id = A_USER_ID_2.value, user = 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
@@ -425,6 +428,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(),
@@ -448,6 +452,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>
+1 -1
View File
@@ -41,7 +41,7 @@ coil = "3.6.3"
# There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs. # There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs.
# When upgrading this version, check state restoration still works fine. # When upgrading this version, check state restoration still works fine.
appyx = "1.7.1" appyx = "1.7.1"
sqldelight = "2.4.0" sqldelight = "2.4.1"
wysiwyg = "2.42.1" wysiwyg = "2.42.1"
telephoto = "0.19.0" telephoto = "0.19.0"
maplibre-compose = "0.19.0" maplibre-compose = "0.19.0"
@@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long {
Timber.v("Get size of ${it.absolutePath}") Timber.v("Get size of ${it.absolutePath}")
true true
} }
.sumOf { it.length() } // Only sum the actual size of files, not directories, to avoid double counting or worse,
// since the docs mention that `length()` for directories is not reliable.
// For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder,
// which is a common block size on many file systems.
.sumOf { if (it.isFile) it.length() else 4096L }
} }
@@ -15,6 +15,8 @@ import androidx.compose.foundation.clickable
import androidx.compose.foundation.combinedClickable import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.gestures.awaitEachGesture import androidx.compose.foundation.gestures.awaitEachGesture
import androidx.compose.foundation.gestures.awaitFirstDown import androidx.compose.foundation.gestures.awaitFirstDown
import androidx.compose.foundation.gestures.awaitLongPressOrCancellation
import androidx.compose.foundation.gestures.waitForUpOrCancellation
import androidx.compose.foundation.horizontalScroll import androidx.compose.foundation.horizontalScroll
import androidx.compose.foundation.layout.Arrangement import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box import androidx.compose.foundation.layout.Box
@@ -53,8 +55,7 @@ import androidx.compose.ui.graphics.drawOutline
import androidx.compose.ui.graphics.drawscope.Fill import androidx.compose.ui.graphics.drawscope.Fill
import androidx.compose.ui.graphics.drawscope.Stroke import androidx.compose.ui.graphics.drawscope.Stroke
import androidx.compose.ui.graphics.drawscope.translate import androidx.compose.ui.graphics.drawscope.translate
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
import androidx.compose.ui.input.pointer.changedToUp
import androidx.compose.ui.input.pointer.pointerInput import androidx.compose.ui.input.pointer.pointerInput
import androidx.compose.ui.platform.ClipEntry import androidx.compose.ui.platform.ClipEntry
import androidx.compose.ui.platform.LocalClipboard import androidx.compose.ui.platform.LocalClipboard
@@ -565,34 +566,18 @@ private fun Modifier.linkTapHandler(
val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture
down.consume() down.consume()
val longPressTimeout = viewConfiguration.longPressTimeoutMillis // Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event.
if (awaitLongPressOrCancellation(down.id) != null) {
// Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that.
// If we did, it's a tap, otherwise it's a long press.
// This warning is a false positive.
@Suppress("KotlinConstantConditions")
val isLongPress = try {
withTimeout(longPressTimeout) {
var receivedUp = false
while (!receivedUp) {
val event = awaitPointerEvent()
val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() }
if (up != null) {
receivedUp = true
up.consume()
}
}
!receivedUp
}
} catch (_: PointerEventTimeoutCancellationException) {
true
}
if (isLongPress) {
context.onLinkLongClick(url, urlText) context.onLinkLongClick(url, urlText)
// Swallow the rest of the gesture so parents don't react to the eventual release.
waitForUpOrCancellation()?.consume()
} else { } else {
context.onLinkClick(url, urlText) // Either a tap (the pointer was released) or a drag (the user probably wanted to scroll).
val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() }
if (up != null) {
up.consume()
context.onLinkClick(url, urlText)
}
} }
} }
} }
+5 -1
View File
@@ -59,7 +59,11 @@ dependencies {
implementation(platform("io.element.android:element-call-bom:$elementCallVersion")) implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
implementation(libs.element.call.api) implementation(libs.element.call.api)
implementation(projects.features.callnative.api) implementation(projects.features.callnative.api)
implementation(libs.element.call.matrix) implementation(libs.element.call.matrix) {
if (usesLocalRustSdk) {
exclude(group = "org.matrix.rustcomponents", module = "sdk-android")
}
}
implementation(projects.appconfig) implementation(projects.appconfig)
implementation(projects.features.enterprise.api) implementation(projects.features.enterprise.api)
@@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.buffer import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.distinctUntilChanged import kotlinx.coroutines.flow.distinctUntilChanged
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.map import kotlinx.coroutines.flow.map
import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.mapNotNull
@@ -320,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)
} }
} }
@@ -1013,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)
} }
} }
@@ -158,6 +158,7 @@ class JoinedRustRoom(
inner = innerRoom.threadListService(), inner = innerRoom.threadListService(),
contentMapper = TimelineEventContentMapper(), contentMapper = TimelineEventContentMapper(),
roomCoroutineScope = roomCoroutineScope, roomCoroutineScope = roomCoroutineScope,
roomDispatcher = roomDispatcher,
) )
override val syncUpdateFlow = flow { override val syncUpdateFlow = flow {
@@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.map import io.element.android.libraries.matrix.impl.timeline.item.event.map
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Job import kotlinx.coroutines.Job
import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.flowOn
import kotlinx.coroutines.flow.launchIn import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.onStart
@@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
class RustThreadsListService( class RustThreadsListService(
private val inner: InnerThreadListService, private val inner: InnerThreadListService,
private val roomCoroutineScope: CoroutineScope, private val roomCoroutineScope: CoroutineScope,
private val roomDispatcher: CoroutineDispatcher,
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(), private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
) : ThreadsListService { ) : ThreadsListService {
private var itemSubscriptionJob: Job? = null private var itemSubscriptionJob: Job? = null
@@ -63,6 +66,8 @@ class RustThreadsListService(
updated.apply(diff, contentMapper) updated.apply(diff, contentMapper)
items.value = updated items.value = updated
} }
// Fetching and mapping the items can be costly, do not do it on the main thread
.flowOn(roomDispatcher)
.launchIn(roomCoroutineScope) .launchIn(roomCoroutineScope)
} }
@@ -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()
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_TIMESTAMP
import io.element.android.libraries.matrix.test.A_USER_ID import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.tests.testutils.lambda.lambdaRecorder import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.ExperimentalCoroutinesApi import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.TestScope import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest import kotlinx.coroutines.test.runTest
@@ -122,6 +123,7 @@ class RustThreadsListServiceTest {
) = RustThreadsListService( ) = RustThreadsListService(
inner = inner, inner = inner,
roomCoroutineScope = backgroundScope, roomCoroutineScope = backgroundScope,
roomDispatcher = StandardTestDispatcher(testScheduler),
) )
private fun aRustThreadListUpdate() = ThreadListUpdate.Append( private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
@@ -10,6 +10,7 @@ package io.element.android.libraries.network.interceptors
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn import dev.zacsweers.metro.SingleIn
import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.tracing.LogLevel import io.element.android.libraries.matrix.api.tracing.LogLevel
import io.element.android.libraries.preferences.api.store.AppPreferencesStore import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
@@ -31,7 +32,12 @@ class DynamicHttpLoggingInterceptor(
override fun intercept(chain: Interceptor.Chain): Response { override fun intercept(chain: Interceptor.Chain): Response {
// This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached // This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached
val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() } val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() }
loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE // If the request body is binary, we log only the headers to avoid logging binary data
val isBinaryBody = chain.request().headers["Content-Type"] == MimeTypes.OctetStream
loggingInterceptor.level = when {
logLevel >= LogLevel.DEBUG -> if (isBinaryBody) Level.HEADERS else Level.BODY
else -> Level.NONE
}
return loggingInterceptor.intercept(chain) return loggingInterceptor.intercept(chain)
} }
} }
@@ -14,6 +14,7 @@ import android.os.PowerManager
import androidx.core.content.getSystemService import androidx.core.content.getSystemService
import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesBinding import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.annotations.ApplicationContext import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.core.EventId import io.element.android.libraries.matrix.api.core.EventId
@@ -23,23 +24,25 @@ import io.element.android.libraries.push.impl.PushDatabase
import io.element.android.libraries.push.impl.db.PushHistory import io.element.android.libraries.push.impl.db.PushHistory
import io.element.android.libraries.push.impl.db.PushRequest import io.element.android.libraries.push.impl.db.PushRequest
import io.element.android.services.toolbox.api.systemclock.SystemClock import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.withContext
import kotlin.time.Instant import kotlin.time.Instant
@ContributesBinding(AppScope::class) @ContributesBinding(AppScope::class)
class DefaultPushHistoryService( class DefaultPushHistoryService(
private val pushDatabase: PushDatabase, private val pushDatabase: PushDatabase,
private val systemClock: SystemClock, private val systemClock: SystemClock,
private val dispatchers: CoroutineDispatchers,
@ApplicationContext context: Context, @ApplicationContext context: Context,
) : PushHistoryService { ) : PushHistoryService {
private val powerManager = context.getSystemService<PowerManager>() private val powerManager = context.getSystemService<PowerManager>()
private val packageName = context.packageName private val packageName = context.packageName
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> { override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> = withContext(dispatchers.io) {
return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() } runCatchingExceptions<Unit> { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() }
} }
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> { override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> = withContext(dispatchers.io) {
return runCatchingExceptions { runCatchingExceptions {
pushDatabase.transaction { pushDatabase.transaction {
for (request in pushRequests) { for (request in pushRequests) {
pushDatabase.pushRequestQueries.insertPushRequest(request) pushDatabase.pushRequestQueries.insertPushRequest(request)
@@ -48,8 +51,8 @@ class DefaultPushHistoryService(
} }
} }
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> { override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> = withContext(dispatchers.io) {
return runCatchingExceptions { runCatchingExceptions {
pushDatabase.transactionWithResult { pushDatabase.transactionWithResult {
val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0 val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0
pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList() pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList()
@@ -57,14 +60,14 @@ class DefaultPushHistoryService(
} }
} }
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> { override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> = withContext(dispatchers.io) {
return runCatchingExceptions { runCatchingExceptions<Unit> {
val keepAmount = 100L val keepAmount = 100L
pushDatabase.pushRequestQueries.removeOldest(keepAmount) pushDatabase.pushRequestQueries.removeOldest(keepAmount)
} }
} }
override fun onPushResult( override suspend fun onPushResult(
providerInfo: String, providerInfo: String,
eventId: EventId?, eventId: EventId?,
roomId: RoomId?, roomId: RoomId?,
@@ -91,19 +94,21 @@ class DefaultPushHistoryService(
} }
}.takeIf { it.isNotEmpty() } }.takeIf { it.isNotEmpty() }
pushDatabase.pushHistoryQueries.insertPushHistory( withContext(dispatchers.io) {
PushHistory( pushDatabase.pushHistoryQueries.insertPushHistory(
pushDate = systemClock.epochMillis(), PushHistory(
providerInfo = providerInfo, pushDate = systemClock.epochMillis(),
eventId = eventId?.value, providerInfo = providerInfo,
roomId = roomId?.value, eventId = eventId?.value,
sessionId = sessionId?.value, roomId = roomId?.value,
hasBeenResolved = if (hasBeenResolved) 1 else 0, sessionId = sessionId?.value,
comment = finalComment, hasBeenResolved = if (hasBeenResolved) 1 else 0,
comment = finalComment,
)
) )
)
// Keep only the last 1_000 events // Keep only the last 1_000 events
pushDatabase.pushHistoryQueries.removeOldest(1_000) pushDatabase.pushHistoryQueries.removeOldest(1_000)
}
} }
} }
@@ -20,7 +20,7 @@ interface PushHistoryService {
* Create a new push history entry. * Create a new push history entry.
* Do not use directly, prefer using the extension functions. * Do not use directly, prefer using the extension functions.
*/ */
fun onPushResult( suspend fun onPushResult(
providerInfo: String, providerInfo: String,
eventId: EventId?, eventId: EventId?,
roomId: RoomId?, roomId: RoomId?,
@@ -52,7 +52,7 @@ interface PushHistoryService {
suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit>
} }
fun PushHistoryService.onInvalidPushReceived( suspend fun PushHistoryService.onInvalidPushReceived(
providerInfo: String, providerInfo: String,
data: String, data: String,
) = onPushResult( ) = onPushResult(
@@ -65,7 +65,7 @@ fun PushHistoryService.onInvalidPushReceived(
comment = "Invalid or ignored push data:\n$data", comment = "Invalid or ignored push data:\n$data",
) )
fun PushHistoryService.onUnableToRetrieveSession( suspend fun PushHistoryService.onUnableToRetrieveSession(
providerInfo: String, providerInfo: String,
eventId: EventId, eventId: EventId,
roomId: RoomId, roomId: RoomId,
@@ -80,7 +80,7 @@ fun PushHistoryService.onUnableToRetrieveSession(
comment = "Unable to retrieve session: $reason", comment = "Unable to retrieve session: $reason",
) )
fun PushHistoryService.onUnableToResolveEvent( suspend fun PushHistoryService.onUnableToResolveEvent(
providerInfo: String, providerInfo: String,
eventId: EventId, eventId: EventId,
roomId: RoomId, roomId: RoomId,
@@ -96,7 +96,7 @@ fun PushHistoryService.onUnableToResolveEvent(
comment = "Unable to resolve event: $reason", comment = "Unable to resolve event: $reason",
) )
fun PushHistoryService.onSuccess( suspend fun PushHistoryService.onSuccess(
providerInfo: String, providerInfo: String,
eventId: EventId, eventId: EventId,
roomId: RoomId, roomId: RoomId,
@@ -117,7 +117,7 @@ fun PushHistoryService.onSuccess(
}, },
) )
fun PushHistoryService.onDiagnosticPush( suspend fun PushHistoryService.onDiagnosticPush(
providerInfo: String, providerInfo: String,
) = onPushResult( ) = onPushResult(
providerInfo = providerInfo, providerInfo = providerInfo,
@@ -30,7 +30,7 @@ class FakePushHistoryService(
private val getPendingPushRequests: (SessionId, Instant?) -> Result<List<PushRequest>> = { _, _ -> lambdaError() }, private val getPendingPushRequests: (SessionId, Instant?) -> Result<List<PushRequest>> = { _, _ -> lambdaError() },
private val removeOldPushRequests: (SessionId) -> Result<Unit> = { lambdaError() }, private val removeOldPushRequests: (SessionId) -> Result<Unit> = { lambdaError() },
) : PushHistoryService { ) : PushHistoryService {
override fun onPushResult( override suspend fun onPushResult(
providerInfo: String, providerInfo: String,
eventId: EventId?, eventId: EventId?,
roomId: RoomId?, roomId: RoomId?,
@@ -10,14 +10,17 @@ package extension
import com.android.build.api.dsl.VariantDimension import com.android.build.api.dsl.VariantDimension
/**
* Adds a String field to the generated BuildConfig class. If [value] is null, the field will be null.
*/
fun VariantDimension.buildConfigFieldStr( fun VariantDimension.buildConfigFieldStr(
name: String, name: String,
value: String, value: String?,
) { ) {
buildConfigField( buildConfigField(
type = "String", type = "String",
name = name, name = name,
value = "\"$value\"" value = value?.let { "\"$it\"" } ?: "null"
) )
} }

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