Merge branch 'develop' into feature/fga/room-version-upgrade

This commit is contained in:
ganfra 2025-06-16 20:46:28 +02:00
commit e2b1ab2632
150 changed files with 2027 additions and 1121 deletions

View file

@ -16,7 +16,6 @@ import io.element.android.libraries.matrix.test.FakeMatrixClient
import io.element.android.libraries.matrix.test.auth.FakeMatrixAuthenticationService
import io.element.android.services.appnavstate.test.FakeAppForegroundStateService
import io.element.android.tests.testutils.testCoroutineDispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.test.TestScope
import kotlinx.coroutines.test.runTest
import org.junit.Test
@ -29,7 +28,6 @@ class MatrixSessionCacheTest {
assertThat(matrixSessionCache.getOrNull(A_SESSION_ID)).isNull()
}
@OptIn(ExperimentalCoroutinesApi::class)
@Test
fun `test getSyncOrchestratorOrNull`() = runTest {
val fakeAuthenticationService = FakeMatrixAuthenticationService()

View file

@ -44,6 +44,7 @@ import io.element.android.services.appnavstate.api.ActiveRoomsHolder
import io.element.android.services.appnavstate.api.AppForegroundStateService
import io.element.android.services.toolbox.api.systemclock.SystemClock
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.launchIn
import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.launch
@ -52,6 +53,7 @@ import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import timber.log.Timber
import java.util.UUID
import kotlin.time.Duration.Companion.seconds
class CallScreenPresenter @AssistedInject constructor(
@Assisted private val callType: CallType,
@ -165,6 +167,13 @@ class CallScreenPresenter @AssistedInject constructor(
// If the call was joined, we need to hang up first. Then the UI will be dismissed automatically.
sendHangupMessage(widgetId, interceptor)
isJoinedCall = false
coroutineScope.launch {
// Wait for a couple of seconds to receive the hangup message
// If we don't get it in time, we close the screen anyway
delay(2.seconds)
close(callWidgetDriver.value, navigator)
}
} else {
coroutineScope.launch {
close(callWidgetDriver.value, navigator)

View file

@ -38,6 +38,7 @@ import io.element.android.features.call.impl.pip.PictureInPictureEvents
import io.element.android.features.call.impl.pip.PictureInPictureState
import io.element.android.features.call.impl.pip.PictureInPictureStateProvider
import io.element.android.features.call.impl.pip.aPictureInPictureState
import io.element.android.features.call.impl.utils.InvalidAudioDeviceReason
import io.element.android.features.call.impl.utils.WebViewAudioManager
import io.element.android.features.call.impl.utils.WebViewPipController
import io.element.android.features.call.impl.utils.WebViewWidgetMessageInterceptor
@ -105,6 +106,14 @@ internal fun CallScreenView(
} else {
var webViewAudioManager by remember { mutableStateOf<WebViewAudioManager?>(null) }
val coroutineScope = rememberCoroutineScope()
var invalidAudioDeviceReason by remember { mutableStateOf<InvalidAudioDeviceReason?>(null) }
invalidAudioDeviceReason?.let {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = it) {
invalidAudioDeviceReason = null
}
}
CallWebView(
modifier = Modifier
.padding(padding)
@ -130,7 +139,11 @@ internal fun CallScreenView(
},
onError = { state.eventSink(CallScreenEvents.OnWebViewError(it)) },
)
webViewAudioManager = WebViewAudioManager(webView, coroutineScope)
webViewAudioManager = WebViewAudioManager(
webView = webView,
coroutineScope = coroutineScope,
onInvalidAudioDeviceAdded = { invalidAudioDeviceReason = it },
)
state.eventSink(CallScreenEvents.SetupMessageChannels(interceptor))
val pipController = WebViewPipController(webView)
pipState.eventSink(PictureInPictureEvents.SetPipController(pipController))
@ -157,6 +170,21 @@ internal fun CallScreenView(
}
}
@Composable
private fun InvalidAudioDeviceDialog(
invalidAudioDeviceReason: InvalidAudioDeviceReason,
onDismiss: () -> Unit,
) {
ErrorDialog(
content = when (invalidAudioDeviceReason) {
InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED -> {
stringResource(R.string.call_invalid_audio_device_bluetooth_devices_disabled)
}
},
onSubmit = onDismiss,
)
}
@Composable
private fun CallWebView(
url: AsyncData<String>,
@ -277,3 +305,9 @@ internal fun CallScreenPipViewPreview(
requestPermissions = { _, _ -> },
)
}
@PreviewsDayNight
@Composable
internal fun InvalidAudioDeviceDialogPreview() = ElementPreview {
InvalidAudioDeviceDialog(invalidAudioDeviceReason = InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED) {}
}

View file

@ -40,8 +40,22 @@ import kotlin.time.Duration.Companion.milliseconds
class WebViewAudioManager(
private val webView: WebView,
private val coroutineScope: CoroutineScope,
private val onInvalidAudioDeviceAdded: (InvalidAudioDeviceReason) -> Unit,
) {
// The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
/**
* Whether to disable bluetooth audio devices. This must be done on Android versions lower than Android 12,
* since the WebView approach breaks when using the legacy Bluetooth audio APIs.
*/
private val disableBluetoothAudioDevices = Build.VERSION.SDK_INT < Build.VERSION_CODES.S
/**
* This flag indicates whether the WebView audio is enabled or not. By default, it is enabled.
*/
private val isWebViewAudioEnabled = AtomicBoolean(true)
/**
* The list of device types that are considered as communication devices, sorted by likelihood of it being used for communication.
*/
private val wantedDeviceTypes = listOf(
// Paired bluetooth device with microphone
AudioDeviceInfo.TYPE_BLUETOOTH_SCO,
@ -60,6 +74,10 @@ class WebViewAudioManager(
private val audioManager = webView.context.getSystemService(Context.AUDIO_SERVICE) as AudioManager
/**
* This wake lock is used to turn off the screen when the proximity sensor is triggered during a call,
* if the selected audio device is the built-in earpiece.
*/
private val proximitySensorWakeLock by lazy {
webView.context.getSystemService<PowerManager>()
?.takeIf { it.isWakeLockLevelSupported(PowerManager.PROXIMITY_SCREEN_OFF_WAKE_LOCK) }
@ -296,12 +314,13 @@ class WebViewAudioManager(
* @param availableDevices The list of available audio devices to select from. If not provided, it will use the current list of audio devices.
*/
private fun selectDefaultAudioDevice(availableDevices: List<AudioDeviceInfo> = listAudioDevices()) {
val selectedDevice = availableDevices.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
val selectedDevice = availableDevices
.minByOrNull {
wantedDeviceTypes.indexOf(it.type).let { index ->
// If the device type is not in the wantedDeviceTypes list, we give it a low priority
if (index == -1) Int.MAX_VALUE else index
}
}
}
expectedNewCommunicationDeviceId = selectedDevice?.id
audioManager.selectAudioDevice(selectedDevice)
@ -361,6 +380,13 @@ class WebViewAudioManager(
// On Android 11 and lower, we don't have the concept of communication devices
// We have to call the right methods based on the device type
if (device != null) {
if (device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO && disableBluetoothAudioDevices) {
Timber.w("Bluetooth audio devices are disabled on this Android version")
setAudioEnabled(false)
onInvalidAudioDeviceAdded(InvalidAudioDeviceReason.BT_AUDIO_DEVICE_DISABLED)
return
}
setAudioEnabled(true)
isSpeakerphoneOn = device.type == AudioDeviceInfo.TYPE_BUILTIN_SPEAKER
isBluetoothScoOn = device.type == AudioDeviceInfo.TYPE_BLUETOOTH_SCO
} else {
@ -380,6 +406,19 @@ class WebViewAudioManager(
proximitySensorWakeLock?.release()
}
}
/**
* Sets whether the audio is enabled for Element Call in the WebView.
* It will only perform the change if the audio state has changed.
*/
private fun setAudioEnabled(enabled: Boolean) {
coroutineScope.launch(Dispatchers.Main) {
Timber.d("Setting audio enabled in Element Call: $enabled")
if (isWebViewAudioEnabled.getAndSet(enabled) != enabled) {
webView.evaluateJavascript("controls.setAudioEnabled($enabled);", null)
}
}
}
}
/**
@ -434,6 +473,10 @@ private fun isBuiltIn(type: Int): Boolean = when (type) {
else -> false
}
enum class InvalidAudioDeviceReason {
BT_AUDIO_DEVICE_DISABLED,
}
/**
* This class is used to serialize the audio device information to JSON.
*/

View file

@ -11,6 +11,10 @@ import android.webkit.WebView
import kotlin.coroutines.resume
import kotlin.coroutines.suspendCoroutine
/**
* Documentation about the `controls` command can be found here:
* https://github.com/element-hq/element-call/blob/livekit/docs/controls.md#picture-in-picture
*/
class WebViewPipController(
private val webView: WebView,
) : PipController {

View file

@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Appel en cours"</string>
<string name="call_foreground_service_message_android">"Cliquez pour retourner à lappel."</string>
<string name="call_foreground_service_title_android">"☎️ Appel en cours"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call ne prend pas en charge lutilisation daccessoires Bluetooth dans cette version dAndroid. Sélectionnez une autre sortie audio."</string>
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
</resources>

View file

@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Pågående samtale"</string>
<string name="call_foreground_service_message_android">"Trykk for å gå tilbake til samtalen"</string>
<string name="call_foreground_service_title_android">"☎️ Samtale pågår"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call støtter ikke bruk av Bluetooth-lydenheter i denne Android-versjonen. Velg en annen lydenhet."</string>
<string name="screen_incoming_call_subtitle_android">"Innkommende Element-anrop"</string>
</resources>

View file

@ -3,5 +3,6 @@
<string name="call_foreground_service_channel_title_android">"Ongoing call"</string>
<string name="call_foreground_service_message_android">"Tap to return to the call"</string>
<string name="call_foreground_service_title_android">"☎️ Call in progress"</string>
<string name="call_invalid_audio_device_bluetooth_devices_disabled">"Element Call does not support using Bluetooth audio devices in this Android version. Please select a different audio device."</string>
<string name="screen_incoming_call_subtitle_android">"Incoming Element Call"</string>
</resources>

View file

@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalComposeUiApi::class)
package io.element.android.features.logout.impl
import androidx.compose.foundation.clickable
@ -32,7 +30,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.graphics.Color

View file

@ -29,7 +29,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.focus.FocusDirection
@ -170,7 +169,6 @@ fun LoginPasswordView(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun LoginForm(
state: LoginPasswordState,

View file

@ -89,5 +89,6 @@ Prøv å logge på manuelt, eller skann QR-koden med en annen enhet."</string>
<string name="screen_server_confirmation_message_login_matrix_dot_org">"Matrix er et åpent nettverk for sikker, desentralisert kommunikasjon."</string>
<string name="screen_server_confirmation_message_register">"Det er her samtalene dine vil ligge - akkurat som du ville brukt en e-postleverandør til å oppbevare e-postene dine."</string>
<string name="screen_server_confirmation_title_login">"Du er i ferd med å logge inn på %1$s"</string>
<string name="screen_server_confirmation_title_picker_mode">"Velg kontoleverandør"</string>
<string name="screen_server_confirmation_title_register">"Du er i ferd med å opprette en konto på %1$s"</string>
</resources>

View file

@ -76,8 +76,6 @@ import io.element.android.libraries.testtags.testTag
import io.element.android.libraries.ui.strings.CommonStrings
import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.FlowPreview
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collectLatest
import kotlinx.coroutines.flow.combine
@ -225,7 +223,6 @@ private fun MessageShieldDialog(state: TimelineState) {
)
}
@OptIn(ExperimentalCoroutinesApi::class, FlowPreview::class)
@Composable
private fun TimelinePrefetchingHelper(
lazyListState: LazyListState,

View file

@ -21,7 +21,6 @@ internal fun ATimelineItemEventRow(
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
renderReadReceipts: Boolean = false,
isLastOutgoingMessage: Boolean = false,
isHighlighted: Boolean = false,
timelineProtectionState: TimelineProtectionState = aTimelineProtectionState(),
) = TimelineItemEventRow(
event = event,
@ -29,7 +28,6 @@ internal fun ATimelineItemEventRow(
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = isHighlighted,
onEventClick = {},
onLongClick = {},
onLinkClick = {},

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
@ -59,7 +58,6 @@ private val avatarRadius = AvatarSize.TimelineSender.dp / 2
private const val BUBBLE_WIDTH_RATIO = 0.78f
private val MIN_BUBBLE_WIDTH = 80.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageEventBubble(
state: BubbleState,
@ -184,7 +182,7 @@ internal fun MessageEventBubblePreview(@PreviewParameter(BubbleStateProvider::cl
contentAlignment = Alignment.Center,
) {
Text(
text = "${state.groupPosition.javaClass.simpleName} m:${state.isMine.to01()} h:${state.isHighlighted.to01()}",
text = "${state.groupPosition.javaClass.simpleName} isMine:${state.isMine.to01()}",
style = ElementTheme.typography.fontBodyXsRegular,
)
}

View file

@ -7,12 +7,8 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.layout.widthIn
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
@ -28,17 +24,14 @@ import io.element.android.libraries.designsystem.theme.components.Surface
private val CORNER_RADIUS = 8.dp
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun MessageStateEventContainer(
@Suppress("UNUSED_PARAMETER") isHighlighted: Boolean,
interactionSource: MutableInteractionSource,
onClick: () -> Unit,
onLongClick: () -> Unit,
modifier: Modifier = Modifier,
content: @Composable () -> Unit = {},
) {
// Ignore isHighlighted for now, we need a design decision on it.
val backgroundColor = Color.Transparent
val shape = RoundedCornerShape(CORNER_RADIUS)
Surface(
@ -60,22 +53,9 @@ fun MessageStateEventContainer(
@PreviewsDayNight
@Composable
internal fun MessageStateEventContainerPreview() = ElementPreview {
Column {
MessageStateEventContainer(
isHighlighted = false,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = {},
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
MessageStateEventContainer(
isHighlighted = true,
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = {},
) {
Spacer(modifier = Modifier.size(width = 120.dp, height = 32.dp))
}
}
MessageStateEventContainer(
interactionSource = remember { MutableInteractionSource() },
onClick = {},
onLongClick = {},
)
}

View file

@ -9,7 +9,6 @@ package io.element.android.features.messages.impl.timeline.components
import androidx.annotation.DrawableRes
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
@ -50,7 +49,6 @@ import io.element.android.libraries.matrix.api.media.MediaSource
import io.element.android.libraries.matrix.ui.media.MediaRequestData
@Composable
@OptIn(ExperimentalFoundationApi::class)
@Suppress("ModifierClickableOrder") // This is needed to display the right ripple shape
fun MessagesReactionButton(
onClick: () -> Unit,

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Arrangement
@ -39,7 +38,6 @@ import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.text.toDp
import io.element.android.libraries.ui.strings.CommonStrings
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TimelineItemCallNotifyView(
event: TimelineItem.Event,

View file

@ -32,7 +32,6 @@ import androidx.compose.runtime.movableContentOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
import androidx.compose.ui.platform.LocalViewConfiguration
@ -120,7 +119,6 @@ fun TimelineItemEventRow(
timelineProtectionState: TimelineProtectionState,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onEventClick: () -> Unit,
onLongClick: () -> Unit,
onLinkClick: (Link) -> Unit,
@ -196,7 +194,6 @@ fun TimelineItemEventRow(
TimelineItemEventRowContent(
event = event,
timelineProtectionState = timelineProtectionState,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onContentClick = onContentClick,
@ -230,7 +227,6 @@ fun TimelineItemEventRow(
TimelineItemEventRowContent(
event = event,
timelineProtectionState = timelineProtectionState,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
interactionSource = interactionSource,
onContentClick = onContentClick,
@ -281,12 +277,10 @@ private fun SwipeSensitivity(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun TimelineItemEventRowContent(
event: TimelineItem.Event,
timelineProtectionState: TimelineProtectionState,
isHighlighted: Boolean,
timelineRoomInfo: TimelineRoomInfo,
interactionSource: MutableInteractionSource,
onContentClick: () -> Unit,
@ -340,7 +334,6 @@ private fun TimelineItemEventRowContent(
val bubbleState = BubbleState(
groupPosition = event.groupPosition,
isMine = event.isMine,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
)
MessageEventBubble(

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxWidth
@ -21,6 +20,7 @@ import androidx.compose.ui.geometry.Size
import androidx.compose.ui.graphics.Brush
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.unit.Dp
import androidx.compose.ui.unit.dp
@ -32,6 +32,7 @@ import io.element.android.features.messages.impl.timeline.components.layout.Cont
import io.element.android.features.messages.impl.timeline.model.TimelineItem
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemCallNotifyContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemLegacyCallInviteContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemPollContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemStateContent
import io.element.android.features.messages.impl.timeline.model.event.TimelineItemVoiceContent
import io.element.android.features.messages.impl.timeline.protection.TimelineProtectionEvent
@ -48,7 +49,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.link.Link
import kotlin.time.DurationUnit
@OptIn(ExperimentalFoundationApi::class)
@Composable
internal fun TimelineItemRow(
timelineItem: TimelineItem,
@ -113,7 +113,6 @@ internal fun TimelineItemRow(
event = timelineItem,
renderReadReceipts = renderReadReceipts,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onClick = { onContentClick(timelineItem) },
onReadReceiptsClick = onReadReceiptClick,
onLongClick = { onLongClick(timelineItem) },
@ -140,6 +139,8 @@ internal fun TimelineItemRow(
} else {
timelineItem.safeSenderName
}
// For Polls, allow the answers to be traversed by Talkback
isTraversalGroup = timelineItem.content is TimelineItemPollContent
}
// Custom clickable that applies over the whole item for accessibility
.then(
@ -157,7 +158,6 @@ internal fun TimelineItemRow(
renderReadReceipts = renderReadReceipts,
timelineProtectionState = timelineProtectionState,
isLastOutgoingMessage = isLastOutgoingMessage,
isHighlighted = timelineItem.isEvent(focusedEventId),
onEventClick = { onContentClick(timelineItem) },
onLongClick = { onLongClick(timelineItem) },
onLinkClick = onLinkClick,

View file

@ -40,7 +40,6 @@ fun TimelineItemStateEventRow(
event: TimelineItem.Event,
renderReadReceipts: Boolean,
isLastOutgoingMessage: Boolean,
isHighlighted: Boolean,
onClick: () -> Unit,
onLongClick: () -> Unit,
onReadReceiptsClick: (event: TimelineItem.Event) -> Unit,
@ -60,7 +59,6 @@ fun TimelineItemStateEventRow(
contentAlignment = Alignment.Center
) {
MessageStateEventContainer(
isHighlighted = isHighlighted,
interactionSource = interactionSource,
onClick = onClick,
onLongClick = onLongClick,
@ -107,7 +105,6 @@ internal fun TimelineItemStateEventRowPreview() = ElementPreview {
),
renderReadReceipts = true,
isLastOutgoingMessage = false,
isHighlighted = false,
onClick = {},
onLongClick = {},
onReadReceiptsClick = {},

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@ -57,7 +56,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemImageView(
content: TimelineItemImageContent,

View file

@ -7,7 +7,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@ -39,7 +38,6 @@ import io.element.android.libraries.ui.strings.CommonStrings
private const val STICKER_SIZE_IN_DP = 128
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemStickerView(
content: TimelineItemStickerContent,

View file

@ -8,7 +8,6 @@
package io.element.android.features.messages.impl.timeline.components.event
import android.text.SpannedString
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.Image
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
@ -67,7 +66,6 @@ import io.element.android.libraries.ui.utils.time.isTalkbackActive
import io.element.android.wysiwyg.compose.EditorStyledText
import io.element.android.wysiwyg.link.Link
@OptIn(ExperimentalFoundationApi::class)
@Composable
fun TimelineItemVideoView(
content: TimelineItemVideoContent,

View file

@ -13,7 +13,6 @@ import io.element.android.features.messages.impl.timeline.model.TimelineItemGrou
data class BubbleState(
val groupPosition: TimelineItemGroupPosition,
val isMine: Boolean,
val isHighlighted: Boolean,
val timelineRoomInfo: TimelineRoomInfo,
) {
/** True to cut out the top start corner of the bubble, to give margin for the sender avatar. */

View file

@ -21,15 +21,11 @@ open class BubbleStateProvider : PreviewParameterProvider<BubbleState> {
TimelineItemGroupPosition.None,
).map { groupPosition ->
sequenceOf(false, true).map { isMine ->
sequenceOf(false, true).map { isHighlighted ->
aBubbleState(
groupPosition = groupPosition,
isMine = isMine,
isHighlighted = isHighlighted,
)
}
aBubbleState(
groupPosition = groupPosition,
isMine = isMine,
)
}
.flatten()
}
.flatten()
}
@ -37,11 +33,9 @@ open class BubbleStateProvider : PreviewParameterProvider<BubbleState> {
internal fun aBubbleState(
groupPosition: TimelineItemGroupPosition = TimelineItemGroupPosition.First,
isMine: Boolean = false,
isHighlighted: Boolean = false,
timelineRoomInfo: TimelineRoomInfo = aTimelineRoomInfo(),
) = BubbleState(
groupPosition = groupPosition,
isMine = isMine,
isHighlighted = isHighlighted,
timelineRoomInfo = timelineRoomInfo,
)

View file

@ -35,6 +35,15 @@
<string name="screen_room_timeline_less_reactions">"Afficher moins"</string>
<string name="screen_room_timeline_message_copied">"Message copié"</string>
<string name="screen_room_timeline_no_permission_to_post">"Vous nêtes pas autorisé à publier dans ce salon"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d membre a réagi avec %2$s"</item>
<item quantity="other">"%1$d membres ont réagi avec %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Vous et %1$d membre avez réagi avec %2$s"</item>
<item quantity="other">"Vous et %1$d membres avez réagi avec %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Vous avez réagi avec %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Afficher moins"</string>
<string name="screen_room_timeline_reactions_show_more">"Afficher plus"</string>
<string name="screen_room_timeline_read_marker_title">"Nouveau"</string>

View file

@ -35,6 +35,15 @@
<string name="screen_room_timeline_less_reactions">"Vis mindre"</string>
<string name="screen_room_timeline_message_copied">"Melding kopiert"</string>
<string name="screen_room_timeline_no_permission_to_post">"Du har ikke tillatelse til å legge ut innlegg i dette rommet"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d medlem reagerte med %2$s"</item>
<item quantity="other">"%1$d medlemmer reagerte med %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"Du og %1$d medlem reagerte med%2$s"</item>
<item quantity="other">"Du og %1$d medlemmer reagerte med%2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"Du reagerte med %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Vis mindre"</string>
<string name="screen_room_timeline_reactions_show_more">"Vis mer"</string>
<string name="screen_room_timeline_read_marker_title">"Ny"</string>

View file

@ -28,13 +28,22 @@
<string name="screen_room_mentions_at_room_title">"Everyone"</string>
<string name="screen_room_retry_send_menu_send_again_action">"Send again"</string>
<string name="screen_room_retry_send_menu_title">"Your message failed to send"</string>
<string name="screen_room_timeline_add_reaction">"Add emoji"</string>
<string name="screen_room_timeline_add_reaction">"Add a reaction"</string>
<string name="screen_room_timeline_beginning_of_room">"This is the beginning of %1$s."</string>
<string name="screen_room_timeline_beginning_of_room_no_name">"This is the beginning of this conversation."</string>
<string name="screen_room_timeline_legacy_call">"Unsupported call. Ask if the caller can use the new Element X app."</string>
<string name="screen_room_timeline_less_reactions">"Show less"</string>
<string name="screen_room_timeline_message_copied">"Message copied"</string>
<string name="screen_room_timeline_no_permission_to_post">"You do not have permission to post to this room"</string>
<plurals name="screen_room_timeline_reaction_a11y">
<item quantity="one">"%1$d member reacted with %2$s"</item>
<item quantity="other">"%1$d members reacted with %2$s"</item>
</plurals>
<plurals name="screen_room_timeline_reaction_including_you_a11y">
<item quantity="one">"You and %1$d member reacted with %2$s"</item>
<item quantity="other">"You and %1$d members reacted with %2$s"</item>
</plurals>
<string name="screen_room_timeline_reaction_you_a11y">"You reacted with %1$s"</string>
<string name="screen_room_timeline_reactions_show_less">"Show less"</string>
<string name="screen_room_timeline_reactions_show_more">"Show more"</string>
<string name="screen_room_timeline_read_marker_title">"New"</string>

View file

@ -47,7 +47,10 @@ class TimelineItemPollViewTest {
)
}
val answer = content.answerItems[answerIndex].answer
rule.onNode(hasText(answer.text)).performClick()
rule.onNode(
matcher = hasText(answer.text),
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(TimelineEvents.SelectPollAnswer(content.eventId!!, answer.id))
}

View file

@ -20,9 +20,13 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.StrokeCap
import androidx.compose.ui.res.pluralStringResource
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
import io.element.android.features.poll.api.R
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.designsystem.theme.components.Icon
@ -32,14 +36,42 @@ import io.element.android.libraries.designsystem.theme.progressIndicatorTrackCol
import io.element.android.libraries.designsystem.toEnabledColor
import io.element.android.libraries.designsystem.utils.CommonDrawables
import io.element.android.libraries.ui.strings.CommonPlurals
import io.element.android.libraries.ui.strings.CommonStrings
@Composable
internal fun PollAnswerView(
answerItem: PollAnswerItem,
modifier: Modifier = Modifier,
) {
val nbVotesText = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount,
)
val a11yText = buildString {
val sentenceDelimiter = stringResource(CommonStrings.common_sentence_delimiter)
append(answerItem.answer.text.removeSuffix("."))
if (answerItem.showVotes) {
append(sentenceDelimiter)
append(nbVotesText)
if (answerItem.votesCount != 0) {
append(sentenceDelimiter)
(answerItem.percentage * 100).toInt().let { percent ->
append(pluralStringResource(R.plurals.a11y_polls_percent_of_total, percent, percent))
}
}
if (answerItem.isWinner) {
append(sentenceDelimiter)
append(stringResource(R.string.a11y_polls_winning_answer))
}
}
}
Row(
modifier = modifier.fillMaxWidth(),
modifier = modifier
.fillMaxWidth()
.clearAndSetSemantics {
contentDescription = a11yText
},
) {
Icon(
imageVector = if (answerItem.isSelected) {
@ -70,11 +102,6 @@ internal fun PollAnswerView(
style = if (answerItem.isWinner) ElementTheme.typography.fontBodyLgMedium else ElementTheme.typography.fontBodyLgRegular,
)
if (answerItem.showVotes) {
val text = pluralStringResource(
id = CommonPlurals.common_poll_votes_count,
count = answerItem.votesCount,
answerItem.votesCount
)
Row(
modifier = Modifier.align(Alignment.Bottom),
verticalAlignment = Alignment.CenterVertically,
@ -87,13 +114,13 @@ internal fun PollAnswerView(
)
Spacer(modifier = Modifier.width(2.dp))
Text(
text = text,
text = nbVotesText,
style = ElementTheme.typography.fontBodySmMedium,
color = ElementTheme.colors.textPrimary,
)
} else {
Text(
text = text,
text = nbVotesText,
style = ElementTheme.typography.fontBodySmRegular,
color = ElementTheme.colors.textSecondary,
)

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d pour cent du total des votes"</item>
<item quantity="other">"%1$d pour cent du total des votes"</item>
</plurals>
<string name="a11y_polls_winning_answer">"Cest la réponse gagnante"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d prosent av totalt antall stemmer"</item>
<item quantity="other">"%1$d prosent av totalt antall stemmer"</item>
</plurals>
<string name="a11y_polls_winning_answer">"Dette er vinnersvaret"</string>
</resources>

View file

@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<plurals name="a11y_polls_percent_of_total">
<item quantity="one">"%1$d percent of total votes"</item>
<item quantity="other">"%1$d percents of total votes"</item>
</plurals>
<string name="a11y_polls_winning_answer">"This is the winning answer"</string>
</resources>

View file

@ -143,7 +143,7 @@ fun CreatePollView(
trailingContent = ListItemContent.Custom {
Icon(
imageVector = CompoundIcons.Delete(),
contentDescription = null,
contentDescription = stringResource(R.string.screen_create_poll_delete_option_a11y, answer.text),
modifier = Modifier.clickable(answer.canDelete) {
state.eventSink(CreatePollEvents.RemoveAnswer(index))
},

View file

@ -8,7 +8,6 @@
package io.element.android.features.poll.impl.history
import androidx.compose.foundation.BorderStroke
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Spacer
@ -30,6 +29,8 @@ import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import androidx.compose.ui.semantics.isTraversalGroup
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.text.style.TextAlign
import androidx.compose.ui.tooling.preview.PreviewParameter
import androidx.compose.ui.unit.dp
@ -52,7 +53,7 @@ import io.element.android.libraries.matrix.api.core.EventId
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun PollHistoryView(
state: PollHistoryState,
@ -179,7 +180,9 @@ private fun PollHistoryList(
if (pollHistoryItems.isEmpty()) {
item {
Column(
modifier = Modifier.fillParentMaxSize().padding(bottom = 24.dp),
modifier = Modifier
.fillParentMaxSize()
.padding(bottom = 24.dp),
verticalArrangement = Arrangement.Center,
horizontalAlignment = Alignment.CenterHorizontally,
) {
@ -192,7 +195,9 @@ private fun PollHistoryList(
text = emptyStringResource,
style = ElementTheme.typography.fontBodyLgRegular,
color = ElementTheme.colors.textSecondary,
modifier = Modifier.fillMaxWidth().padding(vertical = 24.dp, horizontal = 16.dp),
modifier = Modifier
.fillMaxWidth()
.padding(vertical = 24.dp, horizontal = 16.dp),
textAlign = TextAlign.Center,
)
@ -228,7 +233,10 @@ private fun PollHistoryItemRow(
modifier: Modifier = Modifier,
) {
Surface(
modifier = modifier,
modifier = modifier.semantics(mergeDescendants = true) {
// Allow the answers to be traversed by Talkback
isTraversalGroup = true
},
border = BorderStroke(1.dp, ElementTheme.colors.borderInteractiveSecondary),
shape = RoundedCornerShape(size = 12.dp)
) {

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Masquer les votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Vos modifications nont pas été enregistrées. Êtes-vous certain de vouloir quitter ?"</string>
<string name="screen_create_poll_delete_option_a11y">"Supprimer loption %1$s"</string>
<string name="screen_create_poll_question_desc">"Question ou sujet"</string>
<string name="screen_create_poll_question_hint">"Quel est le sujet du sondage ?"</string>
<string name="screen_create_poll_title">"Créer un sondage"</string>

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Skjul stemmer"</string>
<string name="screen_create_poll_answer_hint">"Alternativ %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Endringene dine er ikke lagret. Er du sikker på at du vil gå tilbake?"</string>
<string name="screen_create_poll_delete_option_a11y">"Slett alternativet %1$s"</string>
<string name="screen_create_poll_question_desc">"Spørsmål eller emne"</string>
<string name="screen_create_poll_question_hint">"Hva handler avstemningen om?"</string>
<string name="screen_create_poll_title">"Opprett avstemning"</string>

View file

@ -5,6 +5,7 @@
<string name="screen_create_poll_anonymous_headline">"Hide votes"</string>
<string name="screen_create_poll_answer_hint">"Option %1$d"</string>
<string name="screen_create_poll_cancel_confirmation_content_android">"Your changes have not been saved. Are you sure you want to go back?"</string>
<string name="screen_create_poll_delete_option_a11y">"Delete option %1$s"</string>
<string name="screen_create_poll_question_desc">"Question or topic"</string>
<string name="screen_create_poll_question_hint">"What is the poll about?"</string>
<string name="screen_create_poll_title">"Create Poll"</string>

View file

@ -131,7 +131,10 @@ class PollHistoryViewTest {
rule.setPollHistoryViewView(
state = state,
)
rule.onNodeWithText(answer.text).performClick()
rule.onNodeWithText(
text = answer.text,
useUnmergedTree = true,
).performClick()
eventsRecorder.assertSingle(
PollHistoryEvents.SelectPollAnswer(eventId, answer.id)
)

View file

@ -38,6 +38,7 @@ import io.element.android.libraries.designsystem.theme.components.IconSource
import io.element.android.libraries.designsystem.theme.components.ListItem
import io.element.android.libraries.designsystem.theme.components.Text
import io.element.android.libraries.designsystem.utils.OnLifecycleEvent
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.matrix.api.room.RoomNotificationMode
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.toImmutableList
@ -149,7 +150,7 @@ private fun NotificationSettingsContentView(
Text(stringResource(R.string.full_screen_intent_banner_message))
},
onClick = {
state.fullScreenIntentPermissionsState.openFullScreenIntentSettings()
state.fullScreenIntentPermissionsState.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
}
)
}

View file

@ -12,7 +12,6 @@ import androidx.compose.animation.expandVertically
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.animation.shrinkVertically
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Arrangement
@ -136,7 +135,6 @@ fun RoomMemberListView(
}
}
@OptIn(ExperimentalMaterial3Api::class, ExperimentalFoundationApi::class)
@Composable
private fun RoomMemberList(
roomMembers: AsyncData<RoomMembers>,

View file

@ -33,7 +33,6 @@ import io.element.android.libraries.matrix.api.room.JoinedRoom
import io.element.android.libraries.matrix.ui.room.getRoomMemberAsState
import io.element.android.libraries.matrix.ui.room.roomMemberIdentityStateChange
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.flow.filterNotNull
import kotlinx.coroutines.flow.map
import kotlinx.coroutines.launch
@ -55,7 +54,6 @@ class RoomMemberDetailsPresenter @AssistedInject constructor(
private val userProfilePresenter = userProfilePresenterFactory.create(roomMemberId)
@OptIn(ExperimentalCoroutinesApi::class)
@Composable
override fun present(): UserProfileState {
val coroutineScope = rememberCoroutineScope()

View file

@ -11,7 +11,6 @@ import androidx.activity.compose.BackHandler
import androidx.compose.animation.AnimatedVisibility
import androidx.compose.animation.fadeIn
import androidx.compose.animation.fadeOut
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.clickable
import androidx.compose.foundation.layout.Box
@ -225,7 +224,6 @@ fun ChangeRolesView(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun SearchResultsList(
currentRole: RoomMember.Role,

View file

@ -12,6 +12,8 @@ import io.element.android.features.roomlist.impl.model.RoomListRoomSummary
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.matrix.api.core.RoomId
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toPersistentSet
@ -31,10 +33,12 @@ internal fun aRoomsContentState(
securityBannerState: SecurityBannerState = SecurityBannerState.None,
summaries: ImmutableList<RoomListRoomSummary> = aRoomListRoomSummaryList(),
fullScreenIntentPermissionsState: FullScreenIntentPermissionsState = aFullScreenIntentPermissionsState(),
batteryOptimizationState: BatteryOptimizationState = aBatteryOptimizationState(),
seenRoomInvites: Set<RoomId> = emptySet(),
) = RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsState,
batteryOptimizationState = batteryOptimizationState,
summaries = summaries,
seenRoomInvites = seenRoomInvites.toPersistentSet(),
)

View file

@ -52,6 +52,7 @@ import io.element.android.libraries.matrix.api.sync.SyncService
import io.element.android.libraries.matrix.api.timeline.ReceiptType
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
import io.element.android.services.analyticsproviders.api.trackers.captureInteraction
@ -88,6 +89,7 @@ class RoomListPresenter @Inject constructor(
private val analyticsService: AnalyticsService,
private val acceptDeclineInvitePresenter: Presenter<AcceptDeclineInviteState>,
private val fullScreenIntentPermissionsPresenter: Presenter<FullScreenIntentPermissionsState>,
private val batteryOptimizationPresenter: Presenter<BatteryOptimizationState>,
private val notificationCleaner: NotificationCleaner,
private val logoutPresenter: Presenter<DirectLogoutState>,
private val appPreferencesStore: AppPreferencesStore,
@ -248,6 +250,7 @@ class RoomListPresenter @Inject constructor(
RoomListContentState.Rooms(
securityBannerState = securityBannerState,
fullScreenIntentPermissionsState = fullScreenIntentPermissionsPresenter.present(),
batteryOptimizationState = batteryOptimizationPresenter.present(),
summaries = roomSummaries.dataOrNull().orEmpty().toPersistentList(),
seenRoomInvites = seenRoomInvites.toPersistentSet(),
)

View file

@ -18,6 +18,7 @@ import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
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.battery.BatteryOptimizationState
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.ImmutableSet
@ -78,6 +79,7 @@ sealed interface RoomListContentState {
data class Rooms(
val securityBannerState: SecurityBannerState,
val fullScreenIntentPermissionsState: FullScreenIntentPermissionsState,
val batteryOptimizationState: BatteryOptimizationState,
val summaries: ImmutableList<RoomListRoomSummary>,
val seenRoomInvites: ImmutableSet<RoomId>,
) : RoomListContentState

View file

@ -27,6 +27,7 @@ import io.element.android.libraries.designsystem.components.avatar.AvatarSize
import io.element.android.libraries.designsystem.utils.snackbar.SnackbarMessage
import io.element.android.libraries.matrix.api.core.UserId
import io.element.android.libraries.matrix.api.user.MatrixUser
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.ui.strings.CommonStrings
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
@ -45,6 +46,7 @@ open class RoomListStateProvider : PreviewParameterProvider<RoomListState> {
aRoomListState(contentState = aSkeletonContentState()),
aRoomListState(searchState = aRoomListSearchState(isSearchActive = true, query = "Test")),
aRoomListState(contentState = aRoomsContentState(securityBannerState = SecurityBannerState.SetUpRecovery)),
aRoomListState(contentState = aRoomsContentState(batteryOptimizationState = aBatteryOptimizationState(shouldDisplayBanner = true))),
)
}

View file

@ -0,0 +1,45 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.features.roomlist.impl.components
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.res.stringResource
import io.element.android.features.roomlist.impl.R
import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
@Composable
internal fun BatteryOptimizationBanner(
state: BatteryOptimizationState,
modifier: Modifier = Modifier,
) {
Announcement(
modifier = modifier.roomListBannerPadding(),
title = stringResource(R.string.banner_battery_optimization_title_android),
description = stringResource(R.string.banner_battery_optimization_content_android),
type = AnnouncementType.Actionable(
actionText = stringResource(R.string.banner_battery_optimization_submit_android),
onActionClick = { state.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations) },
onDismissClick = { state.eventSink(BatteryOptimizationEvents.Dismiss) },
),
)
}
@PreviewsDayNight
@Composable
internal fun BatteryOptimizationBannerPreview() = ElementPreview {
BatteryOptimizationBanner(
state = aBatteryOptimizationState(),
)
}

View file

@ -15,6 +15,7 @@ import io.element.android.libraries.designsystem.components.Announcement
import io.element.android.libraries.designsystem.components.AnnouncementType
import io.element.android.libraries.designsystem.preview.ElementPreview
import io.element.android.libraries.designsystem.preview.PreviewsDayNight
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.fullscreenintent.api.aFullScreenIntentPermissionsState
import io.element.android.libraries.ui.strings.CommonStrings
@ -29,8 +30,8 @@ fun FullScreenIntentPermissionBanner(
description = stringResource(R.string.full_screen_intent_banner_message),
type = AnnouncementType.Actionable(
actionText = stringResource(CommonStrings.action_continue),
onDismissClick = state.dismissFullScreenIntentBanner,
onActionClick = state.openFullScreenIntentSettings,
onDismissClick = { state.eventSink(FullScreenIntentPermissionsEvents.Dismiss) },
onActionClick = { state.eventSink(FullScreenIntentPermissionsEvents.OpenSettings) },
),
modifier = modifier.roomListBannerPadding(),
)

View file

@ -149,7 +149,7 @@ private fun EmptyView(
onDismissClick = { eventSink(RoomListEvents.DismissBanner) },
)
}
else -> Unit
SecurityBannerState.None -> Unit
}
}
}
@ -234,6 +234,10 @@ private fun RoomsViewList(
item {
FullScreenIntentPermissionBanner(state = state.fullScreenIntentPermissionsState)
}
} else if (state.batteryOptimizationState.shouldDisplayBanner) {
item {
BatteryOptimizationBanner(state = state.batteryOptimizationState)
}
}
}

View file

@ -7,7 +7,6 @@
package io.element.android.features.roomlist.impl.components
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Arrangement
@ -161,7 +160,6 @@ internal fun RoomSummaryRow(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun RoomSummaryScaffoldRow(
room: RoomListRoomSummary,

View file

@ -1,5 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<resources xmlns:xliff="urn:oasis:names:tc:xliff:document:1.2">
<string name="banner_battery_optimization_content_android">"Disable battery optimization for this app, to make sure all notifications are received."</string>
<string name="banner_battery_optimization_submit_android">"Disable optimization"</string>
<string name="banner_battery_optimization_title_android">"Notifications not arriving?"</string>
<string name="banner_set_up_recovery_content">"Recover your cryptographic identity and message history with a recovery key if you have lost all your existing devices."</string>
<string name="banner_set_up_recovery_submit">"Set up recovery"</string>
<string name="banner_set_up_recovery_title">"Set up recovery to protect your account"</string>

View file

@ -75,6 +75,7 @@ import io.element.android.libraries.preferences.api.store.AppPreferencesStore
import io.element.android.libraries.preferences.api.store.SessionPreferencesStore
import io.element.android.libraries.preferences.test.InMemoryAppPreferencesStore
import io.element.android.libraries.preferences.test.InMemorySessionPreferencesStore
import io.element.android.libraries.push.api.battery.aBatteryOptimizationState
import io.element.android.libraries.push.api.notifications.NotificationCleaner
import io.element.android.libraries.push.test.notifications.FakeNotificationCleaner
import io.element.android.services.analytics.api.AnalyticsService
@ -712,6 +713,7 @@ class RoomListPresenterTest {
analyticsService = analyticsService,
acceptDeclineInvitePresenter = acceptDeclineInvitePresenter,
fullScreenIntentPermissionsPresenter = { aFullScreenIntentPermissionsState() },
batteryOptimizationPresenter = { aBatteryOptimizationState() },
notificationCleaner = notificationCleaner,
logoutPresenter = { aDirectLogoutState() },
appPreferencesStore = appPreferencesStore,

View file

@ -5,8 +5,6 @@
* Please see LICENSE files in the repository root for full details.
*/
@file:OptIn(ExperimentalCoroutinesApi::class)
package io.element.android.features.roomlist.impl
import androidx.activity.ComponentActivity
@ -30,7 +28,6 @@ import io.element.android.tests.testutils.clickOn
import io.element.android.tests.testutils.ensureCalledOnce
import io.element.android.tests.testutils.ensureCalledOnceWithParam
import io.element.android.tests.testutils.setSafeContent
import kotlinx.coroutines.ExperimentalCoroutinesApi
import org.junit.Rule
import org.junit.Test
import org.junit.rules.TestRule

View file

@ -7,7 +7,6 @@
package io.element.android.features.securebackup.impl.enter
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.layout.ColumnScope
import androidx.compose.foundation.layout.ExperimentalLayoutApi
import androidx.compose.foundation.layout.WindowInsets
@ -71,7 +70,7 @@ fun SecureBackupEnterRecoveryKeyView(
}
}
@OptIn(ExperimentalFoundationApi::class, ExperimentalLayoutApi::class)
@OptIn(ExperimentalLayoutApi::class)
@Composable
private fun Content(
state: SecureBackupEnterRecoveryKeyState,

View file

@ -22,7 +22,6 @@ import androidx.compose.foundation.text.KeyboardOptions
import androidx.compose.runtime.Composable
import androidx.compose.runtime.remember
import androidx.compose.ui.Alignment
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.autofill.ContentType
import androidx.compose.ui.draw.alpha
@ -169,7 +168,6 @@ private fun RecoveryKeyWithCopy(
}
}
@OptIn(ExperimentalComposeUiApi::class)
@Composable
private fun RecoveryKeyFormContent(
state: RecoveryKeyViewState,

View file

@ -147,7 +147,7 @@ test_junit = "junit:junit:4.13.2"
test_runner = "androidx.test:runner:1.6.2"
test_mockk = "io.mockk:mockk:1.14.2"
test_konsist = "com.lemonappdev:konsist:0.17.3"
test_turbine = "app.cash.turbine:turbine:1.2.0"
test_turbine = "app.cash.turbine:turbine:1.2.1"
test_truth = "com.google.truth:truth:1.4.4"
test_parameter_injector = "com.google.testparameterinjector:test-parameter-injector:1.18"
test_robolectric = "org.robolectric:robolectric:4.14.1"
@ -186,14 +186,14 @@ vanniktech_blurhash = "com.vanniktech:blurhash:0.3.0"
telephoto_zoomableimage = { module = "me.saket.telephoto:zoomable-image-coil", version.ref = "telephoto" }
telephoto_flick = { module = "me.saket.telephoto:flick-android", version.ref = "telephoto" }
statemachine = "com.freeletics.flowredux:compose:1.2.2"
maplibre = "org.maplibre.gl:android-sdk:11.10.2"
maplibre = "org.maplibre.gl:android-sdk:11.10.3"
maplibre_ktx = "org.maplibre.gl:android-sdk-ktx-v7:3.0.2"
maplibre_annotation = "org.maplibre.gl:android-plugin-annotation-v9:3.0.2"
opusencoder = "io.element.android:opusencoder:1.2.0"
zxing_cpp = "io.github.zxing-cpp:android:2.3.0"
# Analytics
posthog = "com.posthog:posthog-android:3.17.0"
posthog = "com.posthog:posthog-android:3.18.0"
sentry = "io.sentry:sentry-android:8.13.2"
# main branch can be tested replacing the version with main-SNAPSHOT
matrix_analytics_events = "com.github.matrix-org:matrix-analytics-events:0.28.0"

View file

@ -19,7 +19,6 @@ import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.setValue
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.geometry.CornerRadius
import androidx.compose.ui.geometry.Offset
@ -60,7 +59,6 @@ private const val DEFAULT_GRAPHICS_LAYER_ALPHA: Float = 0.99F
* @param lineWidth The width of the waveform lines.
* @param linePadding The padding between waveform lines.
*/
@OptIn(ExperimentalComposeUiApi::class)
@Composable
fun WaveformPlaybackView(
playbackProgress: Float,

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.fullscreenintent.api
sealed interface FullScreenIntentPermissionsEvents {
data object Dismiss : FullScreenIntentPermissionsEvents
data object OpenSettings : FullScreenIntentPermissionsEvents
}

View file

@ -10,6 +10,5 @@ package io.element.android.libraries.fullscreenintent.api
data class FullScreenIntentPermissionsState(
val permissionGranted: Boolean,
val shouldDisplayBanner: Boolean,
val dismissFullScreenIntentBanner: () -> Unit,
val openFullScreenIntentSettings: () -> Unit,
val eventSink: (FullScreenIntentPermissionsEvents) -> Unit,
)

View file

@ -10,11 +10,9 @@ package io.element.android.libraries.fullscreenintent.api
fun aFullScreenIntentPermissionsState(
permissionGranted: Boolean = true,
shouldDisplay: Boolean = false,
openFullScreenIntentSettings: () -> Unit = {},
dismissFullScreenIntentBanner: () -> Unit = {},
eventSink: (FullScreenIntentPermissionsEvents) -> Unit = {},
) = FullScreenIntentPermissionsState(
permissionGranted = permissionGranted,
shouldDisplayBanner = shouldDisplay,
openFullScreenIntentSettings = openFullScreenIntentSettings,
dismissFullScreenIntentBanner = dismissFullScreenIntentBanner,
eventSink = eventSink,
)

View file

@ -23,6 +23,7 @@ import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.SingleIn
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsState
import io.element.android.libraries.preferences.api.store.PreferenceDataStoreFactory
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
@ -60,15 +61,20 @@ class FullScreenIntentPermissionsPresenter @Inject constructor(
val coroutineScope = rememberCoroutineScope()
val isGranted = notificationManagerCompat.canUseFullScreenIntent()
val isBannerDismissed by isFullScreenIntentBannerDismissed.collectAsState(initial = true)
fun handleEvents(event: FullScreenIntentPermissionsEvents) {
when (event) {
FullScreenIntentPermissionsEvents.Dismiss -> coroutineScope.launch {
dismissFullScreenIntentBanner()
}
FullScreenIntentPermissionsEvents.OpenSettings -> openFullScreenIntentSettings()
}
}
return FullScreenIntentPermissionsState(
permissionGranted = isGranted,
shouldDisplayBanner = !isBannerDismissed && !isGranted,
dismissFullScreenIntentBanner = {
coroutineScope.launch {
dismissFullScreenIntentBanner()
}
},
openFullScreenIntentSettings = ::openFullScreenIntentSettings,
eventSink = ::handleEvents,
)
}

View file

@ -15,6 +15,7 @@ import app.cash.molecule.moleculeFlow
import app.cash.turbine.test
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.core.meta.BuildMeta
import io.element.android.libraries.fullscreenintent.api.FullScreenIntentPermissionsEvents
import io.element.android.libraries.fullscreenintent.impl.FullScreenIntentPermissionsPresenter
import io.element.android.libraries.matrix.test.core.aBuildMeta
import io.element.android.libraries.preferences.test.FakePreferenceDataStoreFactory
@ -76,10 +77,8 @@ class FullScreenIntentPermissionsPresenterTest {
}.test {
skipItems(1)
val loadedItem = awaitItem()
loadedItem.dismissFullScreenIntentBanner()
loadedItem.eventSink(FullScreenIntentPermissionsEvents.Dismiss)
runCurrent()
assertThat(awaitItem().shouldDisplayBanner).isFalse()
}
}
@ -94,10 +93,8 @@ class FullScreenIntentPermissionsPresenterTest {
}.test {
skipItems(1)
val loadedItem = awaitItem()
loadedItem.openFullScreenIntentSettings()
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
launchLambda.assertions().isCalledOnce()
cancelAndIgnoreRemainingEvents()
}
}
@ -115,10 +112,8 @@ class FullScreenIntentPermissionsPresenterTest {
}.test {
skipItems(1)
val loadedItem = awaitItem()
loadedItem.openFullScreenIntentSettings()
loadedItem.eventSink(FullScreenIntentPermissionsEvents.OpenSettings)
launchLambda.assertions().isNeverCalled()
cancelAndIgnoreRemainingEvents()
}
}

View file

@ -8,12 +8,12 @@
package io.element.android.libraries.matrix.impl
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.matrix.impl.mapper.toSessionData
import io.element.android.libraries.matrix.impl.paths.getSessionPaths
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
import io.element.android.libraries.sessionstorage.api.SessionStore
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.launch
import org.matrix.rustcomponents.sdk.ClientDelegate
import org.matrix.rustcomponents.sdk.ClientSessionDelegate
@ -22,6 +22,8 @@ import timber.log.Timber
import java.lang.ref.WeakReference
import java.util.concurrent.atomic.AtomicBoolean
private val loggerTag = LoggerTag("RustClientSessionDelegate")
/**
* This class is responsible for handling the session data for the Rust SDK.
*
@ -29,14 +31,11 @@ import java.util.concurrent.atomic.AtomicBoolean
*
* IMPORTANT: you must set the [client] property as soon as possible so [didReceiveAuthError] can work properly.
*/
@OptIn(ExperimentalCoroutinesApi::class)
class RustClientSessionDelegate(
private val sessionStore: SessionStore,
private val appCoroutineScope: CoroutineScope,
coroutineDispatchers: CoroutineDispatchers,
) : ClientSessionDelegate, ClientDelegate {
private val clientLog = Timber.tag("$this")
// Used to ensure several calls to `didReceiveAuthError` don't trigger multiple logouts
private val isLoggingOut = AtomicBoolean(false)
@ -64,7 +63,7 @@ class RustClientSessionDelegate(
appCoroutineScope.launch(updateTokensDispatcher) {
val existingData = sessionStore.getSession(session.userId) ?: return@launch
val (anonymizedAccessToken, anonymizedRefreshToken) = session.anonymizedTokens()
clientLog.d(
Timber.tag(loggerTag.value).d(
"Saving new session data with token: access token '$anonymizedAccessToken' and refresh token '$anonymizedRefreshToken'. " +
"Was token valid: ${existingData.isTokenValid}"
)
@ -75,29 +74,29 @@ class RustClientSessionDelegate(
sessionPaths = existingData.getSessionPaths(),
)
sessionStore.updateData(newData)
clientLog.d("Saved new session data with access token: '$anonymizedAccessToken'.")
Timber.tag(loggerTag.value).d("Saved new session data with access token: '$anonymizedAccessToken'.")
}.invokeOnCompletion {
if (it != null) {
clientLog.e(it, "Failed to save new session data.")
Timber.tag(loggerTag.value).e(it, "Failed to save new session data.")
}
}
}
override fun didReceiveAuthError(isSoftLogout: Boolean) {
clientLog.w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
Timber.tag(loggerTag.value).w("didReceiveAuthError(isSoftLogout=$isSoftLogout)")
if (isLoggingOut.getAndSet(true).not()) {
clientLog.v("didReceiveAuthError -> do the cleanup")
Timber.tag(loggerTag.value).v("didReceiveAuthError -> do the cleanup")
// TODO handle isSoftLogout parameter.
appCoroutineScope.launch(updateTokensDispatcher) {
val currentClient = client.get()
if (currentClient == null) {
clientLog.w("didReceiveAuthError -> no client, exiting")
Timber.tag(loggerTag.value).w("didReceiveAuthError -> no client, exiting")
isLoggingOut.set(false)
return@launch
}
val existingData = sessionStore.getSession(currentClient.sessionId.value)
val (anonymizedAccessToken, anonymizedRefreshToken) = existingData.anonymizedTokens()
clientLog.d(
Timber.tag(loggerTag.value).d(
"Removing session data with access token '$anonymizedAccessToken' " +
"and refresh token '$anonymizedRefreshToken'."
)
@ -105,18 +104,18 @@ class RustClientSessionDelegate(
// Set isTokenValid to false
val newData = existingData.copy(isTokenValid = false)
sessionStore.updateData(newData)
clientLog.d("Invalidated session data with access token: '$anonymizedAccessToken'.")
Timber.tag(loggerTag.value).d("Invalidated session data with access token: '$anonymizedAccessToken'.")
} else {
clientLog.d("No session data found.")
Timber.tag(loggerTag.value).d("No session data found.")
}
currentClient.logout(userInitiated = false, ignoreSdkError = true)
}.invokeOnCompletion {
if (it != null) {
clientLog.e(it, "Failed to remove session data.")
Timber.tag(loggerTag.value).e(it, "Failed to remove session data.")
}
}
} else {
clientLog.v("didReceiveAuthError -> already cleaning up")
Timber.tag(loggerTag.value).v("didReceiveAuthError -> already cleaning up")
}
}

View file

@ -13,7 +13,6 @@ import io.element.android.libraries.core.mimetype.MimeTypes
import io.element.android.libraries.matrix.api.media.MatrixMediaLoader
import io.element.android.libraries.matrix.api.media.MediaFile
import io.element.android.libraries.matrix.api.media.MediaSource
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.withContext
import org.matrix.rustcomponents.sdk.Client
import org.matrix.rustcomponents.sdk.use
@ -25,14 +24,12 @@ class RustMediaLoader(
dispatchers: CoroutineDispatchers,
private val innerClient: Client,
) : MatrixMediaLoader {
@OptIn(ExperimentalCoroutinesApi::class)
private val mediaDispatcher = dispatchers.io.limitedParallelism(32)
private val cacheDirectory
get() = File(baseCacheDirectory, "temp/media").apply {
if (!exists()) mkdirs() // Must always ensure that this directory exists because "Clear cache" does not restart an app's process.
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaContent(source: MediaSource): Result<ByteArray> =
withContext(mediaDispatcher) {
runCatchingExceptions {
@ -42,7 +39,6 @@ class RustMediaLoader(
}
}
@OptIn(ExperimentalUnsignedTypes::class)
override suspend fun loadMediaThumbnail(
source: MediaSource,
width: Long,

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@ -65,7 +64,6 @@ fun AudioItemView(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FilenameRow(
audio: MediaItem.Audio,

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.combinedClickable
import androidx.compose.foundation.layout.Column
@ -65,7 +64,6 @@ fun FileItemView(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun FilenameRow(
file: MediaItem.File,

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.mediaviewer.impl.gallery.ui
import androidx.compose.foundation.ExperimentalFoundationApi
import androidx.compose.foundation.background
import androidx.compose.foundation.border
import androidx.compose.foundation.combinedClickable
@ -84,7 +83,6 @@ fun VoiceItemView(
}
}
@OptIn(ExperimentalFoundationApi::class)
@Composable
private fun VoiceInfoRow(
state: VoiceMessageState,

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.battery
sealed interface BatteryOptimizationEvents {
data object Dismiss : BatteryOptimizationEvents
data object RequestDisableOptimizations : BatteryOptimizationEvents
}

View file

@ -0,0 +1,13 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.battery
data class BatteryOptimizationState(
val shouldDisplayBanner: Boolean,
val eventSink: (BatteryOptimizationEvents) -> Unit,
)

View file

@ -0,0 +1,16 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.api.battery
fun aBatteryOptimizationState(
shouldDisplayBanner: Boolean = false,
eventSink: (BatteryOptimizationEvents) -> Unit = {},
) = BatteryOptimizationState(
shouldDisplayBanner = shouldDisplayBanner,
eventSink = eventSink,
)

View file

@ -7,6 +7,9 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.REQUEST_IGNORE_BATTERY_OPTIMIZATIONS" />
<application>
<receiver
android:name=".notifications.TestNotificationReceiver"

View file

@ -0,0 +1,85 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import android.annotation.SuppressLint
import android.content.ActivityNotFoundException
import android.content.Context
import android.content.Intent
import android.os.PowerManager
import android.provider.Settings
import androidx.core.content.getSystemService
import androidx.core.net.toUri
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
import timber.log.Timber
import javax.inject.Inject
interface BatteryOptimization {
/**
* Tells if the application ignores battery optimizations.
*
* Ignoring them allows the app to run in background to make background sync with the homeserver.
* This user option appears on Android M but Android O enforces its usage and kills apps not
* authorised by the user to run in background.
*
* @return true if battery optimisations are ignored
*/
fun isIgnoringBatteryOptimizations(): Boolean
/**
* Request the user to disable battery optimizations for this app.
* This will open the system settings where the user can disable battery optimizations.
* See https://developer.android.com/training/monitoring-device-state/doze-standby#exemption-cases
*
* @return true if the intent was successfully started, false if the activity was not found
*/
fun requestDisablingBatteryOptimization(): Boolean
}
@ContributesBinding(AppScope::class)
class AndroidBatteryOptimization @Inject constructor(
@ApplicationContext
private val context: Context,
private val externalIntentLauncher: ExternalIntentLauncher,
) : BatteryOptimization {
override fun isIgnoringBatteryOptimizations(): Boolean {
return context.getSystemService<PowerManager>()
?.isIgnoringBatteryOptimizations(context.packageName) == true
}
@SuppressLint("BatteryLife")
override fun requestDisablingBatteryOptimization(): Boolean {
val ignoreBatteryOptimizationsResult = launchAction(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS, withData = true)
if (ignoreBatteryOptimizationsResult) {
return true
}
// Open settings as a fallback if the first attempt fails
return launchAction(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS, withData = false)
}
private fun launchAction(
action: String,
withData: Boolean,
): Boolean {
val intent = Intent()
intent.action = action
if (withData) {
intent.data = ("package:" + context.packageName).toUri()
}
return try {
externalIntentLauncher.launch(intent)
true
} catch (exception: ActivityNotFoundException) {
Timber.w(exception, "Cannot launch intent with action $action.")
false
}
}
}

View file

@ -0,0 +1,71 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import androidx.compose.runtime.Composable
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
import androidx.lifecycle.compose.LifecycleResumeEffect
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.store.PushDataStore
import kotlinx.coroutines.launch
import javax.inject.Inject
class BatteryOptimizationPresenter @Inject constructor(
private val pushDataStore: PushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val batteryOptimization: BatteryOptimization,
) : Presenter<BatteryOptimizationState> {
@Composable
override fun present(): BatteryOptimizationState {
val coroutineScope = rememberCoroutineScope()
var isRequestSent by remember { mutableStateOf(false) }
var localShouldDisplayBanner by remember { mutableStateOf(true) }
val storeShouldDisplayBanner by pushDataStore.shouldDisplayBatteryOptimizationBannerFlow.collectAsState(initial = false)
var isSystemIgnoringBatteryOptimizations by remember {
mutableStateOf(batteryOptimization.isIgnoringBatteryOptimizations())
}
LifecycleResumeEffect(Unit) {
isSystemIgnoringBatteryOptimizations = batteryOptimization.isIgnoringBatteryOptimizations()
if (isRequestSent) {
localShouldDisplayBanner = false
}
onPauseOrDispose {}
}
fun handleEvents(event: BatteryOptimizationEvents) {
when (event) {
BatteryOptimizationEvents.Dismiss -> coroutineScope.launch {
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
}
BatteryOptimizationEvents.RequestDisableOptimizations -> {
isRequestSent = true
if (batteryOptimization.requestDisablingBatteryOptimization().not()) {
// If not able to perform the request, ensure that we do not display the banner again
coroutineScope.launch {
mutableBatteryOptimizationStore.onOptimizationBannerDismissed()
}
}
}
}
}
return BatteryOptimizationState(
shouldDisplayBanner = localShouldDisplayBanner && storeShouldDisplayBanner && !isSystemIgnoringBatteryOptimizations,
eventSink = ::handleEvents,
)
}
}

View file

@ -10,16 +10,25 @@ package io.element.android.libraries.push.impl.di
import android.content.Context
import androidx.core.app.NotificationManagerCompat
import com.squareup.anvil.annotations.ContributesTo
import dagger.Binds
import dagger.Module
import dagger.Provides
import io.element.android.libraries.architecture.Presenter
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.di.ApplicationContext
import io.element.android.libraries.push.api.battery.BatteryOptimizationState
import io.element.android.libraries.push.impl.battery.BatteryOptimizationPresenter
@Module
@ContributesTo(AppScope::class)
object PushModule {
@Provides
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
return NotificationManagerCompat.from(context)
interface PushModule {
companion object {
@Provides
fun provideNotificationCompatManager(@ApplicationContext context: Context): NotificationManagerCompat {
return NotificationManagerCompat.from(context)
}
}
@Binds
fun bindBatteryOptimizationPresenter(presenter: BatteryOptimizationPresenter): Presenter<BatteryOptimizationState>
}

View file

@ -50,6 +50,7 @@ class DefaultPushHandler @Inject constructor(
private val onNotifiableEventReceived: OnNotifiableEventReceived,
private val onRedactedEventReceived: OnRedactedEventReceived,
private val incrementPushDataStore: IncrementPushDataStore,
private val mutableBatteryOptimizationStore: MutableBatteryOptimizationStore,
private val userPushStoreFactory: UserPushStoreFactory,
private val pushClientSecret: PushClientSecret,
private val buildMeta: BuildMeta,
@ -102,6 +103,7 @@ class DefaultPushHandler @Inject constructor(
sessionId = request.sessionId,
reason = exception.message ?: exception.javaClass.simpleName,
)
mutableBatteryOptimizationStore.showBatteryOptimizationBanner()
}
)
}

View file

@ -0,0 +1,31 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.push
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.di.AppScope
import io.element.android.libraries.push.impl.store.DefaultPushDataStore
import javax.inject.Inject
interface MutableBatteryOptimizationStore {
suspend fun showBatteryOptimizationBanner()
suspend fun onOptimizationBannerDismissed()
}
@ContributesBinding(AppScope::class)
class DefaultMutableBatteryOptimizationStore @Inject constructor(
private val defaultPushDataStore: DefaultPushDataStore,
) : MutableBatteryOptimizationStore {
override suspend fun showBatteryOptimizationBanner() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_SHOW)
}
override suspend fun onOptimizationBannerDismissed() {
defaultPushDataStore.setBatteryOptimizationBannerState(DefaultPushDataStore.BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED)
}
}

View file

@ -43,10 +43,24 @@ class DefaultPushDataStore @Inject constructor(
) : PushDataStore {
private val pushCounter = intPreferencesKey("push_counter")
/**
* Integer preference to track the state of the battery optimization banner.
* Possible values:
* [BATTERY_OPTIMIZATION_BANNER_STATE_INIT]: Should not show the banner
* [BATTERY_OPTIMIZATION_BANNER_STATE_SHOW]: Should show the banner
* [BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED]: Banner has been shown and user has dismissed it
*/
private val batteryOptimizationBannerState = intPreferencesKey("battery_optimization_banner_state")
override val pushCounterFlow: Flow<Int> = context.dataStore.data.map { preferences ->
preferences[pushCounter] ?: 0
}
@Suppress("UnnecessaryParentheses")
override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = context.dataStore.data.map { preferences ->
(preferences[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT) == BATTERY_OPTIMIZATION_BANNER_STATE_SHOW
}
suspend fun incrementPushCounter() {
context.dataStore.edit { settings ->
val currentCounterValue = settings[pushCounter] ?: 0
@ -54,6 +68,18 @@ class DefaultPushDataStore @Inject constructor(
}
}
suspend fun setBatteryOptimizationBannerState(newState: Int) {
context.dataStore.edit { settings ->
val currentValue = settings[batteryOptimizationBannerState] ?: BATTERY_OPTIMIZATION_BANNER_STATE_INIT
settings[batteryOptimizationBannerState] = when (currentValue) {
BATTERY_OPTIMIZATION_BANNER_STATE_INIT,
BATTERY_OPTIMIZATION_BANNER_STATE_SHOW -> newState
BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED -> currentValue
else -> error("Invalid value for showBatteryOptimizationBanner: $currentValue")
}
}
}
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {
return pushDatabase.pushHistoryQueries.selectAll()
.asFlow()
@ -84,4 +110,10 @@ class DefaultPushDataStore @Inject constructor(
it.clear()
}
}
companion object {
const val BATTERY_OPTIMIZATION_BANNER_STATE_INIT = 0
const val BATTERY_OPTIMIZATION_BANNER_STATE_SHOW = 1
const val BATTERY_OPTIMIZATION_BANNER_STATE_DISMISSED = 2
}
}

View file

@ -11,6 +11,7 @@ import io.element.android.libraries.push.api.history.PushHistoryItem
import kotlinx.coroutines.flow.Flow
interface PushDataStore {
val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean>
val pushCounterFlow: Flow<Int>
/**

View file

@ -0,0 +1,113 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import android.content.ActivityNotFoundException
import android.content.Intent
import android.provider.Settings
import androidx.test.platform.app.InstrumentationRegistry
import com.google.common.truth.Truth.assertThat
import io.element.android.services.toolbox.api.intent.ExternalIntentLauncher
import io.element.android.services.toolbox.test.intent.FakeExternalIntentLauncher
import io.element.android.tests.testutils.lambda.lambdaRecorder
import org.junit.Test
import org.junit.runner.RunWith
import org.robolectric.RobolectricTestRunner
@RunWith(RobolectricTestRunner::class)
class AndroidBatteryOptimizationTest {
@Test
fun `isIgnoringBatteryOptimizations should return false`() {
val sut = createAndroidBatteryOptimization()
assertThat(sut.isIgnoringBatteryOptimizations()).isFalse()
}
@Test
fun `requestDisablingBatteryOptimization is called once with expected intent`() {
val launchLambda = lambdaRecorder<Intent, Unit> { intent ->
assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}")
}
val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
val sut = createAndroidBatteryOptimization(
externalIntentLauncher = externalIntentLauncher,
)
val result = sut.requestDisablingBatteryOptimization()
launchLambda.assertions().isCalledOnce()
assertThat(result).isTrue()
}
@Test
fun `in case of 1 error, requestDisablingBatteryOptimization returns true`() {
var callNumber = 0
val launchLambda = lambdaRecorder<Intent, Unit> { intent ->
callNumber++
when (callNumber) {
1 -> {
assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}")
throw ActivityNotFoundException("Test exception")
}
2 -> {
assertThat(intent.action).isEqualTo(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
assertThat(intent.data).isNull()
// No error
}
else -> {
throw AssertionError("Unexpected call number: $callNumber")
}
}
}
val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
val sut = createAndroidBatteryOptimization(
externalIntentLauncher = externalIntentLauncher,
)
val result = sut.requestDisablingBatteryOptimization()
launchLambda.assertions().isCalledExactly(2)
assertThat(result).isTrue()
}
@Test
fun `in case of 2 errors, requestDisablingBatteryOptimization returns false`() {
var callNumber = 0
val launchLambda = lambdaRecorder<Intent, Unit> { intent ->
callNumber++
when (callNumber) {
1 -> {
assertThat(intent.action).isEqualTo(Settings.ACTION_REQUEST_IGNORE_BATTERY_OPTIMIZATIONS)
assertThat(intent.data.toString()).isEqualTo("package:${InstrumentationRegistry.getInstrumentation().context.packageName}")
throw ActivityNotFoundException("Test exception")
}
2 -> {
assertThat(intent.action).isEqualTo(Settings.ACTION_IGNORE_BATTERY_OPTIMIZATION_SETTINGS)
assertThat(intent.data).isNull()
throw ActivityNotFoundException("Test exception")
}
else -> {
throw AssertionError("Unexpected call number: $callNumber")
}
}
}
val externalIntentLauncher = FakeExternalIntentLauncher(launchLambda)
val sut = createAndroidBatteryOptimization(
externalIntentLauncher = externalIntentLauncher,
)
val result = sut.requestDisablingBatteryOptimization()
launchLambda.assertions().isCalledExactly(2)
assertThat(result).isFalse()
}
private fun createAndroidBatteryOptimization(
externalIntentLauncher: ExternalIntentLauncher = FakeExternalIntentLauncher(),
): AndroidBatteryOptimization {
return AndroidBatteryOptimization(
context = InstrumentationRegistry.getInstrumentation().context,
externalIntentLauncher = externalIntentLauncher,
)
}
}

View file

@ -0,0 +1,170 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import androidx.lifecycle.Lifecycle
import com.google.common.truth.Truth.assertThat
import io.element.android.libraries.push.api.battery.BatteryOptimizationEvents
import io.element.android.libraries.push.impl.push.FakeMutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.push.MutableBatteryOptimizationStore
import io.element.android.libraries.push.impl.store.InMemoryPushDataStore
import io.element.android.libraries.push.impl.store.PushDataStore
import io.element.android.tests.testutils.FakeLifecycleOwner
import io.element.android.tests.testutils.WarmUpRule
import io.element.android.tests.testutils.lambda.lambdaRecorder
import io.element.android.tests.testutils.testWithLifecycleOwner
import kotlinx.coroutines.test.runTest
import org.junit.Rule
import org.junit.Test
class BatteryOptimizationPresenterTest {
@get:Rule
val warmUpRule = WarmUpRule()
@Test
fun `present - initial state`() = runTest {
val presenter = createPresenter(
pushDataStore = InMemoryPushDataStore(
initialShouldDisplayBatteryOptimizationBanner = false,
),
batteryOptimization = FakeBatteryOptimization(
isIgnoringBatteryOptimizationsResult = false,
),
)
val lifeCycleOwner = FakeLifecycleOwner()
presenter.testWithLifecycleOwner(lifeCycleOwner) {
val initialState = awaitItem()
assertThat(initialState.shouldDisplayBanner).isFalse()
lifeCycleOwner.givenState(Lifecycle.State.RESUMED)
}
}
@Test
fun `present - should display banner`() = runTest {
val presenter = createPresenter(
pushDataStore = InMemoryPushDataStore(
initialShouldDisplayBatteryOptimizationBanner = true,
),
batteryOptimization = FakeBatteryOptimization(
isIgnoringBatteryOptimizationsResult = false,
),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
assertThat(initialState.shouldDisplayBanner).isFalse()
assertThat(awaitItem().shouldDisplayBanner).isTrue()
}
}
@Test
fun `present - should display banner, but setting already performed`() = runTest {
val presenter = createPresenter(
pushDataStore = InMemoryPushDataStore(
initialShouldDisplayBatteryOptimizationBanner = true,
),
batteryOptimization = FakeBatteryOptimization(
isIgnoringBatteryOptimizationsResult = true,
),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
assertThat(initialState.shouldDisplayBanner).isFalse()
assertThat(awaitItem().shouldDisplayBanner).isFalse()
}
}
@Test
fun `present - should display banner, user dismisses`() = runTest {
val onOptimizationBannerDismissedResult = lambdaRecorder<Unit> { }
val presenter = createPresenter(
pushDataStore = InMemoryPushDataStore(
initialShouldDisplayBatteryOptimizationBanner = true,
),
batteryOptimization = FakeBatteryOptimization(
isIgnoringBatteryOptimizationsResult = false,
),
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
onOptimizationBannerDismissedResult = onOptimizationBannerDismissedResult,
),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
assertThat(initialState.shouldDisplayBanner).isFalse()
val displayedItem = awaitItem()
assertThat(displayedItem.shouldDisplayBanner).isTrue()
displayedItem.eventSink(BatteryOptimizationEvents.Dismiss)
onOptimizationBannerDismissedResult.assertions().isCalledOnce()
}
}
@Test
fun `present - should display banner, user continue, error case`() = runTest {
val onOptimizationBannerDismissedResult = lambdaRecorder<Unit> { }
val requestDisablingBatteryOptimizationResult = lambdaRecorder<Boolean> { false }
val presenter = createPresenter(
pushDataStore = InMemoryPushDataStore(
initialShouldDisplayBatteryOptimizationBanner = true,
),
batteryOptimization = FakeBatteryOptimization(
isIgnoringBatteryOptimizationsResult = false,
requestDisablingBatteryOptimizationResult = requestDisablingBatteryOptimizationResult
),
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
onOptimizationBannerDismissedResult = onOptimizationBannerDismissedResult,
),
)
presenter.testWithLifecycleOwner {
val initialState = awaitItem()
assertThat(initialState.shouldDisplayBanner).isFalse()
val displayedItem = awaitItem()
assertThat(displayedItem.shouldDisplayBanner).isTrue()
displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations)
requestDisablingBatteryOptimizationResult.assertions().isCalledOnce()
onOptimizationBannerDismissedResult.assertions().isCalledOnce()
}
}
@Test
fun `present - should display banner, user continue, nominal case`() = runTest {
val requestDisablingBatteryOptimizationResult = lambdaRecorder<Boolean> { true }
val batteryOptimization = FakeBatteryOptimization(
isIgnoringBatteryOptimizationsResult = false,
requestDisablingBatteryOptimizationResult = requestDisablingBatteryOptimizationResult
)
val presenter = createPresenter(
pushDataStore = InMemoryPushDataStore(
initialShouldDisplayBatteryOptimizationBanner = true,
),
batteryOptimization = batteryOptimization,
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
)
val lifeCycleOwner = FakeLifecycleOwner()
presenter.testWithLifecycleOwner(lifeCycleOwner) {
val initialState = awaitItem()
assertThat(initialState.shouldDisplayBanner).isFalse()
val displayedItem = awaitItem()
assertThat(displayedItem.shouldDisplayBanner).isTrue()
displayedItem.eventSink(BatteryOptimizationEvents.RequestDisableOptimizations)
requestDisablingBatteryOptimizationResult.assertions().isCalledOnce()
batteryOptimization.isIgnoringBatteryOptimizationsResult = true
lifeCycleOwner.givenState(Lifecycle.State.RESUMED)
assertThat(awaitItem().shouldDisplayBanner).isFalse()
assertThat(awaitItem().shouldDisplayBanner).isFalse()
}
}
private fun createPresenter(
pushDataStore: PushDataStore = InMemoryPushDataStore(),
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
batteryOptimization: BatteryOptimization = FakeBatteryOptimization(),
) = BatteryOptimizationPresenter(
pushDataStore = pushDataStore,
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
batteryOptimization = batteryOptimization
)
}

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.battery
import io.element.android.tests.testutils.lambda.lambdaError
class FakeBatteryOptimization(
var isIgnoringBatteryOptimizationsResult: Boolean = false,
private val requestDisablingBatteryOptimizationResult: () -> Boolean = { lambdaError() }
) : BatteryOptimization {
override fun isIgnoringBatteryOptimizations(): Boolean {
return isIgnoringBatteryOptimizationsResult
}
override fun requestDisablingBatteryOptimization(): Boolean {
return requestDisablingBatteryOptimizationResult()
}
}

View file

@ -86,7 +86,7 @@ class DefaultPushHandlerTest {
fun `when classical PushData is received, the notification drawer is informed`() = runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _, ->
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
@ -268,11 +268,35 @@ class DefaultPushHandlerTest {
}
@Test
fun `when classical PushData is received, but not able to resolve the event, nothing happen`() =
fun `when classical PushData is received, but a failure occurs (session not found), nothing happen`() {
`test notification resolver failure`(
notificationResolveResult = { _ ->
Result.failure(ResolvingException("Unable to restore session"))
},
shouldSetOptimizationBatteryBanner = false,
)
}
@Test
fun `when classical PushData is received, but not able to resolve the event, the banner to disable battery optimization will be displayed`() {
`test notification resolver failure`(
notificationResolveResult = { requests: List<NotificationEventRequest> ->
Result.success(
requests.associateWith { Result.failure(ResolvingException("Unable to resolve event")) }
)
},
shouldSetOptimizationBatteryBanner = true,
)
}
private fun `test notification resolver failure`(
notificationResolveResult: (List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>,
shouldSetOptimizationBatteryBanner: Boolean,
) {
runTest {
val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
Result.failure(ResolvingException("Unable to resolve"))
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, requests ->
notificationResolveResult(requests)
}
val onNotifiableEventsReceived = lambdaRecorder<List<NotifiableEvent>, Unit> {}
val incrementPushCounterResult = lambdaRecorder<Unit> {}
@ -286,6 +310,7 @@ class DefaultPushHandlerTest {
val pushHistoryService = FakePushHistoryService(
onPushReceivedResult = onPushReceivedResult,
)
val showBatteryOptimizationBannerResult = lambdaRecorder<Unit> {}
val defaultPushHandler = createDefaultPushHandler(
onNotifiableEventsReceived = onNotifiableEventsReceived,
notifiableEventsResult = notifiableEventResult,
@ -297,6 +322,9 @@ class DefaultPushHandlerTest {
getUserIdFromSecretResult = { A_USER_ID }
),
incrementPushCounterResult = incrementPushCounterResult,
mutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(
showBatteryOptimizationBannerResult = showBatteryOptimizationBannerResult,
),
pushHistoryService = pushHistoryService,
)
defaultPushHandler.handle(aPushData, A_PUSHER_INFO)
@ -313,7 +341,15 @@ class DefaultPushHandlerTest {
onPushReceivedResult.assertions()
.isCalledOnce()
.with(any(), value(AN_EVENT_ID), value(A_ROOM_ID), value(A_USER_ID), value(false), value(true), any())
showBatteryOptimizationBannerResult.assertions().let {
if (shouldSetOptimizationBatteryBanner) {
it.isCalledOnce()
} else {
it.isNeverCalled()
}
}
}
}
@Test
fun `when ringing call PushData is received, the incoming call will be handled`() = runTest {
@ -542,7 +578,7 @@ class DefaultPushHandlerTest {
fun `when receiving several push notifications at the same time, those are batched before being processed`() = runTest {
val aNotifiableMessageEvent = aNotifiableMessageEvent()
val notifiableEventResult =
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _, ->
lambdaRecorder<SessionId, List<NotificationEventRequest>, Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>>> { _, _ ->
val request = NotificationEventRequest(A_SESSION_ID, A_ROOM_ID, AN_EVENT_ID, A_PUSHER_INFO)
Result.success(mapOf(request to Result.success(ResolvedPushEvent.Event(aNotifiableMessageEvent))))
}
@ -595,8 +631,9 @@ class DefaultPushHandlerTest {
onNotifiableEventsReceived: (List<NotifiableEvent>) -> Unit = { lambdaError() },
onRedactedEventsReceived: (List<ResolvedPushEvent.Redaction>) -> Unit = { lambdaError() },
notifiableEventsResult: (SessionId, List<NotificationEventRequest>) -> Result<Map<NotificationEventRequest, Result<ResolvedPushEvent>>> =
{ _, _, -> lambdaError() },
{ _, _ -> lambdaError() },
incrementPushCounterResult: () -> Unit = { lambdaError() },
mutableBatteryOptimizationStore: MutableBatteryOptimizationStore = FakeMutableBatteryOptimizationStore(),
userPushStore: UserPushStore = FakeUserPushStore(),
pushClientSecret: PushClientSecret = FakePushClientSecret(),
buildMeta: BuildMeta = aBuildMeta(),
@ -614,6 +651,7 @@ class DefaultPushHandlerTest {
incrementPushCounterResult()
}
},
mutableBatteryOptimizationStore = mutableBatteryOptimizationStore,
userPushStoreFactory = FakeUserPushStoreFactory { userPushStore },
pushClientSecret = pushClientSecret,
buildMeta = buildMeta,

View file

@ -0,0 +1,23 @@
/*
* Copyright 2025 New Vector Ltd.
*
* SPDX-License-Identifier: AGPL-3.0-only OR LicenseRef-Element-Commercial
* Please see LICENSE files in the repository root for full details.
*/
package io.element.android.libraries.push.impl.push
import io.element.android.tests.testutils.lambda.lambdaError
class FakeMutableBatteryOptimizationStore(
private val showBatteryOptimizationBannerResult: () -> Unit = { lambdaError() },
private val onOptimizationBannerDismissedResult: () -> Unit = { lambdaError() },
) : MutableBatteryOptimizationStore {
override suspend fun showBatteryOptimizationBanner() {
showBatteryOptimizationBannerResult()
}
override suspend fun onOptimizationBannerDismissed() {
onOptimizationBannerDismissedResult()
}
}

View file

@ -15,12 +15,16 @@ import kotlinx.coroutines.flow.asStateFlow
class InMemoryPushDataStore(
initialPushCounter: Int = 0,
initialShouldDisplayBatteryOptimizationBanner: Boolean = false,
initialPushHistoryItems: List<PushHistoryItem> = emptyList(),
private val resetResult: () -> Unit = { lambdaError() }
) : PushDataStore {
private val mutablePushCounterFlow = MutableStateFlow(initialPushCounter)
override val pushCounterFlow: Flow<Int> = mutablePushCounterFlow.asStateFlow()
private val mutableShouldDisplayBatteryOptimizationBannerFlow = MutableStateFlow(initialShouldDisplayBatteryOptimizationBanner)
override val shouldDisplayBatteryOptimizationBannerFlow: Flow<Boolean> = mutableShouldDisplayBatteryOptimizationBannerFlow.asStateFlow()
private val mutablePushHistoryItemsFlow = MutableStateFlow(initialPushHistoryItems)
override fun getPushHistoryItemsFlow(): Flow<List<PushHistoryItem>> {

View file

@ -10,6 +10,7 @@ package io.element.android.libraries.pushproviders.unifiedpush
import com.squareup.anvil.annotations.ContributesBinding
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
import io.element.android.libraries.core.data.tryOrNull
import io.element.android.libraries.core.log.logger.LoggerTag
import io.element.android.libraries.di.AppScope
import kotlinx.coroutines.withContext
import retrofit2.HttpException
@ -29,6 +30,8 @@ interface UnifiedPushGatewayResolver {
suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult
}
private val loggerTag = LoggerTag("DefaultUnifiedPushGatewayResolver")
@ContributesBinding(AppScope::class)
class DefaultUnifiedPushGatewayResolver @Inject constructor(
private val unifiedPushApiFactory: UnifiedPushApiFactory,
@ -36,36 +39,36 @@ class DefaultUnifiedPushGatewayResolver @Inject constructor(
) : UnifiedPushGatewayResolver {
override suspend fun getGateway(endpoint: String): UnifiedPushGatewayResolverResult {
val url = tryOrNull(
onException = { Timber.tag("DefaultUnifiedPushGatewayResolver").d(it, "Cannot parse endpoint as an URL") }
onException = { Timber.tag(loggerTag.value).d(it, "Cannot parse endpoint as an URL") }
) {
URL(endpoint)
}
return if (url == null) {
Timber.tag("DefaultUnifiedPushGatewayResolver").d("ErrorInvalidUrl")
Timber.tag(loggerTag.value).d("ErrorInvalidUrl")
UnifiedPushGatewayResolverResult.ErrorInvalidUrl
} else {
val port = if (url.port != -1) ":${url.port}" else ""
val customBase = "${url.protocol}://${url.host}$port"
val customUrl = "$customBase/_matrix/push/v1/notify"
Timber.tag("DefaultUnifiedPushGatewayResolver").i("Testing $customUrl")
Timber.tag(loggerTag.value).i("Testing $customUrl")
return withContext(coroutineDispatchers.io) {
val api = unifiedPushApiFactory.create(customBase)
try {
val discoveryResponse = api.discover()
if (discoveryResponse.unifiedpush.gateway == "matrix") {
Timber.tag("DefaultUnifiedPushGatewayResolver").d("The endpoint seems to be a valid UnifiedPush gateway")
Timber.tag(loggerTag.value).d("The endpoint seems to be a valid UnifiedPush gateway")
UnifiedPushGatewayResolverResult.Success(customUrl)
} else {
// The endpoint returned a 200 OK but didn't promote an actual matrix gateway, which means it doesn't have any
Timber.tag("DefaultUnifiedPushGatewayResolver").w("The endpoint does not seem to be a valid UnifiedPush gateway, using fallback")
Timber.tag(loggerTag.value).w("The endpoint does not seem to be a valid UnifiedPush gateway, using fallback")
UnifiedPushGatewayResolverResult.NoMatrixGateway
}
} catch (throwable: Throwable) {
if ((throwable as? HttpException)?.code() == HttpURLConnection.HTTP_NOT_FOUND) {
Timber.tag("DefaultUnifiedPushGatewayResolver").i("Checking for UnifiedPush endpoint yielded 404, using fallback")
Timber.tag(loggerTag.value).i("Checking for UnifiedPush endpoint yielded 404, using fallback")
UnifiedPushGatewayResolverResult.NoMatrixGateway
} else {
Timber.tag("DefaultUnifiedPushGatewayResolver").e(throwable, "Error checking for UnifiedPush endpoint")
Timber.tag(loggerTag.value).e(throwable, "Error checking for UnifiedPush endpoint")
UnifiedPushGatewayResolverResult.Error(customUrl)
}
}

View file

@ -7,7 +7,6 @@
package io.element.android.libraries.testtags
import androidx.compose.ui.ExperimentalComposeUiApi
import androidx.compose.ui.Modifier
import androidx.compose.ui.semantics.semantics
import androidx.compose.ui.semantics.testTag
@ -16,7 +15,6 @@ import androidx.compose.ui.semantics.testTagsAsResourceId
/**
* Add a testTag to a Modifier, to be used by external tool, like TrafficLight for instance.
*/
@OptIn(ExperimentalComposeUiApi::class)
fun Modifier.testTag(id: TestTag) = semantics {
testTag = id.value
testTagsAsResourceId = true

View file

@ -13,6 +13,7 @@ import androidx.compose.foundation.interaction.MutableInteractionSource
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.size
import androidx.compose.foundation.selection.toggleable
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.ripple
import androidx.compose.runtime.Composable
@ -21,6 +22,8 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.graphics.Color
import androidx.compose.ui.graphics.vector.ImageVector
import androidx.compose.ui.semantics.clearAndSetSemantics
import androidx.compose.ui.semantics.contentDescription
import androidx.compose.ui.unit.dp
import io.element.android.compound.theme.ElementTheme
import io.element.android.compound.tokens.generated.CompoundIcons
@ -32,9 +35,10 @@ import io.element.android.libraries.designsystem.theme.iconSuccessPrimaryBackgro
@Composable
internal fun FormattingOption(
state: FormattingOptionState,
toggleable: Boolean,
onClick: () -> Unit,
imageVector: ImageVector,
contentDescription: String?,
contentDescription: String,
modifier: Modifier = Modifier,
) {
val backgroundColor = when (state) {
@ -52,6 +56,7 @@ internal fun FormattingOption(
modifier = modifier
.clickable(
onClick = onClick,
enabled = state != FormattingOptionState.Disabled,
interactionSource = remember { MutableInteractionSource() },
indication = ripple(
bounded = false,
@ -59,6 +64,20 @@ internal fun FormattingOption(
),
)
.size(48.dp)
.then(
if (toggleable) {
Modifier.toggleable(
value = state == FormattingOptionState.Selected,
enabled = state != FormattingOptionState.Disabled,
onValueChange = { onClick() },
)
} else {
Modifier
}
)
.clearAndSetSemantics {
this.contentDescription = contentDescription
}
) {
Box(
modifier = Modifier
@ -84,21 +103,24 @@ internal fun FormattingOptionPreview() = ElementPreview {
Row {
FormattingOption(
state = FormattingOptionState.Default,
toggleable = false,
onClick = { },
imageVector = CompoundIcons.Bold(),
contentDescription = null,
contentDescription = "",
)
FormattingOption(
state = FormattingOptionState.Selected,
toggleable = true,
onClick = { },
imageVector = CompoundIcons.Italic(),
contentDescription = null,
contentDescription = "",
)
FormattingOption(
state = FormattingOptionState.Disabled,
toggleable = false,
onClick = { },
imageVector = CompoundIcons.Underline(),
contentDescription = null,
contentDescription = "",
)
}
}

View file

@ -104,24 +104,28 @@ internal fun TextFormatting(
) {
FormattingOption(
state = state.actions[ComposerAction.BOLD].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.Bold) },
imageVector = CompoundIcons.Bold(),
contentDescription = stringResource(R.string.rich_text_editor_format_bold)
)
FormattingOption(
state = state.actions[ComposerAction.ITALIC].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.Italic) },
imageVector = CompoundIcons.Italic(),
contentDescription = stringResource(R.string.rich_text_editor_format_italic)
)
FormattingOption(
state = state.actions[ComposerAction.UNDERLINE].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.Underline) },
imageVector = CompoundIcons.Underline(),
contentDescription = stringResource(R.string.rich_text_editor_format_underline)
)
FormattingOption(
state = state.actions[ComposerAction.STRIKE_THROUGH].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.StrikeThrough) },
imageVector = CompoundIcons.Strikethrough(),
contentDescription = stringResource(R.string.rich_text_editor_format_strikethrough)
@ -141,6 +145,7 @@ internal fun TextFormatting(
FormattingOption(
state = state.actions[ComposerAction.LINK].toButtonState(),
toggleable = true,
onClick = { linkDialogAction = state.linkAction },
imageVector = CompoundIcons.Link(),
contentDescription = stringResource(R.string.rich_text_editor_link)
@ -148,42 +153,49 @@ internal fun TextFormatting(
FormattingOption(
state = state.actions[ComposerAction.UNORDERED_LIST].toButtonState(),
toggleable = true,
onClick = { onToggleListClick(ordered = false) },
imageVector = CompoundIcons.ListBulleted(),
contentDescription = stringResource(R.string.rich_text_editor_bullet_list)
)
FormattingOption(
state = state.actions[ComposerAction.ORDERED_LIST].toButtonState(),
toggleable = true,
onClick = { onToggleListClick(ordered = true) },
imageVector = CompoundIcons.ListNumbered(),
contentDescription = stringResource(R.string.rich_text_editor_numbered_list)
)
FormattingOption(
state = state.actions[ComposerAction.INDENT].toButtonState(),
toggleable = false,
onClick = { onIndentClick() },
imageVector = CompoundIcons.IndentIncrease(),
contentDescription = stringResource(R.string.rich_text_editor_indent)
)
FormattingOption(
state = state.actions[ComposerAction.UNINDENT].toButtonState(),
toggleable = false,
onClick = { onUnindentClick() },
imageVector = CompoundIcons.IndentDecrease(),
contentDescription = stringResource(R.string.rich_text_editor_unindent)
)
FormattingOption(
state = state.actions[ComposerAction.INLINE_CODE].toButtonState(),
toggleable = true,
onClick = { onInlineFormatClick(InlineFormat.InlineCode) },
imageVector = CompoundIcons.InlineCode(),
contentDescription = stringResource(R.string.rich_text_editor_inline_code)
)
FormattingOption(
state = state.actions[ComposerAction.CODE_BLOCK].toButtonState(),
toggleable = true,
onClick = { onCodeBlockClick() },
imageVector = CompoundIcons.Code(),
contentDescription = stringResource(R.string.rich_text_editor_code_block)
)
FormattingOption(
state = state.actions[ComposerAction.QUOTE].toButtonState(),
toggleable = true,
onClick = { onQuoteClick() },
imageVector = CompoundIcons.Quote(),
contentDescription = stringResource(R.string.rich_text_editor_quote)

View file

@ -10,8 +10,12 @@
<string name="rich_text_editor_composer_unencrypted_placeholder">"Nešifrovaná zpráva…"</string>
<string name="rich_text_editor_create_link">"Vytvořit odkaz"</string>
<string name="rich_text_editor_edit_link">"Upravit odkaz"</string>
<string name="rich_text_editor_format_action">"%1$s, stav: %2$s"</string>
<string name="rich_text_editor_format_bold">"Použít tučný text"</string>
<string name="rich_text_editor_format_italic">"Použít kurzívu"</string>
<string name="rich_text_editor_format_state_disabled">"zakázáno"</string>
<string name="rich_text_editor_format_state_off">"VYP"</string>
<string name="rich_text_editor_format_state_on">"ZAP"</string>
<string name="rich_text_editor_format_strikethrough">"Použít přeškrtnutí"</string>
<string name="rich_text_editor_format_underline">"Použít podtržení"</string>
<string name="rich_text_editor_full_screen_toggle">"Přepnout režim celé obrazovky"</string>

View file

@ -10,8 +10,12 @@
<string name="rich_text_editor_composer_unencrypted_placeholder">"Message non chiffré…"</string>
<string name="rich_text_editor_create_link">"Créer un lien"</string>
<string name="rich_text_editor_edit_link">"Modifier le lien"</string>
<string name="rich_text_editor_format_action">"%1$s, état : %2$s"</string>
<string name="rich_text_editor_format_bold">"Appliquer le format gras"</string>
<string name="rich_text_editor_format_italic">"Appliquer le format italique"</string>
<string name="rich_text_editor_format_state_disabled">"désactivé"</string>
<string name="rich_text_editor_format_state_off">"désactivé"</string>
<string name="rich_text_editor_format_state_on">"activé"</string>
<string name="rich_text_editor_format_strikethrough">"Appliquer le format barré"</string>
<string name="rich_text_editor_format_underline">"Appliquer le format souligné"</string>
<string name="rich_text_editor_full_screen_toggle">"Activer/désactiver le mode plein écran"</string>

View file

@ -10,8 +10,12 @@
<string name="rich_text_editor_composer_unencrypted_placeholder">"Titkosítatlan üzenet…"</string>
<string name="rich_text_editor_create_link">"Hivatkozás létrehozása"</string>
<string name="rich_text_editor_edit_link">"Hivatkozás szerkesztése"</string>
<string name="rich_text_editor_format_action">"%1$s, állapot: %2$s"</string>
<string name="rich_text_editor_format_bold">"Félkövér formátum alkalmazása"</string>
<string name="rich_text_editor_format_italic">"Dőlt formátum alkalmazása"</string>
<string name="rich_text_editor_format_state_disabled">"letiltva"</string>
<string name="rich_text_editor_format_state_off">"ki"</string>
<string name="rich_text_editor_format_state_on">"be"</string>
<string name="rich_text_editor_format_strikethrough">"Áthúzott formátum alkalmazása"</string>
<string name="rich_text_editor_format_underline">"Aláhúzott formátum alkalmazása"</string>
<string name="rich_text_editor_full_screen_toggle">"Teljes képernyős mód be/ki"</string>

View file

@ -10,8 +10,12 @@
<string name="rich_text_editor_composer_unencrypted_placeholder">"Ukryptert melding…"</string>
<string name="rich_text_editor_create_link">"Opprett en lenke"</string>
<string name="rich_text_editor_edit_link">"Rediger lenke"</string>
<string name="rich_text_editor_format_action">"%1$s, tilstand: %2$s"</string>
<string name="rich_text_editor_format_bold">"Bruk fet skrift"</string>
<string name="rich_text_editor_format_italic">"Bruk kursivformat"</string>
<string name="rich_text_editor_format_state_disabled">"deaktivert"</string>
<string name="rich_text_editor_format_state_off">"av"</string>
<string name="rich_text_editor_format_state_on">"på"</string>
<string name="rich_text_editor_format_strikethrough">"Bruke gjennomstrekingsformat"</string>
<string name="rich_text_editor_format_underline">"Bruke understrekingsformat"</string>
<string name="rich_text_editor_full_screen_toggle">"Veksle fullskjermmodus"</string>

View file

@ -10,8 +10,12 @@
<string name="rich_text_editor_composer_unencrypted_placeholder">"Unencrypted message…"</string>
<string name="rich_text_editor_create_link">"Create a link"</string>
<string name="rich_text_editor_edit_link">"Edit link"</string>
<string name="rich_text_editor_format_action">"%1$s, state: %2$s"</string>
<string name="rich_text_editor_format_bold">"Apply bold format"</string>
<string name="rich_text_editor_format_italic">"Apply italic format"</string>
<string name="rich_text_editor_format_state_disabled">"disabled"</string>
<string name="rich_text_editor_format_state_off">"off"</string>
<string name="rich_text_editor_format_state_on">"on"</string>
<string name="rich_text_editor_format_strikethrough">"Apply strikethrough format"</string>
<string name="rich_text_editor_format_underline">"Apply underline format"</string>
<string name="rich_text_editor_full_screen_toggle">"Toggle full screen mode"</string>

View file

@ -140,6 +140,9 @@
<string name="action_view_source">"Zobrazit zdroj"</string>
<string name="action_yes">"Ano"</string>
<string name="action_yes_try_again">"Ano, zkusit znovu"</string>
<string name="banner_battery_optimization_content_android">"Zakažte optimalizaci baterie pro tuto aplikaci, abyste měli jistotu, že budou přijata všechna oznámení."</string>
<string name="banner_battery_optimization_submit_android">"Zakázat optimalizaci"</string>
<string name="banner_battery_optimization_title_android">"Nepřicházejí vám oznámení?"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Váš server nyní podporuje nový, rychlejší protokol. Chcete-li upgradovat, odhlaste se a znovu se přihlaste. Pokud to uděláte nyní, pomůže vám vyhnout se nucenému odhlášení, když bude starý protokol později odstraněn."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade k dispozici"</string>
<string name="common_about">"O aplikaci"</string>
@ -338,6 +341,7 @@ Opravdu chcete pokračovat?"</string>
<string name="error_room_address_invalid_symbols">"Některé znaky nejsou povoleny. Podporovány jsou pouze písmena, číslice a následující symboly ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="error_some_messages_have_not_been_sent">"Některé zprávy nebyly odeslány"</string>
<string name="error_unknown">"Omlouváme se, došlo k chybě"</string>
<string name="event_shield_mismatched_sender">"Odesílatel události se neshoduje s vlastníkem zařízení, které ji odeslalo."</string>
<string name="event_shield_reason_authenticity_not_guaranteed">"Autenticitu této zašifrované zprávy nelze na tomto zařízení zaručit."</string>
<string name="event_shield_reason_previously_verified">"Zašifrováno dříve ověřeným uživatelem."</string>
<string name="event_shield_reason_sent_in_clear">"Není zašifrováno."</string>

View file

@ -138,12 +138,16 @@
<string name="action_view_source">"Afficher la source"</string>
<string name="action_yes">"Oui"</string>
<string name="action_yes_try_again">"Oui, réessayez"</string>
<string name="banner_battery_optimization_content_android">"Désactivez loptimisation de la batterie pour cette application afin de vous assurer que toutes les notifications sont reçues."</string>
<string name="banner_battery_optimization_submit_android">"Désactiver loptimisation"</string>
<string name="banner_battery_optimization_title_android">"Ils vous manque des notifications?"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Votre serveur prend désormais en charge un nouveau protocole plus rapide. Déconnectez-vous, puis reconnectez-vous pour effectuer la mise à niveau dès maintenant. En le faisant tout de suite, vous éviterez une déconnexion forcée lorsque lancien protocole sera supprimé."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Mise à niveau disponible"</string>
<string name="common_about">"À propos"</string>
<string name="common_acceptable_use_policy">"Politique dutilisation acceptable"</string>
<string name="common_adding_caption">"Ajout dune légende"</string>
<string name="common_advanced_settings">"Paramètres avancés"</string>
<string name="common_an_image">"une image"</string>
<string name="common_analytics">"Statistiques dutilisation"</string>
<string name="common_appearance">"Apparence"</string>
<string name="common_audio">"Audio"</string>
@ -259,6 +263,7 @@ Raison : %1$s."</string>
<string name="common_sending">"Envoi en cours…"</string>
<string name="common_sending_failed">"Échec de lenvoi"</string>
<string name="common_sent">"Envoyé"</string>
<string name="common_sentence_delimiter">". "</string>
<string name="common_server_not_supported">"Serveur non pris en charge"</string>
<string name="common_server_url">"URL du serveur"</string>
<string name="common_settings">"Paramètres"</string>
@ -333,6 +338,7 @@ Raison : %1$s."</string>
<string name="error_room_address_invalid_symbols">"Certains caractères ne sont pas autorisés. Seuls les lettres, les chiffres et les symboles suivants sont utilisables ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="error_some_messages_have_not_been_sent">"Certains messages nont pas été envoyés"</string>
<string name="error_unknown">"Désolé, une erreur sest produite"</string>
<string name="event_shield_mismatched_sender">"Lexpéditeur de ce message ne correspond pas au propriétaire de lappareil qui la envoyé."</string>
<string name="event_shield_reason_authenticity_not_guaranteed">"Lauthenticité de ce message chiffré ne peut être garantie sur cet appareil."</string>
<string name="event_shield_reason_previously_verified">"Chiffré par un utilisateur précédemment vérifié."</string>
<string name="event_shield_reason_sent_in_clear">"Non chiffré."</string>
@ -371,7 +377,13 @@ Raison : %1$s."</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Messages épinglés"</string>
<string name="screen_room_pinned_banner_loading_description">"Chargement du message…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Voir tout"</string>
<string name="screen_room_timeline_tombstoned_room_action">"Aller dans le nouveau salon"</string>
<string name="screen_room_timeline_tombstoned_room_message">"Ce salon a été remplacé et nest plus actif"</string>
<string name="screen_room_timeline_upgraded_room_action">"Voir les anciens messages"</string>
<string name="screen_room_timeline_upgraded_room_message">"Ce salon est la continuation du salon précédent"</string>
<string name="screen_room_title">"Discussion"</string>
<string name="screen_roomlist_knock_event_sent_description">"Demande de rejoindre le salon envoyée"</string>
<string name="screen_roomlist_tombstoned_room_description">"Ce salon a été mis à niveau."</string>
<string name="screen_share_location_title">"Partage de position"</string>
<string name="screen_share_my_location_action">"Partager ma position"</string>
<string name="screen_share_open_apple_maps">"Ouvrir dans Apple Maps"</string>

View file

@ -138,6 +138,9 @@
<string name="action_view_source">"Forrás megtekintése"</string>
<string name="action_yes">"Igen"</string>
<string name="action_yes_try_again">"Igen, újrapróbálkozás"</string>
<string name="banner_battery_optimization_content_android">"Kapcsolja ki az alkalmazás akkumulátor-optimalizálását, hogy biztosan megkapja az összes értesítést."</string>
<string name="banner_battery_optimization_submit_android">"Optimalizálás letiltása"</string>
<string name="banner_battery_optimization_title_android">"Nem érkeznek meg az értesítések?"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"A kiszolgálója mostantól egy új, gyorsabb protokollt támogat. A frissítéshez jelentkezzen ki, majd jelentkezzen be újra. Ha ezt most megteszi, elkerülheti a kényszerített kijelentkeztetést a régi protokollt eltávolításakor."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Frissítés érhető el"</string>
<string name="common_about">"Névjegy"</string>
@ -333,6 +336,7 @@ Biztos, hogy folytatja?"</string>
<string name="error_room_address_invalid_symbols">"Egyes karakterek nem engedélyezettek. Csak a betűk, a számjegyek és a következő szimbólumok támogatottak: $ &amp; \'() * +/; =? @ [] - . _"</string>
<string name="error_some_messages_have_not_been_sent">"Néhány üzenet nem került elküldésre"</string>
<string name="error_unknown">"Elnézést, hiba történt"</string>
<string name="event_shield_mismatched_sender">"Az esemény feladója nem egyezik az eseményt küldő eszköz tulajdonosával."</string>
<string name="event_shield_reason_authenticity_not_guaranteed">"A titkosított üzenetek valódiságát ezen az eszközön nem lehet garantálni."</string>
<string name="event_shield_reason_previously_verified">"Egy korábban ellenőrzött felhasználó által titkosítva."</string>
<string name="event_shield_reason_sent_in_clear">"Nincs titkosítva."</string>

View file

@ -73,7 +73,7 @@
<string name="action_end_poll">"Avslutt avstemning"</string>
<string name="action_enter_pin">"Skriv inn PIN-koden"</string>
<string name="action_forgot_password">"Glemt passordet?"</string>
<string name="action_forward">"Fremover"</string>
<string name="action_forward">"Videresend"</string>
<string name="action_go_back">"Gå tilbake"</string>
<string name="action_ignore">"Ignorer"</string>
<string name="action_invite">"Inviter"</string>
@ -138,12 +138,16 @@
<string name="action_view_source">"Vis kilde"</string>
<string name="action_yes">"Ja"</string>
<string name="action_yes_try_again">"Ja, prøv igjen"</string>
<string name="banner_battery_optimization_content_android">"Deaktiver batterioptimalisering for denne appen for å sikre at alle varsler mottas."</string>
<string name="banner_battery_optimization_submit_android">"Deaktiver optimalisering"</string>
<string name="banner_battery_optimization_title_android">"Kommer ikke varslene frem?"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Serveren din støtter nå en ny, raskere protokoll. Logg ut og logg inn igjen for å oppgradere nå. Ved å gjøre dette nå, unngår du å bli tvunget til å logge ut når den gamle protokollen fjernes senere."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Oppgradering tilgjengelig"</string>
<string name="common_about">"Om"</string>
<string name="common_acceptable_use_policy">"Retningslinjer for akseptabel bruk"</string>
<string name="common_adding_caption">"Legger til bildetekst"</string>
<string name="common_advanced_settings">"Avanserte innstillinger"</string>
<string name="common_an_image">"et bilde"</string>
<string name="common_analytics">"Analyse"</string>
<string name="common_appearance">"Utseende"</string>
<string name="common_audio">"Lyd"</string>
@ -333,6 +337,7 @@ Er du sikker på at du vil fortsette?"</string>
<string name="error_room_address_invalid_symbols">"Noen tegn er ikke tillatt. Bare bokstaver, sifre og følgende symboler støttes! $ &amp; \'() * +/; =? @ [] - . _"</string>
<string name="error_some_messages_have_not_been_sent">"Noen meldinger er ikke sendt"</string>
<string name="error_unknown">"Beklager, det oppstod en feil"</string>
<string name="event_shield_mismatched_sender">"Avsenderen av hendelsen samsvarer ikke med eieren av enheten som sendte den."</string>
<string name="event_shield_reason_authenticity_not_guaranteed">"Ektheten til denne krypterte meldingen kan ikke garanteres på denne enheten."</string>
<string name="event_shield_reason_previously_verified">"Kryptert av en tidligere verifisert bruker."</string>
<string name="event_shield_reason_sent_in_clear">"Ikke kryptert."</string>
@ -343,6 +348,10 @@ Er du sikker på at du vil fortsette?"</string>
<string name="invite_friends_text">"Hei, snakk med meg på %1$s: %2$s"</string>
<string name="login_initial_device_name_android">"%1$s Android"</string>
<string name="preference_rageshake">"Rageshake for å rapportere feil"</string>
<string name="screen_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Alternativer"</string>
<string name="screen_create_poll_remove_accessibility_label">"Fjern %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Innstillinger"</string>
<string name="screen_media_picker_error_failed_selection">"Kunne ikke velge medium, prøv igjen."</string>
<string name="screen_media_upload_preview_caption_warning">"Teksting er kanskje ikke synlig for personer som bruker eldre apper."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Kunne ikke behandle medier for opplasting, vennligst prøv igjen."</string>
@ -371,7 +380,13 @@ Er du sikker på at du vil fortsette?"</string>
<string name="screen_room_pinned_banner_indicator_description">"%1$s Festede meldinger"</string>
<string name="screen_room_pinned_banner_loading_description">"Laster inn melding…"</string>
<string name="screen_room_pinned_banner_view_all_button_title">"Vis alle"</string>
<string name="screen_room_timeline_tombstoned_room_action">"Gå til nytt rom"</string>
<string name="screen_room_timeline_tombstoned_room_message">"Dette rommet har blitt erstattet og er ikke lenger aktivt"</string>
<string name="screen_room_timeline_upgraded_room_action">"Se gamle meldinger"</string>
<string name="screen_room_timeline_upgraded_room_message">"Dette rommet er en fortsettelse av et annet rom"</string>
<string name="screen_room_title">"Chat"</string>
<string name="screen_roomlist_knock_event_sent_description">"Forespørsel om å bli med sendt"</string>
<string name="screen_roomlist_tombstoned_room_description">"Dette rommet har blitt oppgradert"</string>
<string name="screen_share_location_title">"Del lokasjon"</string>
<string name="screen_share_my_location_action">"Del min lokasjon"</string>
<string name="screen_share_open_apple_maps">"Åpne i Apple Maps"</string>

View file

@ -138,12 +138,16 @@
<string name="action_view_source">"View source"</string>
<string name="action_yes">"Yes"</string>
<string name="action_yes_try_again">"Yes, try again"</string>
<string name="banner_battery_optimization_content_android">"Disable battery optimization for this app, to make sure all notifications are received."</string>
<string name="banner_battery_optimization_submit_android">"Disable optimization"</string>
<string name="banner_battery_optimization_title_android">"Notifications not arriving?"</string>
<string name="banner_migrate_to_native_sliding_sync_description">"Your server now supports a new, faster protocol. Log out and log back in to upgrade now. Doing this now will help you avoid a forced logout when the old protocol is removed later."</string>
<string name="banner_migrate_to_native_sliding_sync_title">"Upgrade available"</string>
<string name="common_about">"About"</string>
<string name="common_acceptable_use_policy">"Acceptable use policy"</string>
<string name="common_adding_caption">"Adding caption"</string>
<string name="common_advanced_settings">"Advanced settings"</string>
<string name="common_an_image">"an image"</string>
<string name="common_analytics">"Analytics"</string>
<string name="common_appearance">"Appearance"</string>
<string name="common_audio">"Audio"</string>
@ -240,6 +244,10 @@ Reason: %1$s."</string>
<string name="common_reason">"Reason"</string>
<string name="common_recovery_key">"Recovery key"</string>
<string name="common_refreshing">"Refreshing…"</string>
<plurals name="common_replies">
<item quantity="one">"%1$d reply"</item>
<item quantity="other">"%1$d replies"</item>
</plurals>
<string name="common_replying_to">"Replying to %1$s"</string>
<string name="common_report_a_bug">"Report a bug"</string>
<string name="common_report_a_problem">"Report a problem"</string>
@ -259,6 +267,7 @@ Reason: %1$s."</string>
<string name="common_sending">"Sending…"</string>
<string name="common_sending_failed">"Sending failed"</string>
<string name="common_sent">"Sent"</string>
<string name="common_sentence_delimiter">". "</string>
<string name="common_server_not_supported">"Server not supported"</string>
<string name="common_server_url">"Server URL"</string>
<string name="common_settings">"Settings"</string>
@ -333,6 +342,7 @@ Are you sure you want to continue?"</string>
<string name="error_room_address_invalid_symbols">"Some characters are not allowed. Only letters, digits and the following symbols are supported ! $ &amp; ( ) * + / ; = ? @ [ ] - . _"</string>
<string name="error_some_messages_have_not_been_sent">"Some messages have not been sent"</string>
<string name="error_unknown">"Sorry, an error occurred"</string>
<string name="event_shield_mismatched_sender">"The sender of the event does not match the owner of the device that sent it."</string>
<string name="event_shield_reason_authenticity_not_guaranteed">"The authenticity of this encrypted message can\'t be guaranteed on this device."</string>
<string name="event_shield_reason_previously_verified">"Encrypted by a previously-verified user."</string>
<string name="event_shield_reason_sent_in_clear">"Not encrypted."</string>
@ -343,6 +353,10 @@ Are you sure you want to continue?"</string>
<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_create_poll_option_accessibility_label">"%1$s: %2$s"</string>
<string name="screen_create_poll_options_section_title">"Options"</string>
<string name="screen_create_poll_remove_accessibility_label">"Remove %1$s"</string>
<string name="screen_create_poll_settings_section_title">"Settings"</string>
<string name="screen_media_picker_error_failed_selection">"Failed selecting media, please try again."</string>
<string name="screen_media_upload_preview_caption_warning">"Captions might not be visible to people using older apps."</string>
<string name="screen_media_upload_preview_error_failed_processing">"Failed processing media to upload, please try again."</string>

View file

@ -0,0 +1,3 @@
version https://git-lfs.github.com/spec/v1
oid sha256:5b5cab99263ef7a2ca101b3292221ce1c04f2f0e3a4e96b59cdcd0060bf5f613
size 25598

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