Add forced logout flow when the proxy is no longer available (#3458)
* Add `MatrixClient.isSlidingSyncProxySupported` function * Update localazy strings * Modify `ErrorDialog` to have an `onSubmit` call, which will be used for the submit action. Also make the title text optional and dismissing the dialog by tapping outside/going back configurable. * Check if a forced migration to SSS is needed because the proxy is no longer available. In that case, display the non-dismissable dialog and force the user to log out after enabling SSS. * Enable native/simplified sliding sync by default. * Refactor the login to make sure we: 1. Always try native/simplified sliding sync login first, if available. 2. Then, if it wasn't available or failed with an sliding sync not supported error, try with the proxy instead (either discovered proxy or forced custom one). * Move logic to `LoggedInPresenter` and the UI to `LoggedInView` * Update screenshots --------- Co-authored-by: ElementBot <benoitm+elementbot@element.io>
This commit is contained in:
parent
da3f5e00dc
commit
663362ac7f
79 changed files with 315 additions and 231 deletions
|
|
@ -48,7 +48,7 @@ fun <T> AsyncActionView(
|
|||
ErrorDialog(
|
||||
title = errorTitle(async.error),
|
||||
content = errorMessage(async.error),
|
||||
onDismiss = onErrorDismiss
|
||||
onSubmit = onErrorDismiss
|
||||
)
|
||||
} else {
|
||||
RetryDialog(
|
||||
|
|
|
|||
|
|
@ -13,6 +13,7 @@ import androidx.compose.runtime.Composable
|
|||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.res.stringResource
|
||||
import androidx.compose.ui.tooling.preview.Preview
|
||||
import androidx.compose.ui.window.DialogProperties
|
||||
import io.element.android.libraries.designsystem.preview.ElementPreview
|
||||
import io.element.android.libraries.designsystem.preview.ElementThemedPreview
|
||||
import io.element.android.libraries.designsystem.preview.PreviewGroup
|
||||
|
|
@ -25,17 +26,23 @@ import io.element.android.libraries.ui.strings.CommonStrings
|
|||
@Composable
|
||||
fun ErrorDialog(
|
||||
content: String,
|
||||
onDismiss: () -> Unit,
|
||||
onSubmit: () -> Unit,
|
||||
modifier: Modifier = Modifier,
|
||||
title: String = ErrorDialogDefaults.title,
|
||||
title: String? = ErrorDialogDefaults.title,
|
||||
submitText: String = ErrorDialogDefaults.submitText,
|
||||
onDismiss: () -> Unit = onSubmit,
|
||||
canDismiss: Boolean = true,
|
||||
) {
|
||||
BasicAlertDialog(modifier = modifier, onDismissRequest = onDismiss) {
|
||||
BasicAlertDialog(
|
||||
modifier = modifier,
|
||||
onDismissRequest = onDismiss,
|
||||
properties = DialogProperties(dismissOnClickOutside = canDismiss, dismissOnBackPress = canDismiss)
|
||||
) {
|
||||
ErrorDialogContent(
|
||||
title = title,
|
||||
content = content,
|
||||
submitText = submitText,
|
||||
onSubmitClick = onDismiss,
|
||||
onSubmitClick = onSubmit,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
|
@ -44,7 +51,7 @@ fun ErrorDialog(
|
|||
private fun ErrorDialogContent(
|
||||
content: String,
|
||||
onSubmitClick: () -> Unit,
|
||||
title: String = ErrorDialogDefaults.title,
|
||||
title: String? = ErrorDialogDefaults.title,
|
||||
submitText: String = ErrorDialogDefaults.submitText,
|
||||
) {
|
||||
SimpleAlertDialogContent(
|
||||
|
|
@ -78,6 +85,6 @@ internal fun ErrorDialogContentPreview() {
|
|||
internal fun ErrorDialogPreview() = ElementPreview {
|
||||
ErrorDialog(
|
||||
content = "Content",
|
||||
onDismiss = {},
|
||||
onSubmit = {},
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -131,6 +131,9 @@ interface MatrixClient : Closeable {
|
|||
/** Returns `true` if the home server supports native sliding sync. */
|
||||
suspend fun isNativeSlidingSyncSupported(): Boolean
|
||||
|
||||
/** Returns `true` if the current session is using native sliding sync. */
|
||||
/** Returns `true` if the home server supports sliding sync using a proxy. */
|
||||
suspend fun isSlidingSyncProxySupported(): Boolean
|
||||
|
||||
/** Returns `true` if the current session is using native sliding sync, `false` if it's using a proxy. */
|
||||
fun isUsingNativeSlidingSync(): Boolean
|
||||
}
|
||||
|
|
|
|||
|
|
@ -534,6 +534,10 @@ class RustMatrixClient(
|
|||
return client.availableSlidingSyncVersions().contains(SlidingSyncVersion.Native)
|
||||
}
|
||||
|
||||
override suspend fun isSlidingSyncProxySupported(): Boolean {
|
||||
return client.availableSlidingSyncVersions().any { it is SlidingSyncVersion.Proxy }
|
||||
}
|
||||
|
||||
override fun isUsingNativeSlidingSync(): Boolean {
|
||||
return client.session().slidingSyncVersion == SlidingSyncVersion.Native
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@
|
|||
|
||||
package io.element.android.libraries.matrix.impl
|
||||
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.di.CacheDirectory
|
||||
import io.element.android.libraries.featureflag.api.FeatureFlagService
|
||||
|
|
@ -19,12 +18,10 @@ import io.element.android.libraries.matrix.impl.paths.getSessionPaths
|
|||
import io.element.android.libraries.matrix.impl.proxy.ProxyProvider
|
||||
import io.element.android.libraries.matrix.impl.util.anonymizedTokens
|
||||
import io.element.android.libraries.network.useragent.UserAgentProvider
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.sessionstorage.api.SessionData
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
import io.element.android.services.toolbox.api.systemclock.SystemClock
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.Session
|
||||
|
|
@ -47,7 +44,6 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
private val proxyProvider: ProxyProvider,
|
||||
private val clock: SystemClock,
|
||||
private val utdTracker: UtdTracker,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
private val featureFlagService: FeatureFlagService,
|
||||
) {
|
||||
suspend fun create(sessionData: SessionData): RustMatrixClient = withContext(coroutineDispatchers.io) {
|
||||
|
|
@ -55,7 +51,7 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
val client = getBaseClientBuilder(
|
||||
sessionPaths = sessionData.getSessionPaths(),
|
||||
passphrase = sessionData.passphrase,
|
||||
restore = true,
|
||||
slidingSyncType = ClientBuilderSlidingSync.Restored,
|
||||
)
|
||||
.homeserverUrl(sessionData.homeserverUrl)
|
||||
.username(sessionData.userId)
|
||||
|
|
@ -88,16 +84,8 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
internal suspend fun getBaseClientBuilder(
|
||||
sessionPaths: SessionPaths,
|
||||
passphrase: String?,
|
||||
restore: Boolean,
|
||||
slidingSyncType: ClientBuilderSlidingSync,
|
||||
): ClientBuilder {
|
||||
val slidingSync = when {
|
||||
// Always check restore first, since otherwise other values could accidentally override the already persisted config
|
||||
restore -> ClientBuilderSlidingSync.Restored
|
||||
AuthenticationConfig.SLIDING_SYNC_PROXY_URL != null -> ClientBuilderSlidingSync.CustomProxy(AuthenticationConfig.SLIDING_SYNC_PROXY_URL!!)
|
||||
appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first() -> ClientBuilderSlidingSync.Simplified
|
||||
else -> ClientBuilderSlidingSync.Discovered
|
||||
}
|
||||
|
||||
return ClientBuilder()
|
||||
.sessionPaths(
|
||||
dataPath = sessionPaths.fileDirectory.absolutePath,
|
||||
|
|
@ -117,9 +105,9 @@ class RustMatrixClientFactory @Inject constructor(
|
|||
)
|
||||
.run {
|
||||
// Apply sliding sync version settings
|
||||
when (slidingSync) {
|
||||
when (slidingSyncType) {
|
||||
ClientBuilderSlidingSync.Restored -> this
|
||||
is ClientBuilderSlidingSync.CustomProxy -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.Proxy(slidingSync.url))
|
||||
is ClientBuilderSlidingSync.CustomProxy -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.Proxy(slidingSyncType.url))
|
||||
ClientBuilderSlidingSync.Discovered -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DiscoverProxy)
|
||||
ClientBuilderSlidingSync.Simplified -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.DiscoverNative)
|
||||
ClientBuilderSlidingSync.ForcedSimplified -> slidingSyncVersionBuilder(SlidingSyncVersionBuilder.Native)
|
||||
|
|
|
|||
|
|
@ -8,6 +8,7 @@
|
|||
package io.element.android.libraries.matrix.impl.auth
|
||||
|
||||
import com.squareup.anvil.annotations.ContributesBinding
|
||||
import io.element.android.appconfig.AuthenticationConfig
|
||||
import io.element.android.libraries.core.coroutine.CoroutineDispatchers
|
||||
import io.element.android.libraries.core.extensions.mapFailure
|
||||
import io.element.android.libraries.di.AppScope
|
||||
|
|
@ -19,6 +20,7 @@ import io.element.android.libraries.matrix.api.auth.OidcDetails
|
|||
import io.element.android.libraries.matrix.api.auth.qrlogin.MatrixQrCodeLoginData
|
||||
import io.element.android.libraries.matrix.api.auth.qrlogin.QrCodeLoginStep
|
||||
import io.element.android.libraries.matrix.api.core.SessionId
|
||||
import io.element.android.libraries.matrix.impl.ClientBuilderSlidingSync
|
||||
import io.element.android.libraries.matrix.impl.RustMatrixClientFactory
|
||||
import io.element.android.libraries.matrix.impl.auth.qrlogin.QrErrorMapper
|
||||
import io.element.android.libraries.matrix.impl.auth.qrlogin.SdkQrCodeLoginData
|
||||
|
|
@ -28,6 +30,7 @@ import io.element.android.libraries.matrix.impl.keys.PassphraseGenerator
|
|||
import io.element.android.libraries.matrix.impl.mapper.toSessionData
|
||||
import io.element.android.libraries.matrix.impl.paths.SessionPaths
|
||||
import io.element.android.libraries.matrix.impl.paths.SessionPathsFactory
|
||||
import io.element.android.libraries.preferences.api.store.AppPreferencesStore
|
||||
import io.element.android.libraries.sessionstorage.api.LoggedInState
|
||||
import io.element.android.libraries.sessionstorage.api.LoginType
|
||||
import io.element.android.libraries.sessionstorage.api.SessionStore
|
||||
|
|
@ -35,9 +38,13 @@ import kotlinx.coroutines.CancellationException
|
|||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.withContext
|
||||
import org.matrix.rustcomponents.sdk.Client
|
||||
import org.matrix.rustcomponents.sdk.ClientBuilder
|
||||
import org.matrix.rustcomponents.sdk.HumanQrLoginException
|
||||
import org.matrix.rustcomponents.sdk.OidcConfiguration
|
||||
import org.matrix.rustcomponents.sdk.QrCodeData
|
||||
import org.matrix.rustcomponents.sdk.QrCodeDecodeException
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgress
|
||||
import org.matrix.rustcomponents.sdk.QrLoginProgressListener
|
||||
|
|
@ -55,6 +62,7 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
private val rustMatrixClientFactory: RustMatrixClientFactory,
|
||||
private val passphraseGenerator: PassphraseGenerator,
|
||||
private val oidcConfigurationProvider: OidcConfigurationProvider,
|
||||
private val appPreferencesStore: AppPreferencesStore,
|
||||
) : MatrixAuthenticationService {
|
||||
// Passphrase which will be used for new sessions. Existing sessions will use the passphrase
|
||||
// stored in the SessionData.
|
||||
|
|
@ -117,9 +125,10 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
withContext(coroutineDispatchers.io) {
|
||||
val emptySessionPath = rotateSessionPath()
|
||||
runCatching {
|
||||
val client = getBaseClientBuilder(emptySessionPath)
|
||||
.serverNameOrHomeserverUrl(homeserver)
|
||||
.build()
|
||||
val client = makeClient(sessionPaths = emptySessionPath) {
|
||||
serverNameOrHomeserverUrl(homeserver)
|
||||
}
|
||||
|
||||
currentClient = client
|
||||
val homeServerDetails = client.homeserverLoginDetails().map()
|
||||
currentHomeserver.value = homeServerDetails.copy(url = homeserver)
|
||||
|
|
@ -207,23 +216,24 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
|
||||
override suspend fun loginWithQrCode(qrCodeData: MatrixQrCodeLoginData, progress: (QrCodeLoginStep) -> Unit) =
|
||||
withContext(coroutineDispatchers.io) {
|
||||
val sdkQrCodeLoginData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData
|
||||
val emptySessionPaths = rotateSessionPath()
|
||||
val oidcConfiguration = oidcConfigurationProvider.get()
|
||||
val progressListener = object : QrLoginProgressListener {
|
||||
override fun onUpdate(state: QrLoginProgress) {
|
||||
Timber.d("QR Code login progress: $state")
|
||||
progress(state.toStep())
|
||||
}
|
||||
}
|
||||
runCatching {
|
||||
val client = rustMatrixClientFactory.getBaseClientBuilder(
|
||||
val client = makeQrCodeLoginClient(
|
||||
sessionPaths = emptySessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
restore = false,
|
||||
qrCodeData = sdkQrCodeLoginData,
|
||||
oidcConfiguration = oidcConfiguration,
|
||||
progressListener = progressListener,
|
||||
)
|
||||
.buildWithQrCode(
|
||||
qrCodeData = (qrCodeData as SdkQrCodeLoginData).rustQrCodeData,
|
||||
oidcConfiguration = oidcConfigurationProvider.get(),
|
||||
progressListener = object : QrLoginProgressListener {
|
||||
override fun onUpdate(state: QrLoginProgress) {
|
||||
Timber.d("QR Code login progress: $state")
|
||||
progress(state.toStep())
|
||||
}
|
||||
}
|
||||
)
|
||||
|
||||
client.use { rustClient ->
|
||||
val sessionData = rustClient.session()
|
||||
.toSessionData(
|
||||
|
|
@ -249,14 +259,80 @@ class RustMatrixAuthenticationService @Inject constructor(
|
|||
}
|
||||
}
|
||||
|
||||
private suspend fun getBaseClientBuilder(
|
||||
private suspend fun makeClient(
|
||||
sessionPaths: SessionPaths,
|
||||
) = rustMatrixClientFactory
|
||||
.getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
restore = false,
|
||||
)
|
||||
config: suspend ClientBuilder.() -> ClientBuilder,
|
||||
): Client {
|
||||
val slidingSyncType = getSlidingSyncType()
|
||||
if (slidingSyncType is ClientBuilderSlidingSync.Simplified) {
|
||||
Timber.d("Creating client with simplified sliding sync")
|
||||
try {
|
||||
return rustMatrixClientFactory
|
||||
.getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
slidingSyncType = slidingSyncType,
|
||||
)
|
||||
.run { config() }
|
||||
.build()
|
||||
} catch (e: HumanQrLoginException.SlidingSyncNotAvailable) {
|
||||
Timber.e(e, "Failed to create client with simplified sliding sync, trying with Proxy now")
|
||||
}
|
||||
}
|
||||
Timber.d("Creating client with Proxy sliding sync")
|
||||
return rustMatrixClientFactory
|
||||
.getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
slidingSyncType = getSlidingSyncProxy(),
|
||||
)
|
||||
.run { config() }
|
||||
.build()
|
||||
}
|
||||
|
||||
private suspend fun makeQrCodeLoginClient(
|
||||
sessionPaths: SessionPaths,
|
||||
passphrase: String?,
|
||||
qrCodeData: QrCodeData,
|
||||
oidcConfiguration: OidcConfiguration,
|
||||
progressListener: QrLoginProgressListener,
|
||||
): Client {
|
||||
val slidingSyncType = getSlidingSyncType()
|
||||
if (slidingSyncType is ClientBuilderSlidingSync.Simplified) {
|
||||
Timber.d("Creating client for QR Code login with simplified sliding sync")
|
||||
try {
|
||||
return rustMatrixClientFactory
|
||||
.getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
slidingSyncType = slidingSyncType,
|
||||
)
|
||||
.passphrase(passphrase)
|
||||
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener)
|
||||
} catch (e: HumanQrLoginException.SlidingSyncNotAvailable) {
|
||||
Timber.e(e, "Failed to create client with simplified sliding sync, trying with Proxy now")
|
||||
}
|
||||
}
|
||||
Timber.d("Creating client for QR Code login with Proxy sliding sync")
|
||||
return rustMatrixClientFactory
|
||||
.getBaseClientBuilder(
|
||||
sessionPaths = sessionPaths,
|
||||
passphrase = pendingPassphrase,
|
||||
slidingSyncType = getSlidingSyncProxy(),
|
||||
)
|
||||
.passphrase(passphrase)
|
||||
.buildWithQrCode(qrCodeData, oidcConfiguration, progressListener)
|
||||
}
|
||||
|
||||
private suspend fun getSlidingSyncType(nativeSlidingSyncFailed: Boolean = false) = when {
|
||||
appPreferencesStore.isSimplifiedSlidingSyncEnabledFlow().first() && !nativeSlidingSyncFailed -> ClientBuilderSlidingSync.Simplified
|
||||
else -> getSlidingSyncProxy()
|
||||
}
|
||||
|
||||
private fun getSlidingSyncProxy() = when {
|
||||
AuthenticationConfig.SLIDING_SYNC_PROXY_URL != null -> ClientBuilderSlidingSync.CustomProxy(AuthenticationConfig.SLIDING_SYNC_PROXY_URL!!)
|
||||
else -> ClientBuilderSlidingSync.Discovered
|
||||
}
|
||||
|
||||
private fun clear() {
|
||||
currentClient?.close()
|
||||
|
|
|
|||
|
|
@ -79,6 +79,7 @@ class FakeMatrixClient(
|
|||
private val userIdServerNameLambda: () -> String = { lambdaError() },
|
||||
private val getUrlLambda: (String) -> Result<String> = { lambdaError() },
|
||||
var isNativeSlidingSyncSupportedLambda: suspend () -> Boolean = { true },
|
||||
var isSlidingSyncProxySupportedLambda: suspend () -> Boolean = { true },
|
||||
var isUsingNativeSlidingSyncLambda: () -> Boolean = { true },
|
||||
) : MatrixClient {
|
||||
var setDisplayNameCalled: Boolean = false
|
||||
|
|
@ -324,6 +325,10 @@ class FakeMatrixClient(
|
|||
return isNativeSlidingSyncSupportedLambda()
|
||||
}
|
||||
|
||||
override suspend fun isSlidingSyncProxySupported(): Boolean {
|
||||
return isSlidingSyncProxySupportedLambda()
|
||||
}
|
||||
|
||||
override fun isUsingNativeSlidingSync(): Boolean {
|
||||
return isUsingNativeSlidingSyncLambda()
|
||||
}
|
||||
|
|
|
|||
|
|
@ -87,7 +87,7 @@ class DefaultAppPreferencesStore @Inject constructor(
|
|||
|
||||
override fun isSimplifiedSlidingSyncEnabledFlow(): Flow<Boolean> {
|
||||
return store.data.map { prefs ->
|
||||
prefs[simplifiedSlidingSyncKey] ?: false
|
||||
prefs[simplifiedSlidingSyncKey] ?: true
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -36,6 +36,7 @@
|
|||
<string name="action_back">"Back"</string>
|
||||
<string name="action_call">"Call"</string>
|
||||
<string name="action_cancel">"Cancel"</string>
|
||||
<string name="action_cancel_for_now">"Cancel for now"</string>
|
||||
<string name="action_choose_photo">"Choose photo"</string>
|
||||
<string name="action_clear">"Clear"</string>
|
||||
<string name="action_close">"Close"</string>
|
||||
|
|
@ -283,6 +284,12 @@ Reason: %1$s."</string>
|
|||
<string name="screen_pinned_timeline_screen_title_empty">"Pinned messages"</string>
|
||||
<string name="screen_reset_identity_confirmation_subtitle">"You\'re about to go to your %1$s account to reset your identity. Afterwards you\'ll be taken back to the app."</string>
|
||||
<string name="screen_reset_identity_confirmation_title">"Can\'t confirm? Go to your account to reset your identity."</string>
|
||||
<string name="screen_resolve_send_failure_changed_identity_primary_button_title">"Withdraw verification and send"</string>
|
||||
<string name="screen_resolve_send_failure_changed_identity_subtitle">"You can withdraw your verification and send this message anyway, or you can cancel for now and try again later after reverifying %1$s."</string>
|
||||
<string name="screen_resolve_send_failure_changed_identity_title">"Your message was not sent because %1$s’s verified identity has changed"</string>
|
||||
<string name="screen_resolve_send_failure_unsigned_device_primary_button_title">"Send message anyway"</string>
|
||||
<string name="screen_resolve_send_failure_unsigned_device_subtitle">"%1$s is using one or more unverified devices. You can send the message anyway, or you can cancel for now and try again later after %2$s has verified all their devices."</string>
|
||||
<string name="screen_resolve_send_failure_unsigned_device_title">"Your message was not sent because %1$s has not verified one or more devices"</string>
|
||||
<string name="screen_room_details_pinned_events_row_title">"Pinned messages"</string>
|
||||
<string name="screen_room_error_failed_processing_media">"Failed processing media to upload, please try again."</string>
|
||||
<string name="screen_room_error_failed_retrieving_user_details">"Could not retrieve user details"</string>
|
||||
|
|
@ -304,6 +311,8 @@ Reason: %1$s."</string>
|
|||
<string name="screen_share_open_google_maps">"Open in Google Maps"</string>
|
||||
<string name="screen_share_open_osm_maps">"Open in OpenStreetMap"</string>
|
||||
<string name="screen_share_this_location_action">"Share this location"</string>
|
||||
<string name="screen_timeline_item_menu_send_failure_changed_identity">"Message not sent because %1$s’s verified identity has changed."</string>
|
||||
<string name="screen_timeline_item_menu_send_failure_unsigned_device">"Message not sent because %1$s has not verified one or more devices."</string>
|
||||
<string name="screen_view_location_title">"Location"</string>
|
||||
<string name="settings_version_number">"Version: %1$s (%2$s)"</string>
|
||||
<string name="test_language_identifier">"en"</string>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue