Merge branch 'develop' into feature/fga/timeline-multi-forward

This commit is contained in:
ganfra
2026-09-29 17:15:40 +02:00
1583 changed files with 15749 additions and 7431 deletions
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Soukromé"</string>
<string name="screen_create_room_public_option_description">"Tuto místnost může najít kdokoli.
To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_public_option_short_description">"Vstoupit může kdokoli."</string>
<string name="screen_create_room_public_option_short_description">"Připojit se může kdokoli, včetně osob mimo vaši organizaci."</string>
<string name="screen_create_room_public_option_title">"Veřejné"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kdokoli může požádat o vstup do místnosti, ale správce nebo moderátor bude muset žádost přijmout."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Povolit žádost o vstup"</string>
@@ -19,7 +19,7 @@ To můžete kdykoli změnit v nastavení místnosti."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Požádat o vstup"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Vstoupit mohou pouze pozvaní lidé."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Soukromé"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Vstoupit může kdokoli."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Připojit se může kdokoli, včetně osob mimo vaši organizaci."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Veřejné"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Kdokoli může vstoupit do %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Privat"</string>
<string name="screen_create_room_public_option_description">"Jeder kann diesen Chat finden.
Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_public_option_short_description">"Alle können beitreten."</string>
<string name="screen_create_room_public_option_short_description">"Alle können beitreten, auch Personen außerhalb deiner Organisation."</string>
<string name="screen_create_room_public_option_title">"Öffentlich"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Jeder kann den Beitritt zum Chat erbitten, aber ein Admin oder Moderator muss die Anfrage akzeptieren."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Anfrage zum Beitritt zulassen"</string>
@@ -19,7 +19,7 @@ Du kannst dies jederzeit in den Einstellungen des Chats ändern."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Beitritt anfragen"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Nur eingeladene Personen können beitreten."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privat"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle können beitreten."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Alle können beitreten, auch Personen außerhalb deiner Organisation."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Öffentlich"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Jeder in %1$s kann beitreten."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Ιδιωτικό"</string>
<string name="screen_create_room_public_option_description">"Ο καθένας μπορεί να βρει αυτή την αίθουσα.
Αυτό μπορείτε να το αλλάξετε ανά πάσα στιγμή στις ρυθμίσεις της αίθουσας."</string>
<string name="screen_create_room_public_option_short_description">"Οποιοσδήποτε μπορεί να συμμετάσχει."</string>
<string name="screen_create_room_public_option_short_description">"Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων ατόμων εκτός του οργανισμού σας."</string>
<string name="screen_create_room_public_option_title">"Δημόσιο"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Οποιοσδήποτε μπορεί να ζητήσει να συμμετάσχει στην αίθουσα, αλλά ένας διαχειριστής ή ένας συντονιστής θα πρέπει να αποδεχτεί το αίτημα."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Επιτρέψτε την αίτηση συμμετοχής"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Αίτημα συμμετοχής"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Μόνο άτομα που έχουν προσκληθεί μπορούν να συμμετάσχουν."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Ιδιωτικό"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Οποιοσδήποτε μπορεί να συμμετάσχει."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Οποιοσδήποτε μπορεί να συμμετάσχει, συμπεριλαμβανομένων και ατόμων εκτός του οργανισμού σας."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Δημόσιο"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Οποιοσδήποτε στο %1$s μπορεί να συμμετάσχει."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Πρότυπο"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Yksityinen"</string>
<string name="screen_create_room_public_option_description">"Kuka tahansa voi löytää tämän huoneen.
Voit muuttaa tämän milloin tahansa huoneen asetuksista."</string>
<string name="screen_create_room_public_option_short_description">"Kuka tahansa voi liittyä."</string>
<string name="screen_create_room_public_option_short_description">"Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt."</string>
<string name="screen_create_room_public_option_title">"Julkinen"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Kuka tahansa voi pyytää saada liittyä huoneeseen, mutta ylläpitäjän tai valvojan on hyväksyttävä pyyntö."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Salli liittymispyynnöt"</string>
@@ -19,7 +19,7 @@ Voit muuttaa tämän milloin tahansa huoneen asetuksista."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Pyydä liittymistä"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Vain kutsutut henkilöt voivat liittyä."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Yksityinen"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Kuka tahansa voi liittyä."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Kuka tahansa voi liittyä, myös organisaatiosi ulkopuoliset henkilöt."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Julkinen"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Kuka tahansa tilassa %1$s voi liittyä."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Vakio"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Privatno"</string>
<string name="screen_create_room_public_option_description">"Svatko može pronaći ovu sobu.
To možete u svakom trenutku promijeniti u postavkama sobe."</string>
<string name="screen_create_room_public_option_short_description">"Svatko se može pridružiti."</string>
<string name="screen_create_room_public_option_short_description">"Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije."</string>
<string name="screen_create_room_public_option_title">"Javno"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Svatko može zatražiti pridruživanje sobi, ali administrator ili moderator morat će prihvatiti zahtjev."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Dopusti traženje pridruživanja"</string>
@@ -19,7 +19,7 @@ To možete u svakom trenutku promijeniti u postavkama sobe."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Zatraži pridruživanje"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Samo pozvane osobe mogu pristupiti ovoj sobi. Sve su poruke sveobuhvatno šifrirane."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privatno"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Svatko se može pridružiti."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Svatko se može pridružiti, uključujući i osobe izvan vaše organizacije."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Javno"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Svatko u %1$s može se pridružiti."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Standard"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Privát"</string>
<string name="screen_create_room_public_option_description">"Bárki megtalálhatja ezt a szobát.
Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_public_option_short_description">"Bárki csatlakozhat."</string>
<string name="screen_create_room_public_option_short_description">"Bárki csatlakozhat, beleértve a szervezeten kívülieket is."</string>
<string name="screen_create_room_public_option_title">"Nyilvános"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Bárki kérheti, hogy csatlakozhasson a szobához, de egy adminisztrátornak vagy moderátornak el kell fogadnia a kérést."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Csatlakozás kérése"</string>
@@ -19,7 +19,7 @@ Ezt bármikor módosíthatja a szobabeállításokban."</string>
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Csatlakozás kérése"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Csak a meghívottak léphetnek be."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Privát"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Bárki csatlakozhat."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Bárki csatlakozhat, beleértve a szervezeten kívülieket is."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Nyilvános"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Bárki csatlakozhat innen: %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Szokásos"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"非公開"</string>
<string name="screen_create_room_public_option_description">"ルームは全世界に公開されます。
ルーム設定でいつでも変更できます。"</string>
<string name="screen_create_room_public_option_short_description">"誰でも参加できます。"</string>
<string name="screen_create_room_public_option_short_description">"あなたの組織外のユーザーを含め誰でも参加できます。"</string>
<string name="screen_create_room_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"誰でも参加できますが、管理者またはモデレーターの承認が必要です。"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"参加の要求を許可"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"参加を要求"</string>
<string name="screen_create_room_room_access_section_private_option_description">"招待されたユーザーのみが参加できます。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"非公開"</string>
<string name="screen_create_room_room_access_section_public_option_description">"誰でも参加できます。"</string>
<string name="screen_create_room_room_access_section_public_option_description">"あなたの組織外のユーザーを含め誰でも参加できます。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"%1$s にいる全員が参加することができます。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"スタンダード"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"Приватний"</string>
<string name="screen_create_room_public_option_description">"Будь-хто може знайти цю кімнату.
Ви можете змінити це в будь-який час у налаштуваннях кімнати."</string>
<string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто."</string>
<string name="screen_create_room_public_option_short_description">"Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації."</string>
<string name="screen_create_room_public_option_title">"Публічний"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"Будь-хто може подати запит на приєднання, але адміністратор або модератор повинен схвалити запит."</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"Дозволити запит на приєднання"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"Запит на приєднання"</string>
<string name="screen_create_room_room_access_section_private_option_description">"Приєднатися можуть лише запрошені особи."</string>
<string name="screen_create_room_room_access_section_private_option_title">"Приватний"</string>
<string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто."</string>
<string name="screen_create_room_room_access_section_public_option_description">"Приєднатися може будь-хто, у тому числі й особи, які не належать до вашої організації."</string>
<string name="screen_create_room_room_access_section_public_option_title">"Публічний"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"Приєднатися може будь-хто з %1$s."</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"Стандартний"</string>
@@ -11,7 +11,7 @@
<string name="screen_create_room_private_option_title">"私人"</string>
<string name="screen_create_room_public_option_description">"任何人都可以找到此聊天室。
您隨時都可以在聊天室設定中變更此設定。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以加入。"</string>
<string name="screen_create_room_public_option_short_description">"任何人都可以加入,包括貴組織以外的人士。"</string>
<string name="screen_create_room_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_knocking_option_description">"任何人都可以要求加入,但管理員或版主必須接受該請求"</string>
<string name="screen_create_room_room_access_section_knocking_option_title">"允許要求加入"</string>
@@ -19,7 +19,7 @@
<string name="screen_create_room_room_access_section_knocking_restricted_option_title">"要求加入"</string>
<string name="screen_create_room_room_access_section_private_option_description">"僅被邀請的人才可以加入。"</string>
<string name="screen_create_room_room_access_section_private_option_title">"私人"</string>
<string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入"</string>
<string name="screen_create_room_room_access_section_public_option_description">"任何人都可以加入,包括貴組織以外的人士。"</string>
<string name="screen_create_room_room_access_section_public_option_title">"公開"</string>
<string name="screen_create_room_room_access_section_restricted_option_description">"在 %1$s 中的任何人都可以加入。"</string>
<string name="screen_create_room_room_access_section_restricted_option_title">"標準"</string>
@@ -11,6 +11,7 @@ package io.element.android.features.enterprise.api
import androidx.compose.ui.graphics.Color
import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
@@ -36,18 +37,21 @@ interface EnterpriseService {
suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher): String
/**
* Returns the list of homeservers the user is allowed to sign in to.
*
* If the list is empty or contains the special value [ANY_ACCOUNT_PROVIDER], the user is allowed to sign in to any homeserver.
* Returns the list of account provider the user is allowed to sign in to.
*/
fun homeserverAllowList(): List<String>
fun accountProviderAllowList(): List<AccountProvider>
/**
* Whether the user is allowed to sign in to a given homeserver, according to [homeserverAllowList].
*
* @param homeserverUrl the server the user is trying to use.
* Whether the user is allowed to sign in to any account provider.
*/
suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean
fun canConnectToAnyAccountProvider(): Boolean
/**
* Whether the user is allowed to sign in to a given homeserver, according to [accountProviderAllowList].
*
* @param accountProvider the account provider the user is trying to use.
*/
suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean
/**
* Whether the given homeserver enforces the use of Element Pro or a derived app.
@@ -97,14 +101,4 @@ interface EnterpriseService {
* @param sessionId the session whose channel is requested.
*/
fun getNoisyNotificationChannelId(sessionId: SessionId): String?
companion object {
const val ANY_ACCOUNT_PROVIDER = "*"
}
}
fun EnterpriseService.canConnectToAnyHomeserver(): Boolean {
return homeserverAllowList().let {
it.isEmpty() || it.contains(EnterpriseService.ANY_ACCOUNT_PROVIDER)
}
}
@@ -19,6 +19,7 @@ import io.element.android.libraries.core.extensions.mapCatchingExceptions
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.TemporaryMatrixClientFactory
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flowOf
@@ -33,8 +34,9 @@ class DefaultEnterpriseService(
) : EnterpriseService {
override suspend fun isEnterpriseUser(sessionId: SessionId) = false
override suspend fun tweakMasUrl(url: String, urlContentFetcher: ClientUrlContentFetcher) = url
override fun homeserverAllowList(): List<String> = emptyList()
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String) = true
override fun accountProviderAllowList(): List<AccountProvider> = emptyList()
override fun canConnectToAnyAccountProvider(): Boolean = true
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = true
override suspend fun isElementProEnforced(serverName: String): Boolean {
val temporaryMatrixClient = temporaryMatrixClientFactory.create(serverName).getOrElse { return false }
return temporaryMatrixClient.use { client ->
@@ -18,6 +18,7 @@ import io.element.android.libraries.matrix.test.A_HOMESERVER_URL
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClient
import io.element.android.libraries.matrix.test.FakeTemporaryMatrixClientFactory
import io.element.android.libraries.matrix.test.accountprovider.anAccountProviderGeneric
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.test.runTest
@@ -26,15 +27,21 @@ import org.junit.Test
class DefaultEnterpriseServiceTest {
@Test
fun homeserverWhitelist() {
fun accountProviderAllowList() {
val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.homeserverAllowList()).isEmpty()
assertThat(defaultEnterpriseService.accountProviderAllowList()).isEmpty()
}
@Test
fun `isAllowedToConnectToHomeserver is true for all homeserver urls`() = runTest {
fun `canConnectToAnyAccountProvider is always true`() {
val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToHomeserver(A_HOMESERVER_URL)).isTrue()
assertThat(defaultEnterpriseService.canConnectToAnyAccountProvider()).isTrue()
}
@Test
fun `isAllowedToConnectToAccountProvider is true for all account providers`() = runTest {
val defaultEnterpriseService = createDefaultEnterpriseService()
assertThat(defaultEnterpriseService.isAllowedToConnectToAccountProvider(anAccountProviderGeneric())).isTrue()
}
@Test
@@ -13,6 +13,7 @@ import io.element.android.compound.colors.SemanticColorsLightDark
import io.element.android.features.enterprise.api.BugReportUrl
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.libraries.matrix.api.ClientUrlContentFetcher
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.simulateLongTask
@@ -22,8 +23,9 @@ import kotlinx.coroutines.flow.asStateFlow
class FakeEnterpriseService(
private val isEnterpriseUserResult: (SessionId) -> Boolean = { lambdaError() },
private val defaultHomeserverListResult: () -> List<String> = { emptyList() },
private val isAllowedToConnectToHomeserverResult: (String) -> Boolean = { lambdaError() },
private val accountProviderAllowListResult: () -> List<AccountProvider> = { emptyList() },
private val canConnectToAnyAccountProviderResult: () -> Boolean = { true },
private val isAllowedToConnectToAccountProviderResult: (AccountProvider) -> Boolean = { lambdaError() },
initialSemanticColors: SemanticColorsLightDark = SemanticColorsLightDark.default,
initialBrandColor: Color? = null,
private val overrideBrandColorResult: (SessionId?, String?) -> Unit = { _, _ -> lambdaError() },
@@ -44,12 +46,16 @@ class FakeEnterpriseService(
tweakMasUrlResult(url, urlContentFetcher)
}
override fun homeserverAllowList(): List<String> {
return defaultHomeserverListResult()
override fun accountProviderAllowList(): List<AccountProvider> {
return accountProviderAllowListResult()
}
override suspend fun isAllowedToConnectToHomeserver(homeserverUrl: String): Boolean = simulateLongTask {
isAllowedToConnectToHomeserverResult(homeserverUrl)
override fun canConnectToAnyAccountProvider(): Boolean {
return canConnectToAnyAccountProviderResult()
}
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean = simulateLongTask {
isAllowedToConnectToAccountProviderResult(accountProvider)
}
override suspend fun isElementProEnforced(serverName: String): Boolean = simulateLongTask {
@@ -32,11 +32,13 @@ import io.element.android.libraries.architecture.BackstackView
import io.element.android.libraries.architecture.BaseFlowNode
import io.element.android.libraries.architecture.createNode
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.ui.common.nodes.EmptyState
import io.element.android.libraries.ui.common.nodes.emptyNode
import kotlinx.coroutines.flow.filterIsInstance
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.parcelize.Parcelize
import kotlin.time.Duration.Companion.milliseconds
@ContributesNode(SessionScope::class)
@AssistedInject
@@ -84,7 +86,10 @@ class FtueFlowNode(
override fun resolve(navTarget: NavTarget, buildContext: BuildContext): Node {
return when (navTarget) {
NavTarget.Placeholder -> {
emptyNode(buildContext)
// Rendered while waiting for the initial state (see FtueStep.WaitingForInitialState).
// This can last for a while, or forever if the sync service never manages to run, so
// render a loading state rather than nothing at all.
emptyNode(buildContext, EmptyState(delayBeforeShowingContent = 500.milliseconds))
}
is NavTarget.SessionVerification -> {
val callback = object : FtueSessionVerificationFlowNode.Callback {
+3 -2
View File
@@ -12,6 +12,7 @@ import extension.testCommonDependencies
plugins {
id("io.element.android-compose-library")
id("kotlin-parcelize")
alias(libs.plugins.kotlin.serialization)
}
android {
@@ -31,6 +32,8 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.androidutils)
implementation(projects.libraries.architecture)
implementation(projects.libraries.encryptedDb)
implementation(libs.serialization.json)
implementation(projects.libraries.featureflag.api)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
@@ -55,8 +58,6 @@ dependencies {
implementation(projects.features.rageshake.api)
implementation(projects.services.analytics.api)
implementation(libs.androidx.datastore.preferences)
implementation(libs.haze)
implementation(libs.haze.materials)
implementation(projects.features.preferences.impl)
implementation(projects.features.reportroom.api)
implementation(projects.features.rolesandpermissions.api)
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
userId = UserId(it.userId),
displayName = it.userDisplayName,
avatarUrl = it.userAvatarUrl,
avatarThumbnail = it.userAvatarData,
)
}
}
@@ -6,8 +6,6 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalHazeMaterialsApi::class)
package io.element.android.features.home.impl
import androidx.activity.compose.BackHandler
@@ -40,11 +38,6 @@ import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import androidx.compose.ui.zIndex
import dev.chrisbanes.haze.hazeEffect
import dev.chrisbanes.haze.hazeSource
import dev.chrisbanes.haze.materials.ExperimentalHazeMaterialsApi
import dev.chrisbanes.haze.materials.HazeMaterials
import dev.chrisbanes.haze.rememberHazeState
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.home.impl.components.HomeTopBar
@@ -190,7 +183,6 @@ private fun HomeScaffold(
}
}
val hazeState = rememberHazeState()
val roomsLazyListState = rememberLazyListState()
val spacesLazyListState = rememberLazyListState()
@@ -223,10 +215,6 @@ private fun HomeScaffold(
filtersState = roomListState.filtersState,
spaceFiltersState = roomListState.spaceFiltersState,
canReportBug = state.canReportBug,
modifier = Modifier.hazeEffect(
state = hazeState,
style = HazeMaterials.thick(),
)
)
},
floatingActionButton = {
@@ -297,7 +285,6 @@ private fun HomeScaffold(
modifier = Modifier
.padding(outerPadding)
.consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState)
)
SpaceFiltersView(roomListState.spaceFiltersState)
}
@@ -306,8 +293,7 @@ private fun HomeScaffold(
modifier = Modifier
.fillMaxSize()
.padding(outerPadding)
.consumeWindowInsets(outerPadding)
.hazeSource(state = hazeState),
.consumeWindowInsets(outerPadding),
contentPadding = lazyColumnContentPadding + contentPadding,
state = state.homeSpacesState,
lazyListState = spacesLazyListState,
@@ -345,7 +345,7 @@ private fun MessagePreviewAndIndicatorRow(
// Call and unread
Row(
modifier = Modifier
.height(16.dp)
.heightIn(min = 16.dp)
// Used to force this line to be read aloud earlier than the latest event when using Talkback
.zIndex(-1f),
horizontalArrangement = Arrangement.spacedBy(8.dp),
@@ -358,21 +358,18 @@ private fun MessagePreviewAndIndicatorRow(
isAudio = room.activeCallIntent == CallIntent.AUDIO
)
}
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
val isMuted = room.userDefinedNotificationMode == RoomNotificationMode.MUTE
if (isMuted) {
NotificationOffIndicatorAtom()
} else if (room.numberOfUnreadMentions > 0) {
MentionIndicatorAtom()
}
if (room.hasNewContent) {
val contentDescription = stringResource(CommonStrings.a11y_notifications_new_messages)
val count = if (showUnreadCount) {
if (room.userDefinedNotificationMode == RoomNotificationMode.MUTE) {
room.numberOfUnreadMessages
} else {
room.numberOfUnreadNotifications
}
} else {
null
val count = when {
showUnreadCount && !isMuted && room.numberOfUnreadNotifications > 0 -> room.numberOfUnreadNotifications
!showUnreadCount -> 0
else -> null
}
UnreadIndicatorAtom(
color = tint,
@@ -457,5 +454,6 @@ internal fun RoomSummaryRowPreview(@PreviewParameter(RoomListRoomSummaryPreviewP
isInviteSeen = data.name == "Bob",
onClick = {},
eventSink = {},
showUnreadCount = data.numberOfUnreadNotifications > 0,
)
}
@@ -82,6 +82,24 @@ open class RoomListRoomSummaryPreviewParam : PreviewParameterProvider<RoomListRo
numberOfUnreadMentions = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 1,
hasRoomCall = hasCall,
),
aRoomListRoomSummary(
name = roomNotificationMode.name,
latestEvent = LatestEvent.Synced("New notifications" + if (hasCall) ", call" else ""),
notificationMode = roomNotificationMode,
numberOfUnreadMessages = 0,
numberOfUnreadMentions = 0,
numberOfUnreadNotifications = 100,
hasRoomCall = hasCall,
),
)
}.flatten()
}.flatten(),
@@ -7,9 +7,27 @@
package io.element.android.features.home.impl.search
import io.element.android.libraries.matrix.api.core.RoomId
sealed interface GlobalSearchEvent {
/** The user cleared the search query. */
object ClearQuery : GlobalSearchEvent
/** The search screen visibility changed (e.g. the user opened or closed the search screen). */
object ToggleSearchVisibility : GlobalSearchEvent
/** The visible range for the displayed search results changed. */
data class UpdateVisibleRange(val range: IntRange) : GlobalSearchEvent
/** The user changed the search target (e.g. from "Rooms" to "Messages"). */
data class UpdateTarget(val target: GlobalSearchTarget) : GlobalSearchEvent
/** The user triggered the keyboard search action (enter key): save the current query to the search history. */
object SaveQueryToHistory : GlobalSearchEvent
/** The user opened a room from the search results: save it to the search history. */
data class SaveRoomToHistory(val roomId: RoomId) : GlobalSearchEvent
/** The user selected a search result from the search history. */
data class SearchHistoryResultSelected(val resultItem: SearchHistoryResultItem) : GlobalSearchEvent
}
@@ -18,6 +18,8 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.features.home.impl.search.history.SearchHistoryResult
import io.element.android.features.home.impl.search.history.SearchHistoryStore
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
@@ -47,6 +49,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.isMediaContent
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.toPlainText
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.async
@@ -54,10 +57,8 @@ import kotlinx.coroutines.awaitAll
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import kotlin.jvm.optionals.getOrElse
import kotlin.time.Duration.Companion.milliseconds
@Inject
@@ -71,6 +72,7 @@ class GlobalSearchPresenter(
private val permalinkParser: PermalinkParser,
private val coroutineDispatchers: CoroutineDispatchers,
private val matrixClient: MatrixClient,
private val searchHistoryStore: SearchHistoryStore,
) : Presenter<GlobalSearchState> {
@Composable
override fun present(): GlobalSearchState {
@@ -85,6 +87,21 @@ class GlobalSearchPresenter(
var currentTarget: GlobalSearchTarget by remember { mutableStateOf(GlobalSearchTarget.ROOMS) }
val currentMessageSearch: MessageSearch = remember { messageSearchService.createMessageSearch(scope = coroutineScope) }
val searchHistory by produceState<AsyncData<ImmutableList<SearchHistoryResultItem>>>(initialValue = AsyncData.Uninitialized) {
searchHistoryStore.history.collectLatest { history ->
val mappedHistoryItems = history.mapNotNull { result ->
when (result) {
is SearchHistoryResult.Query -> SearchHistoryResultItem.Query(term = result.term)
is SearchHistoryResult.Room -> {
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@mapNotNull null
SearchHistoryResultItem.Room(roomId = result.roomId, roomInfo = roomInfo)
}
}
}
value = AsyncData.Success(mappedHistoryItems.toImmutableList())
}
}
LaunchedEffect(queryState.text) {
// Add a delay to avoid performing too many searches in a short period of time, which can be expensive
delay(200.milliseconds)
@@ -141,7 +158,7 @@ class GlobalSearchPresenter(
// result, which can be slow if we have a lot of results. The original order is kept by using `awaitAll()`.
val mappedResults = results.map { result ->
async(coroutineDispatchers.computation) {
val roomInfo = matrixClient.getRoomInfoFlow(result.roomId).first().getOrElse { return@async null }
val roomInfo = matrixClient.getRoomInfo(result.roomId).getOrNull() ?: return@async null
val formattedTimestamp = dateFormatter.format(
timestamp = result.timestamp,
mode = DateFormatterMode.TimeOrDate,
@@ -180,7 +197,8 @@ class GlobalSearchPresenter(
fun handleEvent(event: GlobalSearchEvent) {
when (event) {
GlobalSearchEvent.ClearQuery -> {
GlobalSearchEvent.ClearQuery -> coroutineScope.launch {
searchResults = AsyncData.Uninitialized
queryState.clearText()
}
GlobalSearchEvent.ToggleSearchVisibility -> {
@@ -205,6 +223,30 @@ class GlobalSearchPresenter(
}
}
is GlobalSearchEvent.UpdateTarget -> currentTarget = event.target
GlobalSearchEvent.SaveQueryToHistory -> {
val term = queryState.text.toString()
if (term.isNotBlank()) {
coroutineScope.launch {
searchHistoryStore.add(SearchHistoryResult.Query(term))
}
}
}
is GlobalSearchEvent.SaveRoomToHistory -> coroutineScope.launch {
searchHistoryStore.add(SearchHistoryResult.Room(event.roomId))
}
is GlobalSearchEvent.SearchHistoryResultSelected -> {
when (event.resultItem) {
is SearchHistoryResultItem.Query -> coroutineScope.launch {
delay(300.milliseconds)
queryState.edit {
replace(0, length, event.resultItem.term)
}
searchResults = AsyncData.Loading()
currentTarget = GlobalSearchTarget.ROOMS
}
is SearchHistoryResultItem.Room -> Unit
}
}
}
}
@@ -214,6 +256,7 @@ class GlobalSearchPresenter(
queryState = queryState,
currentTarget = currentTarget,
results = searchResults,
history = searchHistory,
eventSink = ::handleEvent,
)
}
@@ -28,6 +28,7 @@ data class GlobalSearchState(
val queryState: TextFieldState,
val currentTarget: GlobalSearchTarget,
val results: AsyncData<GlobalSearchResults>,
val history: AsyncData<ImmutableList<SearchHistoryResultItem>>,
val eventSink: (GlobalSearchEvent) -> Unit
)
@@ -81,6 +82,20 @@ sealed interface MessageSearchResultItem {
}
}
sealed interface SearchHistoryResultItem {
val id: String
data class Query(val term: String) : SearchHistoryResultItem {
override val id: String = "query:$term"
}
data class Room(
val roomId: RoomId,
val roomInfo: RoomInfo,
) : SearchHistoryResultItem {
override val id: String = "room:${roomId.value}"
}
}
data class MediaSearchResultContent(
val filename: String,
val extension: String?,
@@ -36,77 +36,139 @@ import io.element.android.libraries.matrix.api.timeline.item.event.TextMessageTy
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailType
import io.element.android.libraries.matrix.ui.messages.reply.aProfileDetailsReady
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.persistentMapOf
import kotlinx.collections.immutable.toImmutableList
import kotlinx.collections.immutable.toPersistentList
private val tombstonedRoomId = RoomId("!tombstone:server.org")
private val roomId1 = RoomId("!roomId:server.org")
private val roomId2 = RoomId("!roomId2:server.org")
private val dmRoomId = RoomId("!dm:server.org")
private const val AN_AVATAR_URL = "https://example.com/avatar.png"
private val aCanonicalAlias = RoomAlias("#alias:server.org")
class GlobalSearchStatePreviewParam : PreviewParameterProvider<GlobalSearchState> {
override val values: Sequence<GlobalSearchState>
get() = sequenceOf(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
get() {
return sequenceOf(
// Not enabled, no preview
aGlobalSearchState(isEnabled = false),
aGlobalSearchState(isSearchActive = true, queryState = TextFieldState("Query")),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Loading(),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
results = aRoomListRoomSummaryList().mapIndexed { index, summary ->
summary.copy(name = "Room with Query #${index + 1}")
}.toPersistentList()
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(
persistentListOf(
SearchHistoryResultItem.Query("Query 1"),
SearchHistoryResultItem.Query("Query 2"),
SearchHistoryResultItem.Room(
roomId = roomId1,
roomInfo = aRoomInfo(
id = roomId1,
name = "A room with alias",
avatarUrl = AN_AVATAR_URL,
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
roomId = roomId2,
roomInfo = aRoomInfo(
id = roomId2,
name = "A room",
avatarUrl = AN_AVATAR_URL,
)
),
SearchHistoryResultItem.Room(
roomId = dmRoomId,
roomInfo = aRoomInfo(
id = dmRoomId,
name = "A DM",
isDm = true,
heroes = listOf(MatrixUser(UserId("@user:server.org"), "User", AN_AVATAR_URL)),
avatarUrl = AN_AVATAR_URL,
canonicalAlias = aCanonicalAlias,
)
),
SearchHistoryResultItem.Room(
roomId = tombstonedRoomId,
roomInfo = aRoomInfo(
id = tombstonedRoomId,
name = "A tombstoned room",
avatarUrl = AN_AVATAR_URL,
successorRoom = SuccessorRoom(
roomId = RoomId("!successorRoom:server.org"),
reason = null,
),
),
),
)
)
),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId1:server.org")),
body = "A message with Query",
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
MessageSearchResultItem.Media(
messageSearchResult = aMessageSearchResult(eventId = EventId("\$eventId2:server.org")),
mediaContent = MediaSearchResultContent(
filename = "file.png",
extension = "PNG",
caption = "A caption containing Query",
formattedSize = "1 MB",
thumbnailSource = MediaSource("https://example.com/thumbnail.png"),
thumbnailType = AttachmentThumbnailType.Image,
blurhash = null,
),
roomInfo = aRoomInfo(),
formattedTimestamp = "12:00",
),
))),
),
aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.MessageSearchResults(persistentListOf())),
),
)
)
}
}
fun aRoomInfo(
id: RoomId = RoomId("!roomId:server.org"),
id: RoomId = roomId1,
name: String? = "A room",
rawName: String? = "A room raw name",
topic: String? = "A room topic",
avatarUrl: String? = "https://example.com/avatar.png",
avatarUrl: String? = AN_AVATAR_URL,
isPublic: Boolean = true,
isDirect: Boolean = false,
isEncrypted: Boolean = false,
@@ -217,7 +279,7 @@ fun aMessageContent(
)
internal fun aMessageSearchResult(
roomId: RoomId = RoomId("!roomId:server.org"),
roomId: RoomId = roomId1,
eventId: EventId = EventId("\$eventId:server.org"),
senderId: UserId = UserId("@user:server.org"),
senderProfile: ProfileDetails = aProfileDetailsReady(),
@@ -238,6 +300,7 @@ internal fun aGlobalSearchState(
queryState: TextFieldState = TextFieldState(),
currentTarget: GlobalSearchTarget = GlobalSearchTarget.ROOMS,
results: AsyncData<GlobalSearchResults> = AsyncData.Uninitialized,
history: AsyncData<ImmutableList<SearchHistoryResultItem>> = AsyncData.Uninitialized,
eventSink: (GlobalSearchEvent) -> Unit = {},
) = GlobalSearchState(
isEnabled = isEnabled,
@@ -245,5 +308,6 @@ internal fun aGlobalSearchState(
queryState = queryState,
currentTarget = currentTarget,
results = results,
history = history,
eventSink = eventSink
)
@@ -23,11 +23,15 @@ import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.width
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.LazyListScope
import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.itemsIndexed
import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.foundation.text.input.KeyboardActionHandler
import androidx.compose.foundation.text.input.TextFieldLineLimits
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.IconButtonDefaults
import androidx.compose.material3.SingleChoiceSegmentedButtonRow
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.runtime.Composable
@@ -44,6 +48,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.input.ImeAction
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -63,6 +68,7 @@ import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.roundToPx
import io.element.android.libraries.designsystem.theme.components.FilledTextField
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.IconButton
import io.element.android.libraries.designsystem.theme.components.LinearProgressIndicator
@@ -80,6 +86,7 @@ import io.element.android.libraries.matrix.ui.components.AttachmentThumbnailInfo
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
@Composable
@@ -103,7 +110,10 @@ fun GlobalSearchView(
onBackButtonClick = {
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
},
onSelectSearchResult = onSelectSearchResult,
onSelectSearchResult = { roomId, eventId ->
onSelectSearchResult(roomId, eventId)
state.eventSink(GlobalSearchEvent.SaveRoomToHistory(roomId))
},
)
}
}
@@ -115,63 +125,11 @@ private fun GlobalSearchContent(
onBackButtonClick: () -> Unit,
onSelectSearchResult: (RoomId, EventId?) -> Unit,
) {
val borderColor = MaterialTheme.colorScheme.tertiary
val strokeWidth = 1.dp
val drawBottomLineModifier = Modifier.drawBehind {
drawLine(
color = borderColor,
start = Offset(0f, size.height),
end = Offset(size.width, size.height),
strokeWidth = strokeWidth.value
)
}
Scaffold(
topBar = {
TopAppBar(
navigationIcon = { BackButton(onClick = onBackButtonClick) },
title = {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine,
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
IconButton(onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) }) {
Icon(
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field)
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
title = { SearchTopAppBarContent(state = state) }
)
},
contentWindowInsets = scaffoldScrollableContentInsets,
@@ -179,27 +137,9 @@ private fun GlobalSearchContent(
Column(
modifier = Modifier.padding(padding),
) {
SingleChoiceSegmentedButtonRow(
modifier = drawBottomLineModifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 8.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
HorizontalDivider(color = ElementTheme.colors.borderInteractivePrimary)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
SearchTargetSelector(state = state)
val lazyListState = rememberLazyListState()
OnVisibleRangeChangeEffect(lazyListState) { visibleRange ->
@@ -212,9 +152,21 @@ private fun GlobalSearchContent(
state = lazyListState,
) {
val results = state.results.dataOrNull()
val historyResults = state.history.dataOrNull()?.toImmutableList() ?: persistentListOf()
when {
state.results.isUninitialized() -> startSearching()
state.results.isLoading() -> loading()
state.results.isUninitialized() -> if (state.queryState.text.isBlank() && historyResults.isNotEmpty()) {
searchHistory(historyResults) { result ->
when (result) {
is SearchHistoryResultItem.Query -> Unit
is SearchHistoryResultItem.Room -> onSelectSearchResult(result.roomInfo.id, null)
}
state.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(result))
}
} else {
startSearching()
}
state.results.isLoading() -> loadingResults()
results?.isEmpty() == true -> emptySearchResults(query = state.queryState.text.toString())
results is GlobalSearchResults.RoomListResults -> roomListResults(
results = results.results,
@@ -230,6 +182,89 @@ private fun GlobalSearchContent(
}
}
@Composable
private fun SearchTopAppBarContent(state: GlobalSearchState) {
// The stateSaver will keep the selection state when returning to this UI
val focusRequester = remember { FocusRequester() }
val searchLabel = stringResource(CommonStrings.action_search)
FilledTextField(
modifier = Modifier
.fillMaxWidth()
.focusRequester(focusRequester)
.semantics { contentDescription = searchLabel },
state = state.queryState,
lineLimits = TextFieldLineLimits.SingleLine,
keyboardOptions = KeyboardOptions(imeAction = ImeAction.Search),
keyboardActions = KeyboardActionHandler { performDefault ->
state.eventSink(GlobalSearchEvent.SaveQueryToHistory)
performDefault()
},
colors = TextFieldDefaults.colors(
focusedContainerColor = Color.Transparent,
unfocusedContainerColor = Color.Transparent,
disabledContainerColor = Color.Transparent,
focusedIndicatorColor = Color.Transparent,
unfocusedIndicatorColor = Color.Transparent,
disabledIndicatorColor = Color.Transparent,
errorIndicatorColor = Color.Transparent,
),
trailingIcon = if (state.queryState.text.isNotEmpty()) {
@Composable {
IconButton(
modifier = Modifier.clip(CircleShape).size(20.dp),
onClick = { state.eventSink(GlobalSearchEvent.ClearQuery) },
colors = IconButtonDefaults.iconButtonColors(
containerColor = ElementTheme.colors.borderInteractivePrimary,
contentColor = ElementTheme.colors.iconOnSolidPrimary
)
) {
Icon(
modifier = Modifier.size(16.dp),
imageVector = CompoundIcons.Close(),
contentDescription = stringResource(CommonStrings.a11y_clear_search_field),
)
}
}
} else {
null
},
)
LaunchedEffect(Unit) {
if (!focusRequester.restoreFocusedChild()) {
focusRequester.requestFocus()
}
focusRequester.saveFocusedChild()
}
}
@Composable
private fun SearchTargetSelector(state: GlobalSearchState) {
if (state.queryState.text.isEmpty()) return
SingleChoiceSegmentedButtonRow(
modifier = Modifier
.fillMaxWidth()
.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp)
) {
SegmentedButton(
index = 0,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.ROOMS,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS)) },
text = stringResource(R.string.search_section_chats),
)
SegmentedButton(
index = 1,
count = 2,
selected = state.currentTarget == GlobalSearchTarget.MESSAGES,
onClick = { state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES)) },
text = stringResource(R.string.search_section_messages),
)
}
}
private fun LazyListScope.startSearching() {
item {
IconTitleSubtitleMolecule(
@@ -243,7 +278,98 @@ private fun LazyListScope.startSearching() {
}
}
private fun LazyListScope.loading() {
private fun LazyListScope.searchHistory(
historyResults: ImmutableList<SearchHistoryResultItem>,
onSelectSearchHistoryResult: (SearchHistoryResultItem) -> Unit,
) {
item {
Text(
modifier = Modifier.padding(start = 16.dp, end = 16.dp, top = 16.dp, bottom = 8.dp),
text = stringResource(R.string.screen_search_recent_searches_section_title),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
}
items(
items = historyResults,
key = { it.id },
) { result ->
Row(
modifier = Modifier
.niceClickable(onClick = { onSelectSearchHistoryResult(result) })
.fillMaxWidth()
.padding(horizontal = 16.dp, vertical = 12.dp),
horizontalArrangement = Arrangement.spacedBy(12.dp),
verticalAlignment = Alignment.CenterVertically,
) {
when (result) {
is SearchHistoryResultItem.Query -> {
Icon(
modifier = Modifier.size(32.dp)
.clip(RoundedCornerShape(8.dp))
.background(ElementTheme.colors.bgSubtleSecondary)
.padding(6.dp),
imageVector = CompoundIcons.History(),
contentDescription = null,
tint = ElementTheme.colors.iconPrimary,
)
Text(
text = result.term,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
)
}
is SearchHistoryResultItem.Room -> {
val heroAvatars = remember(result.roomInfo.heroes) {
result.roomInfo.heroes.map { it.getAvatarData(AvatarSize.SearchRoomListItem) }.toImmutableList()
}
Avatar(
avatarData = result.roomInfo.getAvatarData(AvatarSize.SearchRoomListItem),
avatarType = AvatarType.Room(
heroes = heroAvatars,
isTombstoned = result.roomInfo.successorRoom != null,
),
)
Column(modifier = Modifier.fillMaxWidth()) {
Text(
text = result.roomInfo.name.orEmpty(),
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textPrimary,
)
if (result.roomInfo.isDm) {
val dmUserId = result.roomInfo.heroes.firstOrNull()?.userId
dmUserId?.let { userId ->
Text(
text = userId.value,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
} else if (result.roomInfo.canonicalAlias != null) {
Text(
text = result.roomInfo.canonicalAlias?.value!!,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
}
}
}
private fun LazyListScope.loadingResults() {
item {
LinearProgressIndicator(modifier = Modifier.fillMaxWidth())
}
@@ -0,0 +1,47 @@
/*
* 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.search.history
import android.content.Context
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.di.annotations.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.encrypteddb.crypto.EncryptedFile
import java.io.File
private const val SEARCH_HISTORY_FILE_NAME = "search_history"
/**
* Persists the search history in an encrypted file inside the current session file directory, using
* [EncryptedFile] (Tink StreamingAead + a master key stored in the Android Keystore).
*/
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryFileStore(
@ApplicationContext private val context: Context,
matrixClient: MatrixClient,
) : SearchHistoryFileStore {
private val file: File by lazy {
File(matrixClient.sessionPaths.fileDirectory, SEARCH_HISTORY_FILE_NAME)
}
override fun read(): ByteArray? {
if (!file.exists()) return null
return EncryptedFile(context, file).openFileInput().use { it.readBytes() }
}
override fun write(bytes: ByteArray) {
file.parentFile?.takeIf { !it.exists() }?.mkdirs()
// Opening a FileOutputStream truncates any previous content, so this overwrites the file.
EncryptedFile(context, file).openFileOutput().use { it.write(bytes) }
}
override fun delete(): Boolean {
return file.delete()
}
}
@@ -0,0 +1,96 @@
/*
* 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.search.history
import dev.zacsweers.metro.ContributesBinding
import io.element.android.libraries.androidutils.json.JsonProvider
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.extensions.runCatchingExceptions
import io.element.android.libraries.di.SessionScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.emitAll
import kotlinx.coroutines.flow.flow
import kotlinx.coroutines.sync.Mutex
import kotlinx.coroutines.sync.withLock
import kotlinx.coroutines.withContext
import timber.log.Timber
@ContributesBinding(SessionScope::class)
class DefaultSearchHistoryStore(
private val fileStore: SearchHistoryFileStore,
private val dispatchers: CoroutineDispatchers,
private val jsonProvider: JsonProvider,
) : SearchHistoryStore {
private val json by lazy { jsonProvider() }
// Guards [fileStore] access and the lazy initialization of [cache].
private val mutex = Mutex()
private val cache = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
private var isLoaded = false
override val history: Flow<List<SearchHistoryResult>> = flow {
ensureLoaded()
emitAll(cache)
}
override suspend fun add(result: SearchHistoryResult) = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
val updated = buildList {
add(result)
// Deduplicate: an equal entry is moved to the front rather than being duplicated.
addAll(cache.value.filterNot { it == result })
}
.distinct()
.take(MAX_SEARCH_HISTORY_SIZE)
cache.value = updated
persist(updated)
}
}
override suspend fun clear() = withContext<Unit>(dispatchers.io) {
mutex.withLock {
isLoaded = true
cache.value = emptyList()
runCatchingExceptions { fileStore.delete() }
.onFailure { Timber.e(it, "Failed to delete search history") }
}
}
private suspend fun ensureLoaded() = withContext(dispatchers.io) {
mutex.withLock {
ensureLoadedLocked()
}
}
/**
* Must be called while holding [mutex].
*/
private fun ensureLoadedLocked() {
if (isLoaded) return
cache.value = readFromDisk()
isLoaded = true
}
private fun readFromDisk(): List<SearchHistoryResult> {
return runCatchingExceptions {
val bytes = fileStore.read() ?: return@runCatchingExceptions emptyList()
json.decodeFromString<List<SearchHistoryResult>>(bytes.decodeToString())
}.getOrElse {
Timber.e(it, "Failed to read search history, resetting it")
emptyList()
}
}
private fun persist(items: List<SearchHistoryResult>) {
runCatchingExceptions {
fileStore.write(json.encodeToString(items).encodeToByteArray())
}.onFailure { Timber.e(it, "Failed to persist search history") }
}
}
@@ -0,0 +1,34 @@
/*
* 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.search.history
/**
* Low level, raw byte read/write of the persisted search history.
*
* This exists as a seam so that [DefaultSearchHistoryStore] can be unit tested without relying on
* the Android Keystore. The production implementation ([DefaultSearchHistoryFileStore]) stores the
* bytes in an encrypted file.
*/
interface SearchHistoryFileStore {
/**
* Return the persisted bytes, or `null` if nothing has been persisted yet.
*/
fun read(): ByteArray?
/**
* Persist [bytes], replacing any previously persisted content.
*/
fun write(bytes: ByteArray)
/**
* Delete any persisted content.
*
* @return `true` if the persisted content was deleted, `false` if there was no persisted content to delete.
*/
fun delete(): Boolean
}
@@ -0,0 +1,26 @@
/*
* 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.search.history
import io.element.android.libraries.matrix.api.core.RoomId
import kotlinx.serialization.Serializable
/**
* A single entry in the global search history.
*
* The history stores either the term used to perform a search (when the keyboard search action is
* triggered) or the id of a room opened from the search results.
*/
@Serializable
sealed interface SearchHistoryResult {
@Serializable
data class Query(val term: String) : SearchHistoryResult
@Serializable
data class Room(val roomId: RoomId) : SearchHistoryResult
}
@@ -0,0 +1,42 @@
/*
* 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.search.history
import kotlinx.coroutines.flow.Flow
/**
* Stores the global search history for the current session.
*
* The history is persisted in an encrypted file and capped to [MAX_SEARCH_HISTORY_SIZE] entries.
* The most recent entry is always at the front of the list; when the cap is reached the oldest
* entries are dropped.
*/
interface SearchHistoryStore {
/**
* Emits the current search history, most recent entry first, and re-emits whenever it changes.
*/
val history: Flow<List<SearchHistoryResult>>
/**
* Add a new [result] at the front of the history.
*
* If an equal entry already exists it is moved to the front instead of being duplicated. When
* the history exceeds [MAX_SEARCH_HISTORY_SIZE] entries the oldest ones are dropped.
*/
suspend fun add(result: SearchHistoryResult)
/**
* Remove every entry from the history.
*/
suspend fun clear()
}
/**
* Maximum number of entries kept in the search history.
*/
const val MAX_SEARCH_HISTORY_SIZE = 25
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"Vaše úložiště klíčů není synchronizováno"</string>
<string name="full_screen_intent_banner_message">"Abyste nikdy nezmeškali důležitý hovor, změňte nastavení tak, abyste povolili oznámení na celé obrazovce, když je telefon uzamčen."</string>
<string name="full_screen_intent_banner_title">"Vylepšete si zážitek z volání"</string>
<string name="screen_home_tab_chats">"Všechny chaty"</string>
<string name="screen_home_tab_chats">"Chaty"</string>
<string name="screen_home_tab_spaces">"Prostory"</string>
<string name="screen_invites_decline_chat_message">"Opravdu chcete odmítnout pozvánku do %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Odmítnout pozvání"</string>
@@ -46,7 +46,7 @@ Prozatím můžete zrušit výběr filtrů, abyste viděli své další chaty"</
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulujeme!
Nemáte žádné nepřečtené zprávy!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Žádost o vstup odeslána"</string>
<string name="screen_roomlist_main_space_title">"Všechny chaty"</string>
<string name="screen_roomlist_main_space_title">"Chaty"</string>
<string name="screen_roomlist_mark_as_read">"Označit jako přečtené"</string>
<string name="screen_roomlist_mark_as_unread">"Označit jako nepřečtené"</string>
<string name="screen_roomlist_tombstoned_room_description">"Tato místnost byla aktualizována"</string>
@@ -36,6 +36,8 @@ Deaktiviere den entsprechenden Filter, um deine anderen Chats zu sehen"</string>
<string name="screen_roomlist_filter_invites_empty_state_title">"Du hast keine ausstehenden Einladungen."</string>
<string name="screen_roomlist_filter_low_priority">"Niedrige Priorität"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Du hast noch keine Chats mit niedriger Priorität"</string>
<string name="screen_roomlist_filter_mention">"Erwähnungen"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"Du hast keine ungelesenen Erwähnungen"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Entferne Filter, um Chats zu sehen"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Für diese Auswahl hast du keinen Chat"</string>
<string name="screen_roomlist_filter_people">"Personen"</string>
@@ -36,6 +36,8 @@ Toistaiseksi voit poistaa suodattimien valinnan, jotta näet muut keskustelut."<
<string name="screen_roomlist_filter_invites_empty_state_title">"Sinulla ei ole yhtään odottavaa kutsua."</string>
<string name="screen_roomlist_filter_low_priority">"Matala prioriteetti"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Sinulla ei ole vielä yhtään matalan prioriteetin keskustelua"</string>
<string name="screen_roomlist_filter_mention">"Maininnat"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"Sinulla ei ole lukemattomia mainintoja"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Voit poistaa suodattimien valinnan nähdäksesi muut keskustelusi."</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Sinulla ei ole sopivia keskusteluja tähän valintaan"</string>
<string name="screen_roomlist_filter_people">"Ihmiset"</string>
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"A kulcstároló nincs szinkronizálva"</string>
<string name="full_screen_intent_banner_message">"Hogy sose maradjon le egyetlen fontos hívásról sem, a beállításokban engedélyezze a teljes képernyős értesítéseket, amikor a telefon zárolva van."</string>
<string name="full_screen_intent_banner_title">"Fokozza a hívásélményét"</string>
<string name="screen_home_tab_chats">"Összes csevegés"</string>
<string name="screen_home_tab_chats">"Csevegések"</string>
<string name="screen_home_tab_spaces">"Terek"</string>
<string name="screen_invites_decline_chat_message">"Biztos, hogy elutasítja a meghívást, hogy csatlakozzon ehhez: %1$s?"</string>
<string name="screen_invites_decline_chat_title">"Meghívás elutasítása"</string>
@@ -36,6 +36,8 @@ Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez."</strin
<string name="screen_roomlist_filter_invites_empty_state_title">"Nincsenek függőben lévő meghívásai."</string>
<string name="screen_roomlist_filter_low_priority">"Alacsony prioritás"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"Még nincsenek alacsony prioritású csevegései"</string>
<string name="screen_roomlist_filter_mention">"Megemlítés"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"Nincs olvasatlan említése"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"Kikapcsolhatja a szűrőket a többi csevegés megtekintéséhez"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"Ehhez a kiválasztáshoz nem tartoznak csevegések"</string>
<string name="screen_roomlist_filter_people">"Emberek"</string>
@@ -46,7 +48,7 @@ Egyelőre törölheti a szűrőket a többi csevegés megtekintéséhez."</strin
<string name="screen_roomlist_filter_unreads_empty_state_title">"Gratulálunk!
Nincs olvasatlan üzenete!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Csatlakozási kérés elküldve"</string>
<string name="screen_roomlist_main_space_title">"Összes csevegés"</string>
<string name="screen_roomlist_main_space_title">"Csevegések"</string>
<string name="screen_roomlist_mark_as_read">"Megjelölés olvasottként"</string>
<string name="screen_roomlist_mark_as_unread">"Megjelölés olvasatlanként"</string>
<string name="screen_roomlist_tombstoned_room_description">"A szoba verzióját frissítették"</string>
@@ -36,6 +36,8 @@
<string name="screen_roomlist_filter_invites_empty_state_title">"承認待ちの招待はありません"</string>
<string name="screen_roomlist_filter_low_priority">"低い優先度"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"低い優先度のチャットはまだありません"</string>
<string name="screen_roomlist_filter_mention">"メンション"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"未読のメンションはありません"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"フィルターを解除して他のチャットを表示できます"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"この選択中にチャットがありません"</string>
<string name="screen_roomlist_filter_people">"ユーザー"</string>
@@ -12,7 +12,7 @@
<string name="confirm_recovery_key_banner_title">"Din nyckellagring är inte synkroniserad"</string>
<string name="full_screen_intent_banner_message">"För att säkerställa att du aldrig missar ett viktigt samtal, ändra dina inställningar för att tillåta helskärmsmeddelanden när telefonen är låst."</string>
<string name="full_screen_intent_banner_title">"Förbättra din samtalsupplevelse"</string>
<string name="screen_home_tab_chats">"Alla chattar"</string>
<string name="screen_home_tab_chats">"Chattar"</string>
<string name="screen_home_tab_spaces">"Utrymmen"</string>
<string name="screen_invites_decline_chat_message">"Är du säker på att du vill tacka nej till inbjudan att gå med%1$s?"</string>
<string name="screen_invites_decline_chat_title">"Avböj inbjudan"</string>
@@ -44,7 +44,7 @@ För tillfället kan du avmarkera filter för att se dina andra chattar"</string
<string name="screen_roomlist_filter_unreads_empty_state_title">"Grattis!
Du har inga olästa meddelanden!"</string>
<string name="screen_roomlist_knock_event_sent_description">"Begäran om att gå med skickad"</string>
<string name="screen_roomlist_main_space_title">"Alla chattar"</string>
<string name="screen_roomlist_main_space_title">"Chattar"</string>
<string name="screen_roomlist_mark_as_read">"Markera som läst"</string>
<string name="screen_roomlist_mark_as_unread">"Markera som oläst"</string>
<string name="screen_roomlist_tombstoned_room_description">"Det här rummet har uppgraderats"</string>
@@ -14,7 +14,7 @@
<string name="confirm_recovery_key_banner_title">"您的金鑰儲存空間並未同步"</string>
<string name="full_screen_intent_banner_message">"為確保您永遠不會錯過重要通話,請變更設定以允許在手機鎖定時允許全螢幕通知。"</string>
<string name="full_screen_intent_banner_title">"提升您的通話體驗"</string>
<string name="screen_home_tab_chats">"所有聊天室"</string>
<string name="screen_home_tab_chats">"聊天"</string>
<string name="screen_home_tab_spaces">"空間"</string>
<string name="screen_invites_decline_chat_message">"您確定您想要拒絕加入 %1$s 的邀請嗎?"</string>
<string name="screen_invites_decline_chat_title">"拒絕邀請"</string>
@@ -36,6 +36,8 @@
<string name="screen_roomlist_filter_invites_empty_state_title">"您沒有任何擱置中的邀請。"</string>
<string name="screen_roomlist_filter_low_priority">"低優先度"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"您尚無任何低優先程度聊天"</string>
<string name="screen_roomlist_filter_mention">"提及"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"您沒有任何未讀提及"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"您可以取消選取篩選條件以檢視其他聊天"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"您並無此選擇的聊天"</string>
<string name="screen_roomlist_filter_people">"夥伴"</string>
@@ -46,7 +48,7 @@
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
您沒有任何未讀的訊息!"</string>
<string name="screen_roomlist_knock_event_sent_description">"已傳送加入請求"</string>
<string name="screen_roomlist_main_space_title">"所有聊天室"</string>
<string name="screen_roomlist_main_space_title">"聊天"</string>
<string name="screen_roomlist_mark_as_read">"標為已讀"</string>
<string name="screen_roomlist_mark_as_unread">"標為未讀"</string>
<string name="screen_roomlist_tombstoned_room_description">"此聊天室已升級"</string>
@@ -37,7 +37,7 @@
<string name="screen_roomlist_filter_low_priority">"低优先级"</string>
<string name="screen_roomlist_filter_low_priority_empty_state_title">"你暂无任何低优先级聊天"</string>
<string name="screen_roomlist_filter_mention">"提及"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"你没有任何未读提及"</string>
<string name="screen_roomlist_filter_mention_empty_state_title">"你暂无任何未读提及"</string>
<string name="screen_roomlist_filter_mixed_empty_state_subtitle">"你可以取消选择筛选器以查看其它对话"</string>
<string name="screen_roomlist_filter_mixed_empty_state_title">"你暂无适用于此选项的聊天"</string>
<string name="screen_roomlist_filter_people">"人员"</string>
@@ -46,7 +46,7 @@
<string name="screen_roomlist_filter_rooms_empty_state_title">"你尚未进入任何房间"</string>
<string name="screen_roomlist_filter_unreads">"未读"</string>
<string name="screen_roomlist_filter_unreads_empty_state_title">"恭喜!
没有任何未读消息!"</string>
你暂无任何未读消息!"</string>
<string name="screen_roomlist_knock_event_sent_description">"加入申请已发送"</string>
<string name="screen_roomlist_main_space_title">"聊天"</string>
<string name="screen_roomlist_mark_as_read">"设为已读"</string>
@@ -14,4 +14,5 @@
<string name="search_section_messages">Messages</string>
<string name="search_no_results_title">No results</string>
<string name="search_no_results_subtitle">There are no results for “%1$s”. Try a new search term.</string>
<string name="screen_search_recent_searches_section_title">Recent searches</string>
</resources>
@@ -220,4 +220,30 @@ class CurrentUserWithNeighborsBuilderTest {
)
)
}
@Test
fun `other accounts use the data from db, including the avatar thumbnail`() {
val sut = CurrentUserWithNeighborsBuilder()
val matrixUser = aMatrixUser(id = A_USER_ID.value)
val list = listOf(
aSessionData(
sessionId = A_USER_ID.value,
),
aSessionData(
sessionId = A_USER_ID_2.value,
userDisplayName = "Carol",
userAvatarUrl = "avatarUrl",
userAvatarData = "avatarData",
),
)
val result = sut.build(matrixUser, list)
assertThat(result[0]).isEqualTo(
MatrixUser(
userId = A_USER_ID_2,
displayName = "Carol",
avatarUrl = "avatarUrl",
avatarThumbnail = "avatarData",
)
)
}
}
@@ -81,7 +81,7 @@ class HomePresenterTest {
val presenter = createHomePresenter(
rageshakeFeatureAvailability = { flowOf(true) },
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
)
presenter.test {
@@ -98,7 +98,7 @@ class HomePresenterTest {
val presenter = createHomePresenter(
indicatorService = indicatorService,
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
)
presenter.test {
@@ -120,7 +120,7 @@ class HomePresenterTest {
val presenter = createHomePresenter(
client = matrixClient,
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
)
presenter.test {
@@ -134,7 +134,7 @@ class HomePresenterTest {
fun `present - NavigationBar change`() = runTest {
val presenter = createHomePresenter(
sessionStore = InMemorySessionStore(
updateUserProfileResult = { _, _, _ -> },
updateUserProfileResult = { _, _, _, _ -> },
),
)
presenter.test {
@@ -91,7 +91,6 @@ import org.junit.Rule
import org.junit.Test
import kotlin.time.Duration.Companion.seconds
@Suppress("LargeClass")
class RoomListPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -11,6 +11,9 @@ package io.element.android.features.home.impl.search
import com.google.common.truth.Truth.assertThat
import io.element.android.features.home.impl.datasource.aRoomListRoomSummaryFactory
import io.element.android.features.home.impl.search.history.FakeSearchHistoryStore
import io.element.android.features.home.impl.search.history.SearchHistoryResult
import io.element.android.features.home.impl.search.history.SearchHistoryStore
import io.element.android.libraries.androidutils.filesize.FakeFileSizeFormatter
import io.element.android.libraries.androidutils.filesize.FileSizeFormatter
import io.element.android.libraries.architecture.AsyncData
@@ -28,6 +31,7 @@ import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.Joined
import io.element.android.libraries.matrix.api.roomlist.RoomListFilter.NormalizedMatchRoomName
import io.element.android.libraries.matrix.api.roomlist.RoomListService
import io.element.android.libraries.matrix.api.search.MessageSearchService
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.room.aRoomSummary
@@ -41,12 +45,12 @@ import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.flowOf
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.advanceUntilIdle
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.util.Optional
class GlobalSearchPresenterTest {
@Test
@@ -60,6 +64,8 @@ class GlobalSearchPresenterTest {
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
assertThat(state.results).isEqualTo(AsyncData.Uninitialized)
}
cancelAndConsumeRemainingEvents()
}
}
@@ -85,6 +91,9 @@ class GlobalSearchPresenterTest {
fun `present - toggle search visibility`() = runTest {
val presenter = createGlobalSearchPresenter()
presenter.test {
// Skip the initial state
skipItems(1)
awaitItem().let { state ->
assertThat(state.isSearchActive).isFalse()
state.eventSink(GlobalSearchEvent.ToggleSearchVisibility)
@@ -103,6 +112,9 @@ class GlobalSearchPresenterTest {
fun `present - update target`() = runTest {
val presenter = createGlobalSearchPresenter()
presenter.test {
// Skip initial item
skipItems(1)
awaitItem().let { state ->
assertThat(state.currentTarget).isEqualTo(GlobalSearchTarget.ROOMS)
state.eventSink(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
@@ -194,7 +206,7 @@ class GlobalSearchPresenterTest {
fun `present - message search results are mapped when the message search emits`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch),
@@ -221,7 +233,7 @@ class GlobalSearchPresenterTest {
fun `present - message search displays the uninitialized state after removing the query`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch),
@@ -256,7 +268,7 @@ class GlobalSearchPresenterTest {
fun `present - UpdateVisibleRange triggers pagination for messages when near the end`() = runTest {
val messageSearch = FakeMessageSearch()
val matrixClient = FakeMatrixClient().apply {
getRoomInfoFlowLambda = { flowOf(Optional.of(aRoomInfo())) }
getRoomInfoLambda = { Result.success(aRoomInfo()) }
}
val presenter = createGlobalSearchPresenter(
messageSearchService = FakeMessageSearchService(messageSearch),
@@ -279,6 +291,82 @@ class GlobalSearchPresenterTest {
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveQueryToHistory stores the current query`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.queryState.edit { append("A query") }
testScheduler.advanceUntilIdle()
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Query("A query"))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveQueryToHistory ignores a blank query`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.SaveQueryToHistory)
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).isEmpty()
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SaveRoomToHistory stores the room`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
initialState.eventSink(GlobalSearchEvent.SaveRoomToHistory(A_ROOM_ID))
testScheduler.advanceUntilIdle()
assertThat(searchHistoryStore.history.first()).containsExactly(SearchHistoryResult.Room(A_ROOM_ID))
cancelAndIgnoreRemainingEvents()
}
}
@Test
fun `present - SearchHistoryResultSelected does nothing for a room result`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Room(A_ROOM_ID, aRoomInfo())))
testScheduler.advanceUntilIdle()
ensureAllEventsConsumed()
}
}
@Test
fun `present - SearchHistoryResultSelected changes the search query when a previous search is selected`() = runTest {
val searchHistoryStore = FakeSearchHistoryStore()
val presenter = createGlobalSearchPresenter(searchHistoryStore = searchHistoryStore)
presenter.test {
val initialState = awaitItem()
// Skip loading search results state
skipItems(1)
initialState.eventSink(GlobalSearchEvent.SearchHistoryResultSelected(SearchHistoryResultItem.Query("Test")))
assertThat(awaitItem().queryState.text.toString()).isEqualTo("Test")
assertThat(awaitItem().results.isLoading()).isTrue()
}
}
}
private fun TestScope.createGlobalSearchPresenter(
@@ -290,6 +378,7 @@ private fun TestScope.createGlobalSearchPresenter(
fileSizeFormatter: FileSizeFormatter = FakeFileSizeFormatter(),
permalinkParser: PermalinkParser = FakePermalinkParser(),
matrixClient: MatrixClient = FakeMatrixClient(),
searchHistoryStore: SearchHistoryStore = FakeSearchHistoryStore(),
): GlobalSearchPresenter {
return GlobalSearchPresenter(
roomListSearchDataSourceFactory = object : RoomListSearchDataSource.Factory {
@@ -310,5 +399,6 @@ private fun TestScope.createGlobalSearchPresenter(
permalinkParser = permalinkParser,
coroutineDispatchers = testCoroutineDispatchers(),
matrixClient = matrixClient,
searchHistoryStore = searchHistoryStore,
)
}
@@ -0,0 +1,275 @@
/*
* 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.home.impl.search
import androidx.activity.ComponentActivity
import androidx.compose.foundation.text.input.TextFieldState
import androidx.compose.ui.test.AndroidComposeUiTest
import androidx.compose.ui.test.ExperimentalTestApi
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.onNodeWithContentDescription
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.compose.ui.test.v2.runAndroidComposeUiTest
import io.element.android.features.home.impl.R
import io.element.android.features.home.impl.model.aRoomListRoomSummary
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.tests.testutils.EnsureCalledOnceWithTwoParams
import io.element.android.tests.testutils.EnsureNeverCalledWithTwoParams
import io.element.android.tests.testutils.EventsRecorder
import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.pressBack
import io.element.android.tests.testutils.pressBackKey
import io.element.android.tests.testutils.robolectric.RobolectricTest
import kotlinx.collections.immutable.persistentListOf
import org.junit.Test
class GlobalSearchViewTest : RobolectricTest() {
@Test
fun `back button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBack()
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `back key - with search active toggles the search`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
pressBackKey()
// Advance time to let the event be processed, as the search toggle might have some delay (e.g. for the animation)
mainClock.advanceTimeBy(1)
eventsRecorder.assertSingle(GlobalSearchEvent.ToggleSearchVisibility)
}
@Test
fun `clear query button - emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
val clearSearch = activity!!.getString(CommonStrings.a11y_clear_search_field)
onNodeWithContentDescription(clearSearch).performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.ClearQuery)
}
@Test
fun `search target selector - clicking on 'Chats' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_chats)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.ROOMS))
}
@Test
fun `search target selector - clicking on 'Messages' emits the expected Event`() = runAndroidComposeUiTest {
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
eventSink = eventsRecorder,
),
)
// Remove automatic initial events
eventsRecorder.clear()
clickOn(R.string.search_section_messages)
eventsRecorder.assertSingle(GlobalSearchEvent.UpdateTarget(GlobalSearchTarget.MESSAGES))
}
@Test
fun `clicking on a room result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aRoom:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.RoomListResults(
persistentListOf(
aRoomListRoomSummary(id = roomId.value, name = "A room result"),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A room result").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a message result - invokes the callback and saves it to the history`() = runAndroidComposeUiTest {
val roomId = RoomId("!aMessageRoom:server.org")
val eventId = EventId("\$anEvent:server.org")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, eventId)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.MESSAGES,
queryState = TextFieldState("Query"),
results = AsyncData.Success(
GlobalSearchResults.MessageSearchResults(
persistentListOf(
MessageSearchResultItem.Message(
messageSearchResult = aMessageSearchResult(roomId = roomId, eventId = eventId),
body = "A message body",
roomInfo = aRoomInfo(id = roomId, name = "A message room"),
formattedTimestamp = "12:00",
),
)
)
),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A message room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertSingle(GlobalSearchEvent.SaveRoomToHistory(roomId))
}
@Test
fun `clicking on a history query result - emits the expected Event`() = runAndroidComposeUiTest {
val queryResult = SearchHistoryResultItem.Query("Recent term")
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(queryResult)),
eventSink = eventsRecorder,
),
// A query history result must not trigger the search result callback
onSelectSearchResult = EnsureNeverCalledWithTwoParams(),
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("Recent term").performClick()
eventsRecorder.assertSingle(GlobalSearchEvent.SearchHistoryResultSelected(queryResult))
}
@Test
fun `clicking on a history room result - invokes the callback and emits the expected Events`() = runAndroidComposeUiTest {
val roomId = RoomId("!aHistoryRoom:server.org")
val roomResult = SearchHistoryResultItem.Room(
roomId = roomId,
roomInfo = aRoomInfo(id = roomId, name = "A history room"),
)
val eventsRecorder = EventsRecorder<GlobalSearchEvent>()
val onSelectSearchResult = EnsureCalledOnceWithTwoParams<RoomId, EventId?>(roomId, null)
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState(),
results = AsyncData.Uninitialized,
history = AsyncData.Success(persistentListOf(roomResult)),
eventSink = eventsRecorder,
),
onSelectSearchResult = onSelectSearchResult,
)
// Remove automatic initial events
eventsRecorder.clear()
onNodeWithText("A history room").performClick()
onSelectSearchResult.assertSuccess()
eventsRecorder.assertList(
listOf(
GlobalSearchEvent.SaveRoomToHistory(roomId),
GlobalSearchEvent.SearchHistoryResultSelected(roomResult),
)
)
}
@Test
fun `with a query and uninitialized results - displays the start searching placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
queryState = TextFieldState("Query"),
results = AsyncData.Uninitialized,
),
)
val startSearching = activity!!.getString(R.string.start_searching_title)
onNodeWithText(startSearching).assertIsDisplayed()
}
@Test
fun `with empty results - displays the no results placeholder`() = runAndroidComposeUiTest {
setGlobalSearchView(
state = aGlobalSearchState(
isSearchActive = true,
currentTarget = GlobalSearchTarget.ROOMS,
queryState = TextFieldState("Query"),
results = AsyncData.Success(GlobalSearchResults.RoomListResults(persistentListOf())),
),
)
val noResults = activity!!.getString(R.string.search_no_results_title)
onNodeWithText(noResults).assertIsDisplayed()
}
private fun AndroidComposeUiTest<ComponentActivity>.setGlobalSearchView(
state: GlobalSearchState,
onSelectSearchResult: (RoomId, EventId?) -> Unit = EnsureNeverCalledWithTwoParams(),
) {
setContent {
GlobalSearchView(
state = state,
onSelectSearchResult = onSelectSearchResult,
)
}
}
}
@@ -0,0 +1,121 @@
/*
* 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.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import kotlinx.serialization.json.Json
import org.junit.Test
class DefaultSearchHistoryStoreTest {
@Test
fun `history is empty when nothing has been persisted`() = runTest {
val store = createStore()
assertThat(store.history.first()).isEmpty()
}
@Test
fun `add prepends new entries so the most recent is first`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("first"))
store.add(SearchHistoryResult.Query("second"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("second"),
SearchHistoryResult.Query("first"),
).inOrder()
}
@Test
fun `add supports both queries and rooms`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("term"))
store.add(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("term"),
).inOrder()
}
@Test
fun `adding an existing entry moves it to the front without duplicating`() = runTest {
val store = createStore()
store.add(SearchHistoryResult.Query("a"))
store.add(SearchHistoryResult.Query("b"))
store.add(SearchHistoryResult.Query("a"))
assertThat(store.history.first()).containsExactly(
SearchHistoryResult.Query("a"),
SearchHistoryResult.Query("b"),
).inOrder()
}
@Test
fun `history is capped to the maximum size, dropping the oldest entries`() = runTest {
val store = createStore()
repeat(MAX_SEARCH_HISTORY_SIZE + 10) { index ->
store.add(SearchHistoryResult.Query("query$index"))
}
val history = store.history.first()
assertThat(history).hasSize(MAX_SEARCH_HISTORY_SIZE)
// Newest entry is first...
assertThat(history.first()).isEqualTo(SearchHistoryResult.Query("query${MAX_SEARCH_HISTORY_SIZE + 9}"))
// ...and the oldest ones have been dropped.
assertThat(history).doesNotContain(SearchHistoryResult.Query("query0"))
assertThat(history.last()).isEqualTo(SearchHistoryResult.Query("query10"))
}
@Test
fun `persisted history is deserialized back into SearchHistoryResult items`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
createStore(fileStore).apply {
add(SearchHistoryResult.Query("persisted term"))
add(SearchHistoryResult.Room(A_ROOM_ID))
}
// A brand new store backed by the same file reads the same content back.
val reloaded = createStore(fileStore)
assertThat(reloaded.history.first()).containsExactly(
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("persisted term"),
).inOrder()
}
@Test
fun `clear removes every entry and deletes the persisted content`() = runTest {
val fileStore = FakeSearchHistoryFileStore()
val store = createStore(fileStore)
store.add(SearchHistoryResult.Query("term"))
store.clear()
assertThat(store.history.first()).isEmpty()
assertThat(fileStore.content).isNull()
}
@Test
fun `corrupted content is ignored and results in an empty history`() = runTest {
val fileStore = FakeSearchHistoryFileStore(initialContent = "not valid json".toByteArray())
val store = createStore(fileStore)
assertThat(store.history.first()).isEmpty()
// The store recovers and can still add new entries.
store.add(SearchHistoryResult.Room(RoomId("!recovered:domain")))
assertThat(store.history.first()).containsExactly(SearchHistoryResult.Room(RoomId("!recovered:domain")))
}
private fun TestScope.createStore(
fileStore: SearchHistoryFileStore = FakeSearchHistoryFileStore(),
) = DefaultSearchHistoryStore(
fileStore = fileStore,
dispatchers = testCoroutineDispatchers(),
jsonProvider = { Json { ignoreUnknownKeys = true } }
)
}
@@ -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.home.impl.search.history
/**
* In-memory [SearchHistoryFileStore] used to test [DefaultSearchHistoryStore] without the Android Keystore.
*/
class FakeSearchHistoryFileStore(
initialContent: ByteArray? = null,
) : SearchHistoryFileStore {
var content: ByteArray? = initialContent
private set
var writeCount = 0
private set
override fun read(): ByteArray? = content
override fun write(bytes: ByteArray) {
content = bytes
writeCount++
}
override fun delete(): Boolean {
content = null
return true
}
}
@@ -0,0 +1,28 @@
/*
* 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.search.history
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
class FakeSearchHistoryStore : SearchHistoryStore {
private val state = MutableStateFlow<List<SearchHistoryResult>>(emptyList())
override val history: Flow<List<SearchHistoryResult>> = state
override suspend fun add(result: SearchHistoryResult) {
state.value = buildList {
add(result)
addAll(state.value.filterNot { it == result })
}.take(MAX_SEARCH_HISTORY_SIZE)
}
override suspend fun clear() {
state.value = emptyList()
}
}
@@ -0,0 +1,50 @@
/*
* 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.search.history
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import kotlinx.serialization.json.Json
import org.junit.Test
class SearchHistoryResultSerializationTest {
private val json = Json
@Test
fun `Query round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Query("hello world")
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `Room round-trips through serialization`() {
val original: SearchHistoryResult = SearchHistoryResult.Room(A_ROOM_ID)
val decoded = json.decodeFromString<SearchHistoryResult>(json.encodeToString(original))
assertThat(decoded).isEqualTo(original)
}
@Test
fun `RoomId is serialized as its raw string value`() {
val encoded = json.encodeToString<SearchHistoryResult>(SearchHistoryResult.Room(A_ROOM_ID))
assertThat(encoded).contains(A_ROOM_ID.value)
}
@Test
fun `a mixed list round-trips preserving order`() {
val original = listOf(
SearchHistoryResult.Query("first"),
SearchHistoryResult.Room(A_ROOM_ID),
SearchHistoryResult.Query("second"),
SearchHistoryResult.Room(A_ROOM_ID_2),
)
val decoded = json.decodeFromString<List<SearchHistoryResult>>(json.encodeToString(original))
assertThat(decoded).containsExactlyElementsIn(original).inOrder()
}
}
@@ -59,7 +59,6 @@ import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
@Suppress("LargeClass")
internal class DefaultInvitePeoplePresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -107,6 +107,7 @@ sealed interface LoadedDetails {
) : LoadedDetails
}
@Immutable
sealed interface JoinAuthorisationStatus {
data object None : JoinAuthorisationStatus
data class IsInvited(val inviteData: InviteData, val inviteSender: InviteSender?) : JoinAuthorisationStatus
@@ -40,8 +40,8 @@ import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.invite.api.InviteData
import io.element.android.libraries.designsystem.atomic.atoms.CopiableTextAtom
import io.element.android.libraries.designsystem.atomic.atoms.PlaceholderAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewAliasAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewDescriptionAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoomPreviewTitleAtom
import io.element.android.libraries.designsystem.atomic.molecules.ButtonRowMolecule
@@ -514,7 +514,7 @@ private fun IncompleteContent(
title = {
when (roomIdOrAlias) {
is RoomIdOrAlias.Alias -> {
RoomPreviewAliasAtom(roomIdOrAlias.identifier)
CopiableTextAtom(roomIdOrAlias.identifier)
}
is RoomIdOrAlias.Id -> {
PlaceholderAtom(width = 200.dp, height = 22.dp)
@@ -567,7 +567,7 @@ private fun DefaultLoadedContent(
},
subtitle = {
if (contentState.alias != null) {
RoomPreviewAliasAtom(contentState.alias.value)
CopiableTextAtom(contentState.alias.value)
}
if (contentState.details is LoadedDetails.Space) {
Spacer(Modifier.height(8.dp))
@@ -27,7 +27,7 @@
<string name="screen_join_room_loading_alert_title">"Náhled této místnosti jsme nemohli zobrazit"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s zatím nepodporuje prostory. Prostory můžete používat na webu."</string>
<string name="screen_join_room_space_not_supported_title">"Prostory zatím nejsou podporovány"</string>
<string name="screen_join_room_subtitle_knock">"Klikněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."</string>
<string name="screen_join_room_subtitle_knock">"Klepněte na tlačítko níže a správce místnosti bude informován. Po schválení se budete moci připojit ke konverzaci."</string>
<string name="screen_join_room_subtitle_no_preview">"Pro zobrazení historie zpráv musíte být členem této místnosti."</string>
<string name="screen_join_room_title_knock">"Chcete se připojit k této místnosti?"</string>
<string name="screen_join_room_title_no_preview">"Náhled není k dispozici"</string>
@@ -27,7 +27,7 @@
<string name="screen_join_room_loading_alert_title">"Emme voineet näyttää tämän huoneen esikatselua"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s ei tue vielä tiloja. Voit käyttää tiloja selainversiolla."</string>
<string name="screen_join_room_space_not_supported_title">"Tiloja ei vielä tueta"</string>
<string name="screen_join_room_subtitle_knock">"Paina alla olevaa nappia ja huoneen ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty."</string>
<string name="screen_join_room_subtitle_knock">"Paina alla olevaa nappia ja keskustelun ylläpitäjä saa ilmoituksen. Voit liittyä keskusteluun kun pyyntösi on hyväksytty."</string>
<string name="screen_join_room_subtitle_no_preview">"Sinun on oltava tämän huoneen jäsen, jotta voit nähdä viestihistorian."</string>
<string name="screen_join_room_title_knock">"Haluatko liittyä tähän huoneeseen?"</string>
<string name="screen_join_room_title_no_preview">"Esikatselu ei ole saatavilla"</string>
@@ -27,7 +27,7 @@
<string name="screen_join_room_loading_alert_title">"我們無法顯示此聊天室的預覽"</string>
<string name="screen_join_room_space_not_supported_description">"%1$s 尚未支援空間。您可以在網頁上存取空間。"</string>
<string name="screen_join_room_space_not_supported_title">"尚未支援空間"</string>
<string name="screen_join_room_subtitle_knock">"點選下方按鈕將會通知聊天試管里員。一旦核准,您就可以加入對話。"</string>
<string name="screen_join_room_subtitle_knock">"輕點下方的按鈕,系統將通知聊天室管理員。經核准後,您即可加入對話。"</string>
<string name="screen_join_room_subtitle_no_preview">"您必須是此聊天室的成員才能檢視訊息歷史紀錄。"</string>
<string name="screen_join_room_title_knock">"想要加入此聊天室?"</string>
<string name="screen_join_room_title_no_preview">"無法使用預覽"</string>
@@ -70,7 +70,6 @@ import org.junit.Rule
import org.junit.Test
import java.util.Optional
@Suppress("LargeClass")
class JoinRoomPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@@ -10,20 +10,12 @@ package io.element.android.features.location.impl.common
import android.Manifest
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.map.GestureOptions
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.map.RenderOptions
import org.maplibre.spatialk.geojson.Position
/**
* Common configuration values for the map.
*/
object MapDefaults {
val options = MapOptions(
renderOptions = RenderOptions.Standard,
gestureOptions = GestureOptions.Standard,
)
val defaultCameraPosition = CameraPosition(
target = Position(0.0, 0.0),
zoom = 0.0,
@@ -24,13 +24,13 @@ import org.maplibre.compose.expressions.dsl.feature
import org.maplibre.compose.expressions.dsl.image
import org.maplibre.compose.expressions.dsl.not
import org.maplibre.compose.expressions.value.SymbolAnchor
import org.maplibre.compose.interaction.ClickResult
import org.maplibre.compose.layers.CircleLayer
import org.maplibre.compose.layers.SymbolLayer
import org.maplibre.compose.sources.GeoJsonData
import org.maplibre.compose.sources.GeoJsonOptions
import org.maplibre.compose.sources.GeoJsonSource
import org.maplibre.compose.sources.rememberGeoJsonSource
import org.maplibre.compose.util.ClickResult
import org.maplibre.spatialk.geojson.Feature
import org.maplibre.spatialk.geojson.FeatureCollection
import org.maplibre.spatialk.geojson.Point
@@ -7,6 +7,7 @@
package io.element.android.features.location.impl.common.ui
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.BoxScope
@@ -23,6 +24,7 @@ import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.navigationBars
import androidx.compose.foundation.layout.only
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.safeDrawing
import androidx.compose.foundation.layout.windowInsetsBottomHeight
import androidx.compose.foundation.layout.windowInsetsPadding
@@ -41,27 +43,22 @@ import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.max
import io.element.android.features.enterprise.api.remoteconfig.MapTilerConfig
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.designsystem.theme.components.BottomSheetScaffold
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapOptions
import org.maplibre.compose.interaction.MapInteractions
import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.MapUiOptions
import org.maplibre.compose.map.MaplibreMap
import org.maplibre.compose.map.RenderOptions
import org.maplibre.compose.overlay.ExpandingAttributionButton
import org.maplibre.compose.overlay.MapOverlay
import org.maplibre.compose.overlay.MaplibreLogo
import org.maplibre.compose.style.BaseStyle
import org.maplibre.compose.util.MaplibreComposable
import kotlin.math.roundToInt
/**
@@ -72,43 +69,45 @@ import kotlin.math.roundToInt
* - Updating camera position padding based on sheet height
* - Rendering the MaplibreMap with proper ornament positioning
*
* @param customMapTilerConfig Optional custom style URL for the map
* @param mapState The map state (camera, style and layer content) for the map. `null` in
* inspection/preview mode, where the native map cannot initialize and a placeholder is shown.
* @param isReady Used to gate rendering until the map should be displayed
* @param modifier Modifier for the root layout
* @param scaffoldState State for the bottom sheet scaffold
* @param cameraState The camera state for the map
* @param mapOptions The options to configure the map
* @param renderOptions The options to configure the map rendering
* @param interactions The gestures and camera interactions allowed on the map
* @param uiOptions The map UI options (gestures, scrolling, keys, first-frame behaviour)
* @param sheetPeekHeight The height of the sheet when collapsed
* @param sheetDragHandle Optional drag handle for the sheet
* @param sheetSwipeEnabled Whether the sheet can be swiped
* @param topBar The top app bar content
* @param snackbarHost The snackbar host content
* @param sheetContent The content to display in the bottom sheet
* @param mapContent The content inside the MaplibreMap (layers, location pucks, etc.)
* @param overlayContent Content to overlay on top of the map (FAB, pin icons, etc.)
* @param overlay Content to overlay on top of the map (FAB, pin icons, etc.)
*/
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun MapBottomSheetScaffold(
customMapTilerConfig: AsyncData<MapTilerConfig?>,
mapState: MapState?,
isReady: Boolean,
modifier: Modifier = Modifier,
scaffoldState: BottomSheetScaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.PartiallyExpanded,
)
),
cameraState: CameraState = rememberCameraState(),
mapOptions: MapOptions = MapDefaults.options,
renderOptions: RenderOptions = RenderOptions.Standard,
interactions: MapInteractions = MapInteractions.Standard,
uiOptions: MapUiOptions = MapUiOptions.Standard,
sheetPeekHeight: Dp = BottomSheetDefaults.SheetPeekHeight,
sheetDragHandle: @Composable (() -> Unit)? = { BottomSheetDefaults.DragHandle() },
sheetSwipeEnabled: Boolean = true,
topBar: (@Composable () -> Unit)? = null,
snackbarHost: @Composable (SnackbarHostState) -> Unit = { SnackbarHost(it) },
sheetContent: @Composable ColumnScope.(PaddingValues) -> Unit = {},
mapContent: @Composable @MaplibreComposable () -> Unit = {},
overlayContent: @Composable BoxScope.(sheetPadding: PaddingValues) -> Unit = {},
sheetContent: @Composable ColumnScope.() -> Unit = {},
overlay: @Composable BoxScope.() -> Unit = {},
) {
val density = LocalDensity.current
val windowInsets = WindowInsets.safeDrawing.only(WindowInsetsSides.Horizontal)
BoxWithConstraints(modifier = modifier.windowInsetsPadding(windowInsets)) {
val layoutHeightPx by rememberUpdatedState(constraints.maxHeight)
@@ -126,7 +125,7 @@ fun MapBottomSheetScaffold(
sheetContent = {
val maxContentHeight = (layoutHeightPx * 0.5f).roundToInt().toDp()
Column(modifier = Modifier.heightIn(max = maxContentHeight)) {
sheetContent(sheetPadding)
sheetContent()
Spacer(modifier = Modifier.windowInsetsBottomHeight(WindowInsets.navigationBars))
}
},
@@ -136,39 +135,57 @@ fun MapBottomSheetScaffold(
snackbarHost = snackbarHost,
topBar = topBar,
) {
Box {
when (customMapTilerConfig) {
is AsyncData.Success -> {
Box(modifier = Modifier.fillMaxSize()) {
when {
isReady && mapState != null -> {
MaplibreMap(
options = mapOptions,
state = mapState,
cameraPadding = sheetPadding,
contentWindowInsets = WindowInsets(bottom = sheetPadding.calculateBottomPadding()),
baseStyle = BaseStyle.Uri(rememberTileStyleUrl(customMapTilerConfig.data)),
overlay = MapOverlay {
val style = styleState
Row(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth(),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
renderOptions = renderOptions,
interactions = interactions,
uiOptions = uiOptions,
overlay = {
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
MaplibreLogo()
ExpandingAttributionButton(cameraState = cameraState, styleState = style)
Row(
Modifier
.align(Alignment.BottomStart)
.fillMaxWidth()
.padding(all = 8.dp),
horizontalArrangement = Arrangement.SpaceBetween,
verticalAlignment = Alignment.CenterVertically,
) {
MaplibreLogo()
ExpandingAttributionButton()
}
}
},
modifier = Modifier.fillMaxSize(),
cameraState = cameraState,
content = mapContent,
)
}
mapState == null -> {
// Inspection/preview mode: the map cannot initialize, render a placeholder
// matching MaplibreMap's own inspection rendering.
Box(modifier = Modifier.fillMaxSize().background(Color.Gray))
}
else -> {
Box(modifier = Modifier.fillMaxSize()) {
CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
}
overlayContent(sheetPadding)
// App overlay (FABs, pins, live-location indicators). Layered above the map so it is
// present even in inspection/preview mode, where the map is a placeholder.
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
overlay()
}
}
}
}
@@ -10,24 +10,23 @@ package io.element.android.features.location.impl.common.ui
import androidx.compose.runtime.Composable
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.location.LocationPuck
import org.maplibre.compose.location.LocationPuckColors
import org.maplibre.compose.location.LocationPuckSizes
import org.maplibre.spatialk.units.Length
@Composable
fun UserLocationPuck(
cameraState: CameraState,
location: Location?,
location: LocationMeasurement?,
) {
LocationPuck(
idPrefix = "user-location",
location = location,
cameraState = cameraState,
accuracyThreshold = Float.POSITIVE_INFINITY,
showBearingAccuracy = false,
showBearing = false,
// Hide the accuracy circle.
accuracyThreshold = Length.PositiveInfinity,
// Hide the bearing (and bearing accuracy) indicator.
bearing = null,
sizes = LocationPuckSizes(
dotRadius = 8.dp,
dotStrokeWidth = 2.dp,
@@ -39,7 +39,7 @@ class DefaultUserLocationStateFactory : UserLocationState.Factory {
.updates(locationRequest)
.mapNotNull { event ->
when (event) {
is LocationEvent.Fix -> event.location
is LocationEvent.Update -> event.measurement
is LocationEvent.Unavailable -> null
}
}
@@ -8,19 +8,18 @@
package io.element.android.features.location.impl.common.userlocation
import io.element.android.features.location.api.Location
import org.maplibre.compose.location.PositionWithAccuracy
import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.spatialk.geojson.Position
import org.maplibre.spatialk.units.extensions.meters
import kotlin.time.TimeSource
import org.maplibre.compose.location.Location as MapLibreLocation
import kotlin.time.Clock
fun Location.asMapLibreLocation(): MapLibreLocation {
return MapLibreLocation(
position = PositionWithAccuracy(
value = Position(latitude = lat, longitude = lon),
accuracy = accuracy?.toDouble()?.meters
fun Location.toLocationMeasurement(): LocationMeasurement {
return LocationMeasurement(
position = Position(
latitude = lat,
longitude = lon,
),
// Not relevant as not used
timestamp = TimeSource.Monotonic.markNow(),
horizontalAccuracy = accuracy?.toDouble()?.meters,
measuredAt = Clock.System.now(),
)
}
@@ -37,7 +37,7 @@ import org.maplibre.compose.location.LocationEvent
import org.maplibre.compose.location.LocationProvider
import org.maplibre.compose.location.LocationRequest
import org.maplibre.compose.location.LocationUnavailableReason
import org.maplibre.compose.location.asMapLibreLocation
import org.maplibre.compose.location.asMapLibreLocationUpdate
import org.maplibre.spatialk.units.extensions.inMeters
import java.util.ServiceLoader
@@ -75,7 +75,7 @@ class PlatformLocationProvider(
val locationManager = context.getSystemService(LocationManager::class.java)
val listener = object : LocationListenerCompat {
override fun onLocationChanged(location: Location) {
trySend(LocationEvent.Fix(location.asMapLibreLocation()))
trySend(location.asMapLibreLocationUpdate())
}
override fun onProviderDisabled(provider: String) {
@@ -100,7 +100,7 @@ class PlatformLocationProvider(
return
}
locationManager.getLastKnownLocation(provider)?.let { location ->
trySend(LocationEvent.Fix(location.asMapLibreLocation()))
trySend(location.asMapLibreLocationUpdate())
}
val locationRequestCompat = LocationRequestCompat.Builder(request.minimumInterval.inWholeMilliseconds)
.setQuality(request.accuracy.toLocationRequestQuality())
@@ -9,10 +9,10 @@ package io.element.android.features.location.impl.common.userlocation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.Immutable
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationMeasurement
@Immutable
data class UserLocationState(val location: Location?) {
data class UserLocationState(val location: LocationMeasurement?) {
fun interface Factory {
@Composable
fun create(hasLocationPermission: Boolean): UserLocationState
@@ -10,12 +10,15 @@ package io.element.android.features.location.impl.common.userlocation
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.runtime.snapshotFlow
import io.element.android.features.location.impl.common.MapDefaults
import kotlinx.coroutines.flow.distinctUntilChanged
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.location.Location
import org.maplibre.compose.location.LocationMeasurement
import org.maplibre.compose.map.MapState
import kotlin.math.abs
/**
@@ -27,7 +30,7 @@ internal fun UserLocationTrackingEffect(
locationState: UserLocationState,
enabled: Boolean = true,
precision: Double = 0.00001,
onLocationChange: suspend (Location?) -> Unit,
onLocationChange: suspend (LocationMeasurement?) -> Unit,
) {
val latestOnLocationChange by rememberUpdatedState(onLocationChange)
val latestLocationState by rememberUpdatedState(locationState)
@@ -38,8 +41,8 @@ internal fun UserLocationTrackingEffect(
.distinctUntilChanged { oldLocation, newLocation ->
if (oldLocation != null && newLocation != null) {
when {
abs(oldLocation.position.value.latitude - newLocation.position.value.latitude) >= precision -> false
abs(oldLocation.position.value.longitude - newLocation.position.value.longitude) >= precision -> false
abs(oldLocation.position.latitude - newLocation.position.latitude) >= precision -> false
abs(oldLocation.position.longitude - newLocation.position.longitude) >= precision -> false
else -> true
}
} else {
@@ -54,24 +57,30 @@ internal fun UserLocationTrackingEffect(
@Composable
internal fun UserLocationTrackingEffect(
cameraState: CameraState,
mapState: MapState,
locationState: UserLocationState,
enabled: Boolean = true,
precision: Double = 0.00001,
) {
// Tracks whether the camera has already been placed on the user location once.
var hasPositioned by remember(mapState) { mutableStateOf(false) }
UserLocationTrackingEffect(
locationState = locationState,
enabled = enabled,
precision = precision
) { location ->
val target = location?.position?.value ?: cameraState.position.target
cameraState.animateTo(
cameraState.position.copy(
target = target,
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
val target = location?.position ?: return@UserLocationTrackingEffect
val position = mapState.cameraPosition.copy(
target = target,
// Force pointing to NORTH
bearing = 0.0,
zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM)
)
if (hasPositioned) {
mapState.animateCameraPosition(position)
} else {
mapState.setCameraPosition(position)
hasPositioned = true
}
}
}
@@ -102,11 +102,11 @@ class LiveLocationSharingService : Service() {
}
.onEach { locationEvent ->
when (locationEvent) {
is LocationEvent.Fix -> {
is LocationEvent.Update -> {
val location = ApiLocation(
lat = locationEvent.location.position.value.latitude,
lon = locationEvent.location.position.value.longitude,
accuracy = locationEvent.location.position.accuracy?.inMeters?.toFloat(),
lat = locationEvent.measurement.position.latitude,
lon = locationEvent.measurement.position.longitude,
accuracy = locationEvent.measurement.horizontalAccuracy?.inMeters?.toFloat(),
)
coordinator.dispatch(location)
}
@@ -115,7 +115,6 @@ class LiveLocationSharingService : Service() {
LocationUnavailableReason.ServicesDisabled,
LocationUnavailableReason.TemporarilyUnavailable -> Unit
LocationUnavailableReason.Unsupported,
LocationUnavailableReason.Misconfigured,
LocationUnavailableReason.PermissionDenied,
LocationUnavailableReason.UnexpectedFailure -> coordinator.dispatchUnrecoverableError()
}
@@ -217,8 +217,8 @@ class ShareLocationPresenter(
val replyMode = messageComposerContext.composerMode as? MessageComposerMode.Reply
val inReplyToEventId = replyMode?.eventId
val geoUri = event.location.toGeoUri()
getTimeline().flatMap {
it.sendLocation(
withTimeline { timeline ->
timeline.sendLocation(
body = generateBody(geoUri),
geoUri = geoUri,
description = null,
@@ -237,10 +237,22 @@ class ShareLocationPresenter(
)
}
private suspend fun getTimeline(): Result<Timeline> {
/**
* Invokes [block] with the [Timeline] matching [timelineMode].
*
* For a thread, a dedicated timeline is created and closed once [block] returns, since such a
* timeline owns SDK resources. For the other modes, the live timeline is used, and is not
* closed here since it is owned by its room.
*/
private suspend fun <T> withTimeline(block: suspend (Timeline) -> Result<T>): Result<T> {
return when (timelineMode) {
is Timeline.Mode.Thread -> room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
else -> Result.success(room.liveTimeline)
is Timeline.Mode.Thread -> {
room.createTimeline(CreateTimelineParams.Threaded(timelineMode.threadRootId))
.flatMap { threadedTimeline ->
threadedTimeline.use { block(it) }
}
}
else -> block(room.liveTimeline)
}
}
}
@@ -13,7 +13,6 @@ package io.element.android.features.location.impl.share
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.lazy.itemsIndexed
@@ -30,6 +29,7 @@ import androidx.compose.runtime.rememberUpdatedState
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@@ -37,6 +37,7 @@ import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.location.api.Location
import io.element.android.features.location.api.internal.centerBottomEdge
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.R
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
@@ -67,11 +68,11 @@ import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraState
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.style.BaseStyle
import kotlin.time.Duration
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShareLocationView(
state: ShareLocationState,
@@ -104,23 +105,84 @@ fun ShareLocationView(
)
}
if (LocalInspectionMode.current) {
// Inspection/preview mode: the native map runtime is unavailable (previews, screenshot
// tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map.
ShareLocationScaffold(
state = state,
mapState = null,
navigateUp = navigateUp,
onSharePinLocation = {},
modifier = modifier,
)
} else {
ShareLocationViewContent(
state = state,
navigateUp = navigateUp,
modifier = modifier,
)
}
}
@Composable
private fun ShareLocationViewContent(
state: ShareLocationState,
navigateUp: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
) {
UserLocationPuck(location = state.userLocationState.location)
}
UserLocationTrackingEffect(
mapState = mapState,
locationState = state.userLocationState,
enabled = state.trackUserLocation,
)
LaunchedEffect(mapState.isCameraMoving) {
if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
}
}
ShareLocationScaffold(
state = state,
mapState = mapState,
navigateUp = navigateUp,
onSharePinLocation = {
val positionTarget = mapState.cameraPosition.target
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
isPinned = true
)
)
navigateUp()
},
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShareLocationScaffold(
mapState: MapState?,
state: ShareLocationState,
navigateUp: () -> Unit,
onSharePinLocation: () -> Unit,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded,
enabledValues = setOf(SheetValue.PartiallyExpanded, SheetValue.Expanded),
)
)
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShareLocationEvent.StopTrackingUserLocation)
}
}
MapBottomSheetScaffold(
customMapTilerConfig = state.customMapTilerConfig,
cameraState = cameraState,
isReady = state.customMapTilerConfig.isReady(),
mapState = mapState,
modifier = modifier,
scaffoldState = scaffoldState,
sheetDragHandle = null,
@@ -135,38 +197,21 @@ fun ShareLocationView(
},
sheetContent = {
BottomSheetContent(
cameraState = cameraState,
state = state,
navigateUp = navigateUp
navigateUp = navigateUp,
onSharePinLocation = onSharePinLocation,
)
},
mapContent = {
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = state.userLocationState,
enabled = state.trackUserLocation,
)
UserLocationPuck(
cameraState = cameraState,
location = state.userLocationState.location,
)
},
overlayContent = { sheetPadding ->
Box(
modifier = Modifier
.fillMaxSize()
.padding(sheetPadding)
) {
val variant = if (state.trackUserLocation) {
PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin))
} else {
PinVariant.PinnedLocation
}
LocationPin(
variant = variant,
modifier = Modifier.centerBottomEdge(this),
)
overlay = {
val variant = if (state.trackUserLocation) {
PinVariant.UserLocation(isLive = false, avatarData = state.currentUser.getAvatarData(AvatarSize.LocationPin))
} else {
PinVariant.PinnedLocation
}
LocationPin(
variant = variant,
modifier = Modifier.centerBottomEdge(this),
)
LocationFloatingActionButton(
isMapCenteredOnUser = state.trackUserLocation,
onClick = { state.eventSink(ShareLocationEvent.StartTrackingUserLocation) },
@@ -217,9 +262,9 @@ private fun StartLiveLocationActionView(
@Composable
private fun BottomSheetContent(
cameraState: CameraState,
state: ShareLocationState,
navigateUp: () -> Unit,
onSharePinLocation: () -> Unit,
) {
Spacer(Modifier.height(20.dp))
val userLocation = state.userLocationState.location
@@ -228,8 +273,8 @@ private fun BottomSheetContent(
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(
lat = userLocation.position.value.latitude,
lon = userLocation.position.value.longitude
lat = userLocation.position.latitude,
lon = userLocation.position.longitude
),
isPinned = false
)
@@ -237,18 +282,7 @@ private fun BottomSheetContent(
navigateUp()
}
} else {
SharePinLocationItem(
onClick = {
val positionTarget = cameraState.position.target
state.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = positionTarget.latitude, lon = positionTarget.longitude),
isPinned = true
)
)
navigateUp()
}
)
SharePinLocationItem(onClick = onSharePinLocation)
}
if (state.canShareLiveLocation) {
ShareLiveLocationItem {
@@ -38,7 +38,7 @@ import io.element.android.features.location.impl.common.permissions.PermissionsS
import io.element.android.features.location.impl.common.toDialogState
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialogState
import io.element.android.features.location.impl.common.userlocation.UserLocationState
import io.element.android.features.location.impl.common.userlocation.asMapLibreLocation
import io.element.android.features.location.impl.common.userlocation.toLocationMeasurement
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.coroutine.mapState
@@ -215,7 +215,7 @@ class ShowLocationPresenter(
val hideUserLocationPuck = mode is ShowLocationMode.Live && isCurrentlySharing
val userLocationState = if (hideUserLocationPuck) {
// When sharing with this device, use the user LocationShareItem as source of data instead of the device.
val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.asMapLibreLocation() } }
val ownLocationShare by remember { derivedStateOf { updatedLocationShares.find { it.isOwnUser }?.location?.toLocationMeasurement() } }
UserLocationState(ownLocationShare)
} else {
userLocationStateFactory.create(hasLocationPermission = permissionsState.isAnyGranted)
@@ -31,11 +31,13 @@ import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalInspectionMode
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.features.location.api.internal.rememberTileStyleUrl
import io.element.android.features.location.impl.common.MapDefaults
import io.element.android.features.location.impl.common.ui.LocationConstraintsDialog
import io.element.android.features.location.impl.common.ui.LocationFloatingActionButton
@@ -54,10 +56,11 @@ import kotlinx.collections.immutable.toImmutableList
import kotlinx.coroutines.launch
import org.maplibre.compose.camera.CameraMoveReason
import org.maplibre.compose.camera.CameraPosition
import org.maplibre.compose.camera.rememberCameraState
import org.maplibre.compose.map.MapState
import org.maplibre.compose.map.rememberMapState
import org.maplibre.compose.style.BaseStyle
import org.maplibre.spatialk.geojson.Position
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun ShowLocationView(
state: ShowLocationState,
@@ -73,7 +76,49 @@ fun ShowLocationView(
onDismiss = { state.eventSink(ShowLocationEvent.DismissDialog) },
)
val cameraState = rememberCameraState(firstPosition = MapDefaults.defaultCameraPosition)
if (LocalInspectionMode.current) {
// Inspection/preview mode: the native map runtime is unavailable (previews, screenshot
// tests, Robolectric), so no MapState is created and the scaffold renders a placeholder map.
ShowLocationScaffold(
state = state,
mapState = null,
onBackClick = onBackClick,
onLocationShareClick = {},
modifier = modifier,
)
} else {
ShowLocationViewContent(
state = state,
onBackClick = onBackClick,
modifier = modifier,
)
}
}
@Composable
private fun ShowLocationViewContent(
state: ShowLocationState,
onBackClick: () -> Unit,
modifier: Modifier = Modifier,
) {
val tileStyleUrl = rememberTileStyleUrl(state.customMapTilerConfig.dataOrNull())
val mapState = rememberMapState(
baseStyle = BaseStyle.Uri(tileStyleUrl),
initialCameraPosition = MapDefaults.defaultCameraPosition,
) {
if (!state.hideUserLocationPuck) {
UserLocationPuck(location = state.userLocationState.location)
}
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
LocationPinMarkers(markers)
}
UserLocationTrackingEffect(
mapState = mapState,
locationState = state.userLocationState,
enabled = state.isTrackMyLocation,
)
var hasAnimatedToFocusedLocation by remember { mutableStateOf(false) }
LaunchedEffect(state.focusedLocation) {
if (state.focusedLocation != null && !hasAnimatedToFocusedLocation) {
@@ -82,15 +127,44 @@ fun ShowLocationView(
target = Position(latitude = state.focusedLocation.location.lat, longitude = state.focusedLocation.location.lon),
zoom = MapDefaults.DEFAULT_ZOOM
)
cameraState.position = position
mapState.setCameraPosition(position)
}
}
LaunchedEffect(cameraState.isCameraMoving) {
if (cameraState.moveReason == CameraMoveReason.GESTURE) {
LaunchedEffect(mapState.isCameraMoving) {
if (mapState.cameraMoveReason == CameraMoveReason.GESTURE) {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
}
}
val coroutineScope = rememberCoroutineScope()
ShowLocationScaffold(
state = state,
mapState = mapState,
onBackClick = onBackClick,
onLocationShareClick = { locationShare ->
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
target = Position(locationShare.location.lon, locationShare.location.lat),
// Force pointing to NORTH
bearing = 0.0,
zoom = mapState.cameraPosition.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
mapState.animateCameraPosition(position = position)
}
},
modifier = modifier,
)
}
@OptIn(ExperimentalMaterial3Api::class)
@Composable
private fun ShowLocationScaffold(
state: ShowLocationState,
mapState: MapState?,
onBackClick: () -> Unit,
onLocationShareClick: (LocationShareItem) -> Unit,
modifier: Modifier = Modifier,
) {
val scaffoldState = rememberBottomSheetScaffoldState(
bottomSheetState = rememberBottomSheetState(
initialValue = SheetValue.Expanded,
@@ -103,7 +177,7 @@ fun ShowLocationView(
}
}
MapBottomSheetScaffold(
customMapTilerConfig = state.customMapTilerConfig,
isReady = state.customMapTilerConfig.isReady(),
sheetDragHandle = if (state.isSheetDraggable) {
{ BottomSheetDefaults.DragHandle() }
} else {
@@ -111,7 +185,7 @@ fun ShowLocationView(
},
sheetSwipeEnabled = state.isSheetDraggable,
scaffoldState = scaffoldState,
cameraState = cameraState,
mapState = mapState,
modifier = modifier,
topBar = {
TopAppBar(
@@ -123,8 +197,7 @@ fun ShowLocationView(
},
)
},
sheetContent = { sheetPaddings ->
val coroutineScope = rememberCoroutineScope()
sheetContent = {
if (!state.isSheetDraggable) {
// If sheet is draggable the DragHandle has already some padding
Spacer(Modifier.height(20.dp))
@@ -152,41 +225,13 @@ fun ShowLocationView(
item = locationShare,
onShareClick = { state.eventSink(ShowLocationEvent.Share(locationShare.location)) },
onStopClick = { state.eventSink(ShowLocationEvent.StopLocationSharing) },
modifier = Modifier.clickable {
state.eventSink(ShowLocationEvent.TrackMyLocation(false))
val position = CameraPosition(
target = Position(locationShare.location.lon, locationShare.location.lat),
// Force pointing to NORTH
bearing = 0.0,
zoom = cameraState.position.zoom.coerceAtLeast(MapDefaults.DEFAULT_ZOOM),
)
coroutineScope.launch {
cameraState.animateTo(finalPosition = position)
}
}
modifier = Modifier.clickable { onLocationShareClick(locationShare) }
)
}
}
}
},
mapContent = {
UserLocationTrackingEffect(
cameraState = cameraState,
locationState = state.userLocationState,
enabled = state.isTrackMyLocation,
)
if (!state.hideUserLocationPuck) {
UserLocationPuck(
cameraState = cameraState,
location = state.userLocationState.location,
)
}
val markers = remember(state.locationShares) {
state.locationShares.map { it.toMarkerData() }.toImmutableList()
}
LocationPinMarkers(markers)
},
overlayContent = {
overlay = {
LocationFloatingActionButton(
isMapCenteredOnUser = state.isTrackMyLocation,
onClick = { state.eventSink(ShowLocationEvent.TrackMyLocation(true)) },
@@ -2,4 +2,5 @@
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="screen_share_location_live_location_disclaimer_title">"Το ιστορικό ζωντανής τοποθεσίας σας θα αποθηκευτεί στην αίθουσα και θα είναι ορατό στα μέλη μετά το τέλος της συνεδρίας."</string>
<string name="screen_share_location_live_location_duration_picker_title">"Επιλέξτε για πόσο χρονικό διάστημα θα κοινοποιείτε την τρέχουσα τοποθεσία σας."</string>
<string name="screen_share_location_live_location_missing_permissions">"Δεν έχετε δικαιώματα για να μοιραστείτε την τρέχουσα τοποθεσία σας σε αυτήν την αίθουσα"</string>
</resources>
@@ -708,6 +708,45 @@ class ShareLocationPresenterTest {
}
}
@Test
fun `ShareStaticLocation in a thread closes the created timeline`() = runTest {
val threadedTimeline = FakeTimeline(mode = Timeline.Mode.Thread(A_THREAD_ID)).apply {
sendLocationLambda = { _, _, _, _, _, _ -> Result.success(Unit) }
}
val liveTimeline = FakeTimeline()
val joinedRoom = FakeJoinedRoom(
liveTimeline = liveTimeline,
createTimelineResult = { Result.success(threadedTimeline) },
)
val shareLocationPresenter = createShareLocationPresenter(
joinedRoom = joinedRoom,
timelineMode = Timeline.Mode.Thread(A_THREAD_ID),
)
fakePermissionsPresenter.givenState(
aPermissionsState(
permissions = PermissionsState.Permissions.AllGranted,
shouldShowRationale = false,
)
)
shareLocationPresenter.test {
val initialState = awaitFirstItem()
initialState.eventSink(
ShareLocationEvent.ShareStaticLocation(
location = Location(lat = 3.0, lon = 4.0, accuracy = 5.0f),
isPinned = false,
)
)
advanceUntilIdle()
assertThat(threadedTimeline.closeCounter).isEqualTo(1)
assertThat(liveTimeline.closeCounter).isEqualTo(0)
cancelAndIgnoreRemainingEvents()
}
}
private suspend fun <T> ReceiveTurbine<T>.awaitFirstItem(): T {
skipItems(1)
return awaitItem()
@@ -510,8 +510,8 @@ class ShowLocationPresenterTest {
val state = awaitItem()
assertThat(state.hideUserLocationPuck).isTrue()
val location = requireNotNull(state.userLocationState.location)
assertThat(location.position.value.latitude).isEqualTo(ownLocation?.lat)
assertThat(location.position.value.longitude).isEqualTo(ownLocation?.lon)
assertThat(location.position.latitude).isEqualTo(ownLocation?.lat)
assertThat(location.position.longitude).isEqualTo(ownLocation?.lon)
}
}
}
+1
View File
@@ -16,4 +16,5 @@ android {
dependencies {
implementation(projects.libraries.architecture)
implementation(projects.libraries.matrix.api)
}
@@ -8,6 +8,8 @@
package io.element.android.features.login.api.accesscontrol
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
/**
* Enforces the restriction an enterprise deployment can put on which account providers the user may sign in to.
*/
@@ -15,7 +17,7 @@ interface AccountProviderAccessControl {
/**
* Whether sign-in to this provider is permitted; `true` on a build with no such restriction.
*
* @param accountProviderUrl the server the user is trying to use.
* @param accountProvider the account provider the user is trying to use.
*/
suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String): Boolean
suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider): Boolean
}
@@ -14,16 +14,16 @@ import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.IsEnterpriseBuild
import io.element.android.features.login.api.accesscontrol.AccountProviderAccessControl
import io.element.android.features.login.impl.changeserver.AccountProviderAccessException
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
@ContributesBinding(AppScope::class)
class DefaultAccountProviderAccessControl(
private val isEnterpriseBuild: IsEnterpriseBuild,
private val enterpriseService: EnterpriseService,
) : AccountProviderAccessControl {
override suspend fun isAllowedToConnectToAccountProvider(accountProviderUrl: String) = try {
override suspend fun isAllowedToConnectToAccountProvider(accountProvider: AccountProvider) = try {
assertIsAllowedToConnectToAccountProvider(
title = accountProviderUrl,
accountProviderUrl = accountProviderUrl,
accountProvider = accountProvider,
)
true
} catch (_: AccountProviderAccessException) {
@@ -32,22 +32,22 @@ class DefaultAccountProviderAccessControl(
@Throws(AccountProviderAccessException::class)
suspend fun assertIsAllowedToConnectToAccountProvider(
title: String,
accountProviderUrl: String,
accountProvider: AccountProvider,
) {
if (isEnterpriseBuild().not()) {
// Ensure that Element Pro is not required for this account provider
if (enterpriseService.isElementProEnforced(accountProviderUrl)) {
if (enterpriseService.isElementProEnforced(accountProvider.serverNameOrBaseUrl())) {
throw AccountProviderAccessException.NeedElementProException(
unauthorisedAccountProviderTitle = title,
unauthorisedAccountProviderTitle = accountProvider.friendlyServerName(),
applicationId = ELEMENT_PRO_APPLICATION_ID,
)
}
}
if (enterpriseService.isAllowedToConnectToHomeserver(accountProviderUrl).not()) {
if (enterpriseService.isAllowedToConnectToAccountProvider(accountProvider).not()) {
throw AccountProviderAccessException.UnauthorizedAccountProviderException(
unauthorisedAccountProviderTitle = title,
authorisedAccountProviderTitles = enterpriseService.homeserverAllowList(),
unauthorisedAccountProviderTitle = accountProvider.friendlyServerName(),
authorisedAccountProviderTitles = enterpriseService.accountProviderAllowList()
.map { it.friendlyServerName() },
)
}
}
@@ -1,17 +0,0 @@
/*
* Copyright (c) 2025 Element Creations Ltd.
* Copyright 2023-2025 New Vector 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.login.impl.accountprovider
data class AccountProvider(
val url: String,
val title: String = url.removePrefix("https://").removePrefix("http://"),
val subtitle: String? = null,
val isPublic: Boolean = false,
val isMatrixOrg: Boolean = false,
)
@@ -11,10 +11,10 @@ package io.element.android.features.login.impl.accountprovider
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.libraries.di.annotations.AppCoroutineScope
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.MutableStateFlow
@@ -33,11 +33,9 @@ class AccountProviderDataSource(
) {
// The provider used when the user has not selected one: an enterprise/MDM-configured provider,
// else matrix.org. The most recently used provider (from history) can override it, see init.
private val configuredAccountProvider = createAccountProvider(
url = enterpriseService.homeserverAllowList()
.firstOrNull { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
?: AuthenticationConfig.MATRIX_ORG_URL
)
private val configuredAccountProvider = enterpriseService.accountProviderAllowList()
.firstOrNull()
?: matrixOrgAccountProvider
private val accountProvider: MutableStateFlow<AccountProvider> = MutableStateFlow(configuredAccountProvider)
@@ -46,7 +44,7 @@ class AccountProviderDataSource(
// The account provider the user last explicitly selected (via [setAccountProvider] / [setUrl]).
// Unlike [flow], this is not recomputed by [reset], so it survives to be persisted to history on a
// successful sign-in even when the login flow is torn down in between (e.g. across an OAuth round-trip).
var lastSelectedAccountProviderUrl: String? = null
var lastSelectedAccountProvider: AccountProvider? = null
private set
init {
@@ -66,28 +64,19 @@ class AccountProviderDataSource(
* connect to any provider, otherwise the enterprise/MDM-configured provider.
*/
private suspend fun defaultAccountProvider(): AccountProvider {
if (!enterpriseService.canConnectToAnyHomeserver()) {
if (!enterpriseService.canConnectToAnyAccountProvider()) {
return configuredAccountProvider
}
val lastUsedProvider = appPreferencesStore.getHomeserverHistoryFlow().first().firstOrNull()
return lastUsedProvider?.let { createAccountProvider(it) } ?: configuredAccountProvider
return lastUsedProvider?.let { AccountProvider.Generic(it) } ?: configuredAccountProvider
}
suspend fun setUrl(url: String) {
setAccountProvider(createAccountProvider(url))
setAccountProvider(AccountProvider.Generic(url))
}
suspend fun setAccountProvider(data: AccountProvider) {
lastSelectedAccountProviderUrl = data.url
lastSelectedAccountProvider = data
accountProvider.emit(data)
}
private fun createAccountProvider(url: String): AccountProvider {
return AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
}
@@ -9,27 +9,27 @@
package io.element.android.features.login.impl.accountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
open class AccountProviderPreviewParam : PreviewParameterProvider<AccountProvider> {
override val values: Sequence<AccountProvider>
get() = sequenceOf(
anAccountProvider(),
anAccountProvider().copy(subtitle = null),
anAccountProvider().copy(subtitle = null, title = "invalid"),
anAccountProvider().copy(subtitle = null, title = "Other", isPublic = false, isMatrixOrg = false),
// Add other state here
anAccountProviderManaged(),
)
}
fun anAccountProvider(
url: String = AuthenticationConfig.MATRIX_ORG_URL,
subtitle: String? = "Matrix.org is an open network for secure, decentralized communication.",
isPublic: Boolean = true,
isMatrixOrg: Boolean = true,
) = AccountProvider(
url = url,
subtitle = subtitle,
isPublic = isPublic,
isMatrixOrg = isMatrixOrg,
fun anAccountProviderManaged(
serverName: String = "matrix.org",
baseUrl: String = "https://matrix-client.matrix.org",
canUseServerName: Boolean = true,
) = AccountProvider.Managed(
serverName = serverName,
baseUrl = baseUrl,
canUseServerName = canUseServerName,
)
fun anAccountProviderGeneric(
serverName: String = "matrix.org",
) = AccountProvider.Generic(
serverName = serverName,
)
@@ -14,17 +14,14 @@ import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.login.impl.R
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtom
import io.element.android.libraries.designsystem.atomic.atoms.RoundedIconAtomSize
import io.element.android.libraries.designsystem.preview.ElementPreview
@@ -32,6 +29,7 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.HorizontalDivider
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
/**
* https://www.figma.com/file/o9p34zmiuEpZRyvZXJZAYL/FTUE?type=design&node-id=604-60817
@@ -60,37 +58,19 @@ fun AccountProviderView(
.heightIn(min = 44.dp),
verticalAlignment = Alignment.CenterVertically
) {
if (item.isMatrixOrg) {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
resourceId = R.drawable.ic_matrix,
tint = Color.Unspecified,
)
} else {
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Host(),
tint = ElementTheme.colors.iconPrimary,
)
}
RoundedIconAtom(
size = RoundedIconAtomSize.Medium,
imageVector = CompoundIcons.Host(),
tint = ElementTheme.colors.iconPrimary,
)
Text(
modifier = Modifier
.padding(start = 16.dp)
.weight(1f),
text = item.title,
text = item.friendlyServerName(),
style = ElementTheme.typography.fontBodyLgMedium,
color = ElementTheme.colors.textPrimary,
)
if (item.isPublic) {
Icon(
modifier = Modifier
.padding(start = 10.dp)
.size(16.dp),
imageVector = CompoundIcons.Public(),
contentDescription = null,
tint = ElementTheme.colors.iconSecondary,
)
}
if (selected) {
Icon(
modifier = Modifier
@@ -101,15 +81,6 @@ fun AccountProviderView(
)
}
}
if (item.subtitle != null) {
Text(
modifier = Modifier
.padding(start = 46.dp, bottom = 12.dp, end = 26.dp),
text = item.subtitle,
style = ElementTheme.typography.fontBodyMdRegular,
color = ElementTheme.colors.textSecondary,
)
}
}
}
}
@@ -25,8 +25,11 @@ class SaveAccountProviderToHistory(
suspend operator fun invoke() {
// Persist the provider the user actually selected, not the currently-exposed default: the latter can be
// reset back to the history default while the login flow is torn down across an OAuth round-trip.
val url = accountProviderDataSource.lastSelectedAccountProviderUrl
?: accountProviderDataSource.flow.value.url
appPreferencesStore.addHomeserverToHistory(url)
val accountProvider = accountProviderDataSource.lastSelectedAccountProvider
?: accountProviderDataSource.flow.value
// Persist the sanitized name the user knows the provider by rather than its base url, which can be an
// internal client API url (e.g. matrix.org resolves to matrix-client.matrix.org) the user would not
// recognise, and so that the same provider is not stored twice with and without its scheme.
appPreferencesStore.addHomeserverToHistory(accountProvider.serverNameOrBaseUrl())
}
}
@@ -8,7 +8,7 @@
package io.element.android.features.login.impl.changeserver
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
sealed interface ChangeServerEvent {
data class ChangeServer(val accountProvider: AccountProvider) : ChangeServerEvent
@@ -14,13 +14,13 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import dev.zacsweers.metro.Inject
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.error.ChangeServerError
import io.element.android.features.login.impl.localnetwork.LocalNetworkPermissionGate
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.architecture.runCatchingUpdatingState
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
@@ -38,7 +38,7 @@ class ChangeServerPresenter(
}
val gateState = localNetworkPermissionGate.present<AccountProvider>(
urlOf = { provider -> provider.url },
urlOf = { provider -> provider.serverNameOrBaseUrl() },
onProceed = { provider -> changeServer(provider, changeServerAction) },
)
@@ -63,11 +63,8 @@ class ChangeServerPresenter(
changeServerAction: MutableState<AsyncData<MatrixHomeServerDetails>>,
) {
suspend {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(
title = data.title,
accountProviderUrl = data.url,
)
val details = authenticationService.setHomeserver(data.url).getOrThrow()
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(data)
val details = authenticationService.setHomeserver(data.serverNameOrBaseUrl()).getOrThrow()
if (!details.isSupported) {
throw ChangeServerError.UnsupportedServer
}
@@ -8,7 +8,7 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
sealed interface ChooseAccountProviderEvent {
data class SelectAccountProvider(val accountProvider: AccountProvider) : ChooseAccountProviderEvent
@@ -14,14 +14,12 @@ import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Inject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import kotlinx.collections.immutable.toImmutableList
@Inject
@@ -41,7 +39,7 @@ class ChooseAccountProviderPresenter(
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = provider.url,
homeserverUrl = provider.serverNameOrBaseUrl(),
resolvedHomeserverUrl = null,
loginHint = null,
)
@@ -59,18 +57,8 @@ class ChooseAccountProviderPresenter(
}
val staticAccountProviderList = remember {
// The list cannot contains ANY_ACCOUNT_PROVIDER ("*") and cannot be empty at this point
enterpriseService.homeserverAllowList()
.map { it.ensureProtocol() }
.map { url ->
AccountProvider(
url = url,
subtitle = null,
isPublic = url == AuthenticationConfig.MATRIX_ORG_URL,
isMatrixOrg = url == AuthenticationConfig.MATRIX_ORG_URL,
)
}
.toImmutableList()
// The list cannot be empty at this point
enterpriseService.accountProviderAllowList().toImmutableList()
}
return ChooseAccountProviderState(
@@ -8,9 +8,9 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import kotlinx.collections.immutable.ImmutableList
data class ChooseAccountProviderState(
@@ -9,31 +9,22 @@
package io.element.android.features.login.impl.screens.chooseaccountprovider
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import kotlinx.collections.immutable.toImmutableList
open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider<ChooseAccountProviderState> {
private val server1 = anAccountProvider(
url = "https://server1.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
private val server1 = anAccountProviderManaged(
baseUrl = "https://server1.io",
)
private val server2 = anAccountProvider(
url = "https://server2.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
private val server2 = anAccountProviderManaged(
baseUrl = "https://server2.io",
)
private val server3 = anAccountProvider(
url = "https://server3.io",
subtitle = null,
isPublic = false,
isMatrixOrg = false,
private val server3 = anAccountProviderManaged(
baseUrl = "https://server3.io",
)
override val values: Sequence<ChooseAccountProviderState>
get() = sequenceOf(
@@ -61,13 +52,12 @@ open class ChooseAccountProviderStatePreviewParam : PreviewParameterProvider<Cho
selectedAccountProvider = server2,
loginModeState = aLoginModeState(loginMode = AsyncData.Loading()),
),
// Add other state here
)
}
fun aChooseAccountProviderState(
accountProviders: List<AccountProvider> = listOf(
anAccountProvider()
anAccountProviderManaged()
),
selectedAccountProvider: AccountProvider? = null,
loginModeState: LoginModeState = aLoginModeState(),
@@ -96,16 +96,8 @@ fun ChooseAccountProviderView(
)
state.accountProviders.forEach { item ->
val alteredItem = if (item.isMatrixOrg) {
// Set the subtitle from the resource
item.copy(
subtitle = stringResource(id = R.string.screen_change_account_provider_matrix_org_subtitle),
)
} else {
item
}
AccountProviderView(
item = alteredItem,
item = item,
selected = item == state.selectedAccountProvider,
onClick = {
state.eventSink(ChooseAccountProviderEvent.SelectAccountProvider(item))
@@ -20,9 +20,7 @@ import androidx.compose.runtime.setValue
import dev.zacsweers.metro.Assisted
import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.appconfig.AuthenticationConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.changeserver.ChangeServerEvent
import io.element.android.features.login.impl.changeserver.ChangeServerState
@@ -30,8 +28,11 @@ import io.element.android.features.login.impl.login.LoginModeEvent
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.uri.ensureProtocol
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.accountprovider.matrixOrgAccountProvider
import io.element.android.libraries.matrix.api.core.MatrixPatterns
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import kotlinx.coroutines.flow.map
@AssistedInject
class ConfirmAccountProviderPresenter(
@@ -56,23 +57,25 @@ class ConfirmAccountProviderPresenter(
val accountProvider by accountProviderDataSource.flow.collectAsState()
val loginModeState = loginModePresenter.present()
val changeServerState = changeServerPresenter.present()
val homeserverHistory by appPreferencesStore.getHomeserverHistoryFlow().collectAsState(emptyList())
val homeserverHistory by remember {
appPreferencesStore.getHomeserverHistoryFlow().map { history ->
history.map { AccountProvider.Generic(it) }
}
}.collectAsState(emptyList())
// The account providers offered for autocomplete: previously-used ones first, then the
// enterprise/MDM-configured allow-list, then matrix.org (always available, even before any sign-in).
// The "*" wildcard is a routing marker, not a real provider, so it is filtered out. Everything is
// rendered without the https:// scheme (added back at connection time).
// Everything is rendered without the https:// scheme (added back at connection time).
val autocompleteCandidates = remember(homeserverHistory) {
(homeserverHistory + enterpriseService.homeserverAllowList() + AuthenticationConfig.MATRIX_ORG_URL)
.filter { it != EnterpriseService.ANY_ACCOUNT_PROVIDER }
.map { it.withoutScheme() }
(homeserverHistory + enterpriseService.accountProviderAllowList() + matrixOrgAccountProvider)
.map { it.friendlyServerName() }
.distinct()
}
// Editable input, seeded from the current (history-defaulted) account provider until the user edits it.
// Displayed without the scheme, so the field shows e.g. "matrix.org" rather than "https://matrix.org".
var userInput by rememberSaveable { mutableStateOf<String?>(null) }
val accountProviderInput = userInput ?: accountProvider.url.withoutScheme()
val accountProviderInput = userInput ?: accountProvider.friendlyServerName()
// Offer the first candidate that the current input is a (case-insensitive) prefix of.
val accountProviderSuggestion = remember(accountProviderInput, autocompleteCandidates) {
@@ -134,7 +137,7 @@ class ConfirmAccountProviderPresenter(
val accountProviderUrl = host.ensureProtocol()
submittedAccountProvider = accountProviderUrl
changeServerState.eventSink(
ChangeServerEvent.ChangeServer(AccountProvider(url = accountProviderUrl))
ChangeServerEvent.ChangeServer(AccountProvider.Generic(host))
)
}
ConfirmAccountProviderEvent.ClearError -> {
@@ -154,9 +157,3 @@ class ConfirmAccountProviderPresenter(
)
}
}
/**
* The host part of an account provider URL, i.e. the value with any `https://` / `http://` scheme removed.
* Returns the input unchanged when it has no scheme (e.g. `matrix.org`).
*/
private fun String.withoutScheme(): String = substringAfter("://")
@@ -9,8 +9,8 @@
package io.element.android.features.login.impl.screens.loginpassword
import android.os.Parcelable
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId
import kotlinx.parcelize.Parcelize
@@ -9,9 +9,9 @@
package io.element.android.features.login.impl.screens.loginpassword
import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.accountprovider.AccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProvider
import io.element.android.features.login.impl.accountprovider.anAccountProviderManaged
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.matrix.api.core.SessionId
open class LoginPasswordStatePreviewParam : PreviewParameterProvider<LoginPasswordState> {
@@ -26,7 +26,7 @@ open class LoginPasswordStatePreviewParam : PreviewParameterProvider<LoginPasswo
}
fun aLoginPasswordState(
accountProvider: AccountProvider = anAccountProvider(),
accountProvider: AccountProvider = anAccountProviderManaged(),
formState: LoginFormState = LoginFormState.Default,
loginAction: AsyncData<SessionId> = AsyncData.Uninitialized,
eventSink: (LoginPasswordEvent) -> Unit = {},
@@ -131,7 +131,7 @@ fun LoginPasswordView(
iconStyle = BigIcon.Style.Default(CompoundIcons.UserProfileSolid()),
title = stringResource(
id = R.string.screen_account_provider_signin_title,
state.accountProvider.title
state.accountProvider.friendlyServerName()
),
subTitle = stringResource(id = R.string.screen_login_subtitle)
)
@@ -8,9 +8,11 @@
package io.element.android.features.login.impl.screens.onboarding
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
sealed interface OnBoardingEvent {
data class OnSignIn(
val defaultAccountProvider: String
val defaultAccountProvider: AccountProvider
) : OnBoardingEvent
data object OnVersionClick : OnBoardingEvent
@@ -22,7 +22,6 @@ import dev.zacsweers.metro.AssistedFactory
import dev.zacsweers.metro.AssistedInject
import io.element.android.appconfig.OnBoardingConfig
import io.element.android.features.enterprise.api.EnterpriseService
import io.element.android.features.enterprise.api.canConnectToAnyHomeserver
import io.element.android.features.login.impl.accesscontrol.DefaultAccountProviderAccessControl
import io.element.android.features.login.impl.accountprovider.AccountProviderDataSource
import io.element.android.features.login.impl.login.LoginModeEvent
@@ -31,9 +30,11 @@ import io.element.android.features.rageshake.api.RageshakeFeatureAvailability
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.core.meta.BuildType
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.libraries.ui.utils.MultipleTapToUnlock
import kotlinx.coroutines.launch
import timber.log.Timber
@AssistedInject
class OnBoardingPresenter(
@@ -59,27 +60,35 @@ class OnBoardingPresenter(
@Composable
override fun present(): OnBoardingState {
val localCoroutineScope = rememberCoroutineScope()
val forcedAccountProvider = remember {
// If homeserverAllowList() returns a singleton list, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
enterpriseService.homeserverAllowList().singleOrNull()
val canConnectToAnyAccountProvider = remember {
enterpriseService.canConnectToAnyAccountProvider()
}
val canConnectToAnyHomeserver = remember {
enterpriseService.canConnectToAnyHomeserver()
val forcedAccountProvider = remember {
// If accountProviderAllowList() returns a singleton list, and the user is not free to use another
// account provider, this is the default account provider.
// In this case, the user can sign in using this homeserver, or use QrCode login
if (canConnectToAnyAccountProvider) {
null
} else {
enterpriseService.accountProviderAllowList().singleOrNull()
}
}
val mustChooseAccountProvider = remember {
!canConnectToAnyHomeserver && enterpriseService.homeserverAllowList().size > 1
!canConnectToAnyAccountProvider && enterpriseService.accountProviderAllowList().size > 1
}
val linkAccountProvider by produceState<String?>(initialValue = null) {
val linkAccountProvider by produceState<AccountProvider?>(initialValue = null) {
// Account provider from the link, if allowed by the enterprise service
value = params.accountProvider?.takeIf {
try {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it, it)
true
} catch (_: Exception) {
false
value = params.accountProvider
?.let { AccountProvider.Generic(it) }
?.takeIf {
try {
defaultAccountProviderAccessControl.assertIsAllowedToConnectToAccountProvider(it)
true
} catch (e: Exception) {
Timber.e(e, "Failed to check account provider from link, assuming not allowed")
false
}
}
}
}
val defaultAccountProvider = remember(linkAccountProvider) {
// If there is a forced account provider, this is the default account provider
@@ -105,11 +114,11 @@ class OnBoardingPresenter(
when (event) {
is OnBoardingEvent.OnSignIn -> localCoroutineScope.launch {
// Ensure that the current account provider is set
accountProviderDataSource.setUrl(event.defaultAccountProvider)
accountProviderDataSource.setAccountProvider(event.defaultAccountProvider)
loginModeState.eventSink(
LoginModeEvent.Submit(
isAccountCreation = false,
homeserverUrl = event.defaultAccountProvider,
homeserverUrl = event.defaultAccountProvider.serverNameOrBaseUrl(),
resolvedHomeserverUrl = null,
loginHint = params.loginHint?.takeIf { forcedAccountProvider == null },
)
@@ -134,7 +143,7 @@ class OnBoardingPresenter(
defaultAccountProvider = defaultAccountProvider,
mustChooseAccountProvider = mustChooseAccountProvider,
canLoginWithQrCode = canLoginWithQrCode,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyHomeserver && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canCreateAccount = defaultAccountProvider == null && canConnectToAnyAccountProvider && OnBoardingConfig.CAN_CREATE_ACCOUNT,
canReportBug = canReportBug && showReportBug,
loginModeState = loginModeState,
version = buildMeta.versionName,
@@ -11,13 +11,14 @@ package io.element.android.features.login.impl.screens.onboarding
import androidx.annotation.DrawableRes
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.libraries.architecture.AsyncData
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
data class OnBoardingState(
val isAddingAccount: Boolean,
val showBackButton: Boolean,
val showDeveloperSettings: Boolean,
val productionApplicationName: String,
val defaultAccountProvider: String?,
val defaultAccountProvider: AccountProvider?,
val mustChooseAccountProvider: Boolean,
val canLoginWithQrCode: Boolean,
val canCreateAccount: Boolean,
@@ -13,6 +13,7 @@ import androidx.compose.ui.tooling.preview.PreviewParameterProvider
import io.element.android.features.login.impl.login.LoginModeState
import io.element.android.features.login.impl.login.aLoginModeState
import io.element.android.libraries.designsystem.R
import io.element.android.libraries.matrix.api.accountprovider.AccountProvider
open class OnBoardingStatePreviewParam : PreviewParameterProvider<OnBoardingState> {
override val values: Sequence<OnBoardingState>
@@ -22,7 +23,11 @@ open class OnBoardingStatePreviewParam : PreviewParameterProvider<OnBoardingStat
anOnBoardingState(canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true),
anOnBoardingState(canLoginWithQrCode = true, canCreateAccount = true, canReportBug = true),
anOnBoardingState(defaultAccountProvider = "element.io", canCreateAccount = false, canReportBug = true),
anOnBoardingState(
defaultAccountProvider = AccountProvider.Generic("element.io"),
canCreateAccount = false,
canReportBug = true
),
anOnBoardingState(customLogoResId = R.drawable.sample_background),
anOnBoardingState(
isAddingAccount = true,
@@ -41,7 +46,7 @@ fun anOnBoardingState(
showBackButton: Boolean = false,
showDeveloperSettings: Boolean = false,
productionApplicationName: String = "Element",
defaultAccountProvider: String? = null,
defaultAccountProvider: AccountProvider? = null,
mustChooseAccountProvider: Boolean = false,
canLoginWithQrCode: Boolean = false,
canCreateAccount: Boolean = false,
@@ -303,7 +303,7 @@ private fun OnBoardingButtons(
)
} else {
Button(
text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider),
text = stringResource(id = R.string.screen_onboarding_sign_in_to, defaultAccountProvider.friendlyServerName()),
showProgress = isLoading,
onClick = {
state.eventSink(OnBoardingEvent.OnSignIn(defaultAccountProvider))
@@ -1,12 +0,0 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="16dp"
android:height="16dp"
android:viewportWidth="16"
android:viewportHeight="16">
<path
android:pathData="M8,0L8,0A8,8 0,0 1,16 8L16,8A8,8 0,0 1,8 16L8,16A8,8 0,0 1,0 8L0,8A8,8 0,0 1,8 0z"
android:fillColor="#101317"/>
<path
android:pathData="M5.355,5.141V5.85H5.375C5.564,5.579 5.793,5.37 6.059,5.223C6.324,5.073 6.632,5 6.976,5C7.307,5 7.609,5.065 7.883,5.192C8.157,5.319 8.363,5.548 8.507,5.87C8.662,5.641 8.874,5.438 9.139,5.263C9.405,5.088 9.721,5 10.085,5C10.362,5 10.619,5.034 10.856,5.102C11.094,5.169 11.294,5.277 11.464,5.426C11.633,5.576 11.763,5.768 11.859,6.008C11.952,6.248 12,6.536 12,6.875V10.38H10.563V7.412C10.563,7.236 10.557,7.07 10.543,6.915C10.529,6.759 10.492,6.624 10.433,6.511C10.371,6.395 10.283,6.305 10.165,6.237C10.046,6.169 9.885,6.135 9.684,6.135C9.481,6.135 9.317,6.175 9.193,6.251C9.069,6.33 8.97,6.429 8.899,6.556C8.829,6.68 8.781,6.821 8.758,6.982C8.736,7.14 8.722,7.301 8.722,7.462V10.38H7.284V7.443C7.284,7.287 7.281,7.135 7.273,6.982C7.267,6.83 7.236,6.691 7.185,6.562C7.134,6.435 7.05,6.33 6.931,6.254C6.813,6.178 6.64,6.138 6.409,6.138C6.341,6.138 6.251,6.152 6.14,6.183C6.03,6.214 5.92,6.271 5.816,6.355C5.711,6.44 5.621,6.562 5.547,6.72C5.474,6.878 5.437,7.087 5.437,7.344V10.382H4V5.141H5.355Z"
android:fillColor="#EBEEF2"/>
</vector>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Na %1$s je vyžadována aplikace Element Pro. Stáhněte si ji prosím z obchodu."</string>
<string name="screen_change_server_error_element_pro_required_title">"Vyžadován Element Pro"</string>
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám připojit k tomuto domovskému serveru. Zkontrolujte prosím, zda jste správně zadali adresu URL domovského serveru. Pokud je adresa URL správná, obraťte se na správce domovského serveru, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_invalid_homeserver">"Nepodařilo se nám spojit s tímto poskytovatelem účtu. Zkontrolujte prosím, zda jste správně zadali URL adresu poskytovatele účtu. Pokud je URL adresa správná, obraťte se na administrátora poskytovatele účtu, který vám poskytne další pomoc."</string>
<string name="screen_change_server_error_invalid_well_known">"Server není k dispozici kvůli problému se souborem well-known:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Vybraný poskytovatel účtu nepodporuje klouzavou synchronizaci. Pro použití %1$s je nutná aktualizace serveru."</string>
@@ -32,7 +32,7 @@
<string name="screen_login_error_invalid_credentials">"Nesprávné uživatelské jméno nebo heslo"</string>
<string name="screen_login_error_invalid_user_id">"Toto není platný identifikátor uživatele. Očekávaný formát: \'@user:homeserver.org\'"</string>
<string name="screen_login_error_refresh_tokens">"Tento server je nakonfigurován tak, aby používal obnovovací tokeny. Ty nejsou podporovány při použití přihlašovacích údajů založených na hesle."</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný domovský server nepodporuje přihlášení pomocí hesla nebo OAuth. Kontaktujte prosím svého správce nebo vyberte jiný domovský server."</string>
<string name="screen_login_error_unsupported_authentication">"Vybraný poskytovatel účtu nepodporuje přihlášení pomocí hesla ani OAuth. Kontaktujte prosím svého poskytovatele účtu nebo zvolte jiného."</string>
<string name="screen_login_form_header">"Zadejte své údaje"</string>
<string name="screen_login_subtitle">"Matrix je otevřená síť pro bezpečnou a decentralizovanou komunikaci."</string>
<string name="screen_login_title">"Vítejte zpět!"</string>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"Element Pro -sovellus on pakollinen %1$s -palvelimella. Lataa se sovelluskaupasta."</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro vaaditaan"</string>
<string name="screen_change_server_error_invalid_homeserver">"Kotipalvelimeen ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palvelimesi ylläpitäjään."</string>
<string name="screen_change_server_error_invalid_homeserver">"Palveluntarjoajaan ei saatu yhteyttä. Varmista, että olet syöttänyt osoitteen oikein. Jos osoite on oikein, ota yhteyttä palveluntarjoajasi ylläpitäjään."</string>
<string name="screen_change_server_error_invalid_well_known">"Palvelin ei ole saatavilla .well-known tiedostossa olevan ongelman vuoksi:
%1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"Valitsemasi palveluntarjoaja ei tue sliding syncia. Palvelimen päivitys tarvitaan %1$s -sovelluksen käyttämiseen."</string>
@@ -40,7 +40,7 @@
<string name="screen_login_error_invalid_credentials">"Väärä käyttäjänimi ja/tai salasana"</string>
<string name="screen_login_error_invalid_user_id">"Tämä ei ole kelvollinen käyttäjätunnus. Odotettu muoto: \'@käyttäjä:kotipalvelin.fi\'"</string>
<string name="screen_login_error_refresh_tokens">"Tämä palvelin on määritetty käyttämään refresh tokeneja. Näitä ei tueta salasanapohjaisen kirjautumisen kanssa."</string>
<string name="screen_login_error_unsupported_authentication">"Valitsemasi kotipalvelin ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palvelimesi ylläpitäjään tai valitse toinen kotipalvelin."</string>
<string name="screen_login_error_unsupported_authentication">"Valitsemasi palveluntarjoaja ei tue salasana- tai OAuth-kirjautumista. Ota yhteyttä palveluntarjoajaasi tai valitse toinen."</string>
<string name="screen_login_form_header">"Anna tietosi"</string>
<string name="screen_login_subtitle">"Matrix on avoin verkko turvallista, hajautettua viestintää varten."</string>
<string name="screen_login_title">"Tervetuloa takaisin!"</string>
@@ -89,7 +89,7 @@ Yritä kirjautua sisään manuaalisesti tai skannaa QR-koodi toisella laitteella
<string name="screen_qr_code_login_error_sliding_sync_not_supported_title">"%1$s -sovellusta ei tueta"</string>
<string name="screen_qr_code_login_initial_state_button_title">"Valmis skannaamaan"</string>
<string name="screen_qr_code_login_initial_state_item_1">"Avaa %1$s tietokoneella"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Napsauta avatariasi"</string>
<string name="screen_qr_code_login_initial_state_item_2">"Klikkaa avatariasi"</string>
<string name="screen_qr_code_login_initial_state_item_3">"Valitse %1$s"</string>
<string name="screen_qr_code_login_initial_state_item_3_action">"“Yhdistä uusi laite”"</string>
<string name="screen_qr_code_login_initial_state_item_4">"Skannaa QR-koodi tällä laitteella"</string>
@@ -25,8 +25,16 @@
<string name="screen_change_server_error_unauthorized_homeserver_title">"A(z) %1$s fiókszolgáltató nem engedélyezett."</string>
<string name="screen_change_server_form_header">"Matrix-kiszolgáló webcíme"</string>
<string name="screen_change_server_form_notice">"Adjon meg egy domaincímet."</string>
<string name="screen_change_server_navigation_title_login">"Bejelentkezés"</string>
<string name="screen_change_server_navigation_title_register">"Fiók létrehozása"</string>
<string name="screen_change_server_subtitle">"Mi a kiszolgálója címe?"</string>
<string name="screen_change_server_textfield_footer_login">"Ha fiókját a vállalata vagy egy közösség biztosítja, akkor módosítsa a fiók szolgáltatóját (például: companyname.com)."</string>
<string name="screen_change_server_textfield_footer_register">"Hozzon létre ingyenes fiókot a matrix.org oldalon. Ha más szolgáltatónál szeretne fiókot létrehozni, akkor módosítsa a nevet ennek megfelelően (például: communityname.org)."</string>
<string name="screen_change_server_textfield_header">"Fiókszolgáltató"</string>
<string name="screen_change_server_textfield_placeholder">"example.com"</string>
<string name="screen_change_server_title">"Válassza ki a kiszolgálóját"</string>
<string name="screen_change_server_title_login">"Adja meg a fiókszolgáltatóját"</string>
<string name="screen_change_server_title_register">"Válasszon fiókszolgáltatót"</string>
<string name="screen_create_account_title">"Fiók létrehozása"</string>
<string name="screen_login_error_deactivated_account">"Ez a fiók törölve lett."</string>
<string name="screen_login_error_invalid_credentials">"Helytelen felhasználónév vagy jelszó"</string>
@@ -16,7 +16,7 @@
<string name="screen_change_server_error_element_pro_required_action_android">"Google Play"</string>
<string name="screen_change_server_error_element_pro_required_message">"%1$s では Element Pro を使用する必要があります。アプリストアよりダウンロードしてください。"</string>
<string name="screen_change_server_error_element_pro_required_title">"Element Pro が必要です"</string>
<string name="screen_change_server_error_invalid_homeserver">"このホームサーバーに接続できませんでした。正しいURLを入力したことを確認し、問題が継続する場合は、ホームサーバーの管理者に問い合わせてください。"</string>
<string name="screen_change_server_error_invalid_homeserver">"このアカウント提供元に接続できませんでした。正しいURLを入力したことを確認し、問題が継続する場合は、アカウント提供元の管理者に問い合わせてください。"</string>
<string name="screen_change_server_error_invalid_well_known">".well-knownファイルに問題があるためサーバーを使用できません: %1$s"</string>
<string name="screen_change_server_error_no_sliding_sync_message">"このアカウント提供元は、スライド同期に対応していません。%1$s を使用するにはサーバーのアップグレードが必要です。"</string>
<string name="screen_change_server_error_unauthorized_homeserver">"%2$s は %1$s からの接続を許可していません。"</string>
@@ -39,7 +39,7 @@
<string name="screen_login_error_invalid_credentials">"ユーザー名またはパスワードが違います"</string>
<string name="screen_login_error_invalid_user_id">"無効なユーザーIDです。正しい形式は \"@ユーザー:ホームサーバー\" です。"</string>
<string name="screen_login_error_refresh_tokens">"このサーバーはリフレッシュトークンを使用します。パスワードを使用したログインとは併用できません。"</string>
<string name="screen_login_error_unsupported_authentication">"指定したホームサーバはパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるホームサーバーを使用してください。"</string>
<string name="screen_login_error_unsupported_authentication">"指定したアカウント提供元はパスワードまたはOAuthによるログインに対応していません。管理者に問い合わせるか、異なるアカウント提供元を使用してください。"</string>
<string name="screen_login_form_header">"詳細を入力"</string>
<string name="screen_login_subtitle">"Matrix は安全で分散型のオープンなネットワークです。"</string>
<string name="screen_login_title">"お待ちしておりました。"</string>

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