mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-04 01:19:33 +00:00
Merge branch 'develop' into feature/bma/otherAccountUnreadCount
Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -74,7 +74,7 @@ jobs:
|
||||
run: ./gradlew :app:assembleGplayDebug app:assembleFDroidDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: elementx-debug
|
||||
path: |
|
||||
|
||||
@@ -79,7 +79,7 @@ jobs:
|
||||
run: ./gradlew :app:assembleGplayDebug -PallWarningsAsErrors=true $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload debug Enterprise APKs
|
||||
if: ${{ matrix.variant == 'debug' }}
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: elementx-enterprise-debug
|
||||
path: |
|
||||
|
||||
@@ -63,7 +63,7 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID: ${{ secrets.MAPTILER_LIGHT_MAP_ID }}
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
- name: Upload APK as artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: elementx-apk-maestro
|
||||
path: |
|
||||
@@ -138,7 +138,7 @@ jobs:
|
||||
pip install requests
|
||||
python3 ./tools/mas/delete_all_sessions.py --homeserver matrix.org --username maestroelement
|
||||
- name: Upload test results
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: test-results
|
||||
path: |
|
||||
|
||||
@@ -48,17 +48,17 @@ jobs:
|
||||
cache-read-only: false
|
||||
|
||||
- name: ⚙️ Run unit tests, debug and release
|
||||
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew test $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
|
||||
- name: 📸 Run screenshot tests
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
|
||||
- name: 📈 Generate kover report and verify coverage
|
||||
run: ./gradlew :app:koverXmlReportGplayDebug :app:koverHtmlReportGplayDebug :app:koverVerifyAll $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: ✅ Upload kover report
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: kover-results
|
||||
path: |
|
||||
@@ -92,7 +92,7 @@ jobs:
|
||||
run: ./gradlew dependencyCheckAnalyze $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload dependency analysis
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: dependency-analysis
|
||||
path: build/reports/dependency-check-report.html
|
||||
|
||||
@@ -120,7 +120,7 @@ jobs:
|
||||
run: ./gradlew :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --no-daemon
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: konsist-report
|
||||
path: |
|
||||
@@ -199,7 +199,7 @@ jobs:
|
||||
run: ./gradlew :app:lintGplayDebug :app:lintFdroidDebug lintDebug $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: linting-report
|
||||
path: |
|
||||
@@ -237,10 +237,10 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Detekt
|
||||
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon
|
||||
run: ./gradlew detekt $CI_GRADLE_ARG_PROPERTIES --no-daemon --continue
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: detekt-report
|
||||
path: |
|
||||
@@ -278,10 +278,10 @@ jobs:
|
||||
with:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
- name: Run Ktlint check
|
||||
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew ktlintCheck $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
- name: Upload reports
|
||||
if: always()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: ktlint-report
|
||||
path: |
|
||||
|
||||
@@ -59,7 +59,7 @@ jobs:
|
||||
ELEMENT_CALL_RAGESHAKE_URL: ${{ secrets.ELEMENT_CALL_RAGESHAKE_URL }}
|
||||
run: ./gradlew bundleGplayRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload bundle as artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: elementx-app-gplay-bundle-unsigned
|
||||
path: |
|
||||
@@ -105,7 +105,7 @@ jobs:
|
||||
ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID: ${{ secrets.MAPTILER_DARK_MAP_ID }}
|
||||
run: ./gradlew assembleFdroidRelease $CI_GRADLE_ARG_PROPERTIES
|
||||
- name: Upload apks as artifact
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: elementx-app-fdroid-apks-unsigned
|
||||
path: |
|
||||
|
||||
@@ -73,17 +73,17 @@ jobs:
|
||||
cache-read-only: ${{ github.ref != 'refs/heads/develop' }}
|
||||
|
||||
- name: ⚙️ Run unit tests
|
||||
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew :app:testGplayDebugUnitTest testDebugUnitTest -x :tests:konsist:testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
|
||||
- name: ⚙️ Run screenshot tests
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES
|
||||
run: ./gradlew verifyPaparazziDebug $CI_GRADLE_ARG_PROPERTIES --continue
|
||||
|
||||
- name: ⚙️ Generate kover coverage report
|
||||
run: ./gradlew :koverXmlReportMerged :koverHtmlReportMerged :koverVerifyAll -x testDebugUnitTest $CI_GRADLE_ARG_PROPERTIES
|
||||
|
||||
- name: 🚫 Upload kover failed coverage reports
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: kover-error-report
|
||||
path: |
|
||||
@@ -95,7 +95,7 @@ jobs:
|
||||
|
||||
- name: 🚫 Upload test results on error
|
||||
if: failure()
|
||||
uses: actions/upload-artifact@043fb46d1a93c77aae656e7c1c64a875d1fc6a0a # v7.0.1
|
||||
uses: actions/upload-artifact@cf430e030ddbb5b0abf93d22962f4752f3646cd9 # v7.0.2
|
||||
with:
|
||||
name: tests-and-screenshot-tests-results
|
||||
path: |
|
||||
|
||||
@@ -33,6 +33,7 @@ import io.element.android.libraries.architecture.createNode
|
||||
import io.element.android.libraries.architecture.inputs
|
||||
import io.element.android.libraries.di.DependencyInjectionGraphOwner
|
||||
import io.element.android.libraries.matrix.api.MatrixClient
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrixmedia.api.ImageLoaderHolder
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
@@ -59,6 +60,7 @@ class LoggedInAppScopeFlowNode(
|
||||
interface Callback : Plugin {
|
||||
fun navigateToBugReport()
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
@@ -91,6 +93,10 @@ class LoggedInAppScopeFlowNode(
|
||||
override fun navigateToAddAccount() {
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
}
|
||||
return createNode<LoggedInFlowNode>(buildContext, listOf(callback))
|
||||
}
|
||||
|
||||
@@ -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.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.RoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.core.UserId
|
||||
import io.element.android.libraries.matrix.api.core.toRoomIdOrAlias
|
||||
import io.element.android.libraries.matrix.api.permalink.PermalinkData
|
||||
@@ -169,6 +170,7 @@ class LoggedInFlowNode(
|
||||
interface Callback : Plugin {
|
||||
fun navigateToBugReport()
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
}
|
||||
|
||||
private val callback: Callback = callback()
|
||||
@@ -356,6 +358,10 @@ class LoggedInFlowNode(
|
||||
override fun navigateToAddAccount() {
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
}
|
||||
homeEntryPoint.createNode(
|
||||
parentNode = this,
|
||||
@@ -448,6 +454,10 @@ class LoggedInFlowNode(
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
|
||||
override fun navigateToLinkNewDevice() {
|
||||
backstack.push(NavTarget.LinkNewDevice)
|
||||
}
|
||||
|
||||
@@ -416,6 +416,12 @@ class RootFlowNode(
|
||||
override fun navigateToAddAccount() {
|
||||
backstack.push(NavTarget.NotLoggedInFlow(null))
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
lifecycleScope.launch {
|
||||
sessionStore.setLatestSession(sessionId.value)
|
||||
}
|
||||
}
|
||||
}
|
||||
val savedNavState = extractSavedStateForNavTarget(navTarget, this.buildContext.savedStateMap)
|
||||
val buildContext = if (savedNavState != null) {
|
||||
|
||||
+2
@@ -19,4 +19,6 @@ sealed interface Announcement {
|
||||
data object NewNotificationSound : Announcement
|
||||
|
||||
data object MultiAccount : Announcement
|
||||
|
||||
data object SwipeAvatarToSwitchAccount : Announcement
|
||||
}
|
||||
|
||||
+7
-1
@@ -34,6 +34,7 @@ class DefaultAnnouncementService(
|
||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Show)
|
||||
}
|
||||
Announcement.MultiAccount -> showAnnouncementIfNeverShown(announcement)
|
||||
Announcement.SwipeAvatarToSwitchAccount -> showAnnouncementIfNeverShown(announcement)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -46,7 +47,8 @@ class DefaultAnnouncementService(
|
||||
flowOf(Unit),
|
||||
announcementStore.announcementStatusFlow(Announcement.NewNotificationSound),
|
||||
announcementStore.announcementStatusFlow(Announcement.MultiAccount),
|
||||
) { _, newNotificationSoundStatus, multiAccountStatus ->
|
||||
announcementStore.announcementStatusFlow(Announcement.SwipeAvatarToSwitchAccount),
|
||||
) { _, newNotificationSoundStatus, multiAccountStatus, swipeAvatarToSwitchAccountStatus ->
|
||||
buildList {
|
||||
if (newNotificationSoundStatus == AnnouncementStatus.Show) {
|
||||
add(Announcement.NewNotificationSound)
|
||||
@@ -56,6 +58,10 @@ class DefaultAnnouncementService(
|
||||
if (multiAccountStatus != AnnouncementStatus.Shown) {
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+5
@@ -20,6 +20,7 @@ import kotlinx.coroutines.flow.map
|
||||
private val spaceAnnouncementKey = intPreferencesKey("spaceAnnouncement")
|
||||
private val newNotificationSoundKey = intPreferencesKey("newNotificationSound")
|
||||
private val multiAccountKey = intPreferencesKey("multiAccountAnnouncement")
|
||||
private val swipeAvatarToSwitchAccountKey = intPreferencesKey("swipeAvatarToSwitchAccountAnnouncement")
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultAnnouncementStore(
|
||||
@@ -42,10 +43,13 @@ class DefaultAnnouncementStore(
|
||||
// - NewNotificationSound is opted in by AppMigration08
|
||||
// - 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.
|
||||
// - 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) {
|
||||
Announcement.Fullscreen.Space -> AnnouncementStatus.Shown
|
||||
Announcement.NewNotificationSound -> AnnouncementStatus.Shown
|
||||
Announcement.MultiAccount -> AnnouncementStatus.NeverShown
|
||||
Announcement.SwipeAvatarToSwitchAccount -> AnnouncementStatus.NeverShown
|
||||
}
|
||||
return store.data.map { prefs ->
|
||||
val ordinal = prefs[key] ?: defaultStatus.ordinal
|
||||
@@ -62,4 +66,5 @@ private fun Announcement.toKey() = when (this) {
|
||||
Announcement.Fullscreen.Space -> spaceAnnouncementKey
|
||||
Announcement.NewNotificationSound -> newNotificationSoundKey
|
||||
Announcement.MultiAccount -> multiAccountKey
|
||||
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountKey
|
||||
}
|
||||
|
||||
+23
-5
@@ -67,6 +67,22 @@ class DefaultAnnouncementServiceTest {
|
||||
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
|
||||
fun `test announcementsToShowFlow`() = runTest {
|
||||
val announcementStore = InMemoryAnnouncementStore()
|
||||
@@ -74,16 +90,18 @@ class DefaultAnnouncementServiceTest {
|
||||
announcementStore = announcementStore,
|
||||
)
|
||||
sut.announcementsToShowFlow().test {
|
||||
// MultiAccount is pending by default
|
||||
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
|
||||
// MultiAccount and SwipeAvatarToSwitchAccount are pending by default
|
||||
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
|
||||
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)
|
||||
// Still pending
|
||||
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount)
|
||||
assertThat(awaitItem()).containsExactly(Announcement.NewNotificationSound, Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
|
||||
announcementStore.setAnnouncementStatus(Announcement.NewNotificationSound, AnnouncementStatus.Shown)
|
||||
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount)
|
||||
assertThat(awaitItem()).containsExactly(Announcement.MultiAccount, Announcement.SwipeAvatarToSwitchAccount)
|
||||
announcementStore.setAnnouncementStatus(Announcement.MultiAccount, AnnouncementStatus.Shown)
|
||||
assertThat(awaitItem()).containsExactly(Announcement.SwipeAvatarToSwitchAccount)
|
||||
announcementStore.setAnnouncementStatus(Announcement.SwipeAvatarToSwitchAccount, AnnouncementStatus.Shown)
|
||||
assertThat(awaitItem()).isEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
+4
@@ -17,10 +17,12 @@ class InMemoryAnnouncementStore(
|
||||
initialFullscreenAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||
initialNewNotificationSoundAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||
initialMultiAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||
initialSwipeAvatarToSwitchAccountAnnouncementStatus: AnnouncementStatus = AnnouncementStatus.NeverShown,
|
||||
) : AnnouncementStore {
|
||||
private val fullScreenAnnouncement = MutableStateFlow(initialFullscreenAnnouncementStatus)
|
||||
private val newNotificationSoundAnnouncement = MutableStateFlow(initialNewNotificationSoundAnnouncementStatus)
|
||||
private val multiAccountAnnouncement = MutableStateFlow(initialMultiAccountAnnouncementStatus)
|
||||
private val swipeAvatarToSwitchAccountAnnouncement = MutableStateFlow(initialSwipeAvatarToSwitchAccountAnnouncementStatus)
|
||||
|
||||
override suspend fun setAnnouncementStatus(announcement: Announcement, status: AnnouncementStatus) {
|
||||
announcement.toMutableStateFlow().value = status
|
||||
@@ -34,11 +36,13 @@ class InMemoryAnnouncementStore(
|
||||
fullScreenAnnouncement.value = AnnouncementStatus.NeverShown
|
||||
newNotificationSoundAnnouncement.value = AnnouncementStatus.NeverShown
|
||||
multiAccountAnnouncement.value = AnnouncementStatus.NeverShown
|
||||
swipeAvatarToSwitchAccountAnnouncement.value = AnnouncementStatus.NeverShown
|
||||
}
|
||||
|
||||
private fun Announcement.toMutableStateFlow() = when (this) {
|
||||
is Announcement.Fullscreen -> fullScreenAnnouncement
|
||||
Announcement.NewNotificationSound -> newNotificationSoundAnnouncement
|
||||
Announcement.MultiAccount -> multiAccountAnnouncement
|
||||
Announcement.SwipeAvatarToSwitchAccount -> swipeAvatarToSwitchAccountAnnouncement
|
||||
}
|
||||
}
|
||||
|
||||
+2
-1
@@ -46,11 +46,12 @@ class ChooseSelfVerificationModeStatePreviewParam :
|
||||
fun aChooseSelfVerificationModeState(
|
||||
buttonsState: AsyncData<ChooseSelfVerificationModeState.ButtonsState> = AsyncData.Success(aButtonsState()),
|
||||
currentUser: MatrixUser = aMatrixUser(),
|
||||
eventSink: (ChooseSelfVerificationModeEvent) -> Unit = {},
|
||||
) = ChooseSelfVerificationModeState(
|
||||
currentUser = currentUser,
|
||||
buttonsState = buttonsState,
|
||||
directLogoutState = aDirectLogoutState(),
|
||||
eventSink = {},
|
||||
eventSink = eventSink,
|
||||
)
|
||||
|
||||
fun aButtonsState(
|
||||
|
||||
+6
-1
@@ -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.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.IconSource
|
||||
import io.element.android.libraries.designsystem.theme.components.OutlinedButton
|
||||
import io.element.android.libraries.designsystem.theme.components.Text
|
||||
import io.element.android.libraries.designsystem.theme.components.TextButton
|
||||
@@ -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.ui.strings.CommonStrings
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=432-31813
|
||||
*/
|
||||
@Composable
|
||||
fun ChooseSelfVerificationModeView(
|
||||
state: ChooseSelfVerificationModeState,
|
||||
@@ -65,7 +69,7 @@ fun ChooseSelfVerificationModeView(
|
||||
title = {},
|
||||
actions = {
|
||||
TextButton(
|
||||
text = stringResource(CommonStrings.action_signout),
|
||||
text = stringResource(R.string.screen_identity_confirmation_sign_out),
|
||||
onClick = { state.eventSink(ChooseSelfVerificationModeEvent.SignOut) }
|
||||
)
|
||||
}
|
||||
@@ -141,6 +145,7 @@ private fun ChooseSelfVerificationModeButtons(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
text = stringResource(R.string.screen_identity_use_another_device),
|
||||
onClick = onUseAnotherDevice,
|
||||
leadingIcon = IconSource.Vector(CompoundIcons.Devices()),
|
||||
)
|
||||
}
|
||||
if (state.buttonsState.data.canUseRecoveryKey) {
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
|
||||
<string name="screen_identity_confirmation_sign_out">"Sign out"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Choose how to verify to set up secure messaging."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirm your digital identity"</string>
|
||||
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
|
||||
|
||||
+11
@@ -18,6 +18,7 @@ import io.element.android.features.ftue.impl.R
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.clickOn
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
@@ -73,6 +74,16 @@ class ChooseSessionVerificationModeViewTest : RobolectricTest() {
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on sign out emits the expected event`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<ChooseSelfVerificationModeEvent>()
|
||||
setChooseSelfVerificationModeView(
|
||||
aChooseSelfVerificationModeState(eventSink = eventsRecorder),
|
||||
)
|
||||
clickOn(R.string.screen_identity_confirmation_sign_out)
|
||||
eventsRecorder.assertSingle(ChooseSelfVerificationModeEvent.SignOut)
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setChooseSelfVerificationModeView(
|
||||
state: ChooseSelfVerificationModeState,
|
||||
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||
|
||||
+2
@@ -14,6 +14,7 @@ import com.bumble.appyx.core.plugin.Plugin
|
||||
import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
|
||||
interface HomeEntryPoint : FeatureEntryPoint {
|
||||
@@ -33,5 +34,6 @@ interface HomeEntryPoint : FeatureEntryPoint {
|
||||
fun navigateToRoomSettings(roomId: RoomId)
|
||||
fun navigateToBugReport()
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-3
@@ -8,10 +8,8 @@
|
||||
|
||||
package io.element.android.features.home.impl
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
sealed interface HomeEvent {
|
||||
data class SelectHomeNavigationBarItem(val item: HomeNavigationBarItem) : HomeEvent
|
||||
data class SwitchToAccount(val sessionId: SessionId) : HomeEvent
|
||||
data object DismissMultiAccountAnnouncement : HomeEvent
|
||||
data object DismissSwipeAvatarAnnouncement : HomeEvent
|
||||
}
|
||||
|
||||
+1
@@ -222,6 +222,7 @@ class HomeFlowNode(
|
||||
onRoomClick = ::navigateToRoom,
|
||||
onSettingsClick = callback::navigateToSettings,
|
||||
onAddAccountClick = callback::navigateToAddAccount,
|
||||
onSwitchToAccount = callback::navigateToSession,
|
||||
onStartChatClick = callback::navigateToCreateRoom,
|
||||
onCreateSpaceClick = callback::navigateToCreateSpace,
|
||||
onSetUpRecoveryClick = callback::navigateToSetUpRecovery,
|
||||
|
||||
+9
-4
@@ -27,6 +27,7 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
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.FeatureFlags
|
||||
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.sessionstorage.api.SessionStore
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.filter
|
||||
import kotlinx.coroutines.launch
|
||||
@@ -51,12 +53,14 @@ class HomePresenter(
|
||||
private val sessionStore: SessionStore,
|
||||
private val announcementService: AnnouncementService,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
@AppCoroutineScope
|
||||
private val appCoroutineScope: CoroutineScope,
|
||||
) : Presenter<HomeState> {
|
||||
private val currentUserWithNeighborsBuilder = CurrentUserWithNeighborsBuilder()
|
||||
|
||||
@Composable
|
||||
override fun present(): HomeState {
|
||||
val coroutineState = rememberCoroutineScope()
|
||||
val coroutineScope = rememberCoroutineScope()
|
||||
val matrixUser by client.userProfile.collectAsState()
|
||||
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.
|
||||
@@ -114,10 +118,11 @@ class HomePresenter(
|
||||
is HomeEvent.SelectHomeNavigationBarItem -> {
|
||||
currentHomeNavigationBarItemOrdinal = event.item.ordinal
|
||||
}
|
||||
is HomeEvent.SwitchToAccount -> coroutineState.launch {
|
||||
sessionStore.setLatestSession(event.sessionId.value)
|
||||
// The user is switching account, which destroys this presenter, so use the app scope to not be cancelled.
|
||||
HomeEvent.DismissSwipeAvatarAnnouncement -> appCoroutineScope.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
|
||||
}
|
||||
HomeEvent.DismissMultiAccountAnnouncement -> coroutineState.launch {
|
||||
HomeEvent.DismissMultiAccountAnnouncement -> coroutineScope.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.MultiAccount)
|
||||
}
|
||||
}
|
||||
|
||||
+10
-2
@@ -69,6 +69,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarHost
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.rememberSnackbarHostState
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
@@ -78,6 +79,7 @@ fun HomeView(
|
||||
onRoomClick: (RoomId, EventId?) -> Unit,
|
||||
onSettingsClick: () -> Unit,
|
||||
onAddAccountClick: () -> Unit,
|
||||
onSwitchToAccount: (SessionId) -> Unit,
|
||||
onSetUpRecoveryClick: () -> Unit,
|
||||
onConfirmRecoveryKeyClick: () -> Unit,
|
||||
onStartChatClick: () -> Unit,
|
||||
@@ -130,6 +132,7 @@ fun HomeView(
|
||||
onStartChatClick = { if (firstThrottler.canHandle()) onStartChatClick() },
|
||||
onCreateSpaceClick = { if (firstThrottler.canHandle()) onCreateSpaceClick() },
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onSwitchToAccount = onSwitchToAccount,
|
||||
)
|
||||
|
||||
if (state.globalSearchState.isEnabled) {
|
||||
@@ -167,6 +170,7 @@ private fun HomeScaffold(
|
||||
onStartChatClick: () -> Unit,
|
||||
onCreateSpaceClick: () -> Unit,
|
||||
onMenuActionClick: (RoomListMenuAction) -> Unit,
|
||||
onSwitchToAccount: (SessionId) -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
fun onRoomClick(room: RoomListRoomSummary) {
|
||||
@@ -213,8 +217,10 @@ private fun HomeScaffold(
|
||||
},
|
||||
onMenuActionClick = onMenuActionClick,
|
||||
onOpenSettings = onOpenSettings,
|
||||
onAccountSwitch = {
|
||||
state.eventSink(HomeEvent.SwitchToAccount(it))
|
||||
onAccountSwitch = { sessionId ->
|
||||
// The user has switched account by swiping their avatar, so they know the gesture.
|
||||
state.eventSink(HomeEvent.DismissSwipeAvatarAnnouncement)
|
||||
onSwitchToAccount(sessionId)
|
||||
},
|
||||
scrollBehavior = scrollBehavior,
|
||||
displayFilters = state.displayRoomListFilters,
|
||||
@@ -379,6 +385,7 @@ internal fun HomeViewPreview(@PreviewParameter(HomeStatePreviewParam::class) sta
|
||||
onRoomClick = { _, _ -> },
|
||||
onSettingsClick = {},
|
||||
onAddAccountClick = {},
|
||||
onSwitchToAccount = {},
|
||||
onSetUpRecoveryClick = {},
|
||||
onConfirmRecoveryKeyClick = {},
|
||||
onStartChatClick = {},
|
||||
@@ -400,6 +407,7 @@ internal fun HomeViewA11yPreview() = ElementPreview {
|
||||
onRoomClick = { _, _ -> },
|
||||
onSettingsClick = {},
|
||||
onAddAccountClick = {},
|
||||
onSwitchToAccount = {},
|
||||
onSetUpRecoveryClick = {},
|
||||
onConfirmRecoveryKeyClick = {},
|
||||
onStartChatClick = {},
|
||||
|
||||
+6
-7
@@ -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.preview.ElementPreview
|
||||
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
|
||||
internal fun BatteryOptimizationBanner(
|
||||
state: BatteryOptimizationState,
|
||||
onActionClick: () -> Unit,
|
||||
onDismissClick: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
Announcement(
|
||||
@@ -31,8 +29,8 @@ internal fun BatteryOptimizationBanner(
|
||||
description = stringResource(R.string.banner_battery_optimization_content_android),
|
||||
type = AnnouncementType.Actionable(
|
||||
actionText = stringResource(R.string.banner_battery_optimization_submit_android),
|
||||
onActionClick = { state.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations) },
|
||||
onDismissClick = { state.eventSink(BatteryOptimizationEvent.Dismiss) },
|
||||
onActionClick = onActionClick,
|
||||
onDismissClick = onDismissClick,
|
||||
),
|
||||
)
|
||||
}
|
||||
@@ -41,6 +39,7 @@ internal fun BatteryOptimizationBanner(
|
||||
@Composable
|
||||
internal fun BatteryOptimizationBannerPreview() = ElementPreview {
|
||||
BatteryOptimizationBanner(
|
||||
state = aBatteryOptimizationState(),
|
||||
onActionClick = {},
|
||||
onDismissClick = {},
|
||||
)
|
||||
}
|
||||
|
||||
+8
-7
@@ -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.preview.ElementPreview
|
||||
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
|
||||
|
||||
@Composable
|
||||
fun FullScreenIntentPermissionBanner(
|
||||
state: FullScreenIntentPermissionsState,
|
||||
onActionClick: () -> Unit,
|
||||
onDismissClick: () -> Unit,
|
||||
modifier: Modifier = Modifier
|
||||
) {
|
||||
Announcement(
|
||||
@@ -31,8 +29,8 @@ fun FullScreenIntentPermissionBanner(
|
||||
description = stringResource(R.string.full_screen_intent_banner_message),
|
||||
type = AnnouncementType.Actionable(
|
||||
actionText = stringResource(CommonStrings.action_continue),
|
||||
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvent.Dismiss) },
|
||||
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvent.OpenSettings) },
|
||||
onDismissClick = onDismissClick,
|
||||
onActionClick = onActionClick,
|
||||
),
|
||||
modifier = modifier.roomListBannerPadding(),
|
||||
)
|
||||
@@ -42,6 +40,9 @@ fun FullScreenIntentPermissionBanner(
|
||||
@Composable
|
||||
internal fun FullScreenIntentPermissionBannerPreview() {
|
||||
ElementPreview {
|
||||
FullScreenIntentPermissionBanner(aFullScreenIntentPermissionsState())
|
||||
FullScreenIntentPermissionBanner(
|
||||
onActionClick = {},
|
||||
onDismissClick = {},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+7
-3
@@ -92,6 +92,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
import kotlinx.collections.immutable.ImmutableList
|
||||
import kotlinx.collections.immutable.persistentListOf
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.flow.filter
|
||||
|
||||
@Composable
|
||||
fun HomeTopBar(
|
||||
@@ -307,9 +308,12 @@ private fun NavigationIcon(
|
||||
// Listen to page changes and switch account if needed
|
||||
val latestOnAccountSwitch by rememberUpdatedState(onAccountSwitch)
|
||||
LaunchedEffect(pagerState) {
|
||||
snapshotFlow { pagerState.settledPage }.collect { page ->
|
||||
latestOnAccountSwitch(SessionId(currentUserAndNeighbors[page].userId.value))
|
||||
}
|
||||
snapshotFlow { pagerState.settledPage }
|
||||
// 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(
|
||||
state = pagerState,
|
||||
|
||||
+135
-56
@@ -8,6 +8,12 @@
|
||||
|
||||
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.Box
|
||||
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.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.heightIn
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.lazy.LazyColumn
|
||||
import androidx.compose.foundation.lazy.LazyListState
|
||||
@@ -24,6 +32,7 @@ import androidx.compose.foundation.lazy.rememberLazyListState
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.platform.LocalDensity
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.text.style.TextAlign
|
||||
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.model.RoomListRoomSummary
|
||||
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.RoomListContentStatePreviewParam
|
||||
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.anUnselectedSpaceFiltersState
|
||||
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.Text
|
||||
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 kotlinx.collections.immutable.ImmutableList
|
||||
|
||||
@@ -158,22 +170,20 @@ private fun EmptyView(
|
||||
},
|
||||
modifier = Modifier.align(Alignment.Center),
|
||||
)
|
||||
Box {
|
||||
when (state.securityBannerState) {
|
||||
SecurityBannerState.SetUpRecovery -> {
|
||||
SetUpRecoveryKeyBanner(
|
||||
onContinueClick = onSetUpRecoveryClick,
|
||||
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
|
||||
)
|
||||
}
|
||||
SecurityBannerState.RecoveryKeyConfirmation -> {
|
||||
ConfirmRecoveryKeyBanner(
|
||||
onContinueClick = onConfirmRecoveryKeyClick,
|
||||
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
|
||||
)
|
||||
}
|
||||
SecurityBannerState.None -> Unit
|
||||
}
|
||||
RoomListBannerContainer(
|
||||
banner = state.securityBannerState.toRoomListBanner(),
|
||||
) { banner ->
|
||||
RoomListBannerView(
|
||||
banner = banner,
|
||||
onActionClick = {
|
||||
when (banner) {
|
||||
RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
|
||||
RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
|
||||
else -> Unit
|
||||
}
|
||||
},
|
||||
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -227,54 +237,56 @@ private fun RoomsViewList(
|
||||
lazyListState: LazyListState,
|
||||
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 ->
|
||||
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(
|
||||
state = lazyListState,
|
||||
modifier = modifier,
|
||||
contentPadding = contentPadding,
|
||||
) {
|
||||
when (state.securityBannerState) {
|
||||
SecurityBannerState.SetUpRecovery -> {
|
||||
item {
|
||||
SetUpRecoveryKeyBanner(
|
||||
onContinueClick = onSetUpRecoveryClick,
|
||||
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
|
||||
)
|
||||
}
|
||||
}
|
||||
SecurityBannerState.RecoveryKeyConfirmation -> {
|
||||
item {
|
||||
ConfirmRecoveryKeyBanner(
|
||||
onContinueClick = onConfirmRecoveryKeyClick,
|
||||
onDismissClick = { eventSink(RoomListEvent.DismissBanner) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// Banner precedence (top-to-bottom): full-screen-intent > battery-optimization >
|
||||
// new-notification-sound > sound-unavailable. At most one renders at a time.
|
||||
SecurityBannerState.None -> when {
|
||||
state.fullScreenIntentPermissionsState.shouldDisplayBanner -> {
|
||||
item {
|
||||
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
|
||||
}
|
||||
}
|
||||
state.batteryOptimizationState.shouldDisplayBanner -> {
|
||||
item {
|
||||
BatteryOptimizationBanner(state = state.batteryOptimizationState)
|
||||
}
|
||||
}
|
||||
state.showNewNotificationSoundBanner -> {
|
||||
item {
|
||||
NewNotificationSoundBanner(
|
||||
onDismissClick = { eventSink(RoomListEvent.DismissNewNotificationSoundBanner) },
|
||||
)
|
||||
}
|
||||
}
|
||||
// The banner slot is always the first item of the list, even when there is no banner to display,
|
||||
// so that the rooms keep the same index when a banner is added or removed. Since the rooms do not
|
||||
// have a key, this is required to keep the scroll position.
|
||||
item {
|
||||
RoomListBannerContainer(
|
||||
banner = banner,
|
||||
modifier = Modifier.heightIn(min = bannerSlotMinHeight),
|
||||
) { displayedBanner ->
|
||||
RoomListBannerView(
|
||||
banner = displayedBanner,
|
||||
onActionClick = {
|
||||
when (displayedBanner) {
|
||||
RoomListBanner.SetUpRecovery -> onSetUpRecoveryClick()
|
||||
RoomListBanner.RecoveryKeyConfirmation -> onConfirmRecoveryKeyClick()
|
||||
RoomListBanner.FullScreenIntentPermission ->
|
||||
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.OpenSettings)
|
||||
RoomListBanner.BatteryOptimization ->
|
||||
state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.RequestDisableOptimizations)
|
||||
RoomListBanner.NewNotificationSound,
|
||||
RoomListBanner.SwipeAvatar -> Unit
|
||||
}
|
||||
},
|
||||
onDismissClick = {
|
||||
when (displayedBanner) {
|
||||
RoomListBanner.SetUpRecovery,
|
||||
RoomListBanner.RecoveryKeyConfirmation -> eventSink(RoomListEvent.DismissBanner)
|
||||
RoomListBanner.FullScreenIntentPermission ->
|
||||
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvent.Dismiss)
|
||||
RoomListBanner.BatteryOptimization -> state.batteryOptimizationState.eventSink(BatteryOptimizationEvent.Dismiss)
|
||||
RoomListBanner.NewNotificationSound -> eventSink(RoomListEvent.DismissNewNotificationSoundBanner)
|
||||
RoomListBanner.SwipeAvatar -> eventSink(RoomListEvent.DismissSwipeAvatarBanner)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// 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.
|
||||
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
|
||||
private fun EmptyViewForFilterStates(
|
||||
selectedFilters: ImmutableList<RoomListFilter>,
|
||||
|
||||
+43
@@ -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 = {},
|
||||
)
|
||||
}
|
||||
+40
@@ -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
|
||||
}
|
||||
}
|
||||
+5
@@ -30,12 +30,16 @@ open class RoomListContentStatePreviewParam : PreviewParameterProvider<RoomListC
|
||||
aRoomsContentState(
|
||||
showNewNotificationSoundBanner = true,
|
||||
),
|
||||
aRoomsContentState(
|
||||
showSwipeAvatarBanner = true,
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
internal fun aRoomsContentState(
|
||||
securityBannerState: SecurityBannerState = SecurityBannerState.None,
|
||||
showNewNotificationSoundBanner: Boolean = false,
|
||||
showSwipeAvatarBanner: Boolean = false,
|
||||
showUnreadCount: Boolean = false,
|
||||
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
|
||||
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
|
||||
@@ -45,6 +49,7 @@ internal fun aRoomsContentState(
|
||||
) = RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
|
||||
showSwipeAvatarBanner = showSwipeAvatarBanner,
|
||||
showUnreadCount = showUnreadCount,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
|
||||
batteryOptimizationState = batteryOptimizationState,
|
||||
|
||||
+1
@@ -16,6 +16,7 @@ sealed interface RoomListEvent {
|
||||
data object DismissRequestVerificationPrompt : RoomListEvent
|
||||
data object DismissBanner : RoomListEvent
|
||||
data object DismissNewNotificationSoundBanner : RoomListEvent
|
||||
data object DismissSwipeAvatarBanner : RoomListEvent
|
||||
data object ToggleSearchResults : RoomListEvent
|
||||
data class ShowContextMenu(val roomSummary: RoomListRoomSummary) : RoomListEvent
|
||||
|
||||
|
||||
+18
@@ -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.ui.safety.rememberHideInvitesAvatar
|
||||
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.watchers.AnalyticsColdStartWatcher
|
||||
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.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.collect
|
||||
import kotlinx.coroutines.flow.combine
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.flatMapLatest
|
||||
import kotlinx.coroutines.flow.map
|
||||
@@ -89,6 +91,7 @@ class RoomListPresenter(
|
||||
private val spaceFiltersPresenter: Presenter<SpaceFiltersState>,
|
||||
private val globalSearchPresenter: Presenter<GlobalSearchState>,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val sessionStore: SessionStore,
|
||||
) : Presenter<RoomListState> {
|
||||
private val encryptionService = client.encryptionService
|
||||
|
||||
@@ -112,6 +115,15 @@ class RoomListPresenter(
|
||||
announcements.contains(Announcement.NewNotificationSound)
|
||||
}
|
||||
}.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
|
||||
val hideInvitesAvatar by client.rememberHideInvitesAvatar()
|
||||
@@ -133,6 +145,9 @@ class RoomListPresenter(
|
||||
RoomListEvent.DismissNewNotificationSoundBanner -> coroutineScope.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.NewNotificationSound)
|
||||
}
|
||||
RoomListEvent.DismissSwipeAvatarBanner -> coroutineScope.launch {
|
||||
announcementService.onAnnouncementDismissed(Announcement.SwipeAvatarToSwitchAccount)
|
||||
}
|
||||
RoomListEvent.ToggleSearchResults -> searchState.eventSink(RoomListSearchEvent.ToggleSearchVisibility)
|
||||
is RoomListEvent.ShowContextMenu -> {
|
||||
coroutineScope.showContextMenu(event, contextMenu)
|
||||
@@ -176,6 +191,7 @@ class RoomListPresenter(
|
||||
val contentState = roomListContentState(
|
||||
securityBannerDismissed,
|
||||
showNewNotificationSoundBanner,
|
||||
showSwipeAvatarBanner,
|
||||
showUnreadCount,
|
||||
showAllActivity,
|
||||
)
|
||||
@@ -235,6 +251,7 @@ class RoomListPresenter(
|
||||
private fun roomListContentState(
|
||||
securityBannerDismissed: Boolean,
|
||||
showNewNotificationSoundBanner: Boolean,
|
||||
showSwipeAvatarBanner: Boolean,
|
||||
showUnreadCount: Boolean,
|
||||
showAllActivity: Boolean,
|
||||
): RoomListContentState {
|
||||
@@ -265,6 +282,7 @@ class RoomListPresenter(
|
||||
RoomListContentState.Rooms(
|
||||
securityBannerState = securityBannerState,
|
||||
showNewNotificationSoundBanner = showNewNotificationSoundBanner,
|
||||
showSwipeAvatarBanner = showSwipeAvatarBanner,
|
||||
showUnreadCount = showUnreadCount,
|
||||
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
|
||||
batteryOptimizationState = batteryOptimizationPresenter.present(),
|
||||
|
||||
+1
@@ -73,6 +73,7 @@ sealed interface RoomListContentState {
|
||||
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
|
||||
val batteryOptimizationState: BatteryOptimizationState,
|
||||
val showNewNotificationSoundBanner: Boolean,
|
||||
val showSwipeAvatarBanner: Boolean,
|
||||
val showUnreadCount: Boolean,
|
||||
val summaries: ImmutableList<RoomListRoomSummary>,
|
||||
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_submit">"Get recovery key"</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_primary_button_title">"Enter your recovery key"</string>
|
||||
<string name="confirm_recovery_key_banner_secondary_button_title">"Forgot your recovery key?"</string>
|
||||
|
||||
+2
@@ -14,6 +14,7 @@ import io.element.android.features.home.api.HomeEntryPoint
|
||||
import io.element.android.features.invite.test.declineandblock.FakeDeclineInviteAndBlockEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.room.JoinedRoom
|
||||
import io.element.android.libraries.matrix.test.FakeMatrixClient
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
@@ -53,6 +54,7 @@ class DefaultHomeEntryPointTest : RobolectricTest() {
|
||||
override fun navigateToRoomSettings(roomId: RoomId) = lambdaError()
|
||||
override fun navigateToBugReport() = lambdaError()
|
||||
override fun navigateToAddAccount() = lambdaError()
|
||||
override fun navigateToSession(sessionId: SessionId) = lambdaError()
|
||||
}
|
||||
val result = entryPoint.createNode(
|
||||
parentNode = parentNode,
|
||||
|
||||
+27
-1
@@ -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.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.flowOf
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.advanceTimeBy
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Rule
|
||||
import org.junit.Test
|
||||
@@ -122,6 +125,7 @@ class HomePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - total unread count is updated on sync heartbeat`() = runTest {
|
||||
var unreadCount = 0L
|
||||
@@ -142,6 +146,7 @@ class HomePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
@Test
|
||||
fun `present - total unread count defaults to 0 when the SDK call fails`() = runTest {
|
||||
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
|
||||
fun `present - total unread count is loaded in the initial state`() = runTest {
|
||||
val presenter = createHomePresenter(
|
||||
@@ -300,7 +324,7 @@ class HomePresenterTest {
|
||||
}
|
||||
}
|
||||
|
||||
internal fun createHomePresenter(
|
||||
internal fun TestScope.createHomePresenter(
|
||||
client: MatrixClient = FakeMatrixClient(),
|
||||
syncService: SyncService = FakeSyncService(),
|
||||
snackbarDispatcher: SnackbarDispatcher = SnackbarDispatcher(),
|
||||
@@ -310,6 +334,7 @@ internal fun createHomePresenter(
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
announcementService: AnnouncementService = FakeAnnouncementService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
appCoroutineScope: CoroutineScope = backgroundScope,
|
||||
) = HomePresenter(
|
||||
client = client,
|
||||
syncService = syncService,
|
||||
@@ -321,4 +346,5 @@ internal fun createHomePresenter(
|
||||
sessionStore = sessionStore,
|
||||
announcementService = announcementService,
|
||||
featureFlagService = featureFlagService,
|
||||
appCoroutineScope = appCoroutineScope,
|
||||
)
|
||||
|
||||
+48
@@ -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_3
|
||||
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.encryption.FakeEncryptionService
|
||||
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.notifications.NotificationCleaner
|
||||
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.test.FakeAnalyticsService
|
||||
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
|
||||
fun `present - showAllActivity and showUnreadCount reflect the feature flag values`() = runTest {
|
||||
val roomList = FakeDynamicRoomList(
|
||||
@@ -724,6 +770,7 @@ class RoomListPresenterTest {
|
||||
announcementService: AnnouncementService = FakeAnnouncementService(),
|
||||
featureFlagService: FeatureFlagService = FakeFeatureFlagService(),
|
||||
markRoomAsRead: MarkRoomAsRead? = null,
|
||||
sessionStore: SessionStore = InMemorySessionStore(),
|
||||
) = RoomListPresenter(
|
||||
client = client,
|
||||
leaveRoomPresenter = { leaveRoomState },
|
||||
@@ -756,5 +803,6 @@ class RoomListPresenterTest {
|
||||
announcementService = announcementService,
|
||||
coldStartWatcher = FakeAnalyticsColdStartWatcher(),
|
||||
featureFlagService = featureFlagService,
|
||||
sessionStore = sessionStore,
|
||||
)
|
||||
}
|
||||
|
||||
+36
-9
@@ -51,9 +51,10 @@ class RoomListViewTest : RobolectricTest() {
|
||||
)
|
||||
)
|
||||
|
||||
// The banner is not included in the range, only the rooms are
|
||||
eventsRecorder.assertList(
|
||||
listOf(
|
||||
RoomListEvent.UpdateVisibleRange(0..5),
|
||||
RoomListEvent.UpdateVisibleRange(0..4),
|
||||
)
|
||||
)
|
||||
}
|
||||
@@ -64,13 +65,15 @@ class RoomListViewTest : RobolectricTest() {
|
||||
setRoomListView(
|
||||
state = aRoomListState(
|
||||
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)
|
||||
onNodeWithContentDescription(close).performClick()
|
||||
eventsRecorder.assertSingle(RoomListEvent.DismissBanner)
|
||||
@@ -82,18 +85,41 @@ class RoomListViewTest : RobolectricTest() {
|
||||
setRoomListView(
|
||||
state = aRoomListState(
|
||||
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)
|
||||
onNodeWithContentDescription(close).performClick()
|
||||
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
|
||||
fun `clicking on continue recovery key banner invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<RoomListEvent>()
|
||||
@@ -305,6 +331,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setRoomListView(
|
||||
onRoomClick = { roomId, _ -> onRoomClick(roomId) },
|
||||
onSettingsClick = onSettingsClick,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSwitchToAccount = EnsureNeverCalledWithParam(),
|
||||
onSetUpRecoveryClick = onSetUpRecoveryClick,
|
||||
onConfirmRecoveryKeyClick = onConfirmRecoveryKeyClick,
|
||||
onStartChatClick = onCreateRoomClick,
|
||||
|
||||
@@ -26,7 +26,7 @@ android {
|
||||
defaultConfig {
|
||||
buildConfigFieldStr(
|
||||
name = "MAPTILER_BASE_URL",
|
||||
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL ?: "https://api.maptiler.com/maps"
|
||||
value = BuildTimeConfig.SERVICES_MAPTILER_BASE_URL,
|
||||
)
|
||||
buildConfigFieldStr(
|
||||
name = "MAPTILER_API_KEY",
|
||||
@@ -35,8 +35,7 @@ android {
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_MAPTILER_API_KEY")
|
||||
?: readLocalProperty("services.maptiler.apikey")
|
||||
}
|
||||
?: ""
|
||||
},
|
||||
)
|
||||
buildConfigFieldStr(
|
||||
name = "MAPTILER_LIGHT_MAP_ID",
|
||||
@@ -45,9 +44,7 @@ android {
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_MAPTILER_LIGHT_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.lightMapId")
|
||||
}
|
||||
// fall back to maptiler's default light map.
|
||||
?: "basic-v2"
|
||||
},
|
||||
)
|
||||
buildConfigFieldStr(
|
||||
name = "MAPTILER_DARK_MAP_ID",
|
||||
@@ -56,9 +53,7 @@ android {
|
||||
} else {
|
||||
System.getenv("ELEMENT_ANDROID_MAPTILER_DARK_MAP_ID")
|
||||
?: readLocalProperty("services.maptiler.darkMapId")
|
||||
}
|
||||
// fall back to maptiler's default dark map.
|
||||
?: "basic-v2-dark"
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -19,14 +19,13 @@ val LocalMapTilerConfig = staticCompositionLocalOf { buildTimeMapTilerConfig() }
|
||||
* The [MapTilerConfig] defined at build time, or null if no MapTiler API key has been provided.
|
||||
*/
|
||||
fun buildTimeMapTilerConfig(): MapTilerConfig? {
|
||||
return BuildConfig.MAPTILER_API_KEY
|
||||
.takeIf { it.isNotEmpty() }
|
||||
?.let { apiKey ->
|
||||
MapTilerConfig(
|
||||
apiKey = apiKey,
|
||||
lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||
darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||
baseUrl = BuildConfig.MAPTILER_BASE_URL
|
||||
)
|
||||
}
|
||||
// BuildConfig fields are null when not provided at build time.
|
||||
val apiKey: String? = BuildConfig.MAPTILER_API_KEY
|
||||
if (apiKey.isNullOrEmpty()) return null
|
||||
return MapTilerConfig(
|
||||
apiKey = apiKey,
|
||||
lightStyleId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||
darkStyleId = BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||
baseUrl = BuildConfig.MAPTILER_BASE_URL,
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -152,8 +152,8 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
|
||||
Triple(painter, state, ContentScale.Crop)
|
||||
} else {
|
||||
val painter = rememberAsyncImagePainter(
|
||||
model = if (constraints.isZero) {
|
||||
// Avoid building a URL if any of the size constraints is zero
|
||||
model = if (builder == null || constraints.isZero) {
|
||||
// Avoid building a URL if there is no map provider or if any of the size constraints is zero
|
||||
null
|
||||
} else {
|
||||
ImageRequest.Builder(context)
|
||||
@@ -195,7 +195,7 @@ private fun BoxWithConstraintsScope.LoadableMapContent(
|
||||
else -> {
|
||||
StaticMapPlaceholder(
|
||||
painter = painterResource(R.drawable.blurred_map),
|
||||
canReload = builder.isServiceAvailable() && state is AsyncImagePainter.State.Error,
|
||||
canReload = builder?.isServiceAvailable() == true && state is AsyncImagePainter.State.Error,
|
||||
contentDescription = contentDescription,
|
||||
width = maxWidth,
|
||||
height = maxHeight,
|
||||
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
|
||||
/**
|
||||
* Public MapTiler defaults, used when a [MapTilerConfig] does not define a value.
|
||||
* Values from another configuration (e.g. the build time one) must never be used as a fallback,
|
||||
* to avoid sending an API key to a server it was not meant for.
|
||||
*/
|
||||
internal object MapTilerDefaults {
|
||||
const val BASE_URL = "https://api.maptiler.com/maps"
|
||||
const val LIGHT_MAP_ID = "basic-v2"
|
||||
const val DARK_MAP_ID = "basic-v2-dark"
|
||||
}
|
||||
|
||||
internal fun MapTilerConfig.baseUrlOrDefault(): String {
|
||||
return baseUrl?.takeIf { it.isNotBlank() }?.removeSuffix("/") ?: MapTilerDefaults.BASE_URL
|
||||
}
|
||||
|
||||
internal fun MapTilerConfig.lightMapIdOrDefault(): String {
|
||||
return lightStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.LIGHT_MAP_ID
|
||||
}
|
||||
|
||||
internal fun MapTilerConfig.darkMapIdOrDefault(): String {
|
||||
return darkStyleId?.takeIf { it.isNotBlank() } ?: MapTilerDefaults.DARK_MAP_ID
|
||||
}
|
||||
+4
-12
@@ -9,7 +9,6 @@
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.api.BuildConfig
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
@@ -23,18 +22,11 @@ internal class MapTilerStaticMapUrlBuilder(
|
||||
private val lightMapId: String,
|
||||
private val darkMapId: String,
|
||||
) : StaticMapUrlBuilder {
|
||||
constructor() : this(
|
||||
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
|
||||
apiKey = BuildConfig.MAPTILER_API_KEY,
|
||||
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||
)
|
||||
|
||||
constructor(mapTilerConfig: MapTilerConfig) : this(
|
||||
baseUrl = (mapTilerConfig.baseUrl ?: BuildConfig.MAPTILER_BASE_URL).removeSuffix("/"),
|
||||
apiKey = mapTilerConfig.apiKey.takeIf { it.isNotBlank() } ?: BuildConfig.MAPTILER_API_KEY,
|
||||
lightMapId = mapTilerConfig.lightStyleId ?: BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||
darkMapId = mapTilerConfig.darkStyleId ?: BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||
baseUrl = mapTilerConfig.baseUrlOrDefault(),
|
||||
apiKey = mapTilerConfig.apiKey,
|
||||
lightMapId = mapTilerConfig.lightMapIdOrDefault(),
|
||||
darkMapId = mapTilerConfig.darkMapIdOrDefault(),
|
||||
)
|
||||
|
||||
override fun build(
|
||||
|
||||
+6
-21
@@ -11,33 +11,18 @@
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import io.element.android.features.location.api.BuildConfig
|
||||
|
||||
internal class MapTilerTileServerStyleUriBuilder(
|
||||
private val baseUrl: String,
|
||||
private val apiKey: String,
|
||||
private val lightMapId: String,
|
||||
private val darkMapId: String,
|
||||
) : TileServerStyleUriBuilder {
|
||||
constructor() : this(
|
||||
baseUrl = BuildConfig.MAPTILER_BASE_URL.removeSuffix("/"),
|
||||
apiKey = BuildConfig.MAPTILER_API_KEY,
|
||||
lightMapId = BuildConfig.MAPTILER_LIGHT_MAP_ID,
|
||||
darkMapId = BuildConfig.MAPTILER_DARK_MAP_ID,
|
||||
)
|
||||
|
||||
internal class MapTilerTileServerStyleUriBuilder : TileServerStyleUriBuilder {
|
||||
override fun build(
|
||||
customMapTilerConfig: MapTilerConfig?,
|
||||
mapTilerConfig: MapTilerConfig,
|
||||
darkMode: Boolean,
|
||||
): String {
|
||||
val baseUrl = customMapTilerConfig?.baseUrl.takeIf { !it.isNullOrBlank() } ?: baseUrl
|
||||
val apiKey = customMapTilerConfig?.apiKey ?: apiKey
|
||||
val baseUrl = mapTilerConfig.baseUrlOrDefault()
|
||||
val mapId = if (darkMode) {
|
||||
customMapTilerConfig?.darkStyleId ?: darkMapId
|
||||
mapTilerConfig.darkMapIdOrDefault()
|
||||
} else {
|
||||
customMapTilerConfig?.lightStyleId ?: lightMapId
|
||||
mapTilerConfig.lightMapIdOrDefault()
|
||||
}
|
||||
|
||||
return "$baseUrl/$mapId/style.json?key=$apiKey"
|
||||
return "$baseUrl/$mapId/style.json?key=${mapTilerConfig.apiKey}"
|
||||
}
|
||||
}
|
||||
|
||||
+3
-7
@@ -36,17 +36,13 @@ interface StaticMapUrlBuilder {
|
||||
density: Float,
|
||||
): String
|
||||
|
||||
/** Whether a map provider is configured for this build; the URLs are unusable when it is not. */
|
||||
/** Whether an API key is configured; the URLs are unusable when it is not. */
|
||||
fun isServiceAvailable(): Boolean
|
||||
}
|
||||
|
||||
@Composable
|
||||
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder {
|
||||
fun rememberStaticMapBuilder(mapTilerConfig: MapTilerConfig?): StaticMapUrlBuilder? {
|
||||
return remember(mapTilerConfig) {
|
||||
if (mapTilerConfig != null) {
|
||||
MapTilerStaticMapUrlBuilder(mapTilerConfig)
|
||||
} else {
|
||||
MapTilerStaticMapUrlBuilder()
|
||||
}
|
||||
mapTilerConfig?.let { MapTilerStaticMapUrlBuilder(it) }
|
||||
}
|
||||
}
|
||||
|
||||
+11
-8
@@ -22,11 +22,11 @@ interface TileServerStyleUriBuilder {
|
||||
/**
|
||||
* Builds the style URI the map view loads its tiles from.
|
||||
*
|
||||
* @param customMapTilerConfig the custom MapTiler configuration received from the server, or null if not available.
|
||||
* @param mapTilerConfig the MapTiler configuration to use.
|
||||
* @param darkMode whether to request the dark map style.
|
||||
*/
|
||||
fun build(
|
||||
customMapTilerConfig: MapTilerConfig?,
|
||||
mapTilerConfig: MapTilerConfig,
|
||||
darkMode: Boolean,
|
||||
): String
|
||||
}
|
||||
@@ -38,13 +38,16 @@ interface TileServerStyleUriBuilder {
|
||||
*/
|
||||
@Composable
|
||||
fun rememberTileStyleUrl(
|
||||
customMapTileConfig: MapTilerConfig?,
|
||||
mapTilerConfig: MapTilerConfig?,
|
||||
): String {
|
||||
val darkMode = !ElementTheme.isLightTheme
|
||||
return remember(darkMode, customMapTileConfig) {
|
||||
MapTilerTileServerStyleUriBuilder().build(
|
||||
customMapTilerConfig = customMapTileConfig,
|
||||
darkMode = darkMode,
|
||||
)
|
||||
return remember(darkMode, mapTilerConfig) {
|
||||
// Without config, the map is not displayed, so the style URL is not used.
|
||||
mapTilerConfig?.let {
|
||||
MapTilerTileServerStyleUriBuilder().build(
|
||||
mapTilerConfig = it,
|
||||
darkMode = darkMode,
|
||||
)
|
||||
}.orEmpty()
|
||||
}
|
||||
}
|
||||
|
||||
+33
@@ -9,6 +9,7 @@
|
||||
package io.element.android.features.location.api.internal
|
||||
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import org.junit.Test
|
||||
|
||||
class MapTilerStaticMapUrlBuilderTest {
|
||||
@@ -24,6 +25,38 @@ class MapTilerStaticMapUrlBuilderTest {
|
||||
assertThat(builder.isServiceAvailable()).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builder created from a config only uses the config values`() {
|
||||
val configBuilder = MapTilerStaticMapUrlBuilder(
|
||||
MapTilerConfig(
|
||||
apiKey = "API_KEY",
|
||||
lightStyleId = "light",
|
||||
darkStyleId = "dark",
|
||||
baseUrl = "https://custom.url/maps/",
|
||||
)
|
||||
)
|
||||
assertThat(configBuilder.isServiceAvailable()).isTrue()
|
||||
assertThat(
|
||||
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = true, width = 800, height = 600, density = 1f)
|
||||
).isEqualTo("https://custom.url/maps/dark/static/-4.56,1.23,7.8/800x600.webp?key=API_KEY&attribution=topright")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `builder created from a partial config falls back to the public MapTiler defaults`() {
|
||||
val configBuilder = MapTilerStaticMapUrlBuilder(
|
||||
MapTilerConfig(
|
||||
apiKey = "",
|
||||
lightStyleId = null,
|
||||
darkStyleId = null,
|
||||
baseUrl = null,
|
||||
)
|
||||
)
|
||||
assertThat(configBuilder.isServiceAvailable()).isFalse()
|
||||
assertThat(
|
||||
configBuilder.build(lat = 1.23, lon = -4.56, zoom = 7.8, darkMode = false, width = 800, height = 600, density = 1f)
|
||||
).isEqualTo("https://api.maptiler.com/maps/basic-v2/static/-4.56,1.23,7.8/800x600.webp?key=&attribution=topright")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `isServiceAvailable returns false if api key is empty`() {
|
||||
val builderWithoutKey = MapTilerStaticMapUrlBuilder(
|
||||
|
||||
+25
-36
@@ -13,75 +13,64 @@ import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
|
||||
import org.junit.Test
|
||||
|
||||
class MapTilerTileServerStyleUriBuilderTest {
|
||||
private val builder = MapTilerTileServerStyleUriBuilder(
|
||||
baseUrl = "https://base.url",
|
||||
apiKey = "anApiKey",
|
||||
lightMapId = "aLightMapId",
|
||||
darkMapId = "aDarkMapId",
|
||||
private val builder = MapTilerTileServerStyleUriBuilder()
|
||||
|
||||
private val config = MapTilerConfig(
|
||||
apiKey = "API_KEY",
|
||||
lightStyleId = "light",
|
||||
darkStyleId = "dark",
|
||||
baseUrl = "https://custom.url/maps/",
|
||||
)
|
||||
|
||||
@Test
|
||||
fun `light map uri`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
customMapTilerConfig = null,
|
||||
mapTilerConfig = config,
|
||||
darkMode = false,
|
||||
)
|
||||
).isEqualTo("https://base.url/aLightMapId/style.json?key=anApiKey")
|
||||
).isEqualTo("https://custom.url/maps/light/style.json?key=API_KEY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `dark map uri`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
customMapTilerConfig = null,
|
||||
mapTilerConfig = config,
|
||||
darkMode = true,
|
||||
)
|
||||
).isEqualTo("https://base.url/aDarkMapId/style.json?key=anApiKey")
|
||||
).isEqualTo("https://custom.url/maps/dark/style.json?key=API_KEY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom map uri light`() {
|
||||
fun `missing values fall back to the public MapTiler defaults`() {
|
||||
val partialConfig = MapTilerConfig(
|
||||
apiKey = "API_KEY",
|
||||
lightStyleId = null,
|
||||
darkStyleId = " ",
|
||||
baseUrl = " ",
|
||||
)
|
||||
assertThat(
|
||||
builder.build(
|
||||
customMapTilerConfig = MapTilerConfig(
|
||||
apiKey = "API_KEY",
|
||||
lightStyleId = "light",
|
||||
darkStyleId = "dark",
|
||||
baseUrl = "https://custom.url/style.json"
|
||||
),
|
||||
mapTilerConfig = partialConfig,
|
||||
darkMode = false,
|
||||
)
|
||||
).isEqualTo("https://custom.url/style.json/light/style.json?key=API_KEY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `custom map uri dark`() {
|
||||
).isEqualTo("https://api.maptiler.com/maps/basic-v2/style.json?key=API_KEY")
|
||||
assertThat(
|
||||
builder.build(
|
||||
customMapTilerConfig = MapTilerConfig(
|
||||
apiKey = "API_KEY",
|
||||
lightStyleId = "light",
|
||||
darkStyleId = "dark",
|
||||
baseUrl = "https://custom.url/style.json"
|
||||
),
|
||||
mapTilerConfig = partialConfig,
|
||||
darkMode = true,
|
||||
)
|
||||
).isEqualTo("https://custom.url/style.json/dark/style.json?key=API_KEY")
|
||||
).isEqualTo("https://api.maptiler.com/maps/basic-v2-dark/style.json?key=API_KEY")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `blank custom map uri falls back to the built-in style`() {
|
||||
fun `blank api key is kept as is`() {
|
||||
assertThat(
|
||||
builder.build(
|
||||
customMapTilerConfig = MapTilerConfig(
|
||||
apiKey = "API_KEY",
|
||||
lightStyleId = "light",
|
||||
darkStyleId = "dark",
|
||||
baseUrl = " "
|
||||
),
|
||||
mapTilerConfig = config.copy(apiKey = ""),
|
||||
darkMode = false,
|
||||
)
|
||||
).isEqualTo("https://base.url/light/style.json?key=API_KEY")
|
||||
).isEqualTo("https://custom.url/maps/light/style.json?key=")
|
||||
}
|
||||
}
|
||||
|
||||
+2
-3
@@ -11,7 +11,6 @@ package io.element.android.features.location.impl
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import io.element.android.features.enterprise.api.remoteconfig.CustomMapTilerConfigProvider
|
||||
import io.element.android.features.enterprise.test.remoteconfig.aMapTilerConfig
|
||||
import io.element.android.features.location.api.BuildConfig
|
||||
import io.element.android.features.location.api.buildTimeMapTilerConfig
|
||||
import io.element.android.libraries.matrix.test.AN_EXCEPTION
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -30,7 +29,7 @@ class DefaultLocationServiceTest {
|
||||
)
|
||||
runCurrent()
|
||||
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
|
||||
assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty())
|
||||
assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -40,7 +39,7 @@ class DefaultLocationServiceTest {
|
||||
)
|
||||
runCurrent()
|
||||
assertThat(locationService.mapTilerConfig.value).isEqualTo(buildTimeMapTilerConfig())
|
||||
assertThat(locationService.isServiceAvailable().value).isEqualTo(BuildConfig.MAPTILER_API_KEY.isNotEmpty())
|
||||
assertThat(locationService.isServiceAvailable().value).isEqualTo(buildTimeMapTilerConfig() != null)
|
||||
}
|
||||
|
||||
@Test
|
||||
|
||||
+1
@@ -56,6 +56,7 @@ class ConfirmAccountProviderNode(
|
||||
ConfirmAccountProviderView(
|
||||
state = state,
|
||||
modifier = modifier,
|
||||
onBackClick = ::navigateUp,
|
||||
onOAuthDetails = callback::navigateToOAuth,
|
||||
onNeedLoginPassword = callback::navigateToLoginPassword,
|
||||
onLearnMoreClick = { openLearnMorePage(context) },
|
||||
|
||||
+1
-1
@@ -34,7 +34,7 @@ open class ConfirmAccountProviderStatePreviewParam : PreviewParameterProvider<Co
|
||||
)
|
||||
}
|
||||
|
||||
private fun aConfirmAccountProviderState(
|
||||
internal fun aConfirmAccountProviderState(
|
||||
accountProviderInput: String = "matrix.org",
|
||||
accountProviderSuggestion: String? = null,
|
||||
isAccountCreation: Boolean = false,
|
||||
|
||||
+12
-1
@@ -14,6 +14,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.text.KeyboardActions
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import androidx.compose.material3.TopAppBarDefaults
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.runtime.getValue
|
||||
@@ -50,12 +51,14 @@ import io.element.android.libraries.designsystem.atomic.molecules.ButtonColumnMo
|
||||
import io.element.android.libraries.designsystem.atomic.molecules.IconTitleSubtitleMolecule
|
||||
import io.element.android.libraries.designsystem.atomic.pages.HeaderFooterPage
|
||||
import io.element.android.libraries.designsystem.components.BigIcon
|
||||
import io.element.android.libraries.designsystem.components.button.BackButton
|
||||
import io.element.android.libraries.designsystem.components.form.textFieldState
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
|
||||
import io.element.android.libraries.designsystem.theme.components.Button
|
||||
import io.element.android.libraries.designsystem.theme.components.Icon
|
||||
import io.element.android.libraries.designsystem.theme.components.TextField
|
||||
import io.element.android.libraries.designsystem.theme.components.TopAppBar
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.libraries.permissions.api.localnetwork.LocalNetworkPermissionDialogView
|
||||
import io.element.android.libraries.testtags.TestTags
|
||||
@@ -71,6 +74,7 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
||||
@Composable
|
||||
fun ConfirmAccountProviderView(
|
||||
state: ConfirmAccountProviderState,
|
||||
onBackClick: () -> Unit,
|
||||
onOAuthDetails: (OAuthDetails) -> Unit,
|
||||
onNeedLoginPassword: () -> Unit,
|
||||
onLearnMoreClick: () -> Unit,
|
||||
@@ -118,9 +122,15 @@ fun ConfirmAccountProviderView(
|
||||
// Let the header and content scroll so the supporting text is not clipped by the footer when the
|
||||
// keyboard is visible and vertical space is reduced.
|
||||
isScrollable = true,
|
||||
topBar = {
|
||||
TopAppBar(
|
||||
title = {},
|
||||
navigationIcon = { BackButton(onClick = onBackClick) },
|
||||
colors = TopAppBarDefaults.topAppBarColors(containerColor = Color.Transparent),
|
||||
)
|
||||
},
|
||||
header = {
|
||||
IconTitleSubtitleMolecule(
|
||||
modifier = Modifier.padding(top = 60.dp),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
|
||||
title = stringResource(
|
||||
id = if (state.isAccountCreation) {
|
||||
@@ -272,6 +282,7 @@ internal fun ConfirmAccountProviderViewPreview(
|
||||
) = ElementPreview {
|
||||
ConfirmAccountProviderView(
|
||||
state = state,
|
||||
onBackClick = {},
|
||||
onOAuthDetails = {},
|
||||
onNeedLoginPassword = {},
|
||||
onLearnMoreClick = {},
|
||||
|
||||
+6
-2
@@ -180,6 +180,9 @@ private fun AddFirstAccountScaffold(
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* Ref: https://www.figma.com/design/kEAcfun9iSpszeUDvdKZ6b/ER-351--Multi-account-in-EX?node-id=147-34607
|
||||
*/
|
||||
@Composable
|
||||
private fun AddOtherAccountScaffold(
|
||||
loginView: @Composable () -> Unit,
|
||||
@@ -189,8 +192,9 @@ private fun AddOtherAccountScaffold(
|
||||
) {
|
||||
FlowStepPage(
|
||||
modifier = modifier,
|
||||
title = stringResource(CommonStrings.common_add_account),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.HomeSolid()),
|
||||
title = stringResource(R.string.screen_onboarding_add_account_title),
|
||||
subTitle = stringResource(R.string.screen_onboarding_add_account_subtitle),
|
||||
iconStyle = BigIcon.Style.Default(CompoundIcons.Plus()),
|
||||
buttons = { buttons() },
|
||||
content = loginView,
|
||||
onBackClick = onBackClick,
|
||||
|
||||
@@ -52,6 +52,8 @@
|
||||
<string name="screen_missing_key_backup_step_4">"Follow the instructions to enable your key storage"</string>
|
||||
<string name="screen_missing_key_backup_step_5">"Come back to %1$s"</string>
|
||||
<string name="screen_missing_key_backup_title">"Enable your key storage before proceeding to %1$s"</string>
|
||||
<string name="screen_onboarding_add_account_subtitle">"You can add multiple accounts to the app and switch between them easily."</string>
|
||||
<string name="screen_onboarding_add_account_title">"Sign in to another account"</string>
|
||||
<string name="screen_onboarding_app_version">"Version %1$s"</string>
|
||||
<string name="screen_onboarding_checking_account">"Checking account"</string>
|
||||
<string name="screen_onboarding_sign_in_manually">"Sign in manually"</string>
|
||||
|
||||
+57
@@ -0,0 +1,57 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
@file:OptIn(ExperimentalTestApi::class)
|
||||
|
||||
package io.element.android.features.login.impl.screens.confirmaccountprovider
|
||||
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.compose.ui.test.AndroidComposeUiTest
|
||||
import androidx.compose.ui.test.ExperimentalTestApi
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.libraries.matrix.api.auth.OAuthDetails
|
||||
import io.element.android.tests.testutils.EnsureNeverCalled
|
||||
import io.element.android.tests.testutils.EnsureNeverCalledWithParam
|
||||
import io.element.android.tests.testutils.EventsRecorder
|
||||
import io.element.android.tests.testutils.ensureCalledOnce
|
||||
import io.element.android.tests.testutils.pressBack
|
||||
import io.element.android.tests.testutils.robolectric.RobolectricTest
|
||||
import org.junit.Test
|
||||
|
||||
class ConfirmAccountProviderViewTest : RobolectricTest() {
|
||||
@Test
|
||||
fun `clicking on back invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventSink = EventsRecorder<ConfirmAccountProviderEvent>(expectEvents = false)
|
||||
ensureCalledOnce {
|
||||
setConfirmAccountProviderView(
|
||||
state = aConfirmAccountProviderState(
|
||||
eventSink = eventSink,
|
||||
),
|
||||
onBackClick = it,
|
||||
)
|
||||
pressBack()
|
||||
}
|
||||
}
|
||||
|
||||
private fun AndroidComposeUiTest<ComponentActivity>.setConfirmAccountProviderView(
|
||||
state: ConfirmAccountProviderState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onOAuthDetails: (OAuthDetails) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onNeedLoginPassword: () -> Unit = EnsureNeverCalled(),
|
||||
onLearnMoreClick: () -> Unit = EnsureNeverCalled(),
|
||||
) {
|
||||
setContent {
|
||||
ConfirmAccountProviderView(
|
||||
state = state,
|
||||
onBackClick = onBackClick,
|
||||
onOAuthDetails = onOAuthDetails,
|
||||
onNeedLoginPassword = onNeedLoginPassword,
|
||||
onLearnMoreClick = onLearnMoreClick,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+51
-38
@@ -43,6 +43,7 @@ import io.element.android.features.messages.impl.timeline.TimelineController
|
||||
import io.element.android.features.messages.impl.utils.TextPillificationHelper
|
||||
import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarDispatcher
|
||||
@@ -110,6 +111,7 @@ import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.merge
|
||||
import kotlinx.coroutines.flow.stateIn
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes.Any as AnyMimeTypes
|
||||
@@ -145,6 +147,7 @@ class MessageComposerPresenter(
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val contentScannerService: ContentScannerService,
|
||||
private val contentValidationCache: EventContentValidationCache,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<MessageComposerState> {
|
||||
@AssistedFactory
|
||||
interface Factory {
|
||||
@@ -190,22 +193,22 @@ class MessageComposerPresenter(
|
||||
.collectAsState(initial = false)
|
||||
|
||||
val galleryMediaPicker = mediaPickerProvider.registerGalleryPicker { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType)
|
||||
localCoroutineScope.handlePickedMedia(uri, mimeType)
|
||||
}
|
||||
val galleryMultiMediaPicker = mediaPickerProvider.registerGalleryMultiPicker { uris ->
|
||||
handlePickedMediaList(uris)
|
||||
localCoroutineScope.handlePickedMediaList(uris)
|
||||
}
|
||||
val filesPicker = mediaPickerProvider.registerFileMultiPicker(AnyMimeTypes) { uris ->
|
||||
handlePickedMediaList(uris, sendAsFile = true)
|
||||
localCoroutineScope.handlePickedMediaList(uris, sendAsFile = true)
|
||||
}
|
||||
val fileSinglePicker = mediaPickerProvider.registerFilePicker(AnyMimeTypes) { uri, mimeType ->
|
||||
handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
|
||||
localCoroutineScope.handlePickedMedia(uri, mimeType ?: MimeTypes.OctetStream, sendAsFile = true)
|
||||
}
|
||||
val cameraPhotoPicker = mediaPickerProvider.registerCameraPhotoPicker { uri ->
|
||||
handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Jpeg)
|
||||
}
|
||||
val cameraVideoPicker = mediaPickerProvider.registerCameraVideoPicker { uri ->
|
||||
handlePickedMedia(uri, MimeTypes.Mp4)
|
||||
localCoroutineScope.handlePickedMedia(uri, MimeTypes.Mp4)
|
||||
}
|
||||
val isFullScreen = rememberSaveable {
|
||||
mutableStateOf(false)
|
||||
@@ -283,17 +286,16 @@ class MessageComposerPresenter(
|
||||
}
|
||||
is MessageComposerEvent.SendUri -> {
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
sessionCoroutineScope.sendAttachment(
|
||||
attachment = Attachment.Media(
|
||||
localMedia = localMediaFactory.createFromUri(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
),
|
||||
),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
sessionCoroutineScope.launch {
|
||||
val localMedia = createLocalMedia(
|
||||
uri = event.uri,
|
||||
mimeType = null,
|
||||
)
|
||||
sendAttachment(
|
||||
attachment = Attachment.Media(localMedia),
|
||||
inReplyToEventId = inReplyToEventId,
|
||||
)
|
||||
}
|
||||
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
@@ -637,17 +639,28 @@ class MessageComposerPresenter(
|
||||
}
|
||||
}
|
||||
|
||||
private fun handlePickedMedia(
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
sendAsFile: Boolean = false,
|
||||
) {
|
||||
uri ?: return
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
// Creating the LocalMedia queries the ContentResolver and can decode the media, so do it off the main thread
|
||||
private suspend fun createLocalMedia(
|
||||
uri: Uri,
|
||||
mimeType: String?,
|
||||
) = withContext(dispatchers.io) {
|
||||
localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
name = null,
|
||||
formattedFileSize = null
|
||||
formattedFileSize = null,
|
||||
)
|
||||
}
|
||||
|
||||
private fun CoroutineScope.handlePickedMedia(
|
||||
uri: Uri?,
|
||||
mimeType: String? = null,
|
||||
sendAsFile: Boolean = false,
|
||||
) = launch {
|
||||
uri ?: return@launch
|
||||
val localMedia = createLocalMedia(
|
||||
uri = uri,
|
||||
mimeType = mimeType,
|
||||
)
|
||||
val mediaAttachment = Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
@@ -656,7 +669,7 @@ class MessageComposerPresenter(
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
|
||||
private fun handlePickedMediaList(
|
||||
private fun CoroutineScope.handlePickedMediaList(
|
||||
uris: List<Uri>,
|
||||
sendAsFile: Boolean = false,
|
||||
) {
|
||||
@@ -665,19 +678,19 @@ class MessageComposerPresenter(
|
||||
handlePickedMedia(uris.first(), sendAsFile = sendAsFile)
|
||||
return
|
||||
}
|
||||
val attachments = uris.map { uri ->
|
||||
val localMedia = localMediaFactory.createFromUri(
|
||||
uri = uri,
|
||||
mimeType = null,
|
||||
name = null,
|
||||
formattedFileSize = null,
|
||||
)
|
||||
Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
}.toImmutableList()
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
launch {
|
||||
val attachments = uris.map { uri ->
|
||||
val localMedia = createLocalMedia(
|
||||
uri = uri,
|
||||
mimeType = null,
|
||||
)
|
||||
Attachment.Media(localMedia, sendAsFile = sendAsFile)
|
||||
}.toImmutableList()
|
||||
val inReplyToEventId = (messageComposerContext.composerMode as? MessageComposerMode.Reply)?.eventId
|
||||
navigator.navigateToPreviewAttachments(attachments, inReplyToEventId)
|
||||
|
||||
resetComposerModeAfterAttaching()
|
||||
resetComposerModeAfterAttaching()
|
||||
}
|
||||
}
|
||||
|
||||
private fun resetComposerModeAfterAttaching() {
|
||||
|
||||
+1
@@ -324,6 +324,7 @@ class MessageComposerPresenterSlashCommandTest {
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+1
@@ -1723,6 +1723,7 @@ class MessageComposerPresenterTest : RobolectricTest() {
|
||||
featureFlagService = featureFlagService,
|
||||
contentScannerService = { _, _ -> },
|
||||
contentValidationCache = InMemoryEventContentValidationCache(),
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
).apply {
|
||||
isTesting = true
|
||||
showTextFormatting = isRichTextEditorEnabled
|
||||
|
||||
+2
@@ -16,6 +16,7 @@ import io.element.android.libraries.architecture.FeatureEntryPoint
|
||||
import io.element.android.libraries.architecture.NodeInputs
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import kotlinx.parcelize.Parcelize
|
||||
|
||||
interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
@@ -44,6 +45,7 @@ interface PreferencesEntryPoint : FeatureEntryPoint {
|
||||
|
||||
interface Callback : Plugin {
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
fun navigateToLinkNewDevice()
|
||||
fun navigateToBugReport()
|
||||
fun navigateToSecureBackup()
|
||||
|
||||
+5
@@ -46,6 +46,7 @@ import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.mediaviewer.api.FileViewerEntryPoint
|
||||
import io.element.android.libraries.troubleshoot.api.NotificationTroubleShootEntryPoint
|
||||
@@ -141,6 +142,10 @@ class PreferencesFlowNode(
|
||||
callback.navigateToAddAccount()
|
||||
}
|
||||
|
||||
override fun navigateToSession(sessionId: SessionId) {
|
||||
callback.navigateToSession(sessionId)
|
||||
}
|
||||
|
||||
override fun navigateToAnalyticsSettings() {
|
||||
backstack.push(NavTarget.AnalyticsSettings)
|
||||
}
|
||||
|
||||
+13
-7
@@ -26,6 +26,7 @@ import io.element.android.libraries.architecture.AsyncAction
|
||||
import io.element.android.libraries.architecture.AsyncData
|
||||
import io.element.android.libraries.architecture.Presenter
|
||||
import io.element.android.libraries.architecture.runUpdatingStateNoSuccess
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.SessionCoroutineScope
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
@@ -57,6 +58,7 @@ import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.launch
|
||||
import kotlinx.coroutines.sync.Mutex
|
||||
import kotlinx.coroutines.sync.withLock
|
||||
import kotlinx.coroutines.withContext
|
||||
import timber.log.Timber
|
||||
import kotlin.time.Duration.Companion.seconds
|
||||
|
||||
@@ -76,6 +78,7 @@ class NotificationSettingsPresenter(
|
||||
@SessionCoroutineScope
|
||||
private val sessionCoroutineScope: CoroutineScope,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
) : Presenter<NotificationSettingsState> {
|
||||
// Serializes the pick → copy → persist → recreate pipeline per slot. The copier already locks
|
||||
// its `<slot>.tmp` file, but the persist+recreate window outside the copier is unprotected;
|
||||
@@ -111,16 +114,19 @@ class NotificationSettingsPresenter(
|
||||
}
|
||||
|
||||
// List of PushProvider -> Distributor
|
||||
val distributors = remember {
|
||||
pushService.getAvailablePushProviders()
|
||||
.flatMap { pushProvider ->
|
||||
pushProvider.getDistributors().map { distributor ->
|
||||
pushProvider to distributor
|
||||
// Getting the distributors can query the PackageManager and the Play Services, so do it off the main thread
|
||||
val distributors by produceState(emptyList<Pair<PushProvider, Distributor>>()) {
|
||||
value = withContext(dispatchers.io) {
|
||||
pushService.getAvailablePushProviders()
|
||||
.flatMap { pushProvider ->
|
||||
pushProvider.getDistributors().map { distributor ->
|
||||
pushProvider to distributor
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
// List of Distributors
|
||||
val availableDistributors = remember {
|
||||
val availableDistributors = remember(distributors) {
|
||||
distributors.map { it.second }.toImmutableList()
|
||||
}
|
||||
|
||||
|
||||
-3
@@ -8,11 +8,8 @@
|
||||
|
||||
package io.element.android.features.preferences.impl.root
|
||||
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
sealed interface PreferencesRootEvent {
|
||||
data object OnVersionInfoClick : PreferencesRootEvent
|
||||
data class SwitchToSession(val sessionId: SessionId) : PreferencesRootEvent
|
||||
data object ToggleOtherAccountsExpanded : PreferencesRootEvent
|
||||
data class SetTheme(val theme: ThemeOption) : PreferencesRootEvent
|
||||
}
|
||||
|
||||
+3
@@ -24,6 +24,7 @@ import io.element.android.libraries.androidutils.browser.openUrlInChromeCustomTa
|
||||
import io.element.android.libraries.architecture.callback
|
||||
import io.element.android.libraries.di.SessionScope
|
||||
import io.element.android.libraries.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
|
||||
@ContributesNode(SessionScope::class)
|
||||
@AssistedInject
|
||||
@@ -35,6 +36,7 @@ class PreferencesRootNode(
|
||||
) : Node(buildContext, plugins = plugins) {
|
||||
interface Callback : PreferencesAccountCallback, Plugin {
|
||||
fun navigateToAddAccount()
|
||||
fun navigateToSession(sessionId: SessionId)
|
||||
fun navigateToAnalyticsSettings()
|
||||
fun navigateToAbout()
|
||||
fun navigateToDeveloperSettings()
|
||||
@@ -72,6 +74,7 @@ class PreferencesRootNode(
|
||||
modifier = modifier,
|
||||
onBackClick = this::navigateUp,
|
||||
onAddAccountClick = callback::navigateToAddAccount,
|
||||
onSwitchToSession = callback::navigateToSession,
|
||||
onOpenAnalytics = callback::navigateToAnalyticsSettings,
|
||||
onOpenAbout = callback::navigateToAbout,
|
||||
onOpenDeveloperSettings = callback::navigateToDeveloperSettings,
|
||||
|
||||
-3
@@ -142,9 +142,6 @@ class PreferencesRootPresenter(
|
||||
is PreferencesRootEvent.OnVersionInfoClick -> {
|
||||
showDeveloperSettingsProvider.unlockDeveloperSettings(coroutineScope)
|
||||
}
|
||||
is PreferencesRootEvent.SwitchToSession -> coroutineScope.launch {
|
||||
sessionStore.setLatestSession(event.sessionId.value)
|
||||
}
|
||||
PreferencesRootEvent.ToggleOtherAccountsExpanded -> coroutineScope.launch {
|
||||
appPreferencesStore.setOtherAccountsExpanded(!isOtherAccountsSectionExpanded)
|
||||
}
|
||||
|
||||
+6
-1
@@ -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.emoji.api.picker.EmojiPickerRenderer
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.ui.components.MatrixUserRow
|
||||
import io.element.android.libraries.matrix.ui.model.getAvatarData
|
||||
@@ -80,6 +81,7 @@ fun PreferencesRootView(
|
||||
emojiPickerRenderer: EmojiPickerRenderer,
|
||||
onBackClick: () -> Unit,
|
||||
onAddAccountClick: () -> Unit,
|
||||
onSwitchToSession: (SessionId) -> Unit,
|
||||
onOpenAnalytics: () -> Unit,
|
||||
onOpenLockScreenSettings: () -> Unit,
|
||||
onOpenAbout: () -> Unit,
|
||||
@@ -125,6 +127,7 @@ fun PreferencesRootView(
|
||||
MultiAccountSection(
|
||||
state = state,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSwitchToSession = onSwitchToSession,
|
||||
)
|
||||
}
|
||||
HorizontalDivider(
|
||||
@@ -209,6 +212,7 @@ private fun ColumnScope.UserStatusSection(
|
||||
private fun ColumnScope.MultiAccountSection(
|
||||
state: PreferencesRootState,
|
||||
onAddAccountClick: () -> Unit,
|
||||
onSwitchToSession: (SessionId) -> Unit,
|
||||
) {
|
||||
HorizontalDivider(
|
||||
thickness = 8.dp,
|
||||
@@ -276,7 +280,7 @@ private fun ColumnScope.MultiAccountSection(
|
||||
MatrixUserRow(
|
||||
modifier = Modifier
|
||||
.clickable {
|
||||
state.eventSink(PreferencesRootEvent.SwitchToSession(otherAccount.user.userId))
|
||||
onSwitchToSession(otherAccount.user.userId)
|
||||
}
|
||||
.padding(top = 2.dp, bottom = 2.dp, end = 8.dp),
|
||||
matrixUser = otherAccount.user,
|
||||
@@ -433,6 +437,7 @@ private fun ContentToPreview(state: PreferencesRootState) {
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
onBackClick = {},
|
||||
onAddAccountClick = {},
|
||||
onSwitchToSession = {},
|
||||
onOpenAnalytics = {},
|
||||
onOpenDeveloperSettings = {},
|
||||
onOpenLocationSettings = {},
|
||||
|
||||
+2
@@ -19,6 +19,7 @@ import io.element.android.features.logout.test.FakeLogoutEntryPoint
|
||||
import io.element.android.features.preferences.api.PreferencesEntryPoint
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
import io.element.android.libraries.matrix.api.core.RoomId
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.mediaviewer.test.FakeFileViewerEntryPoint
|
||||
import io.element.android.libraries.troubleshoot.test.FakeNotificationTroubleShootEntryPoint
|
||||
import io.element.android.libraries.troubleshoot.test.FakePushHistoryEntryPoint
|
||||
@@ -52,6 +53,7 @@ class DefaultPreferencesEntryPointTest {
|
||||
}
|
||||
val callback = object : PreferencesEntryPoint.Callback {
|
||||
override fun navigateToAddAccount() = lambdaError()
|
||||
override fun navigateToSession(sessionId: SessionId) = lambdaError()
|
||||
override fun navigateToLinkNewDevice() = lambdaError()
|
||||
override fun navigateToBugReport() = lambdaError()
|
||||
override fun navigateToSecureBackup() = lambdaError()
|
||||
|
||||
+2
@@ -40,6 +40,7 @@ import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
@@ -1176,6 +1177,7 @@ class NotificationSettingsPresenterTest {
|
||||
stringProvider = FakeStringProvider(),
|
||||
sessionCoroutineScope = backgroundScope,
|
||||
featureFlagService = featureFlagService,
|
||||
dispatchers = testCoroutineDispatchers(useUnconfinedTestDispatcher = true),
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-20
@@ -44,8 +44,6 @@ import io.element.android.libraries.sessionstorage.test.aSessionData
|
||||
import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.tests.testutils.WarmUpRule
|
||||
import io.element.android.tests.testutils.consumeItemsUntilPredicate
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.lambda.value
|
||||
import io.element.android.tests.testutils.test
|
||||
import kotlinx.collections.immutable.toImmutableList
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
@@ -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
|
||||
fun `present - labs can be shown if any feature flag is in labs and not finished`() = runTest {
|
||||
createPreferencesRootPresenter(
|
||||
|
||||
+23
-18
@@ -20,6 +20,7 @@ import androidx.compose.ui.test.performScrollTo
|
||||
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
|
||||
import io.element.android.features.preferences.impl.R
|
||||
import io.element.android.libraries.emoji.api.picker.NoOpEmojiPickerRenderer
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.api.user.MatrixUser
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID_2
|
||||
import io.element.android.libraries.matrix.ui.components.aMatrixUser
|
||||
@@ -69,25 +70,27 @@ class PreferencesRootViewTest : RobolectricTest() {
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `clicking on other session sends a SwitchToSession`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>()
|
||||
setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
isOtherAccountsSectionExpanded = true,
|
||||
otherSessions = listOf(
|
||||
anOtherAccount(
|
||||
user = aMatrixUser(
|
||||
id = A_USER_ID_2.value,
|
||||
displayName = "Bob",
|
||||
),
|
||||
)
|
||||
fun `clicking on other session invokes the expected callback`() = runAndroidComposeUiTest {
|
||||
val eventsRecorder = EventsRecorder<PreferencesRootEvent>(expectEvents = false)
|
||||
ensureCalledOnceWithParam(A_USER_ID_2) { callback ->
|
||||
setView(
|
||||
aPreferencesRootState(
|
||||
isMultiAccountEnabled = true,
|
||||
isOtherAccountsSectionExpanded = true,
|
||||
otherSessions = listOf(
|
||||
anOtherAccount(
|
||||
user = aMatrixUser(
|
||||
id = A_USER_ID_2.value,
|
||||
displayName = "Bob",
|
||||
),
|
||||
)
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
eventSink = eventsRecorder,
|
||||
),
|
||||
)
|
||||
onNodeWithText("Bob").performClick()
|
||||
eventsRecorder.assertSingle(PreferencesRootEvent.SwitchToSession(A_USER_ID_2))
|
||||
onSwitchToSession = callback,
|
||||
)
|
||||
onNodeWithText("Bob").performClick()
|
||||
}
|
||||
}
|
||||
|
||||
@Test
|
||||
@@ -425,6 +428,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
|
||||
state: PreferencesRootState,
|
||||
onBackClick: () -> Unit = EnsureNeverCalled(),
|
||||
onAddAccountClick: () -> Unit = EnsureNeverCalled(),
|
||||
onSwitchToSession: (SessionId) -> Unit = EnsureNeverCalledWithParam(),
|
||||
onOpenAnalytics: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenLockScreenSettings: () -> Unit = EnsureNeverCalled(),
|
||||
onOpenAbout: () -> Unit = EnsureNeverCalled(),
|
||||
@@ -448,6 +452,7 @@ private fun AndroidComposeUiTest<ComponentActivity>.setView(
|
||||
emojiPickerRenderer = NoOpEmojiPickerRenderer,
|
||||
onBackClick = onBackClick,
|
||||
onAddAccountClick = onAddAccountClick,
|
||||
onSwitchToSession = onSwitchToSession,
|
||||
onOpenAnalytics = onOpenAnalytics,
|
||||
onOpenLockScreenSettings = onOpenLockScreenSettings,
|
||||
onOpenAbout = onOpenAbout,
|
||||
|
||||
@@ -2,6 +2,7 @@
|
||||
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
|
||||
<string name="screen_identity_confirmation_cannot_confirm">"Can\'t confirm?"</string>
|
||||
<string name="screen_identity_confirmation_create_new_recovery_key">"Create a new recovery key"</string>
|
||||
<string name="screen_identity_confirmation_sign_out">"Sign out"</string>
|
||||
<string name="screen_identity_confirmation_subtitle">"Choose how to verify to set up secure messaging."</string>
|
||||
<string name="screen_identity_confirmation_title">"Confirm your digital identity"</string>
|
||||
<string name="screen_identity_confirmation_use_another_device">"Use another device"</string>
|
||||
|
||||
@@ -41,7 +41,7 @@ coil = "3.6.3"
|
||||
# There is some custom logic in `RootFlowNode` that may break because it reuses some Appyx internal APIs.
|
||||
# When upgrading this version, check state restoration still works fine.
|
||||
appyx = "1.7.1"
|
||||
sqldelight = "2.4.0"
|
||||
sqldelight = "2.4.1"
|
||||
wysiwyg = "2.42.1"
|
||||
telephoto = "0.19.0"
|
||||
maplibre-compose = "0.19.0"
|
||||
|
||||
+5
-1
@@ -84,5 +84,9 @@ fun File.getSizeOfFiles(): Long {
|
||||
Timber.v("Get size of ${it.absolutePath}")
|
||||
true
|
||||
}
|
||||
.sumOf { it.length() }
|
||||
// Only sum the actual size of files, not directories, to avoid double counting or worse,
|
||||
// since the docs mention that `length()` for directories is not reliable.
|
||||
// For directories, we can assume a size of 4096 bytes (4 KiB) as a placeholder,
|
||||
// which is a common block size on many file systems.
|
||||
.sumOf { if (it.isFile) it.length() else 4096L }
|
||||
}
|
||||
|
||||
+13
-28
@@ -15,6 +15,8 @@ import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.combinedClickable
|
||||
import androidx.compose.foundation.gestures.awaitEachGesture
|
||||
import androidx.compose.foundation.gestures.awaitFirstDown
|
||||
import androidx.compose.foundation.gestures.awaitLongPressOrCancellation
|
||||
import androidx.compose.foundation.gestures.waitForUpOrCancellation
|
||||
import androidx.compose.foundation.horizontalScroll
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
@@ -53,8 +55,7 @@ import androidx.compose.ui.graphics.drawOutline
|
||||
import androidx.compose.ui.graphics.drawscope.Fill
|
||||
import androidx.compose.ui.graphics.drawscope.Stroke
|
||||
import androidx.compose.ui.graphics.drawscope.translate
|
||||
import androidx.compose.ui.input.pointer.PointerEventTimeoutCancellationException
|
||||
import androidx.compose.ui.input.pointer.changedToUp
|
||||
import androidx.compose.ui.input.pointer.changedToUpIgnoreConsumed
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.platform.ClipEntry
|
||||
import androidx.compose.ui.platform.LocalClipboard
|
||||
@@ -565,34 +566,18 @@ private fun Modifier.linkTapHandler(
|
||||
val (url, urlText) = layoutResult.value?.urlAt(down.position, text) ?: return@awaitEachGesture
|
||||
down.consume()
|
||||
|
||||
val longPressTimeout = viewConfiguration.longPressTimeoutMillis
|
||||
|
||||
// Try detecting a long press by waiting for the long press timeout and checking if we received an up event before that.
|
||||
// If we did, it's a tap, otherwise it's a long press.
|
||||
// This warning is a false positive.
|
||||
@Suppress("KotlinConstantConditions")
|
||||
val isLongPress = try {
|
||||
withTimeout(longPressTimeout) {
|
||||
var receivedUp = false
|
||||
while (!receivedUp) {
|
||||
val event = awaitPointerEvent()
|
||||
val up = event.changes.firstOrNull { it.id == down.id && it.changedToUp() }
|
||||
if (up != null) {
|
||||
receivedUp = true
|
||||
up.consume()
|
||||
}
|
||||
}
|
||||
|
||||
!receivedUp
|
||||
}
|
||||
} catch (_: PointerEventTimeoutCancellationException) {
|
||||
true
|
||||
}
|
||||
|
||||
if (isLongPress) {
|
||||
// Returns a non-null change if the long press timeout elapsed, or null if the gesture was canceled by movement beyond touch slop or by an up event.
|
||||
if (awaitLongPressOrCancellation(down.id) != null) {
|
||||
context.onLinkLongClick(url, urlText)
|
||||
// Swallow the rest of the gesture so parents don't react to the eventual release.
|
||||
waitForUpOrCancellation()?.consume()
|
||||
} else {
|
||||
context.onLinkClick(url, urlText)
|
||||
// Either a tap (the pointer was released) or a drag (the user probably wanted to scroll).
|
||||
val up = currentEvent.changes.firstOrNull { it.id == down.id && it.changedToUpIgnoreConsumed() }
|
||||
if (up != null) {
|
||||
up.consume()
|
||||
context.onLinkClick(url, urlText)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -59,7 +59,11 @@ dependencies {
|
||||
implementation(platform("io.element.android:element-call-bom:$elementCallVersion"))
|
||||
implementation(libs.element.call.api)
|
||||
implementation(projects.features.callnative.api)
|
||||
implementation(libs.element.call.matrix)
|
||||
implementation(libs.element.call.matrix) {
|
||||
if (usesLocalRustSdk) {
|
||||
exclude(group = "org.matrix.rustcomponents", module = "sdk-android")
|
||||
}
|
||||
}
|
||||
|
||||
implementation(projects.appconfig)
|
||||
implementation(projects.features.enterprise.api)
|
||||
|
||||
+4
-1
@@ -118,6 +118,7 @@ import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.buffer
|
||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.mapNotNull
|
||||
@@ -320,7 +321,7 @@ class RustMatrixClient(
|
||||
override fun onUpdate(profile: UserProfile) {
|
||||
val matrixUser = profile.map()
|
||||
_userProfile.tryEmit(matrixUser)
|
||||
sessionCoroutineScope.launch {
|
||||
sessionCoroutineScope.launch(sessionDispatcher) {
|
||||
storeUserProfile(matrixUser)
|
||||
}
|
||||
}
|
||||
@@ -1013,6 +1014,8 @@ class RustMatrixClient(
|
||||
searchBackfillService.cancelPeriodicSearchBackfill()
|
||||
}
|
||||
}
|
||||
// Checking the pending work is a blocking call, do not do it on the main thread
|
||||
.flowOn(sessionDispatcher)
|
||||
.launchIn(sessionCoroutineScope)
|
||||
}
|
||||
}
|
||||
|
||||
+1
@@ -158,6 +158,7 @@ class JoinedRustRoom(
|
||||
inner = innerRoom.threadListService(),
|
||||
contentMapper = TimelineEventContentMapper(),
|
||||
roomCoroutineScope = roomCoroutineScope,
|
||||
roomDispatcher = roomDispatcher,
|
||||
)
|
||||
|
||||
override val syncUpdateFlow = flow {
|
||||
|
||||
+5
@@ -17,10 +17,12 @@ import io.element.android.libraries.matrix.api.room.threads.ThreadsListService
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
|
||||
import io.element.android.libraries.matrix.impl.timeline.item.event.map
|
||||
import io.element.android.libraries.matrix.impl.util.mxCallbackFlow
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.flowOn
|
||||
import kotlinx.coroutines.flow.launchIn
|
||||
import kotlinx.coroutines.flow.onEach
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
@@ -33,6 +35,7 @@ import org.matrix.rustcomponents.sdk.ThreadListService as InnerThreadListService
|
||||
class RustThreadsListService(
|
||||
private val inner: InnerThreadListService,
|
||||
private val roomCoroutineScope: CoroutineScope,
|
||||
private val roomDispatcher: CoroutineDispatcher,
|
||||
private val contentMapper: TimelineEventContentMapper = TimelineEventContentMapper(),
|
||||
) : ThreadsListService {
|
||||
private var itemSubscriptionJob: Job? = null
|
||||
@@ -63,6 +66,8 @@ class RustThreadsListService(
|
||||
updated.apply(diff, contentMapper)
|
||||
items.value = updated
|
||||
}
|
||||
// Fetching and mapping the items can be costly, do not do it on the main thread
|
||||
.flowOn(roomDispatcher)
|
||||
.launchIn(roomCoroutineScope)
|
||||
}
|
||||
|
||||
|
||||
+4
@@ -33,7 +33,9 @@ import io.element.android.services.analytics.test.FakeAnalyticsService
|
||||
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import io.element.android.tests.testutils.testCoroutineDispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import org.junit.Test
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
@@ -41,6 +43,7 @@ import org.matrix.rustcomponents.sdk.ClientDelegate
|
||||
import java.io.File
|
||||
import org.matrix.rustcomponents.sdk.SyncService as ClientSyncService
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class RustMatrixClientFactoryTest {
|
||||
@Test
|
||||
fun test() = runTest {
|
||||
@@ -51,6 +54,7 @@ class RustMatrixClientFactoryTest {
|
||||
val result = sut.create(aSessionData())
|
||||
|
||||
assertThat(result.sessionId).isEqualTo(SessionId("@alice:server.org"))
|
||||
runCurrent()
|
||||
// 2 workers are scheduled: one for the search backfill and one for the vacuum
|
||||
scheduleWorkersLambda.assertions().isCalledExactly(2)
|
||||
result.destroy()
|
||||
|
||||
+2
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_TIMESTAMP
|
||||
import io.element.android.libraries.matrix.test.A_USER_ID
|
||||
import io.element.android.tests.testutils.lambda.lambdaRecorder
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.test.StandardTestDispatcher
|
||||
import kotlinx.coroutines.test.TestScope
|
||||
import kotlinx.coroutines.test.runCurrent
|
||||
import kotlinx.coroutines.test.runTest
|
||||
@@ -122,6 +123,7 @@ class RustThreadsListServiceTest {
|
||||
) = RustThreadsListService(
|
||||
inner = inner,
|
||||
roomCoroutineScope = backgroundScope,
|
||||
roomDispatcher = StandardTestDispatcher(testScheduler),
|
||||
)
|
||||
|
||||
private fun aRustThreadListUpdate() = ThreadListUpdate.Append(
|
||||
|
||||
+7
-1
@@ -10,6 +10,7 @@ package io.element.android.libraries.network.interceptors
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.Inject
|
||||
import dev.zacsweers.metro.SingleIn
|
||||
import io.element.android.libraries.core.mimetype.MimeTypes
|
||||
import io.element.android.libraries.matrix.api.tracing.LogLevel
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import kotlinx.coroutines.flow.first
|
||||
@@ -31,7 +32,12 @@ class DynamicHttpLoggingInterceptor(
|
||||
override fun intercept(chain: Interceptor.Chain): Response {
|
||||
// This is called in a separate thread, so calling `runBlocking` here should be fine, it should be also instant after the value is cached
|
||||
val logLevel = runBlocking { appPreferencesStore.getTracingLogLevelFlow().first() }
|
||||
loggingInterceptor.level = if (logLevel >= LogLevel.DEBUG) Level.BODY else Level.NONE
|
||||
// If the request body is binary, we log only the headers to avoid logging binary data
|
||||
val isBinaryBody = chain.request().headers["Content-Type"] == MimeTypes.OctetStream
|
||||
loggingInterceptor.level = when {
|
||||
logLevel >= LogLevel.DEBUG -> if (isBinaryBody) Level.HEADERS else Level.BODY
|
||||
else -> Level.NONE
|
||||
}
|
||||
return loggingInterceptor.intercept(chain)
|
||||
}
|
||||
}
|
||||
|
||||
+26
-21
@@ -14,6 +14,7 @@ import android.os.PowerManager
|
||||
import androidx.core.content.getSystemService
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.ContributesBinding
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.runCatchingExceptions
|
||||
import io.element.android.libraries.di.annotations.ApplicationContext
|
||||
import io.element.android.libraries.matrix.api.core.EventId
|
||||
@@ -23,23 +24,25 @@ import io.element.android.libraries.push.impl.PushDatabase
|
||||
import io.element.android.libraries.push.impl.db.PushHistory
|
||||
import io.element.android.libraries.push.impl.db.PushRequest
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlin.time.Instant
|
||||
|
||||
@ContributesBinding(AppScope::class)
|
||||
class DefaultPushHistoryService(
|
||||
private val pushDatabase: PushDatabase,
|
||||
private val systemClock: SystemClock,
|
||||
private val dispatchers: CoroutineDispatchers,
|
||||
@ApplicationContext context: Context,
|
||||
) : PushHistoryService {
|
||||
private val powerManager = context.getSystemService<PowerManager>()
|
||||
private val packageName = context.packageName
|
||||
|
||||
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> {
|
||||
return runCatchingExceptions { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() }
|
||||
override suspend fun insertOrUpdatePushRequest(pushRequest: PushRequest): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatchingExceptions<Unit> { pushDatabase.pushRequestQueries.insertPushRequest(pushRequest).await() }
|
||||
}
|
||||
|
||||
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
override suspend fun insertOrUpdatePushRequests(pushRequests: List<PushRequest>): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
pushDatabase.transaction {
|
||||
for (request in pushRequests) {
|
||||
pushDatabase.pushRequestQueries.insertPushRequest(request)
|
||||
@@ -48,8 +51,8 @@ class DefaultPushHistoryService(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> {
|
||||
return runCatchingExceptions {
|
||||
override suspend fun getPendingPushRequests(sessionId: SessionId, since: Instant?): Result<List<PushRequest>> = withContext(dispatchers.io) {
|
||||
runCatchingExceptions {
|
||||
pushDatabase.transactionWithResult {
|
||||
val sinceTimeMillis = since?.toEpochMilliseconds() ?: 0
|
||||
pushDatabase.pushRequestQueries.selectAllPendingForSession(sessionId.value, sinceTimeMillis).executeAsList()
|
||||
@@ -57,14 +60,14 @@ class DefaultPushHistoryService(
|
||||
}
|
||||
}
|
||||
|
||||
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> {
|
||||
return runCatchingExceptions {
|
||||
override suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit> = withContext(dispatchers.io) {
|
||||
runCatchingExceptions<Unit> {
|
||||
val keepAmount = 100L
|
||||
pushDatabase.pushRequestQueries.removeOldest(keepAmount)
|
||||
}
|
||||
}
|
||||
|
||||
override fun onPushResult(
|
||||
override suspend fun onPushResult(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
@@ -91,19 +94,21 @@ class DefaultPushHistoryService(
|
||||
}
|
||||
}.takeIf { it.isNotEmpty() }
|
||||
|
||||
pushDatabase.pushHistoryQueries.insertPushHistory(
|
||||
PushHistory(
|
||||
pushDate = systemClock.epochMillis(),
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId?.value,
|
||||
roomId = roomId?.value,
|
||||
sessionId = sessionId?.value,
|
||||
hasBeenResolved = if (hasBeenResolved) 1 else 0,
|
||||
comment = finalComment,
|
||||
withContext(dispatchers.io) {
|
||||
pushDatabase.pushHistoryQueries.insertPushHistory(
|
||||
PushHistory(
|
||||
pushDate = systemClock.epochMillis(),
|
||||
providerInfo = providerInfo,
|
||||
eventId = eventId?.value,
|
||||
roomId = roomId?.value,
|
||||
sessionId = sessionId?.value,
|
||||
hasBeenResolved = if (hasBeenResolved) 1 else 0,
|
||||
comment = finalComment,
|
||||
)
|
||||
)
|
||||
)
|
||||
|
||||
// Keep only the last 1_000 events
|
||||
pushDatabase.pushHistoryQueries.removeOldest(1_000)
|
||||
// Keep only the last 1_000 events
|
||||
pushDatabase.pushHistoryQueries.removeOldest(1_000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-6
@@ -20,7 +20,7 @@ interface PushHistoryService {
|
||||
* Create a new push history entry.
|
||||
* Do not use directly, prefer using the extension functions.
|
||||
*/
|
||||
fun onPushResult(
|
||||
suspend fun onPushResult(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
@@ -52,7 +52,7 @@ interface PushHistoryService {
|
||||
suspend fun removeOldPushRequests(sessionId: SessionId): Result<Unit>
|
||||
}
|
||||
|
||||
fun PushHistoryService.onInvalidPushReceived(
|
||||
suspend fun PushHistoryService.onInvalidPushReceived(
|
||||
providerInfo: String,
|
||||
data: String,
|
||||
) = onPushResult(
|
||||
@@ -65,7 +65,7 @@ fun PushHistoryService.onInvalidPushReceived(
|
||||
comment = "Invalid or ignored push data:\n$data",
|
||||
)
|
||||
|
||||
fun PushHistoryService.onUnableToRetrieveSession(
|
||||
suspend fun PushHistoryService.onUnableToRetrieveSession(
|
||||
providerInfo: String,
|
||||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
@@ -80,7 +80,7 @@ fun PushHistoryService.onUnableToRetrieveSession(
|
||||
comment = "Unable to retrieve session: $reason",
|
||||
)
|
||||
|
||||
fun PushHistoryService.onUnableToResolveEvent(
|
||||
suspend fun PushHistoryService.onUnableToResolveEvent(
|
||||
providerInfo: String,
|
||||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
@@ -96,7 +96,7 @@ fun PushHistoryService.onUnableToResolveEvent(
|
||||
comment = "Unable to resolve event: $reason",
|
||||
)
|
||||
|
||||
fun PushHistoryService.onSuccess(
|
||||
suspend fun PushHistoryService.onSuccess(
|
||||
providerInfo: String,
|
||||
eventId: EventId,
|
||||
roomId: RoomId,
|
||||
@@ -117,7 +117,7 @@ fun PushHistoryService.onSuccess(
|
||||
},
|
||||
)
|
||||
|
||||
fun PushHistoryService.onDiagnosticPush(
|
||||
suspend fun PushHistoryService.onDiagnosticPush(
|
||||
providerInfo: String,
|
||||
) = onPushResult(
|
||||
providerInfo = providerInfo,
|
||||
|
||||
+1
-1
@@ -30,7 +30,7 @@ class FakePushHistoryService(
|
||||
private val getPendingPushRequests: (SessionId, Instant?) -> Result<List<PushRequest>> = { _, _ -> lambdaError() },
|
||||
private val removeOldPushRequests: (SessionId) -> Result<Unit> = { lambdaError() },
|
||||
) : PushHistoryService {
|
||||
override fun onPushResult(
|
||||
override suspend fun onPushResult(
|
||||
providerInfo: String,
|
||||
eventId: EventId?,
|
||||
roomId: RoomId?,
|
||||
|
||||
@@ -10,14 +10,17 @@ package extension
|
||||
|
||||
import com.android.build.api.dsl.VariantDimension
|
||||
|
||||
/**
|
||||
* Adds a String field to the generated BuildConfig class. If [value] is null, the field will be null.
|
||||
*/
|
||||
fun VariantDimension.buildConfigFieldStr(
|
||||
name: String,
|
||||
value: String,
|
||||
value: String?,
|
||||
) {
|
||||
buildConfigField(
|
||||
type = "String",
|
||||
name = name,
|
||||
value = "\"$value\""
|
||||
value = value?.let { "\"$it\"" } ?: "null"
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
BIN
Binary file not shown.
tests/uitests/src/test/snapshots/images/features.home.impl.components_SwipeAvatarBanner_Day_0_en.png
LFS
BIN
Binary file not shown.
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user