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
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue