Merge branch 'develop' into feature/fga/room-version-upgrade
This commit is contained in:
commit
e2b1ab2632
150 changed files with 2027 additions and 1121 deletions
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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) {}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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 à l’appel."</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 l’utilisation d’accessoires Bluetooth dans cette version d’Android. Sélectionnez une autre sortie audio."</string>
|
||||
<string name="screen_incoming_call_subtitle_android">"Appel Element entrant"</string>
|
||||
</resources>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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 = {},
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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. */
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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))
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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">"C’est la réponse gagnante"</string>
|
||||
</resources>
|
||||
|
|
@ -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>
|
||||
8
features/poll/api/src/main/res/values/localazy.xml
Normal file
8
features/poll/api/src/main/res/values/localazy.xml
Normal 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>
|
||||
|
|
@ -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))
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
) {
|
||||
|
|
|
|||
|
|
@ -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 n’ont pas été enregistrées. Êtes-vous certain de vouloir quitter ?"</string>
|
||||
<string name="screen_create_poll_delete_option_a11y">"Supprimer l’option %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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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))),
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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(),
|
||||
)
|
||||
}
|
||||
|
|
@ -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(),
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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")
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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,
|
||||
)
|
||||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -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
|
||||
)
|
||||
}
|
||||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>> {
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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 = "",
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</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>
|
||||
|
|
|
|||
|
|
@ -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 l’optimisation 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 l’optimisation"</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 l’ancien 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 d’utilisation acceptable"</string>
|
||||
<string name="common_adding_caption">"Ajout d’une 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 d’utilisation"</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 l’envoi"</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 ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</string>
|
||||
<string name="error_some_messages_have_not_been_sent">"Certains messages n’ont pas été envoyés"</string>
|
||||
<string name="error_unknown">"Désolé, une erreur s’est produite"</string>
|
||||
<string name="event_shield_mismatched_sender">"L’expéditeur de ce message ne correspond pas au propriétaire de l’appareil qui l’a envoyé."</string>
|
||||
<string name="event_shield_reason_authenticity_not_guaranteed">"L’authenticité 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 n’est 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>
|
||||
|
|
|
|||
|
|
@ -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: $ & \'() * +/; =? @ [] - . _"</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>
|
||||
|
|
|
|||
|
|
@ -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! $ & \'() * +/; =? @ [] - . _"</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>
|
||||
|
|
|
|||
|
|
@ -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 ! $ & ‘ ( ) * + / ; = ? @ [ ] - . _"</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>
|
||||
|
|
|
|||
|
|
@ -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
Loading…
Add table
Add a link
Reference in a new issue