mirror of
https://github.com/Novaloop-AG/element-x-android.git
synced 2026-11-02 21:32:34 +00:00
Merge branch 'develop' into feature/fga/timeline-multi-forward
This commit is contained in:
@@ -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>
|
||||
|
||||
+12
-18
@@ -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)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-2
@@ -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 ->
|
||||
|
||||
+11
-4
@@ -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
|
||||
|
||||
+12
-6
@@ -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 {
|
||||
|
||||
+6
-1
@@ -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 {
|
||||
|
||||
@@ -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)
|
||||
|
||||
+1
@@ -36,6 +36,7 @@ class CurrentUserWithNeighborsBuilder {
|
||||
userId = UserId(it.userId),
|
||||
displayName = it.userDisplayName,
|
||||
avatarUrl = it.userAvatarUrl,
|
||||
avatarThumbnail = it.userAvatarData,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+1
-15
@@ -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,
|
||||
|
||||
+8
-10
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+18
@@ -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(),
|
||||
|
||||
+18
@@ -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
|
||||
}
|
||||
|
||||
+47
-4
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+15
@@ -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?,
|
||||
|
||||
+121
-57
@@ -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
|
||||
)
|
||||
|
||||
+204
-78
@@ -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())
|
||||
}
|
||||
|
||||
+47
@@ -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()
|
||||
}
|
||||
}
|
||||
+96
@@ -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") }
|
||||
}
|
||||
}
|
||||
+34
@@ -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
|
||||
}
|
||||
+26
@@ -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
|
||||
}
|
||||
+42
@@ -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>
|
||||
|
||||
+26
@@ -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",
|
||||
)
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+4
-4
@@ -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 {
|
||||
|
||||
-1
@@ -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()
|
||||
|
||||
+95
-5
@@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
+275
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
+121
@@ -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 } }
|
||||
)
|
||||
}
|
||||
+33
@@ -0,0 +1,33 @@
|
||||
/*
|
||||
* Copyright (c) 2026 Element Creations Ltd.
|
||||
*
|
||||
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial.
|
||||
* Please see LICENSE files in the repository root for full details.
|
||||
*/
|
||||
|
||||
package io.element.android.features.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
|
||||
}
|
||||
}
|
||||
+28
@@ -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()
|
||||
}
|
||||
}
|
||||
+50
@@ -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()
|
||||
}
|
||||
}
|
||||
-1
@@ -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()
|
||||
|
||||
+1
@@ -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
|
||||
|
||||
+3
-3
@@ -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>
|
||||
|
||||
-1
@@ -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()
|
||||
|
||||
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+59
-42
@@ -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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+7
-8
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+9
-10
@@ -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(),
|
||||
)
|
||||
}
|
||||
|
||||
+3
-3
@@ -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())
|
||||
|
||||
+2
-2
@@ -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
|
||||
|
||||
+23
-14
@@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+4
-5
@@ -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()
|
||||
}
|
||||
|
||||
+17
-5
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+91
-57
@@ -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 {
|
||||
|
||||
+2
-2
@@ -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)
|
||||
|
||||
+85
-40
@@ -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>
|
||||
|
||||
+39
@@ -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()
|
||||
|
||||
+2
-2
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -16,4 +16,5 @@ android {
|
||||
|
||||
dependencies {
|
||||
implementation(projects.libraries.architecture)
|
||||
implementation(projects.libraries.matrix.api)
|
||||
}
|
||||
|
||||
+4
-2
@@ -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
|
||||
}
|
||||
|
||||
+10
-10
@@ -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() },
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
-17
@@ -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,
|
||||
)
|
||||
+10
-21
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
+16
-16
@@ -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,
|
||||
)
|
||||
|
||||
+7
-36
@@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
+6
-3
@@ -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())
|
||||
}
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-7
@@ -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
|
||||
}
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
+4
-16
@@ -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(
|
||||
|
||||
+1
-1
@@ -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
-19
@@ -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(),
|
||||
|
||||
+1
-9
@@ -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))
|
||||
|
||||
+13
-16
@@ -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("://")
|
||||
|
||||
+1
-1
@@ -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
|
||||
|
||||
|
||||
+3
-3
@@ -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 = {},
|
||||
|
||||
+1
-1
@@ -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)
|
||||
)
|
||||
|
||||
+3
-1
@@ -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
|
||||
|
||||
+28
-19
@@ -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,
|
||||
|
||||
+2
-1
@@ -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,
|
||||
|
||||
+7
-2
@@ -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,
|
||||
|
||||
+1
-1
@@ -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
Reference in New Issue
Block a user