Merge branch 'develop' into feature/fga/pdf_renderer

This commit is contained in:
ganfra 2023-06-02 16:43:55 +02:00
commit 26adc55ea9
435 changed files with 6832 additions and 1041 deletions

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Nebyla nalezena žádná kompatibilní aplikace, která by tuto akci zpracovala."</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Keine kompatible App für diese Aktion gefunden."</string>
</resources>

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="error_no_compatible_app_found">"Aucune application compatible n\'a été trouvée pour gérer cette action."</string>
</resources>

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.core.extensions
fun Boolean.toOnOff() = if (this) "ON" else "OFF"
fun Boolean.to01() = if (this) "1" else "0"
inline fun <T> T.ooi(block: (T) -> Unit): T = also(block)

View file

@ -31,6 +31,7 @@ dependencies {
implementation(libs.dagger)
implementation(libs.androidx.corektx)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.architecture)
testImplementation(libs.test.junit)
testImplementation(libs.test.truth)

View file

@ -79,3 +79,6 @@ val Compound_Gray_300_Light = Color(0xFFF0F2F5)
val Compound_Gray_300_Dark = Color(0xFF1D1F24)
val Compound_Gray_400_Light = Color(0xFFE1E6EC)
val Compound_Gray_400_Dark = Color(0xFF26282D)
val Gray_1400_Light = Color(0xFF1B1D22)
val Gray_1400_Dark = Color(0xFFEBEEF2)

View file

@ -0,0 +1,87 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.theme.components.TextField
@Composable
fun LabelledTextField(
label: String,
value: String,
modifier: Modifier = Modifier,
placeholder: String? = null,
maxLines: Int = Int.MAX_VALUE,
singleLine: Boolean = false,
onValueChange: (String) -> Unit = {},
) {
Column(
modifier = modifier,
verticalArrangement = Arrangement.spacedBy(8.dp),
) {
Text(
modifier = Modifier.padding(horizontal = 16.dp),
style = MaterialTheme.typography.titleSmall,
color = MaterialTheme.colorScheme.primary,
text = label
)
TextField(
modifier = Modifier.fillMaxWidth(),
value = value,
placeholder = placeholder?.let { { Text(placeholder) } },
onValueChange = onValueChange,
singleLine = singleLine,
maxLines = maxLines,
)
}
}
@Preview
@Composable
fun LabelledTextFieldLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun LabelledTextFieldDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Column {
LabelledTextField(
label = "Room name",
value = "",
placeholder = "e.g. Product Sprint",
)
LabelledTextField(
label = "Room name",
value = "a room name",
placeholder = "e.g. Product Sprint",
)
}
}

View file

@ -0,0 +1,70 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Button
import io.element.android.libraries.designsystem.theme.components.Text
@Composable
fun AsyncFailure(
throwable: Throwable,
onRetry: (() -> Unit)?,
modifier: Modifier = Modifier,
) {
Column(
modifier = modifier
.fillMaxWidth()
.padding(vertical = 32.dp),
horizontalAlignment = Alignment.CenterHorizontally,
) {
Text(text = throwable.message ?: "An error occurred")
if (onRetry != null) {
Spacer(modifier = Modifier.height(24.dp))
Button(onClick = onRetry) {
Text(text = "Retry")
}
}
}
}
@Preview
@Composable
internal fun AsyncFailurePreviewLight() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun AsyncFailurePreviewDark() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AsyncFailure(
throwable = IllegalStateException("An error occurred"),
onRetry = {}
)
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.designsystem.components.async
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.CircularProgressIndicator
@Composable
fun AsyncLoading(modifier: Modifier = Modifier) {
Box(
modifier = modifier
.fillMaxWidth()
.height(120.dp),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
}
@Preview
@Composable
internal fun AsyncLoadingPreviewLight() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
internal fun AsyncLoadingPreviewDark() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AsyncLoading()
}

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.designsystem.Black_800
import io.element.android.libraries.designsystem.Black_950
import io.element.android.libraries.designsystem.Compound_Gray_300_Dark
import io.element.android.libraries.designsystem.DarkGrey
import io.element.android.libraries.designsystem.Gray_1400_Dark
import io.element.android.libraries.designsystem.Gray_300
import io.element.android.libraries.designsystem.Gray_400
import io.element.android.libraries.designsystem.Compound_Gray_400_Dark
@ -42,6 +43,7 @@ fun elementColorsDark() = ElementColors(
quinary = Gray_450,
gray300 = Compound_Gray_300_Dark,
gray400 = Compound_Gray_400_Dark,
gray1400 = Gray_1400_Dark,
textActionCritical = TextColorCriticalDark,
isLight = false,
)

View file

@ -22,12 +22,13 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.Azure
import io.element.android.libraries.designsystem.Black_900
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
import io.element.android.libraries.designsystem.Gray_100
import io.element.android.libraries.designsystem.Gray_1400_Light
import io.element.android.libraries.designsystem.Gray_150
import io.element.android.libraries.designsystem.Gray_200
import io.element.android.libraries.designsystem.Gray_25
import io.element.android.libraries.designsystem.Compound_Gray_300_Light
import io.element.android.libraries.designsystem.Compound_Gray_400_Light
import io.element.android.libraries.designsystem.Gray_50
import io.element.android.libraries.designsystem.SystemGrey5Light
import io.element.android.libraries.designsystem.SystemGrey6Light
@ -42,6 +43,7 @@ fun elementColorsLight() = ElementColors(
quinary = Gray_50,
gray300 = Compound_Gray_300_Light,
gray400 = Compound_Gray_400_Light,
gray1400 = Gray_1400_Light,
textActionCritical = TextColorCriticalLight,
isLight = true,
)

View file

@ -31,6 +31,7 @@ class ElementColors(
quinary: Color,
gray300: Color,
gray400: Color,
gray1400: Color,
textActionCritical: Color,
isLight: Boolean
) {
@ -53,6 +54,9 @@ class ElementColors(
var gray400 by mutableStateOf(gray400)
private set
var gray1400 by mutableStateOf(gray1400)
private set
var textActionCritical by mutableStateOf(textActionCritical)
private set
@ -67,6 +71,7 @@ class ElementColors(
quinary: Color = this.quinary,
gray300: Color = this.gray300,
gray400: Color = this.gray400,
gray1400: Color = this.gray1400,
textActionCritical: Color = this.textActionCritical,
isLight: Boolean = this.isLight,
) = ElementColors(
@ -77,6 +82,7 @@ class ElementColors(
quinary = quinary,
gray300 = gray300,
gray400 = gray400,
gray1400 = gray1400,
textActionCritical = textActionCritical,
isLight = isLight,
)
@ -89,6 +95,7 @@ class ElementColors(
quinary = other.quinary
gray300 = other.gray300
gray400 = other.gray400
gray1400 = other.gray1400
textActionCritical = other.textActionCritical
isLight = other.isLight
}

View file

@ -1,17 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(Avatar wurde ebenfalls geändert)"</string>
<string name="state_event_avatar_url_changed">"%1$s hat seinen Avatar geändert"</string>
<string name="state_event_avatar_changed_too">"(Profilbild wurde auch geändert)"</string>
<string name="state_event_avatar_url_changed">"%1$s hat sein Profilbild geändert"</string>
<string name="state_event_avatar_url_changed_by_you">"Du hast deinen Avatar geändert"</string>
<string name="state_event_display_name_changed_from">"%1$s hat den Anzeigenamen von %2$s in %3$s geändert"</string>
<string name="state_event_display_name_changed_from">"%1$s hat seinen Anzeigenamen von %2$s in %3$s geändert"</string>
<string name="state_event_display_name_changed_from_by_you">"Du hast deinen Anzeigenamen von %1$s in %2$s geändert"</string>
<string name="state_event_display_name_removed">"%1$s hat den Anzeigenamen entfernt (war %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (war %1$s)"</string>
<string name="state_event_display_name_set">"%1$s hat den Anzeigenamen auf %2$s gesetzt"</string>
<string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen auf %1$s gesetzt"</string>
<string name="state_event_display_name_removed">"%1$s hat seinen Anzeigenamen entfernt (es war %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Du hast deinen Anzeigenamen entfernt (es war %1$s)"</string>
<string name="state_event_display_name_set">"%1$s hat seinen Anzeigenamen zu %2$s geändert"</string>
<string name="state_event_display_name_set_by_you">"Du hast deinen Anzeigenamen auf %1$s geändert"</string>
<string name="state_event_room_avatar_changed">"%1$s hat den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_changed_by_you">"Du hast den Raum-Avatar geändert"</string>
<string name="state_event_room_avatar_removed">"%1$s hat den Raum-Avatar entfernt"</string>
<string name="state_event_room_avatar_removed">"%1$s hat das Raumbild entfernt"</string>
<string name="state_event_room_avatar_removed_by_you">"Du hast das Raumbild entfernt"</string>
<string name="state_event_room_ban">"%1$s hat %2$s gebannt"</string>
<string name="state_event_room_ban_by_you">"Du hast %1$s gebannt"</string>
<string name="state_event_room_created">"%1$s hat den Raum erstellt"</string>
<string name="state_event_room_created_by_you">"Du hast den Raum erstellt"</string>
<string name="state_event_room_invite">"%1$s hat %2$s eingeladen"</string>
@ -21,19 +24,34 @@
<string name="state_event_room_invite_you">"%1$s hat dich eingeladen"</string>
<string name="state_event_room_join">"%1$s ist dem Raum beigetreten"</string>
<string name="state_event_room_join_by_you">"Du bist dem Raum beigetreten"</string>
<string name="state_event_room_knock">"%1$s hat um Beitritt gebeten"</string>
<string name="state_event_room_knock_accepted">"%1$s hat %2$s erlaubt, beizutreten"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s hat dir erlaubt beizutreten"</string>
<string name="state_event_room_knock_by_you">"Du hast um Beitritt gebeten"</string>
<string name="state_event_room_knock_denied">"%1$s hat die Beitrittsanfrage von %2$s abgelehnt"</string>
<string name="state_event_room_knock_denied_by_you">"Du hast die Beitrittsanfrage von %1$s abgelehnt"</string>
<string name="state_event_room_knock_denied_you">"%1$s hat deine Beitrittsanfrage abgelehnt"</string>
<string name="state_event_room_knock_retracted">"%1$s ist nicht mehr daran interessiert, beizutreten"</string>
<string name="state_event_room_knock_retracted_by_you">"Du hast deine Beitrittsanfrage zurückgezogen"</string>
<string name="state_event_room_leave">"%1$s hat den Raum verlassen"</string>
<string name="state_event_room_leave_by_you">"Du hast den Raum verlassen"</string>
<string name="state_event_room_name_changed">"%1$s hat den Raumnamen geändert in: %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Sie haben den Raumnamen geändert in: %1$s"</string>
<string name="state_event_room_name_changed_by_you">"Du hast den Raumnamen geändert in: %1$s"</string>
<string name="state_event_room_name_removed">"%1$s hat den Raumnamen entfernt"</string>
<string name="state_event_room_name_removed_by_you">"Du hast den Raumnamen entfernt"</string>
<string name="state_event_room_reject">"%1$s hat die Einladung abgelehnt"</string>
<string name="state_event_room_reject_by_you">"Du hast die Einladung abgelehnt"</string>
<string name="state_event_room_remove">"%1$s hat %2$s entfernt"</string>
<string name="state_event_room_remove_by_you">"Du hast %1$s entfernt"</string>
<string name="state_event_room_third_party_invite">"%1$s hat eine Einladung an %2$s gesendet, um dem Raum beizutreten"</string>
<string name="state_event_room_third_party_invite_by_you">"Du hast eine Einladung an %1$s gesendet, um dem Raum beizutreten"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s hat die Einladung für %2$s widerrufen, dem Raum beizutreten"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Du hast die Einladung für %1$s widerrufen, dem Raum beizutreten"</string>
<string name="state_event_room_topic_changed">"%1$s hat das Thema geändert zu: %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Sie haben das Thema geändert zu: %1$s"</string>
<string name="state_event_room_topic_changed_by_you">"Du hast das Thema geändert zu: %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s hat das Raumthema entfernt"</string>
<string name="state_event_room_topic_removed_by_you">"Du hast das Raumthema entfernt"</string>
<string name="state_event_room_unban">"%1$s hat %2$s entbannt"</string>
<string name="state_event_room_unban_by_you">"Du hast %1$s entbannt"</string>
<string name="state_event_room_unknown_membership_change">"%1$s hat eine unbekannte Änderung an seiner Mitgliedschaft vorgenommen"</string>
</resources>

View file

@ -0,0 +1,57 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="state_event_avatar_changed_too">"(l\'avatar a aussi été modifié)"</string>
<string name="state_event_avatar_url_changed">"%1$s a changé son avatar"</string>
<string name="state_event_avatar_url_changed_by_you">"Vous avez changé d\'avatar"</string>
<string name="state_event_display_name_changed_from">"%1$s a changé son nom d\'affichage de %2$s à %3$s"</string>
<string name="state_event_display_name_changed_from_by_you">"Vous avez changé votre nom d\'affichage de %1$s à %2$s"</string>
<string name="state_event_display_name_removed">"%1$s a supprimé son nom d\'affichage (il s\'agissait de %2$s)"</string>
<string name="state_event_display_name_removed_by_you">"Vous avez supprimé votre nom d\'affichage (il s\'agissait de %1$s)"</string>
<string name="state_event_display_name_set">"%1$s a défini son nom d\'affichage en tant que %2$s"</string>
<string name="state_event_display_name_set_by_you">"Vous avez défini votre nom d\'affichage en tant que %1$s"</string>
<string name="state_event_room_avatar_changed">"%1$s a changé l\'avatar de la salle"</string>
<string name="state_event_room_avatar_changed_by_you">"Vous avez changé l\'avatar de la salle"</string>
<string name="state_event_room_avatar_removed">"%1$s a supprimé l\'avatar de la salle"</string>
<string name="state_event_room_avatar_removed_by_you">"Vous avez supprimé l\'avatar de la salle"</string>
<string name="state_event_room_ban">"%1$s a banni %2$s"</string>
<string name="state_event_room_ban_by_you">"Vous avez banni %1$s"</string>
<string name="state_event_room_created">"%1$s a créé la salle"</string>
<string name="state_event_room_created_by_you">"Vous avez créé la salle"</string>
<string name="state_event_room_invite">"%1$s a invité %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s a accepté l\'invitation"</string>
<string name="state_event_room_invite_accepted_by_you">"Vous avez accepté l\'invitation"</string>
<string name="state_event_room_invite_by_you">"Vous avez invité %1$s"</string>
<string name="state_event_room_invite_you">"%1$s vous a invité."</string>
<string name="state_event_room_join">"%1$s a rejoint la salle"</string>
<string name="state_event_room_join_by_you">"Vous avez rejoint la salle"</string>
<string name="state_event_room_knock">"%1$s a demandé à rejoindre"</string>
<string name="state_event_room_knock_accepted">"%1$s a autorisé %2$s à rejoindre"</string>
<string name="state_event_room_knock_accepted_by_you">"%1$s vous a autorisé à rejoindre"</string>
<string name="state_event_room_knock_by_you">"Vous avez demandé à rejoindre"</string>
<string name="state_event_room_knock_denied">"%1$s a rejeté la demande d\'adhésion de %2$s"</string>
<string name="state_event_room_knock_denied_by_you">"Vous avez rejeté la demande d\'adhésion de %1$s"</string>
<string name="state_event_room_knock_denied_you">"%1$s a rejeté votre demande d\'adhésion"</string>
<string name="state_event_room_knock_retracted">"%1$s nest plus intéressé à rejoindre"</string>
<string name="state_event_room_knock_retracted_by_you">"Vous avez annulé votre demande d\'adhésion"</string>
<string name="state_event_room_leave">"%1$s a quitté la salle"</string>
<string name="state_event_room_leave_by_you">"Vous avez quitté la salle"</string>
<string name="state_event_room_name_changed">"%1$s a changé le nom de la salle en : %2$s"</string>
<string name="state_event_room_name_changed_by_you">"Vous avez changé le nom de la salle en : %1$s"</string>
<string name="state_event_room_name_removed">"%1$s a supprimé le nom de la salle"</string>
<string name="state_event_room_name_removed_by_you">"Vous avez supprimé le nom de la salle"</string>
<string name="state_event_room_reject">"%1$s a rejeté l\'invitation"</string>
<string name="state_event_room_reject_by_you">"Vous avez refusé l\'invitation"</string>
<string name="state_event_room_remove">"%1$s a supprimé %2$s"</string>
<string name="state_event_room_remove_by_you">"Vous avez supprimé %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s a envoyé une invitation à %2$s à rejoindre la salle"</string>
<string name="state_event_room_third_party_invite_by_you">"Vous avez envoyé une invitation à %1$s pour rejoindre la salle"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s a révoqué l\'invitation de %2$s à rejoindre la salle"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Vous avez révoqué l\'invitation de %1$s à rejoindre la salle"</string>
<string name="state_event_room_topic_changed">"%1$s a changé le sujet en : %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Vous avez changé le sujet en : %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s a supprimé le sujet de la salle"</string>
<string name="state_event_room_topic_removed_by_you">"Vous avez supprimé le sujet de la salle"</string>
<string name="state_event_room_unban">"%1$s a débanni %2$s"</string>
<string name="state_event_room_unban_by_you">"Vous avez débanni %1$s"</string>
<string name="state_event_room_unknown_membership_change">"%1$s a apporté une modification inconnue à son adhésion"</string>
</resources>

View file

@ -22,4 +22,5 @@ sealed class AuthenticationException(message: String) : Exception(message) {
class SlidingSyncNotAvailable(message: String) : AuthenticationException(message)
class SessionMissing(message: String) : AuthenticationException(message)
class Generic(message: String) : AuthenticationException(message)
class OidcError(type: String, message: String) : AuthenticationException(message)
}

View file

@ -28,4 +28,23 @@ interface MatrixAuthenticationService {
fun getHomeserverDetails(): StateFlow<MatrixHomeServerDetails?>
suspend fun setHomeserver(homeserver: String): Result<Unit>
suspend fun login(username: String, password: String): Result<SessionId>
/*
* OIDC part.
*/
/**
* Get the Oidc url to display to the user.
*/
suspend fun getOidcUrl(): Result<OidcDetails>
/**
* Cancel Oidc login sequence.
*/
suspend fun cancelOidcLogin(): Result<Unit>
/**
* Attempt to login using the [callbackUrl] provided by the Oidc page.
*/
suspend fun loginWithOidc(callbackUrl: String): Result<SessionId>
}

View file

@ -23,5 +23,5 @@ import kotlinx.parcelize.Parcelize
data class MatrixHomeServerDetails(
val url: String,
val supportsPasswordLogin: Boolean,
val authenticationIssuer: String?
val supportsOidcLogin: Boolean,
): Parcelable

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth
object OidcConfig {
const val redirectUri = "io.element:/callback"
}

View file

@ -0,0 +1,25 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.auth
import android.os.Parcelable
import kotlinx.parcelize.Parcelize
@Parcelize
data class OidcDetails(
val url: String,
) : Parcelable

View file

@ -34,7 +34,7 @@ object MatrixPatterns {
val PATTERN_CONTAIN_MATRIX_USER_IDENTIFIER = MATRIX_USER_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
// regex pattern to find room ids in a string.
private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9]+$DOMAIN_REGEX"
private const val MATRIX_ROOM_IDENTIFIER_REGEX = "![A-Z0-9-]+$DOMAIN_REGEX"
private val PATTERN_CONTAIN_MATRIX_ROOM_IDENTIFIER = MATRIX_ROOM_IDENTIFIER_REGEX.toRegex(RegexOption.IGNORE_CASE)
// regex pattern to find room aliases in a string.

View file

@ -20,15 +20,23 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.UserId
//TODO add content
data class NotificationData(
val senderId: UserId,
val eventId: EventId,
val roomId: RoomId,
val senderAvatarUrl: String? = null,
val senderDisplayName: String? = null,
val roomAvatarUrl: String? = null,
val senderAvatarUrl: String?,
val senderDisplayName: String?,
val roomAvatarUrl: String?,
val roomDisplayName: String?,
val isDirect: Boolean,
val isEncrypted: Boolean,
val isNoisy: Boolean,
val event: NotificationEvent,
)
data class NotificationEvent(
val timestamp: Long,
val content: String,
// For images for instance
val contentUrl: String?
)

View file

@ -89,4 +89,14 @@ interface MatrixRoom : Closeable {
suspend fun inviteUserById(id: UserId): Result<Unit>
suspend fun canInvite(): Result<Boolean>
suspend fun canSendStateEvent(type: StateEventType): Result<Boolean>
suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit>
suspend fun removeAvatar(): Result<Unit>
suspend fun setName(name: String): Result<Unit>
suspend fun setTopic(topic: String): Result<Unit>
}

View file

@ -0,0 +1,41 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.room
enum class StateEventType {
POLICY_RULE_ROOM,
POLICY_RULE_SERVER,
POLICY_RULE_USER,
ROOM_ALIASES,
ROOM_AVATAR,
ROOM_CANONICAL_ALIAS,
ROOM_CREATE,
ROOM_ENCRYPTION,
ROOM_GUEST_ACCESS,
ROOM_HISTORY_VISIBILITY,
ROOM_JOIN_RULES,
ROOM_MEMBER_EVENT,
ROOM_NAME,
ROOM_PINNED_EVENTS,
ROOM_POWER_LEVELS,
ROOM_SERVER_ACL,
ROOM_THIRD_PARTY_INVITE,
ROOM_TOMBSTONE,
ROOM_TOPIC,
SPACE_CHILD,
SPACE_PARENT;
}

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.api.timeline.item.event
/**
* Constants defining known event types from Matrix specifications.
*/
object EventType {
const val PRESENCE = "m.presence"
const val MESSAGE = "m.room.message"
const val STICKER = "m.sticker"
const val ENCRYPTED = "m.room.encrypted"
const val FEEDBACK = "m.room.message.feedback"
const val TYPING = "m.typing"
const val REDACTION = "m.room.redaction"
const val RECEIPT = "m.receipt"
const val ROOM_KEY = "m.room_key"
const val PLUMBING = "m.room.plumbing"
const val BOT_OPTIONS = "m.room.bot.options"
const val PREVIEW_URLS = "org.matrix.room.preview_urls"
// State Events
const val STATE_ROOM_WIDGET_LEGACY = "im.vector.modular.widgets"
const val STATE_ROOM_WIDGET = "m.widget"
const val STATE_ROOM_NAME = "m.room.name"
const val STATE_ROOM_TOPIC = "m.room.topic"
const val STATE_ROOM_AVATAR = "m.room.avatar"
const val STATE_ROOM_MEMBER = "m.room.member"
const val STATE_ROOM_THIRD_PARTY_INVITE = "m.room.third_party_invite"
const val STATE_ROOM_CREATE = "m.room.create"
const val STATE_ROOM_JOIN_RULES = "m.room.join_rules"
const val STATE_ROOM_GUEST_ACCESS = "m.room.guest_access"
const val STATE_ROOM_POWER_LEVELS = "m.room.power_levels"
const val STATE_SPACE_CHILD = "m.space.child"
const val STATE_SPACE_PARENT = "m.space.parent"
/**
* Note that this Event has been deprecated, see
* - https://matrix.org/docs/spec/client_server/r0.6.1#historical-events
* - https://github.com/matrix-org/matrix-doc/pull/2432
*/
const val STATE_ROOM_ALIASES = "m.room.aliases"
const val STATE_ROOM_TOMBSTONE = "m.room.tombstone"
const val STATE_ROOM_CANONICAL_ALIAS = "m.room.canonical_alias"
const val STATE_ROOM_HISTORY_VISIBILITY = "m.room.history_visibility"
const val STATE_ROOM_RELATED_GROUPS = "m.room.related_groups"
const val STATE_ROOM_PINNED_EVENT = "m.room.pinned_events"
const val STATE_ROOM_ENCRYPTION = "m.room.encryption"
const val STATE_ROOM_SERVER_ACL = "m.room.server_acl"
// Call Events
const val CALL_INVITE = "m.call.invite"
const val CALL_CANDIDATES = "m.call.candidates"
const val CALL_ANSWER = "m.call.answer"
const val CALL_SELECT_ANSWER = "m.call.select_answer"
const val CALL_NEGOTIATE = "m.call.negotiate"
const val CALL_REJECT = "m.call.reject"
const val CALL_HANGUP = "m.call.hangup"
// This type is not processed by the client, just sent to the server
const val CALL_REPLACES = "m.call.replaces"
// Key share events
const val ROOM_KEY_REQUEST = "m.room_key_request"
const val FORWARDED_ROOM_KEY = "m.forwarded_room_key"
const val REQUEST_SECRET = "m.secret.request"
const val SEND_SECRET = "m.secret.send"
// Relation Events
const val REACTION = "m.reaction"
fun isCallEvent(type: String): Boolean {
return type == CALL_INVITE ||
type == CALL_CANDIDATES ||
type == CALL_ANSWER ||
type == CALL_HANGUP ||
type == CALL_SELECT_ANSWER ||
type == CALL_NEGOTIATE ||
type == CALL_REJECT ||
type == CALL_REPLACES
}
}

View file

@ -32,6 +32,7 @@ dependencies {
// api(projects.libraries.rustsdk)
implementation(libs.matrix.sdk)
implementation(projects.libraries.di)
implementation(projects.services.toolbox.api)
api(projects.libraries.matrix.api)
implementation(libs.dagger)
implementation(projects.libraries.core)

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.pusher.PushersService
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.RoomMembershipObserver
import io.element.android.libraries.matrix.api.room.RoomSummaryDataSource
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixSearchUserResults
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.api.verification.SessionVerificationService
@ -44,6 +45,7 @@ import io.element.android.libraries.matrix.impl.usersearch.UserProfileMapper
import io.element.android.libraries.matrix.impl.usersearch.UserSearchResultMapper
import io.element.android.libraries.matrix.impl.verification.RustSessionVerificationService
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
@ -77,6 +79,7 @@ class RustMatrixClient constructor(
private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val baseDirectory: File,
private val clock: SystemClock,
) : MatrixClient {
override val sessionId: UserId = UserId(client.userId())
@ -114,9 +117,9 @@ class RustMatrixClient constructor(
.timelineLimit(limit = 1u)
.requiredState(
requiredState = listOf(
RequiredState(key = "m.room.avatar", value = ""),
RequiredState(key = "m.room.encryption", value = ""),
RequiredState(key = "m.room.join_rules", value = ""),
RequiredState(key = EventType.STATE_ROOM_AVATAR, value = ""),
RequiredState(key = EventType.STATE_ROOM_ENCRYPTION, value = ""),
RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""),
)
)
.filters(visibleRoomsSlidingSyncFilters)
@ -136,9 +139,9 @@ class RustMatrixClient constructor(
.timelineLimit(limit = 1u)
.requiredState(
requiredState = listOf(
RequiredState(key = "m.room.avatar", value = ""),
RequiredState(key = "m.room.encryption", value = ""),
RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = EventType.STATE_ROOM_AVATAR, value = ""),
RequiredState(key = EventType.STATE_ROOM_ENCRYPTION, value = ""),
RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""),
)
)
.filters(invitesSlidingSyncFilters)
@ -153,7 +156,7 @@ class RustMatrixClient constructor(
private val slidingSync = client
.slidingSync()
.homeserver("https://slidingsync.lab.matrix.org")
// .homeserver("https://slidingsync.lab.matrix.org")
.withCommonExtensions()
.storageKey("ElementX")
.addList(visibleRoomsSlidingSyncListBuilder)
@ -215,6 +218,7 @@ class RustMatrixClient constructor(
innerRoom = fullRoom,
coroutineScope = coroutineScope,
coroutineDispatchers = dispatchers,
clock = clock,
)
}

View file

@ -26,6 +26,15 @@ fun Throwable.mapAuthenticationException(): Throwable {
is RustAuthenticationException.InvalidServerName -> AuthenticationException.InvalidServerName(this.message!!)
is RustAuthenticationException.SessionMissing -> AuthenticationException.SessionMissing(this.message!!)
is RustAuthenticationException.SlidingSyncNotAvailable -> AuthenticationException.SlidingSyncNotAvailable(this.message!!)
/* TODO Oidc
is RustAuthenticationException.OidcException -> AuthenticationException.OidcError("OidcException", message!!)
is RustAuthenticationException.OidcMetadataInvalid -> AuthenticationException.OidcError("OidcMetadataInvalid", message!!)
is RustAuthenticationException.OidcMetadataMissing -> AuthenticationException.OidcError("OidcMetadataMissing", message!!)
is RustAuthenticationException.OidcNotStarted -> AuthenticationException.OidcError("OidcNotStarted", message!!)
is RustAuthenticationException.OidcNotSupported -> AuthenticationException.OidcError("OidcNotSupported", message!!)
*/
else -> this
}
}

View file

@ -23,6 +23,6 @@ fun HomeserverLoginDetails.map(): MatrixHomeServerDetails = use {
MatrixHomeServerDetails(
url = url(),
supportsPasswordLogin = supportsPasswordLogin(),
authenticationIssuer = authenticationIssuer()
supportsOidcLogin = false // TODO Oidc supportsOidcLogin(),
)
}

View file

@ -0,0 +1,32 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.auth
import io.element.android.libraries.matrix.api.auth.OidcConfig
// TODO Oidc
// import org.matrix.rustcomponents.sdk.OidcClientMetadata
/*
val oidcClientMetadata: OidcClientMetadata = OidcClientMetadata(
clientName = "Element",
redirectUri = OidcConfig.redirectUri,
clientUri = "https://element.io",
tosUri = "https://element.io/user-terms-of-service",
policyUri = "https://element.io/privacy"
)
*/

View file

@ -24,11 +24,12 @@ import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.impl.RustMatrixClient
import io.element.android.libraries.sessionstorage.api.SessionData
import io.element.android.libraries.sessionstorage.api.SessionStore
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
@ -36,6 +37,8 @@ import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.ClientBuilder
// TODO Oidc
// import org.matrix.rustcomponents.sdk.OidcAuthenticationUrl
import org.matrix.rustcomponents.sdk.Session
import org.matrix.rustcomponents.sdk.use
import java.io.File
@ -49,9 +52,16 @@ class RustMatrixAuthenticationService @Inject constructor(
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val sessionStore: SessionStore,
private val clock: SystemClock,
) : MatrixAuthenticationService {
private val authService: RustAuthenticationService = RustAuthenticationService(baseDirectory.absolutePath, null, null)
private val authService: RustAuthenticationService = RustAuthenticationService(
basePath = baseDirectory.absolutePath,
passphrase = null,
// TODO Oidc
// oidcClientMetadata = oidcClientMetadata,
customSlidingSyncProxy = null
)
private var currentHomeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
override fun isLoggedIn(): Flow<Boolean> {
@ -91,9 +101,9 @@ class RustMatrixAuthenticationService @Inject constructor(
if (homeServerDetails != null) {
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
}
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
override suspend fun login(username: String, password: String): Result<SessionId> =
@ -103,11 +113,65 @@ class RustMatrixAuthenticationService @Inject constructor(
val sessionData = client.use { it.session().toSessionData() }
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
// TODO Oidc
// private var pendingUrlForOidcLogin: OidcAuthenticationUrl? = null
override suspend fun getOidcUrl(): Result<OidcDetails> {
TODO("Oidc")
/*
return withContext(coroutineDispatchers.io) {
runCatching {
val urlForOidcLogin = authService.urlForOidcLogin()
val url = urlForOidcLogin.loginUrl()
pendingUrlForOidcLogin = urlForOidcLogin
OidcDetails(url)
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
*/
}
override suspend fun cancelOidcLogin(): Result<Unit> {
TODO("Oidc")
/*
return withContext(coroutineDispatchers.io) {
runCatching {
pendingUrlForOidcLogin?.close()
pendingUrlForOidcLogin = null
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
*/
}
/**
* callbackUrl should be the uriRedirect from OidcClientMetadata (with all the parameters).
*/
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
TODO("Oidc")
/*
return withContext(coroutineDispatchers.io) {
runCatching {
val urlForOidcLogin = pendingUrlForOidcLogin ?: error("You need to call `getOidcUrl()` first")
val client = authService.loginWithOidcCallback(urlForOidcLogin, callbackUrl)
val sessionData = client.use { it.session().toSessionData() }
pendingUrlForOidcLogin = null
sessionStore.storeData(sessionData)
SessionId(sessionData.userId)
}.mapFailure { failure ->
failure.mapAuthenticationException()
}
}
*/
}
private fun createMatrixClient(client: Client): MatrixClient {
return RustMatrixClient(
client = client,
@ -115,6 +179,7 @@ class RustMatrixAuthenticationService @Inject constructor(
coroutineScope = coroutineScope,
dispatchers = coroutineDispatchers,
baseDirectory = baseDirectory,
clock = clock,
)
}
}

View file

@ -23,9 +23,9 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData
import org.matrix.rustcomponents.sdk.NotificationItem
import org.matrix.rustcomponents.sdk.use
import javax.inject.Inject
class NotificationMapper @Inject constructor() {
class NotificationMapper {
private val timelineEventMapper = TimelineEventMapper()
fun map(notificationItem: NotificationItem): NotificationData {
return notificationItem.use {
@ -36,9 +36,11 @@ class NotificationMapper @Inject constructor() {
senderAvatarUrl = it.senderAvatarUrl,
senderDisplayName = it.senderDisplayName,
roomAvatarUrl = it.roomAvatarUrl,
roomDisplayName = it.roomDisplayName,
isDirect = it.isDirect,
isEncrypted = it.isEncrypted.orFalse(),
isNoisy = it.isNoisy
isNoisy = it.isNoisy,
event = it.event.use { event -> timelineEventMapper.map(event) }
)
}
}

View file

@ -16,18 +16,13 @@
package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationService
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use
import java.io.File
class RustNotificationService(
private val client: Client,

View file

@ -0,0 +1,109 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.notification
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationEvent
import org.matrix.rustcomponents.sdk.MessageLikeEventContent
import org.matrix.rustcomponents.sdk.MessageType
import org.matrix.rustcomponents.sdk.StateEventContent
import org.matrix.rustcomponents.sdk.TimelineEvent
import org.matrix.rustcomponents.sdk.TimelineEventType
import org.matrix.rustcomponents.sdk.use
import javax.inject.Inject
class TimelineEventMapper @Inject constructor() {
fun map(timelineEvent: TimelineEvent): NotificationEvent {
return timelineEvent.use {
NotificationEvent(
timestamp = it.timestamp().toLong(),
content = it.eventType().toContent(),
contentUrl = null // TODO it.eventType().toContentUrl(),
)
}
}
}
private fun TimelineEventType.toContent(): String {
return when (this) {
is TimelineEventType.MessageLike -> content.toContent()
is TimelineEventType.State -> content.toContent()
}
}
private fun StateEventContent.toContent(): String {
return when (this) {
StateEventContent.PolicyRuleRoom -> "PolicyRuleRoom"
StateEventContent.PolicyRuleServer -> "PolicyRuleServer"
StateEventContent.PolicyRuleUser -> "PolicyRuleUser"
StateEventContent.RoomAliases -> "RoomAliases"
StateEventContent.RoomAvatar -> "RoomAvatar"
StateEventContent.RoomCanonicalAlias -> "RoomCanonicalAlias"
StateEventContent.RoomCreate -> "RoomCreate"
StateEventContent.RoomEncryption -> "RoomEncryption"
StateEventContent.RoomGuestAccess -> "RoomGuestAccess"
StateEventContent.RoomHistoryVisibility -> "RoomHistoryVisibility"
StateEventContent.RoomJoinRules -> "RoomJoinRules"
is StateEventContent.RoomMemberContent -> "$userId is now $membershipState"
StateEventContent.RoomName -> "RoomName"
StateEventContent.RoomPinnedEvents -> "RoomPinnedEvents"
StateEventContent.RoomPowerLevels -> "RoomPowerLevels"
StateEventContent.RoomServerAcl -> "RoomServerAcl"
StateEventContent.RoomThirdPartyInvite -> "RoomThirdPartyInvite"
StateEventContent.RoomTombstone -> "RoomTombstone"
StateEventContent.RoomTopic -> "RoomTopic"
StateEventContent.SpaceChild -> "SpaceChild"
StateEventContent.SpaceParent -> "SpaceParent"
}
}
private fun MessageLikeEventContent.toContent(): String {
return use {
when (it) {
MessageLikeEventContent.CallAnswer -> "CallAnswer"
MessageLikeEventContent.CallCandidates -> "CallCandidates"
MessageLikeEventContent.CallHangup -> "CallHangup"
MessageLikeEventContent.CallInvite -> "CallInvite"
MessageLikeEventContent.KeyVerificationAccept -> "KeyVerificationAccept"
MessageLikeEventContent.KeyVerificationCancel -> "KeyVerificationCancel"
MessageLikeEventContent.KeyVerificationDone -> "KeyVerificationDone"
MessageLikeEventContent.KeyVerificationKey -> "KeyVerificationKey"
MessageLikeEventContent.KeyVerificationMac -> "KeyVerificationMac"
MessageLikeEventContent.KeyVerificationReady -> "KeyVerificationReady"
MessageLikeEventContent.KeyVerificationStart -> "KeyVerificationStart"
is MessageLikeEventContent.ReactionContent -> "Reacted to ${it.relatedEventId.take(8)}"
MessageLikeEventContent.RoomEncrypted -> "RoomEncrypted"
is MessageLikeEventContent.RoomMessage -> it.messageType.toContent()
MessageLikeEventContent.RoomRedaction -> "RoomRedaction"
MessageLikeEventContent.Sticker -> "Sticker"
}
}
}
private fun MessageType.toContent(): String {
return when (this) {
is MessageType.Audio -> content.use { it.body }
is MessageType.Emote -> content.body
is MessageType.File -> content.use { it.body }
is MessageType.Image -> content.use { it.body }
is MessageType.Notice -> content.body
is MessageType.Text -> content.body
is MessageType.Video -> content.use { it.body }
}
}

View file

@ -27,11 +27,14 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.room.roomMembers
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.impl.media.map
import io.element.android.libraries.matrix.impl.timeline.RustMatrixTimeline
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
@ -54,6 +57,7 @@ class RustMatrixRoom(
private val innerRoom: Room,
private val coroutineScope: CoroutineScope,
private val coroutineDispatchers: CoroutineDispatchers,
private val clock: SystemClock,
) : MatrixRoom {
override val membersStateFlow: StateFlow<MatrixRoomMembersState>
@ -77,9 +81,9 @@ class RustMatrixRoom(
it.rooms.contains(roomId.value)
}
.map {
System.currentTimeMillis()
clock.epochMillis()
}
.onStart { emit(System.currentTimeMillis()) }
.onStart { emit(clock.epochMillis()) }
}
override fun timeline(): MatrixTimeline {
@ -222,6 +226,12 @@ class RustMatrixRoom(
}
}
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.member(sessionId.value).use { it.canSendState(type.map()) }
}
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = withContext(coroutineDispatchers.io) {
runCatching {
innerRoom.sendImage(file.path, thumbnailFile.path, imageInfo.map())
@ -245,4 +255,33 @@ class RustMatrixRoom(
innerRoom.sendFile(file.path, fileInfo.map())
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.uploadAvatar(mimeType, data.toUByteArray().toList())
}
}
override suspend fun removeAvatar(): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.removeAvatar()
}
}
override suspend fun setName(name: String): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.setName(name)
}
}
override suspend fun setTopic(topic: String): Result<Unit> =
withContext(Dispatchers.IO) {
runCatching {
innerRoom.setTopic(topic)
}
}
}

View file

@ -0,0 +1,68 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.impl.room
import io.element.android.libraries.matrix.api.room.StateEventType
import org.matrix.rustcomponents.sdk.StateEventType as RustStateEventType
fun StateEventType.map(): RustStateEventType = when (this) {
StateEventType.POLICY_RULE_ROOM -> RustStateEventType.POLICY_RULE_ROOM
StateEventType.POLICY_RULE_SERVER -> RustStateEventType.POLICY_RULE_SERVER
StateEventType.POLICY_RULE_USER -> RustStateEventType.POLICY_RULE_USER
StateEventType.ROOM_ALIASES -> RustStateEventType.ROOM_ALIASES
StateEventType.ROOM_AVATAR -> RustStateEventType.ROOM_AVATAR
StateEventType.ROOM_CANONICAL_ALIAS -> RustStateEventType.ROOM_CANONICAL_ALIAS
StateEventType.ROOM_CREATE -> RustStateEventType.ROOM_CREATE
StateEventType.ROOM_ENCRYPTION -> RustStateEventType.ROOM_ENCRYPTION
StateEventType.ROOM_GUEST_ACCESS -> RustStateEventType.ROOM_GUEST_ACCESS
StateEventType.ROOM_HISTORY_VISIBILITY -> RustStateEventType.ROOM_HISTORY_VISIBILITY
StateEventType.ROOM_JOIN_RULES -> RustStateEventType.ROOM_JOIN_RULES
StateEventType.ROOM_MEMBER_EVENT -> RustStateEventType.ROOM_MEMBER_EVENT
StateEventType.ROOM_NAME -> RustStateEventType.ROOM_NAME
StateEventType.ROOM_PINNED_EVENTS -> RustStateEventType.ROOM_PINNED_EVENTS
StateEventType.ROOM_POWER_LEVELS -> RustStateEventType.ROOM_POWER_LEVELS
StateEventType.ROOM_SERVER_ACL -> RustStateEventType.ROOM_SERVER_ACL
StateEventType.ROOM_THIRD_PARTY_INVITE -> RustStateEventType.ROOM_THIRD_PARTY_INVITE
StateEventType.ROOM_TOMBSTONE -> RustStateEventType.ROOM_TOMBSTONE
StateEventType.ROOM_TOPIC -> RustStateEventType.ROOM_TOPIC
StateEventType.SPACE_CHILD -> RustStateEventType.SPACE_CHILD
StateEventType.SPACE_PARENT -> RustStateEventType.SPACE_PARENT
}
fun RustStateEventType.map(): StateEventType = when (this) {
RustStateEventType.POLICY_RULE_ROOM -> StateEventType.POLICY_RULE_ROOM
RustStateEventType.POLICY_RULE_SERVER -> StateEventType.POLICY_RULE_SERVER
RustStateEventType.POLICY_RULE_USER -> StateEventType.POLICY_RULE_USER
RustStateEventType.ROOM_ALIASES -> StateEventType.ROOM_ALIASES
RustStateEventType.ROOM_AVATAR -> StateEventType.ROOM_AVATAR
RustStateEventType.ROOM_CANONICAL_ALIAS -> StateEventType.ROOM_CANONICAL_ALIAS
RustStateEventType.ROOM_CREATE -> StateEventType.ROOM_CREATE
RustStateEventType.ROOM_ENCRYPTION -> StateEventType.ROOM_ENCRYPTION
RustStateEventType.ROOM_GUEST_ACCESS -> StateEventType.ROOM_GUEST_ACCESS
RustStateEventType.ROOM_HISTORY_VISIBILITY -> StateEventType.ROOM_HISTORY_VISIBILITY
RustStateEventType.ROOM_JOIN_RULES -> StateEventType.ROOM_JOIN_RULES
RustStateEventType.ROOM_MEMBER_EVENT -> StateEventType.ROOM_MEMBER_EVENT
RustStateEventType.ROOM_NAME -> StateEventType.ROOM_NAME
RustStateEventType.ROOM_PINNED_EVENTS -> StateEventType.ROOM_PINNED_EVENTS
RustStateEventType.ROOM_POWER_LEVELS -> StateEventType.ROOM_POWER_LEVELS
RustStateEventType.ROOM_SERVER_ACL -> StateEventType.ROOM_SERVER_ACL
RustStateEventType.ROOM_THIRD_PARTY_INVITE -> StateEventType.ROOM_THIRD_PARTY_INVITE
RustStateEventType.ROOM_TOMBSTONE -> StateEventType.ROOM_TOMBSTONE
RustStateEventType.ROOM_TOPIC -> StateEventType.ROOM_TOPIC
RustStateEventType.SPACE_CHILD -> StateEventType.SPACE_CHILD
RustStateEventType.SPACE_PARENT -> StateEventType.SPACE_PARENT
}

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.api.timeline.MatrixTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.impl.timeline.item.event.EventMessageMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.EventTimelineItemMapper
import io.element.android.libraries.matrix.impl.timeline.item.event.TimelineEventContentMapper
@ -149,10 +150,10 @@ class RustMatrixTimeline(
runCatching {
val settings = RoomSubscription(
requiredState = listOf(
RequiredState(key = "m.room.canonical_alias", value = ""),
RequiredState(key = "m.room.topic", value = ""),
RequiredState(key = "m.room.join_rules", value = ""),
RequiredState(key = "m.room.power_levels", value = ""),
RequiredState(key = EventType.STATE_ROOM_CANONICAL_ALIAS, value = ""),
RequiredState(key = EventType.STATE_ROOM_TOPIC, value = ""),
RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""),
RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""),
),
timelineLimit = null
)

View file

@ -47,7 +47,8 @@ const val ANOTHER_MESSAGE = "Hello universe!"
const val A_HOMESERVER_URL = "matrix.org"
const val A_HOMESERVER_URL_2 = "matrix-client.org"
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, true, null)
val A_HOMESERVER = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = true, supportsOidcLogin = false)
val A_HOMESERVER_OIDC = MatrixHomeServerDetails(A_HOMESERVER_URL, supportsPasswordLogin = false, supportsOidcLogin = true)
const val AN_AVATAR_URL = "mxc://data"

View file

@ -19,16 +19,22 @@ package io.element.android.libraries.matrix.test.auth
import io.element.android.libraries.matrix.api.MatrixClient
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.auth.MatrixHomeServerDetails
import io.element.android.libraries.matrix.api.auth.OidcDetails
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FAKE_DELAY_IN_MS
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import kotlinx.coroutines.flow.flowOf
val A_OIDC_DATA = OidcDetails(url = "a-url")
class FakeAuthenticationService : MatrixAuthenticationService {
private var homeserver = MutableStateFlow<MatrixHomeServerDetails?>(null)
private var oidcError: Throwable? = null
private var oidcCancelError: Throwable? = null
private var loginError: Throwable? = null
private var changeServerError: Throwable? = null
@ -53,15 +59,36 @@ class FakeAuthenticationService : MatrixAuthenticationService {
}
override suspend fun setHomeserver(homeserver: String): Result<Unit> {
delay(100)
delay(FAKE_DELAY_IN_MS)
return changeServerError?.let { Result.failure(it) } ?: Result.success(Unit)
}
override suspend fun login(username: String, password: String): Result<SessionId> {
delay(100)
delay(FAKE_DELAY_IN_MS)
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
override suspend fun getOidcUrl(): Result<OidcDetails> {
return oidcError?.let { Result.failure(it) } ?: Result.success(A_OIDC_DATA)
}
override suspend fun cancelOidcLogin(): Result<Unit> {
return oidcCancelError?.let { Result.failure(it) } ?: Result.success(Unit)
}
override suspend fun loginWithOidc(callbackUrl: String): Result<SessionId> {
delay(FAKE_DELAY_IN_MS)
return loginError?.let { Result.failure(it) } ?: Result.success(A_USER_ID)
}
fun givenOidcError(throwable: Throwable?) {
oidcError = throwable
}
fun givenOidcCancelError(throwable: Throwable?) {
oidcCancelError = throwable
}
fun givenLoginError(throwable: Throwable?) {
loginError = throwable
}

View file

@ -26,6 +26,7 @@ import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.VideoInfo
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.room.MatrixRoomMembersState
import io.element.android.libraries.matrix.api.room.StateEventType
import io.element.android.libraries.matrix.api.timeline.MatrixTimeline
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -62,7 +63,13 @@ class FakeMatrixRoom(
private var rejectInviteResult = Result.success(Unit)
private var inviteUserResult = Result.success(Unit)
private var canInviteResult = Result.success(true)
private val canSendStateResults = mutableMapOf<StateEventType, Result<Boolean>>()
private var sendMediaResult = Result.success(Unit)
private var setNameResult = Result.success(Unit)
private var setTopicResult = Result.success(Unit)
private var updateAvatarResult = Result.success(Unit)
private var removeAvatarResult = Result.success(Unit)
var sendMediaCount = 0
private set
@ -75,6 +82,18 @@ class FakeMatrixRoom(
var invitedUserId: UserId? = null
private set
var newTopic: String? = null
private set
var newName: String? = null
private set
var newAvatarData: ByteArray? = null
private set
var removedAvatar: Boolean = false
private set
private var leaveRoomError: Throwable? = null
override val membersStateFlow: MutableStateFlow<MatrixRoomMembersState> = MutableStateFlow(MatrixRoomMembersState.Unknown)
@ -151,6 +170,10 @@ class FakeMatrixRoom(
return canInviteResult
}
override suspend fun canSendStateEvent(type: StateEventType): Result<Boolean> {
return canSendStateResults[type] ?: Result.failure(IllegalStateException("No fake answer"))
}
override suspend fun sendImage(file: File, thumbnailFile: File, imageInfo: ImageInfo): Result<Unit> = fakeSendMedia()
override suspend fun sendVideo(file: File, thumbnailFile: File, videoInfo: VideoInfo): Result<Unit> = fakeSendMedia()
@ -166,6 +189,26 @@ class FakeMatrixRoom(
}
}
override suspend fun updateAvatar(mimeType: String, data: ByteArray): Result<Unit> {
newAvatarData = data
return updateAvatarResult
}
override suspend fun removeAvatar(): Result<Unit> {
removedAvatar = true
return removeAvatarResult
}
override suspend fun setName(name: String): Result<Unit> {
newName = name
return setNameResult
}
override suspend fun setTopic(topic: String): Result<Unit> {
newTopic = topic
return setTopicResult
}
override fun close() = Unit
fun givenLeaveRoomError(throwable: Throwable?) {
@ -204,6 +247,10 @@ class FakeMatrixRoom(
canInviteResult = result
}
fun givenCanSendStateResult(type: StateEventType, result: Result<Boolean>) {
canSendStateResults[type] = result
}
fun givenIgnoreResult(result: Result<Unit>) {
ignoreResult = result
}
@ -215,4 +262,20 @@ class FakeMatrixRoom(
fun givenSendMediaResult(result: Result<Unit>) {
sendMediaResult = result
}
fun givenUpdateAvatarResult(result: Result<Unit>) {
updateAvatarResult = result
}
fun givenRemoveAvatarResult(result: Result<Unit>) {
removeAvatarResult = result
}
fun givenSetNameResult(result: Result<Unit>) {
setNameResult = result
}
fun givenSetTopicResult(result: Result<Unit>) {
setTopicResult = result
}
}

View file

@ -39,6 +39,7 @@ dependencies {
implementation(projects.libraries.core)
implementation(projects.libraries.uiStrings)
implementation(libs.coil.compose)
implementation(libs.coil.gif)
ksp(libs.showkase.processor)
}

View file

@ -0,0 +1,127 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
@file:OptIn(ExperimentalMaterialApi::class)
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.imePadding
import androidx.compose.foundation.layout.navigationBarsPadding
import androidx.compose.foundation.lazy.LazyColumn
import androidx.compose.foundation.lazy.items
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.ModalBottomSheetState
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.Text
import androidx.compose.material3.ListItem
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.tooling.preview.Preview
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.ModalBottomSheetLayout
import io.element.android.libraries.matrix.ui.media.AvatarAction
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.coroutines.launch
@Composable
fun AvatarActionBottomSheet(
actions: ImmutableList<AvatarAction>,
modalBottomSheetState: ModalBottomSheetState,
modifier: Modifier = Modifier,
onActionSelected: (action: AvatarAction) -> Unit = {},
) {
val coroutineScope = rememberCoroutineScope()
fun onItemActionClicked(itemAction: AvatarAction) {
onActionSelected(itemAction)
coroutineScope.launch {
modalBottomSheetState.hide()
}
}
ModalBottomSheetLayout(
modifier = modifier,
sheetState = modalBottomSheetState,
sheetContent = {
AvatarActionBottomSheetContent(
actions = actions,
onActionClicked = ::onItemActionClicked,
modifier = Modifier
.navigationBarsPadding()
.imePadding()
)
}
)
}
@Composable
private fun AvatarActionBottomSheetContent(
actions: ImmutableList<AvatarAction>,
modifier: Modifier = Modifier,
onActionClicked: (AvatarAction) -> Unit = { },
) {
LazyColumn(
modifier = modifier.fillMaxWidth()
) {
items(
items = actions,
) { action ->
ListItem(
modifier = Modifier.clickable { onActionClicked(action) },
headlineContent = {
Text(
text = stringResource(action.titleResId),
color = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
},
leadingContent = {
Icon(
imageVector = action.icon,
contentDescription = stringResource(action.titleResId),
tint = if (action.destructive) MaterialTheme.colorScheme.error else MaterialTheme.colorScheme.primary,
)
}
)
}
}
}
@Preview
@Composable
fun AvatarActionBottomSheetLightPreview() =
ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun AvatarActionBottomSheetDarkPreview() =
ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
AvatarActionBottomSheet(
actions = persistentListOf(AvatarAction.TakePhoto, AvatarAction.ChoosePhoto, AvatarAction.Remove),
modalBottomSheetState = ModalBottomSheetState(
initialValue = ModalBottomSheetValue.Expanded
),
)
}

View file

@ -0,0 +1,150 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.IntrinsicSize
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.height
import androidx.compose.foundation.layout.padding
import androidx.compose.foundation.layout.size
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Error
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.Role
import androidx.compose.ui.text.font.FontWeight
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import io.element.android.libraries.designsystem.components.avatar.Avatar
import io.element.android.libraries.designsystem.components.avatar.AvatarData
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
import io.element.android.libraries.designsystem.theme.components.Checkbox
import io.element.android.libraries.designsystem.theme.components.Icon
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.matrix.ui.model.getAvatarData
import io.element.android.libraries.ui.strings.R
@Composable
fun UnresolvedUserRow(
avatarData: AvatarData,
id: String,
modifier: Modifier = Modifier,
) {
Row(
modifier = modifier
.fillMaxWidth()
.padding(start = 16.dp, top = 8.dp, end = 16.dp, bottom = 8.dp)
.height(IntrinsicSize.Min),
verticalAlignment = Alignment.CenterVertically
) {
Avatar(avatarData)
Column(
modifier = Modifier
.padding(start = 12.dp),
) {
// ID
Text(
fontSize = 16.sp,
fontWeight = FontWeight.SemiBold,
text = id,
maxLines = 1,
overflow = TextOverflow.Ellipsis,
color = MaterialTheme.colorScheme.primary,
)
// Warning
Row(modifier = Modifier.fillMaxWidth()) {
Icon(
imageVector = Icons.Filled.Error,
contentDescription = "",
modifier = Modifier
.size(18.dp)
.align(Alignment.Top)
.padding(2.dp),
tint = MaterialTheme.colorScheme.error,
)
Text(
text = stringResource(R.string.common_invite_unknown_profile),
color = MaterialTheme.colorScheme.secondary,
fontSize = 12.sp,
lineHeight = 16.sp,
)
}
}
}
}
@Composable
fun CheckableUnresolvedUserRow(
checked: Boolean,
avatarData: AvatarData,
id: String,
modifier: Modifier = Modifier,
onCheckedChange: (Boolean) -> Unit = {},
enabled: Boolean = true,
) {
Row(
modifier = modifier
.fillMaxWidth()
.clickable(role = Role.Checkbox, enabled = enabled) {
onCheckedChange(!checked)
},
verticalAlignment = Alignment.CenterVertically,
) {
UnresolvedUserRow(
modifier = Modifier.weight(1f),
avatarData = avatarData,
id = id,
)
Checkbox(
checked = checked,
onCheckedChange = onCheckedChange,
enabled = enabled,
)
}
}
@Preview
@Composable
internal fun UnresolvedUserRowPreview() =
ElementThemedPreview {
val matrixUser = aMatrixUser()
UnresolvedUserRow(matrixUser.getAvatarData(), matrixUser.userId.value)
}
@Preview
@Composable
internal fun CheckableUnresolvedUserRowPreview() =
ElementThemedPreview {
val matrixUser = aMatrixUser()
Column {
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value)
CheckableUnresolvedUserRow(false, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
CheckableUnresolvedUserRow(true, matrixUser.getAvatarData(), matrixUser.userId.value, enabled = false)
}
}

View file

@ -0,0 +1,99 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.components
import android.net.Uri
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.shape.CircleShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.AddAPhoto
import androidx.compose.material3.MaterialTheme
import androidx.compose.runtime.Composable
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.graphics.painter.ColorPainter
import androidx.compose.ui.layout.ContentScale
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.tooling.preview.Preview
import androidx.compose.ui.unit.dp
import coil.compose.AsyncImage
import coil.request.ImageRequest
import io.element.android.libraries.designsystem.preview.ElementPreviewDark
import io.element.android.libraries.designsystem.preview.ElementPreviewLight
import io.element.android.libraries.designsystem.preview.debugPlaceholderBackground
import io.element.android.libraries.designsystem.theme.LocalColors
import io.element.android.libraries.designsystem.theme.components.Icon
/**
* An avatar that the user has selected, but which has not yet been uploaded to Matrix.
*
* The image is loaded from a local resource instead of from a MXC URI.
*/
@Composable
fun UnsavedAvatar(
avatarUri: Uri?,
modifier: Modifier = Modifier,
) {
val commonModifier = modifier
.size(70.dp)
.clip(CircleShape)
if (avatarUri != null) {
val context = LocalContext.current
val model = ImageRequest.Builder(context)
.data(avatarUri)
.build()
AsyncImage(
modifier = commonModifier,
model = model,
placeholder = debugPlaceholderBackground(ColorPainter(MaterialTheme.colorScheme.surfaceVariant)),
contentScale = ContentScale.Crop,
contentDescription = null,
)
} else {
Box(modifier = commonModifier.background(LocalColors.current.quinary)) {
Icon(
imageVector = Icons.Outlined.AddAPhoto,
contentDescription = "",
modifier = Modifier
.align(Alignment.Center)
.size(40.dp),
tint = MaterialTheme.colorScheme.secondary,
)
}
}
}
@Preview
@Composable
fun UnsavedAvatarLightPreview() = ElementPreviewLight { ContentToPreview() }
@Preview
@Composable
fun UnsavedAvatarDarkPreview() = ElementPreviewDark { ContentToPreview() }
@Composable
private fun ContentToPreview() {
Row {
UnsavedAvatar(null)
UnsavedAvatar(Uri.EMPTY)
}
}

View file

@ -0,0 +1,37 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.matrix.ui.media
import androidx.annotation.StringRes
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.outlined.Delete
import androidx.compose.material.icons.outlined.PhotoCamera
import androidx.compose.material.icons.outlined.PhotoLibrary
import androidx.compose.runtime.Immutable
import androidx.compose.ui.graphics.vector.ImageVector
import io.element.android.libraries.ui.strings.R
@Immutable
sealed class AvatarAction(
@StringRes val titleResId: Int,
val icon: ImageVector,
val destructive: Boolean = false,
) {
object TakePhoto : AvatarAction(titleResId = R.string.action_take_photo, icon = Icons.Outlined.PhotoCamera)
object ChoosePhoto : AvatarAction(titleResId = R.string.action_choose_photo, icon = Icons.Outlined.PhotoLibrary)
object Remove : AvatarAction(titleResId = R.string.action_remove, icon = Icons.Outlined.Delete, destructive = true)
}

View file

@ -17,8 +17,11 @@
package io.element.android.libraries.matrix.ui.media
import android.content.Context
import android.os.Build
import coil.ImageLoader
import coil.ImageLoaderFactory
import coil.decode.GifDecoder
import coil.decode.ImageDecoderDecoder
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.MatrixClient
import okhttp3.OkHttpClient
@ -34,6 +37,12 @@ class LoggedInImageLoaderFactory @Inject constructor(
.Builder(context)
.okHttpClient(okHttpClient)
.components {
// Add gif support
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
add(ImageDecoderDecoder.Factory())
} else {
add(GifDecoder.Factory())
}
add(AvatarDataKeyer())
add(MediaRequestDataKeyer())
add(CoilMediaFetcher.AvatarFactory(matrixClient))

View file

@ -29,13 +29,13 @@ import io.element.android.libraries.matrix.api.room.RoomMember
import io.element.android.libraries.matrix.api.room.roomMembers
@Composable
fun MatrixRoom.getRoomMember(userId: UserId): State<RoomMember?> {
fun MatrixRoom.getRoomMemberAsState(userId: UserId): State<RoomMember?> {
val roomMembersState by membersStateFlow.collectAsState()
return getRoomMember(roomMembersState = roomMembersState, userId = userId)
return getRoomMemberAsState(roomMembersState = roomMembersState, userId = userId)
}
@Composable
fun getRoomMember(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> {
fun getRoomMemberAsState(roomMembersState: MatrixRoomMembersState, userId: UserId): State<RoomMember?> {
val roomMembers = roomMembersState.roomMembers()
return remember(roomMembers) {
derivedStateOf {

View file

@ -35,6 +35,7 @@ dependencies {
implementation(libs.androidx.security.crypto)
implementation(libs.network.retrofit)
implementation(libs.serialization.json)
implementation(libs.coil)
implementation(projects.libraries.architecture)
implementation(projects.libraries.core)
@ -42,6 +43,7 @@ dependencies {
implementation(projects.libraries.androidutils)
implementation(projects.libraries.network)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api)
api(projects.libraries.push.api)

View file

@ -115,8 +115,7 @@ class PushersManager @Inject constructor(
appDisplayName = appName,
deviceDisplayName = currentSession.sessionParams.deviceId ?: "MOBILE"
)
*/
*/
}
fun getPusherForCurrentSession() {}/*: Pusher? {

View file

@ -16,6 +16,7 @@
package io.element.android.libraries.push.impl.notifications
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -49,7 +50,7 @@ class NotifiableEventProcessor @Inject constructor(
else -> ProcessedEvent.Type.KEEP
}
is SimpleNotifiableEvent -> when (it.type) {
/*EventType.REDACTION*/ "m.room.redaction" -> ProcessedEvent.Type.REMOVE
EventType.REDACTION -> ProcessedEvent.Type.REMOVE
else -> ProcessedEvent.Type.KEEP
}
}

View file

@ -24,6 +24,7 @@ import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.notification.NotificationEvent
import io.element.android.libraries.push.impl.log.pushLoggerTag
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -44,9 +45,9 @@ class NotifiableEventResolver @Inject constructor(
private val stringProvider: StringProvider,
// private val noticeEventFormatter: NoticeEventFormatter,
// private val displayableEventFormatter: DisplayableEventFormatter,
private val clock: SystemClock,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val buildMeta: BuildMeta,
private val clock: SystemClock,
) {
suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
@ -71,44 +72,53 @@ class NotifiableEventResolver @Inject constructor(
return notificationData.asNotifiableEvent(sessionId)
}
}
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent {
return NotifiableMessageEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
noisy = isNoisy,
timestamp = System.currentTimeMillis(),
senderName = senderDisplayName,
senderId = senderId.value,
body = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}",
imageUriString = null,
threadId = null,
roomName = null,
roomIsDirect = false,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
soundName = null,
outGoingMessage = false,
outGoingMessageFailed = false,
isRedacted = false,
isUpdated = false
)
}
private fun NotificationData.asNotifiableEvent(userId: SessionId): NotifiableEvent {
return NotifiableMessageEvent(
sessionId = userId,
roomId = roomId,
eventId = eventId,
editedEventId = null,
canBeReplaced = true,
noisy = isNoisy,
timestamp = event.timestamp,
senderName = senderDisplayName,
senderId = senderId.value,
body = event.content,
imageUriString = event.contentUrl,
threadId = null,
roomName = roomDisplayName,
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
soundName = null,
outGoingMessage = false,
outGoingMessageFailed = false,
isRedacted = false,
isUpdated = false
)
}
/**
* TODO This is a temporary method for EAx.
*/
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
return this ?: NotificationData(
eventId = eventId,
senderId = UserId("@user:domain"),
roomId = roomId,
isNoisy = false,
isEncrypted = false,
isDirect = false
)
/**
* TODO This is a temporary method for EAx.
*/
private fun NotificationData?.orDefault(roomId: RoomId, eventId: EventId): NotificationData {
return this ?: NotificationData(
eventId = eventId,
senderId = UserId("@user:domain"),
roomId = roomId,
senderAvatarUrl = null,
senderDisplayName = null,
roomAvatarUrl = null,
roomDisplayName = null,
isNoisy = false,
isEncrypted = false,
isDirect = false,
event = NotificationEvent(
timestamp = clock.epochMillis(),
content = "Message ${eventId.value.take(8)}… in room ${roomId.value.take(8)}",
contentUrl = null
)
)
}
}

View file

@ -19,9 +19,14 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.graphics.Bitmap
import android.os.Build
import androidx.annotation.WorkerThread
import androidx.core.graphics.drawable.IconCompat
import androidx.core.graphics.drawable.toBitmap
import coil.imageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import timber.log.Timber
import javax.inject.Inject
@ -31,30 +36,24 @@ class NotificationBitmapLoader @Inject constructor(
/**
* Get icon of a room.
* @param path mxc url
*/
@WorkerThread
fun getRoomBitmap(path: String?): Bitmap? {
suspend fun getRoomBitmap(path: String?): Bitmap? {
if (path == null) {
return null
}
return loadRoomBitmap(path)
}
@WorkerThread
private fun loadRoomBitmap(path: String): Bitmap? {
private suspend fun loadRoomBitmap(path: String): Bitmap? {
return try {
null
/* TODO Notification
Glide.with(context)
.asBitmap()
.load(path)
.format(DecodeFormat.PREFER_ARGB_8888)
.signature(ObjectKey("room-icon-notification"))
.submit()
.get()
*/
} catch (e: Exception) {
Timber.e(e, "decodeFile failed")
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
.build()
val result = context.imageLoader.execute(imageRequest)
result.drawable?.toBitmap()
} catch (e: Throwable) {
Timber.e(e, "Unable to load room bitmap")
null
}
}
@ -62,9 +61,9 @@ class NotificationBitmapLoader @Inject constructor(
/**
* Get icon of a user.
* Before Android P, this does nothing because the icon won't be used
* @param path mxc url
*/
@WorkerThread
fun getUserIcon(path: String?): IconCompat? {
suspend fun getUserIcon(path: String?): IconCompat? {
if (path == null || Build.VERSION.SDK_INT < Build.VERSION_CODES.P) {
return null
}
@ -72,23 +71,17 @@ class NotificationBitmapLoader @Inject constructor(
return loadUserIcon(path)
}
@WorkerThread
private fun loadUserIcon(path: String): IconCompat? {
private suspend fun loadUserIcon(path: String): IconCompat? {
return try {
null
/* TODO Notification
val bitmap = Glide.with(context)
.asBitmap()
.load(path)
.transform(CircleCrop())
.format(DecodeFormat.PREFER_ARGB_8888)
.signature(ObjectKey("user-icon-notification"))
.submit()
.get()
IconCompat.createWithBitmap(bitmap)
*/
} catch (e: Exception) {
Timber.e(e, "decodeFile failed")
val imageRequest = ImageRequest.Builder(context)
.data(MediaRequestData(MediaSource(path), MediaRequestData.Kind.Thumbnail(1024)))
.transformations(CircleCropTransformation())
.build()
val result = context.imageLoader.execute(imageRequest)
val bitmap = result.drawable?.toBitmap()
return bitmap?.let { IconCompat.createWithBitmap(it) }
} catch (e: Throwable) {
Timber.e(e, "Unable to load user bitmap")
null
}
}

View file

@ -16,28 +16,28 @@
package io.element.android.libraries.push.impl.notifications
import android.content.Context
import android.os.Handler
import android.os.HandlerThread
import androidx.annotation.WorkerThread
import io.element.android.libraries.androidutils.throttler.FirstThrottler
import io.element.android.libraries.core.cache.CircularCache
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.store.PushDataStore
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreMessageEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import timber.log.Timber
import javax.inject.Inject
@ -48,7 +48,6 @@ import javax.inject.Inject
*/
@SingleIn(AppScope::class)
class NotificationDrawerManager @Inject constructor(
@ApplicationContext context: Context,
private val pushDataStore: PushDataStore,
private val notifiableEventProcessor: NotifiableEventProcessor,
private val notificationRenderer: NotificationRenderer,
@ -56,17 +55,14 @@ class NotificationDrawerManager @Inject constructor(
private val filteredEventDetector: FilteredEventDetector,
private val appNavigationStateService: AppNavigationStateService,
private val coroutineScope: CoroutineScope,
private val dispatchers: CoroutineDispatchers,
private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService,
) {
private val handlerThread: HandlerThread = HandlerThread("NotificationDrawerManager", Thread.MIN_PRIORITY)
private var backgroundHandler: Handler
/**
* Lazily initializes the NotificationState as we rely on having a current session in order to fetch the persisted queue of events.
*/
private val notificationState by lazy { createInitialNotificationState() }
private val avatarSize = context.resources.getDimensionPixelSize(R.dimen.profile_avatar_size)
private var currentAppNavigationState: AppNavigationState? = null
private val firstThrottler = FirstThrottler(200)
@ -74,8 +70,6 @@ class NotificationDrawerManager @Inject constructor(
private var useCompleteNotificationFormat = true
init {
handlerThread.start()
backgroundHandler = Handler(handlerThread.looper)
// Observe application state
coroutineScope.launch {
appNavigationStateService.appNavigationStateFlow
@ -193,30 +187,25 @@ class NotificationDrawerManager @Inject constructor(
notificationState.updateQueuedEvents(this) { queuedEvents, _ ->
action(queuedEvents)
}
refreshNotificationDrawer()
coroutineScope.refreshNotificationDrawer()
}
private fun refreshNotificationDrawer() {
private fun CoroutineScope.refreshNotificationDrawer() = launch {
// Implement last throttler
val canHandle = firstThrottler.canHandle()
Timber.v("refreshNotificationDrawer(), delay: ${canHandle.waitMillis()} ms")
backgroundHandler.removeCallbacksAndMessages(null)
backgroundHandler.postDelayed(
{
try {
refreshNotificationDrawerBg()
} catch (throwable: Throwable) {
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
Timber.w(throwable, "refreshNotificationDrawerBg failure")
}
},
canHandle.waitMillis()
)
withContext(dispatchers.io) {
delay(canHandle.waitMillis())
try {
refreshNotificationDrawerBg()
} catch (throwable: Throwable) {
// It can happen if for instance session has been destroyed. It's a bit ugly to try catch like this, but it's safer
Timber.w(throwable, "refreshNotificationDrawerBg failure")
}
}
}
@WorkerThread
private fun refreshNotificationDrawerBg() {
private suspend fun refreshNotificationDrawerBg() {
Timber.v("refreshNotificationDrawerBg()")
val eventsToRender = notificationState.updateQueuedEvents(this) { queuedEvents, renderedEvents ->
notifiableEventProcessor.process(queuedEvents.rawEvents(), currentAppNavigationState, renderedEvents).also {
@ -239,24 +228,34 @@ class NotificationDrawerManager @Inject constructor(
}
}
private fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
private suspend fun renderEvents(eventsToRender: List<ProcessedEvent<NotifiableEvent>>) {
// Group by sessionId
val eventsForSessions = eventsToRender.groupBy {
it.event.sessionId
}
eventsForSessions.forEach { (sessionId, notifiableEvents) ->
// TODO EAx val user = session.getUserOrDefault(session.myUserId)
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = "Todo display name" // user.toMatrixItem().getBestName()
// TODO EAx avatar URL
val myUserAvatarUrl = null // session.contentUrlResolver().resolveThumbnail(
// contentUrl = user.avatarUrl,
// width = avatarSize,
// height = avatarSize,
// method = ContentUrlResolver.ThumbnailMethod.SCALE
//)
notificationRenderer.render(sessionId, myUserDisplayName, myUserAvatarUrl, useCompleteNotificationFormat, notifiableEvents)
val currentUser = tryOrNull(
onError = { Timber.e(it, "Unable to retrieve info for user ${sessionId.value}") },
operation = {
val client = matrixAuthenticationService.restoreSession(sessionId).getOrNull()
// myUserDisplayName cannot be empty else NotificationCompat.MessagingStyle() will crash
val myUserDisplayName = client?.loadUserDisplayName()?.getOrNull() ?: sessionId.value
val userAvatarUrl = client?.loadUserAvatarURLString()?.getOrNull()
MatrixUser(
userId = sessionId,
displayName = myUserDisplayName,
avatarUrl = userAvatarUrl
)
}
) ?: MatrixUser(
userId = sessionId,
displayName = sessionId.value,
avatarUrl = null
)
notificationRenderer.render(currentUser, useCompleteNotificationFormat, notifiableEvents)
}
}

View file

@ -18,7 +18,7 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -34,10 +34,8 @@ class NotificationFactory @Inject constructor(
private val summaryGroupMessageCreator: SummaryGroupMessageCreator
) {
fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
sessionId: SessionId,
myUserDisplayName: String,
myUserAvatarUrl: String?
suspend fun Map<RoomId, ProcessedMessageEvents>.toNotifications(
currentUser: MatrixUser,
): List<RoomNotification> {
return map { (roomId, events) ->
when {
@ -45,11 +43,9 @@ class NotificationFactory @Inject constructor(
else -> {
val messageEvents = events.onlyKeptEvents().filterNot { it.isRedacted }
roomGroupMessageCreator.createRoomMessage(
sessionId = sessionId,
currentUser = currentUser,
events = messageEvents,
roomId = roomId,
userDisplayName = myUserDisplayName,
userAvatarUrl = myUserAvatarUrl
)
}
}
@ -99,7 +95,7 @@ class NotificationFactory @Inject constructor(
}
fun createSummaryNotification(
sessionId: SessionId,
currentUser: MatrixUser,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
simpleNotifications: List<OneShotNotification>,
@ -112,7 +108,7 @@ class NotificationFactory @Inject constructor(
roomMeta.isEmpty() && invitationMeta.isEmpty() && simpleMeta.isEmpty() -> SummaryNotification.Removed
else -> SummaryNotification.Update(
summaryGroupMessageCreator.createSummaryNotification(
sessionId = sessionId,
currentUser = currentUser,
roomNotifications = roomMeta,
invitationNotifications = invitationMeta,
simpleNotifications = simpleMeta,

View file

@ -16,9 +16,8 @@
package io.element.android.libraries.push.impl.notifications
import androidx.annotation.WorkerThread
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
@ -32,21 +31,18 @@ class NotificationRenderer @Inject constructor(
private val notificationFactory: NotificationFactory,
) {
@WorkerThread
fun render(
sessionId: SessionId,
myUserDisplayName: String,
myUserAvatarUrl: String?,
suspend fun render(
currentUser: MatrixUser,
useCompleteNotificationFormat: Boolean,
eventsToProcess: List<ProcessedEvent<NotifiableEvent>>
) {
val (roomEvents, simpleEvents, invitationEvents) = eventsToProcess.groupByType()
with(notificationFactory) {
val roomNotifications = roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl)
val roomNotifications = roomEvents.toNotifications(currentUser)
val invitationNotifications = invitationEvents.toNotifications()
val simpleNotifications = simpleEvents.toNotifications()
val summaryNotification = createSummaryNotification(
sessionId = sessionId,
currentUser = currentUser,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,
simpleNotifications = simpleNotifications,
@ -56,21 +52,27 @@ class NotificationRenderer @Inject constructor(
// Remove summary first to avoid briefly displaying it after dismissing the last notification
if (summaryNotification == SummaryNotification.Removed) {
Timber.d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(null, notificationIdProvider.getSummaryNotificationId(sessionId))
notificationDisplayer.cancelNotificationMessage(
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId)
)
}
roomNotifications.forEach { wrapper ->
when (wrapper) {
is RoomNotification.Removed -> {
Timber.d("Removing room messages notification ${wrapper.roomId}")
notificationDisplayer.cancelNotificationMessage(wrapper.roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId)
)
}
is RoomNotification.Message -> if (useCompleteNotificationFormat) {
Timber.d("Updating room messages notification ${wrapper.meta.roomId}")
notificationDisplayer.showNotificationMessage(
wrapper.meta.roomId.value,
notificationIdProvider.getRoomMessagesNotificationId(sessionId),
wrapper.notification
tag = wrapper.meta.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
@ -80,14 +82,17 @@ class NotificationRenderer @Inject constructor(
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.d("Removing invitation notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomInvitationNotificationId(sessionId))
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating invitation notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
wrapper.meta.key,
notificationIdProvider.getRoomInvitationNotificationId(sessionId),
wrapper.notification
tag = wrapper.meta.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
@ -97,14 +102,17 @@ class NotificationRenderer @Inject constructor(
when (wrapper) {
is OneShotNotification.Removed -> {
Timber.d("Removing simple notification ${wrapper.key}")
notificationDisplayer.cancelNotificationMessage(wrapper.key, notificationIdProvider.getRoomEventNotificationId(sessionId))
notificationDisplayer.cancelNotificationMessage(
tag = wrapper.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId)
)
}
is OneShotNotification.Append -> if (useCompleteNotificationFormat) {
Timber.d("Updating simple notification ${wrapper.meta.key}")
notificationDisplayer.showNotificationMessage(
wrapper.meta.key,
notificationIdProvider.getRoomEventNotificationId(sessionId),
wrapper.notification
tag = wrapper.meta.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = wrapper.notification
)
}
}
@ -114,9 +122,9 @@ class NotificationRenderer @Inject constructor(
if (summaryNotification is SummaryNotification.Update) {
Timber.d("Updating summary notification")
notificationDisplayer.showNotificationMessage(
null,
notificationIdProvider.getSummaryNotificationId(sessionId),
summaryNotification.notification
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId),
notification = summaryNotification.notification
)
}
}

View file

@ -20,8 +20,9 @@ import android.graphics.Bitmap
import androidx.core.app.NotificationCompat
import androidx.core.app.Person
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.services.toolbox.api.strings.StringProvider
@ -36,24 +37,22 @@ class RoomGroupMessageCreator @Inject constructor(
private val notificationFactory: NotificationFactory
) {
fun createRoomMessage(
sessionId: SessionId,
suspend fun createRoomMessage(
currentUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
userDisplayName: String,
userAvatarUrl: String?
): RoomNotification.Message {
val lastKnownRoomEvent = events.last()
val roomName = lastKnownRoomEvent.roomName ?: lastKnownRoomEvent.senderName ?: "Room name (${roomId.value.take(8)}…)"
val roomIsGroup = !lastKnownRoomEvent.roomIsDirect
val style = NotificationCompat.MessagingStyle(
Person.Builder()
.setName(userDisplayName)
.setIcon(bitmapLoader.getUserIcon(userAvatarUrl))
.setName(currentUser.displayName?.annotateForDebug(50))
.setIcon(bitmapLoader.getUserIcon(currentUser.avatarUrl))
.setKey(lastKnownRoomEvent.sessionId.value)
.build()
).also {
it.conversationTitle = roomName.takeIf { roomIsGroup }
it.conversationTitle = roomName.takeIf { roomIsGroup }?.annotateForDebug(51)
it.isGroupConversation = roomIsGroup
it.addMessagesFromEvents(events)
}
@ -80,7 +79,7 @@ class RoomGroupMessageCreator @Inject constructor(
notificationFactory.createMessagesListNotification(
style,
RoomEventGroupInfo(
sessionId = sessionId,
sessionId = currentUser.userId,
roomId = roomId,
roomDisplayName = roomName,
isDirect = !roomIsGroup,
@ -99,13 +98,13 @@ class RoomGroupMessageCreator @Inject constructor(
)
}
private fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
private suspend fun NotificationCompat.MessagingStyle.addMessagesFromEvents(events: List<NotifiableMessageEvent>) {
events.forEach { event ->
val senderPerson = if (event.outGoingMessage) {
null
} else {
Person.Builder()
.setName(event.senderName)
.setName(event.senderName?.annotateForDebug(70))
.setIcon(bitmapLoader.getUserIcon(event.senderAvatarPath))
.setKey(event.senderId)
.build()
@ -117,7 +116,11 @@ class RoomGroupMessageCreator @Inject constructor(
senderPerson
)
else -> {
val message = NotificationCompat.MessagingStyle.Message(event.body, event.timestamp, senderPerson).also { message ->
val message = NotificationCompat.MessagingStyle.Message(
event.body?.annotateForDebug(71),
event.timestamp,
senderPerson
).also { message ->
event.imageUri?.let {
message.setData("image/", it)
}
@ -168,7 +171,7 @@ class RoomGroupMessageCreator @Inject constructor(
}
}
private fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
private suspend fun getRoomBitmap(events: List<NotifiableMessageEvent>): Bitmap? {
// Use the last event (most recent?)
return events.lastOrNull()
?.roomAvatarPath

View file

@ -18,8 +18,9 @@ package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import androidx.core.app.NotificationCompat
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.NotificationFactory
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
@ -40,20 +41,20 @@ import javax.inject.Inject
*/
class SummaryGroupMessageCreator @Inject constructor(
private val stringProvider: StringProvider,
private val notificationFactory: NotificationFactory
private val notificationFactory: NotificationFactory,
) {
fun createSummaryNotification(
sessionId: SessionId,
currentUser: MatrixUser,
roomNotifications: List<RoomNotification.Message.Meta>,
invitationNotifications: List<OneShotNotification.Append.Meta>,
simpleNotifications: List<OneShotNotification.Append.Meta>,
useCompleteNotificationFormat: Boolean
): Notification {
val summaryInboxStyle = NotificationCompat.InboxStyle().also { style ->
roomNotifications.forEach { style.addLine(it.summaryLine) }
invitationNotifications.forEach { style.addLine(it.summaryLine) }
simpleNotifications.forEach { style.addLine(it.summaryLine) }
roomNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(40)) }
invitationNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(41)) }
simpleNotifications.forEach { style.addLine(it.summaryLine.annotateForDebug(42)) }
}
val summaryIsNoisy = roomNotifications.any { it.shouldBing } ||
@ -69,12 +70,13 @@ class SummaryGroupMessageCreator @Inject constructor(
// FIXME roomIdToEventMap.size is not correct, this is the number of rooms
val nbEvents = roomNotifications.size + simpleNotifications.size
val sumTitle = stringProvider.getQuantityString(R.plurals.notification_compat_summary_title, nbEvents, nbEvents)
summaryInboxStyle.setBigContentTitle(sumTitle)
// TODO get latest event?
.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents))
summaryInboxStyle.setBigContentTitle(sumTitle.annotateForDebug(43))
//.setSummaryText(stringProvider.getQuantityString(R.plurals.notification_unread_notified_messages, nbEvents, nbEvents).annotateForDebug(44))
// Use account name now, for multi-session
.setSummaryText(currentUser.userId.value.annotateForDebug(44))
return if (useCompleteNotificationFormat) {
notificationFactory.createSummaryListNotification(
sessionId,
currentUser,
summaryInboxStyle,
sumTitle,
noisy = summaryIsNoisy,
@ -82,7 +84,7 @@ class SummaryGroupMessageCreator @Inject constructor(
)
} else {
processSimpleGroupSummary(
sessionId,
currentUser,
summaryIsNoisy,
messageCount,
simpleNotifications.size,
@ -94,7 +96,7 @@ class SummaryGroupMessageCreator @Inject constructor(
}
private fun processSimpleGroupSummary(
sessionId: SessionId,
currentUser: MatrixUser,
summaryIsNoisy: Boolean,
messageEventsCount: Int,
simpleEventsCount: Int,
@ -167,7 +169,7 @@ class SummaryGroupMessageCreator @Inject constructor(
}
}
return notificationFactory.createSummaryListNotification(
sessionId = sessionId,
currentUser = currentUser,
style = null,
compatSummary = privacyTitle,
noisy = summaryIsNoisy,

View file

@ -0,0 +1,21 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.debug
fun CharSequence.annotateForDebug(@Suppress("UNUSED_PARAMETER") prefix: Int): CharSequence {
return this // "$prefix-$this"
}

View file

@ -26,11 +26,12 @@ import androidx.core.content.ContextCompat
import androidx.core.content.res.ResourcesCompat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.debug.annotateForDebug
import io.element.android.libraries.push.impl.notifications.factories.action.AcceptInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.MarkAsReadActionFactory
import io.element.android.libraries.push.impl.notifications.factories.action.QuickReplyActionFactory
@ -84,16 +85,16 @@ class NotificationFactory @Inject constructor(
// ID of the corresponding shortcut, for conversation features under API 30+
.setShortcutId(roomInfo.roomId.value)
// Title for API < 16 devices.
.setContentTitle(roomInfo.roomDisplayName)
.setContentTitle(roomInfo.roomDisplayName.annotateForDebug(1))
// Content for API < 16 devices.
.setContentText(stringProvider.getString(R.string.notification_new_messages))
.setContentText(stringProvider.getString(R.string.notification_new_messages).annotateForDebug(2))
// Number of new notifications for API <24 (M and below) devices.
.setSubText(
stringProvider.getQuantityString(
R.plurals.notification_new_messages_for_room,
messageStyle.messages.size,
messageStyle.messages.size
)
).annotateForDebug(3)
)
// Auto-bundling is enabled for 4 or more notifications on API 24+ (N+)
// devices and all Wear devices. But we want a custom grouping, so we specify the groupID
@ -135,7 +136,7 @@ class NotificationFactory @Inject constructor(
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
}
.setTicker(tickerText)
.setTicker(tickerText.annotateForDebug(4))
.build()
}
@ -147,8 +148,8 @@ class NotificationFactory @Inject constructor(
val channelId = notificationChannels.getChannelIdForMessage(inviteNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(inviteNotifiableEvent.roomName ?: buildMeta.applicationName)
.setContentText(inviteNotifiableEvent.description)
.setContentTitle((inviteNotifiableEvent.roomName ?: buildMeta.applicationName).annotateForDebug(5))
.setContentText(inviteNotifiableEvent.description.annotateForDebug(6))
.setGroup(inviteNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
@ -196,8 +197,8 @@ class NotificationFactory @Inject constructor(
val channelId = notificationChannels.getChannelIdForMessage(simpleNotifiableEvent.noisy)
return NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(true)
.setContentTitle(buildMeta.applicationName)
.setContentText(simpleNotifiableEvent.description)
.setContentTitle(buildMeta.applicationName.annotateForDebug(7))
.setContentText(simpleNotifiableEvent.description.annotateForDebug(8))
.setGroup(simpleNotifiableEvent.sessionId.value)
.setGroupAlertBehavior(NotificationCompat.GROUP_ALERT_ALL)
.setSmallIcon(smallIcon)
@ -226,7 +227,7 @@ class NotificationFactory @Inject constructor(
* Create the summary notification.
*/
fun createSummaryListNotification(
sessionId: SessionId,
currentUser: MatrixUser,
style: NotificationCompat.InboxStyle?,
compatSummary: String,
noisy: Boolean,
@ -240,12 +241,12 @@ class NotificationFactory @Inject constructor(
// used in compat < N, after summary is built based on child notifications
.setWhen(lastMessageTimestamp)
.setStyle(style)
.setContentTitle(sessionId.value)
.setContentTitle(currentUser.userId.value.annotateForDebug(9))
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
// set content text to support devices running API level < 24
.setContentText(compatSummary)
.setGroup(sessionId.value)
.setContentText(compatSummary.annotateForDebug(10))
.setGroup(currentUser.userId.value)
// set this notification as the summary for the group
.setGroupSummary(true)
.setColor(accentColor)
@ -264,8 +265,8 @@ class NotificationFactory @Inject constructor(
priority = NotificationCompat.PRIORITY_LOW
}
}
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(sessionId))
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(sessionId))
.setContentIntent(pendingIntentFactory.createOpenSessionPendingIntent(currentUser.userId))
.setDeleteIntent(pendingIntentFactory.createDismissSummaryPendingIntent(currentUser.userId))
.build()
}

View file

@ -20,6 +20,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.currentRoomId
import io.element.android.services.appnavstate.api.currentSessionId
@ -52,10 +53,13 @@ data class NotifiableMessageEvent(
override val isUpdated: Boolean = false
) : NotifiableEvent {
val type: String = /* EventType.MESSAGE */ "m.room.message"
val type: String = EventType.MESSAGE
val description: String = body ?: ""
val title: String = senderName ?: ""
// TODO EAx The image has to be downloaded and expose using the file provider.
// Example of value from Element Android:
// content://im.vector.app.debug.mx-sdk.fileprovider/downloads/downloads/816abf76d806c768760568952b1862c8/F/72c33edd23dee3b95f4d5a18aa25fa54/image.png
val imageUri: Uri?
get() = imageUriString?.let { Uri.parse(it) }
}

View file

@ -0,0 +1,28 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Hovor"</string>
<string name="notification_channel_listening_for_events">"Naslouchání událostem"</string>
<string name="notification_channel_noisy">"Hlasitá oznámení"</string>
<string name="notification_channel_silent">"Tichá oznámení"</string>
<string name="notification_invitation_action_join">"Vstoupit"</string>
<string name="notification_invitation_action_reject">"Odmítnout"</string>
<string name="notification_new_messages">"Nové zprávy"</string>
<string name="notification_room_action_mark_as_read">"Označit jako přečtené"</string>
<string name="notification_sender_me">"Já"</string>
<string name="notification_test_push_notification_content">"Prohlížíte si oznámení! Klikněte na mě!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s a %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s in %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s v %2$s a %3$s"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d zpráva"</item>
<item quantity="few">"%1$s: %2$d zprávy"</item>
<item quantity="other">"%1$s: %2$d zpráv"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Vyberte, jak chcete přijímat oznámení"</string>
<string name="push_distributor_background_sync_android">"Synchronizace na pozadí"</string>
<string name="push_distributor_firebase_android">"Služby Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Nebyly nalezeny žádné funkční služby Google Play. Oznámení nemusí fungovat správně."</string>
<string name="notification_room_action_quick_reply">"Rychlá odpověď"</string>
</resources>

View file

@ -1,10 +1,16 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Anruf"</string>
<string name="notification_channel_listening_for_events">"Warte auf Ereignisse"</string>
<string name="notification_channel_noisy">"Laute Benachrichtigungen"</string>
<string name="notification_channel_silent">"Stumme Benachrichtigungen"</string>
<string name="notification_inline_reply_failed">"** Senden fehlgeschlagen - bitte Raum öffnen"</string>
<string name="notification_invitation_action_join">"Beitreten"</string>
<string name="notification_invitation_action_reject">"Ablehnen"</string>
<string name="notification_new_messages">"Neue Nachrichten"</string>
<string name="notification_room_action_mark_as_read">"Als gelesen markieren"</string>
<string name="notification_sender_me">"Ich"</string>
<string name="notification_test_push_notification_content">"Du siehst die Benachrichtigung an! Klick mich an!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s und %2$s"</string>
@ -26,10 +32,16 @@
<item quantity="one">"%d neue Nachricht"</item>
<item quantity="other">"%d neue Nachrichten"</item>
</plurals>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d ungelesene benachrichtigte Nachricht"</item>
<item quantity="other">"%d ungelesene benachrichtigte Nachrichten"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d Raum"</item>
<item quantity="other">"%d Räume"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Auswählen, wie Benachrichtigungen empfangen werden sollen"</string>
<string name="push_distributor_background_sync_android">"Hintergrundsynchronisation"</string>
<string name="push_distributor_firebase_android">"Google-Dienste"</string>
<string name="push_no_valid_google_play_services_apk_android">"Keine gültigen Google Play-Dienste gefunden. Benachrichtigungen funktionieren möglicherweise nicht richtig."</string>
<string name="notification_room_action_quick_reply">"Schnellantwort"</string>

View file

@ -0,0 +1,48 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Appel"</string>
<string name="notification_channel_listening_for_events">"À l\'écoute d\'événements"</string>
<string name="notification_channel_noisy">"Notifications bruyantes"</string>
<string name="notification_channel_silent">"Notifications silencieuses"</string>
<string name="notification_inline_reply_failed">"** Échec de l\'envoi - veuillez ouvrir la salle"</string>
<string name="notification_invitation_action_join">"Rejoindre"</string>
<string name="notification_invitation_action_reject">"Refuser"</string>
<string name="notification_new_messages">"Nouveaux messages"</string>
<string name="notification_room_action_mark_as_read">"Marquer comme lu"</string>
<string name="notification_sender_me">"Moi"</string>
<string name="notification_test_push_notification_content">"Vous êtes en train de consulter la notification ! Cliquez-moi !"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<string name="notification_ticker_text_group">"%1$s: %2$s %3$s"</string>
<string name="notification_unread_notified_messages_and_invitation">"%1$s et %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s dans %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s dans %2$s et %3$s"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d message"</item>
<item quantity="other">"%1$s: %2$d messages"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d notification"</item>
<item quantity="other">"%d notifications"</item>
</plurals>
<plurals name="notification_invitations">
<item quantity="one">"%d invitation"</item>
<item quantity="other">"%d invitations"</item>
</plurals>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d nouveau message"</item>
<item quantity="other">"%d nouveaux messages"</item>
</plurals>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d message notifié non lu"</item>
<item quantity="other">"%d messages notifiés non lus"</item>
</plurals>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d conversation"</item>
<item quantity="other">"%d conversations"</item>
</plurals>
<string name="push_choose_distributor_dialog_title_android">"Choisissez comment recevoir les notifications"</string>
<string name="push_distributor_background_sync_android">"Synchronisation en arrière-plan"</string>
<string name="push_distributor_firebase_android">"Services Google"</string>
<string name="push_no_valid_google_play_services_apk_android">"Aucun service Google Play valide n\'a été trouvé. Les notifications peuvent ne pas fonctionner correctement."</string>
<string name="notification_room_action_quick_reply">"Réponse rapide"</string>
</resources>

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.AN_EVENT_ID_2
import io.element.android.libraries.matrix.test.A_ROOM_ID
@ -60,7 +61,7 @@ class NotifiableEventProcessorTest {
@Test
fun `given redacted simple event when processing then remove redaction event`() {
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = "m.room.redaction"))
val events = listOf(aSimpleNotifiableEvent(eventId = AN_EVENT_ID, type = EventType.REDACTION))
val result = eventProcessor.process(events, appNavigationState = NOT_VIEWING_A_ROOM, renderedEvents = emptyList())

View file

@ -18,6 +18,7 @@ package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -27,6 +28,7 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGrou
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import kotlinx.coroutines.test.runTest
import org.junit.Test
private val MY_AVATAR_URL: String? = null
@ -124,11 +126,13 @@ class NotificationFactoryTest {
fun `given room with message when mapping to notification then delegates to room group message creator`() = testWith(notificationFactory) {
val events = listOf(A_MESSAGE_EVENT)
val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(
A_SESSION_ID, events, A_ROOM_ID, A_SESSION_ID.value, MY_AVATAR_URL
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL), events, A_ROOM_ID
)
val roomWithMessage = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT)))
val result = roomWithMessage.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
val result = roomWithMessage.toNotifications(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
)
assertThat(result).isEqualTo(listOf(expectedNotification))
}
@ -138,7 +142,9 @@ class NotificationFactoryTest {
val events = listOf(ProcessedEvent(ProcessedEvent.Type.REMOVE, A_MESSAGE_EVENT))
val emptyRoom = mapOf(A_ROOM_ID to events)
val result = emptyRoom.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
val result = emptyRoom.toNotifications(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
)
assertThat(result).isEqualTo(
listOf(
@ -153,7 +159,9 @@ class NotificationFactoryTest {
fun `given a room with only redacted events when mapping to notification then is Empty`() = testWith(notificationFactory) {
val redactedRoom = mapOf(A_ROOM_ID to listOf(ProcessedEvent(ProcessedEvent.Type.KEEP, A_MESSAGE_EVENT.copy(isRedacted = true))))
val result = redactedRoom.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
val result = redactedRoom.toNotifications(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
)
assertThat(result).isEqualTo(
listOf(
@ -176,19 +184,21 @@ class NotificationFactoryTest {
)
val withRedactedRemoved = listOf(A_MESSAGE_EVENT.copy(eventId = EventId("\$not-redacted")))
val expectedNotification = roomGroupMessageCreator.givenCreatesRoomMessageFor(
A_SESSION_ID,
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL),
withRedactedRemoved,
A_ROOM_ID,
A_SESSION_ID.value,
MY_AVATAR_URL
)
val result = roomWithRedactedMessage.toNotifications(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
val result = roomWithRedactedMessage.toNotifications(
MatrixUser(A_SESSION_ID, A_SESSION_ID.value, MY_AVATAR_URL)
)
assertThat(result).isEqualTo(listOf(expectedNotification))
}
}
fun <T> testWith(receiver: T, block: T.() -> Unit) {
receiver.block()
fun <T> testWith(receiver: T, block: suspend T.() -> Unit) {
runTest {
receiver.block()
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.push.impl.notifications
import android.app.Notification
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
@ -24,6 +25,7 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeNotificatio
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationFactory
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.mockk.mockk
import kotlinx.coroutines.test.runTest
import org.junit.Test
private const val MY_USER_DISPLAY_NAME = "display-name"
@ -53,7 +55,7 @@ class NotificationRendererTest {
)
@Test
fun `given no notifications when rendering then cancels summary notification`() {
fun `given no notifications when rendering then cancels summary notification`() = runTest {
givenNoNotifications()
renderEventsAsNotifications()
@ -63,7 +65,7 @@ class NotificationRendererTest {
}
@Test
fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() {
fun `given last room message group notification is removed when rendering then remove the summary and then remove message notification`() = runTest {
givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
renderEventsAsNotifications()
@ -75,7 +77,7 @@ class NotificationRendererTest {
}
@Test
fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() {
fun `given a room message group notification is removed when rendering then remove the message notification and update summary`() = runTest {
givenNotifications(roomNotifications = listOf(RoomNotification.Removed(A_ROOM_ID)))
renderEventsAsNotifications()
@ -87,7 +89,7 @@ class NotificationRendererTest {
}
@Test
fun `given a room message group notification is added when rendering then show the message notification and update summary`() {
fun `given a room message group notification is added when rendering then show the message notification and update summary`() = runTest {
givenNotifications(
roomNotifications = listOf(
RoomNotification.Message(
@ -106,7 +108,7 @@ class NotificationRendererTest {
}
@Test
fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() {
fun `given last simple notification is removed when rendering then remove the summary and then remove simple notification`() = runTest {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
renderEventsAsNotifications()
@ -118,7 +120,7 @@ class NotificationRendererTest {
}
@Test
fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() {
fun `given a simple notification is removed when rendering then remove the simple notification and update summary`() = runTest {
givenNotifications(simpleNotifications = listOf(OneShotNotification.Removed(AN_EVENT_ID.value)))
renderEventsAsNotifications()
@ -130,7 +132,7 @@ class NotificationRendererTest {
}
@Test
fun `given a simple notification is added when rendering then show the simple notification and update summary`() {
fun `given a simple notification is added when rendering then show the simple notification and update summary`() = runTest {
givenNotifications(
simpleNotifications = listOf(
OneShotNotification.Append(
@ -149,7 +151,7 @@ class NotificationRendererTest {
}
@Test
fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() {
fun `given last invitation notification is removed when rendering then remove the summary and then remove invitation notification`() = runTest {
givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)), summaryNotification = A_REMOVE_SUMMARY_NOTIFICATION)
renderEventsAsNotifications()
@ -161,7 +163,7 @@ class NotificationRendererTest {
}
@Test
fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() {
fun `given an invitation notification is removed when rendering then remove the invitation notification and update summary`() = runTest {
givenNotifications(invitationNotifications = listOf(OneShotNotification.Removed(A_ROOM_ID.value)))
renderEventsAsNotifications()
@ -173,7 +175,7 @@ class NotificationRendererTest {
}
@Test
fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() {
fun `given an invitation notification is added when rendering then show the invitation notification and update summary`() = runTest {
givenNotifications(
simpleNotifications = listOf(
OneShotNotification.Append(
@ -191,11 +193,9 @@ class NotificationRendererTest {
}
}
private fun renderEventsAsNotifications() {
private suspend fun renderEventsAsNotifications() {
notificationRenderer.render(
sessionId = A_SESSION_ID,
myUserDisplayName = MY_USER_DISPLAY_NAME,
myUserAvatarUrl = MY_USER_AVATAR_URL,
MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL),
useCompleteNotificationFormat = USE_COMPLETE_NOTIFICATION_FORMAT,
eventsToProcess = AN_EVENT_LIST
)
@ -214,9 +214,7 @@ class NotificationRendererTest {
) {
notificationFactory.givenNotificationsFor(
groupedEvents = A_PROCESSED_EVENTS,
sessionId = A_SESSION_ID,
myUserDisplayName = MY_USER_DISPLAY_NAME,
myUserAvatarUrl = MY_USER_AVATAR_URL,
matrixUser = MatrixUser(A_SESSION_ID, MY_USER_DISPLAY_NAME, MY_USER_AVATAR_URL),
useCompleteNotificationFormat = useCompleteNotificationFormat,
roomNotifications = roomNotifications,
invitationNotifications = invitationNotifications,

View file

@ -16,12 +16,13 @@
package io.element.android.libraries.push.impl.notifications.fake
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.GroupedNotificationEvents
import io.element.android.libraries.push.impl.notifications.NotificationFactory
import io.element.android.libraries.push.impl.notifications.OneShotNotification
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.SummaryNotification
import io.mockk.coEvery
import io.mockk.every
import io.mockk.mockk
@ -30,9 +31,7 @@ class FakeNotificationFactory {
fun givenNotificationsFor(
groupedEvents: GroupedNotificationEvents,
sessionId: SessionId,
myUserDisplayName: String,
myUserAvatarUrl: String?,
matrixUser: MatrixUser,
useCompleteNotificationFormat: Boolean,
roomNotifications: List<RoomNotification>,
invitationNotifications: List<OneShotNotification>,
@ -40,13 +39,13 @@ class FakeNotificationFactory {
summaryNotification: SummaryNotification
) {
with(instance) {
every { groupedEvents.roomEvents.toNotifications(sessionId, myUserDisplayName, myUserAvatarUrl) } returns roomNotifications
coEvery { groupedEvents.roomEvents.toNotifications(matrixUser) } returns roomNotifications
every { groupedEvents.invitationEvents.toNotifications() } returns invitationNotifications
every { groupedEvents.simpleEvents.toNotifications() } returns simpleNotifications
every {
createSummaryNotification(
sessionId,
matrixUser,
roomNotifications,
invitationNotifications,
simpleNotifications,

View file

@ -17,11 +17,11 @@
package io.element.android.libraries.push.impl.notifications.fake
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.impl.notifications.RoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.RoomNotification
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.mockk.every
import io.mockk.coEvery
import io.mockk.mockk
class FakeRoomGroupMessageCreator {
@ -29,14 +29,18 @@ class FakeRoomGroupMessageCreator {
val instance = mockk<RoomGroupMessageCreator>()
fun givenCreatesRoomMessageFor(
sessionId: SessionId,
matrixUser: MatrixUser,
events: List<NotifiableMessageEvent>,
roomId: RoomId,
userDisplayName: String,
userAvatarUrl: String?
): RoomNotification.Message {
val mockMessage = mockk<RoomNotification.Message>()
every { instance.createRoomMessage(sessionId, events, roomId, userDisplayName, userAvatarUrl) } returns mockMessage
coEvery {
instance.createRoomMessage(
currentUser = matrixUser,
events = events,
roomId = roomId,
)
} returns mockMessage
return mockMessage
}
}

View file

@ -34,10 +34,10 @@ object SessionStorageModule {
@SingleIn(AppScope::class)
fun provideMatrixDatabase(@ApplicationContext context: Context): SessionDatabase {
val name = "session_database"
val secretFile = context.getDatabasePath("$name.key")
val secretFile = context.getDatabasePath("${name}.key")
val passphraseProvider = RandomSecretPassphraseProvider(context, secretFile)
val driver = SqlCipherDriverFactory(passphraseProvider)
.create(SessionDatabase.Schema, "$name.db", context)
.create(SessionDatabase.Schema, "${name}.db", context)
return SessionDatabase(driver)
}
}

View file

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_composer_placeholder">"Zpráva…"</string>
</resources>

View file

@ -1,5 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Aufzählungsliste ein-/ausschalten"</string>
<string name="rich_text_editor_code_block">"Codeblock umschalten"</string>
<string name="rich_text_editor_composer_placeholder">"Nachricht…"</string>
<string name="rich_text_editor_format_bold">"Fettformatierung anwenden"</string>
<string name="rich_text_editor_format_italic">"Kursivformat anwenden"</string>
<string name="rich_text_editor_format_strikethrough">"Durchgestrichenes Format anwenden"</string>
<string name="rich_text_editor_format_underline">"Unterstreichungsformat anwenden"</string>
<string name="rich_text_editor_full_screen_toggle">"Vollbildmodus umschalten"</string>
<string name="rich_text_editor_indent">"Einrücken"</string>
<string name="rich_text_editor_inline_code">"Inline-Codeformat anwenden"</string>
<string name="rich_text_editor_link">"Link setzen"</string>
<string name="rich_text_editor_numbered_list">"Nummerierte Liste ein-/ausschalten"</string>
<string name="rich_text_editor_quote">"Zitat umschalten"</string>
<string name="rich_text_editor_unindent">"Einrücken aufheben"</string>
</resources>

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_bullet_list">"Afficher une liste à puces"</string>
<string name="rich_text_editor_code_block">"Afficher le bloc de code"</string>
<string name="rich_text_editor_composer_placeholder">"Envoyer un message…"</string>
<string name="rich_text_editor_format_bold">"Appliquer le format gras"</string>
<string name="rich_text_editor_format_italic">"Appliquer le format italique"</string>
<string name="rich_text_editor_format_strikethrough">"Appliquer le format barré"</string>
<string name="rich_text_editor_format_underline">"Appliquer le format souligné"</string>
<string name="rich_text_editor_full_screen_toggle">"Afficher en mode plein écran"</string>
<string name="rich_text_editor_indent">"Décaler vers la droite"</string>
<string name="rich_text_editor_inline_code">"Appliquer le formatage de code en ligne"</string>
<string name="rich_text_editor_link">"Définir un lien"</string>
<string name="rich_text_editor_numbered_list">"Afficher une liste numérotée"</string>
<string name="rich_text_editor_quote">"Afficher une citation"</string>
<string name="rich_text_editor_unindent">"Décaler vers la gauche"</string>
</resources>

View file

@ -0,0 +1,143 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_hide_password">"Skrýt heslo"</string>
<string name="a11y_send_files">"Odeslat soubory"</string>
<string name="a11y_show_password">"Zobrazit heslo"</string>
<string name="a11y_user_menu">"Uživatelské menu"</string>
<string name="action_accept">"Přijmout"</string>
<string name="action_back">"Zpět"</string>
<string name="action_cancel">"Zrušit"</string>
<string name="action_choose_photo">"Vybrat fotku"</string>
<string name="action_clear">"Vymazat"</string>
<string name="action_close">"Zavřít"</string>
<string name="action_complete_verification">"Dokončit ověření"</string>
<string name="action_confirm">"Potvrdit"</string>
<string name="action_continue">"Pokračovat"</string>
<string name="action_copy">"Kopírovat"</string>
<string name="action_copy_link">"Kopírovat odkaz"</string>
<string name="action_create">"Vytvořit"</string>
<string name="action_create_a_room">"Vytvořit místnost"</string>
<string name="action_decline">"Odmítnout"</string>
<string name="action_disable">"Zakázat"</string>
<string name="action_done">"Hotovo"</string>
<string name="action_edit">"Upravit"</string>
<string name="action_enable">"Povolit"</string>
<string name="action_invite">"Pozvat"</string>
<string name="action_invite_friends_to_app">"Pozvat přátele do %1$s"</string>
<string name="action_invites_list">"Pozvánky"</string>
<string name="action_learn_more">"Zjistit více"</string>
<string name="action_leave">"Odejít"</string>
<string name="action_leave_room">"Opustit místnost"</string>
<string name="action_next">"Další"</string>
<string name="action_no">"Ne"</string>
<string name="action_not_now">"Teď ne"</string>
<string name="action_ok">"OK"</string>
<string name="action_quick_reply">"Rychlá odpověď"</string>
<string name="action_quote">"Citovat"</string>
<string name="action_remove">"Odstranit"</string>
<string name="action_reply">"Odpovědět"</string>
<string name="action_report_bug">"Nahlásit chybu"</string>
<string name="action_report_content">"Nahlásit obsah"</string>
<string name="action_retry">"Zkusit znovu"</string>
<string name="action_retry_decryption">"Opakovat dešifrování"</string>
<string name="action_save">"Uložit"</string>
<string name="action_search">"Hledat"</string>
<string name="action_send">"Odeslat"</string>
<string name="action_send_message">"Odeslat zprávu"</string>
<string name="action_share">"Sdílet"</string>
<string name="action_share_link">"Sdílet odkaz"</string>
<string name="action_skip">"Přeskočit"</string>
<string name="action_start">"Začít"</string>
<string name="action_start_chat">"Zahájit chat"</string>
<string name="action_start_verification">"Zahájit ověření"</string>
<string name="action_take_photo">"Vyfotit"</string>
<string name="action_view_source">"Zobrazit zdroj"</string>
<string name="action_yes">"Ano"</string>
<string name="common_about">"O aplikaci"</string>
<string name="common_analytics">"Analytika"</string>
<string name="common_audio">"Zvuk"</string>
<string name="common_bubbles">"Bubliny"</string>
<string name="common_creating_room">"Vytváření místnosti…"</string>
<string name="common_current_user_left_room">"Opustit místnost"</string>
<string name="common_decryption_error">"Chyba dešifrování"</string>
<string name="common_developer_options">"Možnosti pro vývojáře"</string>
<string name="common_edited_suffix">"(upraveno)"</string>
<string name="common_editing">"Úpravy"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Šifrování povoleno"</string>
<string name="common_error">"Chyba"</string>
<string name="common_file">"Soubor"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Obrázek"</string>
<string name="common_link_copied_to_clipboard">"Odkaz zkopírován do schránky"</string>
<string name="common_loading">"Načítání…"</string>
<string name="common_message">"Zpráva"</string>
<string name="common_message_layout">"Rozložení zprávy"</string>
<string name="common_message_removed">"Zpráva byla odstraněna"</string>
<string name="common_modern">"Moderní"</string>
<string name="common_no_results">"Žádné výsledky"</string>
<string name="common_offline">"Offline"</string>
<string name="common_password">"Heslo"</string>
<string name="common_people">"Lidé"</string>
<string name="common_permalink">"Trvalý odkaz"</string>
<string name="common_reactions">"Reakce"</string>
<string name="common_replying_to">"Odpověď na %1$s"</string>
<string name="common_report_a_bug">"Nahlásit chybu"</string>
<string name="common_report_submitted">"Zpráva odeslána"</string>
<string name="common_room_name">"Název místnosti"</string>
<string name="common_search_for_someone">"Hledat někoho"</string>
<string name="common_search_results">"Výsledky hledání"</string>
<string name="common_security">"Zabezpečení"</string>
<string name="common_select_your_server">"Vyberte svůj server"</string>
<string name="common_sending">"Odesílání…"</string>
<string name="common_server_not_supported">"Server není podporován"</string>
<string name="common_server_url">"URL serveru"</string>
<string name="common_settings">"Nastavení"</string>
<string name="common_sticker">"Nálepka"</string>
<string name="common_success">"Úspěch"</string>
<string name="common_suggestions">"Návrhy"</string>
<string name="common_topic">"Téma"</string>
<string name="common_unable_to_decrypt">"Nelze dešifrovat"</string>
<string name="common_unsupported_event">"Nepodporovaná událost"</string>
<string name="common_username">"Uživatelské jméno"</string>
<string name="common_verification_cancelled">"Ověření zrušeno"</string>
<string name="common_verification_complete">"Ověření dokončeno"</string>
<string name="common_video">"Video"</string>
<string name="common_waiting">"Čekání…"</string>
<string name="dialog_title_confirmation">"Potvrzení"</string>
<string name="dialog_title_warning">"Upozornění"</string>
<string name="emoji_picker_category_activity">"Aktivity"</string>
<string name="emoji_picker_category_flags">"Vlajky"</string>
<string name="emoji_picker_category_foods">"Jídlo a nápoje"</string>
<string name="emoji_picker_category_nature">"Zvířata a příroda"</string>
<string name="emoji_picker_category_objects">"Předměty"</string>
<string name="emoji_picker_category_people">"Smajlíci a lidé"</string>
<string name="emoji_picker_category_places">"Cestování a místa"</string>
<string name="emoji_picker_category_symbols">"Symboly"</string>
<string name="error_failed_creating_the_permalink">"Vytvoření trvalého odkazu se nezdařilo"</string>
<string name="error_failed_loading_messages">"Načítání zpráv se nezdařilo"</string>
<string name="error_some_messages_have_not_been_sent">"Některé zprávy nebyly odeslány"</string>
<string name="error_unknown">"Omlouváme se, došlo k chybě"</string>
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="leave_room_alert_empty_subtitle">"Opravdu chcete opustit tuto místnost? Jste tu jediná osoba. Pokud odejdete, nikdo se v budoucnu nebude moci připojit, včetně vás."</string>
<string name="leave_room_alert_private_subtitle">"Opravdu chcete opustit tuto místnost? Tato místnost není veřejná a bez pozvánky se nebudete moci znovu připojit."</string>
<string name="leave_room_alert_subtitle">"Opravdu chcete opustit místnost?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d člen"</item>
<item quantity="few">"%1$d členové"</item>
<item quantity="other">"%1$d členů"</item>
</plurals>
<string name="report_content_explanation">"Tato zpráva bude nahlášena správci vašeho domovského serveru. Nebude si moci přečíst žádné šifrované zprávy."</string>
<string name="report_content_hint">"Důvod nahlášení tohoto obsahu"</string>
<string name="room_timeline_beginning_of_room">"Toto je začátek %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Toto je začátek této konverzace."</string>
<string name="room_timeline_read_marker_title">"Nové"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_title_general">"Obecné"</string>
<string name="settings_version_number">"Verze: %1$s (%2$s)"</string>
<string name="test_language_identifier">"en"</string>
<string name="dialog_title_error">"Chyba"</string>
<string name="dialog_title_success">"Úspěch"</string>
<string name="screen_report_content_block_user">"Zablokovat uživatele"</string>
</resources>

View file

@ -4,15 +4,19 @@
<string name="a11y_send_files">"Dateien senden"</string>
<string name="a11y_show_password">"Passwort anzeigen"</string>
<string name="a11y_user_menu">"Benutzermenü"</string>
<string name="action_accept">"Zustimmen"</string>
<string name="action_back">"Zurück"</string>
<string name="action_cancel">"Abbrechen"</string>
<string name="action_choose_photo">"Foto auswählen"</string>
<string name="action_clear">"Löschen"</string>
<string name="action_close">"Schließen"</string>
<string name="action_complete_verification">"Verifizierung abschließen"</string>
<string name="action_confirm">"Bestätigen"</string>
<string name="action_continue">"Weiter"</string>
<string name="action_copy">"Kopieren"</string>
<string name="action_copy_link">"Link kopieren"</string>
<string name="action_create">"Erstellen"</string>
<string name="action_create_a_room">"Raum erstellen"</string>
<string name="action_decline">"Ablehnen"</string>
<string name="action_disable">"Deaktivieren"</string>
<string name="action_done">"Fertig"</string>
@ -31,6 +35,7 @@
<string name="action_quick_reply">"Schnellantwort"</string>
<string name="action_quote">"Zitieren"</string>
<string name="action_remove">"Entfernen"</string>
<string name="action_reply">"Antworten"</string>
<string name="action_report_bug">"Fehler melden"</string>
<string name="action_report_content">"Inhalt melden"</string>
<string name="action_retry">"Erneut versuchen"</string>
@ -42,31 +47,50 @@
<string name="action_share">"Teilen"</string>
<string name="action_share_link">"Link teilen"</string>
<string name="action_skip">"Überspringen"</string>
<string name="action_start">"Starten"</string>
<string name="action_start_chat">"Chat starten"</string>
<string name="action_start_verification">"Verifizierung starten"</string>
<string name="action_take_photo">"Foto aufnehmen"</string>
<string name="action_view_source">"Quelltext anzeigen"</string>
<string name="action_yes">"Ja"</string>
<string name="common_about">"Über"</string>
<string name="common_analytics">"Analyse"</string>
<string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Blasen"</string>
<string name="common_creating_room">"Erstelle Raum…"</string>
<string name="common_current_user_left_room">"Raum verlassen"</string>
<string name="common_decryption_error">"Entschlüsselungsfehler"</string>
<string name="common_developer_options">"Entwickleroptionen"</string>
<string name="common_edited_suffix">"(bearbeitet)"</string>
<string name="common_editing">"Bearbeiten"</string>
<string name="common_encryption_enabled">"Verschlüsselung aktiviert"</string>
<string name="common_error">"Fehler"</string>
<string name="common_file">"Datei"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Bild"</string>
<string name="common_link_copied_to_clipboard">"Link in Zwischenablage kopiert"</string>
<string name="common_loading">"Wird geladen…"</string>
<string name="common_message">"Nachricht"</string>
<string name="common_message_layout">"Nachrichtenlayout"</string>
<string name="common_message_removed">"Nachricht wurde entfernt"</string>
<string name="common_modern">"Modern"</string>
<string name="common_no_results">"Keine Ergebnisse"</string>
<string name="common_offline">"Offline"</string>
<string name="common_password">"Passwort"</string>
<string name="common_people">"Personen"</string>
<string name="common_permalink">"Permalink"</string>
<string name="common_reactions">"Reaktionen"</string>
<string name="common_report_a_bug">"Fehler melden"</string>
<string name="common_replying_to">"Auf %1$s antworten"</string>
<string name="common_report_a_bug">"Melde einen Fehler"</string>
<string name="common_report_submitted">"Bericht gesendet"</string>
<string name="common_room_name">"Raumname"</string>
<string name="common_search_for_someone">"Suche nach jemandem"</string>
<string name="common_search_results">"Suchergebnisse"</string>
<string name="common_security">"Sicherheit"</string>
<string name="common_select_your_server">"Wählen deinen Server"</string>
<string name="common_sending">"Senden…"</string>
<string name="common_server_not_supported">"Server wird nicht unterstützt"</string>
<string name="common_server_url">"Server-URL"</string>
<string name="common_settings">"Einstellungen"</string>
<string name="common_starting_chat">"Chat wird gestartet…"</string>
<string name="common_sticker">"Sticker"</string>
@ -80,6 +104,7 @@
<string name="common_verification_complete">"Verifizierung abgeschlossen"</string>
<string name="common_video">"Video"</string>
<string name="common_waiting">"Warten…"</string>
<string name="dialog_title_confirmation">"Bestätigung"</string>
<string name="dialog_title_warning">"Warnung"</string>
<string name="emoji_picker_category_activity">"Aktivitäten"</string>
<string name="emoji_picker_category_flags">"Flaggen"</string>
@ -89,26 +114,45 @@
<string name="emoji_picker_category_people">"Smileys &amp; Personen"</string>
<string name="emoji_picker_category_places">"Reisen &amp; Orte"</string>
<string name="emoji_picker_category_symbols">"Symbole"</string>
<string name="error_failed_creating_the_permalink">"Fehler beim Erstellen des Permalinks"</string>
<string name="error_failed_loading_messages">"Fehler beim Laden der Nachrichten"</string>
<string name="error_some_messages_have_not_been_sent">"Einige Nachrichten wurden nicht gesendet"</string>
<string name="error_unknown">"Entschuldigung, ein Fehler ist aufgetreten."</string>
<string name="invite_friends_text">"Hey, sprich mit mir auf %1$s: %2$s"</string>
<string name="leave_room_alert_empty_subtitle">"Bist du sicher, dass du diesen Raum verlassen willst? Du bist die einzige Person hier. Wenn du gehst, kann in Zukunft niemand mehr beitreten, auch du nicht."</string>
<string name="leave_room_alert_private_subtitle">"Bist du dir sicher, dass du den Raum verlassen möchtest? Dieser Raum ist nicht öffentlich und du kannst ihm ohne eine Einladung nicht mehr beitreten."</string>
<string name="leave_room_alert_subtitle">"Bist du dir sicher, dass du den Raum verlassen möchtest?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d Mitglied"</item>
<item quantity="other">"%1$d Mitglieder"</item>
</plurals>
<string name="preference_rageshake">"Rageshake zum Melden von Fehlern"</string>
<string name="rageshake_dialog_content">"Du scheinst frustriert das Telefon zu schütteln. Möchtest du den Fehlerberichtsbildschirm öffnen?"</string>
<string name="report_content_explanation">"Diese Nachricht wird an deinen Heimserver-Admin gemeldet werden. Er wird nicht in der Lage sein, verschlüsselte Nachrichten zu lesen."</string>
<string name="report_content_hint">"Grund für die Meldung dieses Inhalts"</string>
<string name="room_timeline_beginning_of_room">"Dies ist der Anfang von %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Dies ist der Beginn dieser Konversation."</string>
<string name="room_timeline_read_marker_title">"Neu"</string>
<string name="screen_analytics_prompt_data_usage">"Wir erfassen und analysieren "<b>"keine"</b>" Account-Daten"</string>
<string name="screen_analytics_prompt_help_us_improve">"Helfen Sie uns, Probleme zu identifizieren und %1$s zu verbessern, indem Sie anonyme Nutzungsdaten weitergeben."</string>
<string name="screen_analytics_prompt_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string>
<string name="screen_analytics_prompt_read_terms_content_link">"hier"</string>
<string name="screen_analytics_prompt_settings">"Sie können die Analyse jederzeit in den Einstellungen deaktivieren"</string>
<string name="screen_analytics_prompt_third_party_sharing">"Wir geben "<b>"keine"</b>" Informationen an Dritte weiter"</string>
<string name="screen_analytics_prompt_title">"Helfen Sie %1$s zu verbessern"</string>
<string name="screen_analytics_settings_share_data">"Teile Analyse-Daten"</string>
<string name="screen_media_picker_error_failed_selection">"Medienauswahl fehlgeschlagen, bitte versuche es erneut."</string>
<string name="screen_report_content_block_user_hint">"Prüfe, ob du alle aktuellen und zukünftigen Nachrichten dieses Benutzers ausblenden möchtest"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Erkennungsschwelle"</string>
<string name="settings_title_general">"Allgemein"</string>
<string name="settings_version_number">"Version: %1$s (%2$s)"</string>
<string name="test_language_identifier">"de"</string>
<string name="dialog_title_error">"Fehler"</string>
<string name="dialog_title_success">"Erfolg"</string>
<string name="screen_analytics_settings_help_us_improve">"Helfen Sie uns, Probleme zu identifizieren und %1$s zu verbessern, indem Sie anonyme Nutzungsdaten weitergeben."</string>
<string name="screen_analytics_settings_read_terms">"Sie können alle unsere Nutzerbedingungen %1$s lesen."</string>
<string name="screen_analytics_settings_read_terms_content_link">"hier"</string>
<string name="screen_report_content_block_user">"Nutzer blockieren"</string>
</resources>

View file

@ -4,13 +4,140 @@
<string name="a11y_send_files">"Envoyer des fichiers"</string>
<string name="a11y_show_password">"Afficher le mot de passe"</string>
<string name="a11y_user_menu">"Menu utilisateur"</string>
<string name="action_accept">"Accepter"</string>
<string name="action_back">"Retour"</string>
<string name="action_cancel">"Annuler"</string>
<string name="action_choose_photo">"Choisir une photo"</string>
<string name="action_clear">"Effacer"</string>
<string name="action_close">"Fermer"</string>
<string name="action_complete_verification">"Compléter la vérification"</string>
<string name="action_confirm">"Confirmer"</string>
<string name="action_continue">"Continuer"</string>
<string name="action_copy">"Copier"</string>
<string name="action_copy_link">"Copier le lien"</string>
<string name="action_create">"Créer"</string>
<string name="action_create_a_room">"Créer une salle"</string>
<string name="action_decline">"Refuser"</string>
<string name="action_disable">"Désactiver"</string>
<string name="action_done">"Terminé"</string>
<string name="action_edit">"Éditer"</string>
<string name="action_enable">"Activer"</string>
<string name="action_invite">"Inviter"</string>
<string name="action_invite_friends_to_app">"Inviter des amis à %1$s"</string>
<string name="action_invites_list">"Invitations"</string>
<string name="action_learn_more">"En savoir plus"</string>
<string name="action_leave">"Quitter"</string>
<string name="action_leave_room">"Quitter la salle"</string>
<string name="action_next">"Suivant"</string>
<string name="action_no">"Non"</string>
<string name="action_not_now">"Pas maintenant"</string>
<string name="action_ok">"OK"</string>
<string name="action_quick_reply">"Réponse rapide"</string>
<string name="action_quote">"Citer"</string>
<string name="action_remove">"Supprimer"</string>
<string name="action_reply">"Répondre"</string>
<string name="action_report_bug">"Signaler un bug"</string>
<string name="action_report_content">"Signaler le contenu"</string>
<string name="action_retry">"Réessayer"</string>
<string name="action_retry_decryption">"Réessayer le déchiffrement"</string>
<string name="action_save">"Enregistrer"</string>
<string name="action_search">"Chercher"</string>
<string name="action_send">"Envoyer"</string>
<string name="action_send_message">"Envoyer un message"</string>
<string name="action_share">"Partager"</string>
<string name="action_share_link">"Partager le lien"</string>
<string name="action_skip">"Passer"</string>
<string name="action_start">"Démarrer"</string>
<string name="action_start_chat">"Commencer un chat"</string>
<string name="action_start_verification">"Commencer la vérification"</string>
<string name="action_take_photo">"Prendre une photo"</string>
<string name="action_view_source">"Voir la source"</string>
<string name="action_yes">"Oui"</string>
<string name="common_about">"À propos"</string>
<string name="common_audio">"Audio"</string>
<string name="common_bubbles">"Bulles"</string>
<string name="common_creating_room">"Création de la salle…"</string>
<string name="common_current_user_left_room">"La salle a été quittée"</string>
<string name="common_decryption_error">"Erreur de déchiffrement"</string>
<string name="common_developer_options">"Options de développement"</string>
<string name="common_edited_suffix">"(édité)"</string>
<string name="common_editing">"Modification en cours"</string>
<string name="common_encryption_enabled">"Chiffrement activé"</string>
<string name="common_error">"Erreur"</string>
<string name="common_file">"Fichier"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Image"</string>
<string name="common_link_copied_to_clipboard">"Lien copié dans le presse-papiers"</string>
<string name="common_loading">"Chargement…"</string>
<string name="common_message">"Message"</string>
<string name="common_message_layout">"Mise en page du message"</string>
<string name="common_message_removed">"Message supprimé"</string>
<string name="common_modern">"Moderne"</string>
<string name="common_no_results">"Aucun résultat"</string>
<string name="common_offline">"Hors ligne"</string>
<string name="common_password">"Mot de passe"</string>
<string name="common_people">"Personnes"</string>
<string name="common_permalink">"Permalien"</string>
<string name="common_reactions">"Réactions"</string>
<string name="common_replying_to">"En réponse à %1$s"</string>
<string name="common_report_a_bug">"Signaler un problème"</string>
<string name="common_report_submitted">"Rapport envoyé"</string>
<string name="common_room_name">"Nom de la salle"</string>
<string name="common_search_for_someone">"Rechercher quelqu\'un"</string>
<string name="common_security">"Sécurité"</string>
<string name="common_select_your_server">"Sélectionnez votre serveur"</string>
<string name="common_sending">"Envoi en cours…"</string>
<string name="common_server_not_supported">"Serveur non pris en charge"</string>
<string name="common_server_url">"URL du serveur"</string>
<string name="common_settings">"Paramètres"</string>
<string name="common_sticker">"Autocollant"</string>
<string name="common_success">"Succès"</string>
<string name="common_suggestions">"Suggestions"</string>
<string name="common_topic">"Sujet"</string>
<string name="common_unable_to_decrypt">"Incapable de décrypter"</string>
<string name="common_unsupported_event">"Événement non pris en charge"</string>
<string name="common_username">"Nom d\'utilisateur"</string>
<string name="common_verification_cancelled">"Vérification annulée"</string>
<string name="common_verification_complete">"Vérification terminée"</string>
<string name="common_video">"Vidéo"</string>
<string name="common_waiting">"Patientez…"</string>
<string name="dialog_title_confirmation">"Confirmation"</string>
<string name="dialog_title_warning">"Attention"</string>
<string name="emoji_picker_category_activity">"Activités"</string>
<string name="emoji_picker_category_flags">"Drapeaux"</string>
<string name="emoji_picker_category_foods">"Nourriture et boissons"</string>
<string name="emoji_picker_category_nature">"Animaux et nature"</string>
<string name="emoji_picker_category_objects">"Objets"</string>
<string name="emoji_picker_category_people">"Émoticônes et personnes"</string>
<string name="emoji_picker_category_places">"Voyages &amp; lieux"</string>
<string name="emoji_picker_category_symbols">"Symboles"</string>
<string name="error_failed_creating_the_permalink">"Échec de la création du permalien"</string>
<string name="error_failed_loading_messages">"Échec du chargement des messages"</string>
<string name="error_some_messages_have_not_been_sent">"Certains messages n\'ont pas été envoyés"</string>
<string name="error_unknown">"Désolé, une erreur est survenue."</string>
<string name="invite_friends_text">"Salut, parle-moi sur %1$s : %2$s"</string>
<string name="leave_room_alert_empty_subtitle">"Êtes-vous sûr de vouloir quitter cette salle ? Vous êtes la seule personne ici. Si vous partez, personne ne pourra rejoindre la salle à l\'avenir, y compris vous."</string>
<string name="leave_room_alert_private_subtitle">"Êtes-vous sûr de vouloir quitter cette salle ? Cette salle n\'est pas publique et vous ne pourrez pas la rejoindre sans invitation."</string>
<string name="leave_room_alert_subtitle">"Êtes-vous sûr de vouloir quitter la salle ?"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d membre"</item>
<item quantity="other">"%1$d membres"</item>
</plurals>
<string name="preference_rageshake">"Rageshake pour signaler un bug"</string>
<string name="rageshake_dialog_content">"Vous semblez secouer le téléphone de frustration. Voulez-vous ouvrir le formulaire de rapport de problème ?"</string>
<string name="report_content_explanation">"Ce message sera signalé à ladministrateur de votre serveur d\'accueil. Ils ne pourront lire aucun message crypté."</string>
<string name="report_content_hint">"Raison du signalement de ce contenu"</string>
<string name="room_timeline_beginning_of_room">"Ceci est le début de %1$s."</string>
<string name="room_timeline_beginning_of_room_no_name">"Ceci est le début de cette conversation."</string>
<string name="room_timeline_read_marker_title">"Nouveau"</string>
<string name="screen_report_content_block_user_hint">"Cochez si vous souhaitez masquer tous les messages actuels et futurs de cet utilisateur."</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Seuil de détection"</string>
<string name="settings_title_general">"Général"</string>
<string name="settings_version_number">"Version: %1$s ( %2$s )"</string>
<string name="test_language_identifier">"fr"</string>
<string name="dialog_title_error">"Erreur"</string>
<string name="dialog_title_success">"Succès"</string>
<string name="screen_report_content_block_user">"Bloquer l\'utilisateur"</string>
</resources>

View file

@ -23,6 +23,7 @@
<string name="action_edit">"Editați"</string>
<string name="action_enable">"Activați"</string>
<string name="action_invite">"Invitați"</string>
<string name="action_invite_friends">"Invitați prieteni"</string>
<string name="action_invite_friends_to_app">"Invitați prieteni în %1$s"</string>
<string name="action_invites_list">"Invitații"</string>
<string name="action_learn_more">"Aflați mai multe"</string>
@ -63,11 +64,13 @@
<string name="common_developer_options">"Opțiuni programator"</string>
<string name="common_edited_suffix">"(editat)"</string>
<string name="common_editing">"Editare"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Criptare activată"</string>
<string name="common_error">"Eroare"</string>
<string name="common_file">"Fişier"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Imagine"</string>
<string name="common_leaving_room">"Se părăsește conversația"</string>
<string name="common_link_copied_to_clipboard">"Linkul a fost copiat în clipboard"</string>
<string name="common_loading">"Se încarcă…"</string>
<string name="common_message">"Mesaj"</string>
@ -83,18 +86,23 @@
<string name="common_replying_to">"Răspuns pentru %1$s"</string>
<string name="common_report_a_bug">"Raportați o eroare"</string>
<string name="common_report_submitted">"Raport trimis"</string>
<string name="common_room_name">"Numele camerei"</string>
<string name="common_search_for_someone">"Căutați pe cineva"</string>
<string name="common_search_results">"Rezultatele căutării"</string>
<string name="common_security">"Securitate"</string>
<string name="common_select_your_server">"Selectați serverul"</string>
<string name="common_sending">"Se trimite…"</string>
<string name="common_server_not_supported">"Serverul nu este compatibil"</string>
<string name="common_server_url">"Adresa URL a serverului"</string>
<string name="common_settings">"Setări"</string>
<string name="common_starting_chat">"Se începe conversația…"</string>
<string name="common_sticker">"Autocolant"</string>
<string name="common_success">"Succes"</string>
<string name="common_suggestions">"Sugestii"</string>
<string name="common_topic">"Subiect"</string>
<string name="common_unable_to_decrypt">"Nu s-a putut decripta"</string>
<string name="common_unable_to_invite_message">"Nu am putut trimite cu succes invitații unuia sau mai multor utilizatori."</string>
<string name="common_unable_to_invite_title">"Nu s-a putut trimite invitația (invitațiile)"</string>
<string name="common_unsupported_event">"Eveniment neacceptat"</string>
<string name="common_username">"Utilizator"</string>
<string name="common_verification_cancelled">"Verificare anulată"</string>
@ -115,6 +123,7 @@
<string name="error_failed_loading_messages">"Încărcarea mesajelor a eșuat"</string>
<string name="error_some_messages_have_not_been_sent">"Unele mesaje nu au fost trimise"</string>
<string name="error_unknown">"Ne pare rău, a apărut o eroare"</string>
<string name="invite_friends_rich_title">"🔐️ Alăturați-vă mie pe %1$s"</string>
<string name="invite_friends_text">"Hei, vorbește cu mine pe %1$s: %2$s"</string>
<string name="leave_room_alert_empty_subtitle">"Sunteți sigur că vreți să părăsiți această cameră? Sunteți singura persoană de aici. Dacă o părasiți, nimeni nu se va mai putea alătura în viitor, inclusiv dumneavoastra."</string>
<string name="leave_room_alert_private_subtitle">"Sunteți sigur că vrei să părăsiți această cameră? Această cameră nu este publică și nu va veti putea alătura din nou fără o invitație."</string>
@ -139,6 +148,10 @@
<string name="screen_analytics_prompt_settings">"Puteți dezactiva această opțiune oricând din setări"</string>
<string name="screen_analytics_prompt_third_party_sharing"><b>"Nu"</b>" împărtășim informații cu terți"</string>
<string name="screen_analytics_prompt_title">"Ajutați la îmbunătățirea %1$s"</string>
<string name="screen_analytics_settings_share_data">"Partajați datele analitice"</string>
<string name="screen_media_picker_error_failed_selection">"Selectarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Procesarea datelor media a eșuat, vă rugăm să încercați din nou."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Încărcarea fișierelor media a eșuat, încercați din nou."</string>
<string name="screen_report_content_block_user_hint">"Confirmați că doriți să ascundeți toate mesajele curente și viitoare de la acest utilizator"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Prag de detecție"</string>

View file

@ -22,6 +22,7 @@
<string name="action_done">"Done"</string>
<string name="action_edit">"Edit"</string>
<string name="action_enable">"Enable"</string>
<string name="action_forgot_password">"Forgot password?"</string>
<string name="action_invite">"Invite"</string>
<string name="action_invite_friends">"Invite friends"</string>
<string name="action_invite_friends_to_app">"Invite friends to %1$s"</string>
@ -70,6 +71,7 @@
<string name="common_file">"File"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Image"</string>
<string name="common_invite_unknown_profile">"We cant validate this users Matrix ID. The invite might not be received."</string>
<string name="common_leaving_room">"Leaving room"</string>
<string name="common_link_copied_to_clipboard">"Link copied to clipboard"</string>
<string name="common_loading">"Loading…"</string>
@ -86,6 +88,7 @@
<string name="common_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_bug">"Report a bug"</string>
<string name="common_report_submitted">"Report submitted"</string>
<string name="common_room_name">"Room name"</string>
<string name="common_search_for_someone">"Search for someone"</string>
<string name="common_search_results">"Search results"</string>
<string name="common_security">"Security"</string>
@ -151,6 +154,12 @@
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Failed uploading media, please try again."</string>
<string name="screen_report_content_block_user_hint">"Check if you want to hide all current and future messages from this user"</string>
<string name="screen_server_confirmation_change_server">"Change account provider"</string>
<string name="screen_server_confirmation_message_login_element_dot_io">"A private server for Element employees."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix is an open network for secure, decentralised communication."</string>
<string name="screen_server_confirmation_message_register">"This is where your conversations will live — just like you would use an email provider to keep your emails."</string>
<string name="screen_server_confirmation_title_login">"Youre about to sign in to %1$s"</string>
<string name="screen_server_confirmation_title_register">"Youre about to create an account on %1$s"</string>
<string name="settings_rageshake">"Rageshake"</string>
<string name="settings_rageshake_detection_threshold">"Detection threshold"</string>
<string name="settings_title_general">"General"</string>
@ -163,4 +172,4 @@
<string name="screen_analytics_settings_read_terms">"You can read all our terms %1$s."</string>
<string name="screen_analytics_settings_read_terms_content_link">"here"</string>
<string name="screen_report_content_block_user">"Block user"</string>
</resources>
</resources>

View file

@ -16,10 +16,9 @@
package io.element.android.libraries.usersearch.api
import io.element.android.libraries.matrix.api.user.MatrixUser
import kotlinx.coroutines.flow.Flow
interface UserRepository {
suspend fun search(query: String): Flow<List<MatrixUser>>
suspend fun search(query: String): Flow<List<UserSearchResult>>
}

View file

@ -0,0 +1,24 @@
/*
* Copyright (c) 2023 New Vector Ltd
*
* Licensed under the Apache License, Version 2.0 (the "License");
* you may not use this file except in compliance with the License.
* You may obtain a copy of the License at
*
* http://www.apache.org/licenses/LICENSE-2.0
*
* Unless required by applicable law or agreed to in writing, software
* distributed under the License is distributed on an "AS IS" BASIS,
* WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
* See the License for the specific language governing permissions and
* limitations under the License.
*/
package io.element.android.libraries.usersearch.api
import io.element.android.libraries.matrix.api.user.MatrixUser
data class UserSearchResult(
val matrixUser: MatrixUser,
val isUnresolved: Boolean = false,
)

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserListDataSource
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.flow
@ -33,24 +34,26 @@ class MatrixUserRepository @Inject constructor(
private val dataSource: UserListDataSource
) : UserRepository {
override suspend fun search(query: String): Flow<List<MatrixUser>> = flow {
override suspend fun search(query: String): Flow<List<UserSearchResult>> = flow {
// Manually add a fake result with the matrixId, if any
val isUserId = MatrixPatterns.isUserId(query)
if (isUserId) {
emit(listOf(MatrixUser(UserId(query))))
emit(listOf(UserSearchResult(MatrixUser(UserId(query)))))
}
if (query.length >= MINIMUM_SEARCH_LENGTH) {
// Debounce
delay(DEBOUNCE_TIME_MILLIS)
val results = dataSource.search(query).toMutableList()
val results = dataSource.search(query).map { UserSearchResult(it) }.toMutableList()
// If the query is a user ID and the result doesn't contain that user ID, query the profile information explicitly
if (isUserId && results.none { it.userId.value == query }) {
val getProfileResult: MatrixUser? = dataSource.getProfile(UserId(query))
val profile = getProfileResult ?: MatrixUser(UserId(query))
results.add(0, profile)
if (isUserId && results.none { it.matrixUser.userId.value == query }) {
results.add(
0,
dataSource.getProfile(UserId(query))
?.let { UserSearchResult(it) }
?: UserSearchResult(MatrixUser(UserId(query)), isUnresolved = true))
}
emit(results)

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.ui.components.aMatrixUserList
import io.element.android.libraries.usersearch.api.UserSearchResult
import io.element.android.libraries.usersearch.test.FakeUserListDataSource
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -63,7 +64,7 @@ internal class MatrixUserRepositoryTest {
val result = repository.search("some query")
result.test {
assertThat(awaitItem()).isEqualTo(aMatrixUserList())
assertThat(awaitItem()).isEqualTo(aMatrixUserList().toUserSearchResults())
awaitComplete()
}
}
@ -76,7 +77,7 @@ internal class MatrixUserRepositoryTest {
val result = repository.search(A_USER_ID.value)
result.test {
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)))
assertThat(awaitItem()).isEqualTo(listOf(placeholderResult()))
skipItems(1)
awaitComplete()
}
@ -93,7 +94,7 @@ internal class MatrixUserRepositoryTest {
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(searchResults)
assertThat(awaitItem()).isEqualTo(searchResults.toUserSearchResults())
awaitComplete()
}
}
@ -112,13 +113,13 @@ internal class MatrixUserRepositoryTest {
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(userProfile) + searchResults)
assertThat(awaitItem()).isEqualTo((listOf(userProfile) + searchResults).toUserSearchResults())
awaitComplete()
}
}
@Test
fun `search - just shows id if profile can't be loaded`() = runTest {
fun `search - returns unresolved user if profile can't be loaded`() = runTest {
val searchResults = aMatrixUserListWithoutUserId(A_USER_ID)
val dataSource = FakeUserListDataSource()
@ -130,11 +131,15 @@ internal class MatrixUserRepositoryTest {
result.test {
skipItems(1)
assertThat(awaitItem()).isEqualTo(listOf(MatrixUser(userId = A_USER_ID)) + searchResults)
assertThat(awaitItem()).isEqualTo(listOf(placeholderResult(isUnresolved = true)) + searchResults.toUserSearchResults())
awaitComplete()
}
}
private fun aMatrixUserListWithoutUserId(userId: UserId) = aMatrixUserList().filterNot { it.userId == userId }
private fun List<MatrixUser>.toUserSearchResults() = map { UserSearchResult(it) }
private fun placeholderResult(id: UserId = A_USER_ID, isUnresolved: Boolean = false) = UserSearchResult(MatrixUser(id), isUnresolved = isUnresolved)
}

View file

@ -16,8 +16,8 @@
package io.element.android.libraries.usersearch.test
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.usersearch.api.UserRepository
import io.element.android.libraries.usersearch.api.UserSearchResult
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
@ -26,14 +26,14 @@ class FakeUserRepository : UserRepository {
var providedQuery: String? = null
private set
private val flow = MutableSharedFlow<List<MatrixUser>>()
private val flow = MutableSharedFlow<List<UserSearchResult>>()
override suspend fun search(query: String): Flow<List<MatrixUser>> {
override suspend fun search(query: String): Flow<List<UserSearchResult>> {
providedQuery = query
return flow
}
suspend fun emitResult(result: List<MatrixUser>) {
suspend fun emitResult(result: List<UserSearchResult>) {
flow.emit(result)
}