Merge branch 'develop' into feature/fga/message_queuing

This commit is contained in:
ganfra 2024-06-11 17:08:47 +02:00
commit b927daffe7
620 changed files with 6821 additions and 1244 deletions

View file

@ -16,10 +16,22 @@
package io.element.android.libraries.androidutils.compat
import android.content.Intent
import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.content.pm.ResolveInfo
import android.os.Build
fun PackageManager.queryIntentActivitiesCompat(data: Intent, flags: Int): List<ResolveInfo> {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> queryIntentActivities(
data,
PackageManager.ResolveInfoFlags.of(flags.toLong())
)
else -> @Suppress("DEPRECATION") queryIntentActivities(data, flags)
}
}
fun PackageManager.getApplicationInfoCompat(packageName: String, flags: Int): ApplicationInfo {
return when {
Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU -> getApplicationInfo(

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">"Ei õnnestunud leida selle tegevuse jaoks vajalikku välist rakendust."</string>
</resources>

View file

@ -22,6 +22,7 @@ import androidx.compose.ui.unit.dp
enum class AvatarSize(val dp: Dp) {
CurrentUserTopBar(32.dp),
IncomingCall(140.dp),
RoomHeader(96.dp),
RoomListItem(52.dp),

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.eventformatter.api.RoomLastMessageFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
@ -111,6 +112,8 @@ class DefaultRoomLastMessageFormatter @Inject constructor(
prefixIfNeeded(sp.getString(CommonStrings.common_unsupported_event), senderDisambiguatedDisplayName, isDmRoom)
}
is LegacyCallInviteContent -> sp.getString(CommonStrings.common_call_invite)
is CallNotifyContent -> sp.getString(CommonStrings.common_call_started)
else -> null
}?.take(MAX_SAFE_LENGTH)
}

View file

@ -21,6 +21,7 @@ import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.SessionScope
import io.element.android.libraries.eventformatter.api.TimelineEventFormatter
import io.element.android.libraries.eventformatter.impl.mode.RenderingMode
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventTimelineItem
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -63,6 +64,9 @@ class DefaultTimelineEventFormatter @Inject constructor(
is LegacyCallInviteContent -> {
sp.getString(CommonStrings.common_call_invite)
}
is CallNotifyContent -> {
sp.getString(CommonStrings.common_call_started)
}
RedactedContent,
is StickerContent,
is PollContent,

View file

@ -0,0 +1,63 @@
<?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">"(tunnuspilt muutus ka)"</string>
<string name="state_event_avatar_url_changed">"%1$s muutis oma tunnuspilti"</string>
<string name="state_event_avatar_url_changed_by_you">"Sina muutsid oma tunnuspilti"</string>
<string name="state_event_demoted_to_member">"%1$s on nüüd tavakasutaja rollis"</string>
<string name="state_event_demoted_to_moderator">"%1$s on nüüd moderaatori rollis"</string>
<string name="state_event_display_name_changed_from">"%1$s muutis senise kuvatava nime „%2$s“ asemele uueks nimeks „%3$s“"</string>
<string name="state_event_display_name_changed_from_by_you">"Sina muutsid senise kuvatava nime „%1$s“ asemel uueks nimeks „%2$s“"</string>
<string name="state_event_display_name_removed">"%1$s eemaldas oma kuvatava nime (mis oli „%2$s“)"</string>
<string name="state_event_display_name_removed_by_you">"Sina eemaldasid oma kuvatava nime (mis oli „%1$s“)"</string>
<string name="state_event_display_name_set">"%1$s määras oma kuvatavaks nimeks „%2$s“"</string>
<string name="state_event_display_name_set_by_you">"Sina määrasid oma kuvatavaks nimeks „%1$s“"</string>
<string name="state_event_promoted_to_administrator">"%1$s on nüüd peakasutaja rollis"</string>
<string name="state_event_promoted_to_moderator">"%1$s on nüüd moderaatori rollis"</string>
<string name="state_event_room_avatar_changed">"%1$s muutis jututoa tunnuspilti"</string>
<string name="state_event_room_avatar_changed_by_you">"Sina muutsid jututoa tunnuspilti"</string>
<string name="state_event_room_avatar_removed">"%1$s eemaldas jututoa tunnuspildi"</string>
<string name="state_event_room_avatar_removed_by_you">"Sina eemaldasid jututoa tunnuspildi"</string>
<string name="state_event_room_ban">"%1$s keelas %2$s ligipääsu"</string>
<string name="state_event_room_ban_by_you">"Sina keelasid %1$s ligipääsu"</string>
<string name="state_event_room_created">"%1$s lõi jututoa"</string>
<string name="state_event_room_created_by_you">"Sina lõid jututoa"</string>
<string name="state_event_room_invite">"%1$s saatis kutse kasutajale %2$s"</string>
<string name="state_event_room_invite_accepted">"%1$s võttis kutse vastu"</string>
<string name="state_event_room_invite_accepted_by_you">"Sina võtsid kutse vastu"</string>
<string name="state_event_room_invite_by_you">"Sina saatsid kutse kasutajale %1$s"</string>
<string name="state_event_room_invite_you">"%1$s saatis sulle kutse"</string>
<string name="state_event_room_join">"%1$s liitus jututoaga"</string>
<string name="state_event_room_join_by_you">"Sina liitusid jututoaga"</string>
<string name="state_event_room_knock">"%1$s palus liitumist"</string>
<string name="state_event_room_knock_accepted">"%1$s lubas kasutajal %2$s liituda"</string>
<string name="state_event_room_knock_accepted_by_you">"Sina lubasid kasutajal %1$s liituda"</string>
<string name="state_event_room_knock_by_you">"Sina palusid liitumist"</string>
<string name="state_event_room_knock_denied">"%1$s lükkas tagasi kasutaja %2$s liitumispalve"</string>
<string name="state_event_room_knock_denied_by_you">"Sina lükkasid tagasi kasutaja %1$s liitumispalve"</string>
<string name="state_event_room_knock_denied_you">"%1$s lükkas tagasi sinu liitumispalve"</string>
<string name="state_event_room_knock_retracted">"%1$s pole enam liitumisest huvitatud"</string>
<string name="state_event_room_knock_retracted_by_you">"Sina tühitasid oma liitumissoovi"</string>
<string name="state_event_room_leave">"%1$s lahkus jututoast"</string>
<string name="state_event_room_leave_by_you">"Sina lahkusid jututoast"</string>
<string name="state_event_room_name_changed">"%1$s muutis jututoa uueks nimeks „%2$s“"</string>
<string name="state_event_room_name_changed_by_you">"Sina muutsid jututoa uueks nimeks „%1$s“"</string>
<string name="state_event_room_name_removed">"%1$s eemaldas jututoa nime"</string>
<string name="state_event_room_name_removed_by_you">"Sina eemaldasid jututoa nime"</string>
<string name="state_event_room_none">"%1$s ei teinud ühtegi muudatust"</string>
<string name="state_event_room_none_by_you">"Sina ei teinud ühtegi muudatust"</string>
<string name="state_event_room_reject">"%1$s lükkas kutse tagasi"</string>
<string name="state_event_room_reject_by_you">"Sina lükkasid kutse tagasi"</string>
<string name="state_event_room_remove">"%1$s eemaldas jututoast kasutaja %2$s"</string>
<string name="state_event_room_remove_by_you">"Sina eemaldasid jututoast kasutaja %1$s"</string>
<string name="state_event_room_third_party_invite">"%1$s saatis jututoaga liitumiseks kutse kasutajale %2$s"</string>
<string name="state_event_room_third_party_invite_by_you">"Sina saatsid kasutajale %1$s kutse jututoaga liitumiseks"</string>
<string name="state_event_room_third_party_revoked_invite">"%1$s võttis tagasi jututoaga liitumise kutse kasutajalt %2$s"</string>
<string name="state_event_room_third_party_revoked_invite_by_you">"Sina võtsid tagasi jututoaga liitumise kutse kasutajalt %1$s"</string>
<string name="state_event_room_topic_changed">"%1$s muutis uueks teemaks %2$s"</string>
<string name="state_event_room_topic_changed_by_you">"Sina muutsid uueks teemaks %1$s"</string>
<string name="state_event_room_topic_removed">"%1$s eemaldas jututoa teema"</string>
<string name="state_event_room_topic_removed_by_you">"Sina eemaldasid jututoa teema"</string>
<string name="state_event_room_unban">"%1$s taastas %2$s ligipääsu"</string>
<string name="state_event_room_unban_by_you">"Sina taastasid %1$s ligipääsu"</string>
<string name="state_event_room_unknown_membership_change">"%1$s tegi oma liikmelisuses teadmata muutatuse"</string>
</resources>

View file

@ -50,6 +50,7 @@ import io.element.android.libraries.matrix.api.timeline.item.event.VideoMessageT
import io.element.android.libraries.matrix.api.timeline.item.event.VoiceMessageType
import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.media.aMediaSource
import io.element.android.libraries.matrix.test.permalink.FakePermalinkParser
import io.element.android.libraries.matrix.test.timeline.aPollContent
import io.element.android.libraries.matrix.test.timeline.aProfileChangeMessageContent
@ -106,7 +107,7 @@ class DefaultRoomLastMessageFormatterTest {
fun `Sticker content`() {
val body = "body"
val info = ImageInfo(null, null, null, null, null, null, null)
val message = createRoomEvent(false, null, StickerContent(body, info, "url"))
val message = createRoomEvent(false, null, StickerContent(body, info, aMediaSource(url = "url")))
val result = formatter.format(message, false)
assertThat(result).isEqualTo(body)
}

View file

@ -96,4 +96,11 @@ enum class FeatureFlags(
defaultValue = true,
isFinished = false,
),
IncomingShare(
key = "feature.incomingShare",
title = "Incoming Share support",
description = "Allow the application to receive data from other applications",
defaultValue = true,
isFinished = false,
),
}

View file

@ -44,6 +44,7 @@ class StaticFeatureFlagProvider @Inject constructor() :
FeatureFlags.RoomDirectorySearch -> false
FeatureFlags.ShowBlockedUsersDetails -> false
FeatureFlags.QrCodeLogin -> OnBoardingConfig.CAN_LOGIN_WITH_QR_CODE
FeatureFlags.IncomingShare -> true
}
} else {
false

View file

@ -33,3 +33,5 @@ value class ThreadId(val value: String) : Serializable {
override fun toString(): String = value
}
fun ThreadId.asEventId(): EventId = EventId(value)

View file

@ -330,5 +330,10 @@ interface MatrixRoom : Closeable {
*/
suspend fun getPermalinkFor(eventId: EventId): Result<String>
/**
* Send an Element Call started notification if needed.
*/
suspend fun sendCallNotificationIfNeeded(): Result<Unit>
override fun close() = destroy()
}

View file

@ -57,7 +57,13 @@ interface Timeline : AutoCloseable {
suspend fun enterSpecialMode(eventId: EventId?): Result<Unit>
suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit>
suspend fun replyMessage(
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
fromNotification: Boolean = false,
): Result<Unit>
suspend fun sendImage(
file: File,

View file

@ -19,6 +19,7 @@ package io.element.android.libraries.matrix.api.timeline.item.event
import androidx.compose.runtime.Immutable
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.ImageInfo
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.poll.PollAnswer
import io.element.android.libraries.matrix.api.poll.PollKind
import kotlinx.collections.immutable.ImmutableList
@ -40,7 +41,7 @@ data object RedactedContent : EventContent
data class StickerContent(
val body: String,
val info: ImageInfo,
val url: String
val source: MediaSource,
) : EventContent
data class PollContent(
@ -102,4 +103,6 @@ data class FailedToParseStateContent(
data object LegacyCallInviteContent : EventContent
data object CallNotifyContent : EventContent
data object UnknownContent : EventContent

View file

@ -72,6 +72,7 @@ object EventType {
const val CALL_NEGOTIATE = "m.call.negotiate"
const val CALL_REJECT = "m.call.reject"
const val CALL_HANGUP = "m.call.hangup"
const val CALL_NOTIFY = "m.call.notify"
// This type is not processed by the client, just sent to the server
const val CALL_REPLACES = "m.call.replaces"
@ -94,6 +95,7 @@ object EventType {
type == CALL_SELECT_ANSWER ||
type == CALL_NEGOTIATE ||
type == CALL_REJECT ||
type == CALL_REPLACES
type == CALL_REPLACES ||
type == CALL_NOTIFY
}
}

View file

@ -17,9 +17,8 @@
package io.element.android.libraries.matrix.api.notification
import com.google.common.truth.Truth.assertThat
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_USER_ID
import io.element.android.libraries.matrix.test.notification.aNotificationData
import org.junit.Test
class NotificationDataTest {
@ -49,25 +48,4 @@ class NotificationDataTest {
)
assertThat(sut.getDisambiguatedDisplayName(A_USER_ID)).isEqualTo("Alice (@alice:server.org)")
}
private fun aNotificationData(
senderDisplayName: String?,
senderIsNameAmbiguous: Boolean,
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
senderAvatarUrl = null,
senderDisplayName = senderDisplayName,
senderIsNameAmbiguous = senderIsNameAmbiguous,
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = false,
isEncrypted = false,
isNoisy = false,
timestamp = 0L,
content = NotificationContent.MessageLike.RoomEncrypted,
hasMention = false,
)
}
}

View file

@ -27,6 +27,8 @@ import org.matrix.rustcomponents.sdk.RoomListService
import org.matrix.rustcomponents.sdk.RoomSubscription
import timber.log.Timber
private const val DEFAULT_TIMELINE_LIMIT = 20u
class RoomSyncSubscriber(
private val roomListService: RoomListService,
private val dispatchers: CoroutineDispatchers,
@ -41,8 +43,9 @@ class RoomSyncSubscriber(
RequiredState(key = EventType.STATE_ROOM_JOIN_RULES, value = ""),
RequiredState(key = EventType.STATE_ROOM_POWER_LEVELS, value = ""),
),
timelineLimit = null,
includeHeroes = true,
timelineLimit = DEFAULT_TIMELINE_LIMIT,
// We don't need heroes here as they're already included in the `all_rooms` list
includeHeroes = false,
)
suspend fun subscribe(roomId: RoomId) = mutex.withLock {

View file

@ -590,6 +590,10 @@ class RustMatrixRoom(
innerRoom.matrixToEventPermalink(eventId.value)
}
override suspend fun sendCallNotificationIfNeeded(): Result<Unit> = runCatching {
innerRoom.sendCallNotificationIfNeeded()
}
private fun createTimeline(
timeline: InnerTimeline,
isLive: Boolean,

View file

@ -25,11 +25,27 @@ import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.buffer
import kotlinx.coroutines.flow.callbackFlow
import kotlinx.coroutines.flow.catch
import org.matrix.rustcomponents.sdk.PaginationStatusListener
import org.matrix.rustcomponents.sdk.Timeline
import org.matrix.rustcomponents.sdk.TimelineDiff
import org.matrix.rustcomponents.sdk.TimelineItem
import org.matrix.rustcomponents.sdk.TimelineListener
import timber.log.Timber
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
internal fun Timeline.liveBackPaginationStatus(): Flow<LiveBackPaginationStatus> = callbackFlow {
val listener = object : PaginationStatusListener {
override fun onUpdate(status: LiveBackPaginationStatus) {
trySend(status)
}
}
val result = subscribeToBackPaginationStatus(listener)
awaitClose {
result.cancelAndDestroy()
}
}.catch {
Timber.d(it, "liveBackPaginationStatus() failed")
}.buffer(Channel.UNLIMITED)
internal fun Timeline.timelineDiffFlow(onInitialList: suspend (List<TimelineItem>) -> Unit): Flow<List<TimelineDiff>> =
callbackFlow {

View file

@ -79,6 +79,7 @@ import org.matrix.rustcomponents.sdk.messageEventContentFromMarkdown
import org.matrix.rustcomponents.sdk.use
import timber.log.Timber
import uniffi.matrix_sdk_ui.EventItemOrigin
import uniffi.matrix_sdk_ui.LiveBackPaginationStatus
import java.io.File
import java.util.Date
import java.util.concurrent.atomic.AtomicBoolean
@ -154,6 +155,21 @@ class RustTimeline(
launch {
fetchMembers()
}
if (isLive) {
// When timeline is live, we need to listen to the back pagination status as
// sdk can automatically paginate backwards.
inner.liveBackPaginationStatus()
.onEach { backPaginationStatus ->
updatePaginationStatus(Timeline.PaginationDirection.BACKWARDS) {
when (backPaginationStatus) {
is LiveBackPaginationStatus.Idle -> it.copy(isPaginating = false, hasMoreToLoad = !backPaginationStatus.hitStartOfTimeline)
is LiveBackPaginationStatus.Paginating -> it.copy(isPaginating = true, hasMoreToLoad = true)
}
}
}
.launchIn(this)
}
}
}
@ -333,13 +349,28 @@ class RustTimeline(
}
}
override suspend fun replyMessage(eventId: EventId, body: String, htmlBody: String?, mentions: List<Mention>): Result<Unit> = withContext(dispatcher) {
override suspend fun replyMessage(
eventId: EventId,
body: String,
htmlBody: String?,
mentions: List<Mention>,
fromNotification: Boolean,
): Result<Unit> = withContext(dispatcher) {
runCatching {
val inReplyTo = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(eventId.value)
inReplyTo.use { eventTimelineItem ->
inner.sendReply(messageEventContentFromParts(body, htmlBody).withMentions(mentions.map()), eventTimelineItem)
val msg = messageEventContentFromParts(body, htmlBody).withMentions(mentions.map())
if (fromNotification) {
// When replying from a notification, do not interfere with `specialModeEventTimelineItem`
val inReplyTo = inner.getEventTimelineItemByEventId(eventId.value)
inReplyTo.use { eventTimelineItem ->
inner.sendReply(msg, eventTimelineItem)
}
} else {
val inReplyTo = specialModeEventTimelineItem ?: inner.getEventTimelineItemByEventId(eventId.value)
inReplyTo.use { eventTimelineItem ->
inner.sendReply(msg, eventTimelineItem)
}
specialModeEventTimelineItem = null
}
specialModeEventTimelineItem = null
}
}

View file

@ -17,6 +17,7 @@
package io.element.android.libraries.matrix.impl.timeline.item.event
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.timeline.item.event.CallNotifyContent
import io.element.android.libraries.matrix.api.timeline.item.event.EventContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseMessageLikeContent
import io.element.android.libraries.matrix.api.timeline.item.event.FailedToParseStateContent
@ -103,7 +104,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
StickerContent(
body = kind.body,
info = kind.info.map(),
url = kind.url,
source = kind.source.map(),
)
}
is TimelineItemContentKind.Poll -> {
@ -125,6 +126,7 @@ class TimelineEventContentMapper(private val eventMessageMapper: EventMessageMap
)
}
is TimelineItemContentKind.CallInvite -> LegacyCallInviteContent
is TimelineItemContentKind.CallNotify -> CallNotifyContent
else -> UnknownContent
}
}

View file

@ -0,0 +1,43 @@
/*
* Copyright (c) 2024 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.test.notification
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID
fun aNotificationData(
senderDisplayName: String?,
senderIsNameAmbiguous: Boolean,
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
senderAvatarUrl = null,
senderDisplayName = senderDisplayName,
senderIsNameAmbiguous = senderIsNameAmbiguous,
roomAvatarUrl = null,
roomDisplayName = null,
isDirect = false,
isEncrypted = false,
isNoisy = false,
timestamp = 0L,
content = NotificationContent.MessageLike.RoomEncrypted,
hasMention = false,
)
}

View file

@ -86,6 +86,7 @@ class FakeMatrixRoom(
override val liveTimeline: Timeline = FakeTimeline(),
private var roomPermalinkResult: () -> Result<String> = { Result.success("room link") },
private var eventPermalinkResult: (EventId) -> Result<String> = { Result.success("event link") },
var sendCallNotificationIfNeededResult: () -> Result<Unit> = { Result.success(Unit) },
canRedactOwn: Boolean = false,
canRedactOther: Boolean = false,
) : MatrixRoom {
@ -520,6 +521,10 @@ class FakeMatrixRoom(
theme: String?,
): Result<String> = generateWidgetWebViewUrlResult
override suspend fun sendCallNotificationIfNeeded(): Result<Unit> {
return sendCallNotificationIfNeededResult()
}
override fun getWidgetDriver(widgetSettings: MatrixWidgetSettings): Result<MatrixWidgetDriver> = getWidgetDriverResult
fun givenRoomMembersState(state: MatrixRoomMembersState) {

View file

@ -114,7 +114,8 @@ class FakeTimeline(
body: String,
htmlBody: String?,
mentions: List<Mention>,
) -> Result<Unit> = { _, _, _, _ ->
fromNotification: Boolean,
) -> Result<Unit> = { _, _, _, _, _ ->
Result.success(Unit)
}
@ -123,11 +124,13 @@ class FakeTimeline(
body: String,
htmlBody: String?,
mentions: List<Mention>,
fromNotification: Boolean,
): Result<Unit> = replyMessageLambda(
eventId,
body,
htmlBody,
mentions
mentions,
fromNotification,
)
var sendImageLambda: (

View file

@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="dialog_permission_camera">"Selleks, et rakendus saaks kaamerat kasutada, palun luba see süsteemi seadistuses."</string>
<string name="dialog_permission_generic">"Palun luba süsteemi seadistustest vajalikud õigused."</string>
<string name="dialog_permission_microphone">"Selleks, et rakendus saaks mikrofoni kasutada, palun luba see süsteemi seadistuses."</string>
<string name="dialog_permission_notification">"Selleks, et rakendus saaks kuvada teavitusi, palun anna vajalikud õiguse süsteemi seadistustes."</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="troubleshoot_notifications_test_check_permission_title">"Täpsusta õigusi"</string>
</resources>

View file

@ -21,12 +21,13 @@ import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaTwoParamsRecorder
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import kotlinx.coroutines.CoroutineScope
class FakeSessionPreferencesStoreFactory(
var getLambda: LambdaTwoParamsRecorder<SessionId, CoroutineScope, SessionPreferencesStore> = lambdaRecorder { _, _ -> throw NotImplementedError() },
var removeLambda: LambdaOneParamRecorder<SessionId, Unit> = lambdaRecorder { _ -> },
val getLambda: LambdaTwoParamsRecorder<SessionId, CoroutineScope, SessionPreferencesStore> = lambdaRecorder { _, _ -> lambdaError() },
val removeLambda: LambdaOneParamRecorder<SessionId, Unit> = lambdaRecorder { _ -> lambdaError() },
) : SessionPreferencesStoreFactory {
override fun get(sessionId: SessionId, sessionCoroutineScope: CoroutineScope): SessionPreferencesStore {
return getLambda(sessionId, sessionCoroutineScope)

View file

@ -24,6 +24,7 @@ android {
dependencies {
implementation(libs.androidx.corektx)
implementation(libs.coroutines.core)
implementation(libs.coil.compose)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.pushproviders.api)
}

View file

@ -0,0 +1,38 @@
/*
* Copyright (c) 2024 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.api.notifications
import android.graphics.Bitmap
import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader
interface NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap?
/**
* Get icon of a user.
* Before Android P, this does nothing because the icon won't be used
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat?
}

View file

@ -16,10 +16,15 @@
package io.element.android.libraries.push.api.notifications
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
interface NotificationDrawerManager {
fun clearAllMessagesEvents(sessionId: SessionId)
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId)
fun clearEvent(sessionId: SessionId, eventId: EventId)
fun clearMembershipNotificationForSession(sessionId: SessionId)
fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId)
}

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 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.
@ -14,13 +14,12 @@
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications
package io.element.android.libraries.push.api.notifications
import io.element.android.libraries.matrix.api.core.SessionId
import javax.inject.Inject
import kotlin.math.abs
class NotificationIdProvider @Inject constructor() {
object NotificationIdProvider {
fun getSummaryNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + SUMMARY_NOTIFICATION_ID
}
@ -41,16 +40,30 @@ class NotificationIdProvider @Inject constructor() {
return getOffset(sessionId) + FALLBACK_NOTIFICATION_ID
}
fun getCallNotificationId(sessionId: SessionId): Int {
return getOffset(sessionId) + ROOM_CALL_NOTIFICATION_ID
}
fun getForegroundServiceNotificationId(type: ForegroundServiceType): Int {
return type.id * 10 + FOREGROUND_SERVICE_NOTIFICATION_ID
}
private fun getOffset(sessionId: SessionId): Int {
// Compute a int from a string with a low risk of collision.
return abs(sessionId.value.hashCode() % 100_000) * 10
}
companion object {
private const val FALLBACK_NOTIFICATION_ID = -1
private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
}
private const val FALLBACK_NOTIFICATION_ID = -1
private const val SUMMARY_NOTIFICATION_ID = 0
private const val ROOM_MESSAGES_NOTIFICATION_ID = 1
private const val ROOM_EVENT_NOTIFICATION_ID = 2
private const val ROOM_INVITATION_NOTIFICATION_ID = 3
private const val ROOM_CALL_NOTIFICATION_ID = 3
private const val FOREGROUND_SERVICE_NOTIFICATION_ID = 4
}
enum class ForegroundServiceType(val id: Int) {
INCOMING_CALL(1),
ONGOING_CALL(2),
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 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.api.notifications
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
/**
* Handles missed calls by creating a new notification.
*/
interface OnMissedCallNotificationHandler {
/**
* Adds a missed call notification.
*/
suspend fun addMissedCallNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
)
}

View file

@ -55,6 +55,7 @@ dependencies {
implementation(projects.libraries.preferences.api)
implementation(projects.libraries.uiStrings)
implementation(projects.libraries.troubleshoot.api)
implementation(projects.features.call.api)
api(projects.libraries.pushproviders.api)
api(projects.libraries.pushstore.api)
api(projects.libraries.push.api)
@ -71,10 +72,12 @@ dependencies {
testImplementation(libs.coil.test)
testImplementation(libs.coroutines.test)
testImplementation(projects.libraries.matrix.test)
testImplementation(projects.libraries.preferences.test)
testImplementation(projects.libraries.push.test)
testImplementation(projects.libraries.pushproviders.test)
testImplementation(projects.libraries.pushstore.test)
testImplementation(projects.tests.testutils)
testImplementation(projects.features.call.test)
testImplementation(projects.services.appnavstate.test)
testImplementation(projects.services.toolbox.impl)
testImplementation(projects.services.toolbox.test)

View file

@ -22,6 +22,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import javax.inject.Inject
interface ActiveNotificationsProvider {
@ -37,7 +38,6 @@ interface ActiveNotificationsProvider {
@ContributesBinding(AppScope::class)
class DefaultActiveNotificationsProvider @Inject constructor(
private val notificationManager: NotificationManagerCompat,
private val notificationIdProvider: NotificationIdProvider,
) : ActiveNotificationsProvider {
override fun getAllNotifications(): List<StatusBarNotification> {
return notificationManager.activeNotifications
@ -48,22 +48,22 @@ class DefaultActiveNotificationsProvider @Inject constructor(
}
override fun getMembershipNotificationForSession(sessionId: SessionId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId }
}
override fun getMessageNotificationsForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomMessagesNotificationId(sessionId)
val notificationId = NotificationIdProvider.getRoomMessagesNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId): List<StatusBarNotification> {
val notificationId = notificationIdProvider.getRoomInvitationNotificationId(sessionId)
val notificationId = NotificationIdProvider.getRoomInvitationNotificationId(sessionId)
return getNotificationsForSession(sessionId).filter { it.id == notificationId && it.tag == roomId.value }
}
override fun getSummaryNotification(sessionId: SessionId): StatusBarNotification? {
val summaryId = notificationIdProvider.getSummaryNotificationId(sessionId)
val summaryId = NotificationIdProvider.getSummaryNotificationId(sessionId)
return getNotificationsForSession(sessionId).find { it.id == summaryId }
}

View file

@ -0,0 +1,90 @@
/*
* Copyright (c) 2024 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
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
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.NotifiableRingingCallEvent
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
/**
* Helper to resolve a valid [NotifiableEvent] from a [NotificationData].
*/
interface CallNotificationEventResolver {
/**
* Resolve a call notification event from a notification data depending on whether it should be a ringing one or not.
* @param sessionId the current session id
* @param notificationData the notification data
* @param forceNotify `true` to force the notification to be non-ringing, `false` to use the default behaviour. Default is `false`.
* @return a [NotifiableEvent] if the notification data is a call notification, null otherwise
*/
fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean = false): NotifiableEvent?
}
@ContributesBinding(AppScope::class)
class DefaultCallNotificationEventResolver @Inject constructor(
private val stringProvider: StringProvider,
) : CallNotificationEventResolver {
override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): NotifiableEvent? {
val content = notificationData.content as? NotificationContent.MessageLike.CallNotify ?: return null
return notificationData.run {
if (NotifiableRingingCallEvent.shouldRing(content.type, timestamp) && !forceNotify) {
NotifiableRingingCallEvent(
sessionId = sessionId,
roomId = roomId,
eventId = eventId,
roomName = roomDisplayName,
editedEventId = null,
canBeReplaced = true,
timestamp = this.timestamp,
isRedacted = false,
isUpdated = false,
description = stringProvider.getString(R.string.notification_incoming_call),
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
roomAvatarUrl = roomAvatarUrl,
callNotifyType = content.type,
senderId = content.senderId,
senderAvatarUrl = senderAvatarUrl,
)
} else {
// Create a simple message notification event
buildNotifiableMessageEvent(
sessionId = sessionId,
senderId = content.senderId,
roomId = roomId,
eventId = eventId,
noisy = true,
timestamp = this.timestamp,
senderDisambiguatedDisplayName = getDisambiguatedDisplayName(content.senderId),
body = "☎️ ${stringProvider.getString(R.string.notification_incoming_call)}",
roomName = roomDisplayName,
roomIsDirect = isDirect,
roomAvatarPath = roomAvatarUrl,
senderAvatarPath = senderAvatarUrl,
type = EventType.CALL_NOTIFY,
)
}
}
}
}

View file

@ -36,6 +36,7 @@ import io.element.android.libraries.matrix.api.permalink.PermalinkParser
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.LocationMessageType
@ -77,6 +78,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
private val notificationMediaRepoFactory: NotificationMediaRepo.Factory,
@ApplicationContext private val context: Context,
private val permalinkParser: PermalinkParser,
private val callNotificationEventResolver: CallNotificationEventResolver,
) : NotifiableEventResolver {
override suspend fun resolveEvent(sessionId: SessionId, roomId: RoomId, eventId: EventId): NotifiableEvent? {
// Restore session
@ -150,8 +152,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
}
NotificationContent.MessageLike.CallAnswer,
NotificationContent.MessageLike.CallCandidates,
NotificationContent.MessageLike.CallHangup,
is NotificationContent.MessageLike.CallNotify -> { // TODO CallNotify will be handled separately in the future
NotificationContent.MessageLike.CallHangup -> {
Timber.tag(loggerTag.value).d("Ignoring notification for call ${content.javaClass.simpleName}")
null
}
@ -172,6 +173,9 @@ class DefaultNotifiableEventResolver @Inject constructor(
senderAvatarPath = senderAvatarUrl,
)
}
is NotificationContent.MessageLike.CallNotify -> {
callNotificationEventResolver.resolveEvent(userId, this)
}
NotificationContent.MessageLike.KeyVerificationAccept,
NotificationContent.MessageLike.KeyVerificationCancel,
NotificationContent.MessageLike.KeyVerificationDone,
@ -310,7 +314,7 @@ class DefaultNotifiableEventResolver @Inject constructor(
}
@Suppress("LongParameterList")
private fun buildNotifiableMessageEvent(
internal fun buildNotifiableMessageEvent(
sessionId: SessionId,
senderId: UserId,
roomId: RoomId,
@ -334,7 +338,8 @@ private fun buildNotifiableMessageEvent(
outGoingMessage: Boolean = false,
outGoingMessageFailed: Boolean = false,
isRedacted: Boolean = false,
isUpdated: Boolean = false
isUpdated: Boolean = false,
type: String = EventType.MESSAGE,
) = NotifiableMessageEvent(
sessionId = sessionId,
senderId = senderId,
@ -356,5 +361,6 @@ private fun buildNotifiableMessageEvent(
outGoingMessage = outGoingMessage,
outGoingMessageFailed = outGoingMessageFailed,
isRedacted = isRedacted,
isUpdated = isUpdated
isUpdated = isUpdated,
type = type,
)

View file

@ -24,23 +24,27 @@ import androidx.core.graphics.drawable.toBitmap
import coil.ImageLoader
import coil.request.ImageRequest
import coil.transform.CircleCropTransformation
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
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 io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import timber.log.Timber
import javax.inject.Inject
class NotificationBitmapLoader @Inject constructor(
@ContributesBinding(AppScope::class)
class DefaultNotificationBitmapLoader @Inject constructor(
@ApplicationContext private val context: Context,
private val sdkIntProvider: BuildVersionSdkIntProvider,
) {
) : NotificationBitmapLoader {
/**
* Get icon of a room.
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
if (path == null) {
return null
}
@ -67,7 +71,7 @@ class NotificationBitmapLoader @Inject constructor(
* @param path mxc url
* @param imageLoader Coil image loader
*/
suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
if (path == null || sdkIntProvider.get() < Build.VERSION_CODES.P) {
return null
}

View file

@ -31,6 +31,7 @@ import io.element.android.libraries.matrix.api.core.ThreadId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.matrix.ui.media.ImageLoaderHolder
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.shouldIgnoreEventInRoom
import io.element.android.services.appnavstate.api.AppNavigationStateService
@ -53,7 +54,6 @@ private val loggerTag = LoggerTag("DefaultNotificationDrawerManager", LoggerTag.
class DefaultNotificationDrawerManager @Inject constructor(
private val notificationManager: NotificationManagerCompat,
private val notificationRenderer: NotificationRenderer,
private val notificationIdProvider: NotificationIdProvider,
private val appNavigationStateService: AppNavigationStateService,
coroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
@ -123,8 +123,8 @@ class DefaultNotificationDrawerManager @Inject constructor(
/**
* Clear all known message events for a [sessionId].
*/
fun clearAllMessagesEvents(sessionId: SessionId) {
notificationManager.cancel(null, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
override fun clearAllMessagesEvents(sessionId: SessionId) {
notificationManager.cancel(null, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
@ -141,8 +141,8 @@ class DefaultNotificationDrawerManager @Inject constructor(
* Used to ignore events related to that room (no need to display notification) and clean any existing notification on this room.
* Can also be called when a notification for this room is dismissed by the user.
*/
fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
notificationManager.cancel(roomId.value, notificationIdProvider.getRoomMessagesNotificationId(sessionId))
override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
notificationManager.cancel(roomId.value, NotificationIdProvider.getRoomMessagesNotificationId(sessionId))
clearSummaryNotificationIfNeeded(sessionId)
}
@ -164,8 +164,8 @@ class DefaultNotificationDrawerManager @Inject constructor(
/**
* Clear the notifications for a single event.
*/
fun clearEvent(sessionId: SessionId, eventId: EventId) {
val id = notificationIdProvider.getRoomEventNotificationId(sessionId)
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
val id = NotificationIdProvider.getRoomEventNotificationId(sessionId)
notificationManager.cancel(eventId.value, id)
clearSummaryNotificationIfNeeded(sessionId)
}

View file

@ -0,0 +1,54 @@
/*
* Copyright (c) 2024 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
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.MatrixClientProvider
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.push.api.notifications.OnMissedCallNotificationHandler
import javax.inject.Inject
@ContributesBinding(AppScope::class)
class DefaultOnMissedCallNotificationHandler @Inject constructor(
private val matrixClientProvider: MatrixClientProvider,
private val defaultNotificationDrawerManager: DefaultNotificationDrawerManager,
private val callNotificationEventResolver: CallNotificationEventResolver,
) : OnMissedCallNotificationHandler {
override suspend fun addMissedCallNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
) {
// Resolve the event and add a notification for it, at this point it should no longer be a ringing one
val notificationData = matrixClientProvider.getOrRestore(sessionId).getOrNull()
?.notificationService()
?.getNotification(sessionId, roomId, eventId)
?.getOrNull()
?: return
val notifiableEvent = callNotificationEventResolver.resolveEvent(
sessionId = sessionId,
notificationData = notificationData,
// Make sure the notifiable event is not a ringing one
forceNotify = true,
)
notifiableEvent?.let { defaultNotificationDrawerManager.onNotifiableEventReceived(it) }
}
}

View file

@ -20,16 +20,13 @@ import io.element.android.libraries.core.meta.BuildMeta
import javax.inject.Inject
/**
* Util class for creating notifications.
* Note: Cannot inject ColorProvider in the constructor, because it requires an Activity
* Util class for creating notifications action Ids, using the application id.
*/
data class NotificationActionIds @Inject constructor(
private val buildMeta: BuildMeta,
) {
val join = "${buildMeta.applicationId}.NotificationActions.JOIN_ACTION"
val reject = "${buildMeta.applicationId}.NotificationActions.REJECT_ACTION"
val quickLaunch = "${buildMeta.applicationId}.NotificationActions.QUICK_LAUNCH_ACTION"
val markRoomRead = "${buildMeta.applicationId}.NotificationActions.MARK_ROOM_READ_ACTION"
val smartReply = "${buildMeta.applicationId}.NotificationActions.SMART_REPLY_ACTION"
val dismissSummary = "${buildMeta.applicationId}.NotificationActions.DISMISS_SUMMARY_ACTION"
@ -37,5 +34,4 @@ data class NotificationActionIds @Inject constructor(
val dismissInvite = "${buildMeta.applicationId}.NotificationActions.DISMISS_INVITE_NOTIF_ACTION"
val dismissEvent = "${buildMeta.applicationId}.NotificationActions.DISMISS_EVENT_NOTIF_ACTION"
val diagnostic = "${buildMeta.applicationId}.NotificationActions.DIAGNOSTIC"
val push = "${buildMeta.applicationId}.PUSH"
}

View file

@ -19,211 +19,21 @@ package io.element.android.libraries.push.impl.notifications
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.architecture.bindings
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.MatrixClientProvider
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.timeline.ReceiptType
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import javax.inject.Inject
private val loggerTag = LoggerTag("NotificationBroadcastReceiver", LoggerTag.NotificationLoggerTag)
/**
* Receives actions broadcast by notification (on click, on dismiss, inline replies, etc.).
*/
class NotificationBroadcastReceiver : BroadcastReceiver() {
@Inject lateinit var appCoroutineScope: CoroutineScope
@Inject lateinit var matrixClientProvider: MatrixClientProvider
@Inject lateinit var sessionPreferencesStore: SessionPreferencesStoreFactory
@Inject lateinit var defaultNotificationDrawerManager: DefaultNotificationDrawerManager
@Inject lateinit var actionIds: NotificationActionIds
@Inject lateinit var notificationBroadcastReceiverHandler: NotificationBroadcastReceiverHandler
override fun onReceive(context: Context?, intent: Intent?) {
if (intent == null || context == null) return
val sessionId = intent.extras?.getString(KEY_SESSION_ID)?.let(::SessionId) ?: return
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
val eventId = intent.getStringExtra(KEY_EVENT_ID)?.let(::EventId)
context.bindings<NotificationBroadcastReceiverBindings>().inject(this)
Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}")
when (intent.action) {
actionIds.smartReply ->
handleSmartReply(intent, context)
actionIds.dismissRoom -> if (roomId != null) {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
}
actionIds.dismissSummary ->
defaultNotificationDrawerManager.clearAllMessagesEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
}
actionIds.dismissEvent -> if (eventId != null) {
defaultNotificationDrawerManager.clearEvent(sessionId, eventId)
}
actionIds.markRoomRead -> if (roomId != null) {
defaultNotificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId)
}
actionIds.join -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleJoinRoom(sessionId, roomId)
}
actionIds.reject -> if (roomId != null) {
defaultNotificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleRejectRoom(sessionId, roomId)
}
}
notificationBroadcastReceiverHandler.onReceive(intent)
}
private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.joinRoom(roomId)
}
private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.getRoom(roomId)?.leave()
}
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
val receiptType = if (isSendPublicReadReceiptsEnabled) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
}
@Suppress("UNUSED_PARAMETER")
private fun handleSmartReply(intent: Intent, context: Context) {
/*
val message = getReplyMessage(intent)
val sessionId = intent.getStringExtra(KEY_SESSION_ID)?.let(::SessionId)
val roomId = intent.getStringExtra(KEY_ROOM_ID)?.let(::RoomId)
val threadId = intent.getStringExtra(KEY_THREAD_ID)?.let(::ThreadId)
if (message.isNullOrBlank() || roomId == null) {
// ignore this event
// Can this happen? should we update notification?
return
}
activeSessionHolder.getActiveSession().let { session ->
session.getRoom(roomId)?.let { room ->
sendMatrixEvent(message, threadId, session, room, context)
}
}
*/
}
/*
private fun sendMatrixEvent(message: String, threadId: String?, session: Session, room: Room, context: Context?) {
if (threadId != null) {
room.relationService().replyInThread(
rootThreadEventId = threadId,
replyInThreadText = message,
)
} else {
room.sendService().sendTextMessage(message)
}
// Create a new event to be displayed in the notification drawer, right now
val notifiableMessageEvent = NotifiableMessageEvent(
// Generate a Fake event id
eventId = UUID.randomUUID().toString(),
editedEventId = null,
noisy = false,
timestamp = clock.epochMillis(),
senderName = session.roomService().getRoomMember(session.myUserId, room.roomId)?.displayName
?: context?.getString(R.string.notification_sender_me),
senderId = session.myUserId,
body = message,
imageUriString = null,
roomId = room.roomId,
threadId = threadId,
roomName = room.roomSummary()?.displayName ?: room.roomId,
roomIsDirect = room.roomSummary()?.isDirect == true,
outGoingMessage = true,
canBeReplaced = false
)
notificationDrawerManager.updateEvents { it.onNotifiableEventReceived(notifiableMessageEvent) }
/*
// TODO Error cannot be managed the same way than in Riot
val event = Event(mxMessage, session.credentials.userId, roomId)
room.storeOutgoingEvent(event)
room.sendEvent(event, object : MatrixCallback<Void?> {
override fun onSuccess(info: Void?) {
Timber.v("Send message : onSuccess ")
}
override fun onNetworkError(e: Exception) {
Timber.e(e, "Send message : onNetworkError")
onSmartReplyFailed(e.localizedMessage)
}
override fun onMatrixError(e: MatrixError) {
Timber.v("Send message : onMatrixError " + e.message)
if (e is MXCryptoError) {
Toast.makeText(context, e.detailedErrorDescription, Toast.LENGTH_SHORT).show()
onSmartReplyFailed(e.detailedErrorDescription)
} else {
Toast.makeText(context, e.localizedMessage, Toast.LENGTH_SHORT).show()
onSmartReplyFailed(e.localizedMessage)
}
}
override fun onUnexpectedError(e: Exception) {
Timber.e(e, "Send message : onUnexpectedError " + e.message)
onSmartReplyFailed(e.message)
}
fun onSmartReplyFailed(reason: String?) {
val notifiableMessageEvent = NotifiableMessageEvent(
event.eventId,
false,
clock.epochMillis(),
session.myUser?.displayname
?: context?.getString(R.string.notification_sender_me),
session.myUserId,
message,
roomId,
room.getRoomDisplayName(context),
room.isDirect)
notifiableMessageEvent.outGoingMessage = true
notifiableMessageEvent.outGoingMessageFailed = true
VectorApp.getInstance().notificationDrawerManager.onNotifiableEventReceived(notifiableMessageEvent)
VectorApp.getInstance().notificationDrawerManager.refreshNotificationDrawer(null)
}
})
*/
}
private fun getReplyMessage(intent: Intent?): String? {
if (intent != null) {
val remoteInput = RemoteInput.getResultsFromIntent(intent)
if (remoteInput != null) {
return remoteInput.getCharSequence(KEY_TEXT_REPLY)?.toString()
}
}
return null
}
*/
companion object {
const val KEY_SESSION_ID = "sessionID"
const val KEY_ROOM_ID = "roomID"

View file

@ -0,0 +1,191 @@
/*
* Copyright (c) 2024 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
import android.content.Intent
import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.MatrixClientProvider
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.core.asEventId
import io.element.android.libraries.matrix.api.room.MatrixRoom
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import timber.log.Timber
import java.util.UUID
import javax.inject.Inject
private val loggerTag = LoggerTag("NotificationBroadcastReceiverHandler", LoggerTag.NotificationLoggerTag)
class NotificationBroadcastReceiverHandler @Inject constructor(
private val appCoroutineScope: CoroutineScope,
private val matrixClientProvider: MatrixClientProvider,
private val sessionPreferencesStore: SessionPreferencesStoreFactory,
private val notificationDrawerManager: NotificationDrawerManager,
private val actionIds: NotificationActionIds,
private val systemClock: SystemClock,
private val onNotifiableEventReceived: OnNotifiableEventReceived,
private val stringProvider: StringProvider,
private val replyMessageExtractor: ReplyMessageExtractor,
) {
fun onReceive(intent: Intent) {
val sessionId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_SESSION_ID)?.let(::SessionId) ?: return
val roomId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_ROOM_ID)?.let(::RoomId)
val threadId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_THREAD_ID)?.let(::ThreadId)
val eventId = intent.getStringExtra(NotificationBroadcastReceiver.KEY_EVENT_ID)?.let(::EventId)
Timber.tag(loggerTag.value).d("onReceive: ${intent.action} ${intent.data} for: ${roomId?.value}/${eventId?.value}")
when (intent.action) {
actionIds.smartReply -> if (roomId != null) {
handleSmartReply(sessionId, roomId, threadId, intent)
}
actionIds.dismissRoom -> if (roomId != null) {
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
}
actionIds.dismissSummary ->
notificationDrawerManager.clearAllMessagesEvents(sessionId)
actionIds.dismissInvite -> if (roomId != null) {
notificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
}
actionIds.dismissEvent -> if (eventId != null) {
notificationDrawerManager.clearEvent(sessionId, eventId)
}
actionIds.markRoomRead -> if (roomId != null) {
notificationDrawerManager.clearMessagesForRoom(sessionId, roomId)
handleMarkAsRead(sessionId, roomId)
}
actionIds.join -> if (roomId != null) {
notificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleJoinRoom(sessionId, roomId)
}
actionIds.reject -> if (roomId != null) {
notificationDrawerManager.clearMembershipNotificationForRoom(sessionId, roomId)
handleRejectRoom(sessionId, roomId)
}
}
}
private fun handleJoinRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.joinRoom(roomId)
}
private fun handleRejectRoom(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.getRoom(roomId)?.leave()
}
private fun handleMarkAsRead(sessionId: SessionId, roomId: RoomId) = appCoroutineScope.launch {
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
val isSendPublicReadReceiptsEnabled = sessionPreferencesStore.get(sessionId, this).isSendPublicReadReceiptsEnabled().first()
val receiptType = if (isSendPublicReadReceiptsEnabled) {
ReceiptType.READ
} else {
ReceiptType.READ_PRIVATE
}
client.getRoom(roomId)?.markAsRead(receiptType = receiptType)
}
private fun handleSmartReply(
sessionId: SessionId,
roomId: RoomId,
threadId: ThreadId?,
intent: Intent,
) = appCoroutineScope.launch {
val message = replyMessageExtractor.getReplyMessage(intent)
if (message.isNullOrBlank()) {
// ignore this event
// Can this happen? should we update notification?
return@launch
}
val client = matrixClientProvider.getOrRestore(sessionId).getOrNull() ?: return@launch
client.getRoom(roomId)?.let { room ->
sendMatrixEvent(
sessionId = sessionId,
roomId = roomId,
threadId = threadId,
room = room,
message = message,
)
}
}
private suspend fun sendMatrixEvent(
sessionId: SessionId,
roomId: RoomId,
threadId: ThreadId?,
room: MatrixRoom,
message: String,
) {
// Create a new event to be displayed in the notification drawer, right now
val notifiableMessageEvent = NotifiableMessageEvent(
sessionId = sessionId,
roomId = roomId,
// Generate a Fake event id
eventId = EventId("\$" + UUID.randomUUID().toString()),
editedEventId = null,
canBeReplaced = false,
senderId = sessionId,
noisy = false,
timestamp = systemClock.epochMillis(),
senderDisambiguatedDisplayName = room.getUpdatedMember(sessionId).getOrNull()
?.disambiguatedDisplayName
?: stringProvider.getString(R.string.notification_sender_me),
body = message,
imageUriString = null,
threadId = threadId,
roomName = room.displayName,
roomIsDirect = room.isDirect,
outGoingMessage = true,
)
onNotifiableEventReceived.onNotifiableEventReceived(notifiableMessageEvent)
if (threadId != null) {
room.liveTimeline.replyMessage(
eventId = threadId.asEventId(),
body = message,
htmlBody = null,
mentions = emptyList(),
fromNotification = true,
)
} else {
room.liveTimeline.sendMessage(
body = message,
htmlBody = null,
mentions = emptyList()
)
}.onFailure {
Timber.e(it, "Failed to send smart reply message")
onNotifiableEventReceived.onNotifiableEventReceived(
notifiableMessageEvent.copy(
outGoingMessageFailed = true
)
)
}
}
}

View file

@ -49,6 +49,8 @@ interface NotificationDataFactory {
@JvmName("toNotificationSimpleEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(simpleEvents: List<SimpleNotifiableEvent>): List<OneShotNotification>
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification>
fun createSummaryNotification(
@ -130,6 +132,8 @@ class DefaultNotificationDataFactory @Inject constructor(
}
}
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallback.map { event ->
OneShotNotification(

View file

@ -19,10 +19,12 @@ package io.element.android.libraries.push.impl.notifications
import coil.ImageLoader
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
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
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import timber.log.Timber
import javax.inject.Inject
@ -30,7 +32,6 @@ import javax.inject.Inject
private val loggerTag = LoggerTag("NotificationRenderer", LoggerTag.NotificationLoggerTag)
class NotificationRenderer @Inject constructor(
private val notificationIdProvider: NotificationIdProvider,
private val notificationDisplayer: NotificationDisplayer,
private val notificationDataFactory: NotificationDataFactory,
) {
@ -59,14 +60,14 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Removing summary notification")
notificationDisplayer.cancelNotificationMessage(
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId)
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId)
)
}
roomNotifications.forEach { notificationData ->
notificationDisplayer.showNotificationMessage(
tag = notificationData.roomId.value,
id = notificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
id = NotificationIdProvider.getRoomMessagesNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
@ -76,7 +77,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Updating invitation notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
id = NotificationIdProvider.getRoomInvitationNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
@ -87,7 +88,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Updating simple notification ${notificationData.key}")
notificationDisplayer.showNotificationMessage(
tag = notificationData.key,
id = notificationIdProvider.getRoomEventNotificationId(currentUser.userId),
id = NotificationIdProvider.getRoomEventNotificationId(currentUser.userId),
notification = notificationData.notification
)
}
@ -98,7 +99,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Showing fallback notification")
notificationDisplayer.showNotificationMessage(
tag = "FALLBACK",
id = notificationIdProvider.getFallbackNotificationId(currentUser.userId),
id = NotificationIdProvider.getFallbackNotificationId(currentUser.userId),
notification = fallbackNotifications.first().notification
)
}
@ -108,7 +109,7 @@ class NotificationRenderer @Inject constructor(
Timber.tag(loggerTag.value).d("Updating summary notification")
notificationDisplayer.showNotificationMessage(
tag = null,
id = notificationIdProvider.getSummaryNotificationId(currentUser.userId),
id = NotificationIdProvider.getSummaryNotificationId(currentUser.userId),
notification = summaryNotification.notification
)
}
@ -127,6 +128,8 @@ private fun List<NotifiableEvent>.groupByType(): GroupedNotificationEvents {
is NotifiableMessageEvent -> roomEvents.add(event.castedToEventType())
is SimpleNotifiableEvent -> simpleEvents.add(event.castedToEventType())
is FallbackNotifiableEvent -> fallbackEvents.add(event.castedToEventType())
// Nothing should be done for ringing call events as they're not handled here
is NotifiableRingingCallEvent -> {}
}
}
return GroupedNotificationEvents(roomEvents, simpleEvents, invitationEvents, fallbackEvents)

View file

@ -0,0 +1,36 @@
/*
* Copyright (c) 2024 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
import android.content.Intent
import androidx.core.app.RemoteInput
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import javax.inject.Inject
interface ReplyMessageExtractor {
fun getReplyMessage(intent: Intent): String?
}
@ContributesBinding(AppScope::class)
class AndroidReplyMessageExtractor @Inject constructor() : ReplyMessageExtractor {
override fun getReplyMessage(intent: Intent): String? {
return RemoteInput.getResultsFromIntent(intent)
?.getCharSequence(NotificationBroadcastReceiver.KEY_TEXT_REPLY)
?.toString()
}
}

View file

@ -23,6 +23,7 @@ import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.factories.NotificationCreator
import io.element.android.libraries.push.impl.notifications.factories.isSmartReplyError

View file

@ -19,10 +19,15 @@ package io.element.android.libraries.push.impl.notifications.channels
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.Context
import android.media.AudioAttributes
import android.media.AudioManager
import android.media.RingtoneManager
import android.os.Build
import androidx.annotation.ChecksSdkIntAtLeast
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.core.content.ContextCompat
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.di.SingleIn
@ -30,15 +35,51 @@ import io.element.android.libraries.push.impl.R
import io.element.android.services.toolbox.api.strings.StringProvider
import javax.inject.Inject
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
internal const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
internal const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
// Legacy channel
private const val CALL_NOTIFICATION_CHANNEL_ID_V2 = "CALL_NOTIFICATION_CHANNEL_ID_V2"
internal const val CALL_NOTIFICATION_CHANNEL_ID_V3 = "CALL_NOTIFICATION_CHANNEL_ID_V3"
internal const val RINGING_CALL_NOTIFICATION_CHANNEL_ID = "RINGING_CALL_NOTIFICATION_CHANNEL_ID"
/**
* on devices >= android O, we need to define a channel for each notifications.
*/
interface NotificationChannels {
/**
* Get the channel for incoming call.
* @param ring true if the device should ring when receiving the call.
*/
fun getChannelForIncomingCall(ring: Boolean): String
/**
* Get the channel for messages.
* @param noisy true if the notification should have sound and vibration.
*/
fun getChannelIdForMessage(noisy: Boolean): String
/**
* Get the channel for test notifications.
*/
fun getChannelIdForTest(): String
}
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
@SingleIn(AppScope::class)
class NotificationChannels @Inject constructor(
@ContributesBinding(AppScope::class)
class DefaultNotificationChannels @Inject constructor(
@ApplicationContext private val context: Context,
private val notificationManager: NotificationManagerCompat,
private val stringProvider: StringProvider,
) {
) : NotificationChannels {
init {
createNotificationChannels()
}
@ -75,6 +116,9 @@ class NotificationChannels @Inject constructor(
}
}
// Migration - Create new call channel
notificationManager.deleteNotificationChannel(CALL_NOTIFICATION_CHANNEL_ID_V2)
/**
* Default notification importance: shows everywhere, makes noise, but does not visually
* intrude.
@ -123,46 +167,52 @@ class NotificationChannels @Inject constructor(
}
)
// Register a channel for incoming and in progress call notifications with no ringing
notificationManager.createNotificationChannel(
NotificationChannel(
CALL_NOTIFICATION_CHANNEL_ID,
CALL_NOTIFICATION_CHANNEL_ID_V3,
stringProvider.getString(R.string.notification_channel_call).ifEmpty { "Call" },
NotificationManager.IMPORTANCE_HIGH
)
.apply {
description = stringProvider.getString(R.string.notification_channel_call)
setSound(null, null)
enableVibration(true)
enableLights(true)
lightColor = accentColor
}
)
// Register a channel for incoming call notifications which will ring the device when received
val ringtoneUri = RingtoneManager.getActualDefaultRingtoneUri(context, RingtoneManager.TYPE_RINGTONE)
notificationManager.createNotificationChannel(
NotificationChannelCompat.Builder(
RINGING_CALL_NOTIFICATION_CHANNEL_ID,
NotificationManagerCompat.IMPORTANCE_MAX,
)
.setName(stringProvider.getString(R.string.notification_channel_ringing_calls).ifEmpty { "Ringing calls" })
.setVibrationEnabled(true)
.setSound(
ringtoneUri,
AudioAttributes.Builder()
.setContentType(AudioAttributes.CONTENT_TYPE_SONIFICATION)
.setLegacyStreamType(AudioManager.STREAM_RING)
.setUsage(AudioAttributes.USAGE_NOTIFICATION_RINGTONE)
.build()
)
.setDescription(stringProvider.getString(R.string.notification_channel_ringing_calls))
.setLightsEnabled(true)
.setLightColor(accentColor)
.build()
)
}
private fun getChannel(channelId: String): NotificationChannel? {
return notificationManager.getNotificationChannel(channelId)
override fun getChannelForIncomingCall(ring: Boolean): String {
return if (ring) RINGING_CALL_NOTIFICATION_CHANNEL_ID else CALL_NOTIFICATION_CHANNEL_ID_V3
}
fun getChannelForIncomingCall(fromBg: Boolean): NotificationChannel? {
val notificationChannel = if (fromBg) CALL_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
return getChannel(notificationChannel)
}
fun getChannelIdForMessage(noisy: Boolean): String {
override fun getChannelIdForMessage(noisy: Boolean): String {
return if (noisy) NOISY_NOTIFICATION_CHANNEL_ID else SILENT_NOTIFICATION_CHANNEL_ID
}
fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
companion object {
/* ==========================================================================================
* IDs for channels
* ========================================================================================== */
private const val LISTENING_FOR_EVENTS_NOTIFICATION_CHANNEL_ID = "LISTEN_FOR_EVENTS_NOTIFICATION_CHANNEL_ID"
private const val SILENT_NOTIFICATION_CHANNEL_ID = "DEFAULT_SILENT_NOTIFICATION_CHANNEL_ID_V2"
private const val NOISY_NOTIFICATION_CHANNEL_ID = "DEFAULT_NOISY_NOTIFICATION_CHANNEL_ID"
private const val CALL_NOTIFICATION_CHANNEL_ID = "CALL_NOTIFICATION_CHANNEL_ID_V2"
@ChecksSdkIntAtLeast(api = Build.VERSION_CODES.O)
private fun supportNotificationChannels() = Build.VERSION.SDK_INT >= Build.VERSION_CODES.O
}
override fun getChannelIdForTest(): String = NOISY_NOTIFICATION_CHANNEL_ID
}

View file

@ -35,9 +35,10 @@ import io.element.android.libraries.di.AppScope
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.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.R
import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader
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
@ -129,12 +130,16 @@ class DefaultNotificationCreator @Inject constructor(
val smallIcon = CommonDrawables.ic_notification_small
val channelId = notificationChannels.getChannelIdForMessage(roomInfo.shouldBing)
val containsMissedCall = events.any { it.type == EventType.CALL_NOTIFY }
val channelId = if (containsMissedCall) {
notificationChannels.getChannelForIncomingCall(false)
} else {
notificationChannels.getChannelIdForMessage(noisy = roomInfo.shouldBing)
}
val builder = if (existingNotification != null) {
NotificationCompat.Builder(context, existingNotification)
} else {
NotificationCompat.Builder(context, channelId)
.setOnlyAlertOnce(roomInfo.isUpdated)
// A category allows groups of notifications to be ranked and filtered per user or system settings.
// For example, alarm notifications should display before promo notifications, or message from known contact
// that can be displayed in not disturb mode if white listed (the later will need compat28.x)
@ -210,6 +215,11 @@ class DefaultNotificationCreator @Inject constructor(
setLargeIcon(largeIcon)
}
setDeleteIntent(pendingIntentFactory.createDismissRoomPendingIntent(roomInfo.sessionId, roomInfo.roomId))
// If any of the events are of call notify type it means a missed call, set the category to the right value
if (events.any { it.type == EventType.CALL_NOTIFY }) {
setCategory(NotificationCompat.CATEGORY_MISSED_CALL)
}
}
.setTicker(tickerText)
.build()
@ -343,7 +353,6 @@ class DefaultNotificationCreator @Inject constructor(
.setWhen(lastMessageTimestamp)
.setCategory(NotificationCompat.CATEGORY_MESSAGE)
.setSmallIcon(smallIcon)
// set content text to support devices running API level < 24
.setGroup(currentUser.userId.value)
// set this notification as the summary for the group
.setGroupSummary(true)

View file

@ -51,9 +51,9 @@ data class NotifiableMessageEvent(
val outGoingMessage: Boolean = false,
val outGoingMessageFailed: Boolean = false,
override val isRedacted: Boolean = false,
override val isUpdated: Boolean = false
) : NotifiableEvent {
override val isUpdated: Boolean = false,
val type: String = EventType.MESSAGE
) : NotifiableEvent {
override val description: String = body ?: ""
// Example of value:
@ -69,9 +69,16 @@ fun NotifiableEvent.shouldIgnoreEventInRoom(appNavigationState: AppNavigationSta
val currentSessionId = appNavigationState.navigationState.currentSessionId() ?: return false
return when (val currentRoomId = appNavigationState.navigationState.currentRoomId()) {
null -> false
else -> appNavigationState.isInForeground &&
sessionId == currentSessionId &&
roomId == currentRoomId &&
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
else -> {
// Never ignore ringing call notifications
if (this is NotifiableRingingCallEvent) {
false
} else {
appNavigationState.isInForeground &&
sessionId == currentSessionId &&
roomId == currentRoomId &&
(this as? NotifiableMessageEvent)?.threadId == appNavigationState.navigationState.currentThreadId()
}
}
}
}

View file

@ -0,0 +1,52 @@
/*
* Copyright (c) 2024 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.model
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.CallNotifyType
import java.time.Instant
import kotlin.time.Duration.Companion.seconds
data class NotifiableRingingCallEvent(
override val sessionId: SessionId,
override val roomId: RoomId,
override val eventId: EventId,
override val editedEventId: EventId?,
override val description: String?,
override val canBeReplaced: Boolean,
override val isRedacted: Boolean,
override val isUpdated: Boolean,
val roomName: String?,
val senderId: UserId,
val senderDisambiguatedDisplayName: String?,
val senderAvatarUrl: String?,
val roomAvatarUrl: String? = null,
val callNotifyType: CallNotifyType,
val timestamp: Long,
) : NotifiableEvent {
companion object {
fun shouldRing(callNotifyType: CallNotifyType, timestamp: Long): Boolean {
val timeout = 10.seconds.inWholeMilliseconds
val elapsed = Instant.now().toEpochMilli() - timestamp
// Only ring if the type is RING and the elapsed time is less than the timeout
return callNotifyType == CallNotifyType.RING && elapsed < timeout
}
}
}

View file

@ -17,11 +17,15 @@
package io.element.android.libraries.push.impl.push
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.features.call.api.CallType
import io.element.android.features.call.api.ElementCallEntryPoint
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
import io.element.android.libraries.push.impl.notifications.NotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.test.DefaultTestPush
import io.element.android.libraries.push.impl.troubleshoot.DiagnosticPushHandler
import io.element.android.libraries.pushproviders.api.PushData
@ -44,6 +48,8 @@ class DefaultPushHandler @Inject constructor(
private val buildMeta: BuildMeta,
private val matrixAuthenticationService: MatrixAuthenticationService,
private val diagnosticPushHandler: DiagnosticPushHandler,
private val elementCallEntryPoint: ElementCallEntryPoint,
private val notificationChannels: NotificationChannels,
) : PushHandler {
/**
* Called when message is received.
@ -91,19 +97,33 @@ class DefaultPushHandler @Inject constructor(
return
}
val userPushStore = userPushStoreFactory.getOrCreate(userId)
if (userPushStore.getNotificationEnabledForDevice().first()) {
val areNotificationsEnabled = userPushStore.getNotificationEnabledForDevice().first()
if (areNotificationsEnabled) {
val notifiableEvent = notifiableEventResolver.resolveEvent(userId, pushData.roomId, pushData.eventId)
if (notifiableEvent == null) {
Timber.w("Unable to get a notification data")
return
when (notifiableEvent) {
null -> Timber.tag(loggerTag.value).w("Unable to get a notification data")
is NotifiableRingingCallEvent -> handleRingingCallEvent(notifiableEvent)
else -> onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
}
onNotifiableEventReceived.onNotifiableEventReceived(notifiableEvent)
} else {
// TODO We need to check if this is an incoming call
Timber.tag(loggerTag.value).i("Notification are disabled for this device, ignore push.")
}
} catch (e: Exception) {
Timber.tag(loggerTag.value).e(e, "## handleInternal() failed")
}
}
private fun handleRingingCallEvent(notifiableEvent: NotifiableRingingCallEvent) {
Timber.i("## handleInternal() : Incoming call.")
elementCallEntryPoint.handleIncomingCall(
callType = CallType.RoomCall(notifiableEvent.sessionId, notifiableEvent.roomId),
eventId = notifiableEvent.eventId,
senderId = notifiableEvent.senderId,
roomName = notifiableEvent.roomName,
senderName = notifiableEvent.senderDisambiguatedDisplayName,
avatarUrl = notifiableEvent.roomAvatarUrl,
timestamp = notifiableEvent.timestamp,
notificationChannelId = notificationChannels.getChannelForIncomingCall(ring = true),
)
}
}

View file

@ -3,6 +3,7 @@
<string name="notification_channel_call">"Пазваніць"</string>
<string name="notification_channel_listening_for_events">"Праслухоўванне падзей"</string>
<string name="notification_channel_noisy">"Шумныя апавяшчэнні"</string>
<string name="notification_channel_ringing_calls">"Званкі"</string>
<string name="notification_channel_silent">"Ціхія апавяшчэнні"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d паведамленне"</item>
@ -15,6 +16,7 @@
<item quantity="many">"%d апавяшчэнняў"</item>
</plurals>
<string name="notification_fallback_content">"Апавяшчэнне"</string>
<string name="notification_incoming_call">"Уваходны званок"</string>
<string name="notification_inline_reply_failed">"** Не атрымалася даслаць - калі ласка, адкрыйце пакой"</string>
<string name="notification_invitation_action_join">"Далучыцца"</string>
<string name="notification_invitation_action_reject">"Адхіліць"</string>

View file

@ -3,6 +3,7 @@
<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_ringing_calls">"Vyzvánění hovorů"</string>
<string name="notification_channel_silent">"Tichá oznámení"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d zpráva"</item>
@ -15,6 +16,7 @@
<item quantity="other">"%d oznámení"</item>
</plurals>
<string name="notification_fallback_content">"Oznámení"</string>
<string name="notification_incoming_call">"Příchozí hovor"</string>
<string name="notification_inline_reply_failed">"** Nepodařilo se odeslat - otevřete prosím místnost"</string>
<string name="notification_invitation_action_join">"Vstoupit"</string>
<string name="notification_invitation_action_reject">"Odmítnout"</string>

View file

@ -0,0 +1,49 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="notification_channel_call">"Kõne"</string>
<string name="notification_channel_listening_for_events">"Kontrollime, kas on uusi sündmusi"</string>
<string name="notification_channel_noisy">"Lärmakad teavitused"</string>
<string name="notification_channel_silent">"Vaiksed teavitused"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d sõnum"</item>
<item quantity="other">"%1$s: %2$d sõnumit"</item>
</plurals>
<plurals name="notification_compat_summary_title">
<item quantity="one">"%d teavitus"</item>
<item quantity="other">"%d teavitust"</item>
</plurals>
<string name="notification_fallback_content">"Teavitus"</string>
<string name="notification_inline_reply_failed">"** Saatmine ei õnnestunud - palun ava jututoa täisvaade"</string>
<string name="notification_invitation_action_join">"Liitu"</string>
<string name="notification_invitation_action_reject">"Lükka tagasi"</string>
<plurals name="notification_invitations">
<item quantity="one">"%d kutse"</item>
<item quantity="other">"%d kutset"</item>
</plurals>
<string name="notification_invite_body">"Kutse osalema vestluses"</string>
<string name="notification_mentioned_you_body">"Mainis sind: %1$s"</string>
<string name="notification_new_messages">"Uued sõnumid"</string>
<plurals name="notification_new_messages_for_room">
<item quantity="one">"%d uus sõnum"</item>
<item quantity="other">"%d uut sõnumit"</item>
</plurals>
<string name="notification_room_action_quick_reply">"Kiirvastus"</string>
<string name="notification_sender_me">"Mina"</string>
<string name="notification_test_push_notification_content">"See ongi teavitus! Klõpsi mind!"</string>
<string name="notification_ticker_text_dm">"%1$s: %2$s"</string>
<plurals name="notification_unread_notified_messages">
<item quantity="one">"%d lugemata sõnum, millele on teavitus saadetud"</item>
<item quantity="other">"%d lugemata sõnumit, millele on teavitus saadetud"</item>
</plurals>
<string name="notification_unread_notified_messages_and_invitation">"%1$s ja %2$s"</string>
<string name="notification_unread_notified_messages_in_room">"%1$s jututoas %2$s"</string>
<string name="notification_unread_notified_messages_in_room_and_invitation">"%1$s jututoas %2$s ning kutse jututuppa %3$s"</string>
<plurals name="notification_unread_notified_messages_in_room_rooms">
<item quantity="one">"%d jututuba"</item>
<item quantity="other">"%d jututuba"</item>
</plurals>
<string name="push_distributor_background_sync_android">"Sünkroniseerimine taustal"</string>
<string name="push_distributor_firebase_android">"Google\'i teenused"</string>
<string name="push_no_valid_google_play_services_apk_android">"Google Play Services taustateenust ei leidu. Teavitused ei pruugi toimida korrektselt."</string>
<string name="troubleshoot_notifications_test_push_loop_back_failure_2">"Viga: %1$s."</string>
</resources>

View file

@ -3,6 +3,7 @@
<string name="notification_channel_call">"Chamada"</string>
<string name="notification_channel_listening_for_events">"À escuta de eventos"</string>
<string name="notification_channel_noisy">"Notificações barulhentas"</string>
<string name="notification_channel_ringing_calls">"Chamadas a tocar"</string>
<string name="notification_channel_silent">"Notificações silenciosas"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d mensagem"</item>
@ -13,6 +14,7 @@
<item quantity="other">"%d notificações"</item>
</plurals>
<string name="notification_fallback_content">"Notificação"</string>
<string name="notification_incoming_call">"Chamada recebida"</string>
<string name="notification_inline_reply_failed">"** Falha no envio - por favor abre a sala"</string>
<string name="notification_invitation_action_join">"Entrar"</string>
<string name="notification_invitation_action_reject">"Rejeitar"</string>

View file

@ -3,6 +3,7 @@
<string name="notification_channel_call">"Позвонить"</string>
<string name="notification_channel_listening_for_events">"Прослушивание событий"</string>
<string name="notification_channel_noisy">"Шумные уведомления"</string>
<string name="notification_channel_ringing_calls">"Звонки"</string>
<string name="notification_channel_silent">"Бесшумные уведомления"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d сообщение"</item>
@ -15,6 +16,7 @@
<item quantity="many">"%d уведомлений"</item>
</plurals>
<string name="notification_fallback_content">"Уведомление"</string>
<string name="notification_incoming_call">"Входящий вызов"</string>
<string name="notification_inline_reply_failed">"** Не удалось отправить - пожалуйста, откройте комнату"</string>
<string name="notification_invitation_action_join">"Присоединиться"</string>
<string name="notification_invitation_action_reject">"Отклонить"</string>

View file

@ -3,6 +3,7 @@
<string name="notification_channel_call">"Zavolať"</string>
<string name="notification_channel_listening_for_events">"Počúvanie udalostí"</string>
<string name="notification_channel_noisy">"Hlasité oznámenia"</string>
<string name="notification_channel_ringing_calls">"Vyzváňanie hovorov"</string>
<string name="notification_channel_silent">"Tiché oznámenia"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d správa"</item>
@ -15,6 +16,7 @@
<item quantity="other">"%d oznámení"</item>
</plurals>
<string name="notification_fallback_content">"Oznámenie"</string>
<string name="notification_incoming_call">"Prichádzajúci hovor"</string>
<string name="notification_inline_reply_failed">"** Nepodarilo sa odoslať - prosím otvorte miestnosť"</string>
<string name="notification_invitation_action_join">"Pripojiť sa"</string>
<string name="notification_invitation_action_reject">"Zamietnuť"</string>

View file

@ -3,6 +3,7 @@
<string name="notification_channel_call">"Call"</string>
<string name="notification_channel_listening_for_events">"Listening for events"</string>
<string name="notification_channel_noisy">"Noisy notifications"</string>
<string name="notification_channel_ringing_calls">"Ringing calls"</string>
<string name="notification_channel_silent">"Silent notifications"</string>
<plurals name="notification_compat_summary_line_for_room">
<item quantity="one">"%1$s: %2$d message"</item>
@ -13,6 +14,7 @@
<item quantity="other">"%d notifications"</item>
</plurals>
<string name="notification_fallback_content">"Notification"</string>
<string name="notification_incoming_call">"Incoming call"</string>
<string name="notification_inline_reply_failed">"** Failed to send - please open room"</string>
<string name="notification_invitation_action_join">"Join"</string>
<string name="notification_invitation_action_reject">"Reject"</string>

View file

@ -25,6 +25,7 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_ROOM_ID_2
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.mockk.every
import io.mockk.mockk
import org.junit.Test
@ -33,6 +34,8 @@ import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultActiveNotificationsProviderTest {
private val notificationIdProvider = NotificationIdProvider
@Test
fun `getAllNotifications with no active notifications returns empty list`() {
val activeNotificationsProvider = createActiveNotificationsProvider(activeNotifications = emptyList())
@ -43,7 +46,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getAllNotifications with active notifications returns all`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
@ -57,7 +59,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getNotificationsForSession returns only notifications for that session id`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
@ -71,7 +72,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getMembershipNotificationsForSession returns only membership notifications for that session id`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value,),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID_2), groupId = A_SESSION_ID_2.value),
@ -89,7 +89,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getMessageNotificationsForRoom returns only message notifications for those session and room ids`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
@ -117,7 +116,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getMembershipNotificationsForRoom returns only membership notifications for those session and room ids`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(
id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID),
@ -145,7 +143,6 @@ class DefaultActiveNotificationsProviderTest {
@Test
fun `getSummaryNotification returns only the summary notification for that session id if it exists`() {
val notificationIdProvider = NotificationIdProvider()
val activeNotifications = listOf(
aStatusBarNotification(id = notificationIdProvider.getRoomMessagesNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
aStatusBarNotification(id = notificationIdProvider.getSummaryNotificationId(A_SESSION_ID), groupId = A_SESSION_ID.value),
@ -172,7 +169,6 @@ class DefaultActiveNotificationsProviderTest {
}
return DefaultActiveNotificationsProvider(
notificationManager = notificationManager,
notificationIdProvider = NotificationIdProvider(),
)
}
}

View file

@ -18,12 +18,15 @@ package io.element.android.libraries.push.impl.notifications
import android.content.Context
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.notification.NotificationContent
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.matrix.api.room.RoomMembershipState
import io.element.android.libraries.matrix.api.timeline.item.event.AudioMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EmoteMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.api.timeline.item.event.FileMessageType
import io.element.android.libraries.matrix.api.timeline.item.event.FormattedBody
import io.element.android.libraries.matrix.api.timeline.item.event.ImageMessageType
@ -48,7 +51,9 @@ import io.element.android.libraries.push.impl.notifications.fake.FakeNotificatio
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.impl.systemclock.DefaultSystemClock
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import kotlinx.coroutines.test.runTest
@ -58,6 +63,7 @@ import org.robolectric.RobolectricTestRunner
import org.robolectric.RuntimeEnvironment
import org.robolectric.annotation.Config
@Suppress("LargeClass")
@RunWith(RobolectricTestRunner::class)
class DefaultNotifiableEventResolverTest {
@Test
@ -479,6 +485,109 @@ class DefaultNotifiableEventResolverTest {
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - ringing`() = runTest {
val timestamp = DefaultSystemClock().epochMillis()
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
createNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.RING
),
timestamp = timestamp,
)
)
)
val expectedResult = NotifiableRingingCallEvent(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
senderId = A_USER_ID_2,
roomName = null,
editedEventId = null,
description = "Incoming call",
timestamp = timestamp,
canBeReplaced = true,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = "Bob",
senderAvatarUrl = null,
callNotifyType = CallNotifyType.RING,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - ring but timed out displays the same as notify`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
createNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.RING
),
timestamp = 0L,
)
)
)
val expectedResult = NotifiableMessageEvent(
sessionId = A_SESSION_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
noisy = true,
timestamp = 0L,
senderDisambiguatedDisplayName = "Bob",
senderId = UserId("@bob:server.org"),
body = "\uFE0F Incoming call",
roomId = A_ROOM_ID,
threadId = null,
roomName = null,
roomIsDirect = false,
canBeReplaced = false,
isRedacted = false,
imageUriString = null,
type = EventType.CALL_NOTIFY,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve CallNotify - notify`() = runTest {
val sut = createDefaultNotifiableEventResolver(
notificationResult = Result.success(
createNotificationData(
content = NotificationContent.MessageLike.CallNotify(
A_USER_ID_2,
CallNotifyType.NOTIFY
)
)
)
)
val expectedResult = NotifiableMessageEvent(
sessionId = A_SESSION_ID,
eventId = AN_EVENT_ID,
editedEventId = null,
noisy = true,
timestamp = A_TIMESTAMP,
senderDisambiguatedDisplayName = "Bob",
senderId = UserId("@bob:server.org"),
body = "\uFE0F Incoming call",
roomId = A_ROOM_ID,
threadId = null,
roomName = null,
roomIsDirect = false,
canBeReplaced = false,
isRedacted = false,
imageUriString = null,
type = EventType.CALL_NOTIFY,
)
val result = sut.resolveEvent(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID)
assertThat(result).isEqualTo(expectedResult)
}
@Test
fun `resolve null cases`() {
testNull(NotificationContent.MessageLike.CallAnswer)
@ -551,6 +660,9 @@ class DefaultNotifiableEventResolverTest {
notificationMediaRepoFactory = notificationMediaRepoFactory,
context = context,
permalinkParser = FakePermalinkParser(),
callNotificationEventResolver = DefaultCallNotificationEventResolver(
stringProvider = AndroidStringProvider(context.resources)
),
)
}
@ -558,6 +670,7 @@ class DefaultNotifiableEventResolverTest {
content: NotificationContent,
isDirect: Boolean = false,
hasMention: Boolean = false,
timestamp: Long = A_TIMESTAMP,
): NotificationData {
return NotificationData(
eventId = AN_EVENT_ID,
@ -570,7 +683,7 @@ class DefaultNotifiableEventResolverTest {
isDirect = isDirect,
isEncrypted = false,
isNoisy = false,
timestamp = A_TIMESTAMP,
timestamp = timestamp,
content = content,
hasMention = hasMention,
)

View file

@ -28,12 +28,13 @@ import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoaderHolder
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.services.appnavstate.api.AppNavigationState
import io.element.android.services.appnavstate.api.AppNavigationStateService
import io.element.android.services.appnavstate.api.NavigationState
@ -164,7 +165,7 @@ class DefaultNotificationDrawerManagerTest {
val notificationManager = mockk<NotificationManagerCompat> {
every { cancel(any(), any()) } returns Unit
}
val summaryId = NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)
val summaryId = NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)
val activeNotificationsProvider = FakeActiveNotificationsProvider(
mutableListOf(
mockk {
@ -198,7 +199,6 @@ class DefaultNotificationDrawerManagerTest {
return DefaultNotificationDrawerManager(
notificationManager = notificationManager,
notificationRenderer = NotificationRenderer(
notificationIdProvider = NotificationIdProvider(),
notificationDisplayer = DefaultNotificationDisplayer(context, NotificationManagerCompat.from(context)),
notificationDataFactory = DefaultNotificationDataFactory(
notificationCreator = FakeNotificationCreator(),
@ -208,7 +208,6 @@ class DefaultNotificationDrawerManagerTest {
stringProvider = FakeStringProvider(),
),
),
notificationIdProvider = NotificationIdProvider(),
appNavigationStateService = appNavigationStateService,
coroutineScope = this,
matrixClientProvider = matrixClientProvider,

View file

@ -0,0 +1,92 @@
/*
* Copyright (c) 2024 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
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
import io.element.android.libraries.matrix.test.A_USER_NAME
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.notification.FakeNotificationService
import io.element.android.libraries.matrix.test.notification.aNotificationData
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDataFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeCallNotificationEventResolver
import io.element.android.libraries.push.test.notifications.FakeImageLoaderHolder
import io.element.android.services.appnavstate.test.FakeAppNavigationStateService
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.mockk.mockk
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.cancel
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class DefaultOnMissedCallNotificationHandlerTest {
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `addMissedCallNotification - should add missed call notification`() = runTest {
val childScope = CoroutineScope(coroutineContext + SupervisorJob())
val dataFactory = FakeNotificationDataFactory(
messageEventToNotificationsResult = lambdaRecorder { _, _, _ -> emptyList() }
)
// Create a fake matrix client provider that returns a fake matrix client with a fake notification service that returns a valid notification data
val matrixClientProvider = FakeMatrixClientProvider(getClient = {
val notificationService = FakeNotificationService().apply {
givenGetNotificationResult(Result.success(aNotificationData(senderDisplayName = A_USER_NAME, senderIsNameAmbiguous = false)))
}
Result.success(FakeMatrixClient(notificationService = notificationService))
})
val defaultOnMissedCallNotificationHandler = DefaultOnMissedCallNotificationHandler(
matrixClientProvider = matrixClientProvider,
defaultNotificationDrawerManager = DefaultNotificationDrawerManager(
notificationManager = mockk(relaxed = true),
notificationRenderer = NotificationRenderer(
notificationDisplayer = FakeNotificationDisplayer(),
notificationDataFactory = dataFactory,
),
appNavigationStateService = FakeAppNavigationStateService(),
coroutineScope = childScope,
matrixClientProvider = FakeMatrixClientProvider(),
imageLoaderHolder = FakeImageLoaderHolder(),
activeNotificationsProvider = FakeActiveNotificationsProvider(),
),
callNotificationEventResolver = FakeCallNotificationEventResolver(resolveEventLambda = { _, _, _ -> aNotifiableMessageEvent() }),
)
defaultOnMissedCallNotificationHandler.addMissedCallNotification(
sessionId = A_SESSION_ID,
roomId = A_ROOM_ID,
eventId = AN_EVENT_ID,
)
runCurrent()
dataFactory.messageEventToNotificationsResult.assertions().isCalledOnce()
// Cancel the coroutine scope so the test can finish
childScope.cancel()
}
}

View file

@ -25,8 +25,8 @@ import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.matrix.ui.media.MediaRequestData
import io.element.android.libraries.push.impl.notifications.factories.createNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.api.sdk.BuildVersionSdkIntProvider
import io.element.android.services.toolbox.impl.strings.AndroidStringProvider
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
@ -212,7 +212,7 @@ fun createRoomGroupMessageCreator(
sdkIntProvider: BuildVersionSdkIntProvider = FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.O),
): RoomGroupMessageCreator {
val context = RuntimeEnvironment.getApplication() as Context
val bitmapLoader = NotificationBitmapLoader(
val bitmapLoader = DefaultNotificationBitmapLoader(
context = RuntimeEnvironment.getApplication(),
sdkIntProvider = sdkIntProvider,
)

View file

@ -0,0 +1,27 @@
/*
* Copyright (c) 2024 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
import android.content.Intent
class FakeReplyMessageExtractor(
private val result: String? = null,
) : ReplyMessageExtractor {
override fun getReplyMessage(intent: Intent): String? {
return result
}
}

View file

@ -0,0 +1,474 @@
/*
* Copyright (c) 2024 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
import android.content.Intent
import com.google.common.truth.Truth.assertThat
import io.element.android.features.preferences.api.store.SessionPreferencesStore
import io.element.android.features.preferences.api.store.SessionPreferencesStoreFactory
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.ThreadId
import io.element.android.libraries.matrix.api.core.asEventId
import io.element.android.libraries.matrix.api.room.Mention
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.matrix.test.AN_EVENT_ID
import io.element.android.libraries.matrix.test.A_MESSAGE
import io.element.android.libraries.matrix.test.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.FakeMatrixClientProvider
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.test.room.FakeMatrixRoom
import io.element.android.libraries.matrix.test.timeline.FakeTimeline
import io.element.android.libraries.preferences.test.FakeSessionPreferencesStoreFactory
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.notifications.NotificationDrawerManager
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.push.FakeOnNotifiableEventReceived
import io.element.android.libraries.push.impl.push.OnNotifiableEventReceived
import io.element.android.libraries.push.test.notifications.FakeNotificationDrawerManager
import io.element.android.services.toolbox.api.strings.StringProvider
import io.element.android.services.toolbox.api.systemclock.SystemClock
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.FakeSystemClock
import io.element.android.tests.testutils.lambda.lambdaError
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runCurrent
import kotlinx.coroutines.test.runTest
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@ExperimentalCoroutinesApi
@RunWith(RobolectricTestRunner::class)
class NotificationBroadcastReceiverHandlerTest {
private val actionIds = NotificationActionIds(aBuildMeta())
@Test
fun `When no sessionId, nothing happen`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.join,
sessionId = null
),
)
}
@Test
fun `Test dismiss room without a roomId, nothing happen`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.dismissRoom,
),
)
}
@Test
fun `Test dismiss room`() = runTest {
val clearMessagesForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMessagesForRoomLambda = clearMessagesForRoomLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
notificationDrawerManager = notificationDrawerManager
)
sut.onReceive(
createIntent(
action = actionIds.dismissRoom,
roomId = A_ROOM_ID,
),
)
runCurrent()
clearMessagesForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
}
@Test
fun `Test dismiss summary`() = runTest {
val clearAllMessagesEventsLambda = lambdaRecorder<SessionId, Unit> { _ -> }
val notificationDrawerManager = FakeNotificationDrawerManager(
clearAllMessagesEventsLambda = clearAllMessagesEventsLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
notificationDrawerManager = notificationDrawerManager
)
sut.onReceive(
createIntent(
action = actionIds.dismissSummary,
),
)
clearAllMessagesEventsLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID))
}
@Test
fun `Test dismiss Invite without room`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.dismissInvite,
),
)
}
@Test
fun `Test dismiss Invite`() = runTest {
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
notificationDrawerManager = notificationDrawerManager
)
sut.onReceive(
createIntent(
action = actionIds.dismissInvite,
roomId = A_ROOM_ID,
),
)
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
}
@Test
fun `Test dismiss Event without event`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.dismissEvent,
),
)
}
@Test
fun `Test dismiss Event`() = runTest {
val clearEventLambda = lambdaRecorder<SessionId, EventId, Unit> { _, _ -> }
val notificationDrawerManager = FakeNotificationDrawerManager(
clearEventLambda = clearEventLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
notificationDrawerManager = notificationDrawerManager
)
sut.onReceive(
createIntent(
action = actionIds.dismissEvent,
eventId = AN_EVENT_ID,
),
)
clearEventLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(AN_EVENT_ID))
}
@Test
fun `Test mark room as read without room`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.markRoomRead,
),
)
}
@Test
fun `Test mark room as read, send public RR`() {
testMarkRoomAsRead(
isSendPublicReadReceiptsEnabled = true,
expectedReceiptType = ReceiptType.READ
)
}
@Test
fun `Test mark room as read, send private RR`() {
testMarkRoomAsRead(
isSendPublicReadReceiptsEnabled = false,
expectedReceiptType = ReceiptType.READ_PRIVATE
)
}
private fun testMarkRoomAsRead(
isSendPublicReadReceiptsEnabled: Boolean,
expectedReceiptType: ReceiptType,
) = runTest {
val getLambda = lambdaRecorder<SessionId, CoroutineScope, SessionPreferencesStore> { _, _ ->
InMemorySessionPreferencesStore(
isSendPublicReadReceiptsEnabled = isSendPublicReadReceiptsEnabled
)
}
val sessionPreferencesStore = FakeSessionPreferencesStoreFactory(
getLambda = getLambda
)
val clearMessagesForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val matrixRoom = FakeMatrixRoom()
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMessagesForRoomLambda = clearMessagesForRoomLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
sessionPreferencesStore = sessionPreferencesStore,
matrixRoom = matrixRoom,
notificationDrawerManager = notificationDrawerManager
)
sut.onReceive(
createIntent(
action = actionIds.markRoomRead,
roomId = A_ROOM_ID,
),
)
runCurrent()
clearMessagesForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
assertThat(matrixRoom.markAsReadCalls).isEqualTo(listOf(expectedReceiptType))
}
@Test
fun `Test join room without room`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.join,
),
)
}
@Test
fun `Test join room`() = runTest {
val joinRoom = lambdaRecorder<RoomId, Result<Unit>> { _ -> Result.success(Unit) }
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
joinRoom = joinRoom,
notificationDrawerManager = notificationDrawerManager,
)
sut.onReceive(
createIntent(
action = actionIds.join,
roomId = A_ROOM_ID,
),
)
runCurrent()
joinRoom.assertions()
.isCalledOnce()
.with(value(A_ROOM_ID))
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
}
@Test
fun `Test reject room without room`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.reject,
),
)
}
@Test
fun `Test reject room`() = runTest {
val leaveRoom = lambdaRecorder<Result<Unit>> { Result.success(Unit) }
val matrixRoom = FakeMatrixRoom().apply {
leaveRoomLambda = leaveRoom
}
val clearMembershipNotificationForRoomLambda = lambdaRecorder<SessionId, RoomId, Unit> { _, _ -> }
val notificationDrawerManager = FakeNotificationDrawerManager(
clearMembershipNotificationForRoomLambda = clearMembershipNotificationForRoomLambda,
)
val sut = createNotificationBroadcastReceiverHandler(
matrixRoom = matrixRoom,
notificationDrawerManager = notificationDrawerManager
)
sut.onReceive(
createIntent(
action = actionIds.reject,
roomId = A_ROOM_ID,
),
)
runCurrent()
clearMembershipNotificationForRoomLambda.assertions()
.isCalledOnce()
.with(value(A_SESSION_ID), value(A_ROOM_ID))
leaveRoom.assertions()
.isCalledOnce()
.with()
}
@Test
fun `Test send reply without room`() = runTest {
val sut = createNotificationBroadcastReceiverHandler()
sut.onReceive(
createIntent(
action = actionIds.smartReply,
),
)
}
@Test
fun `Test send reply`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<Mention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val replyMessage = lambdaRecorder<EventId, String, String?, List<Mention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
}
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline
)
val onNotifiableEventReceivedResult = lambdaRecorder<NotifiableEvent, Unit> { _ -> }
val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult)
val sut = createNotificationBroadcastReceiverHandler(
matrixRoom = matrixRoom,
onNotifiableEventReceived = onNotifiableEventReceived,
replyMessageExtractor = FakeReplyMessageExtractor(A_MESSAGE)
)
sut.onReceive(
createIntent(
action = actionIds.smartReply,
roomId = A_ROOM_ID,
),
)
runCurrent()
sendMessage.assertions()
.isCalledOnce()
.with(value(A_MESSAGE), value(null), value(emptyList<Mention>()))
onNotifiableEventReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
.isNeverCalled()
}
@Test
fun `Test send reply blank message`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<Mention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
}
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline
)
val sut = createNotificationBroadcastReceiverHandler(
matrixRoom = matrixRoom,
replyMessageExtractor = FakeReplyMessageExtractor(" "),
)
sut.onReceive(
createIntent(
action = actionIds.smartReply,
roomId = A_ROOM_ID,
),
)
runCurrent()
sendMessage.assertions()
.isNeverCalled()
}
@Test
fun `Test send reply to thread`() = runTest {
val sendMessage = lambdaRecorder<String, String?, List<Mention>, Result<Unit>> { _, _, _ -> Result.success(Unit) }
val replyMessage = lambdaRecorder<EventId, String, String?, List<Mention>, Boolean, Result<Unit>> { _, _, _, _, _ -> Result.success(Unit) }
val liveTimeline = FakeTimeline().apply {
sendMessageLambda = sendMessage
replyMessageLambda = replyMessage
}
val matrixRoom = FakeMatrixRoom(
liveTimeline = liveTimeline
)
val onNotifiableEventReceivedResult = lambdaRecorder<NotifiableEvent, Unit> { _ -> }
val onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceivedResult = onNotifiableEventReceivedResult)
val sut = createNotificationBroadcastReceiverHandler(
matrixRoom = matrixRoom,
onNotifiableEventReceived = onNotifiableEventReceived,
replyMessageExtractor = FakeReplyMessageExtractor(A_MESSAGE)
)
sut.onReceive(
createIntent(
action = actionIds.smartReply,
roomId = A_ROOM_ID,
threadId = A_THREAD_ID,
),
)
runCurrent()
sendMessage.assertions()
.isNeverCalled()
onNotifiableEventReceivedResult.assertions()
.isCalledOnce()
replyMessage.assertions()
.isCalledOnce()
.with(value(A_THREAD_ID.asEventId()), value(A_MESSAGE), value(null), value(emptyList<Mention>()), value(true))
}
private fun createIntent(
action: String,
sessionId: SessionId? = A_SESSION_ID,
roomId: RoomId? = null,
eventId: EventId? = null,
threadId: ThreadId? = null,
) = Intent(action).apply {
putExtra(NotificationBroadcastReceiver.KEY_SESSION_ID, sessionId?.value)
putExtra(NotificationBroadcastReceiver.KEY_ROOM_ID, roomId?.value)
putExtra(NotificationBroadcastReceiver.KEY_THREAD_ID, threadId?.value)
putExtra(NotificationBroadcastReceiver.KEY_EVENT_ID, eventId?.value)
}
private fun TestScope.createNotificationBroadcastReceiverHandler(
matrixRoom: FakeMatrixRoom? = FakeMatrixRoom(),
joinRoom: (RoomId) -> Result<Unit> = { lambdaError() },
matrixClient: MatrixClient? = FakeMatrixClient().apply {
givenGetRoomResult(A_ROOM_ID, matrixRoom)
joinRoomLambda = joinRoom
},
sessionPreferencesStore: SessionPreferencesStoreFactory = FakeSessionPreferencesStoreFactory(),
notificationDrawerManager: NotificationDrawerManager = FakeNotificationDrawerManager(),
systemClock: SystemClock = FakeSystemClock(),
onNotifiableEventReceived: OnNotifiableEventReceived = FakeOnNotifiableEventReceived(),
stringProvider: StringProvider = FakeStringProvider(),
replyMessageExtractor: ReplyMessageExtractor = FakeReplyMessageExtractor(),
): NotificationBroadcastReceiverHandler {
return NotificationBroadcastReceiverHandler(
appCoroutineScope = this,
matrixClientProvider = FakeMatrixClientProvider {
if (matrixClient == null) {
Result.failure(Exception("No matrix client"))
} else {
Result.success(matrixClient)
}
},
sessionPreferencesStore = sessionPreferencesStore,
notificationDrawerManager = notificationDrawerManager,
actionIds = actionIds,
systemClock = systemClock,
onNotifiableEventReceived = onNotifiableEventReceived,
stringProvider = stringProvider,
replyMessageExtractor = replyMessageExtractor,
)
}
}

View file

@ -23,13 +23,13 @@ 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
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeSummaryGroupMessageCreator
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 io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -37,6 +37,7 @@ import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
private val MY_AVATAR_URL: String? = null
private val AN_INVITATION_EVENT = anInviteNotifiableEvent(roomId = A_ROOM_ID)
private val A_SIMPLE_EVENT = aSimpleNotifiableEvent(eventId = AN_EVENT_ID)
private val A_MESSAGE_EVENT = aNotifiableMessageEvent(eventId = AN_EVENT_ID, roomId = A_ROOM_ID)

View file

@ -19,12 +19,13 @@ package io.element.android.libraries.push.impl.notifications
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_SESSION_ID_2
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import org.junit.Test
class NotificationIdProviderTest {
@Test
fun `test notification id provider`() {
val sut = NotificationIdProvider()
val sut = NotificationIdProvider
val offsetForASessionId = 305_410
assertThat(sut.getSummaryNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 0)
assertThat(sut.getRoomMessagesNotificationId(A_SESSION_ID)).isEqualTo(offsetForASessionId + 1)

View file

@ -20,8 +20,8 @@ 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
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeActiveNotificationsProvider
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationCreator
import io.element.android.libraries.push.impl.notifications.fake.FakeNotificationDisplayer
import io.element.android.libraries.push.impl.notifications.fake.FakeRoomGroupMessageCreator
@ -31,6 +31,7 @@ import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiable
import io.element.android.libraries.push.impl.notifications.fixtures.aSimpleNotifiableEvent
import io.element.android.libraries.push.impl.notifications.fixtures.anInviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.lambda.value
@ -61,10 +62,9 @@ class NotificationRendererTest {
activeNotificationsProvider = FakeActiveNotificationsProvider(),
stringProvider = FakeStringProvider(),
)
private val notificationIdProvider = NotificationIdProvider()
private val notificationIdProvider = NotificationIdProvider
private val notificationRenderer = NotificationRenderer(
notificationIdProvider = notificationIdProvider,
notificationDisplayer = notificationDisplayer,
notificationDataFactory = notificationDataFactory,
)

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 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.channels
class FakeNotificationChannels(
var channelForIncomingCall: (ring: Boolean) -> String = { _ -> "" },
var channelIdForMessage: (noisy: Boolean) -> String = { _ -> "" },
var channelIdForTest: () -> String = { "" }
) : NotificationChannels {
override fun getChannelForIncomingCall(ring: Boolean): String {
return channelForIncomingCall(ring)
}
override fun getChannelIdForMessage(noisy: Boolean): String {
return channelIdForMessage(noisy)
}
override fun getChannelIdForTest(): String {
return channelIdForTest()
}
}

View file

@ -0,0 +1,83 @@
/*
* Copyright (c) 2024 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.channels
import android.app.NotificationChannel
import android.os.Build
import androidx.core.app.NotificationChannelCompat
import androidx.core.app.NotificationManagerCompat
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.mockk.every
import io.mockk.mockk
import io.mockk.verify
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
import org.robolectric.annotation.Config
@RunWith(RobolectricTestRunner::class)
class NotificationChannelsTest {
@Test
@Config(sdk = [Build.VERSION_CODES.O])
fun `init - creates notification channels and migrates old ones`() {
val notificationManager = mockk<NotificationManagerCompat>(relaxed = true) {
every { notificationChannels } returns emptyList()
}
createNotificationChannels(notificationManager = notificationManager)
verify { notificationManager.createNotificationChannel(any<NotificationChannelCompat>()) }
verify { notificationManager.createNotificationChannel(any<NotificationChannel>()) }
verify { notificationManager.deleteNotificationChannel(any<String>()) }
}
@Test
fun `getChannelForIncomingCall - returns the right channel`() {
val notificationChannels = createNotificationChannels()
val ringingChannel = notificationChannels.getChannelForIncomingCall(ring = true)
assertThat(ringingChannel).isEqualTo(RINGING_CALL_NOTIFICATION_CHANNEL_ID)
val normalChannel = notificationChannels.getChannelForIncomingCall(ring = false)
assertThat(normalChannel).isEqualTo(CALL_NOTIFICATION_CHANNEL_ID_V3)
}
@Test
fun `getChannelIdForMessage - returns the right channel`() {
val notificationChannels = createNotificationChannels()
assertThat(notificationChannels.getChannelIdForMessage(noisy = true)).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID)
assertThat(notificationChannels.getChannelIdForMessage(noisy = false)).isEqualTo(SILENT_NOTIFICATION_CHANNEL_ID)
}
@Test
fun `getChannelIdForTest - returns the right channel`() {
val notificationChannels = createNotificationChannels()
assertThat(notificationChannels.getChannelIdForTest()).isEqualTo(NOISY_NOTIFICATION_CHANNEL_ID)
}
private fun createNotificationChannels(
notificationManager: NotificationManagerCompat = mockk(relaxed = true),
) = DefaultNotificationChannels(
context = InstrumentationRegistry.getInstrumentation().targetContext,
notificationManager = notificationManager,
stringProvider = FakeStringProvider(),
)
}

View file

@ -29,18 +29,20 @@ import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.matrix.test.A_THREAD_ID
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.matrix.ui.components.aMatrixUser
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.DefaultNotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.NotificationActionIds
import io.element.android.libraries.push.impl.notifications.NotificationBitmapLoader
import io.element.android.libraries.push.impl.notifications.RoomEventGroupInfo
import io.element.android.libraries.push.impl.notifications.channels.DefaultNotificationChannels
import io.element.android.libraries.push.impl.notifications.channels.NotificationChannels
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
import io.element.android.libraries.push.impl.notifications.factories.action.RejectInvitationActionFactory
import io.element.android.libraries.push.impl.notifications.fake.FakeImageLoader
import io.element.android.libraries.push.impl.notifications.model.FallbackNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
import io.element.android.libraries.push.test.notifications.FakeImageLoader
import io.element.android.services.toolbox.test.sdk.FakeBuildVersionSdkIntProvider
import io.element.android.services.toolbox.test.strings.FakeStringProvider
import io.element.android.services.toolbox.test.systemclock.A_FAKE_TIMESTAMP
@ -284,7 +286,7 @@ fun createNotificationCreator(
context: Context = RuntimeEnvironment.getApplication(),
buildMeta: BuildMeta = aBuildMeta(),
notificationChannels: NotificationChannels = createNotificationChannels(),
bitmapLoader: NotificationBitmapLoader = NotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)),
bitmapLoader: NotificationBitmapLoader = DefaultNotificationBitmapLoader(context, FakeBuildVersionSdkIntProvider(Build.VERSION_CODES.R)),
): NotificationCreator {
return DefaultNotificationCreator(
context = context,
@ -327,5 +329,5 @@ fun createNotificationCreator(
fun createNotificationChannels(): NotificationChannels {
val context = RuntimeEnvironment.getApplication()
return NotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider(""))
return DefaultNotificationChannels(context, NotificationManagerCompat.from(context), FakeStringProvider(""))
}

View file

@ -46,7 +46,7 @@ class FakeNotificationDataFactory(
var inviteToNotificationsResult: LambdaOneParamRecorder<List<InviteNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var simpleEventToNotificationsResult: LambdaOneParamRecorder<List<SimpleNotifiableEvent>, List<OneShotNotification>> = lambdaRecorder { _ -> emptyList() },
var fallbackEventToNotificationsResult: LambdaOneParamRecorder<List<FallbackNotifiableEvent>, List<OneShotNotification>> =
lambdaRecorder { _ -> emptyList() },
lambdaRecorder { _ -> emptyList() },
) : NotificationDataFactory {
override suspend fun toNotifications(messages: List<NotifiableMessageEvent>, currentUser: MatrixUser, imageLoader: ImageLoader): List<RoomNotification> {
return messageEventToNotificationsResult(messages, currentUser, imageLoader)
@ -64,6 +64,8 @@ class FakeNotificationDataFactory(
return simpleEventToNotificationsResult(simpleEvents)
}
@JvmName("toNotificationFallbackEvents")
@Suppress("INAPPLICABLE_JVM_NAME")
override fun toNotifications(fallback: List<FallbackNotifiableEvent>): List<OneShotNotification> {
return fallbackEventToNotificationsResult(fallback)
}

View file

@ -18,8 +18,8 @@ package io.element.android.libraries.push.impl.notifications.fake
import android.app.Notification
import io.element.android.libraries.matrix.test.A_SESSION_ID
import io.element.android.libraries.push.api.notifications.NotificationIdProvider
import io.element.android.libraries.push.impl.notifications.NotificationDisplayer
import io.element.android.libraries.push.impl.notifications.NotificationIdProvider
import io.element.android.tests.testutils.lambda.LambdaNoParamRecorder
import io.element.android.tests.testutils.lambda.LambdaOneParamRecorder
import io.element.android.tests.testutils.lambda.LambdaThreeParamsRecorder
@ -51,7 +51,7 @@ class FakeNotificationDisplayer(
fun verifySummaryCancelled(times: Int = 1) {
cancelNotificationMessageResult.assertions().isCalledExactly(times).withSequence(
listOf(value(null), value(NotificationIdProvider().getSummaryNotificationId(A_SESSION_ID)))
listOf(value(null), value(NotificationIdProvider.getSummaryNotificationId(A_SESSION_ID)))
)
}
}

View file

@ -21,11 +21,16 @@ 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.core.UserId
import io.element.android.libraries.matrix.api.notification.CallNotifyType
import io.element.android.libraries.matrix.api.timeline.item.event.EventType
import io.element.android.libraries.matrix.test.AN_AVATAR_URL
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
import io.element.android.libraries.matrix.test.A_USER_ID_2
import io.element.android.libraries.push.impl.notifications.model.InviteNotifiableEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableRingingCallEvent
import io.element.android.libraries.push.impl.notifications.model.SimpleNotifiableEvent
fun aSimpleNotifiableEvent(
@ -79,6 +84,7 @@ fun aNotifiableMessageEvent(
threadId: ThreadId? = null,
isRedacted: Boolean = false,
timestamp: Long = 0,
type: String = EventType.MESSAGE,
) = NotifiableMessageEvent(
sessionId = sessionId,
eventId = eventId,
@ -94,5 +100,34 @@ fun aNotifiableMessageEvent(
roomIsDirect = false,
canBeReplaced = false,
isRedacted = isRedacted,
imageUriString = null
imageUriString = null,
type = type,
)
fun aNotifiableCallEvent(
sessionId: SessionId = A_SESSION_ID,
roomId: RoomId = A_ROOM_ID,
eventId: EventId = AN_EVENT_ID,
senderId: UserId = A_USER_ID_2,
senderName: String? = null,
roomAvatarUrl: String? = AN_AVATAR_URL,
senderAvatarUrl: String? = AN_AVATAR_URL,
callNotifyType: CallNotifyType = CallNotifyType.NOTIFY,
timestamp: Long = 0L,
) = NotifiableRingingCallEvent(
sessionId = sessionId,
eventId = eventId,
roomId = roomId,
roomName = "a room name",
editedEventId = null,
description = "description",
timestamp = timestamp,
canBeReplaced = false,
isRedacted = false,
isUpdated = false,
senderDisambiguatedDisplayName = senderName,
senderId = senderId,
roomAvatarUrl = roomAvatarUrl,
senderAvatarUrl = senderAvatarUrl,
callNotifyType = callNotifyType,
)

View file

@ -19,11 +19,16 @@
package io.element.android.libraries.push.impl.push
import app.cash.turbine.test
import io.element.android.features.call.api.CallType
import io.element.android.features.call.test.FakeElementCallEntryPoint
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.matrix.api.auth.MatrixAuthenticationService
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.CallNotifyType
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.A_ROOM_ID
import io.element.android.libraries.matrix.test.A_SECRET
@ -31,6 +36,8 @@ import io.element.android.libraries.matrix.test.A_USER_ID
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.push.impl.notifications.FakeNotifiableEventResolver
import io.element.android.libraries.push.impl.notifications.channels.FakeNotificationChannels
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableCallEvent
import io.element.android.libraries.push.impl.notifications.fixtures.aNotifiableMessageEvent
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.libraries.push.impl.test.DefaultTestPush
@ -47,6 +54,7 @@ import io.element.android.tests.testutils.lambda.value
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.runTest
import org.junit.Test
import java.time.Instant
class DefaultPushHandlerTest {
@Test
@ -220,6 +228,55 @@ class DefaultPushHandlerTest {
.isNeverCalled()
}
@Test
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val handleIncomingCallLambda = lambdaRecorder<CallType.RoomCall, EventId, UserId, String?, String?, String?, String, Unit> { _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
notifiableEventResult = { _, _, _ -> aNotifiableCallEvent(callNotifyType = CallNotifyType.RING, timestamp = Instant.now().toEpochMilli()) },
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
)
defaultPushHandler.handle(aPushData)
handleIncomingCallLambda.assertions().isCalledOnce()
}
@Test
fun `when notify call PushData is received, the incoming call will be treated as a normal notification`() = runTest {
val aPushData = PushData(
eventId = AN_EVENT_ID,
roomId = A_ROOM_ID,
unread = 0,
clientSecret = A_SECRET,
)
val onNotifiableEventReceived = lambdaRecorder<NotifiableEvent, Unit> {}
val handleIncomingCallLambda = lambdaRecorder<CallType.RoomCall, EventId, UserId, String?, String?, String?, String, Unit> { _, _, _, _, _, _, _ -> }
val elementCallEntryPoint = FakeElementCallEntryPoint(handleIncomingCallResult = handleIncomingCallLambda)
val defaultPushHandler = createDefaultPushHandler(
elementCallEntryPoint = elementCallEntryPoint,
onNotifiableEventReceived = onNotifiableEventReceived,
notifiableEventResult = { _, _, _ -> aNotifiableMessageEvent(type = EventType.CALL_NOTIFY) },
incrementPushCounterResult = {},
pushClientSecret = FakePushClientSecret(
getUserIdFromSecretResult = { A_USER_ID }
),
)
defaultPushHandler.handle(aPushData)
handleIncomingCallLambda.assertions().isNeverCalled()
onNotifiableEventReceived.assertions().isCalledOnce()
}
@Test
fun `when diagnostic PushData is received, the diagnostic push handler is informed `() =
runTest {
@ -249,6 +306,8 @@ class DefaultPushHandlerTest {
buildMeta: BuildMeta = aBuildMeta(),
matrixAuthenticationService: MatrixAuthenticationService = FakeMatrixAuthenticationService(),
diagnosticPushHandler: DiagnosticPushHandler = DiagnosticPushHandler(),
elementCallEntryPoint: FakeElementCallEntryPoint = FakeElementCallEntryPoint(),
notificationChannels: FakeNotificationChannels = FakeNotificationChannels(),
): DefaultPushHandler {
return DefaultPushHandler(
onNotifiableEventReceived = FakeOnNotifiableEventReceived(onNotifiableEventReceived),
@ -263,6 +322,8 @@ class DefaultPushHandlerTest {
buildMeta = buildMeta,
matrixAuthenticationService = matrixAuthenticationService,
diagnosticPushHandler = diagnosticPushHandler,
elementCallEntryPoint = elementCallEntryPoint,
notificationChannels = notificationChannels,
)
}
}

View file

@ -17,9 +17,10 @@
package io.element.android.libraries.push.impl.push
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
import io.element.android.tests.testutils.lambda.lambdaError
class FakeOnNotifiableEventReceived(
private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit,
private val onNotifiableEventReceivedResult: (NotifiableEvent) -> Unit = { lambdaError() },
) : OnNotifiableEventReceived {
override fun onNotifiableEventReceived(notifiableEvent: NotifiableEvent) {
onNotifiableEventReceivedResult(notifiableEvent)

View file

@ -24,7 +24,13 @@ android {
dependencies {
api(projects.libraries.push.api)
implementation(projects.libraries.push.impl)
implementation(projects.libraries.matrix.api)
implementation(projects.libraries.matrixui)
implementation(projects.libraries.pushproviders.api)
implementation(projects.tests.testutils)
implementation(libs.androidx.core)
implementation(libs.coil.compose)
implementation(libs.coil.test)
implementation(libs.test.robolectric)
}

View file

@ -0,0 +1,30 @@
/*
* Copyright (c) 2024 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.test.notifications
import io.element.android.libraries.matrix.api.core.SessionId
import io.element.android.libraries.matrix.api.notification.NotificationData
import io.element.android.libraries.push.impl.notifications.CallNotificationEventResolver
import io.element.android.libraries.push.impl.notifications.model.NotifiableEvent
class FakeCallNotificationEventResolver(
var resolveEventLambda: (sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean) -> NotifiableEvent? = { _, _, _ -> null },
) : CallNotificationEventResolver {
override fun resolveEvent(sessionId: SessionId, notificationData: NotificationData, forceNotify: Boolean): NotifiableEvent? {
return resolveEventLambda(sessionId, notificationData, forceNotify)
}
}

View file

@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.fake
package io.element.android.libraries.push.test.notifications
import android.graphics.Color
import android.graphics.drawable.ColorDrawable

View file

@ -1,5 +1,5 @@
/*
* Copyright (c) 2023 New Vector Ltd
* Copyright (c) 2024 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.
@ -14,7 +14,7 @@
* limitations under the License.
*/
package io.element.android.libraries.push.impl.notifications.fake
package io.element.android.libraries.push.test.notifications
import coil.ImageLoader
import io.element.android.libraries.matrix.api.MatrixClient

View file

@ -16,33 +16,36 @@
package io.element.android.libraries.push.test.notifications
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.push.api.notifications.NotificationDrawerManager
import io.element.android.tests.testutils.lambda.lambdaError
class FakeNotificationDrawerManager : NotificationDrawerManager {
private val clearMemberShipNotificationForSessionCallsCount = mutableMapOf<String, Int>()
private val clearMemberShipNotificationForRoomCallsCount = mutableMapOf<String, Int>()
class FakeNotificationDrawerManager(
private val clearAllMessagesEventsLambda: (SessionId) -> Unit = { lambdaError() },
private val clearMessagesForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() },
private val clearEventLambda: (SessionId, EventId) -> Unit = { _, _ -> lambdaError() },
private val clearMembershipNotificationForSessionLambda: (SessionId) -> Unit = { lambdaError() },
private val clearMembershipNotificationForRoomLambda: (SessionId, RoomId) -> Unit = { _, _ -> lambdaError() }
) : NotificationDrawerManager {
override fun clearAllMessagesEvents(sessionId: SessionId) {
clearAllMessagesEventsLambda(sessionId)
}
override fun clearMessagesForRoom(sessionId: SessionId, roomId: RoomId) {
clearMessagesForRoomLambda(sessionId, roomId)
}
override fun clearEvent(sessionId: SessionId, eventId: EventId) {
clearEventLambda(sessionId, eventId)
}
override fun clearMembershipNotificationForSession(sessionId: SessionId) {
clearMemberShipNotificationForSessionCallsCount.merge(sessionId.value, 1) { oldValue, value -> oldValue + value }
clearMembershipNotificationForSessionLambda(sessionId)
}
override fun clearMembershipNotificationForRoom(sessionId: SessionId, roomId: RoomId) {
val key = getMembershipNotificationKey(sessionId, roomId)
clearMemberShipNotificationForRoomCallsCount.merge(key, 1) { oldValue, value -> oldValue + value }
}
fun getClearMembershipNotificationForSessionCount(sessionId: SessionId): Int {
return clearMemberShipNotificationForRoomCallsCount[sessionId.value] ?: 0
}
fun getClearMembershipNotificationForRoomCount(sessionId: SessionId, roomId: RoomId): Int {
val key = getMembershipNotificationKey(sessionId, roomId)
return clearMemberShipNotificationForRoomCallsCount[key] ?: 0
}
private fun getMembershipNotificationKey(sessionId: SessionId, roomId: RoomId): String {
return "$sessionId-$roomId"
clearMembershipNotificationForRoomLambda(sessionId, roomId)
}
}

View file

@ -0,0 +1,34 @@
/*
* Copyright (c) 2024 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.test.notifications
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.push.api.notifications.OnMissedCallNotificationHandler
class FakeOnMissedCallNotificationHandler(
var addMissedCallNotificationLambda: (SessionId, RoomId, EventId) -> Unit = { _, _, _ -> }
) : OnMissedCallNotificationHandler {
override suspend fun addMissedCallNotification(
sessionId: SessionId,
roomId: RoomId,
eventId: EventId,
) {
addMissedCallNotificationLambda(sessionId, roomId, eventId)
}
}

View file

@ -0,0 +1,35 @@
/*
* Copyright (c) 2024 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.test.notifications.push
import android.graphics.Bitmap
import androidx.core.graphics.drawable.IconCompat
import coil.ImageLoader
import io.element.android.libraries.push.api.notifications.NotificationBitmapLoader
class FakeNotificationBitmapLoader(
var getRoomBitmapResult: (String?, ImageLoader) -> Bitmap? = { _, _ -> null },
var getUserIconResult: (String?, ImageLoader) -> IconCompat? = { _, _ -> null },
) : NotificationBitmapLoader {
override suspend fun getRoomBitmap(path: String?, imageLoader: ImageLoader): Bitmap? {
return getRoomBitmapResult(path, imageLoader)
}
override suspend fun getUserIcon(path: String?, imageLoader: ImageLoader): IconCompat? {
return getUserIconResult(path, imageLoader)
}
}

View file

@ -18,4 +18,5 @@ package io.element.android.libraries.roomselect.api
enum class RoomSelectMode {
Forward,
Share,
}

View file

@ -31,29 +31,33 @@ open class RoomSelectStateProvider : PreviewParameterProvider<RoomSelectState> {
get() = sequenceOf(
aRoomSelectState(),
aRoomSelectState(query = "Test", isSearchActive = true),
aRoomSelectState(resultState = SearchBarResultState.Results(aForwardMessagesRoomList())),
aRoomSelectState(resultState = SearchBarResultState.Results(aRoomSelectRoomList())),
aRoomSelectState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
),
aRoomSelectState(
resultState = SearchBarResultState.Results(aForwardMessagesRoomList()),
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
query = "Test",
isSearchActive = true,
selectedRooms = persistentListOf(aRoomSummaryDetails(roomId = RoomId("!room2:domain")))
),
// Add other states here
aRoomSelectState(
mode = RoomSelectMode.Share,
resultState = SearchBarResultState.Results(aRoomSelectRoomList()),
),
)
}
private fun aRoomSelectState(
mode: RoomSelectMode = RoomSelectMode.Forward,
resultState: SearchBarResultState<ImmutableList<RoomSummaryDetails>> = SearchBarResultState.Initial(),
query: String = "",
isSearchActive: Boolean = false,
selectedRooms: ImmutableList<RoomSummaryDetails> = persistentListOf(),
) = RoomSelectState(
mode = RoomSelectMode.Forward,
mode = mode,
resultState = resultState,
query = query,
isSearchActive = isSearchActive,
@ -61,7 +65,7 @@ private fun aRoomSelectState(
eventSink = {}
)
private fun aForwardMessagesRoomList() = persistentListOf(
private fun aRoomSelectRoomList() = persistentListOf(
aRoomSummaryDetails(),
aRoomSummaryDetails(
roomId = RoomId("!room2:domain"),

View file

@ -105,6 +105,7 @@ fun RoomSelectView(
Text(
text = when (state.mode) {
RoomSelectMode.Forward -> stringResource(CommonStrings.common_forward_message)
RoomSelectMode.Share -> stringResource(CommonStrings.common_send_to)
},
style = ElementTheme.typography.aliasScreenTitle
)

View file

@ -0,0 +1,24 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="rich_text_editor_a11y_add_attachment">"Lisa manus"</string>
<string name="rich_text_editor_bullet_list">"Lülita mummudega loend sisse/välja"</string>
<string name="rich_text_editor_close_formatting_options">"Sulge vorminduse valikud"</string>
<string name="rich_text_editor_code_block">"Lülita lähtekoodi lõik sisse/välja"</string>
<string name="rich_text_editor_composer_placeholder">"Sõnum…"</string>
<string name="rich_text_editor_create_link">"Lisa link"</string>
<string name="rich_text_editor_edit_link">"Muuda linki"</string>
<string name="rich_text_editor_format_bold">"Kasuta paksu kirja"</string>
<string name="rich_text_editor_format_italic">"Kasuta kaldkirja"</string>
<string name="rich_text_editor_format_strikethrough">"Kasuta läbikriipsutatud kirja"</string>
<string name="rich_text_editor_format_underline">"Kasuta allajoonitud kirja"</string>
<string name="rich_text_editor_full_screen_toggle">"Lülita täisekraanivaade sisse/välja"</string>
<string name="rich_text_editor_indent">"Lisa taandrida"</string>
<string name="rich_text_editor_inline_code">"Kuva lähtekoodi lõiguna"</string>
<string name="rich_text_editor_link">"Lisa link"</string>
<string name="rich_text_editor_numbered_list">"Lülita nummerdatud loend sisse/välja"</string>
<string name="rich_text_editor_open_compose_options">"Ava vorminduse valikud"</string>
<string name="rich_text_editor_quote">"Lülita tsiteerimine sisse/välja"</string>
<string name="rich_text_editor_remove_link">"Eemalda link"</string>
<string name="rich_text_editor_unindent">"Eemalda taandrida"</string>
<string name="rich_text_editor_url_placeholder">"Link"</string>
</resources>

View file

@ -85,6 +85,7 @@
<string name="action_quick_reply">"Хуткі адказ"</string>
<string name="action_quote">"Цытата"</string>
<string name="action_react">"Рэакцыя"</string>
<string name="action_reject">"Адхіліць"</string>
<string name="action_remove">"Выдаліць"</string>
<string name="action_reply">"Адказаць"</string>
<string name="action_reply_in_thread">"Адказаць у гутаркі"</string>
@ -162,7 +163,7 @@
<string name="common_message_layout">"Выгляд паведамлення"</string>
<string name="common_message_removed">"Паведамленне выдалена"</string>
<string name="common_modern">"Сучасны"</string>
<string name="common_mute">"Адключыць гук"</string>
<string name="common_mute">"Адкл. гук"</string>
<string name="common_no_results">"Вынікаў няма"</string>
<string name="common_no_room_name">"Няма назвы пакоя"</string>
<string name="common_offline">"Па-за сеткай"</string>
@ -201,6 +202,7 @@
<string name="common_search_results">"Вынікі пошуку"</string>
<string name="common_security">"Бяспека"</string>
<string name="common_seen_by">"Прагледжана"</string>
<string name="common_send_to">"Адправіць"</string>
<string name="common_sending">"Адпраўка…"</string>
<string name="common_sending_failed">"Памылка адпраўкі"</string>
<string name="common_sent">"Адпраўлена"</string>
@ -226,7 +228,7 @@
<string name="common_unable_to_invite_message">"Не ўдалося адправіць запрашэнні аднаму або некалькім карыстальнікам."</string>
<string name="common_unable_to_invite_title">"Немагчыма адправіць запрашэнне(я)"</string>
<string name="common_unlock">"Разблакіраваць"</string>
<string name="common_unmute">"Уключыць гук"</string>
<string name="common_unmute">"Укл. гук"</string>
<string name="common_unsupported_event">"Падзея не падтрымліваецца"</string>
<string name="common_username">"Імя карыстальніка"</string>
<string name="common_verification_cancelled">"Праверка адменена"</string>
@ -235,7 +237,7 @@
<string name="common_video">"Відэа"</string>
<string name="common_voice_message">"Галасавое паведамленне"</string>
<string name="common_waiting">"Чакаем…"</string>
<string name="common_waiting_for_decryption_key">"Чакаю гэтага паведамлення"</string>
<string name="common_waiting_for_decryption_key">"Чакаю гэта паведамленне"</string>
<string name="dialog_title_confirmation">"Пацвярджэнне"</string>
<string name="dialog_title_error">"Памылка"</string>
<string name="dialog_title_success">"Поспех"</string>
@ -257,6 +259,7 @@
<string name="invite_friends_text">"Гэй, пагавары са мной у %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Паведаміць аб памылцы з дапамогай Rageshake"</string>
<string name="screen_incoming_call_subtitle_android">"Уваходны званок Element Call"</string>
<string name="screen_media_picker_error_failed_selection">"Не ўдалося выбраць носьбіт, паўтарыце спробу."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не атрымалася апрацаваць медыяфайл для загрузкі, паспрабуйце яшчэ раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не атрымалася загрузіць медыяфайлы, паспрабуйце яшчэ раз."</string>

View file

@ -85,6 +85,7 @@
<string name="action_quick_reply">"Rychlá odpověď"</string>
<string name="action_quote">"Citovat"</string>
<string name="action_react">"Reagovat"</string>
<string name="action_reject">"Odmítnout"</string>
<string name="action_remove">"Odstranit"</string>
<string name="action_reply">"Odpovědět"</string>
<string name="action_reply_in_thread">"Odpovědět ve vlákně"</string>
@ -201,6 +202,7 @@
<string name="common_search_results">"Výsledky hledání"</string>
<string name="common_security">"Zabezpečení"</string>
<string name="common_seen_by">"Viděno"</string>
<string name="common_send_to">"Odeslat do"</string>
<string name="common_sending">"Odesílání…"</string>
<string name="common_sending_failed">"Odeslání se nezdařilo"</string>
<string name="common_sent">"Odesláno"</string>
@ -257,6 +259,7 @@
<string name="invite_friends_text">"Ahoj, ozvi se mi na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zatřeste zařízením pro nahlášení chyby"</string>
<string name="screen_incoming_call_subtitle_android">"Příchozí Element Call"</string>
<string name="screen_media_picker_error_failed_selection">"Výběr média se nezdařil, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nahrání média se nezdařilo, zkuste to prosím znovu."</string>

View file

@ -0,0 +1,262 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="a11y_delete">"Kustuta"</string>
<plurals name="a11y_digits_entered">
<item quantity="one">"%1$d number sisestatud"</item>
<item quantity="other">"%1$d numbrit sisestatud"</item>
</plurals>
<string name="a11y_hide_password">"Peida salasõna"</string>
<string name="a11y_jump_to_bottom">"Mine lõppu"</string>
<string name="a11y_notifications_mentions_only">"Ainult mainimised"</string>
<string name="a11y_notifications_muted">"Summutatud"</string>
<string name="a11y_page_n">"%1$d. lehekülg"</string>
<string name="a11y_pause">"Peata"</string>
<string name="a11y_pin_field">"PIN-koodi väli"</string>
<string name="a11y_play">"Esita"</string>
<string name="a11y_poll">"Küsitlus"</string>
<string name="a11y_poll_end">"Lõppenud küsitlus"</string>
<string name="a11y_react_with">"Reageeri emotikoniga %1$s"</string>
<string name="a11y_react_with_other_emojis">"Reageeri mõne muu emotikoniga"</string>
<string name="a11y_read_receipts_multiple">"Seda lugesid %1$s ja %2$s"</string>
<plurals name="a11y_read_receipts_multiple_with_others">
<item quantity="one">"Seda lugesid %1$s ja veel %2$d kasutaja"</item>
<item quantity="other">"Seda lugesid %1$s ja veel %2$d kasutajat"</item>
</plurals>
<string name="a11y_read_receipts_single">"Seda luges %1$s"</string>
<string name="a11y_read_receipts_tap_to_show_all">"Vaata kõiki"</string>
<string name="a11y_remove_reaction_with">"Eemalda reageerimine %1$s emotikoniga"</string>
<string name="a11y_send_files">"Saada faile"</string>
<string name="a11y_show_password">"Näita salasõna"</string>
<string name="a11y_start_call">"Helista"</string>
<string name="a11y_user_menu">"Kasutajamenüü"</string>
<string name="a11y_voice_message_record">"Salvesta häälsõnum"</string>
<string name="a11y_voice_message_stop_recording">"Lõpeta salvestamine"</string>
<string name="action_accept">"Nõustu"</string>
<string name="action_add_to_timeline">"Lisa ajajoonele"</string>
<string name="action_back">"Tagasi"</string>
<string name="action_call">"Helista kasutajale"</string>
<string name="action_cancel">"Loobu"</string>
<string name="action_choose_photo">"Vali foto"</string>
<string name="action_clear">"Selge"</string>
<string name="action_close">"Sulge"</string>
<string name="action_complete_verification">"Tee verifitseerimine lõpuni"</string>
<string name="action_confirm">"Kinnita"</string>
<string name="action_continue">"Jätka"</string>
<string name="action_copy">"Kopeeri"</string>
<string name="action_copy_link">"Kopeeri link"</string>
<string name="action_copy_link_to_message">"Kopeeri sõnumi link"</string>
<string name="action_create">"Loo"</string>
<string name="action_create_a_room">"Loo jututuba"</string>
<string name="action_decline">"Keeldu"</string>
<string name="action_delete_poll">"Kustuta küsitlus"</string>
<string name="action_disable">"Lülita välja"</string>
<string name="action_discard">"Loobu"</string>
<string name="action_done">"Valmis"</string>
<string name="action_edit">"Muuda"</string>
<string name="action_edit_poll">"Muuda küsitlust"</string>
<string name="action_enable">"Võta kasutusele"</string>
<string name="action_end_poll">"Lõpeta küsitlus"</string>
<string name="action_enter_pin">"Sisesta PIN-kood"</string>
<string name="action_forgot_password">"Kas unustasid salasõna?"</string>
<string name="action_forward">"Edasta"</string>
<string name="action_go_back">"Tagasi eelmisesse vaatesse"</string>
<string name="action_invite">"Kutsu"</string>
<string name="action_invite_friends">"Kutsu osalejaid"</string>
<string name="action_invite_people_to_app">"Kutsu huvilisi kasutama rakendust %1$s"</string>
<string name="action_invites_list">"Kutsed"</string>
<string name="action_join">"Liitu"</string>
<string name="action_learn_more">"Lisateave"</string>
<string name="action_leave">"Lahku"</string>
<string name="action_leave_conversation">"Lahku vestlusest"</string>
<string name="action_leave_room">"Lahku jututoast"</string>
<string name="action_load_more">"Näita veel"</string>
<string name="action_manage_account">"Halda kasutajakontot"</string>
<string name="action_manage_devices">"Halda seadmeid"</string>
<string name="action_message">"Saada sõnum kasutajale"</string>
<string name="action_next">"Edasi"</string>
<string name="action_no">"Ei"</string>
<string name="action_not_now">"Mitte praegu"</string>
<string name="action_ok">"OK"</string>
<string name="action_open_settings">"Seadistused"</string>
<string name="action_open_with">"Ava rakendusega"</string>
<string name="action_quick_reply">"Kiirvastus"</string>
<string name="action_quote">"Tsiteeri"</string>
<string name="action_react">"Reageeri"</string>
<string name="action_remove">"Eemalda"</string>
<string name="action_reply">"Vasta"</string>
<string name="action_reply_in_thread">"Vasta jutulõngas"</string>
<string name="action_report_bug">"Teata veast"</string>
<string name="action_report_content">"Teata sisust haldurile"</string>
<string name="action_reset">"Lähtesta"</string>
<string name="action_retry">"Proovi uuesti"</string>
<string name="action_retry_decryption">"Proovi dekrüptimist uuesti"</string>
<string name="action_save">"Salvesta"</string>
<string name="action_search">"Otsi"</string>
<string name="action_send">"Saada"</string>
<string name="action_send_message">"Saada sõnum"</string>
<string name="action_share">"Jaga"</string>
<string name="action_share_link">"Jaga linki"</string>
<string name="action_sign_in_again">"Logi uuesti sisse"</string>
<string name="action_signout">"Logi välja"</string>
<string name="action_signout_anyway">"Ikkagi logi välja"</string>
<string name="action_skip">"Jäta vahele"</string>
<string name="action_start">"Alusta"</string>
<string name="action_start_chat">"Alusta vestlust"</string>
<string name="action_start_verification">"Alusta verifitseerimist"</string>
<string name="action_static_map_load">"Kaardi laadimiseks klõpsa"</string>
<string name="action_take_photo">"Tee pilt"</string>
<string name="action_tap_for_options">"Valikuteks klõpsa"</string>
<string name="action_try_again">"Proovi uuesti"</string>
<string name="action_view_source">"Vaata lähtekoodi"</string>
<string name="action_yes">"Jah"</string>
<string name="common_about">"Rakenduse teave"</string>
<string name="common_acceptable_use_policy">"Vastuvõetava kasutamise põhimõtted"</string>
<string name="common_advanced_settings">"Täiendavad seadistused"</string>
<string name="common_analytics">"Analüütika"</string>
<string name="common_appearance">"Välimus"</string>
<string name="common_audio">"Heli"</string>
<string name="common_blocked_users">"Blokeeritud kasutajad"</string>
<string name="common_bubbles">"Mullid"</string>
<string name="common_call_invite">"Kõne on pooleli (pole toetatud)"</string>
<string name="common_call_started">"Kõne algas"</string>
<string name="common_chat_backup">"Vestluse varukoopia"</string>
<string name="common_copyright">"Autoriõigused"</string>
<string name="common_creating_room">"Loome jututoa…"</string>
<string name="common_current_user_left_room">"Lahkus jututoast"</string>
<string name="common_dark">"Tume"</string>
<string name="common_decryption_error">"Dekrüptimisviga"</string>
<string name="common_developer_options">"Arendaja valikud"</string>
<string name="common_direct_chat">"Otsevestlus"</string>
<string name="common_edited_suffix">"(muudetud)"</string>
<string name="common_editing">"Muutmine"</string>
<string name="common_emote">"* %1$s %2$s"</string>
<string name="common_encryption_enabled">"Krüptimine on kasutusel"</string>
<string name="common_enter_your_pin">"Sisesta oma PIN-kood"</string>
<string name="common_error">"Viga"</string>
<string name="common_everyone">"Kõik"</string>
<string name="common_failed">"Ei õnnestunud"</string>
<string name="common_favourite">"Lemmik"</string>
<string name="common_favourited">"Lemmikuks määratud"</string>
<string name="common_file">"Fail"</string>
<string name="common_file_saved_on_disk_android">"Fail on salvestatud kausta Allalaadimised"</string>
<string name="common_forward_message">"Edasta sõnum"</string>
<string name="common_gif">"GIF"</string>
<string name="common_image">"Pilt"</string>
<string name="common_in_reply_to">"Vastuseks kasutajale %1$s"</string>
<string name="common_install_apk_android">"Paigalda APK-failist"</string>
<string name="common_invite_unknown_profile">"Sellist Matrix\'i kasutajatunnust ei õnnestu leida, seega sõnumit ilmselt keegi kätte ei saa."</string>
<string name="common_leaving_room">"On lahkumas jututoast"</string>
<string name="common_light">"Hele"</string>
<string name="common_link_copied_to_clipboard">"Link on kopeeritud lõikelauale"</string>
<string name="common_loading">"Laadime…"</string>
<plurals name="common_member_count">
<item quantity="one">"%1$d liige"</item>
<item quantity="other">"%1$d liiget"</item>
</plurals>
<string name="common_message">"Sõnum"</string>
<string name="common_message_actions">"Tegevused sõnumiga"</string>
<string name="common_message_layout">"Sõnumi paigutus"</string>
<string name="common_message_removed">"Sõnum on eemaldatud"</string>
<string name="common_modern">"Kaasaegne"</string>
<string name="common_mute">"Summuta"</string>
<string name="common_no_results">"Otsingul pole tulemusi"</string>
<string name="common_no_room_name">"Jututoal puudub nimi"</string>
<string name="common_offline">"Võrgust väljas"</string>
<string name="common_or">"või"</string>
<string name="common_password">"Salasõna"</string>
<string name="common_people">"Inimesed"</string>
<string name="common_permalink">"Püsilink"</string>
<string name="common_permission">"Õigus"</string>
<string name="common_please_wait">"Palun oota…"</string>
<string name="common_poll_end_confirmation">"Kas oled kindel, et soovid selle küsitluse lõpetada?"</string>
<string name="common_poll_summary">"Küsitlus: %1$s"</string>
<string name="common_poll_total_votes">"Hääli kokku: %1$s"</string>
<string name="common_poll_undisclosed_text">"Tulemused on näha peale küsitluse lõppemist"</string>
<plurals name="common_poll_votes_count">
<item quantity="one">"%d hääl"</item>
<item quantity="other">"%d häält"</item>
</plurals>
<string name="common_privacy_policy">"Privaatsuspoliitika"</string>
<string name="common_reaction">"Reaktsioon"</string>
<string name="common_reactions">"Reaktsioonid"</string>
<string name="common_recovery_key">"Taastevõti"</string>
<string name="common_refreshing">"Värskendame andmeid…"</string>
<string name="common_replying_to">"Vastates kasutajale %1$s"</string>
<string name="common_report_a_bug">"Teata veast"</string>
<string name="common_report_a_problem">"Teata veast"</string>
<string name="common_report_submitted">"Veateade on saadetud"</string>
<string name="common_rich_text_editor">"Vormindatud teksti toimeti"</string>
<string name="common_room">"Jututuba"</string>
<string name="common_room_name">"Jututoa nimi"</string>
<string name="common_room_name_placeholder">"näiteks sinu projekti või seltsingu nimi"</string>
<string name="common_saved_changes">"Muudatused on salvestatud"</string>
<string name="common_saving">"Salvestame"</string>
<string name="common_screen_lock">"Ekraanilukk"</string>
<string name="common_search_for_someone">"Otsi kedagi"</string>
<string name="common_search_results">"Otsingutulemused"</string>
<string name="common_security">"Turvalisus"</string>
<string name="common_seen_by">"Seda nägi"</string>
<string name="common_sending">"Saadame…"</string>
<string name="common_sending_failed">"Saatmine ei õnnestunud"</string>
<string name="common_sent">"Saadetud"</string>
<string name="common_server_not_supported">"Server pole toetatud"</string>
<string name="common_server_url">"Serveri URL"</string>
<string name="common_settings">"Seadistused"</string>
<string name="common_shared_location">"Jagatud asukoht"</string>
<string name="common_signing_out">"Logime välja"</string>
<string name="common_something_went_wrong">"Midagi läks valesti"</string>
<string name="common_starting_chat">"Alustame vestlust…"</string>
<string name="common_sticker">"Kleeps"</string>
<string name="common_success">"Õnnestus"</string>
<string name="common_suggestions">"Soovitused"</string>
<string name="common_third_party_notices">"Kolmandate osapoolte teatised"</string>
<string name="common_thread">"Jutulõng"</string>
<string name="common_topic">"Teema"</string>
<string name="common_topic_placeholder">"Mis on selle jututoa mõte?"</string>
<string name="common_unable_to_decrypt">"Dekrüptimine ei olnud võimalik"</string>
<string name="common_unable_to_decrypt_no_access">"Sul pole ligipääsu antud sõnumile"</string>
<string name="common_unable_to_invite_message">"Kutset polnud võimalik saata ühele või enamale kasutajale."</string>
<string name="common_unable_to_invite_title">"Kutse(te) saatmine ei õnnestunud"</string>
<string name="common_unlock">"Eemalda lukustus"</string>
<string name="common_unmute">"Lõpeta summutamine"</string>
<string name="common_unsupported_event">"Toetamata sündmus"</string>
<string name="common_username">"Kasutajanimi"</string>
<string name="common_verification_cancelled">"Verifitseerimine on katkestatud"</string>
<string name="common_verification_complete">"Verifitseerimine on tehtud"</string>
<string name="common_verify_device">"Verifitseeri seade"</string>
<string name="common_video">"Video"</string>
<string name="common_voice_message">"Häälsõnum"</string>
<string name="common_waiting">"Ootame…"</string>
<string name="common_waiting_for_decryption_key">"Ootame selle sõnumi dekrüptimisvõtit"</string>
<string name="dialog_title_confirmation">"Kinnitus"</string>
<string name="dialog_title_error">"Viga"</string>
<string name="dialog_title_success">"Õnnestus"</string>
<string name="dialog_title_warning">"Hoiatus"</string>
<string name="dialog_unsaved_changes_description_android">"Sinu tehtud muudatused pole veel salvestatud. Kas sa oled kindel, et soovid minna tagasi?"</string>
<string name="dialog_unsaved_changes_title">"Kas salvestame muudatused?"</string>
<string name="error_failed_creating_the_permalink">"Püsilingi loomine ei õnnestumud"</string>
<string name="error_failed_loading_map">"%1$s kaardi laadimine ei õnnestunud. Palun proovi hiljem uuesti."</string>
<string name="error_failed_loading_messages">"Sõnumite laadimine ei õnnestunud"</string>
<string name="error_failed_locating_user">"Rakendus %1$s ei suutnud tuvastada sinu asukohta. Palun proovi hiljem uuesti."</string>
<string name="error_failed_uploading_voice_message">"Sinu häälsõnumi üleslaadimine ei õnnestunud."</string>
<string name="error_message_not_found">"Sõnumit ei leidu"</string>
<string name="error_missing_location_auth_android">"Rakendusel %1$s puudub õigus sinu asukohta tuvastada. Sa saad seda lubada süsteemi seadistustest."</string>
<string name="error_missing_location_rationale_android">"Rakendusel %1$s puudub õigus sinu asukohta tuvastada. Alljärgnevas luba vastavad õigused."</string>
<string name="error_missing_microphone_voice_rationale_android">"Rakendusel %1$s puudub õigus sinu nutiseadme mikrofoni kasutada. Alljärgnevas luba õigused heli salvestamiseks."</string>
<string name="error_some_messages_have_not_been_sent">"Mõned sõnumid on saatmata"</string>
<string name="error_unknown">"Vabandust, ilmnes viga"</string>
<string name="invite_friends_rich_title">"🔐️ Liitu minuga rakenduses %1$s"</string>
<string name="invite_friends_text">"Hei, suhtle minuga %1$s võrgus: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Veast teatamiseks raputa nutiseadet ägedalt"</string>
<string name="screen_media_picker_error_failed_selection">"Meediafaili valimine ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Meediafaili üleslaadimine ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_room_error_failed_processing_media">"Meediafaili töötlemine enne üleslaadimist ei õnnestunud. Palun proovi uuesti."</string>
<string name="screen_share_location_title">"Jaga asukohta"</string>
<string name="screen_share_my_location_action">"Jaga minu asukohta"</string>
<string name="screen_share_this_location_action">"Jaga seda asukohta"</string>
<string name="screen_view_location_title">"Asukoht"</string>
<string name="settings_version_number">"Versioon: %1$s (%2$s)"</string>
<string name="test_language_identifier">"et"</string>
</resources>

View file

@ -197,6 +197,7 @@
<string name="common_search_results">"Résultats de la recherche"</string>
<string name="common_security">"Sécurité"</string>
<string name="common_seen_by">"Vu par"</string>
<string name="common_send_to">"Envoyer vers"</string>
<string name="common_sending">"Envoi en cours…"</string>
<string name="common_sending_failed">"Échec de lenvoi"</string>
<string name="common_sent">"Envoyé"</string>

View file

@ -83,6 +83,7 @@
<string name="action_quick_reply">"Resposta rápida"</string>
<string name="action_quote">"Citação"</string>
<string name="action_react">"Reagir"</string>
<string name="action_reject">"Rejeitar"</string>
<string name="action_remove">"Remover"</string>
<string name="action_reply">"Responder"</string>
<string name="action_reply_in_thread">"Responder ao tópico"</string>
@ -197,6 +198,7 @@
<string name="common_search_results">"Resultados da pesquisa"</string>
<string name="common_security">"Segurança"</string>
<string name="common_seen_by">"Vista por"</string>
<string name="common_send_to">"Enviar para"</string>
<string name="common_sending">"A enviar…"</string>
<string name="common_sending_failed">"Falha no envio"</string>
<string name="common_sent">"Enviada"</string>
@ -253,6 +255,7 @@
<string name="invite_friends_text">"Alô! Fala comigo na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Agita o dispositivo em fúria para comunicar um problema"</string>
<string name="screen_incoming_call_subtitle_android">"A receber chamada da Element "</string>
<string name="screen_media_picker_error_failed_selection">"Falha ao selecionar multimédia, por favor tente novamente."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Falha ao processar multimédia para carregamento, por favor tente novamente."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Falhar ao carregar multimédia, por favor tente novamente."</string>

View file

@ -85,6 +85,7 @@
<string name="action_quick_reply">"Быстрый ответ"</string>
<string name="action_quote">"Цитата"</string>
<string name="action_react">"Реакция"</string>
<string name="action_reject">"Отклонить"</string>
<string name="action_remove">"Удалить"</string>
<string name="action_reply">"Ответить"</string>
<string name="action_reply_in_thread">"Ответить в теме"</string>
@ -203,6 +204,7 @@
<string name="common_search_results">"Результаты поиска"</string>
<string name="common_security">"Безопасность"</string>
<string name="common_seen_by">"Просмотрено"</string>
<string name="common_send_to">"Отправить"</string>
<string name="common_sending">"Отправка…"</string>
<string name="common_sending_failed">"Сбой отправки"</string>
<string name="common_sent">"Отправлено"</string>
@ -259,6 +261,7 @@
<string name="invite_friends_text">"Привет, поговори со мной по %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Встряхните устройство, чтобы сообщить об ошибке"</string>
<string name="screen_incoming_call_subtitle_android">"Входящий вызов Element"</string>
<string name="screen_media_picker_error_failed_selection">"Не удалось выбрать носитель, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Не удалось обработать медиафайл для загрузки, попробуйте еще раз."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Не удалось загрузить медиафайлы, попробуйте еще раз."</string>

View file

@ -85,6 +85,7 @@
<string name="action_quick_reply">"Rýchla odpoveď"</string>
<string name="action_quote">"Citovať"</string>
<string name="action_react">"Reagovať"</string>
<string name="action_reject">"Odmietnuť"</string>
<string name="action_remove">"Odstrániť"</string>
<string name="action_reply">"Odpovedať"</string>
<string name="action_reply_in_thread">"Odpovedať vo vlákne"</string>
@ -201,6 +202,7 @@
<string name="common_search_results">"Výsledky hľadania"</string>
<string name="common_security">"Bezpečnosť"</string>
<string name="common_seen_by">"Videné"</string>
<string name="common_send_to">"Odoslať"</string>
<string name="common_sending">"Odosiela sa…"</string>
<string name="common_sending_failed">"Odoslanie zlyhalo"</string>
<string name="common_sent">"Odoslané"</string>
@ -257,6 +259,7 @@
<string name="invite_friends_text">"Ahoj, porozprávajte sa so mnou na %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Zúrivo potriasť pre nahlásenie chyby"</string>
<string name="screen_incoming_call_subtitle_android">"Prichádzajúci hovor Element Call"</string>
<string name="screen_media_picker_error_failed_selection">"Nepodarilo sa vybrať médium, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Nepodarilo sa spracovať médiá na odoslanie, skúste to prosím znova."</string>
<string name="screen_media_upload_preview_error_failed_sending">"Nepodarilo sa nahrať médiá, skúste to prosím znova."</string>

View file

@ -32,6 +32,7 @@
<string name="action_accept">"接受"</string>
<string name="action_add_to_timeline">"添加到时间线"</string>
<string name="action_back">"返回"</string>
<string name="action_call">"呼叫"</string>
<string name="action_cancel">"取消"</string>
<string name="action_choose_photo">"选择照片"</string>
<string name="action_clear">"清除"</string>
@ -70,6 +71,7 @@
<string name="action_load_more">"载入更多"</string>
<string name="action_manage_account">"管理账户"</string>
<string name="action_manage_devices">"管理设备"</string>
<string name="action_message">"发送消息给"</string>
<string name="action_next">"下一步"</string>
<string name="action_no">"否"</string>
<string name="action_not_now">"以后再说"</string>
@ -115,6 +117,7 @@
<string name="common_blocked_users">"已屏蔽用户"</string>
<string name="common_bubbles">"气泡"</string>
<string name="common_call_invite">"通话进行中(不支持)"</string>
<string name="common_call_started">"通话已开始"</string>
<string name="common_chat_backup">"聊天记录备份"</string>
<string name="common_copyright">"版权"</string>
<string name="common_creating_room">"正在创建房间…"</string>
@ -159,7 +162,7 @@
<string name="common_offline">"离线"</string>
<string name="common_or">"或"</string>
<string name="common_password">"密码"</string>
<string name="common_people">""</string>
<string name="common_people">"用户"</string>
<string name="common_permalink">"固定链接"</string>
<string name="common_permission">"权限"</string>
<string name="common_please_wait">"请稍候……"</string>

View file

@ -83,6 +83,7 @@
<string name="action_quick_reply">"Quick reply"</string>
<string name="action_quote">"Quote"</string>
<string name="action_react">"React"</string>
<string name="action_reject">"Reject"</string>
<string name="action_remove">"Remove"</string>
<string name="action_reply">"Reply"</string>
<string name="action_reply_in_thread">"Reply in thread"</string>
@ -197,6 +198,7 @@
<string name="common_search_results">"Search results"</string>
<string name="common_security">"Security"</string>
<string name="common_seen_by">"Seen by"</string>
<string name="common_send_to">"Send to"</string>
<string name="common_sending">"Sending…"</string>
<string name="common_sending_failed">"Sending failed"</string>
<string name="common_sent">"Sent"</string>
@ -253,6 +255,7 @@
<string name="invite_friends_text">"Hey, talk to me on %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake to report bug"</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<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>