From b923ee1a564d2febca040686d2d8396d6263e796 Mon Sep 17 00:00:00 2001 From: Uladzislau Hramyka Date: Mon, 19 Oct 2026 22:02:21 +0300 Subject: [PATCH 1/2] feat: add log in and add device via QR --- .../org/monogram/core/models/DeviceSession.kt | 16 + .../java/org/monogram/core/models/QrLogin.kt | 9 + .../core/ui/components/QrCodeScreen.kt | 108 +++++ .../java/org/monogram/core/ui/qr/QrCode.kt | 23 ++ feature/auth/build.gradle.kts | 2 + .../org/monogram/feature/auth/AuthStore.kt | 166 +++++++- .../monogram/feature/auth/ui/AuthContent.kt | 11 +- .../monogram/feature/auth/ui/AuthPhoneStep.kt | 7 + .../monogram/feature/auth/ui/AuthQrScreen.kt | 130 ++++++ .../monogram/feature/auth/ui/AuthScreen.kt | 5 + .../auth/src/main/res/values-es/strings.xml | 5 + .../auth/src/main/res/values-hy/strings.xml | 5 + .../auth/src/main/res/values-ja/strings.xml | 5 + .../src/main/res/values-pt-rBR/strings.xml | 5 + .../auth/src/main/res/values-ru/strings.xml | 5 + .../auth/src/main/res/values-sk/strings.xml | 5 + .../auth/src/main/res/values-tr/strings.xml | 5 + .../auth/src/main/res/values-uk/strings.xml | 5 + .../auth/src/main/res/values-zh/strings.xml | 5 + feature/auth/src/main/res/values/strings.xml | 5 + feature/settings/build.gradle.kts | 3 + feature/settings/src/main/AndroidManifest.xml | 5 +- .../monogram/feature/settings/DevicesStore.kt | 196 +++++++++ .../feature/settings/SettingsComponent.kt | 12 +- .../monogram/feature/settings/SettingsPage.kt | 6 + .../feature/settings/ui/DeviceQrCamera.kt | 163 ++++++++ .../feature/settings/ui/DeviceQrScanner.kt | 130 ++++++ .../feature/settings/ui/DevicesSettings.kt | 236 +++++++++++ .../feature/settings/ui/SettingsContent.kt | 187 +++++---- .../feature/settings/ui/SettingsHome.kt | 12 + .../src/main/res/values-es/strings.xml | 21 + .../src/main/res/values-hy/strings.xml | 21 + .../src/main/res/values-ja/strings.xml | 21 + .../src/main/res/values-pt-rBR/strings.xml | 21 + .../src/main/res/values-ru/strings.xml | 21 + .../src/main/res/values-sk/strings.xml | 21 + .../src/main/res/values-tr/strings.xml | 21 + .../src/main/res/values-uk/strings.xml | 21 + .../src/main/res/values-zh/strings.xml | 21 + .../settings/src/main/res/values/strings.xml | 21 + gradle/libs.versions.toml | 4 + native/Cargo.lock | 322 ++++++++++++++- native/mtproto-rs/Cargo.toml | 1 + native/mtproto-rs/src/api_invoke.rs | 1 + native/mtproto-rs/src/auth/auth_rpc.rs | 111 ++++- native/mtproto-rs/src/client/auth.rs | 145 +++++++ native/mtproto-rs/src/dto.rs | 22 + native/mtproto-rs/src/ffi.rs | 43 +- native/mtproto-rs/src/lib.rs | 2 + native/mtproto-rs/src/qr.rs | 65 +++ .../org/monogram/mtproto/MtprotoNative.kt | 13 + .../monogram/mtproto/MtprotoNativeUniFfi.kt | 10 + .../monogram_mtproto/monogram_mtproto.kt | 378 ++++++++++++++++++ .../network/bridge/session/LoginToken.kt | 51 +++ .../network/bridge/session/SessionCore.kt | 74 ++++ .../network/bridge/session/SessionOps.kt | 13 + 56 files changed, 2842 insertions(+), 100 deletions(-) create mode 100644 core/models/src/main/java/org/monogram/core/models/DeviceSession.kt create mode 100644 core/models/src/main/java/org/monogram/core/models/QrLogin.kt create mode 100644 core/ui/src/main/java/org/monogram/core/ui/components/QrCodeScreen.kt create mode 100644 core/ui/src/main/java/org/monogram/core/ui/qr/QrCode.kt create mode 100644 feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthQrScreen.kt create mode 100644 feature/settings/src/main/java/org/monogram/feature/settings/DevicesStore.kt create mode 100644 feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrCamera.kt create mode 100644 feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrScanner.kt create mode 100644 feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt create mode 100644 native/mtproto-rs/src/qr.rs create mode 100644 network/bridge/src/main/java/org/monogram/network/bridge/session/LoginToken.kt diff --git a/core/models/src/main/java/org/monogram/core/models/DeviceSession.kt b/core/models/src/main/java/org/monogram/core/models/DeviceSession.kt new file mode 100644 index 000000000..2af9fa647 --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/DeviceSession.kt @@ -0,0 +1,16 @@ +package org.monogram.core.models + +data class DeviceSession( + val hash: Long, + val current: Boolean, + val passwordPending: Boolean, + val deviceModel: String, + val platform: String, + val systemVersion: String, + val appName: String, + val appVersion: String, + val dateActive: Int, + val ip: String, + val country: String, + val region: String, +) diff --git a/core/models/src/main/java/org/monogram/core/models/QrLogin.kt b/core/models/src/main/java/org/monogram/core/models/QrLogin.kt new file mode 100644 index 000000000..fc29bcfbe --- /dev/null +++ b/core/models/src/main/java/org/monogram/core/models/QrLogin.kt @@ -0,0 +1,9 @@ +package org.monogram.core.models + +sealed interface QrLogin { + class Code(val link: String, val expires: Int) : QrLogin + + data class Authorized(val session: AuthSession) : QrLogin + + data object AwaitingPassword : QrLogin +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/components/QrCodeScreen.kt b/core/ui/src/main/java/org/monogram/core/ui/components/QrCodeScreen.kt new file mode 100644 index 000000000..abdd24a2d --- /dev/null +++ b/core/ui/src/main/java/org/monogram/core/ui/components/QrCodeScreen.kt @@ -0,0 +1,108 @@ +package org.monogram.core.ui.components + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.ColumnScope +import androidx.compose.foundation.layout.Spacer +import androidx.compose.foundation.layout.WindowInsets +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.height +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.layout.safeDrawing +import androidx.compose.foundation.layout.size +import androidx.compose.foundation.layout.widthIn +import androidx.compose.foundation.layout.windowInsetsPadding +import androidx.compose.foundation.rememberScrollState +import androidx.compose.foundation.shape.CircleShape +import androidx.compose.foundation.verticalScroll +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.ArrowBack +import androidx.compose.material3.FilledTonalIconButton +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Surface +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.vector.ImageVector +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp + +@Composable +fun QrCodeScreen( + title: String, + description: String, + icon: ImageVector, + backDescription: String, + onBack: () -> Unit, + modifier: Modifier = Modifier, + descriptionAlignment: TextAlign = TextAlign.Start, + content: @Composable ColumnScope.() -> Unit, +) { + Surface(modifier.fillMaxSize(), color = MaterialTheme.colorScheme.surfaceContainer) { + BoxWithConstraints(Modifier.fillMaxSize().windowInsetsPadding(WindowInsets.safeDrawing)) { + val contentGap = (maxHeight * 0.13f).coerceIn(32.dp, 112.dp) + Column( + modifier = + Modifier.align(Alignment.TopCenter) + .widthIn(max = 600.dp) + .fillMaxWidth() + .verticalScroll(rememberScrollState()) + .padding(horizontal = 24.dp, vertical = 16.dp), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Box(Modifier.fillMaxWidth().height(72.dp)) { + FilledTonalIconButton( + onClick = onBack, + modifier = Modifier.align(Alignment.CenterStart).size(44.dp), + shape = CircleShape, + colors = + IconButtonDefaults.filledTonalIconButtonColors( + containerColor = MaterialTheme.colorScheme.surfaceContainerHigh, + contentColor = MaterialTheme.colorScheme.onSurface, + ), + ) { + Icon( + Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = backDescription, + ) + } + Icon( + icon, + contentDescription = null, + tint = MaterialTheme.colorScheme.primary, + modifier = Modifier.align(Alignment.Center).size(48.dp), + ) + } + Spacer(Modifier.height(24.dp)) + Text( + title, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.displaySmall, + ) + Spacer(Modifier.height(24.dp)) + Text( + description, + modifier = Modifier.fillMaxWidth(), + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurfaceVariant, + textAlign = descriptionAlignment, + ) + Spacer(Modifier.height(contentGap)) + Column( + modifier = + Modifier.padding(horizontal = 24.dp).widthIn(max = 360.dp).fillMaxWidth(), + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(24.dp), + content = content, + ) + Spacer(Modifier.height(32.dp)) + } + } + } +} diff --git a/core/ui/src/main/java/org/monogram/core/ui/qr/QrCode.kt b/core/ui/src/main/java/org/monogram/core/ui/qr/QrCode.kt new file mode 100644 index 000000000..a422cd0a7 --- /dev/null +++ b/core/ui/src/main/java/org/monogram/core/ui/qr/QrCode.kt @@ -0,0 +1,23 @@ +package org.monogram.core.ui.qr + +import android.graphics.Bitmap +import uniffi.monogram_mtproto.decodeQrCode +import uniffi.monogram_mtproto.generateQrCode + +object QrCode { + fun render(text: String, size: Int, foreground: Int, background: Int): Bitmap { + require(size in 1..2048) + val matrix = generateQrCode(text, size.toUInt()) + val pixels = + IntArray(matrix.luma.size) { index -> + if (matrix.luma[index] == 0.toByte()) foreground else background + } + val side = matrix.size.toInt() + return Bitmap.createBitmap(pixels, side, side, Bitmap.Config.ARGB_8888) + } + + fun decode(luma: ByteArray, width: Int, height: Int): String? { + require(width in 1..2048 && height in 1..2048 && luma.size == width * height) + return decodeQrCode(luma, width.toUInt(), height.toUInt()) + } +} diff --git a/feature/auth/build.gradle.kts b/feature/auth/build.gradle.kts index b41b3f2b4..fc0fb811c 100644 --- a/feature/auth/build.gradle.kts +++ b/feature/auth/build.gradle.kts @@ -24,6 +24,8 @@ android { } dependencies { + implementation(libs.androidx.activity.compose) + implementation(libs.androidx.lifecycle.runtime.compose) implementation(project(":core:common")) implementation(project(":core:models")) implementation(libs.mvikotlin) diff --git a/feature/auth/src/main/java/org/monogram/feature/auth/AuthStore.kt b/feature/auth/src/main/java/org/monogram/feature/auth/AuthStore.kt index faf7be72b..866366e2e 100644 --- a/feature/auth/src/main/java/org/monogram/feature/auth/AuthStore.kt +++ b/feature/auth/src/main/java/org/monogram/feature/auth/AuthStore.kt @@ -5,13 +5,17 @@ import com.arkivanov.mvikotlin.core.store.Store import com.arkivanov.mvikotlin.core.store.StoreFactory import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor import kotlinx.coroutines.launch +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.isActive import org.monogram.core.common.AppLog import org.monogram.core.common.Outcome import org.monogram.core.common.telegram.TelegramError import org.monogram.core.models.AuthSession import org.monogram.core.models.LoadingUi import org.monogram.core.models.AuthState -import org.monogram.network.bridge.MtprotoClient +import org.monogram.core.models.QrLogin +import org.monogram.network.bridge.session.SessionOps interface AuthStore : Store { sealed interface Intent { @@ -24,10 +28,14 @@ interface AuthStore : Store data object SignIn : Intent data class SubmitPassword(val password: String) : Intent data object BackToPhone : Intent + data object StartQrLogin : Intent + data object PauseQrLogin : Intent + data object ResumeQrLogin : Intent } sealed interface Phase { data object PhoneEntry : Phase + data class QrEntry(val code: QrLogin.Code? = null) : Phase data class CodeEntry( val phone: String, val phoneCodeHash: String, @@ -66,7 +74,8 @@ interface AuthStore : Store internal class AuthStoreFactory( private val storeFactory: StoreFactory, - private val client: MtprotoClient, + private val client: SessionOps, + private val nowSeconds: () -> Long = { System.currentTimeMillis() / 1000 }, ) { fun create(): AuthStore = object : @@ -87,6 +96,7 @@ internal class AuthStoreFactory( data class Phase(val value: AuthStore.Phase) : Msg data class UseTestDc(val value: Boolean) : Msg data object RestartAuth : Msg + data object RestartQr : Msg data object BackToPhone : Msg } @@ -111,6 +121,11 @@ internal class AuthStoreFactory( private inner class ExecutorImpl : CoroutineExecutor() { + private var qrJob: Job? = null + private var passwordJob: Job? = null + private var qrPasswordPending = false + private var qrScreenActive = true + override fun executeIntent(intent: AuthStore.Intent) { when (intent) { is AuthStore.Intent.PhoneChanged -> dispatch(Msg.Phone(intent.value)) @@ -124,7 +139,132 @@ internal class AuthStoreFactory( AuthStore.Intent.ResendCode -> resendCode() AuthStore.Intent.SignIn -> signIn() is AuthStore.Intent.SubmitPassword -> submitPassword(intent.password) - AuthStore.Intent.BackToPhone -> dispatch(Msg.BackToPhone) + AuthStore.Intent.BackToPhone -> backToPhone() + AuthStore.Intent.StartQrLogin -> if (!state().loading) startQrLogin() + AuthStore.Intent.ResumeQrLogin -> { + qrScreenActive = true + if (qrJob?.isActive != true) { + when { + state().phase is AuthStore.Phase.QrEntry -> startQrLogin() + qrPasswordPending && state().phase is AuthStore.Phase.PasswordEntry && + !state().loading -> watchQrPassword() + } + } + } + AuthStore.Intent.PauseQrLogin -> { + qrScreenActive = false + qrJob?.cancel() + if (state().phase is AuthStore.Phase.QrEntry) { + dispatch(Msg.Phase(AuthStore.Phase.QrEntry())) + dispatch(Msg.Loading(false)) + } + } + } + } + + private fun backToPhone() { + qrJob?.cancel() + passwordJob?.cancel() + qrPasswordPending = false + dispatch(Msg.BackToPhone) + } + + private fun startQrLogin() { + qrJob?.cancel() + passwordJob?.cancel() + qrPasswordPending = false + dispatch(Msg.RestartQr) + dispatch(Msg.Loading(true)) + qrJob = scope.launch { + when (val result = client.setTestDc(state().useTestDc)) { + is Outcome.Err -> { + dispatch(Msg.Error(AuthStore.Error.Rpc(result.telegramError))) + dispatch(Msg.Loading(false)) + return@launch + } + is Outcome.Ok -> Unit + } + while (isActive) { + dispatch(Msg.Loading(true)) + val code = when (val result = client.exportLoginToken()) { + is Outcome.Err -> { + dispatch(Msg.Phase(AuthStore.Phase.QrEntry())) + dispatch(Msg.Error(AuthStore.Error.Rpc(result.telegramError))) + dispatch(Msg.Loading(false)) + return@launch + } + is Outcome.Ok -> when (val login = result.value) { + is QrLogin.Code -> login + QrLogin.AwaitingPassword -> { + qrPasswordPending = true + dispatch(Msg.Phase(AuthStore.Phase.PasswordEntry)) + dispatch(Msg.Loading(false)) + watchQrPassword() + return@launch + } + is QrLogin.Authorized -> { + dispatch(Msg.Phase(AuthStore.Phase.Authorized(login.session))) + dispatch(Msg.Loading(false)) + publish(AuthStore.Label.Authorized(login.session)) + return@launch + } + } + } + dispatch(Msg.Phase(AuthStore.Phase.QrEntry(code))) + dispatch(Msg.Loading(false)) + while (isActive && code.expires > nowSeconds() + 1) { + delay(1000) + if (code.expires <= nowSeconds() + 1) break + when (val update = client.pollLoginToken()) { + is Outcome.Ok -> if (update.value) break + is Outcome.Err -> { + dispatch(Msg.Phase(AuthStore.Phase.QrEntry())) + dispatch(Msg.Error(AuthStore.Error.Rpc(update.telegramError))) + return@launch + } + } + } + delay(100) + } + } + } + + private fun watchQrPassword() { + qrJob?.cancel() + if (!qrScreenActive) return + qrJob = scope.launch { + var pollsSinceProbe = 0 + while (isActive && qrPasswordPending && state().phase is AuthStore.Phase.PasswordEntry) { + delay(1000) + val result = client.pollLoginToken() + if (result is Outcome.Err && result.telegramError.requiresReauth) { + backToPhone() + return@launch + } + // A quiet update stream does not verify that the pending authorization exists. + if (result is Outcome.Ok && !result.value && ++pollsSinceProbe < 3) continue + pollsSinceProbe = 0 + when (val probe = client.exportLoginToken()) { + is Outcome.Ok -> when (val login = probe.value) { + QrLogin.AwaitingPassword -> Unit + is QrLogin.Code -> { + backToPhone() + return@launch + } + is QrLogin.Authorized -> { + qrPasswordPending = false + dispatch(Msg.Phase(AuthStore.Phase.Authorized(login.session))) + dispatch(Msg.Loading(false)) + publish(AuthStore.Label.Authorized(login.session)) + return@launch + } + } + is Outcome.Err -> if (probe.telegramError.requiresReauth) { + backToPhone() + return@launch + } + } + } } } @@ -255,7 +395,7 @@ internal class AuthStoreFactory( } private fun submitPassword(rawPassword: String) { - if (state().phase !is AuthStore.Phase.PasswordEntry) return + if (state().phase !is AuthStore.Phase.PasswordEntry || state().loading) return // Do not trim: cloud passwords may contain leading/trailing spaces. val password = rawPassword.ifEmpty { state().password } if (password.isEmpty()) { @@ -265,9 +405,11 @@ internal class AuthStoreFactory( dispatch(Msg.Password(password)) dispatch(Msg.Loading(true)) dispatch(Msg.Error(null)) - scope.launch { + qrJob?.cancel() + passwordJob = scope.launch { when (val result = client.checkPassword(password)) { is Outcome.Ok -> { + qrPasswordPending = false dispatch(Msg.Phase(AuthStore.Phase.Authorized(result.value.session))) publish(AuthStore.Label.Authorized(result.value.session)) } @@ -276,6 +418,10 @@ internal class AuthStoreFactory( when { isPasswordInvalid(rpc) -> dispatch(Msg.Error(AuthStore.Error.Rpc(rpc))) + qrPasswordPending && (rpc.requiresReauth || isStaleAuth(rpc)) -> { + backToPhone() + return@launch + } isStaleAuth(rpc) -> { dispatch(Msg.RestartAuth) val phone = state().phone.trim() @@ -291,6 +437,9 @@ internal class AuthStoreFactory( } } dispatch(Msg.Loading(false)) + if (qrPasswordPending && state().phase is AuthStore.Phase.PasswordEntry) { + watchQrPassword() + } } } } @@ -308,6 +457,13 @@ internal class AuthStoreFactory( is Msg.Error -> copy(error = msg.value) is Msg.Phase -> copy(phase = msg.value, error = null) is Msg.UseTestDc -> copy(useTestDc = msg.value, error = null) + Msg.RestartQr -> copy( + phase = AuthStore.Phase.QrEntry(), + code = "", + password = "", + loading = false, + error = null, + ) Msg.RestartAuth -> copy( phase = AuthStore.Phase.PhoneEntry, code = "", diff --git a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthContent.kt b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthContent.kt index 76b8aa560..fcc0e0e07 100644 --- a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthContent.kt +++ b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthContent.kt @@ -3,7 +3,8 @@ package org.monogram.feature.auth.ui import androidx.compose.animation.core.CubicBezierEasing import androidx.compose.runtime.Composable import androidx.compose.runtime.LaunchedEffect -import androidx.compose.runtime.collectAsState +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import androidx.lifecycle.compose.LifecycleStartEffect import androidx.compose.runtime.getValue import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalFocusManager @@ -38,11 +39,15 @@ fun AuthContent( modifier: Modifier = Modifier, onOpenProxy: (() -> Unit)? = null, ) { - val state by component.state.collectAsState() + val state by component.state.collectAsStateWithLifecycle() + LifecycleStartEffect(component) { + component.onIntent(AuthStore.Intent.ResumeQrLogin) + onStopOrDispose { component.onIntent(AuthStore.Intent.PauseQrLogin) } + } val keyboard = LocalSoftwareKeyboardController.current val focusManager = LocalFocusManager.current LaunchedEffect(state.phase) { - if (state.phase is AuthStore.Phase.Authorized) { + if (state.phase is AuthStore.Phase.Authorized || state.phase is AuthStore.Phase.QrEntry) { focusManager.clearFocus(force = true) keyboard?.hide() } diff --git a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthPhoneStep.kt b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthPhoneStep.kt index 750ba9ae1..9d871896b 100644 --- a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthPhoneStep.kt +++ b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthPhoneStep.kt @@ -31,6 +31,13 @@ internal fun AuthPhoneStep( onIntent: (AuthStore.Intent) -> Unit, ) { Column(verticalArrangement = Arrangement.spacedBy(12.dp)) { + androidx.compose.material3.TextButton( + onClick = { onIntent(AuthStore.Intent.StartQrLogin) }, + enabled = enabled, + modifier = Modifier.fillMaxWidth(), + ) { + Text(stringResource(R.string.auth_qr_login)) + } PhoneInput( value = state.phone, onValueChange = { onIntent(AuthStore.Intent.PhoneChanged(it)) }, diff --git a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthQrScreen.kt b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthQrScreen.kt new file mode 100644 index 000000000..fbf330495 --- /dev/null +++ b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthQrScreen.kt @@ -0,0 +1,130 @@ +package org.monogram.feature.auth.ui + +import android.graphics.Bitmap +import androidx.activity.compose.BackHandler +import androidx.compose.foundation.Image +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.BoxWithConstraints +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCode2 +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.OutlinedButton +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.getValue +import androidx.compose.runtime.produceState +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.graphics.FilterQuality +import androidx.compose.ui.graphics.ImageBitmap +import androidx.compose.ui.graphics.asImageBitmap +import androidx.compose.ui.graphics.luminance +import androidx.compose.ui.graphics.toArgb +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.text.style.TextAlign +import androidx.compose.ui.unit.dp +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import org.monogram.core.ui.components.QrCodeScreen +import org.monogram.core.ui.loading.MonogramLoading +import org.monogram.core.ui.qr.QrCode +import org.monogram.feature.auth.AuthStore +import org.monogram.feature.auth.R + +@Composable +internal fun AuthQrScreen( + state: AuthStore.State, + onIntent: (AuthStore.Intent) -> Unit, + modifier: Modifier, +) { + val onBack = { onIntent(AuthStore.Intent.BackToPhone) } + BackHandler(onBack = onBack) + val code = (state.phase as? AuthStore.Phase.QrEntry)?.code + val colors = MaterialTheme.colorScheme + val background = + if (colors.surfaceContainerHighest.luminance() > colors.onSurface.luminance()) { + colors.surfaceContainerHighest + } else { + colors.onSurface + } + val foreground = + if (colors.primary.luminance() < colors.inversePrimary.luminance()) { + colors.primary + } else { + colors.inversePrimary + } + val image by + produceState(null, code, foreground, background) { + if (code == null) { + value = null + } else { + value = + withContext(Dispatchers.Default) { + val ink = foreground.toArgb() + val bitmap = QrCode.render(code.link, 768, ink, background.toArgb()) + val pixels = IntArray(bitmap.width * bitmap.height) + bitmap.getPixels(pixels, 0, bitmap.width, 0, 0, bitmap.width, bitmap.height) + // The square QR is centered, so its first dark row marks the border inset. + val inset = pixels.indexOfFirst { it == ink } / bitmap.width + val side = bitmap.width - inset * 2 + Bitmap.createBitmap(bitmap, inset, inset, side, side).asImageBitmap() + } + } + } + QrCodeScreen( + title = stringResource(R.string.auth_qr_login), + description = stringResource(R.string.auth_qr_instructions), + icon = Icons.Outlined.QrCode2, + backDescription = stringResource(R.string.auth_cd_back), + onBack = onBack, + modifier = modifier, + descriptionAlignment = TextAlign.Center, + ) { + BoxWithConstraints( + Modifier.padding(horizontal = 16.dp) + .fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(24.dp)) + .background(background), + contentAlignment = Alignment.Center, + ) { + val qrImage = image.takeIf { code != null } + if (qrImage != null) { + Image( + qrImage, + contentDescription = stringResource(R.string.auth_qr_code), + modifier = Modifier.fillMaxSize().padding(24.dp), + filterQuality = FilterQuality.None, + ) + } else if (state.error == null) { + MonogramLoading(size = maxWidth / 2, color = foreground) + } + } + Column( + horizontalAlignment = Alignment.CenterHorizontally, + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + AuthInlineError(state.error?.let { errorText(it) }) + if (state.error != null) { + Button( + onClick = { onIntent(AuthStore.Intent.StartQrLogin) }, + enabled = !state.loading, + ) { + Text(stringResource(R.string.auth_qr_retry)) + } + } + OutlinedButton(onClick = onBack) { + Text(stringResource(R.string.auth_qr_use_phone)) + } + } + } +} diff --git a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthScreen.kt b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthScreen.kt index d013f547f..333c5c944 100644 --- a/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthScreen.kt +++ b/feature/auth/src/main/java/org/monogram/feature/auth/ui/AuthScreen.kt @@ -73,6 +73,7 @@ import org.monogram.feature.auth.normalizePhone internal fun authStepOf(phase: AuthStore.Phase): Int? = when (phase) { AuthStore.Phase.PhoneEntry -> 0 + is AuthStore.Phase.QrEntry -> 0 is AuthStore.Phase.CodeEntry -> 1 AuthStore.Phase.PasswordEntry -> 2 is AuthStore.Phase.Authorized -> null @@ -86,6 +87,10 @@ fun AuthScreen( onOpenProxy: (() -> Unit)? = null, modifier: Modifier = Modifier, ) { + if (state.phase is AuthStore.Phase.QrEntry) { + AuthQrScreen(state, onIntent, modifier) + return + } val step = authStepOf(state.phase) if (step == null) { Box(modifier.fillMaxSize().background(MaterialTheme.colorScheme.surface)) diff --git a/feature/auth/src/main/res/values-es/strings.xml b/feature/auth/src/main/res/values-es/strings.xml index b47911508..2909c53f6 100644 --- a/feature/auth/src/main/res/values-es/strings.xml +++ b/feature/auth/src/main/res/values-es/strings.xml @@ -44,4 +44,9 @@ El portapapeles está vacío Pegar código Ajustes de conexión + Iniciar sesión con código QR + Abre Telegram en tu otro dispositivo. En Ajustes, abre Dispositivos, toca Vincular dispositivo y escanea este código. + Código QR para iniciar sesión en Telegram + Reintentar + Iniciar sesión con número de teléfono diff --git a/feature/auth/src/main/res/values-hy/strings.xml b/feature/auth/src/main/res/values-hy/strings.xml index cca164155..1d985df7c 100644 --- a/feature/auth/src/main/res/values-hy/strings.xml +++ b/feature/auth/src/main/res/values-hy/strings.xml @@ -44,4 +44,9 @@ Սեղմատախտակը դատարկ է Տեղադրել կոդը Միացման կարգավորումներ + Մուտք գործել QR կոդով + Բացեք Telegram-ն այլ սարքում։ Կարգավորումներում բացեք Սարքեր բաժինը, սեղմեք Կապել սարք և սկանավորեք այս կոդը։ + Telegram մուտք գործելու QR կոդ + Կրկին փորձել + Մուտք գործել հեռախոսահամարով diff --git a/feature/auth/src/main/res/values-ja/strings.xml b/feature/auth/src/main/res/values-ja/strings.xml index 2d2aa5844..35209313a 100644 --- a/feature/auth/src/main/res/values-ja/strings.xml +++ b/feature/auth/src/main/res/values-ja/strings.xml @@ -44,4 +44,9 @@ クリップボードが空です コードを貼り付け 接続設定 + QRコードでログイン + 別のデバイスでTelegramを開いてください。設定で「デバイス」を開き、「デバイスを接続」をタップして、このコードをスキャンしてください。 + Telegramログイン用QRコード + 再試行 + 電話番号でログイン diff --git a/feature/auth/src/main/res/values-pt-rBR/strings.xml b/feature/auth/src/main/res/values-pt-rBR/strings.xml index ff3cf286a..6bf2d9abd 100644 --- a/feature/auth/src/main/res/values-pt-rBR/strings.xml +++ b/feature/auth/src/main/res/values-pt-rBR/strings.xml @@ -44,4 +44,9 @@ A área de transferência está vazia Colar código Configurações de conexão + Entrar com código QR + Abra o Telegram no outro dispositivo. Em Configurações, abra Dispositivos, toque em Conectar dispositivo e leia este código. + Código QR para entrar no Telegram + Tentar novamente + Entrar com número de telefone diff --git a/feature/auth/src/main/res/values-ru/strings.xml b/feature/auth/src/main/res/values-ru/strings.xml index 567ac2bf6..d9dc9c5e9 100644 --- a/feature/auth/src/main/res/values-ru/strings.xml +++ b/feature/auth/src/main/res/values-ru/strings.xml @@ -44,4 +44,9 @@ Буфер обмена пуст Вставить код Настройки соединения + Войти по QR-коду + Откройте Telegram на другом устройстве. В Настройках откройте Устройства, нажмите Подключить устройство и отсканируйте этот код. + QR-код для входа в Telegram + Повторить + Войти по номеру телефона diff --git a/feature/auth/src/main/res/values-sk/strings.xml b/feature/auth/src/main/res/values-sk/strings.xml index b6896aa6b..f91d4ca8a 100644 --- a/feature/auth/src/main/res/values-sk/strings.xml +++ b/feature/auth/src/main/res/values-sk/strings.xml @@ -44,4 +44,9 @@ Schránka je prázdna Vložiť kód Nastavenia pripojenia + Prihlásiť sa pomocou QR kódu + Otvorte Telegram na inom zariadení. V Nastaveniach otvorte Zariadenia, klepnite na Prepojiť zariadenie a naskenujte tento kód. + QR kód na prihlásenie do Telegramu + Skúsiť znova + Prihlásiť sa telefónnym číslom diff --git a/feature/auth/src/main/res/values-tr/strings.xml b/feature/auth/src/main/res/values-tr/strings.xml index 19d70e584..5122a73bf 100644 --- a/feature/auth/src/main/res/values-tr/strings.xml +++ b/feature/auth/src/main/res/values-tr/strings.xml @@ -44,4 +44,9 @@ Pano boş Kodu yapıştır Bağlantı ayarları + QR Koduyla Giriş Yap + Diğer cihazınızda Telegram uygulamasını açın. Ayarlar bölümünde Cihazlar sayfasını açın, Cihaz Bağla seçeneğine dokunun ve bu kodu tarayın. + Telegram giriş QR kodu + Tekrar Dene + Telefon Numarasıyla Giriş Yap diff --git a/feature/auth/src/main/res/values-uk/strings.xml b/feature/auth/src/main/res/values-uk/strings.xml index 3783fcd5e..afa15991c 100644 --- a/feature/auth/src/main/res/values-uk/strings.xml +++ b/feature/auth/src/main/res/values-uk/strings.xml @@ -44,4 +44,9 @@ Буфер обміну порожній Вставити код Налаштування з’єднання + Увійти за QR-кодом + Відкрийте Telegram на іншому пристрої. У Налаштуваннях відкрийте Пристрої, натисніть Підключити пристрій і відскануйте цей код. + QR-код для входу в Telegram + Спробувати знову + Увійти за номером телефону diff --git a/feature/auth/src/main/res/values-zh/strings.xml b/feature/auth/src/main/res/values-zh/strings.xml index 6046632f3..cd8d3cb64 100644 --- a/feature/auth/src/main/res/values-zh/strings.xml +++ b/feature/auth/src/main/res/values-zh/strings.xml @@ -44,4 +44,9 @@ 剪贴板为空 粘贴验证码 连接设置 + 通过二维码登录 + 在另一台设备上打开 Telegram。在设置中打开「设备」,点击「关联设备」,然后扫描此二维码。 + Telegram 登录二维码 + 重试 + 通过手机号码登录 diff --git a/feature/auth/src/main/res/values/strings.xml b/feature/auth/src/main/res/values/strings.xml index 3644a5038..d37cb0077 100644 --- a/feature/auth/src/main/res/values/strings.xml +++ b/feature/auth/src/main/res/values/strings.xml @@ -44,4 +44,9 @@ Clipboard is empty Paste code Connection settings + Log in with QR Code + Open Telegram on your other device. Open Devices in Settings, tap Link Desktop Device, and scan this code. + Telegram login QR code + Try Again + Log in with Phone Number diff --git a/feature/settings/build.gradle.kts b/feature/settings/build.gradle.kts index c1f0d0f63..4271230b7 100644 --- a/feature/settings/build.gradle.kts +++ b/feature/settings/build.gradle.kts @@ -27,6 +27,9 @@ android { } dependencies { + implementation(libs.androidx.camera.camera2) + implementation(libs.androidx.camera.lifecycle) + implementation(libs.androidx.camera.view) coreLibraryDesugaring(libs.desugar.jdk.libs) implementation(project(":core:common")) implementation(project(":core:models")) diff --git a/feature/settings/src/main/AndroidManifest.xml b/feature/settings/src/main/AndroidManifest.xml index 8072ee00d..9bfb6d066 100644 --- a/feature/settings/src/main/AndroidManifest.xml +++ b/feature/settings/src/main/AndroidManifest.xml @@ -1,2 +1,5 @@ - + + + + diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/DevicesStore.kt b/feature/settings/src/main/java/org/monogram/feature/settings/DevicesStore.kt new file mode 100644 index 000000000..32ced008c --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/DevicesStore.kt @@ -0,0 +1,196 @@ +package org.monogram.feature.settings + +import com.arkivanov.mvikotlin.core.store.Reducer +import com.arkivanov.mvikotlin.core.store.Store +import com.arkivanov.mvikotlin.core.store.StoreFactory +import com.arkivanov.mvikotlin.extensions.coroutines.CoroutineExecutor +import kotlinx.coroutines.Job +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.sync.Mutex +import kotlinx.coroutines.sync.withLock +import org.monogram.core.common.Outcome +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.DeviceSession +import org.monogram.network.bridge.session.LoginTokenFailure +import org.monogram.network.bridge.session.SessionOps +import org.monogram.network.bridge.session.loginTokenFailure + +interface DevicesStore : Store { + sealed interface Intent { + data object Resume : Intent + + data object Pause : Intent + + data object Refresh : Intent + + class Accept(val link: String) : Intent + + data class Revoke(val hash: Long) : Intent + + data class DismissLinked(val hash: Long) : Intent + } + + data class State( + val sessions: List = emptyList(), + val loading: Boolean = false, + val error: TelegramError? = null, + val tokenFailure: LoginTokenFailure? = null, + val linkedSession: DeviceSession? = null, + ) +} + +internal class DevicesStoreFactory( + private val factory: StoreFactory, + private val client: SessionOps, +) { + fun create(): DevicesStore = + object : + DevicesStore, + Store by factory.create( + name = "DevicesStore", + initialState = DevicesStore.State(), + executorFactory = { Executor() }, + reducer = Reducer { it }, + ) {} + + private inner class Executor : + CoroutineExecutor< + DevicesStore.Intent, + Nothing, + DevicesStore.State, + DevicesStore.State, + Nothing, + >() { + private val operationMutex = Mutex() + private var resumed = false + private var pollingJob: Job? = null + private var refreshFailed = false + + override fun executeIntent(intent: DevicesStore.Intent) { + if (intent == DevicesStore.Intent.Resume) { + resumed = true + watchPendingSessions() + return + } + if (intent == DevicesStore.Intent.Pause) { + resumed = false + pollingJob?.cancel() + pollingJob = null + return + } + if (intent is DevicesStore.Intent.DismissLinked) { + if (state().linkedSession?.hash == intent.hash) { + dispatch(state().copy(linkedSession = null)) + } + return + } + if (intent is DevicesStore.Intent.Refresh && state().loading) return + scope.launch { + operationMutex.withLock { + if ( + intent is DevicesStore.Intent.Revoke && + state().sessions.none { it.hash == intent.hash && !it.current } + ) + return@withLock + refreshFailed = false + dispatch(state().copy(loading = true, error = null, tokenFailure = null)) + when (intent) { + DevicesStore.Intent.Refresh -> refresh() + is DevicesStore.Intent.Accept -> + when (val result = client.acceptLoginToken(intent.link)) { + is Outcome.Ok -> { + val session = result.value + dispatch( + state() + .copy( + sessions = + (state().sessions.filterNot { + it.hash == session.hash + } + session) + .ordered(), + linkedSession = session, + ) + ) + } + is Outcome.Err -> failure(result) + } + is DevicesStore.Intent.Revoke -> + when (val result = client.revokeDeviceSession(intent.hash)) { + is Outcome.Ok -> + dispatch( + state() + .copy( + sessions = + state().sessions.filterNot { + it.hash == intent.hash + }, + linkedSession = + state().linkedSession?.takeUnless { + it.hash == intent.hash + }, + ) + ) + is Outcome.Err -> failure(result) + } + is DevicesStore.Intent.DismissLinked, + DevicesStore.Intent.Resume, + DevicesStore.Intent.Pause -> Unit + } + dispatch(state().copy(loading = false)) + watchPendingSessions() + } + } + } + + private fun watchPendingSessions() { + if ( + !resumed || + pollingJob?.isActive == true || + state().sessions.none { it.passwordPending } + ) + return + pollingJob = scope.launch { + while (state().sessions.any { it.passwordPending }) { + delay(1500) + operationMutex.withLock { + if (state().sessions.none { it.passwordPending }) return@launch + refresh() + } + } + } + } + + private suspend fun refresh() { + when (val result = client.getDeviceSessions()) { + is Outcome.Ok -> { + dispatch( + state() + .copy( + sessions = result.value.ordered(), + error = state().error.takeUnless { refreshFailed }, + tokenFailure = state().tokenFailure.takeUnless { refreshFailed }, + ) + ) + refreshFailed = false + } + is Outcome.Err -> failure(result, fromRefresh = true) + } + } + + private fun failure(result: Outcome.Err, fromRefresh: Boolean = false) { + refreshFailed = fromRefresh + val tokenFailure = result.telegramError.loginTokenFailure() + dispatch( + state() + .copy( + error = result.telegramError.takeIf { tokenFailure == null }, + tokenFailure = tokenFailure, + ) + ) + } + } +} + +private fun List.ordered() = + sortedWith(compareByDescending { it.current }.thenByDescending { it.dateActive }) diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt index dcc009eda..b3a51a025 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsComponent.kt @@ -37,7 +37,7 @@ import org.monogram.network.http.MediaRepository @OptIn(ExperimentalCoroutinesApi::class) class SettingsComponent( componentContext: ComponentContext, - storeFactory: StoreFactory, + private val storeFactory: StoreFactory, private val client: MtprotoClient, sessionStore: SessionMetadataStore?, warmup: OfflineWarmup?, @@ -72,6 +72,16 @@ class SettingsComponent( } val state: StateFlow = store.stateFlow + private val devicesStore by lazy { + instanceKeeper.getStore("devices") { DevicesStoreFactory(storeFactory, client).create() } + } + val devices: StateFlow by lazy { devicesStore.stateFlow } + fun onDevices(intent: DevicesStore.Intent) = devicesStore.accept(intent) + fun onDeviceScanned(link: String) { + if (pages.value.active.configuration != SettingsPage.DeviceScanner) return + pageNavigation.pop() + onDevices(DevicesStore.Intent.Accept(link)) + } val updateState: StateFlow = appUpdate?.state ?: MutableStateFlow(AppUpdateState.Idle) private val notificationsStore = instanceKeeper.getStore("notifications") { diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt index 9fe0197a6..83c4378a8 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/SettingsPage.kt @@ -8,6 +8,12 @@ sealed interface SettingsPage { @Serializable data object Home : SettingsPage + @Serializable + data object Devices : SettingsPage + + @Serializable + data object DeviceScanner : SettingsPage + @Serializable data object Proxy : SettingsPage diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrCamera.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrCamera.kt new file mode 100644 index 000000000..9cf6239d6 --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrCamera.kt @@ -0,0 +1,163 @@ +package org.monogram.feature.settings.ui + +import android.graphics.Outline +import android.util.Size +import android.view.View +import android.view.ViewOutlineProvider +import androidx.camera.core.Camera +import androidx.camera.core.CameraSelector +import androidx.camera.core.CameraState +import androidx.camera.core.ImageAnalysis +import androidx.camera.core.ImageProxy +import androidx.camera.core.Preview +import androidx.camera.core.resolutionselector.ResolutionSelector +import androidx.camera.core.resolutionselector.ResolutionStrategy +import androidx.camera.lifecycle.ProcessCameraProvider +import androidx.camera.view.PreviewView +import androidx.compose.runtime.Composable +import androidx.compose.runtime.DisposableEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.ui.Modifier +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.viewinterop.AndroidView +import androidx.core.content.ContextCompat +import androidx.lifecycle.Observer +import androidx.lifecycle.compose.LocalLifecycleOwner +import java.util.concurrent.Executor +import java.util.concurrent.Executors +import java.util.concurrent.atomic.AtomicBoolean +import org.monogram.core.ui.qr.QrCode + +@Composable +internal fun DeviceQrCamera( + onScanned: (String) -> Unit, + onFailure: () -> Unit, + modifier: Modifier, +) { + val context = LocalContext.current + val owner = LocalLifecycleOwner.current + val onResult by rememberUpdatedState(onScanned) + val onError by rememberUpdatedState(onFailure) + val view = + remember(context) { + PreviewView(context).apply { + implementationMode = PreviewView.ImplementationMode.COMPATIBLE + clipToOutline = true + outlineProvider = + object : ViewOutlineProvider() { + override fun getOutline(view: View, outline: Outline) { + outline.setRoundRect( + 0, + 0, + view.width, + view.height, + 24 * resources.displayMetrics.density, + ) + } + } + } + } + DisposableEffect(view, owner) { + val main = ContextCompat.getMainExecutor(context) + val executor = Executors.newSingleThreadExecutor() + val analyzer = QrFrameAnalyzer(main, { onResult(it) }, { onError() }) + val preview = Preview.Builder().build().apply { surfaceProvider = view.surfaceProvider } + val analysis = + ImageAnalysis.Builder() + .setBackpressureStrategy(ImageAnalysis.STRATEGY_KEEP_ONLY_LATEST) + .setResolutionSelector( + ResolutionSelector.Builder() + .setResolutionStrategy( + ResolutionStrategy( + Size(1280, 720), + ResolutionStrategy.FALLBACK_RULE_CLOSEST_LOWER, + ) + ) + .build() + ) + .build() + .apply { setAnalyzer(executor, analyzer) } + val future = ProcessCameraProvider.getInstance(context) + var provider: ProcessCameraProvider? = null + var camera: Camera? = null + var disposed = false + val observer = Observer { if (it.error != null) onError() } + future.addListener( + { + if (!disposed) { + try { + provider = future.get() + camera = + provider!!.bindToLifecycle( + owner, + CameraSelector.DEFAULT_BACK_CAMERA, + preview, + analysis, + ) + camera!!.cameraInfo.cameraState.observe(owner, observer) + } catch (_: Exception) { + onError() + } + } + }, + main, + ) + onDispose { + disposed = true + analyzer.stop() + analysis.clearAnalyzer() + camera?.cameraInfo?.cameraState?.removeObserver(observer) + provider?.unbind(preview, analysis) + executor.shutdown() + } + } + AndroidView(factory = { view }, modifier = modifier) +} + +internal class QrFrameAnalyzer( + private val main: Executor, + private val onScanned: (String) -> Unit, + private val onFailure: () -> Unit, +) : ImageAnalysis.Analyzer { + private val active = AtomicBoolean(true) + private val delivered = AtomicBoolean(false) + + override fun analyze(image: ImageProxy) { + try { + if (!active.get() || delivered.get()) return + val plane = image.planes.first() + val buffer = plane.buffer.duplicate() + val start = buffer.position() + val width = image.width + val height = image.height + val luma = ByteArray(width * height) + for (y in 0 until height) { + val row = start + y * plane.rowStride + if (plane.pixelStride == 1) { + buffer.position(row) + buffer.get(luma, y * width, width) + } else { + for (x in 0 until width) luma[y * width + x] = + buffer.get(row + x * plane.pixelStride) + } + } + // QR detection is rotation invariant; retain the full frame, including its quiet zone. + val result = QrCode.decode(luma, width, height) + if (result != null && delivered.compareAndSet(false, true)) { + main.execute { if (active.get()) onScanned(result) } + } + } catch (_: Exception) { + if (delivered.compareAndSet(false, true)) { + main.execute { if (active.get()) onFailure() } + } + } finally { + image.close() + } + } + + fun stop() { + active.set(false) + } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrScanner.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrScanner.kt new file mode 100644 index 000000000..8e7d15ee7 --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DeviceQrScanner.kt @@ -0,0 +1,130 @@ +package org.monogram.feature.settings.ui + +import android.Manifest +import android.content.Intent +import android.content.pm.PackageManager +import android.net.Uri +import android.provider.Settings +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.compose.foundation.background +import androidx.compose.foundation.border +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.aspectRatio +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.Button +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Text +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.platform.LocalContext +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.core.content.ContextCompat +import androidx.lifecycle.Lifecycle +import androidx.lifecycle.compose.LifecycleEventEffect +import org.monogram.core.ui.components.QrCodeScreen +import org.monogram.feature.settings.R + +@Composable +internal fun DeviceQrScanner(onScanned: (String) -> Unit, onBack: () -> Unit, modifier: Modifier) { + val context = LocalContext.current + val onResult by rememberUpdatedState(onScanned) + var granted by remember { + mutableStateOf( + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + ) + } + var cameraFailed by remember { mutableStateOf(false) } + var delivered by remember { mutableStateOf(false) } + val permission = + rememberLauncherForActivityResult(ActivityResultContracts.RequestPermission()) { + granted = it + } + LaunchedEffect(Unit) { if (!granted) permission.launch(Manifest.permission.CAMERA) } + LifecycleEventEffect(Lifecycle.Event.ON_RESUME) { + granted = + ContextCompat.checkSelfPermission(context, Manifest.permission.CAMERA) == + PackageManager.PERMISSION_GRANTED + } + QrCodeScreen( + title = stringResource(R.string.settings_devices_scan_title), + description = stringResource(R.string.settings_devices_scan_hint), + icon = Icons.Outlined.QrCodeScanner, + backDescription = stringResource(R.string.settings_back), + onBack = onBack, + modifier = modifier, + ) { + Box( + Modifier.fillMaxWidth() + .aspectRatio(1f) + .clip(RoundedCornerShape(32.dp)) + .background(MaterialTheme.colorScheme.surfaceContainerLow) + .border(8.dp, MaterialTheme.colorScheme.primary, RoundedCornerShape(32.dp)) + .padding(8.dp), + contentAlignment = Alignment.Center, + ) { + if (granted && !cameraFailed) { + DeviceQrCamera( + onScanned = { result -> + if (!delivered) { + delivered = true + onResult(result) + } + }, + onFailure = { cameraFailed = true }, + modifier = Modifier.fillMaxSize().clip(RoundedCornerShape(24.dp)), + ) + } else { + Column( + Modifier.fillMaxSize().padding(24.dp), + verticalArrangement = Arrangement.spacedBy(16.dp, Alignment.CenterVertically), + horizontalAlignment = Alignment.CenterHorizontally, + ) { + Text( + stringResource( + if (cameraFailed) R.string.settings_devices_camera_unavailable + else R.string.settings_devices_camera_permission + ), + style = MaterialTheme.typography.bodyLarge, + ) + Button( + onClick = { + if (cameraFailed) cameraFailed = false + else + context.startActivity( + Intent( + Settings.ACTION_APPLICATION_DETAILS_SETTINGS, + Uri.fromParts("package", context.packageName, null), + ) + ) + } + ) { + Text( + stringResource( + if (cameraFailed) R.string.settings_devices_refresh + else R.string.settings_devices_open_settings + ) + ) + } + } + } + } + } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt new file mode 100644 index 000000000..a6d636c26 --- /dev/null +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt @@ -0,0 +1,236 @@ +package org.monogram.feature.settings.ui + +import androidx.compose.foundation.layout.Arrangement +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.fillMaxWidth +import androidx.compose.foundation.layout.padding +import androidx.compose.foundation.lazy.LazyColumn +import androidx.compose.foundation.lazy.items +import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.outlined.Devices +import androidx.compose.material.icons.outlined.QrCodeScanner +import androidx.compose.material3.AlertDialog +import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.MaterialTheme +import androidx.compose.material3.Snackbar +import androidx.compose.material3.SnackbarDefaults +import androidx.compose.material3.SnackbarDuration +import androidx.compose.material3.SnackbarHost +import androidx.compose.material3.SnackbarHostState +import androidx.compose.material3.SnackbarResult +import androidx.compose.material3.Text +import androidx.compose.material3.TextButton +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.setValue +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.res.stringResource +import androidx.compose.ui.unit.dp +import androidx.lifecycle.compose.LifecycleResumeEffect +import androidx.lifecycle.compose.collectAsStateWithLifecycle +import java.text.DateFormat +import java.util.Date +import org.monogram.core.ui.components.AppStatusBanner +import org.monogram.core.ui.components.AppSyncStatus +import org.monogram.core.ui.components.SettingsCard +import org.monogram.core.ui.components.SettingsCardRow +import org.monogram.core.ui.components.SettingsTile +import org.monogram.core.ui.loading.MonogramLinearProgress +import org.monogram.feature.settings.DevicesStore +import org.monogram.feature.settings.R +import org.monogram.feature.settings.SettingsComponent +import org.monogram.feature.settings.SettingsPage +import org.monogram.network.bridge.session.LoginTokenFailure + +@Composable +internal fun DevicesSettings(component: SettingsComponent, modifier: Modifier) { + val state by component.devices.collectAsStateWithLifecycle() + LifecycleResumeEffect(component) { + component.onDevices(DevicesStore.Intent.Resume) + component.onDevices(DevicesStore.Intent.Refresh) + onPauseOrDispose { component.onDevices(DevicesStore.Intent.Pause) } + } + DevicesSettingsContent( + state = state, + onIntent = component::onDevices, + onScan = { component.openPage(SettingsPage.DeviceScanner) }, + modifier = modifier, + ) +} + +@Composable +internal fun DevicesSettingsContent( + state: DevicesStore.State, + onIntent: (DevicesStore.Intent) -> Unit, + onScan: () -> Unit, + modifier: Modifier = Modifier, +) { + var revokeHash by remember { mutableStateOf(null) } + val snackbar = remember { SnackbarHostState() } + val title = stringResource(R.string.settings_devices_linked) + val terminate = stringResource(R.string.settings_devices_terminate_action) + LaunchedEffect(state.linkedSession?.hash) { + val session = state.linkedSession ?: return@LaunchedEffect + if ( + snackbar.showSnackbar( + message = session.appName, + actionLabel = terminate, + duration = SnackbarDuration.Long, + ) == SnackbarResult.ActionPerformed + ) { + onIntent(DevicesStore.Intent.Revoke(session.hash)) + } + onIntent(DevicesStore.Intent.DismissLinked(session.hash)) + } + Box(modifier.fillMaxSize()) { + LazyColumn( + Modifier.fillMaxSize().padding(horizontal = 16.dp), + verticalArrangement = Arrangement.spacedBy(12.dp), + ) { + item { + SettingsTile( + icon = Icons.Outlined.QrCodeScanner, + iconColor = MaterialTheme.colorScheme.primary, + title = stringResource(R.string.settings_devices_link), + subtitle = stringResource(R.string.settings_devices_scan_hint), + onClick = onScan, + ) + } + if (state.loading) item { MonogramLinearProgress(modifier = Modifier.fillMaxWidth()) } + state.error?.let { error -> + item { + AppStatusBanner( + sync = AppSyncStatus.Hidden, + error = error, + onRetry = { onIntent(DevicesStore.Intent.Refresh) }, + ) + } + } + state.tokenFailure?.let { failure -> + item { + Text( + stringResource( + when (failure) { + LoginTokenFailure.Invalid -> R.string.settings_devices_invalid + LoginTokenFailure.Expired -> R.string.settings_devices_expired + LoginTokenFailure.AlreadyAccepted -> + R.string.settings_devices_already_accepted + LoginTokenFailure.AlreadyLoggedIn -> + R.string.settings_devices_already_logged_in + } + ), + color = MaterialTheme.colorScheme.error, + ) + } + } + item { + TextButton( + onClick = { onIntent(DevicesStore.Intent.Refresh) }, + enabled = !state.loading, + ) { + Text(stringResource(R.string.settings_devices_refresh)) + } + } + if (state.sessions.isEmpty() && !state.loading && state.error == null) + item { Text(stringResource(R.string.settings_devices_empty)) } + items(state.sessions, key = { it.hash }) { session -> + SettingsCard { + SettingsCardRow( + icon = Icons.Outlined.Devices, + iconColor = MaterialTheme.colorScheme.secondary, + title = session.deviceModel, + subtitle = + listOf( + "${session.appName} ${session.appVersion}", + "${session.platform} ${session.systemVersion}", + listOf(session.ip, session.country, session.region) + .filter { it.isNotBlank() } + .joinToString(" · "), + when { + session.current -> + stringResource(R.string.settings_devices_current) + session.passwordPending -> + stringResource( + R.string.settings_devices_password_pending + ) + else -> + stringResource( + R.string.settings_devices_last_active, + DateFormat.getDateTimeInstance( + DateFormat.MEDIUM, + DateFormat.SHORT, + ) + .format(Date(session.dateActive * 1000L)), + ) + }, + ) + .filter { it.isNotBlank() } + .joinToString("\n"), + ) + if (!session.current) { + TextButton( + onClick = { revokeHash = session.hash }, + enabled = !state.loading, + ) { + Text( + stringResource(R.string.settings_devices_terminate), + color = MaterialTheme.colorScheme.error, + ) + } + } + } + } + } + SnackbarHost( + hostState = snackbar, + modifier = Modifier.align(Alignment.BottomCenter).padding(16.dp), + ) { data -> + Snackbar( + action = { + TextButton( + onClick = data::performAction, + colors = + ButtonDefaults.textButtonColors( + contentColor = SnackbarDefaults.actionColor + ), + ) { + Text(data.visuals.actionLabel.orEmpty()) + } + } + ) { + Column(verticalArrangement = Arrangement.spacedBy(2.dp)) { + Text(title, style = MaterialTheme.typography.titleSmall) + Text(data.visuals.message, style = MaterialTheme.typography.bodyMedium) + } + } + } + } + revokeHash?.let { hash -> + AlertDialog( + onDismissRequest = { revokeHash = null }, + title = { Text(stringResource(R.string.settings_devices_terminate)) }, + text = { Text(stringResource(R.string.settings_devices_terminate_confirm)) }, + confirmButton = { + TextButton( + onClick = { + revokeHash = null + onIntent(DevicesStore.Intent.Revoke(hash)) + } + ) { + Text(stringResource(R.string.settings_devices_terminate)) + } + }, + dismissButton = { + TextButton(onClick = { revokeHash = null }) { + Text(stringResource(R.string.settings_cancel)) + } + }, + ) + } +} diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt index 208b9c430..22972bc90 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsContent.kt @@ -179,6 +179,8 @@ fun SettingsContent( ) val title = when (page) { SettingsPage.Home -> stringResource(R.string.settings_title) + SettingsPage.Devices -> stringResource(R.string.settings_devices) + SettingsPage.DeviceScanner -> stringResource(R.string.settings_devices_link) SettingsPage.Proxy -> stringResource(R.string.settings_proxy) SettingsPage.Data -> stringResource(R.string.settings_data) SettingsPage.DebugStats -> stringResource(R.string.settings_debug_stats) @@ -212,101 +214,103 @@ fun SettingsContent( containerColor = MaterialTheme.colorScheme.surface, contentWindowInsets = WindowInsets(0, 0, 0, 0), topBar = { - TopAppBar( - windowInsets = WindowInsets.statusBars, - title = { - AnimatedContent( - targetState = title to (page == SettingsPage.Home), - transitionSpec = { - fadeIn(tween(180)) togetherWith fadeOut(tween(120)) - }, - label = "settingsTitle", - ) { (barTitle, home) -> - if (home) { - AppBarSyncTitle( - idleTitle = barTitle, - sync = when { - state.loading && profile == null -> AppSyncStatus.Connecting - state.loading -> AppSyncStatus.Syncing - else -> AppSyncStatus.Hidden - }, - ) - } else { - Text( - barTitle, - style = MaterialTheme.typography.titleLargeEmphasized, - maxLines = 1, - ) - } - } - }, - navigationIcon = { - IconButton( - onClick = { - if (page == SettingsPage.Home) { - component.onBack() - } else { - component.popPage() - } - }, - ) { - Icon( - imageVector = Icons.AutoMirrored.Outlined.ArrowBack, - contentDescription = stringResource(R.string.settings_back), - ) - } - }, - actions = { - if (page == SettingsPage.Logs) { - LogsTopActions( - onShare = { - val kind = debugLogKind(logKindName) - logShareFailed = !shareDebugLog( - logContext, - DebugLog.exportText(kind, logQuery), - ) - }, - onClear = { confirmClearLogs = true }, - ) - } - if (page == SettingsPage.Folders && folders != null) { + if (page != SettingsPage.DeviceScanner) { + TopAppBar( + windowInsets = WindowInsets.statusBars, + title = { AnimatedContent( - targetState = folders.isEditing, + targetState = title to (page == SettingsPage.Home), transitionSpec = { fadeIn(tween(180)) togetherWith fadeOut(tween(120)) }, - label = "foldersActions", - ) { editing -> - Row { - if (editing) { - TextButton( - onClick = folders.onSave, - enabled = folders.canSave, - ) { - Text(stringResource(R.string.settings_save)) - } + label = "settingsTitle", + ) { (barTitle, home) -> + if (home) { + AppBarSyncTitle( + idleTitle = barTitle, + sync = when { + state.loading && profile == null -> AppSyncStatus.Connecting + state.loading -> AppSyncStatus.Syncing + else -> AppSyncStatus.Hidden + }, + ) + } else { + Text( + barTitle, + style = MaterialTheme.typography.titleLargeEmphasized, + maxLines = 1, + ) + } + } + }, + navigationIcon = { + IconButton( + onClick = { + if (page == SettingsPage.Home) { + component.onBack() } else { - IconButton(onClick = folders.onAdd) { - Icon( - imageVector = Icons.Outlined.Add, - contentDescription = stringResource(R.string.settings_folders_add), - ) - } - IconButton(onClick = folders.onRefresh) { - Icon( - imageVector = Icons.Outlined.Refresh, - contentDescription = stringResource(R.string.settings_folders_refresh), - ) + component.popPage() + } + }, + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.ArrowBack, + contentDescription = stringResource(R.string.settings_back), + ) + } + }, + actions = { + if (page == SettingsPage.Logs) { + LogsTopActions( + onShare = { + val kind = debugLogKind(logKindName) + logShareFailed = !shareDebugLog( + logContext, + DebugLog.exportText(kind, logQuery), + ) + }, + onClear = { confirmClearLogs = true }, + ) + } + if (page == SettingsPage.Folders && folders != null) { + AnimatedContent( + targetState = folders.isEditing, + transitionSpec = { + fadeIn(tween(180)) togetherWith fadeOut(tween(120)) + }, + label = "foldersActions", + ) { editing -> + Row { + if (editing) { + TextButton( + onClick = folders.onSave, + enabled = folders.canSave, + ) { + Text(stringResource(R.string.settings_save)) + } + } else { + IconButton(onClick = folders.onAdd) { + Icon( + imageVector = Icons.Outlined.Add, + contentDescription = stringResource(R.string.settings_folders_add), + ) + } + IconButton(onClick = folders.onRefresh) { + Icon( + imageVector = Icons.Outlined.Refresh, + contentDescription = stringResource(R.string.settings_folders_refresh), + ) + } } } } } - } - }, - colors = TopAppBarDefaults.topAppBarColors( - containerColor = MaterialTheme.colorScheme.surface, - ), - ) + }, + colors = TopAppBarDefaults.topAppBarColors( + containerColor = MaterialTheme.colorScheme.surface, + ), + ) + } }, ) { innerPadding -> Children( @@ -319,6 +323,15 @@ fun SettingsContent( .fillMaxSize() .background(MaterialTheme.colorScheme.surface)) { when (val visiblePage = child.instance) { + SettingsPage.Devices -> DevicesSettings( + component, + Modifier.padding(innerPadding), + ) + SettingsPage.DeviceScanner -> DeviceQrScanner( + onScanned = component::onDeviceScanned, + onBack = component::popPage, + modifier = Modifier, + ) SettingsPage.Home -> SettingsPageList(innerPadding) { if (state.error != null) { item { @@ -626,6 +639,8 @@ private fun SettingsPageList( private fun settingsPageKey(page: SettingsPage): String = when (page) { SettingsPage.Home -> "home" + SettingsPage.Devices -> "devices" + SettingsPage.DeviceScanner -> "device-scanner" SettingsPage.Folders -> "folders" SettingsPage.Data -> "data" SettingsPage.Proxy -> "proxy" diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt index 074edebef..e7a123d33 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/SettingsHome.kt @@ -12,6 +12,7 @@ import androidx.compose.material.icons.automirrored.outlined.Help import androidx.compose.material.icons.automirrored.outlined.KeyboardArrowRight import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.outlined.Campaign +import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.Favorite import androidx.compose.material.icons.outlined.Folder import androidx.compose.material.icons.outlined.Notifications @@ -120,6 +121,17 @@ internal fun LazyListScope.homeItems( trailingContent = { Chevron() }, ) } + item { + SettingsTile( + icon = Icons.Outlined.Devices, + title = stringResource(R.string.settings_devices), + subtitle = stringResource(R.string.settings_devices_sub), + iconColor = MaterialTheme.colorScheme.secondary, + position = ItemPosition.MIDDLE, + onClick = { onOpen(SettingsPage.Devices) }, + trailingContent = { Chevron() }, + ) + } item { SettingsTile( icon = Icons.Outlined.Storage, diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index 8d9749d24..bccda51e8 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -408,4 +408,25 @@ Probar %1$s Editar %1$s Menciones y respuestas + Dispositivos + Administrar dispositivos vinculados + Vincular dispositivo + Escanea el código QR de Telegram Desktop o Web para iniciar sesión. + Este no es un código QR válido para iniciar sesión en Telegram. + Este código QR ha caducado. Escanea el nuevo código. + Este código QR ya se ha utilizado. Genera uno nuevo. + Esta cuenta ya tiene una sesión iniciada en ese dispositivo. + Inicio de sesión correcto + Cerrar sesión + Actualizar + No hay dispositivos vinculados. + Este dispositivo + Esperando la contraseña de verificación en dos pasos en el nuevo dispositivo + Última actividad: %1$s + Cerrar sesión + ¿Cerrar la sesión de Telegram en este dispositivo? + La cámara no está disponible. Comprueba que otra aplicación no la esté usando. + Permite el acceso a la cámara en los ajustes de la aplicación para escanear códigos QR. + Abrir ajustes de la aplicación + Escanear código QR diff --git a/feature/settings/src/main/res/values-hy/strings.xml b/feature/settings/src/main/res/values-hy/strings.xml index 393ce905c..69eddcefd 100644 --- a/feature/settings/src/main/res/values-hy/strings.xml +++ b/feature/settings/src/main/res/values-hy/strings.xml @@ -408,4 +408,25 @@ Ստուգել %1$s Խմբագրել %1$s Հիշատակումներ և պատասխաններ + Սարքեր + Կառավարել կապված սարքերը + Կապել սարք + Մուտք գործելու համար սկանավորեք Telegram Desktop-ի կամ Web-ի QR կոդը։ + Սա Telegram մուտք գործելու վավեր QR կոդ չէ։ + Այս QR կոդի ժամկետը լրացել է։ Սկանավորեք նոր կոդը։ + Այս QR կոդն արդեն օգտագործվել է։ Ստեղծեք նոր կոդ։ + Այս հաշիվն արդեն մուտք է գործել այդ սարքում։ + Մուտքը հաջողվեց + Ավարտել + Թարմացնել + Կապված սարքեր չկան։ + Այս սարքը + Նոր սարքում սպասվում է երկքայլ հաստատման գաղտնաբառը + Վերջին ակտիվությունը՝ %1$s + Ավարտել աշխատաշրջանը + Դո՞ւրս գալ Telegram-ից այս սարքում։ + Տեսախցիկը հասանելի չէ։ Ստուգեք՝ արդյոք այն չի օգտագործվում այլ հավելվածի կողմից։ + QR կոդերը սկանավորելու համար հավելվածի կարգավորումներում թույլատրեք տեսախցիկի հասանելիությունը։ + Բացել հավելվածի կարգավորումները + Սկանավորել QR կոդը diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index 3d0751dd1..c6646c429 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -408,4 +408,25 @@ %1$s をテスト %1$s を編集 メンションと返信 + デバイス + 接続済みデバイスを管理 + デバイスを接続 + Telegram DesktopまたはWebのQRコードをスキャンしてログインしてください。 + 有効なTelegramログイン用QRコードではありません。 + このQRコードの有効期限が切れました。新しいコードをスキャンしてください。 + このQRコードは使用済みです。新しいコードを生成してください。 + このアカウントはそのデバイスですでにログインしています。 + ログインしました + 終了 + 更新 + 接続済みデバイスはありません。 + このデバイス + 新しいデバイスで2段階認証のパスワード入力を待っています + 最終アクティブ:%1$s + セッションを終了 + このデバイスでTelegramからログアウトしますか? + カメラを使用できません。他のアプリが使用していないか確認してください。 + ログイン用QRコードをスキャンするには、アプリの設定でカメラへのアクセスを許可してください。 + アプリの設定を開く + QRコードをスキャン diff --git a/feature/settings/src/main/res/values-pt-rBR/strings.xml b/feature/settings/src/main/res/values-pt-rBR/strings.xml index 0b3b05457..4b7a1166c 100644 --- a/feature/settings/src/main/res/values-pt-rBR/strings.xml +++ b/feature/settings/src/main/res/values-pt-rBR/strings.xml @@ -408,4 +408,25 @@ Testar %1$s Editar %1$s Menções e respostas + Dispositivos + Gerenciar dispositivos conectados + Conectar dispositivo + Leia o código QR do Telegram Desktop ou Web para entrar. + Este não é um código QR válido para entrar no Telegram. + Este código QR expirou. Leia o novo código. + Este código QR já foi usado. Gere um novo código. + Esta conta já está conectada nesse dispositivo. + Login realizado com sucesso + Encerrar + Atualizar + Nenhum dispositivo conectado para exibir. + Este dispositivo + Aguardando a senha de verificação em duas etapas no novo dispositivo + Última atividade: %1$s + Encerrar sessão + Sair do Telegram neste dispositivo? + A câmera está indisponível. Verifique se outro aplicativo está usando a câmera. + Permita o acesso à câmera nas configurações do aplicativo para ler códigos QR. + Abrir configurações do aplicativo + Ler código QR diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml index f8b61165c..1229a6f2e 100644 --- a/feature/settings/src/main/res/values-ru/strings.xml +++ b/feature/settings/src/main/res/values-ru/strings.xml @@ -408,4 +408,25 @@ Проверить %1$s Изменить %1$s Упоминания и ответы + Устройства + Управление подключёнными устройствами + Подключить устройство + Отсканируйте QR-код в Telegram Desktop или Web для входа. + Это недействительный QR-код для входа в Telegram. + Срок действия QR-кода истёк. Отсканируйте новый код. + Этот QR-код уже использован. Создайте новый код. + На этом устройстве уже выполнен вход в этот аккаунт. + Вход выполнен + Завершить + Обновить + Нет подключённых устройств. + Это устройство + Ожидание пароля двухэтапной аутентификации на новом устройстве + Последняя активность: %1$s + Завершить сеанс + Выйти из Telegram на этом устройстве? + Камера недоступна. Проверьте, не используется ли она другим приложением. + Разрешите доступ к камере в настройках приложения для сканирования QR-кодов. + Открыть настройки приложения + Сканировать QR-код diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml index 142098b69..b81397c3f 100644 --- a/feature/settings/src/main/res/values-sk/strings.xml +++ b/feature/settings/src/main/res/values-sk/strings.xml @@ -408,4 +408,25 @@ Otestovať %1$s Upraviť %1$s Zmienky a odpovede + Zariadenia + Spravovať prepojené zariadenia + Prepojiť zariadenie + Prihláste sa naskenovaním QR kódu v Telegrame Desktop alebo Web. + Toto nie je platný QR kód na prihlásenie do Telegramu. + Platnosť tohto QR kódu vypršala. Naskenujte nový kód. + Tento QR kód už bol použitý. Vygenerujte nový kód. + Tento účet je už na danom zariadení prihlásený. + Prihlásenie bolo úspešné + Ukončiť + Obnoviť + Žiadne prepojené zariadenia. + Toto zariadenie + Čaká sa na heslo dvojstupňového overenia na novom zariadení + Posledná aktivita: %1$s + Ukončiť reláciu + Odhlásiť sa z Telegramu na tomto zariadení? + Kamera nie je dostupná. Skontrolujte, či ju nepoužíva iná aplikácia. + Ak chcete skenovať QR kódy, povoľte prístup ku kamere v nastaveniach aplikácie. + Otvoriť nastavenia aplikácie + Skenovať QR kód diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml index a3de4f9e6..578cd7902 100644 --- a/feature/settings/src/main/res/values-tr/strings.xml +++ b/feature/settings/src/main/res/values-tr/strings.xml @@ -408,4 +408,25 @@ %1$s bağlantısını dene %1$s proxy’sini düzenle Bahsetmeler ve yanıtlar + Cihazlar + Bağlı cihazları yönet + Cihaz Bağla + Giriş yapmak için Telegram Desktop veya Web üzerindeki QR kodunu tarayın. + Bu, geçerli bir Telegram giriş QR kodu değil. + Bu QR kodunun süresi doldu. Yeni kodu tarayın. + Bu QR kodu zaten kullanıldı. Yeni bir kod oluşturun. + Bu hesapta o cihazdan zaten oturum açılmış. + Giriş başarılı + Sonlandır + Yenile + Gösterilecek bağlı cihaz yok. + Bu cihaz + Yeni cihazda iki adımlı doğrulama parolası bekleniyor + Son etkinlik: %1$s + Oturumu Sonlandır + Bu cihazda Telegram oturumu kapatılsın mı? + Kamera kullanılamıyor. Başka bir uygulamanın kamerayı kullanmadığından emin olun. + Giriş QR kodlarını taramak için uygulama ayarlarında kamera erişimine izin verin. + Uygulama Ayarlarını Aç + QR kodunu tara diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml index 5fc1a6cf9..b679175ea 100644 --- a/feature/settings/src/main/res/values-uk/strings.xml +++ b/feature/settings/src/main/res/values-uk/strings.xml @@ -408,4 +408,25 @@ Перевірити %1$s Змінити %1$s Згадки й відповіді + Пристрої + Керування підключеними пристроями + Підключити пристрій + Відскануйте QR-код у Telegram Desktop або Web для входу. + Це недійсний QR-код для входу в Telegram. + Термін дії QR-коду минув. Відскануйте новий код. + Цей QR-код уже використано. Створіть новий код. + На цьому пристрої вже виконано вхід у цей обліковий запис. + Вхід виконано + Завершити + Оновити + Немає підключених пристроїв. + Цей пристрій + Очікування пароля двоетапної перевірки на новому пристрої + Остання активність: %1$s + Завершити сеанс + Вийти з Telegram на цьому пристрої? + Камера недоступна. Перевірте, чи не використовує її інша програма. + Дозвольте доступ до камери в налаштуваннях програми для сканування QR-кодів. + Відкрити налаштування програми + Сканувати QR-код diff --git a/feature/settings/src/main/res/values-zh/strings.xml b/feature/settings/src/main/res/values-zh/strings.xml index 79beca369..d9be950b2 100644 --- a/feature/settings/src/main/res/values-zh/strings.xml +++ b/feature/settings/src/main/res/values-zh/strings.xml @@ -408,4 +408,25 @@ 测试 %1$s 编辑 %1$s 提及和回复 + 设备 + 管理已关联设备 + 关联设备 + 扫描 Telegram Desktop 或网页版上的二维码以登录。 + 这不是有效的 Telegram 登录二维码。 + 此二维码已过期,请扫描新二维码。 + 此二维码已被使用,请生成新二维码。 + 此账号已在该设备上登录。 + 登录成功 + 终止 + 刷新 + 没有已关联的设备。 + 此设备 + 正在等待在新设备上输入两步验证密码 + 上次活跃:%1$s + 终止会话 + 要在此设备上退出 Telegram 吗? + 相机不可用,请检查是否被其他应用占用。 + 请在应用设置中允许相机访问权限,以扫描登录二维码。 + 打开应用设置 + 扫描二维码 diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 0379ce182..6e7f93220 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -408,4 +408,25 @@ Test %1$s Edit %1$s Mentions and replies + Devices + Manage linked devices + Link Desktop Device + Scan the QR code on Telegram Desktop or Web to sign in. + This is not a valid Telegram login QR code. + This QR code has expired. Scan the new code. + This QR code has already been used. Generate a new code. + This account is already logged in on that device. + Login successful + Terminate + Refresh + No linked devices to show. + This device + Waiting for the two-step verification password on the new device + Last active: %1$s + Terminate Session + Sign out of Telegram on this device? + The camera is unavailable. Check that it is not being used by another app. + Allow camera access in app settings to scan login QR codes. + Open App Settings + Scan QR code diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 6217208f2..c220cf35a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -31,8 +31,12 @@ unifiedpush = "3.3.5" playServicesBase = "18.11.0" desugar-jdk = "2.1.5" libphonenumber = "9.0.40" +camera = "1.6.2" [libraries] +androidx-camera-camera2 = { module = "androidx.camera:camera-camera2", version.ref = "camera" } +androidx-camera-lifecycle = { module = "androidx.camera:camera-lifecycle", version.ref = "camera" } +androidx-camera-view = { module = "androidx.camera:camera-view", version.ref = "camera" } desugar-jdk-libs = { group = "com.android.tools", name = "desugar_jdk_libs", version.ref = "desugar-jdk" } androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycleRuntimeKtx" } jlatexmath = { group = "ru.noties", name = "jlatexmath-android", version.ref = "jlatexmath" } diff --git a/native/Cargo.lock b/native/Cargo.lock index 10aa823ab..02a15401f 100644 --- a/native/Cargo.lock +++ b/native/Cargo.lock @@ -52,6 +52,15 @@ dependencies = [ "memchr", ] +[[package]] +name = "android_system_properties" +version = "0.1.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ae221649c9976a6f6c56ae1facf410f3ddb33cc661c4b7b61020a912d4237fbc" +dependencies = [ + "libc", +] + [[package]] name = "anstyle" version = "1.0.14" @@ -174,6 +183,12 @@ version = "0.14.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b1b08cb4ecbb540b940016b0a0f35b7e48ab0cc372ab88150c351de4cc469959" +[[package]] +name = "bumpalo" +version = "3.20.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "72f5acc6cb2ba439de613abc23857ec3d78374d8ed5ac84e9d11336e87da8649" + [[package]] name = "bytes" version = "1.12.1" @@ -238,6 +253,17 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "4e7648175b45a9a48536d676f68d918270699102aa8dab5496df06904c914600" +[[package]] +name = "chrono" +version = "0.4.45" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1aa79e62e7697b8e29b513a68abacf485adcd1fe8284a4316c5ae868e6633327" +dependencies = [ + "iana-time-zone", + "num-traits", + "windows-link", +] + [[package]] name = "cipher" version = "0.5.2" @@ -294,6 +320,15 @@ version = "0.5.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "0c9ea0ac24bc397ab3c98583a3c9ba74fa56b09a4449bbe172b9b1ddb016027a" +[[package]] +name = "codepage-437" +version = "0.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e40c1169585d8d08e5675a39f2fc056cd19a258fc4cba5e3bbf4a9c1026de535" +dependencies = [ + "csv", +] + [[package]] name = "combine" version = "4.6.8" @@ -347,6 +382,12 @@ version = "0.8.7" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "773648b94d0e5d620f64f280777445740e61fe701025087ec8b57f45c791888b" +[[package]] +name = "core_detect" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7f8f80099a98041a3d1622845c271458a2d73e688351bf3cb999266764b81d48" + [[package]] name = "cpubits" version = "0.1.1" @@ -401,6 +442,27 @@ dependencies = [ "rand_core 0.10.1", ] +[[package]] +name = "csv" +version = "1.4.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "52cd9d68cf7efc6ddfaaee42e7288d3a99d613d4b50f76ce9827ae0c6e14f938" +dependencies = [ + "csv-core", + "itoa", + "ryu", + "serde_core", +] + +[[package]] +name = "csv-core" +version = "0.1.13" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "704a3c26996a80471189265814dbc2c257598b96b8a7feae2d31ace646bb9782" +dependencies = [ + "memchr", +] + [[package]] name = "ctr" version = "0.10.1" @@ -453,6 +515,20 @@ dependencies = [ "ctutils", ] +[[package]] +name = "encoding_rs" +version = "0.8.42" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "8e985e0451871ad22fb8d2b6b076e2028a502a0d3950998c2c5c0a4f9b5d9679" +dependencies = [ + "cfg-if", + "core_detect", + "multiversion_no_op", + "rustversion", + "scopeguard", + "simdutf8", +] + [[package]] name = "equivalent" version = "1.0.2" @@ -501,6 +577,30 @@ dependencies = [ "autocfg", ] +[[package]] +name = "futures-core" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92d699e522242e69e3003b94ecc1f960f3a5e015aa7c5d7486e65ad01dd94f5e" + +[[package]] +name = "futures-task" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cd417de3d1d015fc3bfd2b1ea46dfc7bab72ef86f1cc7cc9c78e728b34a6d1fd" + +[[package]] +name = "futures-util" +version = "0.3.34" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0d50a92467f8ba5dd6e3ee5d4bd04d73ab2e4e1c44474a0674821dfce14b79bc" +dependencies = [ + "futures-core", + "futures-task", + "pin-project-lite", + "slab", +] + [[package]] name = "generic-array" version = "0.14.7" @@ -590,6 +690,30 @@ dependencies = [ "typenum", ] +[[package]] +name = "iana-time-zone" +version = "0.1.65" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "e31bc9ad994ba00e440a8aa5c9ef0ec67d5cb5e5cb0cc7f8b744a35b389cc470" +dependencies = [ + "android_system_properties", + "core-foundation-sys", + "iana-time-zone-haiku", + "js-sys", + "log", + "wasm-bindgen", + "windows-core", +] + +[[package]] +name = "iana-time-zone-haiku" +version = "0.1.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f31827a206f56af32e590ba56d5d2d085f558508192593743f16b2306495269f" +dependencies = [ + "cc", +] + [[package]] name = "indexmap" version = "2.14.2" @@ -666,6 +790,17 @@ dependencies = [ "syn 2.0.119", ] +[[package]] +name = "js-sys" +version = "0.3.106" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7883d941dae510fb2d978fc3fe018c71c9e2892fd38854de3e8b92c2e5ad9cc5" +dependencies = [ + "cfg-if", + "futures-util", + "wasm-bindgen", +] + [[package]] name = "lazy_static" version = "1.5.1" @@ -754,12 +889,13 @@ dependencies = [ "indexmap", "jni", "monogram-mtproto-transport", - "num-bigint", + "num-bigint 0.5.1", "num-traits", "parking_lot", "pbkdf2", "rapidhash", "rustls-platform-verifier", + "rxing", "serde", "serde_json", "sha2 0.11.0", @@ -789,6 +925,12 @@ dependencies = [ "thiserror", ] +[[package]] +name = "multiversion_no_op" +version = "1.0.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "743fb55ba31b18fb1ecef6bdc9aa2743314978ac084044301a7eee33fb99a20d" + [[package]] name = "nom" version = "7.1.3" @@ -799,6 +941,30 @@ dependencies = [ "minimal-lexical", ] +[[package]] +name = "num" +version = "0.4.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "35bd024e8b2ff75562e5f34e7f4905839deb4b22955ef5e73d2fea1b9813cb23" +dependencies = [ + "num-bigint 0.4.8", + "num-complex", + "num-integer", + "num-iter", + "num-rational", + "num-traits", +] + +[[package]] +name = "num-bigint" +version = "0.4.8" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c89e69e7e0f03bea5ef08013795c25018e101932225a656383bd384495ecc367" +dependencies = [ + "num-integer", + "num-traits", +] + [[package]] name = "num-bigint" version = "0.5.1" @@ -825,6 +991,15 @@ dependencies = [ "zeroize", ] +[[package]] +name = "num-complex" +version = "0.4.6" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73f88a1307638156682bada9d7604135552957b7818057dcef22705b4d509495" +dependencies = [ + "num-traits", +] + [[package]] name = "num-integer" version = "0.1.47" @@ -844,6 +1019,17 @@ dependencies = [ "num-traits", ] +[[package]] +name = "num-rational" +version = "0.4.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "f83d14da390562dca69fc84082e73e548e1ad308d24accdedd2720017cb37824" +dependencies = [ + "num-bigint 0.4.8", + "num-integer", + "num-traits", +] + [[package]] name = "num-traits" version = "0.2.19" @@ -914,6 +1100,12 @@ version = "2.3.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "9b4f627cb1b25917193a259e49bdad08f671f8d9708acfd5fe0a8c1455d87220" +[[package]] +name = "pin-project-lite" +version = "0.2.17" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a89322df9ebe1c1578d689c92318e070967d1042b512afbe49518723f4e6d5cd" + [[package]] name = "pkcs1" version = "0.7.5" @@ -1210,6 +1402,22 @@ version = "1.0.23" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" +[[package]] +name = "rxing" +version = "0.9.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "842b2a567172af73f7d18ee076f7bbb4ed8545c933b11f43868f0b1bba203157" +dependencies = [ + "chrono", + "codepage-437", + "encoding_rs", + "num", + "once_cell", + "regex", + "thiserror", + "unicode-segmentation", +] + [[package]] name = "ryu" version = "1.0.23" @@ -1434,6 +1642,12 @@ version = "1.0.3" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ee5873ec9cce0195efcb7a4e9507a04cd49aec9c83d0389df45b1ef7ba2e649" +[[package]] +name = "slab" +version = "0.4.12" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "0c790de23124f9ab44544d7ac05d60440adc586479ce501c1d6d7da3cd8c9cf5" + [[package]] name = "smallvec" version = "1.16.2" @@ -1532,7 +1746,7 @@ dependencies = [ "cipher", "ctr", "getrandom 0.4.3", - "num-bigint", + "num-bigint 0.5.1", "num-integer", "num-traits", "rsa", @@ -1702,6 +1916,12 @@ version = "1.0.26" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "d245f478577f809a851594d02313b640fb437e0bb33866753cff937863096954" +[[package]] +name = "unicode-segmentation" +version = "1.13.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "c6f5d3c3b1bf09027a88a6bc961fc00497d651009560b5463668dc81b0fa87a8" + [[package]] name = "unicode-width" version = "0.2.2" @@ -1879,6 +2099,51 @@ version = "0.11.1+wasi-snapshot-preview1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ccf3ec651a847eb01de73ccad15eb7d99f80485de043efb2f370cd654f4ea44b" +[[package]] +name = "wasm-bindgen" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "9bb54f33acc68fd454578d9820b0bde1a1a3d17aa17bb7b6595806d02886d409" +dependencies = [ + "cfg-if", + "once_cell", + "rustversion", + "wasm-bindgen-macro", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-macro" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "2e29d0c35b16e224a7eeb5cd2d25e3e1968fbd65604117b44d3b789d00ee8535" +dependencies = [ + "quote", + "wasm-bindgen-macro-support", +] + +[[package]] +name = "wasm-bindgen-macro-support" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6f501a8bc3719dba86ef8ae4728879c08001bea749eb1333ac5b91e040e2a6b7" +dependencies = [ + "bumpalo", + "proc-macro2", + "quote", + "syn 3.0.6", + "wasm-bindgen-shared", +] + +[[package]] +name = "wasm-bindgen-shared" +version = "0.2.129" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23f0c9c52aa7cd7d77769a4cfe2a9adb1b331f489a41d912ce14513d5ab995c6" +dependencies = [ + "unicode-ident", +] + [[package]] name = "webpki-root-certs" version = "1.0.9" @@ -1906,12 +2171,65 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "windows-core" +version = "0.62.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "b8e83a14d34d0623b51dce9581199302a221863196a1dde71a7663a4c2be9deb" +dependencies = [ + "windows-implement", + "windows-interface", + "windows-link", + "windows-result", + "windows-strings", +] + +[[package]] +name = "windows-implement" +version = "0.60.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "053e2e040ab57b9dc951b72c264860db7eb3b0200ba345b4e4c3b14f67855ddf" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + +[[package]] +name = "windows-interface" +version = "0.59.3" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "3f316c4a2570ba26bbec722032c4099d8c8bc095efccdc15688708623367e358" +dependencies = [ + "proc-macro2", + "quote", + "syn 2.0.119", +] + [[package]] name = "windows-link" version = "0.2.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "f0805222e57f7521d6a62e36fa9163bc891acd422f971defe97d64e70d0a4fe5" +[[package]] +name = "windows-result" +version = "0.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7781fa89eaf60850ac3d2da7af8e5242a5ea78d1a11c49bf2910bb5a73853eb5" +dependencies = [ + "windows-link", +] + +[[package]] +name = "windows-strings" +version = "0.5.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7837d08f69c77cf6b07689544538e017c1bfcf57e34b4c0ff58e6c2cd3b37091" +dependencies = [ + "windows-link", +] + [[package]] name = "windows-sys" version = "0.52.0" diff --git a/native/mtproto-rs/Cargo.toml b/native/mtproto-rs/Cargo.toml index ea6489d73..37b87f8f4 100644 --- a/native/mtproto-rs/Cargo.toml +++ b/native/mtproto-rs/Cargo.toml @@ -18,6 +18,7 @@ bindgen-cli = ["uniffi/cli"] [dependencies] uniffi = { version = "0.32", default-features = false } parking_lot = "0.12" +rxing = { version = "0.9.3", default-features = false, features = ["qrcode", "encoders", "decoders", "encoding_rs"] } rapidhash = "4" compact_str = { version = "0.9", features = ["serde"] } smallvec = "1" diff --git a/native/mtproto-rs/src/api_invoke.rs b/native/mtproto-rs/src/api_invoke.rs index 82a51df75..916c519cc 100644 --- a/native/mtproto-rs/src/api_invoke.rs +++ b/native/mtproto-rs/src/api_invoke.rs @@ -300,6 +300,7 @@ fn replay_safe_method(id: u32) -> bool { HelpGetConfigRequest::ID | HelpGetAppConfigRequest::ID | AccountGetPasswordRequest::ID + | AccountGetAuthorizationsRequest::ID | MessagesGetDialogsRequest::ID | MessagesGetPinnedDialogsRequest::ID | MessagesGetWebPagePreviewRequest::ID diff --git a/native/mtproto-rs/src/auth/auth_rpc.rs b/native/mtproto-rs/src/auth/auth_rpc.rs index 9e348c51c..998a18a38 100644 --- a/native/mtproto-rs/src/auth/auth_rpc.rs +++ b/native/mtproto-rs/src/auth/auth_rpc.rs @@ -15,6 +15,115 @@ use crate::api_invoke; use crate::auth::srp; use crate::{AuthCodeSent, AuthSignedIn, MtprotoError}; +pub(crate) fn has_login_token_update(packet: &[u8]) -> bool { + use tellers_mtproto::codec::{BoxedDecode, Decoder, Limits}; + use tellers_mtproto::latest::api::{Update, Updates}; + let Ok(mut decoder) = Decoder::new( + packet, + Limits { + max_bytes: 1024 * 1024, + max_total_bytes: 1024 * 1024, + max_vector_len: 4096, + max_depth: 32, + }, + ) else { + return false; + }; + let Ok(updates) = Updates::decode_boxed(&mut decoder) else { + return false; + }; + if decoder.finish().is_err() { + return false; + } + match updates { + Updates::UpdateShort(short) => matches!(*short.update, Update::UpdateLoginToken(_)), + Updates::Updates(updates) => { + let Vector::Vector(updates) = *updates.updates; + updates + .field_1 + .iter() + .any(|u| matches!(**u, Update::UpdateLoginToken(_))) + } + Updates::UpdatesCombined(updates) => { + let Vector::Vector(updates) = *updates.updates; + updates + .field_1 + .iter() + .any(|u| matches!(**u, Update::UpdateLoginToken(_))) + } + _ => false, + } +} + +fn device_session( + authorization: tellers_mtproto::latest::api::Authorization, +) -> crate::DeviceSessionDto { + let tellers_mtproto::latest::api::Authorization::Authorization(a) = authorization; + crate::DeviceSessionDto { + hash: a.hash, + current: a.current.is_some(), + password_pending: a.password_pending.is_some(), + device_model: a.device_model, + platform: a.platform, + system_version: a.system_version, + app_name: a.app_name, + app_version: a.app_version, + date_active: a.date_active, + ip: a.ip, + country: a.country, + region: a.region, + } +} + +pub fn get_device_sessions( + snapshot: &mut Snapshot, + api_id: i32, +) -> Result, MtprotoError> { + use tellers_mtproto::latest::api::{AccountAuthorizations, AccountGetAuthorizationsRequest}; + let sessions: AccountAuthorizations = + api_invoke::invoke_api(snapshot, api_id, AccountGetAuthorizationsRequest {})?; + let AccountAuthorizations::AccountAuthorizations(sessions) = sessions; + let Vector::Vector(sessions) = *sessions.authorizations; + Ok(sessions + .field_1 + .into_iter() + .map(|a| device_session(*a)) + .collect()) +} + +pub fn accept_login_token( + snapshot: &mut Snapshot, + api_id: i32, + token: &[u8], +) -> Result { + use tellers_mtproto::latest::api::AuthAcceptLoginTokenRequest; + if token.is_empty() || token.len() > 512 { + return Err(MtprotoError::Message("RPC 400: AUTH_TOKEN_INVALID".into())); + } + let authorization = api_invoke::invoke_api( + snapshot, + api_id, + AuthAcceptLoginTokenRequest { + token: token.to_vec(), + }, + )?; + Ok(device_session(authorization)) +} + +pub fn revoke_device_session( + snapshot: &mut Snapshot, + api_id: i32, + hash: i64, +) -> Result<(), MtprotoError> { + use tellers_mtproto::latest::api::{AccountResetAuthorizationRequest, Bool}; + let result: Bool = + api_invoke::invoke_api(snapshot, api_id, AccountResetAuthorizationRequest { hash })?; + match result { + Bool::BoolTrue(_) => Ok(()), + _ => Err(MtprotoError::Message("session revocation failed".into())), + } +} + fn code_settings(logout_tokens: &[Vec]) -> Box { let logout_tokens = (!logout_tokens.is_empty()).then(|| { Box::new(Vector::Vector(VectorConstructor { @@ -48,7 +157,7 @@ fn user_id(user: &User) -> Result { } } -fn authorization_to_signed( +pub(crate) fn authorization_to_signed( auth: AuthAuthorization, dc_id: i32, ) -> Result { diff --git a/native/mtproto-rs/src/client/auth.rs b/native/mtproto-rs/src/client/auth.rs index 411738fa8..0879395ab 100644 --- a/native/mtproto-rs/src/client/auth.rs +++ b/native/mtproto-rs/src/client/auth.rs @@ -9,6 +9,151 @@ use crate::{AuthCodeSent, AuthSignedIn, MtprotoError}; use super::*; +pub(super) fn login_key_error(state: &ClientState, error: MtprotoError) -> MtprotoError { + // Missing login keys require a fresh QR. PFS key failures must stay on the + // temporary-key recovery path instead of logging out the permanent session. + if state.permanent.is_none() + && matches!(&error, MtprotoError::Message(message) if message == "MTProto transport error -404") + { + MtprotoError::Message("RPC 401: AUTH_KEY_INVALID".into()) + } else { + error + } +} + +pub fn export_login_token(handle: u64) -> Result { + use tellers_mtproto::latest::api::{ + AuthExportLoginTokenRequest, AuthImportLoginTokenRequest, AuthLoginToken, Vector, + VectorConstructor, + }; + let result = with_client_mut(handle, |state| { + if let Some(user_id) = state.user_id { + reject_if_dead(state)?; + return Ok(crate::QrLoginDto::Authorized { + session: AuthSignedIn { + user_id, + dc_id: state.snapshot.dc_id, + }, + }); + } + if state.session_dead && state.user_id.is_none() { + if state.session_dead_reason.as_deref() == Some("AUTH_KEY_UNREGISTERED") { + state.session_dead = false; + state.session_dead_reason = None; + } else { + recreate_mtproto_session(state)?; + } + } + let result = (|| { + let mut token: AuthLoginToken = call_with_migrate(state, |state| { + crate::api_invoke::invoke_api( + &mut state.snapshot, + state.api_id, + AuthExportLoginTokenRequest { + api_id: state.api_id, + api_hash: state.api_hash.clone(), + except_ids: Box::new(Vector::Vector(VectorConstructor { + field_0: 0, + field_1: vec![], + })), + }, + ) + })?; + for _ in 0..3 { + match token { + AuthLoginToken::AuthLoginToken(token) => { + return Ok(crate::QrLoginDto::Token { + token: token.token, + expires: token.expires, + }); + } + AuthLoginToken::AuthLoginTokenSuccess(success) => { + let signed = auth_rpc::authorization_to_signed( + *success.authorization, + state.snapshot.dc_id, + )?; + complete_sign_in(state, signed.user_id)?; + return Ok(crate::QrLoginDto::Authorized { session: signed }); + } + AuthLoginToken::AuthLoginTokenMigrateTo(migrate) => { + if !(1..=5).contains(&migrate.dc_id) { + return Err(MtprotoError::Message("invalid login datacenter".into())); + } + ensure_ready_after_migrate( + state, + MtprotoError::Message(format!("MIGRATE_{}", migrate.dc_id)), + )?; + token = crate::api_invoke::invoke_api( + &mut state.snapshot, + state.api_id, + AuthImportLoginTokenRequest { + token: migrate.token, + }, + )?; + } + _ => return Err(MtprotoError::Message("unexpected login token".into())), + } + } + Err(MtprotoError::Message( + "too many login datacenter migrations".into(), + )) + })(); + if matches!(result, Err(MtprotoError::PasswordRequired)) { + persist(state)?; + } + result.map_err(|error| login_key_error(state, error)) + })?; + if matches!(result, crate::QrLoginDto::Authorized { .. }) { + drop_updates_transport(handle); + } + Ok(result) +} + +pub fn poll_login_token(handle: u64) -> Result { + poll_login_token_with(handle, crate::rpc::receive_updates) +} + +pub(super) fn poll_login_token_with( + handle: u64, + receive: impl FnOnce(&mut Snapshot) -> Result>, MtprotoError>, +) -> Result { + with_client_mut(handle, |state| { + reject_if_dead(state)?; + let packets = + receive(&mut state.snapshot).map_err(|error| login_key_error(state, error))?; + Ok(packets + .iter() + .any(|packet| auth_rpc::has_login_token_update(packet))) + }) +} + +pub fn get_device_sessions(handle: u64) -> Result, MtprotoError> { + with_client_mut(handle, |state| { + call_with_migrate(state, |state| { + auth_rpc::get_device_sessions(&mut state.snapshot, state.api_id) + }) + }) +} + +pub fn accept_login_token( + handle: u64, + token: Vec, +) -> Result { + with_client_mut(handle, |state| { + call_with_migrate(state, |state| { + auth_rpc::accept_login_token(&mut state.snapshot, state.api_id, &token) + }) + }) +} + +pub fn revoke_device_session(handle: u64, hash: i64) -> Result<(), MtprotoError> { + with_client_mut(handle, |state| { + call_with_migrate(state, |state| { + auth_rpc::revoke_device_session(&mut state.snapshot, state.api_id, hash) + }) + }) +} + pub fn connect(handle: u64) -> Result<(), MtprotoError> { connect_with_binding(handle, crate::auth::bind_temp::negotiate_bound_temp) } diff --git a/native/mtproto-rs/src/dto.rs b/native/mtproto-rs/src/dto.rs index 775a2cda2..b9fdf254f 100644 --- a/native/mtproto-rs/src/dto.rs +++ b/native/mtproto-rs/src/dto.rs @@ -12,6 +12,28 @@ pub struct AuthSignedIn { pub dc_id: i32, } +#[derive(Clone, uniffi::Enum)] +pub enum QrLoginDto { + Token { token: Vec, expires: i32 }, + Authorized { session: AuthSignedIn }, +} + +#[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] +pub struct DeviceSessionDto { + pub hash: i64, + pub current: bool, + pub password_pending: bool, + pub device_model: String, + pub platform: String, + pub system_version: String, + pub app_name: String, + pub app_version: String, + pub date_active: i32, + pub ip: String, + pub country: String, + pub region: String, +} + /// Values sent in `initConnection` on a fresh transport. /// https://core.telegram.org/api/invoking #[derive(Debug, Clone, PartialEq, Eq, uniffi::Record)] diff --git a/native/mtproto-rs/src/ffi.rs b/native/mtproto-rs/src/ffi.rs index 0676460ce..b1cb3ca10 100644 --- a/native/mtproto-rs/src/ffi.rs +++ b/native/mtproto-rs/src/ffi.rs @@ -19,6 +19,7 @@ use crate::client::push_rpc; use crate::lottie; use crate::messages::read_receipts_rpc; use crate::perf; +use crate::qr; use crate::request_control; use crate::scheduler; use crate::session_crypto; @@ -26,7 +27,7 @@ use crate::session_file; use crate::vpx; use crate::waveform; use crate::{ - AuthCodeSent, AuthSignedIn, BotCallbackAnswerDto, ChatDto, ContactsSearchDto, DiscussionDto, + AuthCodeSent, AuthSignedIn, DeviceSessionDto, QrLoginDto, BotCallbackAnswerDto, ChatDto, ContactsSearchDto, DiscussionDto, FolderDto, ForumTopicsPageDto, GlobalMessageSearchDto, InlineBotResultsDto, InstantViewDto, JoinChatDto, LottieSize, MessageDto, MessagesPageDto, MtprotoError, NotifyExceptionDto, NotifySettingsDto, ProfileDto, @@ -35,6 +36,36 @@ use crate::{ WallpaperCatalogDto, }; +#[uniffi::export] +pub fn export_login_token(handle: u64) -> Result { + client::export_login_token(handle) +} + +#[uniffi::export] +pub fn poll_login_token(handle: u64) -> Result { + client::poll_login_token(handle) +} + +#[uniffi::export] +pub fn get_device_sessions( + handle: u64, +) -> Result, MtprotoError> { + client::get_device_sessions(handle) +} + +#[uniffi::export] +pub fn accept_login_token( + handle: u64, + token: Vec, +) -> Result { + client::accept_login_token(handle, token) +} + +#[uniffi::export] +pub fn revoke_device_session(handle: u64, hash: i64) -> Result<(), MtprotoError> { + client::revoke_device_session(handle, hash) +} + #[uniffi::export] pub fn get_wallpapers(handle: u64, hash: i64) -> Result { client::get_wallpapers(handle, hash) @@ -1289,3 +1320,13 @@ pub fn finish_waveform(handle: u64) -> Result, MtprotoError> { pub fn destroy_waveform(handle: u64) { waveform::destroy(handle) } + +#[uniffi::export] +pub fn generate_qr_code(text: String, size: u32) -> Result { + qr::generate(&text, size) +} + +#[uniffi::export] +pub fn decode_qr_code(luma: Vec, width: u32, height: u32) -> Result, MtprotoError> { + qr::decode(luma, width, height) +} diff --git a/native/mtproto-rs/src/lib.rs b/native/mtproto-rs/src/lib.rs index d4152aef5..2d26288e0 100644 --- a/native/mtproto-rs/src/lib.rs +++ b/native/mtproto-rs/src/lib.rs @@ -23,6 +23,7 @@ mod peers; mod perf; mod presence; mod profile; +mod qr; mod reply_markup; mod request_control; mod rpc; @@ -51,6 +52,7 @@ pub use ffi::*; pub use instant_view::InstantViewDto; pub use media::wallpaper_rpc::{WallpaperCatalogDto, WallpaperDto}; pub use media::{ProgressCallback, notify_progress, set_progress_callback}; +pub use qr::QrCodeMatrix; uniffi::setup_scaffolding!(); diff --git a/native/mtproto-rs/src/qr.rs b/native/mtproto-rs/src/qr.rs new file mode 100644 index 000000000..7bedd5238 --- /dev/null +++ b/native/mtproto-rs/src/qr.rs @@ -0,0 +1,65 @@ +use crate::MtprotoError; +use rxing::{ + common::HybridBinarizer, + qrcode::{QRCodeReader, QRCodeWriter}, + BarcodeFormat, BinaryBitmap, DecodeHints, EncodeHints, ImmutableReader, Luma8LuminanceSource, + LuminanceSource, Writer, +}; + +const MAX_SIDE: u32 = 2048; + +#[derive(uniffi::Record)] +pub struct QrCodeMatrix { + pub size: u32, + pub luma: Vec, +} + +pub fn generate(text: &str, size: u32) -> Result { + if text.is_empty() || text.len() > 4096 || size == 0 || size > MAX_SIDE { + return Err(MtprotoError::Message("invalid QR input".into())); + } + let hints = EncodeHints { + CharacterSet: (!text.is_ascii()).then(|| "UTF-8".into()), + ..Default::default() + }; + let matrix = QRCodeWriter + .encode_with_hints( + text, + &BarcodeFormat::QR_CODE, + size as i32, + size as i32, + &hints, + ) + .map_err(|_| MtprotoError::Message("QR encoding failed".into()))?; + let size = matrix.width(); + let luma = (0..size) + .flat_map(|y| (0..size).map(move |x| (x, y))) + .map(|(x, y)| if matrix.get(x, y) { 0 } else { 255 }) + .collect(); + Ok(QrCodeMatrix { size, luma }) +} + +pub fn decode(luma: Vec, width: u32, height: u32) -> Result, MtprotoError> { + if width == 0 + || height == 0 + || width > MAX_SIDE + || height > MAX_SIDE + || luma.len() != width as usize * height as usize + { + return Err(MtprotoError::Message("invalid QR image dimensions".into())); + } + let mut source = Luma8LuminanceSource::new(luma, width, height) + .map_err(|_| MtprotoError::Message("invalid QR image".into()))?; + let hints = DecodeHints { + TryHarder: Some(true), + ..Default::default() + }; + for _ in 0..2 { + let mut bitmap = BinaryBitmap::new(HybridBinarizer::new(source.clone())); + if let Ok(result) = QRCodeReader::new().immutable_decode_with_hints(&mut bitmap, &hints) { + return Ok(Some(result.getText().to_owned())); + } + source.invert(); + } + Ok(None) +} diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt index 67ecdfe5d..0922673ea 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNative.kt @@ -1,5 +1,7 @@ package org.monogram.mtproto +import uniffi.monogram_mtproto.DeviceSessionDto +import uniffi.monogram_mtproto.QrLoginDto import uniffi.monogram_mtproto.AuthCodeSent import uniffi.monogram_mtproto.AuthSignedIn import uniffi.monogram_mtproto.BotCallbackAnswerDto @@ -44,6 +46,17 @@ data class NativeJoinResult( ) interface MtprotoNative { + fun exportLoginToken(handle: Long): QrLoginDto = + throw UnsupportedOperationException("QR login") + fun pollLoginToken(handle: Long): Boolean = throw UnsupportedOperationException("QR login") + + fun getDeviceSessions(handle: Long): List = + throw UnsupportedOperationException("device sessions") + fun acceptLoginToken(handle: Long, token: ByteArray): DeviceSessionDto = + throw UnsupportedOperationException("QR login") + fun revokeDeviceSession(handle: Long, hash: Long): Unit = + throw UnsupportedOperationException("device session revocation") + fun setTransportMode(mode: String) = Unit fun setProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray) = Unit fun pingProxy(kind: String, host: String, port: Int, username: String?, password: String?, secret: ByteArray): Long = diff --git a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt index bf81a8925..015c6d452 100644 --- a/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt +++ b/native/mtproto/src/main/java/org/monogram/mtproto/MtprotoNativeUniFfi.kt @@ -1,5 +1,6 @@ package org.monogram.mtproto +import uniffi.monogram_mtproto.DeviceSessionDto import uniffi.monogram_mtproto.AuthCodeSent import uniffi.monogram_mtproto.AuthSignedIn import uniffi.monogram_mtproto.BotCallbackAnswerDto @@ -36,6 +37,9 @@ import uniffi.monogram_mtproto.UpdatesStateDto import uniffi.monogram_mtproto.UploadItemDto import uniffi.monogram_mtproto.uniffiEnsureInitialized import java.io.File +import uniffi.monogram_mtproto.acceptLoginToken as nativeAcceptLoginToken +import uniffi.monogram_mtproto.getDeviceSessions as nativeGetDeviceSessions +import uniffi.monogram_mtproto.revokeDeviceSession as nativeRevokeDeviceSession import uniffi.monogram_mtproto.animatedEmojiMax as nativeAnimatedEmojiMax import uniffi.monogram_mtproto.appendTodoItems as nativeAppendTodoItems import uniffi.monogram_mtproto.checkPassword as nativeCheckPassword @@ -146,6 +150,12 @@ import uniffi.monogram_mtproto.updateNotifySettings as nativeUpdateNotifySetting import uniffi.monogram_mtproto.updateStatus as nativeUpdateStatus object MtprotoNativeUniFfi : MtprotoNative { + override fun getDeviceSessions(handle: Long) = nativeGetDeviceSessions(handle.toULong()) + override fun acceptLoginToken(handle: Long, token: ByteArray) = nativeAcceptLoginToken(handle.toULong(), token) + override fun revokeDeviceSession(handle: Long, hash: Long) = nativeRevokeDeviceSession(handle.toULong(), hash) + override fun exportLoginToken(handle: Long) = uniffi.monogram_mtproto.exportLoginToken(handle.toULong()) + override fun pollLoginToken(handle: Long) = uniffi.monogram_mtproto.pollLoginToken(handle.toULong()) + override fun downloadMessageMediaChunk(handle: Long, chatId: Long, messageId: Int, destPath: String, offset: Long): String = uniffi.monogram_mtproto.downloadMessageMediaChunk(handle.toULong(), chatId, messageId, destPath, offset) override fun createRequestControl(): Long = uniffi.monogram_mtproto.createRequestControl().toLong() diff --git a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt index 43ad007e2..1ee954325 100644 --- a/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt +++ b/native/mtproto/src/main/java/uniffi/monogram_mtproto/monogram_mtproto.kt @@ -694,6 +694,8 @@ internal object IntegrityCheckingUniffiLib { uniffiCheckContractApiVersion(this) uniffiCheckApiChecksums(this) } + external fun uniffi_monogram_mtproto_checksum_func_accept_login_token( + ): Int external fun uniffi_monogram_mtproto_checksum_func_acknowledge_update_batch( ): Int external fun uniffi_monogram_mtproto_checksum_func_add_waveform_pcm( @@ -736,6 +738,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_custom_emoji_is_free( ): Int + external fun uniffi_monogram_mtproto_checksum_func_decode_qr_code( + ): Int external fun uniffi_monogram_mtproto_checksum_func_decode_vpx_alpha_packet( ): Int external fun uniffi_monogram_mtproto_checksum_func_decode_vpx_packet( @@ -776,10 +780,14 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_edit_text_message( ): Int + external fun uniffi_monogram_mtproto_checksum_func_export_login_token( + ): Int external fun uniffi_monogram_mtproto_checksum_func_finish_waveform( ): Int external fun uniffi_monogram_mtproto_checksum_func_forward_messages( ): Int + external fun uniffi_monogram_mtproto_checksum_func_generate_qr_code( + ): Int external fun uniffi_monogram_mtproto_checksum_func_get_all_stickers( ): Int external fun uniffi_monogram_mtproto_checksum_func_get_bot_callback_answer( @@ -788,6 +796,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_get_common_chats( ): Int + external fun uniffi_monogram_mtproto_checksum_func_get_device_sessions( + ): Int external fun uniffi_monogram_mtproto_checksum_func_get_discussion_message( ): Int external fun uniffi_monogram_mtproto_checksum_func_get_emoji_stickers( @@ -882,6 +892,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_ping_proxy( ): Int + external fun uniffi_monogram_mtproto_checksum_func_poll_login_token( + ): Int external fun uniffi_monogram_mtproto_checksum_func_promote_request_control( ): Int external fun uniffi_monogram_mtproto_checksum_func_read_discussion( @@ -906,6 +918,8 @@ internal object IntegrityCheckingUniffiLib { ): Int external fun uniffi_monogram_mtproto_checksum_func_resolve_username( ): Int + external fun uniffi_monogram_mtproto_checksum_func_revoke_device_session( + ): Int external fun uniffi_monogram_mtproto_checksum_func_search_global( ): Int external fun uniffi_monogram_mtproto_checksum_func_search_messages( @@ -994,6 +1008,8 @@ internal object UniffiLib { } external fun uniffi_monogram_mtproto_fn_init_callback_vtable_downloadprogresslistener(`vtable`: UniffiVTableCallbackInterfaceDownloadProgressListener, ): Unit + external fun uniffi_monogram_mtproto_fn_func_accept_login_token(`handle`: Long,`token`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_acknowledge_update_batch(`handle`: Long,`accountGeneration`: Long,`batchId`: Long,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_monogram_mtproto_fn_func_add_waveform_pcm(`handle`: Long,`samples`: RustBuffer.ByValue,`sampleRate`: Int,`channels`: Int,`presentationTimeUs`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1036,6 +1052,8 @@ internal object UniffiLib { ): Long external fun uniffi_monogram_mtproto_fn_func_custom_emoji_is_free(`handle`: Long,`documentId`: Long,uniffi_out_err: UniffiRustCallStatus, ): Byte + external fun uniffi_monogram_mtproto_fn_func_decode_qr_code(`luma`: RustBuffer.ByValue,`width`: Int,`height`: Int,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_decode_vpx_alpha_packet(`handle`: Long,`data`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_decode_vpx_packet(`handle`: Long,`data`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1076,10 +1094,14 @@ internal object UniffiLib { ): Unit external fun uniffi_monogram_mtproto_fn_func_edit_text_message(`handle`: Long,`chatId`: Long,`messageId`: Int,`text`: RustBuffer.ByValue,`entitiesJson`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_export_login_token(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_finish_waveform(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_forward_messages(`handle`: Long,`fromChatId`: Long,`messageIds`: RustBuffer.ByValue,`toChatId`: Long,`dropAuthor`: Byte,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_generate_qr_code(`text`: RustBuffer.ByValue,`size`: Int,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_get_all_stickers(`handle`: Long,`hash`: Long,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_get_bot_callback_answer(`handle`: Long,`chatId`: Long,`messageId`: Int,`dataHex`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, @@ -1088,6 +1110,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_get_common_chats(`handle`: Long,`userId`: Long,`maxId`: Long,`limit`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_get_device_sessions(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_get_discussion_message(`handle`: Long,`chatId`: Long,`messageId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_get_emoji_stickers(`handle`: Long,`hash`: Long,uniffi_out_err: UniffiRustCallStatus, @@ -1182,6 +1206,8 @@ internal object UniffiLib { ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_ping_proxy(`kind`: RustBuffer.ByValue,`host`: RustBuffer.ByValue,`port`: Short,`username`: RustBuffer.ByValue,`password`: RustBuffer.ByValue,`secret`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): Long + external fun uniffi_monogram_mtproto_fn_func_poll_login_token(`handle`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Byte external fun uniffi_monogram_mtproto_fn_func_promote_request_control(`id`: Long,`class`: Int,uniffi_out_err: UniffiRustCallStatus, ): Unit external fun uniffi_monogram_mtproto_fn_func_read_discussion(`handle`: Long,`chatId`: Long,`msgId`: Int,`readMaxId`: Int,uniffi_out_err: UniffiRustCallStatus, @@ -1206,6 +1232,8 @@ internal object UniffiLib { ): Unit external fun uniffi_monogram_mtproto_fn_func_resolve_username(`handle`: Long,`username`: RustBuffer.ByValue,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue + external fun uniffi_monogram_mtproto_fn_func_revoke_device_session(`handle`: Long,`hash`: Long,uniffi_out_err: UniffiRustCallStatus, + ): Unit external fun uniffi_monogram_mtproto_fn_func_search_global(`handle`: Long,`query`: RustBuffer.ByValue,`offsetRate`: Int,`offsetPeerId`: Long,`offsetId`: Int,`limit`: Int,`folderId`: Int,uniffi_out_err: UniffiRustCallStatus, ): RustBuffer.ByValue external fun uniffi_monogram_mtproto_fn_func_search_messages(`handle`: Long,`chatId`: Long,`query`: RustBuffer.ByValue,`limit`: Int,uniffi_out_err: UniffiRustCallStatus, @@ -1395,6 +1423,9 @@ private fun uniffiCheckContractApiVersion(lib: IntegrityCheckingUniffiLib) { } @Suppress("UNUSED_PARAMETER") private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { + if ((lib.uniffi_monogram_mtproto_checksum_func_accept_login_token() and 0xFFFF) != 4219) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_acknowledge_update_batch() and 0xFFFF) != 5843) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1458,6 +1489,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_custom_emoji_is_free() and 0xFFFF) != 48167) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_decode_qr_code() and 0xFFFF) != 19143) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_decode_vpx_alpha_packet() and 0xFFFF) != 29097) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1518,12 +1552,18 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_edit_text_message() and 0xFFFF) != 17813) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_export_login_token() and 0xFFFF) != 33248) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_finish_waveform() and 0xFFFF) != 21863) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } if ((lib.uniffi_monogram_mtproto_checksum_func_forward_messages() and 0xFFFF) != 63264) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_generate_qr_code() and 0xFFFF) != 9821) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_get_all_stickers() and 0xFFFF) != 28474) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1536,6 +1576,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_get_common_chats() and 0xFFFF) != 43261) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_get_device_sessions() and 0xFFFF) != 40643) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_get_discussion_message() and 0xFFFF) != 11564) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1677,6 +1720,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_ping_proxy() and 0xFFFF) != 46980) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_poll_login_token() and 0xFFFF) != 22428) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_promote_request_control() and 0xFFFF) != 59110) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -1713,6 +1759,9 @@ private fun uniffiCheckApiChecksums(lib: IntegrityCheckingUniffiLib) { if ((lib.uniffi_monogram_mtproto_checksum_func_resolve_username() and 0xFFFF) != 49790) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } + if ((lib.uniffi_monogram_mtproto_checksum_func_revoke_device_session() and 0xFFFF) != 17378) { + throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") + } if ((lib.uniffi_monogram_mtproto_checksum_func_search_global() and 0xFFFF) != 25087) { throw RuntimeException("UniFFI API checksum mismatch: try cleaning and rebuilding your project") } @@ -2666,6 +2715,94 @@ public object FfiConverterTypeContactsSearchDto: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): DeviceSessionDto { + return DeviceSessionDto( + FfiConverterLong.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterBoolean.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterInt.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + FfiConverterString.read(buf), + ) + } + + override fun allocationSize(value: DeviceSessionDto) = ( + FfiConverterLong.allocationSize(value.`hash`) + + FfiConverterBoolean.allocationSize(value.`current`) + + FfiConverterBoolean.allocationSize(value.`passwordPending`) + + FfiConverterString.allocationSize(value.`deviceModel`) + + FfiConverterString.allocationSize(value.`platform`) + + FfiConverterString.allocationSize(value.`systemVersion`) + + FfiConverterString.allocationSize(value.`appName`) + + FfiConverterString.allocationSize(value.`appVersion`) + + FfiConverterInt.allocationSize(value.`dateActive`) + + FfiConverterString.allocationSize(value.`ip`) + + FfiConverterString.allocationSize(value.`country`) + + FfiConverterString.allocationSize(value.`region`) + ) + + override fun write(value: DeviceSessionDto, buf: ByteBuffer) { + FfiConverterLong.write(value.`hash`, buf) + FfiConverterBoolean.write(value.`current`, buf) + FfiConverterBoolean.write(value.`passwordPending`, buf) + FfiConverterString.write(value.`deviceModel`, buf) + FfiConverterString.write(value.`platform`, buf) + FfiConverterString.write(value.`systemVersion`, buf) + FfiConverterString.write(value.`appName`, buf) + FfiConverterString.write(value.`appVersion`, buf) + FfiConverterInt.write(value.`dateActive`, buf) + FfiConverterString.write(value.`ip`, buf) + FfiConverterString.write(value.`country`, buf) + FfiConverterString.write(value.`region`, buf) + } +} + + + data class DiscussionDto ( var `chatId`: kotlin.Long , @@ -3940,6 +4077,44 @@ public object FfiConverterTypeProfileDto: FfiConverterRustBuffer { +data class QrCodeMatrix ( + var `size`: kotlin.UInt + , + var `luma`: kotlin.ByteArray + +){ + + + + + + companion object +} + +/** + * @suppress + */ +public object FfiConverterTypeQrCodeMatrix: FfiConverterRustBuffer { + override fun read(buf: ByteBuffer): QrCodeMatrix { + return QrCodeMatrix( + FfiConverterUInt.read(buf), + FfiConverterByteArray.read(buf), + ) + } + + override fun allocationSize(value: QrCodeMatrix) = ( + FfiConverterUInt.allocationSize(value.`size`) + + FfiConverterByteArray.allocationSize(value.`luma`) + ) + + override fun write(value: QrCodeMatrix, buf: ByteBuffer) { + FfiConverterUInt.write(value.`size`, buf) + FfiConverterByteArray.write(value.`luma`, buf) + } +} + + + data class ReactionChoiceDto ( var `emoticon`: kotlin.String , @@ -5052,6 +5227,93 @@ public object FfiConverterTypeMtprotoError : FfiConverterRustBuffer{ + override fun read(buf: ByteBuffer): QrLoginDto { + return when(buf.getInt()) { + 1 -> QrLoginDto.Token( + FfiConverterByteArray.read(buf), + FfiConverterInt.read(buf), + ) + 2 -> QrLoginDto.Authorized( + FfiConverterTypeAuthSignedIn.read(buf), + ) + else -> throw RuntimeException("invalid enum value, something is very wrong!!") + } + } + + override fun allocationSize(value: QrLoginDto): ULong = when(value) { + is QrLoginDto.Token -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterByteArray.allocationSize(value.`token`) + + FfiConverterInt.allocationSize(value.`expires`) + ) + } + is QrLoginDto.Authorized -> { + // Add the size for the Int that specifies the variant plus the size needed for all fields + ( + 4UL + + FfiConverterTypeAuthSignedIn.allocationSize(value.`session`) + ) + } + } + + override fun write(value: QrLoginDto, buf: ByteBuffer) { + when(value) { + is QrLoginDto.Token -> { + buf.putInt(1) + FfiConverterByteArray.write(value.`token`, buf) + FfiConverterInt.write(value.`expires`, buf) + Unit + } + is QrLoginDto.Authorized -> { + buf.putInt(2) + FfiConverterTypeAuthSignedIn.write(value.`session`, buf) + Unit + } + }.let { /* this makes the `when` an expression, which ensures it is exhaustive */ } + } +} + + + + + sealed class UpdateEventDto { object ChatsChanged : UpdateEventDto() @@ -6046,6 +6308,34 @@ public object FfiConverterSequenceTypeChatDto: FfiConverterRustBuffer> { + override fun read(buf: ByteBuffer): List { + val len = buf.getInt() + return List(len) { + FfiConverterTypeDeviceSessionDto.read(buf) + } + } + + override fun allocationSize(value: List): ULong { + val sizeForLength = 4UL + val sizeForItems = value.map { FfiConverterTypeDeviceSessionDto.allocationSize(it) }.sum() + return sizeForLength + sizeForItems + } + + override fun write(value: List, buf: ByteBuffer) { + buf.putInt(value.size) + value.iterator().forEach { + FfiConverterTypeDeviceSessionDto.write(it, buf) + } + } +} + + + + /** * @suppress */ @@ -6490,6 +6780,19 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_accept_login_token( + + + FfiConverterULong.lower(`handle`), + FfiConverterByteArray.lower(`token`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `acknowledgeUpdateBatch`(`handle`: kotlin.ULong, `accountGeneration`: kotlin.Long, `batchId`: kotlin.Long) = uniffiRustCallWithError(MtprotoException) { _status -> @@ -6744,6 +7047,20 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_decode_qr_code( + + + FfiConverterByteArray.lower(`luma`), + FfiConverterUInt.lower(`width`), + FfiConverterUInt.lower(`height`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `decodeVpxAlphaPacket`(`handle`: kotlin.ULong, `data`: kotlin.ByteArray): VpxAlphaFrame? { return FfiConverterOptionalTypeVpxAlphaFrame.lift( uniffiRustCallWithError(MtprotoException) { _status -> @@ -7001,6 +7318,18 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_export_login_token( + + + FfiConverterULong.lower(`handle`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `finishWaveform`(`handle`: kotlin.ULong): List { return FfiConverterSequenceFloat.lift( uniffiRustCallWithError(MtprotoException) { _status -> @@ -7029,6 +7358,19 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_generate_qr_code( + + + FfiConverterString.lower(`text`), + FfiConverterUInt.lower(`size`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `getAllStickers`(`handle`: kotlin.ULong, `hash`: kotlin.Long): StickerCatalogDto { return FfiConverterTypeStickerCatalogDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> @@ -7087,6 +7429,18 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer { + return FfiConverterSequenceTypeDeviceSessionDto.lift( + uniffiRustCallWithError(MtprotoException) { _status -> + UniffiLib.uniffi_monogram_mtproto_fn_func_get_device_sessions( + + + FfiConverterULong.lower(`handle`),_status) +} + ) + } + + @Throws(MtprotoException::class) fun `getDiscussionMessage`(`handle`: kotlin.ULong, `chatId`: kotlin.Long, `messageId`: kotlin.Int): DiscussionDto { return FfiConverterTypeDiscussionDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> @@ -7744,6 +8098,18 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_poll_login_token( + + + FfiConverterULong.lower(`handle`),_status) +} + ) + } + fun `promoteRequestControl`(`id`: kotlin.ULong, `class`: kotlin.Int) = uniffiRustCall() { _status -> @@ -7912,6 +8278,18 @@ public object FfiConverterSequenceTypeUpdateEventDto: FfiConverterRustBuffer + UniffiLib.uniffi_monogram_mtproto_fn_func_revoke_device_session( + + + FfiConverterULong.lower(`handle`), + FfiConverterLong.lower(`hash`),_status) +} + + + @Throws(MtprotoException::class) fun `searchGlobal`(`handle`: kotlin.ULong, `query`: kotlin.String, `offsetRate`: kotlin.Int, `offsetPeerId`: kotlin.Long, `offsetId`: kotlin.Int, `limit`: kotlin.Int, `folderId`: kotlin.Int): GlobalMessageSearchDto { return FfiConverterTypeGlobalMessageSearchDto.lift( uniffiRustCallWithError(MtprotoException) { _status -> diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/LoginToken.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/LoginToken.kt new file mode 100644 index 000000000..9d589f565 --- /dev/null +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/LoginToken.kt @@ -0,0 +1,51 @@ +package org.monogram.network.bridge.session + +import kotlin.io.encoding.Base64 +import org.monogram.core.common.telegram.TelegramError +import org.monogram.core.models.DeviceSession +import uniffi.monogram_mtproto.DeviceSessionDto + +enum class LoginTokenFailure { + Invalid, + Expired, + AlreadyAccepted, + AlreadyLoggedIn, +} + +fun TelegramError.loginTokenFailure(): LoginTokenFailure? = + when { + type == "AUTH_TOKEN_EXPIRED" -> LoginTokenFailure.Expired + type == "AUTH_TOKEN_ALREADY_ACCEPTED" -> LoginTokenFailure.AlreadyAccepted + type == "AUTH_TOKEN_EXCEPTION" -> LoginTokenFailure.AlreadyLoggedIn + type.startsWith("AUTH_TOKEN_INVALID") -> LoginTokenFailure.Invalid + else -> null + } + +internal fun decodeLoginToken(link: String): ByteArray? { + if (!link.startsWith("tg://login?token=") || link.length > 1024) return null + val encoded = link.removePrefix("tg://login?token=") + if (!encoded.matches(Regex("[A-Za-z0-9_-]+={0,2}"))) return null + return try { + Base64.UrlSafe.withPadding(Base64.PaddingOption.ABSENT_OPTIONAL).decode(encoded).takeIf { + it.isNotEmpty() && it.size <= 512 + } + } catch (_: IllegalArgumentException) { + null + } +} + +internal fun DeviceSessionDto.toModel() = + DeviceSession( + hash, + current, + passwordPending, + deviceModel, + platform, + systemVersion, + appName, + appVersion, + dateActive, + ip, + country, + region, + ) diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt index 13ce55fd0..68656379b 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionCore.kt @@ -1,5 +1,6 @@ package org.monogram.network.bridge.session +import kotlin.io.encoding.Base64 import kotlinx.coroutines.CancellationException import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope @@ -27,6 +28,8 @@ import org.monogram.core.common.telegram.TelegramError import org.monogram.core.common.telegram.retryShortFlood import org.monogram.core.models.AuthSession import org.monogram.core.models.AuthState +import org.monogram.core.models.DeviceSession +import org.monogram.core.models.QrLogin import org.monogram.core.models.PeerId import org.monogram.mtproto.MtprotoNative import org.monogram.network.bridge.ClientInitInfo @@ -34,6 +37,7 @@ import org.monogram.network.bridge.DurableUpdateSink import org.monogram.network.bridge.MtprotoTransportMode import org.monogram.network.bridge.MtprotoUpdate import org.monogram.network.bridge.ProxyConfig +import uniffi.monogram_mtproto.QrLoginDto import uniffi.monogram_mtproto.MtprotoException import java.util.concurrent.atomic.AtomicBoolean @@ -47,6 +51,76 @@ internal class SessionCore( internal val clock: MonotonicClock = NanoTimeClock, internal val initConnection: ClientInitInfo? = null, ) : SessionOps { + override suspend fun exportLoginToken(): Outcome { + sessionDead = false + when (val connected = connect()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return try { + Outcome.Ok(onNative { handle -> + when (val result = native.exportLoginToken(handle)) { + is QrLoginDto.Token -> QrLogin.Code( + "tg://login?token=" + Base64.UrlSafe + .withPadding(Base64.PaddingOption.ABSENT).encode(result.token), + result.expires, + ) + is QrLoginDto.Authorized -> { + maybeStartUpdates(handle) + QrLogin.Authorized( + AuthSession(PeerId(result.session.userId), result.session.dcId), + ) + } + } + }) + } catch (_: MtprotoException.PasswordRequired) { + Outcome.Ok(QrLogin.AwaitingPassword) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + fail(e, "auth.exportLoginToken failed") + } + } + + override suspend fun pollLoginToken(): Outcome = + try { + Outcome.Ok(onNative { handle -> native.pollLoginToken(handle) }) + } catch (e: CancellationException) { + throw e + } catch (e: Exception) { + // Pending QR authorization belongs to AuthStore, not the signed-in account. + fail(e, "auth.pollLoginToken failed", degradeHome = false) + } + + override suspend fun getDeviceSessions(): Outcome> { + when (val connected = ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return rpc("getDeviceSessions failed") { handle -> + native.getDeviceSessions(handle).map { it.toModel() } + } + } + + override suspend fun acceptLoginToken(link: String): Outcome { + val token = decodeLoginToken(link) ?: return Outcome.Err("AUTH_TOKEN_INVALID") + when (val connected = ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return rpcWrite("acceptLoginToken failed") { handle -> + native.acceptLoginToken(handle, token).toModel() + } + } + + override suspend fun revokeDeviceSession(hash: Long): Outcome { + when (val connected = ensureConnected()) { + is Outcome.Err -> return connected + is Outcome.Ok -> Unit + } + return rpcWrite("revokeDeviceSession failed") { handle -> native.revokeDeviceSession(handle, hash) } + } + @Volatile internal var handle: Long = 0L diff --git a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt index d9fa62d0a..a8e1b3739 100644 --- a/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt +++ b/network/bridge/src/main/java/org/monogram/network/bridge/session/SessionOps.kt @@ -2,12 +2,25 @@ package org.monogram.network.bridge.session import org.monogram.core.common.Outcome import org.monogram.core.models.AuthState +import org.monogram.core.models.DeviceSession +import org.monogram.core.models.QrLogin import org.monogram.network.bridge.MtprotoTransportMode import org.monogram.network.bridge.ProxyConfig enum class ProxyConnectionStatus { Disabled, Disconnected, Connecting, Connected, Failed } interface SessionOps { + suspend fun exportLoginToken(): Outcome = Outcome.Err("unsupported") + suspend fun pollLoginToken(): Outcome = Outcome.Err("unsupported") + + suspend fun getDeviceSessions(): Outcome> = + Outcome.Err("unsupported") + + suspend fun acceptLoginToken(link: String): Outcome = + Outcome.Err("unsupported") + + suspend fun revokeDeviceSession(hash: Long): Outcome = Outcome.Err("unsupported") + fun proxyConnectionStatus(): ProxyConnectionStatus = ProxyConnectionStatus.Disabled fun setTransportMode(mode: MtprotoTransportMode): Outcome = Outcome.Ok(Unit) fun configureProxy(config: ProxyConfig): Outcome = Outcome.Ok(Unit) From 4aee93c1cfe7c008334f88cd555fdb43c7f4a5df Mon Sep 17 00:00:00 2001 From: Uladzislau Hramyka Date: Mon, 19 Oct 2026 22:25:11 +0300 Subject: [PATCH 2/2] fix(settings): polish device session animations, actions, and wording --- .../feature/settings/ui/DevicesSettings.kt | 38 +++++++++++++------ .../src/main/res/values-es/strings.xml | 18 ++++----- .../src/main/res/values-hy/strings.xml | 20 +++++----- .../src/main/res/values-ja/strings.xml | 18 ++++----- .../src/main/res/values-pt-rBR/strings.xml | 18 ++++----- .../src/main/res/values-ru/strings.xml | 18 ++++----- .../src/main/res/values-sk/strings.xml | 18 ++++----- .../src/main/res/values-tr/strings.xml | 18 ++++----- .../src/main/res/values-uk/strings.xml | 18 ++++----- .../src/main/res/values-zh/strings.xml | 18 ++++----- .../settings/src/main/res/values/strings.xml | 18 ++++----- 11 files changed, 117 insertions(+), 103 deletions(-) diff --git a/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt index a6d636c26..c010e5110 100644 --- a/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt +++ b/feature/settings/src/main/java/org/monogram/feature/settings/ui/DevicesSettings.kt @@ -9,10 +9,14 @@ import androidx.compose.foundation.layout.padding import androidx.compose.foundation.lazy.LazyColumn import androidx.compose.foundation.lazy.items import androidx.compose.material.icons.Icons +import androidx.compose.material.icons.automirrored.outlined.Logout import androidx.compose.material.icons.outlined.Devices import androidx.compose.material.icons.outlined.QrCodeScanner import androidx.compose.material3.AlertDialog import androidx.compose.material3.ButtonDefaults +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton +import androidx.compose.material3.IconButtonDefaults import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Snackbar import androidx.compose.material3.SnackbarDefaults @@ -41,6 +45,7 @@ import org.monogram.core.ui.components.AppSyncStatus import org.monogram.core.ui.components.SettingsCard import org.monogram.core.ui.components.SettingsCardRow import org.monogram.core.ui.components.SettingsTile +import org.monogram.core.ui.components.listItemMotion import org.monogram.core.ui.loading.MonogramLinearProgress import org.monogram.feature.settings.DevicesStore import org.monogram.feature.settings.R @@ -140,7 +145,7 @@ internal fun DevicesSettingsContent( if (state.sessions.isEmpty() && !state.loading && state.error == null) item { Text(stringResource(R.string.settings_devices_empty)) } items(state.sessions, key = { it.hash }) { session -> - SettingsCard { + SettingsCard(modifier = listItemMotion()) { SettingsCardRow( icon = Icons.Outlined.Devices, iconColor = MaterialTheme.colorScheme.secondary, @@ -172,18 +177,27 @@ internal fun DevicesSettingsContent( ) .filter { it.isNotBlank() } .joinToString("\n"), + trailingContent = + if (session.current) null + else { + { + IconButton( + onClick = { revokeHash = session.hash }, + enabled = !state.loading, + colors = + IconButtonDefaults.iconButtonColors( + contentColor = MaterialTheme.colorScheme.error + ), + ) { + Icon( + imageVector = Icons.AutoMirrored.Outlined.Logout, + contentDescription = + stringResource(R.string.settings_devices_terminate), + ) + } + } + }, ) - if (!session.current) { - TextButton( - onClick = { revokeHash = session.hash }, - enabled = !state.loading, - ) { - Text( - stringResource(R.string.settings_devices_terminate), - color = MaterialTheme.colorScheme.error, - ) - } - } } } } diff --git a/feature/settings/src/main/res/values-es/strings.xml b/feature/settings/src/main/res/values-es/strings.xml index bccda51e8..916324863 100644 --- a/feature/settings/src/main/res/values-es/strings.xml +++ b/feature/settings/src/main/res/values-es/strings.xml @@ -411,22 +411,22 @@ Dispositivos Administrar dispositivos vinculados Vincular dispositivo - Escanea el código QR de Telegram Desktop o Web para iniciar sesión. - Este no es un código QR válido para iniciar sesión en Telegram. - Este código QR ha caducado. Escanea el nuevo código. - Este código QR ya se ha utilizado. Genera uno nuevo. - Esta cuenta ya tiene una sesión iniciada en ese dispositivo. - Inicio de sesión correcto + Escanea el código QR de Telegram Desktop o Web para iniciar sesión + Código QR de inicio de sesión no válido + El código QR ha caducado, escanea uno nuevo + El código QR ya se ha utilizado, escanea uno nuevo + Ya has iniciado sesión en este dispositivo + Dispositivo vinculado Cerrar sesión Actualizar - No hay dispositivos vinculados. + No hay dispositivos vinculados Este dispositivo Esperando la contraseña de verificación en dos pasos en el nuevo dispositivo Última actividad: %1$s Cerrar sesión ¿Cerrar la sesión de Telegram en este dispositivo? - La cámara no está disponible. Comprueba que otra aplicación no la esté usando. - Permite el acceso a la cámara en los ajustes de la aplicación para escanear códigos QR. + Cámara no disponible, comprueba si otra app la está usando + Permite el acceso a la cámara en los ajustes de la app para escanear códigos QR Abrir ajustes de la aplicación Escanear código QR diff --git a/feature/settings/src/main/res/values-hy/strings.xml b/feature/settings/src/main/res/values-hy/strings.xml index 69eddcefd..1f2d61ac7 100644 --- a/feature/settings/src/main/res/values-hy/strings.xml +++ b/feature/settings/src/main/res/values-hy/strings.xml @@ -411,22 +411,22 @@ Սարքեր Կառավարել կապված սարքերը Կապել սարք - Մուտք գործելու համար սկանավորեք Telegram Desktop-ի կամ Web-ի QR կոդը։ - Սա Telegram մուտք գործելու վավեր QR կոդ չէ։ - Այս QR կոդի ժամկետը լրացել է։ Սկանավորեք նոր կոդը։ - Այս QR կոդն արդեն օգտագործվել է։ Ստեղծեք նոր կոդ։ - Այս հաշիվն արդեն մուտք է գործել այդ սարքում։ - Մուտքը հաջողվեց + Մուտք գործելու համար սկանավորեք Telegram Desktop-ի կամ Web-ի QR կոդը + Մուտքի անվավեր QR կոդ + QR կոդի ժամկետը լրացել է, սկանավորեք նորը + QR կոդն արդեն օգտագործվել է, սկանավորեք նորը + Այս սարքում արդեն մուտք եք գործել + Սարքը կապվել է Ավարտել Թարմացնել - Կապված սարքեր չկան։ + Կապված սարքեր չկան Այս սարքը Նոր սարքում սպասվում է երկքայլ հաստատման գաղտնաբառը Վերջին ակտիվությունը՝ %1$s Ավարտել աշխատաշրջանը - Դո՞ւրս գալ Telegram-ից այս սարքում։ - Տեսախցիկը հասանելի չէ։ Ստուգեք՝ արդյոք այն չի օգտագործվում այլ հավելվածի կողմից։ - QR կոդերը սկանավորելու համար հավելվածի կարգավորումներում թույլատրեք տեսախցիկի հասանելիությունը։ + Դո՞ւրս գալ Telegram-ից այս սարքում + Տեսախցիկը հասանելի չէ, ստուգեք՝ արդյոք այն չի օգտագործվում այլ հավելվածի կողմից + QR կոդերը սկանավորելու համար հավելվածի կարգավորումներում թույլատրեք տեսախցիկի հասանելիությունը Բացել հավելվածի կարգավորումները Սկանավորել QR կոդը diff --git a/feature/settings/src/main/res/values-ja/strings.xml b/feature/settings/src/main/res/values-ja/strings.xml index c6646c429..0bc291891 100644 --- a/feature/settings/src/main/res/values-ja/strings.xml +++ b/feature/settings/src/main/res/values-ja/strings.xml @@ -411,22 +411,22 @@ デバイス 接続済みデバイスを管理 デバイスを接続 - Telegram DesktopまたはWebのQRコードをスキャンしてログインしてください。 - 有効なTelegramログイン用QRコードではありません。 - このQRコードの有効期限が切れました。新しいコードをスキャンしてください。 - このQRコードは使用済みです。新しいコードを生成してください。 - このアカウントはそのデバイスですでにログインしています。 - ログインしました + Telegram DesktopまたはWebのQRコードをスキャンしてログイン + 無効なログイン用QRコードです + 有効期限が切れたため、新しいQRコードをスキャンしてください + 使用済みのため、新しいQRコードをスキャンしてください + このデバイスではすでにログインしています + デバイスを接続しました 終了 更新 - 接続済みデバイスはありません。 + 接続済みデバイスはありません このデバイス 新しいデバイスで2段階認証のパスワード入力を待っています 最終アクティブ:%1$s セッションを終了 このデバイスでTelegramからログアウトしますか? - カメラを使用できません。他のアプリが使用していないか確認してください。 - ログイン用QRコードをスキャンするには、アプリの設定でカメラへのアクセスを許可してください。 + カメラを使用できないため、他のアプリが使用していないか確認してください + QRコードをスキャンするには、アプリの設定でカメラへのアクセスを許可してください アプリの設定を開く QRコードをスキャン diff --git a/feature/settings/src/main/res/values-pt-rBR/strings.xml b/feature/settings/src/main/res/values-pt-rBR/strings.xml index 4b7a1166c..4da7be230 100644 --- a/feature/settings/src/main/res/values-pt-rBR/strings.xml +++ b/feature/settings/src/main/res/values-pt-rBR/strings.xml @@ -411,22 +411,22 @@ Dispositivos Gerenciar dispositivos conectados Conectar dispositivo - Leia o código QR do Telegram Desktop ou Web para entrar. - Este não é um código QR válido para entrar no Telegram. - Este código QR expirou. Leia o novo código. - Este código QR já foi usado. Gere um novo código. - Esta conta já está conectada nesse dispositivo. - Login realizado com sucesso + Leia o código QR do Telegram Desktop ou Web para entrar + Código QR de login inválido + Código QR expirado, leia um novo + Código QR já utilizado, leia um novo + Você já está conectado neste dispositivo + Dispositivo conectado Encerrar Atualizar - Nenhum dispositivo conectado para exibir. + Nenhum dispositivo conectado Este dispositivo Aguardando a senha de verificação em duas etapas no novo dispositivo Última atividade: %1$s Encerrar sessão Sair do Telegram neste dispositivo? - A câmera está indisponível. Verifique se outro aplicativo está usando a câmera. - Permita o acesso à câmera nas configurações do aplicativo para ler códigos QR. + Câmera indisponível, verifique se outro app está usando a câmera + Permita o acesso à câmera nas configurações do app para ler códigos QR Abrir configurações do aplicativo Ler código QR diff --git a/feature/settings/src/main/res/values-ru/strings.xml b/feature/settings/src/main/res/values-ru/strings.xml index 1229a6f2e..d511fdc25 100644 --- a/feature/settings/src/main/res/values-ru/strings.xml +++ b/feature/settings/src/main/res/values-ru/strings.xml @@ -411,22 +411,22 @@ Устройства Управление подключёнными устройствами Подключить устройство - Отсканируйте QR-код в Telegram Desktop или Web для входа. - Это недействительный QR-код для входа в Telegram. - Срок действия QR-кода истёк. Отсканируйте новый код. - Этот QR-код уже использован. Создайте новый код. - На этом устройстве уже выполнен вход в этот аккаунт. - Вход выполнен + Отсканируйте QR-код в Telegram Desktop или Web для входа + Недействительный QR-код для входа + Срок действия QR-кода истёк, отсканируйте новый + QR-код уже использован, отсканируйте новый + На этом устройстве уже выполнен вход + Устройство подключено Завершить Обновить - Нет подключённых устройств. + Нет подключённых устройств Это устройство Ожидание пароля двухэтапной аутентификации на новом устройстве Последняя активность: %1$s Завершить сеанс Выйти из Telegram на этом устройстве? - Камера недоступна. Проверьте, не используется ли она другим приложением. - Разрешите доступ к камере в настройках приложения для сканирования QR-кодов. + Камера недоступна, проверьте, не используется ли она другим приложением + Разрешите доступ к камере в настройках приложения для сканирования QR-кодов Открыть настройки приложения Сканировать QR-код diff --git a/feature/settings/src/main/res/values-sk/strings.xml b/feature/settings/src/main/res/values-sk/strings.xml index b81397c3f..5ab218e2b 100644 --- a/feature/settings/src/main/res/values-sk/strings.xml +++ b/feature/settings/src/main/res/values-sk/strings.xml @@ -411,22 +411,22 @@ Zariadenia Spravovať prepojené zariadenia Prepojiť zariadenie - Prihláste sa naskenovaním QR kódu v Telegrame Desktop alebo Web. - Toto nie je platný QR kód na prihlásenie do Telegramu. - Platnosť tohto QR kódu vypršala. Naskenujte nový kód. - Tento QR kód už bol použitý. Vygenerujte nový kód. - Tento účet je už na danom zariadení prihlásený. - Prihlásenie bolo úspešné + Prihláste sa naskenovaním QR kódu v Telegrame Desktop alebo Web + Neplatný prihlasovací QR kód + Platnosť QR kódu vypršala, naskenujte nový + QR kód už bol použitý, naskenujte nový + Na tomto zariadení už ste prihlásení + Zariadenie prepojené Ukončiť Obnoviť - Žiadne prepojené zariadenia. + Žiadne prepojené zariadenia Toto zariadenie Čaká sa na heslo dvojstupňového overenia na novom zariadení Posledná aktivita: %1$s Ukončiť reláciu Odhlásiť sa z Telegramu na tomto zariadení? - Kamera nie je dostupná. Skontrolujte, či ju nepoužíva iná aplikácia. - Ak chcete skenovať QR kódy, povoľte prístup ku kamere v nastaveniach aplikácie. + Kamera nie je dostupná, skontrolujte, či ju nepoužíva iná aplikácia + Ak chcete skenovať QR kódy, povoľte prístup ku kamere v nastaveniach aplikácie Otvoriť nastavenia aplikácie Skenovať QR kód diff --git a/feature/settings/src/main/res/values-tr/strings.xml b/feature/settings/src/main/res/values-tr/strings.xml index 578cd7902..1bdb74b05 100644 --- a/feature/settings/src/main/res/values-tr/strings.xml +++ b/feature/settings/src/main/res/values-tr/strings.xml @@ -411,22 +411,22 @@ Cihazlar Bağlı cihazları yönet Cihaz Bağla - Giriş yapmak için Telegram Desktop veya Web üzerindeki QR kodunu tarayın. - Bu, geçerli bir Telegram giriş QR kodu değil. - Bu QR kodunun süresi doldu. Yeni kodu tarayın. - Bu QR kodu zaten kullanıldı. Yeni bir kod oluşturun. - Bu hesapta o cihazdan zaten oturum açılmış. - Giriş başarılı + Giriş yapmak için Telegram Desktop veya Web üzerindeki QR kodunu tarayın + Geçersiz giriş QR kodu + QR kodunun süresi doldu, yeni bir kod tarayın + QR kodu zaten kullanıldı, yeni bir kod tarayın + Bu cihazda zaten oturum açtınız + Cihaz bağlandı Sonlandır Yenile - Gösterilecek bağlı cihaz yok. + Bağlı cihaz yok Bu cihaz Yeni cihazda iki adımlı doğrulama parolası bekleniyor Son etkinlik: %1$s Oturumu Sonlandır Bu cihazda Telegram oturumu kapatılsın mı? - Kamera kullanılamıyor. Başka bir uygulamanın kamerayı kullanmadığından emin olun. - Giriş QR kodlarını taramak için uygulama ayarlarında kamera erişimine izin verin. + Kamera kullanılamıyor, başka bir uygulamanın kamerayı kullanıp kullanmadığını kontrol edin + QR kodlarını taramak için uygulama ayarlarında kamera erişimine izin verin Uygulama Ayarlarını Aç QR kodunu tara diff --git a/feature/settings/src/main/res/values-uk/strings.xml b/feature/settings/src/main/res/values-uk/strings.xml index b679175ea..c3146933b 100644 --- a/feature/settings/src/main/res/values-uk/strings.xml +++ b/feature/settings/src/main/res/values-uk/strings.xml @@ -411,22 +411,22 @@ Пристрої Керування підключеними пристроями Підключити пристрій - Відскануйте QR-код у Telegram Desktop або Web для входу. - Це недійсний QR-код для входу в Telegram. - Термін дії QR-коду минув. Відскануйте новий код. - Цей QR-код уже використано. Створіть новий код. - На цьому пристрої вже виконано вхід у цей обліковий запис. - Вхід виконано + Відскануйте QR-код у Telegram Desktop або Web для входу + Недійсний QR-код для входу + Термін дії QR-коду минув, відскануйте новий + QR-код уже використано, відскануйте новий + На цьому пристрої вже виконано вхід + Пристрій підключено Завершити Оновити - Немає підключених пристроїв. + Немає підключених пристроїв Цей пристрій Очікування пароля двоетапної перевірки на новому пристрої Остання активність: %1$s Завершити сеанс Вийти з Telegram на цьому пристрої? - Камера недоступна. Перевірте, чи не використовує її інша програма. - Дозвольте доступ до камери в налаштуваннях програми для сканування QR-кодів. + Камера недоступна, перевірте, чи не використовує її інша програма + Дозвольте доступ до камери в налаштуваннях програми для сканування QR-кодів Відкрити налаштування програми Сканувати QR-код diff --git a/feature/settings/src/main/res/values-zh/strings.xml b/feature/settings/src/main/res/values-zh/strings.xml index d9be950b2..88abb53b4 100644 --- a/feature/settings/src/main/res/values-zh/strings.xml +++ b/feature/settings/src/main/res/values-zh/strings.xml @@ -411,22 +411,22 @@ 设备 管理已关联设备 关联设备 - 扫描 Telegram Desktop 或网页版上的二维码以登录。 - 这不是有效的 Telegram 登录二维码。 - 此二维码已过期,请扫描新二维码。 - 此二维码已被使用,请生成新二维码。 - 此账号已在该设备上登录。 - 登录成功 + 扫描 Telegram Desktop 或网页版上的二维码以登录 + 无效的登录二维码 + 二维码已过期,请扫描新二维码 + 二维码已被使用,请扫描新二维码 + 你已在此设备上登录 + 设备已关联 终止 刷新 - 没有已关联的设备。 + 没有已关联的设备 此设备 正在等待在新设备上输入两步验证密码 上次活跃:%1$s 终止会话 要在此设备上退出 Telegram 吗? - 相机不可用,请检查是否被其他应用占用。 - 请在应用设置中允许相机访问权限,以扫描登录二维码。 + 相机不可用,请检查是否被其他应用占用 + 请在应用设置中允许访问相机,以扫描二维码 打开应用设置 扫描二维码 diff --git a/feature/settings/src/main/res/values/strings.xml b/feature/settings/src/main/res/values/strings.xml index 6e7f93220..f17980bed 100644 --- a/feature/settings/src/main/res/values/strings.xml +++ b/feature/settings/src/main/res/values/strings.xml @@ -411,22 +411,22 @@ Devices Manage linked devices Link Desktop Device - Scan the QR code on Telegram Desktop or Web to sign in. - This is not a valid Telegram login QR code. - This QR code has expired. Scan the new code. - This QR code has already been used. Generate a new code. - This account is already logged in on that device. - Login successful + Scan the QR code on Telegram Desktop or Web to log in + Invalid login QR code + QR code expired, scan a new one + QR code already used, scan a new one + You’re already logged in on this device + Device linked Terminate Refresh - No linked devices to show. + No linked devices This device Waiting for the two-step verification password on the new device Last active: %1$s Terminate Session Sign out of Telegram on this device? - The camera is unavailable. Check that it is not being used by another app. - Allow camera access in app settings to scan login QR codes. + Camera unavailable, check if another app is using it + Allow camera access in app settings to scan QR codes Open App Settings Scan QR code