mirror of
https://github.com/fromchat-messenger/app.git
synced 2026-09-23 11:35:06 +03:00
Compare commits
8 Commits
@@ -62,8 +62,8 @@ extensions.configure<ApplicationExtension> {
|
|||||||
applicationId = "ru.fromchat"
|
applicationId = "ru.fromchat"
|
||||||
minSdk = 24
|
minSdk = 24
|
||||||
targetSdk = 37
|
targetSdk = 37
|
||||||
versionCode = 1
|
versionCode = rootProject.extra["versionCode"] as Int
|
||||||
versionName = "1.0"
|
versionName = rootProject.extra["versionName"] as String
|
||||||
|
|
||||||
ndk {
|
ndk {
|
||||||
abiFilters += listOf("arm64-v8a", "x86_64")
|
abiFilters += listOf("arm64-v8a", "x86_64")
|
||||||
@@ -166,9 +166,4 @@ dependencies {
|
|||||||
|
|
||||||
implementation(project(":app:shared"))
|
implementation(project(":app:shared"))
|
||||||
implementation(project(":utils:shared"))
|
implementation(project(":utils:shared"))
|
||||||
|
|
||||||
testImplementation("junit:junit:4.13.2")
|
|
||||||
testImplementation(libs.androidx.compose.material3)
|
|
||||||
testImplementation("androidx.graphics:graphics-shapes:1.0.1")
|
|
||||||
testImplementation("org.robolectric:robolectric:4.14.1")
|
|
||||||
}
|
}
|
||||||
@@ -22,6 +22,9 @@
|
|||||||
<meta-data
|
<meta-data
|
||||||
android:name="com.google.firebase.messaging.default_notification_icon"
|
android:name="com.google.firebase.messaging.default_notification_icon"
|
||||||
android:resource="@drawable/ic_stat_fromchat" />
|
android:resource="@drawable/ic_stat_fromchat" />
|
||||||
|
<meta-data
|
||||||
|
android:name="firebase_messaging_installation_id_enabled"
|
||||||
|
android:value="true" />
|
||||||
<activity
|
<activity
|
||||||
android:name=".MainActivity"
|
android:name=".MainActivity"
|
||||||
android:exported="true"
|
android:exported="true"
|
||||||
@@ -51,6 +54,15 @@
|
|||||||
android:host="oauth"
|
android:host="oauth"
|
||||||
android:pathPrefix="/yandex" />
|
android:pathPrefix="/yandex" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.VIEW" />
|
||||||
|
<category android:name="android.intent.category.DEFAULT" />
|
||||||
|
<category android:name="android.intent.category.BROWSABLE" />
|
||||||
|
<data
|
||||||
|
android:scheme="fromchat"
|
||||||
|
android:host="oauth"
|
||||||
|
android:pathPrefix="/vk" />
|
||||||
|
</intent-filter>
|
||||||
</activity>
|
</activity>
|
||||||
<service
|
<service
|
||||||
android:name=".fcm.FromChatFirebaseMessagingService"
|
android:name=".fcm.FromChatFirebaseMessagingService"
|
||||||
|
|||||||
@@ -9,8 +9,8 @@ import kotlinx.coroutines.GlobalScope
|
|||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.notifications.NotificationHelper
|
|
||||||
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
import ru.fromchat.api.uploadPendingFcmTokenIfAvailable
|
||||||
|
import ru.fromchat.notifications.NotificationHelper
|
||||||
|
|
||||||
@OptIn(DelicateCoroutinesApi::class)
|
@OptIn(DelicateCoroutinesApi::class)
|
||||||
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
||||||
@@ -65,18 +65,16 @@ class FromChatFirebaseMessagingService : FirebaseMessagingService() {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun onNewToken(token: String) {
|
override fun onRegistered(installationId: String) {
|
||||||
Logger.i("FromChatFCM", "onNewToken received (...${token.takeLast(8)})")
|
Logger.i("FromChatFCM", "onRegistered received (...${installationId.takeLast(8)})")
|
||||||
GlobalScope.launch(Dispatchers.IO) {
|
GlobalScope.launch(Dispatchers.IO) {
|
||||||
try {
|
try {
|
||||||
settings.putString("pending_fcm_token", token)
|
settings.putString("pending_fcm_token", installationId)
|
||||||
uploadPendingFcmTokenIfAvailable()
|
uploadPendingFcmTokenIfAvailable()
|
||||||
Logger.i("FromChatFCM", "FCM token queued or uploaded for this app instance")
|
Logger.i("FromChatFCM", "FCM installation id queued or uploaded for this app instance")
|
||||||
} catch (e: Exception) {
|
} catch (e: Exception) {
|
||||||
Logger.e("FromChatFCM", "onNewToken upload error: ${e.message}", e)
|
Logger.e("FromChatFCM", "onRegistered upload error: ${e.message}", e)
|
||||||
}
|
}
|
||||||
|
|
||||||
super.onNewToken(token)
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,3 +1,12 @@
|
|||||||
|
@file:Suppress("TaskMissingDescription")
|
||||||
|
|
||||||
|
import org.gradle.api.DefaultTask
|
||||||
|
import org.gradle.api.file.DirectoryProperty
|
||||||
|
import org.gradle.api.provider.Property
|
||||||
|
import org.gradle.api.tasks.Input
|
||||||
|
import org.gradle.api.tasks.OutputDirectory
|
||||||
|
import org.gradle.api.tasks.TaskAction
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlin.multiplatform)
|
alias(libs.plugins.kotlin.multiplatform)
|
||||||
alias(libs.plugins.compose.multiplatform)
|
alias(libs.plugins.compose.multiplatform)
|
||||||
@@ -7,6 +16,54 @@ plugins {
|
|||||||
alias(libs.plugins.sqldelight)
|
alias(libs.plugins.sqldelight)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
abstract class GenerateAppBuildInfoTask : DefaultTask() {
|
||||||
|
@get:Input
|
||||||
|
abstract val versionName: Property<String>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val versionCode: Property<Int>
|
||||||
|
|
||||||
|
@get:Input
|
||||||
|
abstract val debugBuild: Property<Boolean>
|
||||||
|
|
||||||
|
@get:OutputDirectory
|
||||||
|
abstract val outputDirectory: DirectoryProperty
|
||||||
|
|
||||||
|
@TaskAction
|
||||||
|
fun generate() {
|
||||||
|
val outRoot = outputDirectory.get().asFile
|
||||||
|
check(outRoot.invariantSeparatorsPath.contains("/generated/")) {
|
||||||
|
"AppBuildInfo must be written under a generated/ directory, got: $outRoot"
|
||||||
|
}
|
||||||
|
outRoot.deleteRecursively()
|
||||||
|
outRoot.resolve("ru/fromchat").apply { mkdirs() }.resolve("AppBuildInfo.kt").writeText(
|
||||||
|
"""
|
||||||
|
|package ru.fromchat
|
||||||
|
|
|
||||||
|
|/** Injected by Gradle into generated sources (not under src/). */
|
||||||
|
|object AppBuildInfo {
|
||||||
|
| const val version = "${versionName.get()}"
|
||||||
|
| const val versionCode = ${versionCode.get()}
|
||||||
|
| const val isDebug = ${debugBuild.get()}
|
||||||
|
|}
|
||||||
|
|
|
||||||
|
""".trimMargin()
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val generateAppBuildInfo = tasks.register<GenerateAppBuildInfoTask>("generateAppBuildInfo") {
|
||||||
|
versionName.set(rootProject.extra["versionName"] as String)
|
||||||
|
versionCode.set(rootProject.extra["versionCode"] as Int)
|
||||||
|
debugBuild.set(
|
||||||
|
gradle.startParameter.taskNames.let { names ->
|
||||||
|
!names.any { it.contains("Release", ignoreCase = true) } ||
|
||||||
|
names.any { it.contains("Debug", ignoreCase = true) }
|
||||||
|
},
|
||||||
|
)
|
||||||
|
outputDirectory.set(layout.buildDirectory.dir("generated/sources/appBuildInfo/kotlin"))
|
||||||
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
android {
|
android {
|
||||||
namespace = "ru.fromchat.shared"
|
namespace = "ru.fromchat.shared"
|
||||||
@@ -21,7 +78,6 @@ kotlin {
|
|||||||
listOf(
|
listOf(
|
||||||
iosArm64(),
|
iosArm64(),
|
||||||
iosSimulatorArm64(),
|
iosSimulatorArm64(),
|
||||||
iosX64(),
|
|
||||||
).forEach { iosTarget ->
|
).forEach { iosTarget ->
|
||||||
iosTarget.binaries.framework {
|
iosTarget.binaries.framework {
|
||||||
baseName = "ComposeApp"
|
baseName = "ComposeApp"
|
||||||
@@ -37,6 +93,10 @@ kotlin {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
commonMain {
|
||||||
|
kotlin.srcDir(generateAppBuildInfo.map { it.outputDirectory })
|
||||||
|
}
|
||||||
|
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(libs.compose.runtime)
|
implementation(libs.compose.runtime)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
@@ -123,6 +183,7 @@ compose.resources {
|
|||||||
|
|
||||||
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
|
tasks.matching { it.name == "compileAndroidMain" || it.name == "compileKotlinIosArm64" }.configureEach {
|
||||||
dependsOn("generateResourceAccessorsForCommonMain")
|
dependsOn("generateResourceAccessorsForCommonMain")
|
||||||
|
dependsOn(generateAppBuildInfo)
|
||||||
}
|
}
|
||||||
|
|
||||||
tasks.register("generateResourceAccessors") {
|
tasks.register("generateResourceAccessors") {
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
package ru.fromchat.api
|
package ru.fromchat.api
|
||||||
|
|
||||||
import com.google.android.gms.tasks.Task
|
import com.google.android.gms.tasks.Task
|
||||||
|
import com.google.firebase.installations.FirebaseInstallations
|
||||||
import com.google.firebase.messaging.FirebaseMessaging
|
import com.google.firebase.messaging.FirebaseMessaging
|
||||||
import com.pr0gramm3r101.utils.settings.settings
|
import com.pr0gramm3r101.utils.settings.settings
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
@@ -13,19 +14,27 @@ import kotlin.coroutines.resumeWithException
|
|||||||
private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token"
|
private const val PENDING_FCM_TOKEN_KEY = "pending_fcm_token"
|
||||||
private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token"
|
private const val CURRENT_FCM_TOKEN_KEY = "current_fcm_token"
|
||||||
|
|
||||||
private suspend fun fetchCurrentFcmToken(): String? = suspendCancellableCoroutine { cont ->
|
private suspend fun <T> Task<T>.awaitResult(): T = suspendCancellableCoroutine { cont ->
|
||||||
FirebaseMessaging.getInstance().token
|
addOnCompleteListener { task ->
|
||||||
.addOnCompleteListener { task: Task<String> ->
|
|
||||||
if (task.isSuccessful) {
|
if (task.isSuccessful) {
|
||||||
cont.resume(task.result)
|
cont.resume(task.result)
|
||||||
} else {
|
} else {
|
||||||
cont.resumeWithException(
|
cont.resumeWithException(
|
||||||
task.exception ?: IllegalStateException("Failed to fetch FCM token")
|
task.exception ?: IllegalStateException("Firebase task failed"),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Registers with FCM and returns the Firebase Installation ID used for targeting. */
|
||||||
|
private suspend fun fetchCurrentFcmToken(): String? = runCatching {
|
||||||
|
FirebaseMessaging.getInstance().register().awaitResult()
|
||||||
|
FirebaseInstallations.getInstance().id.awaitResult()
|
||||||
|
}.getOrElse { e ->
|
||||||
|
Logger.e("FcmReg", "Failed to fetch FCM installation id: ${e.message}", e)
|
||||||
|
null
|
||||||
|
}
|
||||||
|
|
||||||
private suspend fun postFcmToken(token: String): Boolean {
|
private suspend fun postFcmToken(token: String): Boolean {
|
||||||
return runCatching {
|
return runCatching {
|
||||||
ApiClient.registerFcmToken(token)
|
ApiClient.registerFcmToken(token)
|
||||||
|
|||||||
+1
-1
@@ -40,7 +40,7 @@ internal actual suspend fun platformAesGcmStreamDecryptMekFile(
|
|||||||
outputFile.delete()
|
outputFile.delete()
|
||||||
}
|
}
|
||||||
|
|
||||||
val cipher = GCMBlockCipher.newInstance(AESEngine())
|
val cipher = GCMBlockCipher.newInstance(AESEngine.newInstance())
|
||||||
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
|
cipher.init(false, AEADParameters(KeyParameter(key), 128, iv))
|
||||||
|
|
||||||
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
|
val inBuf = ByteArray(FILE_DECRYPT_BUFFER_BYTES)
|
||||||
|
|||||||
+36
-70
@@ -1,4 +1,4 @@
|
|||||||
package ru.fromchat.ui.auth.yandex
|
package ru.fromchat.ui.auth.oauth
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
import android.annotation.SuppressLint
|
||||||
import android.app.Activity
|
import android.app.Activity
|
||||||
@@ -57,12 +57,10 @@ import kotlinx.coroutines.delay
|
|||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||||
import ru.fromchat.Logger
|
import ru.fromchat.Logger
|
||||||
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
|
||||||
import ru.fromchat.auth.yandex.extractOAuthCode
|
|
||||||
import ru.fromchat.ui.components.PredictiveBackHandler
|
import ru.fromchat.ui.components.PredictiveBackHandler
|
||||||
import kotlin.coroutines.resume
|
import kotlin.coroutines.resume
|
||||||
|
|
||||||
private const val LOG_TAG = "YandexOAuthWV"
|
private const val LOG_TAG = "OAuthWebView"
|
||||||
private const val LOADING_FADE_MS = 250
|
private const val LOADING_FADE_MS = 250
|
||||||
private const val LOADING_HIDE_DELAY_MS = 500L
|
private const val LOADING_HIDE_DELAY_MS = 500L
|
||||||
private const val TOP_ROW_SAMPLE_INTERVAL_MS = 50L
|
private const val TOP_ROW_SAMPLE_INTERVAL_MS = 50L
|
||||||
@@ -135,15 +133,18 @@ private const val PAGE_SCROLL_Y_JS = """
|
|||||||
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3Api::class, ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@SuppressLint("SetJavaScriptEnabled")
|
@SuppressLint("SetJavaScriptEnabled")
|
||||||
@Composable
|
@Composable
|
||||||
actual fun YandexOAuthWebView(
|
actual fun OAuthWebView(
|
||||||
authorizeUrl: String,
|
authorizeUrl: String,
|
||||||
languageTag: String,
|
languageTag: String,
|
||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
fallbackColor: Color,
|
fallbackColor: Color,
|
||||||
|
redirectUriPrefix: String,
|
||||||
|
isAuthNavigation: (url: String) -> Boolean,
|
||||||
clearCookies: Boolean,
|
clearCookies: Boolean,
|
||||||
|
themeCookieHosts: List<String>,
|
||||||
onPageBackgroundColor: (Color) -> Unit,
|
onPageBackgroundColor: (Color) -> Unit,
|
||||||
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
||||||
onCode: (String) -> Unit,
|
onRedirectUrl: (String) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
@@ -158,8 +159,10 @@ actual fun YandexOAuthWebView(
|
|||||||
val scheme = if (darkTheme) "dark" else "light"
|
val scheme = if (darkTheme) "dark" else "light"
|
||||||
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
|
val onHistoryBackAvailabilityChangedState = rememberUpdatedState(onHistoryBackAvailabilityChanged)
|
||||||
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
|
val onPageBackgroundColorState = rememberUpdatedState(onPageBackgroundColor)
|
||||||
val onCodeState = rememberUpdatedState(onCode)
|
val onRedirectUrlState = rememberUpdatedState(onRedirectUrl)
|
||||||
val onErrorState = rememberUpdatedState(onError)
|
val onErrorState = rememberUpdatedState(onError)
|
||||||
|
val isAuthNavigationState = rememberUpdatedState(isAuthNavigation)
|
||||||
|
val redirectUriPrefixState = rememberUpdatedState(redirectUriPrefix)
|
||||||
val onCancelState = rememberUpdatedState(onCancel)
|
val onCancelState = rememberUpdatedState(onCancel)
|
||||||
val lifecycleOwner = LocalLifecycleOwner.current
|
val lifecycleOwner = LocalLifecycleOwner.current
|
||||||
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
val instanceId = remember { Integer.toHexString(System.identityHashCode(Any())) }
|
||||||
@@ -193,7 +196,7 @@ actual fun YandexOAuthWebView(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
ApplyYandexWebViewSystemBars(
|
ApplyOAuthWebViewSystemBars(
|
||||||
chromeColor = chromeColor,
|
chromeColor = chromeColor,
|
||||||
darkTheme = darkTheme,
|
darkTheme = darkTheme,
|
||||||
restoreSurfaceColor = fallbackColor,
|
restoreSurfaceColor = fallbackColor,
|
||||||
@@ -269,7 +272,7 @@ actual fun YandexOAuthWebView(
|
|||||||
onCancel = { },
|
onCancel = { },
|
||||||
)
|
)
|
||||||
|
|
||||||
val client = remember(authorizeUrl, lang, darkTheme) {
|
val client = remember(authorizeUrl, lang, darkTheme, redirectUriPrefix) {
|
||||||
Logger.i(
|
Logger.i(
|
||||||
LOG_TAG,
|
LOG_TAG,
|
||||||
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
|
"WebViewClient create id=$instanceId darkTheme=$darkTheme lang=$lang " +
|
||||||
@@ -278,17 +281,16 @@ actual fun YandexOAuthWebView(
|
|||||||
object : WebViewClient() {
|
object : WebViewClient() {
|
||||||
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
|
private fun handleSpecialUrl(view: WebView?, url: String?): Boolean {
|
||||||
if (url.isNullOrBlank()) return false
|
if (url.isNullOrBlank()) return false
|
||||||
if (url.startsWith("fromchat://", ignoreCase = true)) {
|
val redirectPrefix = redirectUriPrefixState.value
|
||||||
|
// Intercept trusted HTTPS callback (and fromchat:// deep links) before any page paint.
|
||||||
|
if (url.startsWith(redirectPrefix, ignoreCase = true) ||
|
||||||
|
url.startsWith("fromchat://", ignoreCase = true)
|
||||||
|
) {
|
||||||
Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
|
Logger.i(LOG_TAG, "intercept redirect url=${shortUrl(url)} id=$instanceId")
|
||||||
val code = extractOAuthCode(url)
|
onRedirectUrlState.value(url)
|
||||||
if (code != null) {
|
|
||||||
onCodeState.value(code)
|
|
||||||
} else {
|
|
||||||
onErrorState.value("")
|
|
||||||
}
|
|
||||||
return true
|
return true
|
||||||
}
|
}
|
||||||
if (!isYandexAuthNavigation(url)) {
|
if (!isAuthNavigationState.value(url)) {
|
||||||
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
|
Logger.d(LOG_TAG, "external nav url=${shortUrl(url)} id=$instanceId")
|
||||||
view?.context?.let { ctx ->
|
view?.context?.let { ctx ->
|
||||||
runCatching {
|
runCatching {
|
||||||
@@ -327,7 +329,11 @@ actual fun YandexOAuthWebView(
|
|||||||
"wv=${view?.let { Integer.toHexString(System.identityHashCode(it)) }} " +
|
"wv=${view?.let { Integer.toHexString(System.identityHashCode(it)) }} " +
|
||||||
"canGoBack=${view?.canGoBack()} stack=${Throwable().stackTraceToString().lineSequence().take(8).joinToString(" ← ")}",
|
"canGoBack=${view?.canGoBack()} stack=${Throwable().stackTraceToString().lineSequence().take(8).joinToString(" ← ")}",
|
||||||
)
|
)
|
||||||
if (url != null && url.startsWith(YANDEX_OAUTH_REDIRECT_URI, ignoreCase = true)) {
|
if (url != null && (
|
||||||
|
url.startsWith(redirectUriPrefixState.value, ignoreCase = true) ||
|
||||||
|
url.startsWith("fromchat://", ignoreCase = true)
|
||||||
|
)
|
||||||
|
) {
|
||||||
handleSpecialUrl(view, url)
|
handleSpecialUrl(view, url)
|
||||||
}
|
}
|
||||||
pageLoading = true
|
pageLoading = true
|
||||||
@@ -374,16 +380,16 @@ actual fun YandexOAuthWebView(
|
|||||||
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
|
"stack=${Throwable().stackTraceToString().lineSequence().drop(1).take(10).joinToString(" ← ")}",
|
||||||
)
|
)
|
||||||
if (clearCookies) {
|
if (clearCookies) {
|
||||||
clearYandexWebViewCookies()
|
clearOAuthWebViewCookies()
|
||||||
}
|
}
|
||||||
seedYandexThemeCookies(darkTheme)
|
seedOAuthThemeCookies(darkTheme, themeCookieHosts)
|
||||||
WebView(activity).apply {
|
WebView(activity).apply {
|
||||||
setBackgroundColor(fallbackColor.toArgb())
|
setBackgroundColor(fallbackColor.toArgb())
|
||||||
settings.javaScriptEnabled = true
|
settings.javaScriptEnabled = true
|
||||||
settings.domStorageEnabled = true
|
settings.domStorageEnabled = true
|
||||||
settings.javaScriptCanOpenWindowsAutomatically = true
|
settings.javaScriptCanOpenWindowsAutomatically = true
|
||||||
settings.setSupportMultipleWindows(true)
|
settings.setSupportMultipleWindows(true)
|
||||||
applyYandexDarkSettingsIfNeeded(this, darkTheme)
|
applyOAuthDarkSettingsIfNeeded(this, darkTheme)
|
||||||
webViewClient = client
|
webViewClient = client
|
||||||
webChromeClient = object : WebChromeClient() {
|
webChromeClient = object : WebChromeClient() {
|
||||||
override fun onCreateWindow(
|
override fun onCreateWindow(
|
||||||
@@ -394,14 +400,14 @@ actual fun YandexOAuthWebView(
|
|||||||
): Boolean {
|
): Boolean {
|
||||||
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
|
val transport = resultMsg?.obj as? WebView.WebViewTransport ?: return false
|
||||||
val temp = WebView(activity).apply {
|
val temp = WebView(activity).apply {
|
||||||
applyYandexDarkSettingsIfNeeded(this, darkTheme)
|
applyOAuthDarkSettingsIfNeeded(this, darkTheme)
|
||||||
webViewClient = object : WebViewClient() {
|
webViewClient = object : WebViewClient() {
|
||||||
override fun shouldOverrideUrlLoading(
|
override fun shouldOverrideUrlLoading(
|
||||||
v: WebView?,
|
v: WebView?,
|
||||||
request: WebResourceRequest?,
|
request: WebResourceRequest?,
|
||||||
): Boolean {
|
): Boolean {
|
||||||
val url = request?.url?.toString() ?: return false
|
val url = request?.url?.toString() ?: return false
|
||||||
if (!isYandexAuthNavigation(url)) {
|
if (!isAuthNavigationState.value(url)) {
|
||||||
runCatching {
|
runCatching {
|
||||||
context.startActivity(
|
context.startActivity(
|
||||||
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
|
Intent(Intent.ACTION_VIEW, Uri.parse(url)),
|
||||||
@@ -451,7 +457,7 @@ actual fun YandexOAuthWebView(
|
|||||||
update = { wv ->
|
update = { wv ->
|
||||||
val clientChanged = wv.webViewClient !== client
|
val clientChanged = wv.webViewClient !== client
|
||||||
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
val darkBefore = wv.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
||||||
applyYandexDarkSettingsIfNeeded(wv, darkTheme)
|
applyOAuthDarkSettingsIfNeeded(wv, darkTheme)
|
||||||
if (clientChanged) {
|
if (clientChanged) {
|
||||||
Logger.i(
|
Logger.i(
|
||||||
LOG_TAG,
|
LOG_TAG,
|
||||||
@@ -500,7 +506,7 @@ actual fun YandexOAuthWebView(
|
|||||||
}
|
}
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
private fun ApplyYandexWebViewSystemBars(
|
private fun ApplyOAuthWebViewSystemBars(
|
||||||
chromeColor: Color,
|
chromeColor: Color,
|
||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
restoreSurfaceColor: Color,
|
restoreSurfaceColor: Color,
|
||||||
@@ -557,26 +563,19 @@ private fun Context.findActivity(): Activity? {
|
|||||||
return null
|
return null
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun clearYandexWebViewCookies() {
|
private fun clearOAuthWebViewCookies() {
|
||||||
val cookieManager = CookieManager.getInstance()
|
val cookieManager = CookieManager.getInstance()
|
||||||
cookieManager.setAcceptCookie(true)
|
cookieManager.setAcceptCookie(true)
|
||||||
cookieManager.removeAllCookies(null)
|
cookieManager.removeAllCookies(null)
|
||||||
cookieManager.flush()
|
cookieManager.flush()
|
||||||
Logger.i(LOG_TAG, "cleared all WebView cookies for Yandex re-auth")
|
Logger.i(LOG_TAG, "cleared all WebView cookies for OAuth re-auth")
|
||||||
}
|
}
|
||||||
|
|
||||||
private fun seedYandexThemeCookies(darkTheme: Boolean) {
|
private fun seedOAuthThemeCookies(darkTheme: Boolean, hosts: List<String>) {
|
||||||
|
if (hosts.isEmpty()) return
|
||||||
val theme = if (darkTheme) "dark" else "light"
|
val theme = if (darkTheme) "dark" else "light"
|
||||||
val cookieManager = CookieManager.getInstance()
|
val cookieManager = CookieManager.getInstance()
|
||||||
cookieManager.setAcceptCookie(true)
|
cookieManager.setAcceptCookie(true)
|
||||||
val hosts = listOf(
|
|
||||||
"https://yandex.ru",
|
|
||||||
"https://yandex.com",
|
|
||||||
"https://passport.yandex.ru",
|
|
||||||
"https://passport.yandex.com",
|
|
||||||
"https://oauth.yandex.ru",
|
|
||||||
"https://oauth.yandex.com",
|
|
||||||
)
|
|
||||||
for (host in hosts) {
|
for (host in hosts) {
|
||||||
cookieManager.setCookie(host, "color_scheme=$theme; path=/")
|
cookieManager.setCookie(host, "color_scheme=$theme; path=/")
|
||||||
cookieManager.setCookie(host, "theme=$theme; path=/")
|
cookieManager.setCookie(host, "theme=$theme; path=/")
|
||||||
@@ -590,7 +589,7 @@ private fun seedYandexThemeCookies(darkTheme: Boolean) {
|
|||||||
* Re-applying the same force-dark value can reload the page and wipe in-progress OAuth UI.
|
* Re-applying the same force-dark value can reload the page and wipe in-progress OAuth UI.
|
||||||
*/
|
*/
|
||||||
@Suppress("DEPRECATION")
|
@Suppress("DEPRECATION")
|
||||||
private fun applyYandexDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
|
private fun applyOAuthDarkSettingsIfNeeded(webView: WebView, darkTheme: Boolean) {
|
||||||
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
val previous = webView.getTag(TAG_APPLIED_DARK_THEME) as? Boolean
|
||||||
if (previous == darkTheme) return
|
if (previous == darkTheme) return
|
||||||
Logger.w(
|
Logger.w(
|
||||||
@@ -671,36 +670,3 @@ private suspend fun sampleTopRowColor(webView: WebView): Color? {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
/**
|
|
||||||
* Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
|
|
||||||
*/
|
|
||||||
internal fun isYandexAuthNavigation(url: String): Boolean {
|
|
||||||
if (url.startsWith("fromchat://", ignoreCase = true)) return true
|
|
||||||
val uri = Uri.parse(url)
|
|
||||||
val host = uri.host?.lowercase() ?: return false
|
|
||||||
val path = uri.path.orEmpty().lowercase()
|
|
||||||
|
|
||||||
if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
|
|
||||||
return path.contains("captcha") ||
|
|
||||||
path.startsWith("/auth") ||
|
|
||||||
path.startsWith("/showcaptcha") ||
|
|
||||||
path.startsWith("/checkcaptcha")
|
|
||||||
}
|
|
||||||
|
|
||||||
return host == "oauth.yandex.com" ||
|
|
||||||
host == "oauth.yandex.ru" ||
|
|
||||||
host.endsWith(".oauth.yandex.com") ||
|
|
||||||
host.endsWith(".oauth.yandex.ru") ||
|
|
||||||
host == "passport.yandex.ru" ||
|
|
||||||
host == "passport.yandex.com" ||
|
|
||||||
host.endsWith(".passport.yandex.ru") ||
|
|
||||||
host.endsWith(".passport.yandex.com") ||
|
|
||||||
host.startsWith("auth.yandex.") ||
|
|
||||||
host.startsWith("login.yandex.") ||
|
|
||||||
host.startsWith("id.yandex.") ||
|
|
||||||
host == "sso.passport.yandex.ru" ||
|
|
||||||
host == "captcha.yandex.net" ||
|
|
||||||
host.endsWith(".captcha.yandex.net") ||
|
|
||||||
(host.contains("captcha") && host.contains("yandex"))
|
|
||||||
}
|
|
||||||
@@ -0,0 +1,10 @@
|
|||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="24dp"
|
||||||
|
android:height="24dp"
|
||||||
|
android:viewportWidth="48"
|
||||||
|
android:viewportHeight="48">
|
||||||
|
<!-- VK Compact Logo (2021–present) letter mark only; brand blue background removed for tintable icons. -->
|
||||||
|
<path
|
||||||
|
android:fillColor="#FFFFFFFF"
|
||||||
|
android:pathData="M25.54,34.5801C14.6,34.5801 8.3601,27.0801 8.1001,14.6001H13.5801C13.7601,23.7601 17.8,27.6401 21,28.4401V14.6001H26.1602V22.5001C29.3202,22.1601 32.6398,18.5601 33.7598,14.6001H38.9199C38.0599,19.4801 34.4599,23.0801 31.8999,24.5601C34.4599,25.7601 38.5601,28.9001 40.1201,34.5801H34.4399C33.2199,30.7801 30.1802,27.8401 26.1602,27.4401V34.5801H25.54Z" />
|
||||||
|
</vector>
|
||||||
@@ -5,7 +5,7 @@
|
|||||||
<string name="settings">Настройки</string>
|
<string name="settings">Настройки</string>
|
||||||
<string name="home">Главная</string>
|
<string name="home">Главная</string>
|
||||||
<string name="about">О приложении</string>
|
<string name="about">О приложении</string>
|
||||||
<string name="about_version">Версия 1.0</string>
|
<string name="about_version">Версия %1$s</string>
|
||||||
<string name="about_link_telegram">Telegram</string>
|
<string name="about_link_telegram">Telegram</string>
|
||||||
<string name="about_link_max">MAX</string>
|
<string name="about_link_max">MAX</string>
|
||||||
<string name="about_link_website">Сайт</string>
|
<string name="about_link_website">Сайт</string>
|
||||||
@@ -31,6 +31,7 @@
|
|||||||
<string name="display_name_error">От 1 до 64 символов</string>
|
<string name="display_name_error">От 1 до 64 символов</string>
|
||||||
<string name="fill_all_fields">Заполните все поля</string>
|
<string name="fill_all_fields">Заполните все поля</string>
|
||||||
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
<string name="username_length_error">Имя пользователя — от 3 до 20 символов</string>
|
||||||
|
<string name="username_chars_error">Имя пользователя может содержать только английские буквы, цифры, дефисы и подчеркивания</string>
|
||||||
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
<string name="password_length_error">Пароль — от 5 до 50 символов</string>
|
||||||
<string name="passwords_dont_match">Пароли не совпадают</string>
|
<string name="passwords_dont_match">Пароли не совпадают</string>
|
||||||
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
|
<string name="auth_welcome_title">Добро пожаловать в FromChat</string>
|
||||||
@@ -54,9 +55,14 @@
|
|||||||
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
<string name="auth_step_yandex_title">Войдите через Яндекс ID</string>
|
||||||
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
<string name="auth_step_yandex_body">Так мы боремся с вредоносными ботами и соблюдаем требования российских законов. От Яндекса мы получаем только email — и мы его не сохраняем: вход нужен лишь для защиты.</string>
|
||||||
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
<string name="auth_step_yandex_cta">Продолжить через Яндекс ID</string>
|
||||||
|
<string name="auth_step_verify_title">Подтвердите аккаунт</string>
|
||||||
|
<string name="auth_step_verify_body">Выберите Яндекс ID или VK ID. Мы используем это только против ботов и для соблюдения законов — email и профиль этих сервисов не сохраняем.</string>
|
||||||
|
<string name="auth_step_vk_cta">Продолжить через VK ID</string>
|
||||||
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
<string name="auth_yandex_webview_title">Яндекс ID</string>
|
||||||
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
<string name="auth_yandex_client_mismatch">Сервер вернул неожиданный идентификатор приложения Яндекса. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
<string name="auth_yandex_failed">Вход через Яндекс ID отменён или не удался.</string>
|
||||||
|
<string name="auth_vk_client_mismatch">Сервер вернул неожиданный идентификатор приложения VK. Обновите приложение или обратитесь в поддержку.</string>
|
||||||
|
<string name="auth_vk_failed">Вход через VK ID отменён или не удался.</string>
|
||||||
<string name="chats">Чаты</string>
|
<string name="chats">Чаты</string>
|
||||||
<string name="contacts">Контакты</string>
|
<string name="contacts">Контакты</string>
|
||||||
<string name="profile">Профиль</string>
|
<string name="profile">Профиль</string>
|
||||||
@@ -169,6 +175,7 @@
|
|||||||
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
<string name="profile_load_failed">Не получилось загрузить профиль</string>
|
||||||
<string name="profile_not_found">Профиль не найден</string>
|
<string name="profile_not_found">Профиль не найден</string>
|
||||||
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
<string name="profile_open_failed">Не удалось открыть профиль. Попробуйте снова.</string>
|
||||||
|
<string name="profile_invalid_link">Не удалось открыть ссылку</string>
|
||||||
<string name="action_open_settings">Настройки</string>
|
<string name="action_open_settings">Настройки</string>
|
||||||
<string name="action_chat">Написать</string>
|
<string name="action_chat">Написать</string>
|
||||||
<string name="action_copy_link">Скопировать ссылку</string>
|
<string name="action_copy_link">Скопировать ссылку</string>
|
||||||
@@ -431,6 +438,13 @@
|
|||||||
<string name="settings_yandex_step_confirm_cta">Продолжить</string>
|
<string name="settings_yandex_step_confirm_cta">Продолжить</string>
|
||||||
<string name="settings_yandex_step_done_title">Яндекс ID обновлён</string>
|
<string name="settings_yandex_step_done_title">Яндекс ID обновлён</string>
|
||||||
<string name="settings_yandex_step_done_body">Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.</string>
|
<string name="settings_yandex_step_done_body">Аккаунт теперь привязан к Яндекс ID, с которым вы только что вошли.</string>
|
||||||
|
<string name="settings_account_change_vk">Сменить VK ID</string>
|
||||||
|
<string name="settings_account_change_vk_d">Привязать другой аккаунт VK</string>
|
||||||
|
<string name="settings_vk_step_confirm_title">Сменить VK ID?</string>
|
||||||
|
<string name="settings_vk_step_confirm_body">Это не удаляет ваш аккаунт FromChat. Предыдущий VK ID будет освобождён, а вместо него привяжется тот, с которым вы войдёте.</string>
|
||||||
|
<string name="settings_vk_step_confirm_cta">Продолжить</string>
|
||||||
|
<string name="settings_vk_step_done_title">VK ID обновлён</string>
|
||||||
|
<string name="settings_vk_step_done_body">Аккаунт теперь привязан к VK ID, с которым вы только что вошли.</string>
|
||||||
<string name="settings_done">Готово</string>
|
<string name="settings_done">Готово</string>
|
||||||
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
<string name="settings_account_delete_confirm_title">Удалить аккаунт?</string>
|
||||||
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
<string name="settings_account_delete_confirm_body">Это нельзя отменить.</string>
|
||||||
|
|||||||
@@ -8,7 +8,7 @@
|
|||||||
<string name="settings">Settings</string>
|
<string name="settings">Settings</string>
|
||||||
<string name="home">Home</string>
|
<string name="home">Home</string>
|
||||||
<string name="about">About</string>
|
<string name="about">About</string>
|
||||||
<string name="about_version">Version 1.0</string>
|
<string name="about_version">Version %1$s</string>
|
||||||
<string name="about_link_telegram">Telegram</string>
|
<string name="about_link_telegram">Telegram</string>
|
||||||
<string name="about_link_max">MAX</string>
|
<string name="about_link_max">MAX</string>
|
||||||
<string name="about_link_website">Website</string>
|
<string name="about_link_website">Website</string>
|
||||||
@@ -38,6 +38,7 @@
|
|||||||
<!-- Validation Errors -->
|
<!-- Validation Errors -->
|
||||||
<string name="fill_all_fields">Please fill in every field</string>
|
<string name="fill_all_fields">Please fill in every field</string>
|
||||||
<string name="username_length_error">Username must be 3 to 20 characters</string>
|
<string name="username_length_error">Username must be 3 to 20 characters</string>
|
||||||
|
<string name="username_chars_error">Username can only contain English letters, numbers, hyphens and underscores</string>
|
||||||
<string name="password_length_error">Password must be 5 to 50 characters</string>
|
<string name="password_length_error">Password must be 5 to 50 characters</string>
|
||||||
<string name="passwords_dont_match">The two passwords don’t match</string>
|
<string name="passwords_dont_match">The two passwords don’t match</string>
|
||||||
<string name="auth_welcome_title">Welcome to FromChat</string>
|
<string name="auth_welcome_title">Welcome to FromChat</string>
|
||||||
@@ -61,9 +62,14 @@
|
|||||||
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
<string name="auth_step_yandex_title">Sign in with Yandex ID</string>
|
||||||
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
<string name="auth_step_yandex_body">This helps us fight malicious bots and meet Russian legal requirements. From Yandex we only get your email — and we don’t store it; sign-in is only used for security reasons.</string>
|
||||||
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
<string name="auth_step_yandex_cta">Continue with Yandex ID</string>
|
||||||
|
<string name="auth_step_verify_title">Verify your account</string>
|
||||||
|
<string name="auth_step_verify_body">Choose Yandex ID or VK ID. We only use this to fight bots and meet legal requirements — we don’t store your email or profile from these providers.</string>
|
||||||
|
<string name="auth_step_vk_cta">Continue with VK ID</string>
|
||||||
<string name="auth_yandex_webview_title">Yandex ID</string>
|
<string name="auth_yandex_webview_title">Yandex ID</string>
|
||||||
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
<string name="auth_yandex_client_mismatch">This server returned an unexpected Yandex app id. Update the app or contact support.</string>
|
||||||
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
<string name="auth_yandex_failed">Yandex sign-in was cancelled or failed.</string>
|
||||||
|
<string name="auth_vk_client_mismatch">This server returned an unexpected VK app id. Update the app or contact support.</string>
|
||||||
|
<string name="auth_vk_failed">VK sign-in was cancelled or failed.</string>
|
||||||
<!-- Main Screen -->
|
<!-- Main Screen -->
|
||||||
<string name="chats">Chats</string>
|
<string name="chats">Chats</string>
|
||||||
<string name="contacts">Contacts</string>
|
<string name="contacts">Contacts</string>
|
||||||
@@ -187,6 +193,7 @@
|
|||||||
<string name="profile_load_failed">Couldn’t load this profile</string>
|
<string name="profile_load_failed">Couldn’t load this profile</string>
|
||||||
<string name="profile_not_found">This profile could not be found</string>
|
<string name="profile_not_found">This profile could not be found</string>
|
||||||
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
|
<string name="profile_open_failed">Could not open this profile. Please try again.</string>
|
||||||
|
<string name="profile_invalid_link">Couldn’t open the link</string>
|
||||||
<string name="action_open_settings">Settings</string>
|
<string name="action_open_settings">Settings</string>
|
||||||
<string name="action_chat">Chat</string>
|
<string name="action_chat">Chat</string>
|
||||||
<string name="action_copy_link">Copy link</string>
|
<string name="action_copy_link">Copy link</string>
|
||||||
@@ -459,6 +466,13 @@
|
|||||||
<string name="settings_yandex_step_confirm_cta">Continue</string>
|
<string name="settings_yandex_step_confirm_cta">Continue</string>
|
||||||
<string name="settings_yandex_step_done_title">Yandex ID updated</string>
|
<string name="settings_yandex_step_done_title">Yandex ID updated</string>
|
||||||
<string name="settings_yandex_step_done_body">Your account is now linked to the Yandex ID you just signed in with.</string>
|
<string name="settings_yandex_step_done_body">Your account is now linked to the Yandex ID you just signed in with.</string>
|
||||||
|
<string name="settings_account_change_vk">Change VK ID</string>
|
||||||
|
<string name="settings_account_change_vk_d">Link a different VK account</string>
|
||||||
|
<string name="settings_vk_step_confirm_title">Change VK ID?</string>
|
||||||
|
<string name="settings_vk_step_confirm_body">This does not delete your FromChat account. Your previous VK ID will be freed, and the new one you sign in with will be linked instead.</string>
|
||||||
|
<string name="settings_vk_step_confirm_cta">Continue</string>
|
||||||
|
<string name="settings_vk_step_done_title">VK ID updated</string>
|
||||||
|
<string name="settings_vk_step_done_body">Your account is now linked to the VK ID you just signed in with.</string>
|
||||||
<string name="settings_done">Done</string>
|
<string name="settings_done">Done</string>
|
||||||
<string name="settings_account_delete_confirm_title">Delete account?</string>
|
<string name="settings_account_delete_confirm_title">Delete account?</string>
|
||||||
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
<string name="settings_account_delete_confirm_body">This cannot be undone.</string>
|
||||||
|
|||||||
@@ -107,13 +107,19 @@ import ru.fromchat.api.schema.user.VerifyPasswordRequest
|
|||||||
import ru.fromchat.api.schema.user.auth.AuthPasswordStepRequest
|
import ru.fromchat.api.schema.user.auth.AuthPasswordStepRequest
|
||||||
import ru.fromchat.api.schema.user.auth.AuthUsernameStepRequest
|
import ru.fromchat.api.schema.user.auth.AuthUsernameStepRequest
|
||||||
import ru.fromchat.api.schema.user.auth.AuthUsernameStepResponse
|
import ru.fromchat.api.schema.user.auth.AuthUsernameStepResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.AccountVkResponse
|
||||||
import ru.fromchat.api.schema.user.auth.AccountYandexResponse
|
import ru.fromchat.api.schema.user.auth.AccountYandexResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.ChangeVkRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.ChangeVkResponse
|
||||||
import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
|
import ru.fromchat.api.schema.user.auth.ChangeYandexRequest
|
||||||
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
|
import ru.fromchat.api.schema.user.auth.ChangeYandexResponse
|
||||||
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
import ru.fromchat.api.schema.user.auth.CheckAuthResponse
|
||||||
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
import ru.fromchat.api.schema.user.auth.CheckUsernameResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkExchangeRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkExchangeResponse
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
import ru.fromchat.api.schema.user.auth.YandexExchangeRequest
|
||||||
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
import ru.fromchat.api.schema.user.auth.YandexExchangeResponse
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
@@ -493,8 +499,9 @@ object ApiClient {
|
|||||||
sealed interface AuthPasswordStepOutcome {
|
sealed interface AuthPasswordStepOutcome {
|
||||||
data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
|
data class LoggedIn(val response: LoginResponse) : AuthPasswordStepOutcome
|
||||||
data class NeedsRegister(
|
data class NeedsRegister(
|
||||||
val yandexRequired: Boolean,
|
val verificationRequired: Boolean,
|
||||||
val yandex: YandexOAuthParams?,
|
val yandex: YandexOAuthParams?,
|
||||||
|
val vk: VkOAuthParams?,
|
||||||
) : AuthPasswordStepOutcome
|
) : AuthPasswordStepOutcome
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -513,8 +520,9 @@ object ApiClient {
|
|||||||
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
val status = raw["status"]?.jsonPrimitive?.contentOrNull
|
||||||
return when (status) {
|
return when (status) {
|
||||||
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
"needs_register" -> AuthPasswordStepOutcome.NeedsRegister(
|
||||||
yandexRequired = raw["yandex_required"]?.jsonPrimitive?.booleanOrNull == true,
|
verificationRequired = raw["verification_required"]?.jsonPrimitive?.booleanOrNull == true,
|
||||||
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
yandex = raw["yandex"]?.let { json.decodeFromJsonElement(YandexOAuthParams.serializer(), it) },
|
||||||
|
vk = raw["vk"]?.let { json.decodeFromJsonElement(VkOAuthParams.serializer(), it) },
|
||||||
)
|
)
|
||||||
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
else -> AuthPasswordStepOutcome.LoggedIn(json.decodeFromJsonElement(LoginResponse.serializer(), raw))
|
||||||
}
|
}
|
||||||
@@ -528,6 +536,26 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
|
||||||
|
suspend fun authVkExchange(
|
||||||
|
code: String,
|
||||||
|
codeVerifier: String,
|
||||||
|
deviceId: String,
|
||||||
|
state: String,
|
||||||
|
): VkExchangeResponse =
|
||||||
|
httpProbe
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/auth/vk/exchange") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(
|
||||||
|
VkExchangeRequest(
|
||||||
|
code = code,
|
||||||
|
code_verifier = codeVerifier,
|
||||||
|
device_id = deviceId,
|
||||||
|
state = state,
|
||||||
|
),
|
||||||
|
)
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
|
suspend fun authRegisterConfirm(request: RegisterConfirmRequest): LoginResponse =
|
||||||
httpProbe
|
httpProbe
|
||||||
.post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
|
.post("${ServerConfig.apiBaseUrl}/auth/steps/register/confirm") {
|
||||||
@@ -1542,6 +1570,19 @@ object ApiClient {
|
|||||||
}
|
}
|
||||||
.body()
|
.body()
|
||||||
|
|
||||||
|
suspend fun getAccountVk(): AccountVkResponse =
|
||||||
|
http
|
||||||
|
.get("${ServerConfig.apiBaseUrl}/account/vk")
|
||||||
|
.body()
|
||||||
|
|
||||||
|
suspend fun changeAccountVk(registrationProof: String): ChangeVkResponse =
|
||||||
|
http
|
||||||
|
.post("${ServerConfig.apiBaseUrl}/account/vk") {
|
||||||
|
contentType(ContentType.Application.Json)
|
||||||
|
setBody(ChangeVkRequest(registration_proof = registrationProof))
|
||||||
|
}
|
||||||
|
.body()
|
||||||
|
|
||||||
suspend fun verifyPasswordDerived(passwordDerived: String) {
|
suspend fun verifyPasswordDerived(passwordDerived: String) {
|
||||||
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
|
http.post("${ServerConfig.apiBaseUrl}/verify-password") {
|
||||||
contentType(ContentType.Application.Json)
|
contentType(ContentType.Application.Json)
|
||||||
|
|||||||
+1
-1
@@ -386,7 +386,7 @@ object MessageCacheStore {
|
|||||||
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
|
if (product in 0.92f..1.08f && kotlin.math.abs(decodedAspect - serverAspect) > 0.15f) {
|
||||||
resolved = resolved.copy(
|
resolved = resolved.copy(
|
||||||
fileAspectRatios = listOf(decodedAspect),
|
fileAspectRatios = listOf(decodedAspect),
|
||||||
fileDimensions = thumbDims?.let { listOf(it.first to it.second) } ?: resolved.fileDimensions,
|
fileDimensions = listOf(thumbDims.first to thumbDims.second),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+4
-1
@@ -1,6 +1,7 @@
|
|||||||
package ru.fromchat.api.local.db.store
|
package ru.fromchat.api.local.db.store
|
||||||
|
|
||||||
import kotlinx.coroutines.flow.Flow
|
import kotlinx.coroutines.flow.Flow
|
||||||
|
import kotlinx.coroutines.flow.map
|
||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewState
|
import ru.fromchat.api.local.messages.ChatListPreviewState
|
||||||
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
import ru.fromchat.api.local.messages.ChatListPreviewStrings
|
||||||
@@ -21,7 +22,9 @@ object MessageRepository {
|
|||||||
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
MessageCacheStore.observeMessages(activeInstance(), conversationId)
|
||||||
|
|
||||||
fun observePublicMessages(): Flow<List<Message>> =
|
fun observePublicMessages(): Flow<List<Message>> =
|
||||||
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID))
|
observeMessages(conversationIdForGroup(GENERAL_PUBLIC_GROUP_ID)).map { rows ->
|
||||||
|
ProfileCache.enrichPublicMessagesForDisplay(rows)
|
||||||
|
}
|
||||||
|
|
||||||
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
fun observeDmMessages(otherUserId: Int): Flow<List<Message>> =
|
||||||
observeMessages(conversationIdForDm(otherUserId))
|
observeMessages(conversationIdForDm(otherUserId))
|
||||||
|
|||||||
@@ -454,30 +454,33 @@ object ProfileCache {
|
|||||||
val uid = message.user_id
|
val uid = message.user_id
|
||||||
if (uid <= 0) return
|
if (uid <= 0) return
|
||||||
val existing = get(uid)
|
val existing = get(uid)
|
||||||
|
val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
val incomingPic = message.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
if (existing != null && !existing.isClientPreviewOnly) {
|
if (existing != null && !existing.isClientPreviewOnly) {
|
||||||
val patched = existing.copy(
|
val patched = existing.copy(
|
||||||
verified = message.verified ?: existing.verified,
|
verified = message.verified ?: existing.verified,
|
||||||
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
verificationStatus = message.verificationStatus ?: existing.verificationStatus,
|
||||||
|
displayName = existing.displayName?.takeIf { it.isNotBlank() } ?: incomingDisplay,
|
||||||
|
profilePicture = existing.profilePicture?.takeIf { it.isNotBlank() } ?: incomingPic,
|
||||||
|
username = existing.username.trim().ifBlank { message.username.trim() },
|
||||||
)
|
)
|
||||||
if (patched != existing) put(patched)
|
if (patched != existing) put(patched)
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
|
||||||
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
val uname = message.username.trim().ifBlank { existing?.username?.trim().orEmpty() }
|
||||||
val incomingDisplay = message.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
val displayName = incomingDisplay ?: existing?.displayName?.takeIf { it.isNotBlank() }
|
||||||
?: existing?.displayName?.takeIf { it.isNotBlank() }
|
if (uname.isBlank() && displayName.isNullOrBlank()) return
|
||||||
if (uname.isBlank() && incomingDisplay.isNullOrBlank()) return
|
if (uname.isBlank() || displayName.isNullOrBlank()) {
|
||||||
if (uname.isBlank() || incomingDisplay.isNullOrBlank()) {
|
|
||||||
Logger.d(
|
Logger.d(
|
||||||
"ProfileCache",
|
"ProfileCache",
|
||||||
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
|
"mergePreviewFromPublicMessage missingIdentity id=$uid " +
|
||||||
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!incomingDisplay.isNullOrBlank()}",
|
"hasUsername=${uname.isNotBlank()} hasDisplayName=${!displayName.isNullOrBlank()}",
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
|
val isDeleted = isDeletedPlaceholderUsername(uname) || existing?.deleted == true
|
||||||
val display = if (isDeleted) null else incomingDisplay
|
val display = if (isDeleted) null else displayName
|
||||||
val pic = if (isDeleted) null else message.profile_picture?.takeIf { it.isNotBlank() }
|
val pic = if (isDeleted) null else incomingPic ?: existing?.profilePicture
|
||||||
?: existing?.profilePicture
|
|
||||||
|
|
||||||
put(
|
put(
|
||||||
UserProfile(
|
UserProfile(
|
||||||
|
|||||||
@@ -27,11 +27,20 @@ data class YandexOAuthParams(
|
|||||||
val scope: String,
|
val scope: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VkOAuthParams(
|
||||||
|
val client_id: String,
|
||||||
|
val redirect_uri: String,
|
||||||
|
val authorize_url: String,
|
||||||
|
val scope: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AuthNeedsRegisterResponse(
|
data class AuthNeedsRegisterResponse(
|
||||||
val status: String,
|
val status: String,
|
||||||
val yandex_required: Boolean = false,
|
val verification_required: Boolean = false,
|
||||||
val yandex: YandexOAuthParams? = null,
|
val yandex: YandexOAuthParams? = null,
|
||||||
|
val vk: VkOAuthParams? = null,
|
||||||
)
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
@@ -45,6 +54,19 @@ data class YandexExchangeResponse(
|
|||||||
val registration_proof: String,
|
val registration_proof: String,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VkExchangeRequest(
|
||||||
|
val code: String,
|
||||||
|
val code_verifier: String,
|
||||||
|
val device_id: String,
|
||||||
|
val state: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class VkExchangeResponse(
|
||||||
|
val registration_proof: String,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class AccountYandexResponse(
|
data class AccountYandexResponse(
|
||||||
val linked: Boolean = false,
|
val linked: Boolean = false,
|
||||||
@@ -62,6 +84,23 @@ data class ChangeYandexResponse(
|
|||||||
val unchanged: Boolean = false,
|
val unchanged: Boolean = false,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class AccountVkResponse(
|
||||||
|
val linked: Boolean = false,
|
||||||
|
val vk: VkOAuthParams? = null,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChangeVkRequest(
|
||||||
|
val registration_proof: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
@Serializable
|
||||||
|
data class ChangeVkResponse(
|
||||||
|
val status: String? = null,
|
||||||
|
val unchanged: Boolean = false,
|
||||||
|
)
|
||||||
|
|
||||||
@Serializable
|
@Serializable
|
||||||
data class RegisterConfirmRequest(
|
data class RegisterConfirmRequest(
|
||||||
val username: String,
|
val username: String,
|
||||||
@@ -70,4 +109,5 @@ data class RegisterConfirmRequest(
|
|||||||
val confirm_password: String,
|
val confirm_password: String,
|
||||||
val bio: String? = null,
|
val bio: String? = null,
|
||||||
val registration_proof: String? = null,
|
val registration_proof: String? = null,
|
||||||
|
val vk_registration_proof: String? = null,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -0,0 +1,36 @@
|
|||||||
|
package ru.fromchat.auth.vk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep VK ID / OAuth / captcha flows in the WebView; open everything else externally.
|
||||||
|
*/
|
||||||
|
internal fun isVkAuthNavigation(url: String): Boolean {
|
||||||
|
if (url.startsWith("fromchat://", ignoreCase = true)) return true
|
||||||
|
val withoutScheme = url.substringAfter("://", missingDelimiterValue = "")
|
||||||
|
val host = withoutScheme.substringBefore('/').substringBefore('?').substringBefore('#').lowercase()
|
||||||
|
|
||||||
|
return host == "id.vk.ru" ||
|
||||||
|
host == "id.vk.com" ||
|
||||||
|
host.endsWith(".id.vk.ru") ||
|
||||||
|
host.endsWith(".id.vk.com") ||
|
||||||
|
host == "login.vk.ru" ||
|
||||||
|
host == "login.vk.com" ||
|
||||||
|
host == "oauth.vk.com" ||
|
||||||
|
host == "oauth.vk.ru" ||
|
||||||
|
host == "m.vk.ru" ||
|
||||||
|
host == "m.vk.com" ||
|
||||||
|
host == "vk.ru" ||
|
||||||
|
host == "vk.com" ||
|
||||||
|
host == "www.vk.ru" ||
|
||||||
|
host == "www.vk.com" ||
|
||||||
|
host == "api.fromchat.ru" ||
|
||||||
|
(host.contains("captcha") && host.contains("vk"))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val VK_OAUTH_THEME_COOKIE_HOSTS = listOf(
|
||||||
|
"https://id.vk.ru",
|
||||||
|
"https://id.vk.com",
|
||||||
|
"https://vk.ru",
|
||||||
|
"https://vk.com",
|
||||||
|
"https://login.vk.ru",
|
||||||
|
"https://oauth.vk.com",
|
||||||
|
)
|
||||||
@@ -0,0 +1,150 @@
|
|||||||
|
package ru.fromchat.auth.vk
|
||||||
|
|
||||||
|
import kotlin.random.Random
|
||||||
|
import ru.fromchat.auth.yandex.PkcePair
|
||||||
|
import ru.fromchat.auth.yandex.generatePkcePair
|
||||||
|
import ru.fromchat.auth.yandex.isOfficialApiHost
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Prod VK OAuth client id (identity-only app). When non-empty and the API host is
|
||||||
|
* [OFFICIAL_API_HOST], the server-supplied client_id must match this value.
|
||||||
|
*/
|
||||||
|
internal const val OFFICIAL_VK_OAUTH_CLIENT_ID = ""
|
||||||
|
|
||||||
|
internal const val VK_OAUTH_REDIRECT_URI = "https://api.fromchat.ru/oauth/vk"
|
||||||
|
internal const val VK_OAUTH_DEEP_LINK = "fromchat://oauth/vk"
|
||||||
|
|
||||||
|
private const val OAUTH_STATE_ALPHABET = "abcdefghijklmnopqrstuvwxyzABCDEFGHIJKLMNOPQRSTUVWXYZ0123456789_-"
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns the client_id to use, or null if the official host sent a mismatched id.
|
||||||
|
*/
|
||||||
|
internal fun resolveVkClientId(serverClientId: String, serverIp: String): String? {
|
||||||
|
val trimmed = serverClientId.trim()
|
||||||
|
if (trimmed.isEmpty()) return null
|
||||||
|
if (!isOfficialApiHost(serverIp)) return trimmed
|
||||||
|
val pinned = OFFICIAL_VK_OAUTH_CLIENT_ID.trim()
|
||||||
|
if (pinned.isEmpty()) return trimmed
|
||||||
|
return if (pinned == trimmed) trimmed else null
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun generateOAuthState(length: Int = 43): String {
|
||||||
|
require(length >= 32)
|
||||||
|
val bytes = ByteArray(length).also { Random.Default.nextBytes(it) }
|
||||||
|
return buildString(length) {
|
||||||
|
for (b in bytes) {
|
||||||
|
append(OAUTH_STATE_ALPHABET[(b.toInt() and 0x7f) % OAUTH_STATE_ALPHABET.length])
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
internal fun buildVkAuthorizeUrl(
|
||||||
|
authorizeUrl: String,
|
||||||
|
clientId: String,
|
||||||
|
redirectUri: String,
|
||||||
|
scope: String,
|
||||||
|
codeChallenge: String,
|
||||||
|
state: String,
|
||||||
|
languageTag: String = "en",
|
||||||
|
darkTheme: Boolean = false,
|
||||||
|
): String {
|
||||||
|
val lang = languageTag.substringBefore('-').lowercase().ifBlank { "en" }
|
||||||
|
val langId = if (lang == "ru") "0" else "3"
|
||||||
|
val scheme = if (darkTheme) "dark" else "light"
|
||||||
|
val base = authorizeUrl.trim().trimEnd('?')
|
||||||
|
val params = buildList {
|
||||||
|
add("response_type" to "code")
|
||||||
|
add("client_id" to clientId)
|
||||||
|
add("redirect_uri" to redirectUri)
|
||||||
|
if (scope.isNotBlank()) {
|
||||||
|
add("scope" to scope.trim())
|
||||||
|
}
|
||||||
|
add("code_challenge" to codeChallenge)
|
||||||
|
add("code_challenge_method" to "S256")
|
||||||
|
add("state" to state)
|
||||||
|
add("lang_id" to langId)
|
||||||
|
add("scheme" to scheme)
|
||||||
|
}.joinToString("&") { (k, v) ->
|
||||||
|
"${encodeUrl(k)}=${encodeUrl(v)}"
|
||||||
|
}
|
||||||
|
return "$base?$params"
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun encodeUrl(value: String): String = buildString(value.length) {
|
||||||
|
for (ch in value) {
|
||||||
|
when {
|
||||||
|
ch.isLetterOrDigit() || ch in "-_.~" -> append(ch)
|
||||||
|
else -> {
|
||||||
|
val bytes = ch.toString().encodeToByteArray()
|
||||||
|
for (b in bytes) {
|
||||||
|
append('%')
|
||||||
|
append(((b.toInt() shr 4) and 0xf).toString(16).uppercase())
|
||||||
|
append((b.toInt() and 0xf).toString(16).uppercase())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class VkOAuthRedirect(
|
||||||
|
val code: String,
|
||||||
|
val deviceId: String,
|
||||||
|
val state: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
internal fun extractVkOAuthRedirect(redirectUrl: String, redirectUri: String = VK_OAUTH_REDIRECT_URI): VkOAuthRedirect? {
|
||||||
|
val uri = redirectUrl.trim()
|
||||||
|
val expectedHttps = redirectUri.trim().ifBlank { VK_OAUTH_REDIRECT_URI }
|
||||||
|
val matchesPrefix = uri.startsWith(expectedHttps, ignoreCase = true) ||
|
||||||
|
uri.startsWith(VK_OAUTH_DEEP_LINK, ignoreCase = true) ||
|
||||||
|
uri.contains("/oauth/vk?", ignoreCase = true) ||
|
||||||
|
uri.substringBefore('?', missingDelimiterValue = uri).endsWith("/oauth/vk", ignoreCase = true)
|
||||||
|
if (!matchesPrefix) return null
|
||||||
|
val query = uri.substringAfter('?', missingDelimiterValue = "")
|
||||||
|
if (query.isEmpty()) return null
|
||||||
|
var code: String? = null
|
||||||
|
var deviceId: String? = null
|
||||||
|
var state: String? = null
|
||||||
|
for (part in query.split('&')) {
|
||||||
|
val key = part.substringBefore('=')
|
||||||
|
val raw = part.substringAfter('=', missingDelimiterValue = "")
|
||||||
|
if (raw.isEmpty()) continue
|
||||||
|
val decoded = decodeUrl(raw)
|
||||||
|
when (key) {
|
||||||
|
"code" -> code = decoded
|
||||||
|
"device_id" -> deviceId = decoded
|
||||||
|
"state" -> state = decoded
|
||||||
|
}
|
||||||
|
}
|
||||||
|
val c = code ?: return null
|
||||||
|
val d = deviceId ?: return null
|
||||||
|
val s = state ?: return null
|
||||||
|
return VkOAuthRedirect(code = c, deviceId = d, state = s)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun decodeUrl(value: String): String {
|
||||||
|
val bytes = ArrayList<Byte>()
|
||||||
|
var i = 0
|
||||||
|
while (i < value.length) {
|
||||||
|
val c = value[i]
|
||||||
|
when {
|
||||||
|
c == '+' -> {
|
||||||
|
bytes.add(' '.code.toByte())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
c == '%' && i + 2 < value.length -> {
|
||||||
|
val hex = value.substring(i + 1, i + 3)
|
||||||
|
bytes.add(hex.toInt(16).toByte())
|
||||||
|
i += 3
|
||||||
|
}
|
||||||
|
else -> {
|
||||||
|
bytes.add(c.code.toByte())
|
||||||
|
i++
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
return bytes.toByteArray().decodeToString()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Re-export PKCE helpers used by VK flows (same module as Yandex).
|
||||||
|
internal fun generateVkPkcePair(): PkcePair = generatePkcePair()
|
||||||
@@ -0,0 +1,44 @@
|
|||||||
|
package ru.fromchat.auth.yandex
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Keep Yandex ID / OAuth / captcha flows in the WebView; open everything else externally.
|
||||||
|
*/
|
||||||
|
internal fun isYandexAuthNavigation(url: String): Boolean {
|
||||||
|
if (url.startsWith("fromchat://", ignoreCase = true)) return true
|
||||||
|
val withoutScheme = url.substringAfter("://", missingDelimiterValue = "")
|
||||||
|
val hostAndPath = withoutScheme.substringBefore('#').substringBefore('?')
|
||||||
|
val host = hostAndPath.substringBefore('/').lowercase()
|
||||||
|
val path = hostAndPath.substringAfter('/', missingDelimiterValue = "").lowercase().let { "/$it" }
|
||||||
|
|
||||||
|
if (host == "yandex.ru" || host == "www.yandex.ru" || host == "ya.ru" || host == "www.ya.ru") {
|
||||||
|
return path.contains("captcha") ||
|
||||||
|
path.startsWith("/auth") ||
|
||||||
|
path.startsWith("/showcaptcha") ||
|
||||||
|
path.startsWith("/checkcaptcha")
|
||||||
|
}
|
||||||
|
|
||||||
|
return host == "oauth.yandex.com" ||
|
||||||
|
host == "oauth.yandex.ru" ||
|
||||||
|
host.endsWith(".oauth.yandex.com") ||
|
||||||
|
host.endsWith(".oauth.yandex.ru") ||
|
||||||
|
host == "passport.yandex.ru" ||
|
||||||
|
host == "passport.yandex.com" ||
|
||||||
|
host.endsWith(".passport.yandex.ru") ||
|
||||||
|
host.endsWith(".passport.yandex.com") ||
|
||||||
|
host.startsWith("auth.yandex.") ||
|
||||||
|
host.startsWith("login.yandex.") ||
|
||||||
|
host.startsWith("id.yandex.") ||
|
||||||
|
host == "sso.passport.yandex.ru" ||
|
||||||
|
host == "captcha.yandex.net" ||
|
||||||
|
host.endsWith(".captcha.yandex.net") ||
|
||||||
|
(host.contains("captcha") && host.contains("yandex"))
|
||||||
|
}
|
||||||
|
|
||||||
|
internal val YANDEX_OAUTH_THEME_COOKIE_HOSTS = listOf(
|
||||||
|
"https://yandex.ru",
|
||||||
|
"https://yandex.com",
|
||||||
|
"https://passport.yandex.ru",
|
||||||
|
"https://passport.yandex.com",
|
||||||
|
"https://oauth.yandex.ru",
|
||||||
|
"https://oauth.yandex.com",
|
||||||
|
)
|
||||||
@@ -79,6 +79,8 @@ import ru.fromchat.legal.DocumentScreen
|
|||||||
import ru.fromchat.legal.DocumentType
|
import ru.fromchat.legal.DocumentType
|
||||||
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
import ru.fromchat.notifications.NotificationLaunchCoordinator
|
||||||
import ru.fromchat.ui.auth.AuthScreen
|
import ru.fromchat.ui.auth.AuthScreen
|
||||||
|
import ru.fromchat.ui.auth.vk.VkOAuthNav
|
||||||
|
import ru.fromchat.ui.auth.vk.VkOAuthScreen
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||||
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
import ru.fromchat.ui.auth.yandex.YandexOAuthScreen
|
||||||
import ru.fromchat.ui.calls.CallOverlay
|
import ru.fromchat.ui.calls.CallOverlay
|
||||||
@@ -103,6 +105,9 @@ import ru.fromchat.ui.main.settings.account.changepassword.ChangePasswordScreen
|
|||||||
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
|
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexConfirmScreen
|
||||||
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
|
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexDoneScreen
|
||||||
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
|
import ru.fromchat.ui.main.settings.account.changeyandex.ChangeYandexOAuthScreen
|
||||||
|
import ru.fromchat.ui.main.settings.account.changevk.ChangeVkConfirmScreen
|
||||||
|
import ru.fromchat.ui.main.settings.account.changevk.ChangeVkDoneScreen
|
||||||
|
import ru.fromchat.ui.main.settings.account.changevk.ChangeVkOAuthScreen
|
||||||
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
import ru.fromchat.ui.main.settings.account.delete.DeleteAccountScreen
|
||||||
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
|
import ru.fromchat.ui.main.settings.server.ServerConfigScreen
|
||||||
import ru.fromchat.ui.profile.EditProfileFocusField
|
import ru.fromchat.ui.profile.EditProfileFocusField
|
||||||
@@ -466,6 +471,10 @@ fun App(
|
|||||||
YandexOAuthScreen()
|
YandexOAuthScreen()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
composable(VkOAuthNav.ROUTE) {
|
||||||
|
VkOAuthScreen()
|
||||||
|
}
|
||||||
|
|
||||||
composable("chat") {
|
composable("chat") {
|
||||||
MainScreen(
|
MainScreen(
|
||||||
sharedTransitionScope = this@SharedTransitionLayout,
|
sharedTransitionScope = this@SharedTransitionLayout,
|
||||||
@@ -699,6 +708,26 @@ fun App(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
settingsComposable(SettingsRoutes.AccountVkFlow) {
|
||||||
|
ChangeVkConfirmScreen(
|
||||||
|
onBack = { navController.navigateUp() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsComposable(SettingsRoutes.AccountVkOAuth) {
|
||||||
|
ChangeVkOAuthScreen(
|
||||||
|
onBack = { navController.navigateUp() },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
settingsComposable(SettingsRoutes.AccountVkDone) {
|
||||||
|
ChangeVkDoneScreen(
|
||||||
|
onDone = {
|
||||||
|
navController.popBackStack(SettingsRoutes.Account, inclusive = false)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
settingsComposable(SettingsRoutes.Account) {
|
settingsComposable(SettingsRoutes.Account) {
|
||||||
AccountScreen(
|
AccountScreen(
|
||||||
onBack = { navController.navigateUp() },
|
onBack = { navController.navigateUp() },
|
||||||
@@ -709,6 +738,7 @@ fun App(
|
|||||||
},
|
},
|
||||||
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
|
onChangePassword = { navController.navigate(SettingsRoutes.SecurityPasswordFlow) },
|
||||||
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
|
onChangeYandexId = { navController.navigate(SettingsRoutes.AccountYandexFlow) },
|
||||||
|
onChangeVkId = { navController.navigate(SettingsRoutes.AccountVkFlow) },
|
||||||
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
|
onDeleteAccount = { navController.navigate(SettingsRoutes.AccountDeleteFlow) },
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +1,10 @@
|
|||||||
package ru.fromchat.ui.auth
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Survives [AuthScreen] leaving composition when navigating to the Yandex OAuth route.
|
* Survives [AuthScreen] leaving composition when navigating to an OAuth route.
|
||||||
* Cleared on welcome / successful auth / explicit reset to username.
|
* Cleared on welcome / successful auth / explicit reset to username.
|
||||||
*/
|
*/
|
||||||
internal object AuthRegisterDraft {
|
internal object AuthRegisterDraft {
|
||||||
@@ -12,9 +13,11 @@ internal object AuthRegisterDraft {
|
|||||||
var confirmPassword: String = ""
|
var confirmPassword: String = ""
|
||||||
var displayName: String = ""
|
var displayName: String = ""
|
||||||
var bio: String = ""
|
var bio: String = ""
|
||||||
var yandexRequired: Boolean = false
|
var verificationRequired: Boolean = false
|
||||||
var yandexParams: YandexOAuthParams? = null
|
var yandexParams: YandexOAuthParams? = null
|
||||||
var registrationProof: String? = null
|
var vkParams: VkOAuthParams? = null
|
||||||
|
var yandexRegistrationProof: String? = null
|
||||||
|
var vkRegistrationProof: String? = null
|
||||||
var page: Int = 0
|
var page: Int = 0
|
||||||
|
|
||||||
fun clear() {
|
fun clear() {
|
||||||
@@ -23,9 +26,11 @@ internal object AuthRegisterDraft {
|
|||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
displayName = ""
|
displayName = ""
|
||||||
bio = ""
|
bio = ""
|
||||||
yandexRequired = false
|
verificationRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
registrationProof = null
|
vkParams = null
|
||||||
|
yandexRegistrationProof = null
|
||||||
|
vkRegistrationProof = null
|
||||||
page = 0
|
page = 0
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -30,13 +30,13 @@ import ru.fromchat.api.instance.probeServer
|
|||||||
import ru.fromchat.api.schema.core.ErrorResponse
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
import ru.fromchat.api.schema.user.auth.LoginResponse
|
import ru.fromchat.api.schema.user.auth.LoginResponse
|
||||||
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
import ru.fromchat.api.schema.user.auth.RegisterConfirmRequest
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.change_server
|
import ru.fromchat.change_server
|
||||||
import ru.fromchat.config.Settings
|
import ru.fromchat.config.Settings
|
||||||
import ru.fromchat.ui.LocalNavController
|
import ru.fromchat.ui.LocalNavController
|
||||||
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
import ru.fromchat.ui.auth.register.confirmPasswordStepPage
|
||||||
import ru.fromchat.ui.auth.register.profileStepPage
|
import ru.fromchat.ui.auth.register.profileStepPage
|
||||||
import ru.fromchat.ui.auth.yandex.yandexIdStepPage
|
|
||||||
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import ru.fromchat.ui.components.TextCta
|
import ru.fromchat.ui.components.TextCta
|
||||||
@@ -49,15 +49,16 @@ private enum class AuthFlowStep {
|
|||||||
Username,
|
Username,
|
||||||
Password,
|
Password,
|
||||||
ConfirmPassword,
|
ConfirmPassword,
|
||||||
YandexId,
|
IdentityVerify,
|
||||||
Profile,
|
Profile,
|
||||||
}
|
}
|
||||||
|
|
||||||
internal sealed interface PasswordStepResult {
|
internal sealed interface PasswordStepResult {
|
||||||
data object LoginSuccess : PasswordStepResult
|
data object LoginSuccess : PasswordStepResult
|
||||||
data class NeedsRegister(
|
data class NeedsRegister(
|
||||||
val yandexRequired: Boolean,
|
val verificationRequired: Boolean,
|
||||||
val yandex: YandexOAuthParams?,
|
val yandex: YandexOAuthParams?,
|
||||||
|
val vk: VkOAuthParams?,
|
||||||
) : PasswordStepResult
|
) : PasswordStepResult
|
||||||
data class WrongPassword(val message: String) : PasswordStepResult
|
data class WrongPassword(val message: String) : PasswordStepResult
|
||||||
data class RateLimited(val message: String) : PasswordStepResult
|
data class RateLimited(val message: String) : PasswordStepResult
|
||||||
@@ -121,8 +122,9 @@ internal suspend fun authPasswordStep(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
is ApiClient.AuthPasswordStepOutcome.NeedsRegister -> PasswordStepResult.NeedsRegister(
|
||||||
yandexRequired = outcome.yandexRequired,
|
verificationRequired = outcome.verificationRequired,
|
||||||
yandex = outcome.yandex,
|
yandex = outcome.yandex,
|
||||||
|
vk = outcome.vk,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
} catch (e: ClientRequestException) {
|
} catch (e: ClientRequestException) {
|
||||||
@@ -146,7 +148,8 @@ internal suspend fun register(
|
|||||||
displayName: String,
|
displayName: String,
|
||||||
password: String,
|
password: String,
|
||||||
bio: String,
|
bio: String,
|
||||||
registrationProof: String?,
|
yandexRegistrationProof: String?,
|
||||||
|
vkRegistrationProof: String?,
|
||||||
unexpectedError: String,
|
unexpectedError: String,
|
||||||
) = try {
|
) = try {
|
||||||
fullLogin(username.trim(), password.trim()) {
|
fullLogin(username.trim(), password.trim()) {
|
||||||
@@ -158,7 +161,8 @@ internal suspend fun register(
|
|||||||
password = derived,
|
password = derived,
|
||||||
confirm_password = derived,
|
confirm_password = derived,
|
||||||
bio = bio.trim().takeIf { it.isNotEmpty() },
|
bio = bio.trim().takeIf { it.isNotEmpty() },
|
||||||
registration_proof = registrationProof,
|
registration_proof = yandexRegistrationProof,
|
||||||
|
vk_registration_proof = vkRegistrationProof,
|
||||||
),
|
),
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
@@ -214,9 +218,11 @@ fun AuthScreen(
|
|||||||
var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
|
var confirmPassword by remember { mutableStateOf(AuthRegisterDraft.confirmPassword) }
|
||||||
var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
|
var displayName by remember { mutableStateOf(AuthRegisterDraft.displayName) }
|
||||||
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
var bio by remember { mutableStateOf(AuthRegisterDraft.bio) }
|
||||||
var yandexRequired by remember { mutableStateOf(AuthRegisterDraft.yandexRequired) }
|
var verificationRequired by remember { mutableStateOf(AuthRegisterDraft.verificationRequired) }
|
||||||
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
var yandexParams by remember { mutableStateOf(AuthRegisterDraft.yandexParams) }
|
||||||
var registrationProof by remember { mutableStateOf(AuthRegisterDraft.registrationProof) }
|
var vkParams by remember { mutableStateOf(AuthRegisterDraft.vkParams) }
|
||||||
|
var yandexRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.yandexRegistrationProof) }
|
||||||
|
var vkRegistrationProof by remember { mutableStateOf(AuthRegisterDraft.vkRegistrationProof) }
|
||||||
|
|
||||||
fun persistDraft() {
|
fun persistDraft() {
|
||||||
AuthRegisterDraft.username = username
|
AuthRegisterDraft.username = username
|
||||||
@@ -224,9 +230,11 @@ fun AuthScreen(
|
|||||||
AuthRegisterDraft.confirmPassword = confirmPassword
|
AuthRegisterDraft.confirmPassword = confirmPassword
|
||||||
AuthRegisterDraft.displayName = displayName
|
AuthRegisterDraft.displayName = displayName
|
||||||
AuthRegisterDraft.bio = bio
|
AuthRegisterDraft.bio = bio
|
||||||
AuthRegisterDraft.yandexRequired = yandexRequired
|
AuthRegisterDraft.verificationRequired = verificationRequired
|
||||||
AuthRegisterDraft.yandexParams = yandexParams
|
AuthRegisterDraft.yandexParams = yandexParams
|
||||||
AuthRegisterDraft.registrationProof = registrationProof
|
AuthRegisterDraft.vkParams = vkParams
|
||||||
|
AuthRegisterDraft.yandexRegistrationProof = yandexRegistrationProof
|
||||||
|
AuthRegisterDraft.vkRegistrationProof = vkRegistrationProof
|
||||||
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
AuthRegisterDraft.page = flowState.pagerState.currentPage
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -255,9 +263,11 @@ fun AuthScreen(
|
|||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
displayName = ""
|
displayName = ""
|
||||||
bio = ""
|
bio = ""
|
||||||
yandexRequired = false
|
verificationRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
registrationProof = null
|
vkParams = null
|
||||||
|
yandexRegistrationProof = null
|
||||||
|
vkRegistrationProof = null
|
||||||
AuthRegisterDraft.clear()
|
AuthRegisterDraft.clear()
|
||||||
flowState.resetPredictiveState()
|
flowState.resetPredictiveState()
|
||||||
scope.launch {
|
scope.launch {
|
||||||
@@ -265,11 +275,27 @@ fun AuthScreen(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fun clearProofs() {
|
||||||
|
yandexRegistrationProof = null
|
||||||
|
vkRegistrationProof = null
|
||||||
|
}
|
||||||
|
|
||||||
DisposableEffect(Unit) {
|
DisposableEffect(Unit) {
|
||||||
onDispose { persistDraft() }
|
onDispose { persistDraft() }
|
||||||
}
|
}
|
||||||
|
|
||||||
LaunchedEffect(username, password, confirmPassword, displayName, bio, yandexRequired, yandexParams, registrationProof) {
|
LaunchedEffect(
|
||||||
|
username,
|
||||||
|
password,
|
||||||
|
confirmPassword,
|
||||||
|
displayName,
|
||||||
|
bio,
|
||||||
|
verificationRequired,
|
||||||
|
yandexParams,
|
||||||
|
vkParams,
|
||||||
|
yandexRegistrationProof,
|
||||||
|
vkRegistrationProof,
|
||||||
|
) {
|
||||||
persistDraft()
|
persistDraft()
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -284,7 +310,7 @@ fun AuthScreen(
|
|||||||
snapshotFlow { flowState.pagerState.currentPage }
|
snapshotFlow { flowState.pagerState.currentPage }
|
||||||
.collect { page ->
|
.collect { page ->
|
||||||
AuthRegisterDraft.page = page
|
AuthRegisterDraft.page = page
|
||||||
if (page == AuthFlowStep.YandexId.ordinal && !yandexRequired) {
|
if (page == AuthFlowStep.IdentityVerify.ordinal && !verificationRequired) {
|
||||||
val target = if (page > settledPage) {
|
val target = if (page > settledPage) {
|
||||||
AuthFlowStep.Profile.ordinal
|
AuthFlowStep.Profile.ordinal
|
||||||
} else {
|
} else {
|
||||||
@@ -296,37 +322,27 @@ fun AuthScreen(
|
|||||||
}
|
}
|
||||||
if (page < settledPage) {
|
if (page < settledPage) {
|
||||||
when (page) {
|
when (page) {
|
||||||
AuthFlowStep.Username.ordinal -> {
|
AuthFlowStep.Username.ordinal,
|
||||||
|
AuthFlowStep.Password.ordinal,
|
||||||
|
-> {
|
||||||
password = ""
|
password = ""
|
||||||
confirmPassword = ""
|
confirmPassword = ""
|
||||||
yandexRequired = false
|
verificationRequired = false
|
||||||
yandexParams = null
|
yandexParams = null
|
||||||
registrationProof = null
|
vkParams = null
|
||||||
|
clearProofs()
|
||||||
}
|
}
|
||||||
|
|
||||||
AuthFlowStep.Password.ordinal -> {
|
AuthFlowStep.ConfirmPassword.ordinal,
|
||||||
password = ""
|
AuthFlowStep.IdentityVerify.ordinal,
|
||||||
confirmPassword = ""
|
-> clearProofs()
|
||||||
yandexRequired = false
|
|
||||||
yandexParams = null
|
|
||||||
registrationProof = null
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthFlowStep.ConfirmPassword.ordinal -> {
|
|
||||||
// Keep confirm password when returning from Yandex ID / OAuth.
|
|
||||||
registrationProof = null
|
|
||||||
}
|
|
||||||
|
|
||||||
AuthFlowStep.YandexId.ordinal -> {
|
|
||||||
registrationProof = null
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
settledPage = page
|
settledPage = page
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
val yandexStep = yandexParams
|
val showVerify = verificationRequired && (yandexParams != null || vkParams != null)
|
||||||
ExpressiveStepFlowScaffold(
|
ExpressiveStepFlowScaffold(
|
||||||
flowState = flowState,
|
flowState = flowState,
|
||||||
pages = listOf(
|
pages = listOf(
|
||||||
@@ -343,10 +359,11 @@ fun AuthScreen(
|
|||||||
password = password,
|
password = password,
|
||||||
onPasswordChange = { password = it },
|
onPasswordChange = { password = it },
|
||||||
onLoginSuccess = wrappedAuthSuccess,
|
onLoginSuccess = wrappedAuthSuccess,
|
||||||
onNeedsRegister = { required, params ->
|
onNeedsRegister = { required, yandex, vk ->
|
||||||
yandexRequired = required
|
verificationRequired = required
|
||||||
yandexParams = params
|
yandexParams = yandex
|
||||||
registrationProof = null
|
vkParams = vk
|
||||||
|
clearProofs()
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.ConfirmPassword.ordinal)
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -356,19 +373,29 @@ fun AuthScreen(
|
|||||||
onConfirmPasswordChange = { confirmPassword = it },
|
onConfirmPasswordChange = { confirmPassword = it },
|
||||||
password = password,
|
password = password,
|
||||||
onContinue = {
|
onContinue = {
|
||||||
if (yandexRequired && yandexParams != null) {
|
if (showVerify) {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.YandexId.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.IdentityVerify.ordinal)
|
||||||
} else {
|
} else {
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
),
|
),
|
||||||
if (yandexStep != null) {
|
if (showVerify) {
|
||||||
yandexIdStepPage(
|
identityVerifyStepPage(
|
||||||
yandex = yandexStep,
|
yandex = yandexParams,
|
||||||
onProof = { proof ->
|
vk = vkParams,
|
||||||
registrationProof = proof
|
onProof = { provider, proof ->
|
||||||
|
when (provider) {
|
||||||
|
IdentityProvider.Yandex -> {
|
||||||
|
yandexRegistrationProof = proof
|
||||||
|
vkRegistrationProof = null
|
||||||
|
}
|
||||||
|
IdentityProvider.Vk -> {
|
||||||
|
vkRegistrationProof = proof
|
||||||
|
yandexRegistrationProof = null
|
||||||
|
}
|
||||||
|
}
|
||||||
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
flowState.pagerState.animateScrollToPage(AuthFlowStep.Profile.ordinal)
|
||||||
},
|
},
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
@@ -391,7 +418,8 @@ fun AuthScreen(
|
|||||||
bio = bio,
|
bio = bio,
|
||||||
onBioChange = { bio = it },
|
onBioChange = { bio = it },
|
||||||
password = password,
|
password = password,
|
||||||
registrationProof = registrationProof,
|
yandexRegistrationProof = yandexRegistrationProof,
|
||||||
|
vkRegistrationProof = vkRegistrationProof,
|
||||||
onRegisterSuccess = wrappedAuthSuccess,
|
onRegisterSuccess = wrappedAuthSuccess,
|
||||||
onUsernameTaken = resetToUsername,
|
onUsernameTaken = resetToUsername,
|
||||||
onSnackbar = ::snackbar,
|
onSnackbar = ::snackbar,
|
||||||
|
|||||||
@@ -0,0 +1,217 @@
|
|||||||
|
package ru.fromchat.ui.auth
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.Arrangement
|
||||||
|
import androidx.compose.foundation.layout.Column
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.VerifiedUser
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||||
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
|
import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.vk.buildVkAuthorizeUrl
|
||||||
|
import ru.fromchat.auth.vk.generateOAuthState
|
||||||
|
import ru.fromchat.auth.vk.generateVkPkcePair
|
||||||
|
import ru.fromchat.auth.vk.resolveVkClientId
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
|
||||||
|
import ru.fromchat.auth.yandex.generatePkcePair
|
||||||
|
import ru.fromchat.auth.yandex.resolveYandexClientId
|
||||||
|
import ru.fromchat.auth_step_verify_body
|
||||||
|
import ru.fromchat.auth_step_verify_title
|
||||||
|
import ru.fromchat.auth_step_vk_cta
|
||||||
|
import ru.fromchat.auth_step_yandex_cta
|
||||||
|
import ru.fromchat.auth_vk_client_mismatch
|
||||||
|
import ru.fromchat.auth_yandex_client_mismatch
|
||||||
|
import ru.fromchat.config.Settings
|
||||||
|
import ru.fromchat.error_unexpected
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.auth.vk.VkOAuthNav
|
||||||
|
import ru.fromchat.ui.auth.yandex.YandexOAuthNav
|
||||||
|
import ru.fromchat.ui.components.ActionButton
|
||||||
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
|
||||||
|
enum class IdentityProvider {
|
||||||
|
Yandex,
|
||||||
|
Vk,
|
||||||
|
}
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
internal fun identityVerifyStepPage(
|
||||||
|
yandex: YandexOAuthParams?,
|
||||||
|
vk: VkOAuthParams?,
|
||||||
|
onProof: suspend (provider: IdentityProvider, proof: String) -> Unit,
|
||||||
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
|
): ExpressiveStepPage {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
val languageTag = Locale.current.toLanguageTag()
|
||||||
|
val darkTheme = isAppInDarkTheme()
|
||||||
|
val onProofState = rememberUpdatedState(onProof)
|
||||||
|
val onSnackbarState = rememberUpdatedState(onSnackbar)
|
||||||
|
|
||||||
|
val title = stringResource(Res.string.auth_step_verify_title)
|
||||||
|
val body = stringResource(Res.string.auth_step_verify_body)
|
||||||
|
val yandexCta = stringResource(Res.string.auth_step_yandex_cta)
|
||||||
|
val vkCta = stringResource(Res.string.auth_step_vk_cta)
|
||||||
|
val yandexMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
|
||||||
|
val vkMismatch = stringResource(Res.string.auth_vk_client_mismatch)
|
||||||
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
|
||||||
|
if (proof == null) return@collect
|
||||||
|
handle.remove<String>(YandexOAuthNav.RESULT_PROOF)
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
onProofState.value(IdentityProvider.Yandex, proof)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
|
||||||
|
if (message == null) return@collect
|
||||||
|
handle.remove<String>(YandexOAuthNav.RESULT_ERROR)
|
||||||
|
onSnackbarState.value(message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(VkOAuthNav.RESULT_PROOF, null).collect { proof ->
|
||||||
|
if (proof == null) return@collect
|
||||||
|
handle.remove<String>(VkOAuthNav.RESULT_PROOF)
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
onProofState.value(IdentityProvider.Vk, proof)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
LaunchedEffect(navController) {
|
||||||
|
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
||||||
|
handle.getStateFlow<String?>(VkOAuthNav.RESULT_ERROR, null).collect { message ->
|
||||||
|
if (message == null) return@collect
|
||||||
|
handle.remove<String>(VkOAuthNav.RESULT_ERROR)
|
||||||
|
onSnackbarState.value(message, null)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
return ExpressiveStepPage(
|
||||||
|
hero = ExpressiveHeroSpec(
|
||||||
|
icon = Icons.Filled.VerifiedUser,
|
||||||
|
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
||||||
|
containerColor = colorScheme.primaryContainer,
|
||||||
|
contentColor = colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
content = {
|
||||||
|
ExpressiveStepPageHeader(title = title, body = body)
|
||||||
|
},
|
||||||
|
button = {
|
||||||
|
Column(
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||||
|
) {
|
||||||
|
if (yandex != null) {
|
||||||
|
ActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (busy) return@ActionButton
|
||||||
|
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
|
||||||
|
onSnackbar(unexpected, it)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
val clientId = resolveYandexClientId(yandex.client_id, serverIp)
|
||||||
|
if (clientId == null) {
|
||||||
|
onSnackbar(yandexMismatch, null)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
val pkce = generatePkcePair()
|
||||||
|
YandexOAuthNav.pending = YandexOAuthNav.Session(
|
||||||
|
authorizeUrl = buildYandexAuthorizeUrl(
|
||||||
|
authorizeUrl = yandex.authorize_url,
|
||||||
|
clientId = clientId,
|
||||||
|
redirectUri = yandex.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
|
||||||
|
scope = yandex.scope,
|
||||||
|
codeChallenge = pkce.codeChallenge,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
),
|
||||||
|
codeVerifier = pkce.codeVerifier,
|
||||||
|
)
|
||||||
|
navController.navigate(YandexOAuthNav.ROUTE)
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
loading = busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(yandexCta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
if (vk != null) {
|
||||||
|
ActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (busy) return@ActionButton
|
||||||
|
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
|
||||||
|
onSnackbar(unexpected, it)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
val clientId = resolveVkClientId(vk.client_id, serverIp)
|
||||||
|
if (clientId == null) {
|
||||||
|
onSnackbar(vkMismatch, null)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
val pkce = generateVkPkcePair()
|
||||||
|
val state = generateOAuthState()
|
||||||
|
val redirectUri = vk.redirect_uri.ifBlank { VK_OAUTH_REDIRECT_URI }
|
||||||
|
VkOAuthNav.pending = VkOAuthNav.Session(
|
||||||
|
authorizeUrl = buildVkAuthorizeUrl(
|
||||||
|
authorizeUrl = vk.authorize_url,
|
||||||
|
clientId = clientId,
|
||||||
|
redirectUri = redirectUri,
|
||||||
|
scope = vk.scope,
|
||||||
|
codeChallenge = pkce.codeChallenge,
|
||||||
|
state = state,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
),
|
||||||
|
codeVerifier = pkce.codeVerifier,
|
||||||
|
state = state,
|
||||||
|
redirectUri = redirectUri,
|
||||||
|
)
|
||||||
|
navController.navigate(VkOAuthNav.ROUTE)
|
||||||
|
},
|
||||||
|
enabled = !busy,
|
||||||
|
loading = busy,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(vkCta)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -35,6 +35,7 @@ import ru.fromchat.login
|
|||||||
import ru.fromchat.password
|
import ru.fromchat.password
|
||||||
import ru.fromchat.password_length_error
|
import ru.fromchat.password_length_error
|
||||||
import ru.fromchat.show_password
|
import ru.fromchat.show_password
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
||||||
import ru.fromchat.ui.components.ActionButton
|
import ru.fromchat.ui.components.ActionButton
|
||||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
@@ -54,7 +55,11 @@ internal fun passwordStepPage(
|
|||||||
password: String,
|
password: String,
|
||||||
onPasswordChange: (String) -> Unit,
|
onPasswordChange: (String) -> Unit,
|
||||||
onLoginSuccess: () -> Unit,
|
onLoginSuccess: () -> Unit,
|
||||||
onNeedsRegister: suspend (yandexRequired: Boolean, yandex: YandexOAuthParams?) -> Unit,
|
onNeedsRegister: suspend (
|
||||||
|
verificationRequired: Boolean,
|
||||||
|
yandex: YandexOAuthParams?,
|
||||||
|
vk: VkOAuthParams?,
|
||||||
|
) -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
): ExpressiveStepPage {
|
): ExpressiveStepPage {
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -135,7 +140,11 @@ internal fun passwordStepPage(
|
|||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.NeedsRegister -> {
|
is PasswordStepResult.NeedsRegister -> {
|
||||||
onNeedsRegister(result.yandexRequired, result.yandex)
|
onNeedsRegister(
|
||||||
|
result.verificationRequired,
|
||||||
|
result.yandex,
|
||||||
|
result.vk,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
is PasswordStepResult.WrongPassword -> {
|
is PasswordStepResult.WrongPassword -> {
|
||||||
|
|||||||
@@ -42,8 +42,13 @@ import ru.fromchat.ui.components.expressiveStepFieldColors
|
|||||||
import ru.fromchat.ui.components.trackImeScrollTarget
|
import ru.fromchat.ui.components.trackImeScrollTarget
|
||||||
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
import ru.fromchat.ui.main.settings.SettingsStepHorizontalPadding
|
||||||
import ru.fromchat.username
|
import ru.fromchat.username
|
||||||
|
import ru.fromchat.username_chars_error
|
||||||
import ru.fromchat.username_length_error
|
import ru.fromchat.username_length_error
|
||||||
|
|
||||||
|
/** Matches backend `is_valid_username`: English letters, digits, hyphen, underscore. */
|
||||||
|
private fun isAllowedUsernameChar(ch: Char) =
|
||||||
|
ch in 'a'..'z' || ch in 'A'..'Z' || ch in '0'..'9' || ch == '_' || ch == '-'
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
@Composable
|
@Composable
|
||||||
internal fun usernameStepPage(
|
internal fun usernameStepPage(
|
||||||
@@ -59,9 +64,11 @@ internal fun usernameStepPage(
|
|||||||
|
|
||||||
val fillAll = stringResource(Res.string.fill_all_fields)
|
val fillAll = stringResource(Res.string.fill_all_fields)
|
||||||
val usernameLenError = stringResource(Res.string.username_length_error)
|
val usernameLenError = stringResource(Res.string.username_length_error)
|
||||||
|
val usernameCharsError = stringResource(Res.string.username_chars_error)
|
||||||
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
val serverFail = stringResource(Res.string.auth_server_connect_failed)
|
||||||
val unexpected = stringResource(Res.string.error_unexpected)
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
val nextLabel = stringResource(Res.string.settings_next)
|
val nextLabel = stringResource(Res.string.settings_next)
|
||||||
|
val hasProhibitedChars = username.trim().any { !isAllowedUsernameChar(it) }
|
||||||
|
|
||||||
return ExpressiveStepPage(
|
return ExpressiveStepPage(
|
||||||
hero = ExpressiveHeroSpec(
|
hero = ExpressiveHeroSpec(
|
||||||
@@ -90,6 +97,12 @@ internal fun usernameStepPage(
|
|||||||
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
.trackImeScrollTarget(imeScroll, ExpressiveStepLazyListIndices.STEPS_BODY)
|
||||||
.padding(horizontal = SettingsStepHorizontalPadding),
|
.padding(horizontal = SettingsStepHorizontalPadding),
|
||||||
singleLine = true,
|
singleLine = true,
|
||||||
|
isError = hasProhibitedChars,
|
||||||
|
supportingText = if (hasProhibitedChars) {
|
||||||
|
{ Text(usernameCharsError) }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
},
|
||||||
colors = expressiveStepFieldColors(),
|
colors = expressiveStepFieldColors(),
|
||||||
shape = SettingsPasswordOutlineFieldShape,
|
shape = SettingsPasswordOutlineFieldShape,
|
||||||
)
|
)
|
||||||
@@ -98,7 +111,7 @@ internal fun usernameStepPage(
|
|||||||
button = {
|
button = {
|
||||||
ActionButton(
|
ActionButton(
|
||||||
onClick = {
|
onClick = {
|
||||||
if (busy) return@ActionButton
|
if (busy || hasProhibitedChars) return@ActionButton
|
||||||
val trimmed = username.trim()
|
val trimmed = username.trim()
|
||||||
if (trimmed.isBlank()) {
|
if (trimmed.isBlank()) {
|
||||||
onSnackbar(fillAll, null)
|
onSnackbar(fillAll, null)
|
||||||
@@ -136,7 +149,7 @@ internal fun usernameStepPage(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
enabled = !busy,
|
enabled = !busy && !hasProhibitedChars,
|
||||||
loading = busy,
|
loading = busy,
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,28 @@
|
|||||||
|
package ru.fromchat.ui.auth.oauth
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Platform WebView for identity OAuth (Yandex, VK ID, …).
|
||||||
|
* Intercepts [redirectUriPrefix] and reports the full redirect URL via [onRedirectUrl].
|
||||||
|
*
|
||||||
|
* @param isAuthNavigation Keep matching hosts in-WebView; open everything else externally.
|
||||||
|
* @param themeCookieHosts Optional hosts that receive light/dark theme cookies before load.
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
expect fun OAuthWebView(
|
||||||
|
authorizeUrl: String,
|
||||||
|
languageTag: String,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
fallbackColor: Color,
|
||||||
|
redirectUriPrefix: String,
|
||||||
|
isAuthNavigation: (url: String) -> Boolean,
|
||||||
|
clearCookies: Boolean = false,
|
||||||
|
themeCookieHosts: List<String> = emptyList(),
|
||||||
|
onPageBackgroundColor: (Color) -> Unit = {},
|
||||||
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
|
||||||
|
onRedirectUrl: (String) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
)
|
||||||
@@ -55,7 +55,8 @@ internal fun profileStepPage(
|
|||||||
bio: String,
|
bio: String,
|
||||||
onBioChange: (String) -> Unit,
|
onBioChange: (String) -> Unit,
|
||||||
password: String,
|
password: String,
|
||||||
registrationProof: String?,
|
yandexRegistrationProof: String?,
|
||||||
|
vkRegistrationProof: String?,
|
||||||
onRegisterSuccess: () -> Unit,
|
onRegisterSuccess: () -> Unit,
|
||||||
onUsernameTaken: () -> Unit,
|
onUsernameTaken: () -> Unit,
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
onSnackbar: (String, Throwable?) -> Unit,
|
||||||
@@ -148,7 +149,8 @@ internal fun profileStepPage(
|
|||||||
displayName = displayName.trim(),
|
displayName = displayName.trim(),
|
||||||
password = password,
|
password = password,
|
||||||
bio = bio.trim(),
|
bio = bio.trim(),
|
||||||
registrationProof = registrationProof,
|
yandexRegistrationProof = yandexRegistrationProof,
|
||||||
|
vkRegistrationProof = vkRegistrationProof,
|
||||||
unexpectedError = unexpected,
|
unexpectedError = unexpected,
|
||||||
)
|
)
|
||||||
) {
|
) {
|
||||||
|
|||||||
@@ -0,0 +1,41 @@
|
|||||||
|
package ru.fromchat.ui.auth.vk
|
||||||
|
|
||||||
|
import androidx.compose.runtime.saveable.listSaver
|
||||||
|
import kotlin.concurrent.Volatile
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Root [androidx.navigation.NavController] route for the VK OAuth WebView.
|
||||||
|
* Session is staged in [pending] before [navigate]; PKCE verifier and state must not go in the route.
|
||||||
|
* Prefer [SessionSaver] / rememberSaveable on the OAuth screen so pause/recreate keeps PKCE.
|
||||||
|
*/
|
||||||
|
internal object VkOAuthNav {
|
||||||
|
const val ROUTE = "vkOAuth"
|
||||||
|
const val RESULT_PROOF = "vk_registration_proof"
|
||||||
|
const val RESULT_ERROR = "vk_oauth_error"
|
||||||
|
|
||||||
|
data class Session(
|
||||||
|
val authorizeUrl: String,
|
||||||
|
val codeVerifier: String,
|
||||||
|
val state: String,
|
||||||
|
val redirectUri: String,
|
||||||
|
)
|
||||||
|
|
||||||
|
val SessionSaver = listSaver<Session?, String>(
|
||||||
|
save = { session ->
|
||||||
|
if (session == null) emptyList()
|
||||||
|
else listOf(session.authorizeUrl, session.codeVerifier, session.state, session.redirectUri)
|
||||||
|
},
|
||||||
|
restore = { saved ->
|
||||||
|
if (saved.size < 4) null
|
||||||
|
else Session(
|
||||||
|
authorizeUrl = saved[0],
|
||||||
|
codeVerifier = saved[1],
|
||||||
|
state = saved[2],
|
||||||
|
redirectUri = saved[3],
|
||||||
|
)
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var pending: Session? = null
|
||||||
|
}
|
||||||
@@ -0,0 +1,180 @@
|
|||||||
|
package ru.fromchat.ui.auth.vk
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.DisposableEffect
|
||||||
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
|
import androidx.compose.runtime.getValue
|
||||||
|
import androidx.compose.runtime.mutableStateOf
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.runtime.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.saveable.rememberSaveable
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import io.ktor.client.call.body
|
||||||
|
import io.ktor.client.plugins.ClientRequestException
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Logger
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.core.ErrorResponse
|
||||||
|
import ru.fromchat.auth_vk_failed
|
||||||
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.ui.auth.vk.VkOAuthWebView
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
|
||||||
|
private const val LOG_TAG = "VkOAuthScreen"
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
internal fun VkOAuthScreen() {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
var session by rememberSaveable(stateSaver = VkOAuthNav.SessionSaver) {
|
||||||
|
mutableStateOf(VkOAuthNav.pending)
|
||||||
|
}
|
||||||
|
val fallbackColor = MaterialTheme.colorScheme.background
|
||||||
|
var chromeColor by remember { mutableStateOf(fallbackColor) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var webViewCanGoBack by remember { mutableStateOf(false) }
|
||||||
|
val failedMessage = stringResource(Res.string.auth_vk_failed)
|
||||||
|
val backLabel = stringResource(Res.string.back)
|
||||||
|
val darkTheme = isAppInDarkTheme()
|
||||||
|
val screenId = remember { (100000..999999).random().toString(16) }
|
||||||
|
|
||||||
|
DisposableEffect(screenId) {
|
||||||
|
Logger.i(
|
||||||
|
LOG_TAG,
|
||||||
|
"compose enter id=$screenId sessionNull=${session == null} " +
|
||||||
|
"pendingNull=${VkOAuthNav.pending == null} darkTheme=$darkTheme " +
|
||||||
|
"route=${navController.currentBackStackEntry?.destination?.route}",
|
||||||
|
)
|
||||||
|
onDispose {
|
||||||
|
Logger.i(LOG_TAG, "compose dispose id=$screenId")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(session) {
|
||||||
|
if (session == null) {
|
||||||
|
Logger.w(LOG_TAG, "session null → popBackStack id=$screenId")
|
||||||
|
navController.popBackStack()
|
||||||
|
} else {
|
||||||
|
VkOAuthNav.pending = session
|
||||||
|
Logger.d(LOG_TAG, "session kept id=$screenId urlLen=${session!!.authorizeUrl.length}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val active = session ?: return
|
||||||
|
|
||||||
|
fun finishWithProof(proof: String) {
|
||||||
|
Logger.i(LOG_TAG, "finishWithProof id=$screenId")
|
||||||
|
VkOAuthNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(VkOAuthNav.RESULT_PROOF, proof)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun finishWithError(message: String) {
|
||||||
|
Logger.w(LOG_TAG, "finishWithError id=$screenId message=$message")
|
||||||
|
VkOAuthNav.pending = null
|
||||||
|
navController.previousBackStackEntry
|
||||||
|
?.savedStateHandle
|
||||||
|
?.set(VkOAuthNav.RESULT_ERROR, message)
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
if (busy) return
|
||||||
|
Logger.i(LOG_TAG, "cancel id=$screenId")
|
||||||
|
VkOAuthNav.pending = null
|
||||||
|
navController.popBackStack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(chromeColor),
|
||||||
|
) {
|
||||||
|
VkOAuthWebView(
|
||||||
|
authorizeUrl = active.authorizeUrl,
|
||||||
|
redirectUri = active.redirectUri,
|
||||||
|
languageTag = Locale.current.toLanguageTag(),
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
fallbackColor = fallbackColor,
|
||||||
|
onPageBackgroundColor = { chromeColor = it },
|
||||||
|
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
|
||||||
|
onRedirect = { redirect ->
|
||||||
|
if (busy) return@VkOAuthWebView
|
||||||
|
if (redirect.state != active.state) {
|
||||||
|
finishWithError(failedMessage)
|
||||||
|
return@VkOAuthWebView
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
val proof = ApiClient.authVkExchange(
|
||||||
|
code = redirect.code,
|
||||||
|
codeVerifier = active.codeVerifier,
|
||||||
|
deviceId = redirect.deviceId,
|
||||||
|
state = redirect.state,
|
||||||
|
).registration_proof
|
||||||
|
finishWithProof(proof)
|
||||||
|
} catch (e: ClientRequestException) {
|
||||||
|
val detail = if (e.response.status.value == 400) {
|
||||||
|
runCatching { e.response.body<ErrorResponse>().detail }
|
||||||
|
.getOrNull()
|
||||||
|
?.ifBlank { null }
|
||||||
|
} else {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
finishWithError(detail ?: failedMessage)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
finishWithError(failedMessage)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { finishWithError(it.ifBlank { failedMessage }) },
|
||||||
|
onCancel = { cancel() },
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !webViewCanGoBack && !busy,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(start = 12.dp, top = 12.dp),
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { cancel() },
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,50 @@
|
|||||||
|
package ru.fromchat.ui.auth.vk
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.vk.VK_OAUTH_THEME_COOKIE_HOSTS
|
||||||
|
import ru.fromchat.auth.vk.VkOAuthRedirect
|
||||||
|
import ru.fromchat.auth.vk.extractVkOAuthRedirect
|
||||||
|
import ru.fromchat.auth.vk.isVkAuthNavigation
|
||||||
|
import ru.fromchat.ui.auth.oauth.OAuthWebView
|
||||||
|
|
||||||
|
/**
|
||||||
|
* VK-specific wrapper around [OAuthWebView].
|
||||||
|
*
|
||||||
|
* @param redirectUri Trusted HTTPS redirect from the server (must match VK ID cabinet).
|
||||||
|
*/
|
||||||
|
@Composable
|
||||||
|
internal fun VkOAuthWebView(
|
||||||
|
authorizeUrl: String,
|
||||||
|
redirectUri: String,
|
||||||
|
languageTag: String,
|
||||||
|
darkTheme: Boolean,
|
||||||
|
fallbackColor: Color,
|
||||||
|
clearCookies: Boolean = false,
|
||||||
|
onPageBackgroundColor: (Color) -> Unit = {},
|
||||||
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit = {},
|
||||||
|
onRedirect: (VkOAuthRedirect) -> Unit,
|
||||||
|
onError: (String) -> Unit,
|
||||||
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
val resolvedRedirect = redirectUri.trim().ifBlank { VK_OAUTH_REDIRECT_URI }
|
||||||
|
OAuthWebView(
|
||||||
|
authorizeUrl = authorizeUrl,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
fallbackColor = fallbackColor,
|
||||||
|
redirectUriPrefix = resolvedRedirect,
|
||||||
|
isAuthNavigation = ::isVkAuthNavigation,
|
||||||
|
clearCookies = clearCookies,
|
||||||
|
themeCookieHosts = VK_OAUTH_THEME_COOKIE_HOSTS,
|
||||||
|
onPageBackgroundColor = onPageBackgroundColor,
|
||||||
|
onHistoryBackAvailabilityChanged = onHistoryBackAvailabilityChanged,
|
||||||
|
onRedirectUrl = { url ->
|
||||||
|
val redirect = extractVkOAuthRedirect(url, resolvedRedirect)
|
||||||
|
if (redirect != null) onRedirect(redirect) else onError("")
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
onCancel = onCancel,
|
||||||
|
)
|
||||||
|
}
|
||||||
@@ -1,130 +0,0 @@
|
|||||||
package ru.fromchat.ui.auth.yandex
|
|
||||||
|
|
||||||
import androidx.compose.foundation.layout.fillMaxWidth
|
|
||||||
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
|
||||||
import androidx.compose.material3.MaterialShapes
|
|
||||||
import androidx.compose.material3.MaterialTheme
|
|
||||||
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.Modifier
|
|
||||||
import androidx.compose.ui.text.intl.Locale
|
|
||||||
import org.jetbrains.compose.resources.stringResource
|
|
||||||
import org.jetbrains.compose.resources.vectorResource
|
|
||||||
import ru.fromchat.Res
|
|
||||||
import ru.fromchat.api.schema.user.auth.YandexOAuthParams
|
|
||||||
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
|
||||||
import ru.fromchat.auth.yandex.buildYandexAuthorizeUrl
|
|
||||||
import ru.fromchat.auth.yandex.generatePkcePair
|
|
||||||
import ru.fromchat.auth.yandex.resolveYandexClientId
|
|
||||||
import ru.fromchat.auth_step_yandex_body
|
|
||||||
import ru.fromchat.auth_step_yandex_cta
|
|
||||||
import ru.fromchat.auth_step_yandex_title
|
|
||||||
import ru.fromchat.auth_yandex_client_mismatch
|
|
||||||
import ru.fromchat.config.Settings
|
|
||||||
import ru.fromchat.error_unexpected
|
|
||||||
import ru.fromchat.ic_yandex
|
|
||||||
import ru.fromchat.ui.LocalNavController
|
|
||||||
import ru.fromchat.ui.components.ActionButton
|
|
||||||
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
|
||||||
import ru.fromchat.ui.components.ExpressiveStepPage
|
|
||||||
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
|
||||||
import ru.fromchat.ui.components.Text
|
|
||||||
import ru.fromchat.ui.isAppInDarkTheme
|
|
||||||
|
|
||||||
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
|
||||||
@Composable
|
|
||||||
internal fun yandexIdStepPage(
|
|
||||||
yandex: YandexOAuthParams,
|
|
||||||
onProof: suspend (String) -> Unit,
|
|
||||||
onSnackbar: (String, Throwable?) -> Unit,
|
|
||||||
): ExpressiveStepPage {
|
|
||||||
val navController = LocalNavController.current
|
|
||||||
val colorScheme = MaterialTheme.colorScheme
|
|
||||||
var busy by remember { mutableStateOf(false) }
|
|
||||||
val languageTag = Locale.current.toLanguageTag()
|
|
||||||
val darkTheme = isAppInDarkTheme()
|
|
||||||
val onProofState = rememberUpdatedState(onProof)
|
|
||||||
val onSnackbarState = rememberUpdatedState(onSnackbar)
|
|
||||||
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
|
||||||
|
|
||||||
val title = stringResource(Res.string.auth_step_yandex_title)
|
|
||||||
val body = stringResource(Res.string.auth_step_yandex_body)
|
|
||||||
val cta = stringResource(Res.string.auth_step_yandex_cta)
|
|
||||||
val clientMismatch = stringResource(Res.string.auth_yandex_client_mismatch)
|
|
||||||
val unexpected = stringResource(Res.string.error_unexpected)
|
|
||||||
|
|
||||||
LaunchedEffect(navController) {
|
|
||||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
|
||||||
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_PROOF, null).collect { proof ->
|
|
||||||
if (proof == null) return@collect
|
|
||||||
handle.remove<String>(YandexOAuthNav.RESULT_PROOF)
|
|
||||||
busy = true
|
|
||||||
try {
|
|
||||||
onProofState.value(proof)
|
|
||||||
} finally {
|
|
||||||
busy = false
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
LaunchedEffect(navController) {
|
|
||||||
val handle = navController.currentBackStackEntry?.savedStateHandle ?: return@LaunchedEffect
|
|
||||||
handle.getStateFlow<String?>(YandexOAuthNav.RESULT_ERROR, null).collect { message ->
|
|
||||||
if (message == null) return@collect
|
|
||||||
handle.remove<String>(YandexOAuthNav.RESULT_ERROR)
|
|
||||||
onSnackbarState.value(message, null)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
return ExpressiveStepPage(
|
|
||||||
hero = ExpressiveHeroSpec(
|
|
||||||
icon = yandexIcon,
|
|
||||||
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
|
||||||
containerColor = colorScheme.primaryContainer,
|
|
||||||
contentColor = colorScheme.onPrimaryContainer,
|
|
||||||
),
|
|
||||||
content = {
|
|
||||||
ExpressiveStepPageHeader(title = title, body = body)
|
|
||||||
},
|
|
||||||
button = {
|
|
||||||
ActionButton(
|
|
||||||
onClick = {
|
|
||||||
if (busy) return@ActionButton
|
|
||||||
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
|
|
||||||
onSnackbar(unexpected, it)
|
|
||||||
return@ActionButton
|
|
||||||
}
|
|
||||||
val clientId = resolveYandexClientId(yandex.client_id, serverIp)
|
|
||||||
if (clientId == null) {
|
|
||||||
onSnackbar(clientMismatch, null)
|
|
||||||
return@ActionButton
|
|
||||||
}
|
|
||||||
val pkce = generatePkcePair()
|
|
||||||
YandexOAuthNav.pending = YandexOAuthNav.Session(
|
|
||||||
authorizeUrl = buildYandexAuthorizeUrl(
|
|
||||||
authorizeUrl = yandex.authorize_url,
|
|
||||||
clientId = clientId,
|
|
||||||
redirectUri = yandex.redirect_uri.ifBlank { YANDEX_OAUTH_REDIRECT_URI },
|
|
||||||
scope = yandex.scope,
|
|
||||||
codeChallenge = pkce.codeChallenge,
|
|
||||||
languageTag = languageTag,
|
|
||||||
darkTheme = darkTheme,
|
|
||||||
),
|
|
||||||
codeVerifier = pkce.codeVerifier,
|
|
||||||
)
|
|
||||||
navController.navigate(YandexOAuthNav.ROUTE)
|
|
||||||
},
|
|
||||||
enabled = !busy,
|
|
||||||
loading = busy,
|
|
||||||
modifier = Modifier.fillMaxWidth(),
|
|
||||||
) {
|
|
||||||
Text(cta)
|
|
||||||
}
|
|
||||||
},
|
|
||||||
)
|
|
||||||
}
|
|
||||||
@@ -2,19 +2,17 @@ package ru.fromchat.ui.auth.yandex
|
|||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.yandex.YANDEX_OAUTH_THEME_COOKIE_HOSTS
|
||||||
|
import ru.fromchat.auth.yandex.extractOAuthCode
|
||||||
|
import ru.fromchat.auth.yandex.isYandexAuthNavigation
|
||||||
|
import ru.fromchat.ui.auth.oauth.OAuthWebView
|
||||||
|
|
||||||
/**
|
/**
|
||||||
* Platform WebView that loads [authorizeUrl] and reports the OAuth redirect.
|
* Yandex-specific wrapper around [OAuthWebView].
|
||||||
* Intercepts `fromchat://oauth/yandex` and returns the authorization code.
|
|
||||||
*
|
|
||||||
* @param languageTag BCP-47 tag for Accept-Language / WebView locale.
|
|
||||||
* @param darkTheme Pins WebView `prefers-color-scheme` via configuration night mode.
|
|
||||||
* @param fallbackColor Shown until the page background can be sampled.
|
|
||||||
* @param onPageBackgroundColor Reported whenever a non-transparent page background is detected.
|
|
||||||
* @param onHistoryBackAvailabilityChanged `true` when the WebView can go back in its own history.
|
|
||||||
*/
|
*/
|
||||||
@Composable
|
@Composable
|
||||||
expect fun YandexOAuthWebView(
|
internal fun YandexOAuthWebView(
|
||||||
authorizeUrl: String,
|
authorizeUrl: String,
|
||||||
languageTag: String,
|
languageTag: String,
|
||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
@@ -25,4 +23,23 @@ expect fun YandexOAuthWebView(
|
|||||||
onCode: (String) -> Unit,
|
onCode: (String) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
|
) {
|
||||||
|
OAuthWebView(
|
||||||
|
authorizeUrl = authorizeUrl,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
fallbackColor = fallbackColor,
|
||||||
|
redirectUriPrefix = YANDEX_OAUTH_REDIRECT_URI,
|
||||||
|
isAuthNavigation = ::isYandexAuthNavigation,
|
||||||
|
clearCookies = clearCookies,
|
||||||
|
themeCookieHosts = YANDEX_OAUTH_THEME_COOKIE_HOSTS,
|
||||||
|
onPageBackgroundColor = onPageBackgroundColor,
|
||||||
|
onHistoryBackAvailabilityChanged = onHistoryBackAvailabilityChanged,
|
||||||
|
onRedirectUrl = { url ->
|
||||||
|
val code = extractOAuthCode(url)
|
||||||
|
if (code != null) onCode(code) else onError("")
|
||||||
|
},
|
||||||
|
onError = onError,
|
||||||
|
onCancel = onCancel,
|
||||||
)
|
)
|
||||||
|
}
|
||||||
|
|||||||
@@ -1189,7 +1189,7 @@ fun ChatScreen(
|
|||||||
.getOrNull(panelState.messages.lastIndex - 1)
|
.getOrNull(panelState.messages.lastIndex - 1)
|
||||||
previous != null &&
|
previous != null &&
|
||||||
messageListKey(previous) == listKey &&
|
messageListKey(previous) == listKey &&
|
||||||
classifyEnterMode(previous, newest!!) ==
|
classifyEnterMode(previous, newest) ==
|
||||||
EnterMode.ExtendGroup
|
EnterMode.ExtendGroup
|
||||||
}
|
}
|
||||||
val showTimestamp = when {
|
val showTimestamp = when {
|
||||||
|
|||||||
+4
-4
@@ -136,7 +136,7 @@ fun ChatFileAttachmentTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
downloadPaused && canDownload -> file?.let { downloadFile ->
|
downloadPaused && file != null && canDownload -> {
|
||||||
{
|
{
|
||||||
AttachmentDownloadNotifier.beginDownload(
|
AttachmentDownloadNotifier.beginDownload(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
@@ -148,7 +148,7 @@ fun ChatFileAttachmentTile(
|
|||||||
val ok = downloadAttachmentToCache(
|
val ok = downloadAttachmentToCache(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
file = downloadFile,
|
file = file,
|
||||||
dmEnvelope = dmEnvelope,
|
dmEnvelope = dmEnvelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
@@ -166,7 +166,7 @@ fun ChatFileAttachmentTile(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
canDownload && !isDownloading && !downloadPaused -> file?.let { downloadFile ->
|
file != null && canDownload && !isDownloading && !downloadPaused -> {
|
||||||
{
|
{
|
||||||
AttachmentDownloadNotifier.beginDownload(
|
AttachmentDownloadNotifier.beginDownload(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
@@ -178,7 +178,7 @@ fun ChatFileAttachmentTile(
|
|||||||
val ok = downloadAttachmentToCache(
|
val ok = downloadAttachmentToCache(
|
||||||
messageId = messageId,
|
messageId = messageId,
|
||||||
fileIndex = fileIndex,
|
fileIndex = fileIndex,
|
||||||
file = downloadFile,
|
file = file,
|
||||||
dmEnvelope = dmEnvelope,
|
dmEnvelope = dmEnvelope,
|
||||||
currentUserId = currentUserId,
|
currentUserId = currentUserId,
|
||||||
clientMessageId = clientMessageId,
|
clientMessageId = clientMessageId,
|
||||||
|
|||||||
+3
@@ -84,6 +84,7 @@ class PublicChatPanel(
|
|||||||
ProfileCache.enrichPublicMessageForDisplay(
|
ProfileCache.enrichPublicMessageForDisplay(
|
||||||
mergeMessageUiFields(fresh, message).copy(
|
mergeMessageUiFields(fresh, message).copy(
|
||||||
username = fresh.username,
|
username = fresh.username,
|
||||||
|
displayName = fresh.displayName,
|
||||||
profile_picture = fresh.profile_picture,
|
profile_picture = fresh.profile_picture,
|
||||||
verified = fresh.verified,
|
verified = fresh.verified,
|
||||||
verificationStatus = fresh.verificationStatus,
|
verificationStatus = fresh.verificationStatus,
|
||||||
@@ -179,6 +180,8 @@ class PublicChatPanel(
|
|||||||
}
|
}
|
||||||
|
|
||||||
suspend fun hydrateFromLocalCache() {
|
suspend fun hydrateFromLocalCache() {
|
||||||
|
// Sender display names live in ProfileCache (message rows only store userId).
|
||||||
|
runCatching { ProfileCache.hydrateFromDisk() }
|
||||||
hydrateMessagesFromLocalCache()
|
hydrateMessagesFromLocalCache()
|
||||||
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
runCatching { PublicChatProfileCache.hydrateFromDisk() }
|
||||||
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
|
PublicChatProfileCache.profile?.let { applyPublicChatProfile(it) }
|
||||||
|
|||||||
@@ -129,7 +129,17 @@ internal fun mergeMessageUiFields(db: Message, panel: Message?): Message {
|
|||||||
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
|
?: db.pendingFileAspectRatio?.takeIf { it > 0f }
|
||||||
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
?: panel.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
||||||
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
?: db.fileDimensions?.firstOrNull()?.let { (w, h) -> aspectRatioFromDimensionPair(w, h) }
|
||||||
|
// DB rows only store userId; sender identity is reconstructed from ProfileCache and can
|
||||||
|
// briefly be blank. Keep non-blank panel fields (e.g. from a network payload) so text
|
||||||
|
// avatars / names are not wiped on every SQLDelight emission.
|
||||||
val merged = db.copy(
|
val merged = db.copy(
|
||||||
|
username = db.username.trim().ifBlank { panel.username.trim() },
|
||||||
|
displayName = db.displayName?.trim()?.takeIf { it.isNotEmpty() }
|
||||||
|
?: panel.displayName?.trim()?.takeIf { it.isNotEmpty() },
|
||||||
|
profile_picture = db.profile_picture?.takeIf { it.isNotBlank() }
|
||||||
|
?: panel.profile_picture?.takeIf { it.isNotBlank() },
|
||||||
|
verified = db.verified ?: panel.verified,
|
||||||
|
verificationStatus = db.verificationStatus ?: panel.verificationStatus,
|
||||||
pendingFileUri = when {
|
pendingFileUri = when {
|
||||||
confirmed -> localPreview
|
confirmed -> localPreview
|
||||||
else -> panel.pendingFileUri ?: db.pendingFileUri
|
else -> panel.pendingFileUri ?: db.pendingFileUri
|
||||||
|
|||||||
+6
-2
@@ -20,7 +20,8 @@ import androidx.compose.material3.MaterialTheme
|
|||||||
import androidx.compose.material3.OutlinedButton
|
import androidx.compose.material3.OutlinedButton
|
||||||
import androidx.compose.material3.Surface
|
import androidx.compose.material3.Surface
|
||||||
import androidx.compose.material3.Icon
|
import androidx.compose.material3.Icon
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import ru.fromchat.ui.components.Text
|
import ru.fromchat.ui.components.Text
|
||||||
import androidx.compose.material3.ExperimentalMaterial3Api
|
import androidx.compose.material3.ExperimentalMaterial3Api
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
@@ -138,7 +139,10 @@ fun SuspendedAccountSupportSheet(
|
|||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
|
val onContact = { uriHandler.openUri("https://t.me/fromchat_ch?direct") }
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
)
|
||||||
|
|
||||||
val closeSheet: () -> Unit = {
|
val closeSheet: () -> Unit = {
|
||||||
scope.launch {
|
scope.launch {
|
||||||
|
|||||||
@@ -938,13 +938,13 @@ internal fun DmConversationRowContent(
|
|||||||
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
val avatarUrl = if (isPeerDeleted) null else cached?.profilePicture
|
||||||
val peerTitle = when {
|
val peerTitle = when {
|
||||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
|
||||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||||
else -> cached?.visibleUsername(currentUserId).orEmpty()
|
else -> cached?.visibleUsername(currentUserId).orEmpty()
|
||||||
}
|
}
|
||||||
val avatarInitialsLabel = when {
|
val avatarInitialsLabel = when {
|
||||||
isPeerDeleted -> deletedUserDisplayNameForUi()
|
isPeerDeleted -> deletedUserDisplayNameForUi()
|
||||||
!cached?.displayName.isNullOrBlank() -> cached.displayName!!.trim()
|
!cached?.displayName.isNullOrBlank() -> cached.displayName.trim()
|
||||||
conversation.displayName.isNotBlank() -> conversation.displayName
|
conversation.displayName.isNotBlank() -> conversation.displayName
|
||||||
else -> ""
|
else -> ""
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -90,15 +90,14 @@ import androidx.compose.ui.input.pointer.pointerInput
|
|||||||
import androidx.compose.ui.layout.ContentScale
|
import androidx.compose.ui.layout.ContentScale
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.text.style.TextOverflow
|
import androidx.compose.ui.text.style.TextOverflow
|
||||||
import androidx.compose.ui.unit.Dp
|
import androidx.compose.ui.unit.Dp
|
||||||
import androidx.compose.ui.unit.IntOffset
|
import androidx.compose.ui.unit.IntOffset
|
||||||
import androidx.compose.ui.unit.IntSize
|
import androidx.compose.ui.unit.IntSize
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import coil3.compose.AsyncImage
|
import coil3.compose.AsyncImage
|
||||||
|
import com.pr0gramm3r101.utils.supportClipboardManagerImpl
|
||||||
import kotlinx.coroutines.coroutineScope
|
import kotlinx.coroutines.coroutineScope
|
||||||
import kotlinx.coroutines.flow.distinctUntilChanged
|
import kotlinx.coroutines.flow.distinctUntilChanged
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
@@ -529,7 +528,7 @@ fun ChatsTab(
|
|||||||
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
animatedVisibilityScope: AnimatedVisibilityScope? = null,
|
||||||
) {
|
) {
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val clipboardManager = LocalClipboardManager.current
|
val clipboard = supportClipboardManagerImpl
|
||||||
val haptic = rememberHapticFeedback()
|
val haptic = rememberHapticFeedback()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
val connectionStatus by ConnectionStateStore.status.collectAsState()
|
||||||
@@ -1092,7 +1091,9 @@ fun ChatsTab(
|
|||||||
chatContextMenuOverlay.onLink = {
|
chatContextMenuOverlay.onLink = {
|
||||||
when (contextMenuState.target) {
|
when (contextMenuState.target) {
|
||||||
ChatContextMenuTarget.Public -> {
|
ChatContextMenuTarget.Public -> {
|
||||||
publicChatLink?.let { clipboardManager.setText(AnnotatedString(it)) }
|
publicChatLink?.let { link ->
|
||||||
|
scope.launch { clipboard.setText(link) }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
ChatContextMenuTarget.Dm -> {
|
ChatContextMenuTarget.Dm -> {
|
||||||
val link = contextMenuState.otherUserId?.let { userId ->
|
val link = contextMenuState.otherUserId?.let { userId ->
|
||||||
@@ -1100,7 +1101,7 @@ fun ChatsTab(
|
|||||||
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
|
val username = cached?.visibleUsername(ApiClient.user?.id) ?: cached?.username
|
||||||
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
|
username?.let { "https://fromchat.ru/@$it" } ?: "https://fromchat.ru/?u=$userId"
|
||||||
}
|
}
|
||||||
link?.let { clipboardManager.setText(AnnotatedString(it)) }
|
link?.let { scope.launch { clipboard.setText(it) } }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -37,6 +37,7 @@ import com.pr0gramm3r101.components.ListItem
|
|||||||
import com.pr0gramm3r101.ui.Website
|
import com.pr0gramm3r101.ui.Website
|
||||||
import org.jetbrains.compose.resources.stringResource
|
import org.jetbrains.compose.resources.stringResource
|
||||||
import org.jetbrains.compose.resources.vectorResource
|
import org.jetbrains.compose.resources.vectorResource
|
||||||
|
import ru.fromchat.AppBuildInfo
|
||||||
import ru.fromchat.Res
|
import ru.fromchat.Res
|
||||||
import ru.fromchat.about
|
import ru.fromchat.about
|
||||||
import ru.fromchat.about_link_max
|
import ru.fromchat.about_link_max
|
||||||
@@ -111,7 +112,10 @@ fun AboutScreen() {
|
|||||||
BrandTitle(Modifier.padding(bottom = 4.dp))
|
BrandTitle(Modifier.padding(bottom = 4.dp))
|
||||||
|
|
||||||
Text(
|
Text(
|
||||||
text = stringResource(Res.string.about_version),
|
text = stringResource(
|
||||||
|
Res.string.about_version,
|
||||||
|
AppBuildInfo.version + if (AppBuildInfo.isDebug) "-beta" else "",
|
||||||
|
),
|
||||||
style = MaterialTheme.typography.bodyMedium,
|
style = MaterialTheme.typography.bodyMedium,
|
||||||
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
color = MaterialTheme.colorScheme.onSurfaceVariant,
|
||||||
modifier = Modifier.padding(bottom = 16.dp)
|
modifier = Modifier.padding(bottom = 16.dp)
|
||||||
|
|||||||
@@ -46,7 +46,8 @@ import androidx.compose.material3.SnackbarHostState
|
|||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.runtime.getValue
|
import androidx.compose.runtime.getValue
|
||||||
@@ -595,11 +596,14 @@ fun DevicesScreen(onBack: () -> Unit) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
sheetDevice?.let { d ->
|
sheetDevice?.let { d ->
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
)
|
||||||
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
|
onDismissRequest = { if (!sheetSigningOut) sheetDevice = null },
|
||||||
sheetState = sheetState
|
sheetState = sheetState,
|
||||||
) {
|
) {
|
||||||
DeviceSessionDetailBottomSheet(
|
DeviceSessionDetailBottomSheet(
|
||||||
d = d,
|
d = d,
|
||||||
|
|||||||
@@ -50,7 +50,8 @@ import androidx.compose.material3.ModalBottomSheet
|
|||||||
import androidx.compose.material3.Scaffold
|
import androidx.compose.material3.Scaffold
|
||||||
import androidx.compose.material3.TextButton
|
import androidx.compose.material3.TextButton
|
||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
@@ -389,13 +390,15 @@ fun LogFilesScreen(
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showShareSheet) {
|
if (showShareSheet) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showShareSheet = false
|
showShareSheet = false
|
||||||
pendingSharePaths = emptyList()
|
pendingSharePaths = emptyList()
|
||||||
},
|
},
|
||||||
sheetState = sheetState,
|
sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
LogsShareBottomSheet(
|
LogsShareBottomSheet(
|
||||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||||
|
|||||||
@@ -88,7 +88,8 @@ import androidx.compose.material3.TextButton
|
|||||||
import androidx.compose.material3.TopAppBar
|
import androidx.compose.material3.TopAppBar
|
||||||
import androidx.compose.material3.TopAppBarDefaults
|
import androidx.compose.material3.TopAppBarDefaults
|
||||||
import androidx.compose.material3.rememberDatePickerState
|
import androidx.compose.material3.rememberDatePickerState
|
||||||
import androidx.compose.material3.rememberModalBottomSheetState
|
import androidx.compose.material3.SheetValue
|
||||||
|
import androidx.compose.material3.rememberBottomSheetState
|
||||||
import androidx.compose.material3.rememberTopAppBarState
|
import androidx.compose.material3.rememberTopAppBarState
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.DisposableEffect
|
import androidx.compose.runtime.DisposableEffect
|
||||||
@@ -632,13 +633,15 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showShareSheet) {
|
if (showShareSheet) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = {
|
onDismissRequest = {
|
||||||
showShareSheet = false
|
showShareSheet = false
|
||||||
pendingShareRequest = null
|
pendingShareRequest = null
|
||||||
},
|
},
|
||||||
sheetState = sheetState,
|
sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
),
|
||||||
) {
|
) {
|
||||||
LogsShareBottomSheet(
|
LogsShareBottomSheet(
|
||||||
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
onUncompressed = { performShare(LogShareCompression.Uncompressed) },
|
||||||
@@ -648,7 +651,10 @@ fun LogsScreen() {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if (showCleanSheet) {
|
if (showCleanSheet) {
|
||||||
val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
|
val sheetState = rememberBottomSheetState(
|
||||||
|
initialValue = SheetValue.Hidden,
|
||||||
|
enabledValues = setOf(SheetValue.Hidden, SheetValue.Expanded),
|
||||||
|
)
|
||||||
ModalBottomSheet(
|
ModalBottomSheet(
|
||||||
onDismissRequest = { showCleanSheet = false },
|
onDismissRequest = { showCleanSheet = false },
|
||||||
sheetState = sheetState,
|
sheetState = sheetState,
|
||||||
|
|||||||
@@ -16,6 +16,9 @@ object SettingsRoutes {
|
|||||||
const val AccountYandexFlow = "settings/account/yandex"
|
const val AccountYandexFlow = "settings/account/yandex"
|
||||||
const val AccountYandexOAuth = "settings/account/yandex/oauth"
|
const val AccountYandexOAuth = "settings/account/yandex/oauth"
|
||||||
const val AccountYandexDone = "settings/account/yandex/done"
|
const val AccountYandexDone = "settings/account/yandex/done"
|
||||||
|
const val AccountVkFlow = "settings/account/vk"
|
||||||
|
const val AccountVkOAuth = "settings/account/vk/oauth"
|
||||||
|
const val AccountVkDone = "settings/account/vk/done"
|
||||||
const val ServerConfig = "serverConfig"
|
const val ServerConfig = "serverConfig"
|
||||||
const val About = "about"
|
const val About = "about"
|
||||||
const val Logs = "settings/logs"
|
const val Logs = "settings/logs"
|
||||||
|
|||||||
+25
@@ -43,8 +43,11 @@ import ru.fromchat.Res
|
|||||||
import ru.fromchat.api.ApiClient
|
import ru.fromchat.api.ApiClient
|
||||||
import ru.fromchat.back
|
import ru.fromchat.back
|
||||||
import ru.fromchat.cancel
|
import ru.fromchat.cancel
|
||||||
|
import ru.fromchat.ic_vk
|
||||||
import ru.fromchat.ic_yandex
|
import ru.fromchat.ic_yandex
|
||||||
import ru.fromchat.logout
|
import ru.fromchat.logout
|
||||||
|
import ru.fromchat.settings_account_change_vk
|
||||||
|
import ru.fromchat.settings_account_change_vk_d
|
||||||
import ru.fromchat.settings_account_change_yandex
|
import ru.fromchat.settings_account_change_yandex
|
||||||
import ru.fromchat.settings_account_change_yandex_d
|
import ru.fromchat.settings_account_change_yandex_d
|
||||||
import ru.fromchat.settings_account_delete
|
import ru.fromchat.settings_account_delete
|
||||||
@@ -63,16 +66,20 @@ fun AccountScreen(
|
|||||||
onLogout: () -> Unit,
|
onLogout: () -> Unit,
|
||||||
onChangePassword: () -> Unit,
|
onChangePassword: () -> Unit,
|
||||||
onChangeYandexId: () -> Unit,
|
onChangeYandexId: () -> Unit,
|
||||||
|
onChangeVkId: () -> Unit,
|
||||||
onDeleteAccount: () -> Unit,
|
onDeleteAccount: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
val scrollBehavior = TopAppBarDefaults.exitUntilCollapsedScrollBehavior(rememberTopAppBarState())
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
var showLogoutConfirm by remember { mutableStateOf(false) }
|
var showLogoutConfirm by remember { mutableStateOf(false) }
|
||||||
var yandexAvailable by remember { mutableStateOf(false) }
|
var yandexAvailable by remember { mutableStateOf(false) }
|
||||||
|
var vkAvailable by remember { mutableStateOf(false) }
|
||||||
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
val yandexIcon = vectorResource(Res.drawable.ic_yandex)
|
||||||
|
val vkIcon = vectorResource(Res.drawable.ic_vk)
|
||||||
|
|
||||||
LaunchedEffect(Unit) {
|
LaunchedEffect(Unit) {
|
||||||
yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
|
yandexAvailable = runCatching { ApiClient.getAccountYandex() }.isSuccess
|
||||||
|
vkAvailable = runCatching { ApiClient.getAccountVk() }.isSuccess
|
||||||
}
|
}
|
||||||
|
|
||||||
Scaffold(
|
Scaffold(
|
||||||
@@ -133,6 +140,24 @@ fun AccountScreen(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
if (vkAvailable) {
|
||||||
|
ListItem(
|
||||||
|
headline = stringResource(Res.string.settings_account_change_vk),
|
||||||
|
supportingText = stringResource(Res.string.settings_account_change_vk_d),
|
||||||
|
onClick = onChangeVkId,
|
||||||
|
leadingContent = { Icon(vkIcon, null) },
|
||||||
|
divider = true,
|
||||||
|
trailingContent = {
|
||||||
|
Icon(
|
||||||
|
imageVector = Icons.AutoMirrored.Filled.KeyboardArrowRight,
|
||||||
|
contentDescription = null,
|
||||||
|
modifier = Modifier.size(24.dp),
|
||||||
|
tint = MaterialTheme.colorScheme.onSurfaceVariant
|
||||||
|
)
|
||||||
|
}
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
ListItem(
|
ListItem(
|
||||||
headline = stringResource(Res.string.settings_account_delete),
|
headline = stringResource(Res.string.settings_account_delete),
|
||||||
supportingText = stringResource(Res.string.settings_account_delete_d),
|
supportingText = stringResource(Res.string.settings_account_delete_d),
|
||||||
|
|||||||
+161
@@ -0,0 +1,161 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changevk
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarDuration
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import org.jetbrains.compose.resources.vectorResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.api.schema.user.auth.VkOAuthParams
|
||||||
|
import ru.fromchat.auth.vk.VK_OAUTH_REDIRECT_URI
|
||||||
|
import ru.fromchat.auth.vk.buildVkAuthorizeUrl
|
||||||
|
import ru.fromchat.auth.vk.generateOAuthState
|
||||||
|
import ru.fromchat.auth.vk.generateVkPkcePair
|
||||||
|
import ru.fromchat.auth.vk.resolveVkClientId
|
||||||
|
import ru.fromchat.auth_vk_client_mismatch
|
||||||
|
import ru.fromchat.config.Settings
|
||||||
|
import ru.fromchat.error_unexpected
|
||||||
|
import ru.fromchat.ic_vk
|
||||||
|
import ru.fromchat.settings_next
|
||||||
|
import ru.fromchat.settings_vk_step_confirm_body
|
||||||
|
import ru.fromchat.settings_vk_step_confirm_cta
|
||||||
|
import ru.fromchat.settings_vk_step_confirm_title
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.components.ActionButton
|
||||||
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||||
|
import ru.fromchat.ui.components.showReplacingSnackbar
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChangeVkConfirmScreen(onBack: () -> Unit) {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val flowState = rememberExpressiveStepFlow(1)
|
||||||
|
var vk by remember { mutableStateOf<VkOAuthParams?>(null) }
|
||||||
|
var loadingParams by remember { mutableStateOf(true) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
val darkTheme = isAppInDarkTheme()
|
||||||
|
val languageTag = Locale.current.toLanguageTag()
|
||||||
|
val vkIcon = vectorResource(Res.drawable.ic_vk)
|
||||||
|
|
||||||
|
val title = stringResource(Res.string.settings_vk_step_confirm_title)
|
||||||
|
val body = stringResource(Res.string.settings_vk_step_confirm_body)
|
||||||
|
val cta = stringResource(Res.string.settings_vk_step_confirm_cta)
|
||||||
|
val next = stringResource(Res.string.settings_next)
|
||||||
|
val clientMismatch = stringResource(Res.string.auth_vk_client_mismatch)
|
||||||
|
val unexpected = stringResource(Res.string.error_unexpected)
|
||||||
|
|
||||||
|
fun showSnack(text: String) {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showReplacingSnackbar(
|
||||||
|
message = text,
|
||||||
|
withDismissAction = false,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
LaunchedEffect(Unit) {
|
||||||
|
ChangeVkDraft.clear()
|
||||||
|
loadingParams = true
|
||||||
|
try {
|
||||||
|
vk = ApiClient.getAccountVk().vk
|
||||||
|
} catch (e: Exception) {
|
||||||
|
showSnack(e.message ?: unexpected)
|
||||||
|
} finally {
|
||||||
|
loadingParams = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
ExpressiveStepFlowScaffold(
|
||||||
|
flowState = flowState,
|
||||||
|
pages = listOf(
|
||||||
|
ExpressiveStepPage(
|
||||||
|
hero = ExpressiveHeroSpec(
|
||||||
|
icon = vkIcon,
|
||||||
|
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
||||||
|
containerColor = colorScheme.primaryContainer,
|
||||||
|
contentColor = colorScheme.onPrimaryContainer,
|
||||||
|
),
|
||||||
|
content = {
|
||||||
|
ExpressiveStepPageHeader(title = title, body = body)
|
||||||
|
},
|
||||||
|
button = {
|
||||||
|
ActionButton(
|
||||||
|
onClick = {
|
||||||
|
if (busy || loadingParams) return@ActionButton
|
||||||
|
val params = vk
|
||||||
|
if (params == null) {
|
||||||
|
showSnack(unexpected)
|
||||||
|
return@ActionButton
|
||||||
|
}
|
||||||
|
busy = true
|
||||||
|
scope.launch {
|
||||||
|
try {
|
||||||
|
val serverIp = runCatching { Settings.serverConfig.serverIp }.getOrElse {
|
||||||
|
showSnack(it.message ?: unexpected)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val clientId = resolveVkClientId(params.client_id, serverIp)
|
||||||
|
if (clientId == null) {
|
||||||
|
showSnack(clientMismatch)
|
||||||
|
return@launch
|
||||||
|
}
|
||||||
|
val pkce = generateVkPkcePair()
|
||||||
|
val state = generateOAuthState()
|
||||||
|
val redirectUri = params.redirect_uri.ifBlank { VK_OAUTH_REDIRECT_URI }
|
||||||
|
ChangeVkDraft.authorizeUrl = buildVkAuthorizeUrl(
|
||||||
|
authorizeUrl = params.authorize_url,
|
||||||
|
clientId = clientId,
|
||||||
|
redirectUri = redirectUri,
|
||||||
|
scope = params.scope,
|
||||||
|
codeChallenge = pkce.codeChallenge,
|
||||||
|
state = state,
|
||||||
|
languageTag = languageTag,
|
||||||
|
darkTheme = darkTheme,
|
||||||
|
)
|
||||||
|
ChangeVkDraft.codeVerifier = pkce.codeVerifier
|
||||||
|
ChangeVkDraft.state = state
|
||||||
|
ChangeVkDraft.redirectUri = redirectUri
|
||||||
|
navController.navigate(SettingsRoutes.AccountVkOAuth)
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
enabled = !busy && !loadingParams && vk != null,
|
||||||
|
loading = busy || loadingParams,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(if (busy || loadingParams) next else cta)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
onBackAtFirstPage = onBack,
|
||||||
|
)
|
||||||
|
}
|
||||||
+62
@@ -0,0 +1,62 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changevk
|
||||||
|
|
||||||
|
import androidx.compose.foundation.layout.fillMaxWidth
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.filled.CheckCircle
|
||||||
|
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
|
||||||
|
import androidx.compose.material3.MaterialShapes
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
import androidx.compose.material3.SnackbarHostState
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.remember
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.settings_done
|
||||||
|
import ru.fromchat.settings_vk_step_done_body
|
||||||
|
import ru.fromchat.settings_vk_step_done_title
|
||||||
|
import ru.fromchat.ui.components.ActionButton
|
||||||
|
import ru.fromchat.ui.components.ExpressiveHeroSpec
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepFlowScaffold
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPage
|
||||||
|
import ru.fromchat.ui.components.ExpressiveStepPageHeader
|
||||||
|
import ru.fromchat.ui.components.Text
|
||||||
|
import ru.fromchat.ui.components.rememberExpressiveStepFlow
|
||||||
|
|
||||||
|
@OptIn(ExperimentalMaterial3ExpressiveApi::class)
|
||||||
|
@Composable
|
||||||
|
fun ChangeVkDoneScreen(onDone: () -> Unit) {
|
||||||
|
val flowState = rememberExpressiveStepFlow(1)
|
||||||
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
|
val title = stringResource(Res.string.settings_vk_step_done_title)
|
||||||
|
val body = stringResource(Res.string.settings_vk_step_done_body)
|
||||||
|
val done = stringResource(Res.string.settings_done)
|
||||||
|
val colorScheme = MaterialTheme.colorScheme
|
||||||
|
|
||||||
|
ExpressiveStepFlowScaffold(
|
||||||
|
flowState = flowState,
|
||||||
|
pages = listOf(
|
||||||
|
ExpressiveStepPage(
|
||||||
|
hero = ExpressiveHeroSpec(
|
||||||
|
icon = Icons.Filled.CheckCircle,
|
||||||
|
polygon = MaterialShapes.Cookie9Sided.normalized(),
|
||||||
|
containerColor = colorScheme.tertiaryContainer,
|
||||||
|
contentColor = colorScheme.onTertiaryContainer,
|
||||||
|
),
|
||||||
|
content = {
|
||||||
|
ExpressiveStepPageHeader(title = title, body = body)
|
||||||
|
},
|
||||||
|
button = {
|
||||||
|
ActionButton(
|
||||||
|
onClick = onDone,
|
||||||
|
modifier = Modifier.fillMaxWidth(),
|
||||||
|
) {
|
||||||
|
Text(done)
|
||||||
|
}
|
||||||
|
},
|
||||||
|
),
|
||||||
|
),
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
onBackAtFirstPage = onDone,
|
||||||
|
)
|
||||||
|
}
|
||||||
+19
@@ -0,0 +1,19 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changevk
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Stages PKCE + authorize URL + OAuth state while the change-VK OAuth WebView is open
|
||||||
|
* (settings composition can leave the confirm screen).
|
||||||
|
*/
|
||||||
|
internal object ChangeVkDraft {
|
||||||
|
var authorizeUrl: String? = null
|
||||||
|
var codeVerifier: String? = null
|
||||||
|
var state: String? = null
|
||||||
|
var redirectUri: String? = null
|
||||||
|
|
||||||
|
fun clear() {
|
||||||
|
authorizeUrl = null
|
||||||
|
codeVerifier = null
|
||||||
|
state = null
|
||||||
|
redirectUri = null
|
||||||
|
}
|
||||||
|
}
|
||||||
+142
@@ -0,0 +1,142 @@
|
|||||||
|
package ru.fromchat.ui.main.settings.account.changevk
|
||||||
|
|
||||||
|
import androidx.compose.animation.AnimatedVisibility
|
||||||
|
import androidx.compose.animation.fadeIn
|
||||||
|
import androidx.compose.animation.fadeOut
|
||||||
|
import androidx.compose.foundation.background
|
||||||
|
import androidx.compose.foundation.layout.Box
|
||||||
|
import androidx.compose.foundation.layout.fillMaxSize
|
||||||
|
import androidx.compose.foundation.layout.padding
|
||||||
|
import androidx.compose.foundation.layout.safeDrawingPadding
|
||||||
|
import androidx.compose.material.icons.Icons
|
||||||
|
import androidx.compose.material.icons.automirrored.filled.ArrowBack
|
||||||
|
import androidx.compose.material3.FilledIconButton
|
||||||
|
import androidx.compose.material3.Icon
|
||||||
|
import androidx.compose.material3.IconButtonDefaults
|
||||||
|
import androidx.compose.material3.MaterialTheme
|
||||||
|
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.rememberCoroutineScope
|
||||||
|
import androidx.compose.runtime.setValue
|
||||||
|
import androidx.compose.ui.Alignment
|
||||||
|
import androidx.compose.ui.Modifier
|
||||||
|
import androidx.compose.ui.text.intl.Locale
|
||||||
|
import androidx.compose.ui.unit.dp
|
||||||
|
import kotlinx.coroutines.launch
|
||||||
|
import org.jetbrains.compose.resources.stringResource
|
||||||
|
import ru.fromchat.Res
|
||||||
|
import ru.fromchat.api.ApiClient
|
||||||
|
import ru.fromchat.back
|
||||||
|
import ru.fromchat.ui.LocalNavController
|
||||||
|
import ru.fromchat.ui.auth.vk.VkOAuthWebView
|
||||||
|
import ru.fromchat.ui.isAppInDarkTheme
|
||||||
|
import ru.fromchat.ui.main.settings.SettingsRoutes
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
fun ChangeVkOAuthScreen(onBack: () -> Unit) {
|
||||||
|
val navController = LocalNavController.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val authorizeUrl = remember { ChangeVkDraft.authorizeUrl }
|
||||||
|
val codeVerifier = remember { ChangeVkDraft.codeVerifier }
|
||||||
|
val expectedState = remember { ChangeVkDraft.state }
|
||||||
|
val redirectUri = remember { ChangeVkDraft.redirectUri }
|
||||||
|
val fallbackColor = MaterialTheme.colorScheme.background
|
||||||
|
var chromeColor by remember { mutableStateOf(fallbackColor) }
|
||||||
|
var busy by remember { mutableStateOf(false) }
|
||||||
|
var webViewCanGoBack by remember { mutableStateOf(false) }
|
||||||
|
val backLabel = stringResource(Res.string.back)
|
||||||
|
|
||||||
|
LaunchedEffect(authorizeUrl, codeVerifier, expectedState, redirectUri) {
|
||||||
|
if (authorizeUrl.isNullOrBlank() ||
|
||||||
|
codeVerifier.isNullOrBlank() ||
|
||||||
|
expectedState.isNullOrBlank() ||
|
||||||
|
redirectUri.isNullOrBlank()
|
||||||
|
) {
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
val url = authorizeUrl ?: return
|
||||||
|
val verifier = codeVerifier ?: return
|
||||||
|
val state = expectedState ?: return
|
||||||
|
val callbackUri = redirectUri ?: return
|
||||||
|
|
||||||
|
fun finishSuccess() {
|
||||||
|
ChangeVkDraft.clear()
|
||||||
|
navController.navigate(SettingsRoutes.AccountVkDone) {
|
||||||
|
popUpTo(SettingsRoutes.AccountVkFlow) { inclusive = true }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun cancel() {
|
||||||
|
if (busy) return
|
||||||
|
ChangeVkDraft.clear()
|
||||||
|
onBack()
|
||||||
|
}
|
||||||
|
|
||||||
|
Box(
|
||||||
|
modifier = Modifier
|
||||||
|
.fillMaxSize()
|
||||||
|
.background(chromeColor),
|
||||||
|
) {
|
||||||
|
VkOAuthWebView(
|
||||||
|
authorizeUrl = url,
|
||||||
|
redirectUri = callbackUri,
|
||||||
|
languageTag = Locale.current.toLanguageTag(),
|
||||||
|
darkTheme = isAppInDarkTheme(),
|
||||||
|
fallbackColor = fallbackColor,
|
||||||
|
clearCookies = true,
|
||||||
|
onPageBackgroundColor = { chromeColor = it },
|
||||||
|
onHistoryBackAvailabilityChanged = { webViewCanGoBack = it },
|
||||||
|
onRedirect = { redirect ->
|
||||||
|
if (busy) return@VkOAuthWebView
|
||||||
|
if (redirect.state != state) {
|
||||||
|
cancel()
|
||||||
|
return@VkOAuthWebView
|
||||||
|
}
|
||||||
|
scope.launch {
|
||||||
|
busy = true
|
||||||
|
try {
|
||||||
|
val proof = ApiClient.authVkExchange(
|
||||||
|
code = redirect.code,
|
||||||
|
codeVerifier = verifier,
|
||||||
|
deviceId = redirect.deviceId,
|
||||||
|
state = redirect.state,
|
||||||
|
).registration_proof
|
||||||
|
ApiClient.changeAccountVk(proof)
|
||||||
|
finishSuccess()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
cancel()
|
||||||
|
} finally {
|
||||||
|
busy = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
onError = { cancel() },
|
||||||
|
onCancel = { cancel() },
|
||||||
|
)
|
||||||
|
|
||||||
|
AnimatedVisibility(
|
||||||
|
visible = !webViewCanGoBack && !busy,
|
||||||
|
enter = fadeIn(),
|
||||||
|
exit = fadeOut(),
|
||||||
|
modifier = Modifier
|
||||||
|
.align(Alignment.TopStart)
|
||||||
|
.safeDrawingPadding()
|
||||||
|
.padding(start = 12.dp, top = 12.dp),
|
||||||
|
) {
|
||||||
|
FilledIconButton(
|
||||||
|
onClick = { cancel() },
|
||||||
|
colors = IconButtonDefaults.filledIconButtonColors(
|
||||||
|
containerColor = MaterialTheme.colorScheme.surfaceContainerHigh.copy(alpha = 0.92f),
|
||||||
|
contentColor = MaterialTheme.colorScheme.onSurface,
|
||||||
|
),
|
||||||
|
) {
|
||||||
|
Icon(Icons.AutoMirrored.Filled.ArrowBack, contentDescription = backLabel)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -105,10 +105,7 @@ import androidx.compose.ui.Modifier
|
|||||||
import androidx.compose.ui.geometry.Rect
|
import androidx.compose.ui.geometry.Rect
|
||||||
import androidx.compose.ui.layout.onGloballyPositioned
|
import androidx.compose.ui.layout.onGloballyPositioned
|
||||||
import androidx.compose.ui.layout.positionInRoot
|
import androidx.compose.ui.layout.positionInRoot
|
||||||
import androidx.compose.ui.platform.ClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalClipboardManager
|
|
||||||
import androidx.compose.ui.platform.LocalDensity
|
import androidx.compose.ui.platform.LocalDensity
|
||||||
import androidx.compose.ui.text.AnnotatedString
|
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import com.pr0gramm3r101.components.Category
|
import com.pr0gramm3r101.components.Category
|
||||||
import com.pr0gramm3r101.components.ContextMenuPressable
|
import com.pr0gramm3r101.components.ContextMenuPressable
|
||||||
@@ -177,6 +174,7 @@ import ru.fromchat.profile_headline_bio
|
|||||||
import ru.fromchat.profile_headline_member_since
|
import ru.fromchat.profile_headline_member_since
|
||||||
import ru.fromchat.profile_headline_username
|
import ru.fromchat.profile_headline_username
|
||||||
import ru.fromchat.profile_headline_verification
|
import ru.fromchat.profile_headline_verification
|
||||||
|
import ru.fromchat.profile_invalid_link
|
||||||
import ru.fromchat.profile_load_failed
|
import ru.fromchat.profile_load_failed
|
||||||
import ru.fromchat.profile_not_found
|
import ru.fromchat.profile_not_found
|
||||||
import ru.fromchat.profile_verified_support
|
import ru.fromchat.profile_verified_support
|
||||||
@@ -267,7 +265,6 @@ fun ProfileScreen(
|
|||||||
onOpenSettings: () -> Unit = {},
|
onOpenSettings: () -> Unit = {},
|
||||||
showBackButton: Boolean = false,
|
showBackButton: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val clipboardManager: ClipboardManager = LocalClipboardManager.current
|
|
||||||
val clipboard = supportClipboardManagerImpl
|
val clipboard = supportClipboardManagerImpl
|
||||||
val navController = LocalNavController.current
|
val navController = LocalNavController.current
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
@@ -377,7 +374,7 @@ fun ProfileScreen(
|
|||||||
ApiClient.applyOwnProfile(refreshed)
|
ApiClient.applyOwnProfile(refreshed)
|
||||||
state = latestUi.copy(profile = refreshed, error = null)
|
state = latestUi.copy(profile = refreshed, error = null)
|
||||||
} catch (_: Exception) {
|
} catch (_: Exception) {
|
||||||
ownUserId?.let { ProfileCache.get(it) }?.let { cached ->
|
ownUserId.let { ProfileCache.get(it) }?.let { cached ->
|
||||||
state = latestUi.copy(profile = cached)
|
state = latestUi.copy(profile = cached)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -609,7 +606,7 @@ fun ProfileScreen(
|
|||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelLink,
|
label = labelLink,
|
||||||
icon = Icons.Filled.Link,
|
icon = Icons.Filled.Link,
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||||
),
|
),
|
||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelSettings,
|
label = labelSettings,
|
||||||
@@ -654,7 +651,7 @@ fun ProfileScreen(
|
|||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelLink,
|
label = labelLink,
|
||||||
icon = Icons.Filled.Link,
|
icon = Icons.Filled.Link,
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||||
)
|
)
|
||||||
)
|
)
|
||||||
if (ServerConfig.callsEnabled) {
|
if (ServerConfig.callsEnabled) {
|
||||||
@@ -831,10 +828,10 @@ fun ProfileScreen(
|
|||||||
labelCopy = labelCopy,
|
labelCopy = labelCopy,
|
||||||
labelEdit = labelEdit,
|
labelEdit = labelEdit,
|
||||||
detailsBringIntoView = detailsBringIntoView,
|
detailsBringIntoView = detailsBringIntoView,
|
||||||
clipboardManager = clipboardManager,
|
|
||||||
clipboard = clipboard,
|
clipboard = clipboard,
|
||||||
navController = navController,
|
navController = navController,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
openContextMenuHaptic = openContextMenuHaptic,
|
openContextMenuHaptic = openContextMenuHaptic,
|
||||||
onBack = onBack,
|
onBack = onBack,
|
||||||
onProfileUpdated = { updated ->
|
onProfileUpdated = { updated ->
|
||||||
@@ -880,7 +877,6 @@ fun PublicChatProfileScreen(
|
|||||||
initialDisplayName: String? = null,
|
initialDisplayName: String? = null,
|
||||||
showBackButton: Boolean = false,
|
showBackButton: Boolean = false,
|
||||||
) {
|
) {
|
||||||
val clipboardManager = LocalClipboardManager.current
|
|
||||||
val clipboard = supportClipboardManagerImpl
|
val clipboard = supportClipboardManagerImpl
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
val snackbarHostState = remember { SnackbarHostState() }
|
val snackbarHostState = remember { SnackbarHostState() }
|
||||||
@@ -962,7 +958,7 @@ fun PublicChatProfileScreen(
|
|||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelLink,
|
label = labelLink,
|
||||||
icon = Icons.Filled.Link,
|
icon = Icons.Filled.Link,
|
||||||
onClick = { clipboardManager.setText(AnnotatedString(profileLink.orEmpty())) },
|
onClick = { scope.launch { clipboard.setText(profileLink.orEmpty()) } },
|
||||||
),
|
),
|
||||||
ProfileAction(
|
ProfileAction(
|
||||||
label = labelSearch,
|
label = labelSearch,
|
||||||
@@ -1003,15 +999,15 @@ fun PublicChatProfileScreen(
|
|||||||
when {
|
when {
|
||||||
useSharedAvatar && displayName.isNotBlank() -> {
|
useSharedAvatar && displayName.isNotBlank() -> {
|
||||||
item {
|
item {
|
||||||
with(sharedTransitionScope!!) {
|
with(sharedTransitionScope) {
|
||||||
Avatar(
|
Avatar(
|
||||||
profilePictureUrl = null,
|
profilePictureUrl = null,
|
||||||
displayName = displayName,
|
displayName = displayName,
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
.padding(top = profileAvatarTop)
|
.padding(top = profileAvatarTop)
|
||||||
.sharedElement(
|
.sharedElement(
|
||||||
rememberSharedContentState(key = sharedAvatarKey!!),
|
rememberSharedContentState(key = sharedAvatarKey),
|
||||||
animatedVisibilityScope = animatedVisibilityScope!!,
|
animatedVisibilityScope = animatedVisibilityScope,
|
||||||
)
|
)
|
||||||
.size(104.dp),
|
.size(104.dp),
|
||||||
)
|
)
|
||||||
@@ -1051,6 +1047,7 @@ fun PublicChatProfileScreen(
|
|||||||
labelCopy = labelCopy,
|
labelCopy = labelCopy,
|
||||||
clipboard = clipboard,
|
clipboard = clipboard,
|
||||||
scope = scope,
|
scope = scope,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -1090,6 +1087,7 @@ private fun PublicChatProfileLoadedBody(
|
|||||||
labelCopy: String,
|
labelCopy: String,
|
||||||
clipboard: SupportClipboardManager,
|
clipboard: SupportClipboardManager,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
Column(
|
Column(
|
||||||
@@ -1136,6 +1134,7 @@ private fun PublicChatProfileLoadedBody(
|
|||||||
supportingSlot = {
|
supportingSlot = {
|
||||||
ProfileBioMarkdown(
|
ProfileBioMarkdown(
|
||||||
content = resolvedProfile.bio.orEmpty(),
|
content = resolvedProfile.bio.orEmpty(),
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
)
|
)
|
||||||
},
|
},
|
||||||
position = ListItemPosition.START,
|
position = ListItemPosition.START,
|
||||||
@@ -1320,10 +1319,10 @@ private fun ProfileLoadedBody(
|
|||||||
labelCopy: String,
|
labelCopy: String,
|
||||||
labelEdit: String,
|
labelEdit: String,
|
||||||
detailsBringIntoView: BringIntoViewRequester,
|
detailsBringIntoView: BringIntoViewRequester,
|
||||||
clipboardManager: ClipboardManager,
|
|
||||||
clipboard: SupportClipboardManager,
|
clipboard: SupportClipboardManager,
|
||||||
navController: NavController,
|
navController: NavController,
|
||||||
scope: CoroutineScope,
|
scope: CoroutineScope,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
openContextMenuHaptic: () -> Unit,
|
openContextMenuHaptic: () -> Unit,
|
||||||
onBack: () -> Unit,
|
onBack: () -> Unit,
|
||||||
onProfileUpdated: (UserProfile) -> Unit,
|
onProfileUpdated: (UserProfile) -> Unit,
|
||||||
@@ -1349,7 +1348,7 @@ private fun ProfileLoadedBody(
|
|||||||
onContextMenuOpen = openContextMenuHaptic,
|
onContextMenuOpen = openContextMenuHaptic,
|
||||||
contextMenu = {
|
contextMenu = {
|
||||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||||
clipboardManager.setText(AnnotatedString(displayName))
|
scope.launch { clipboard.setText(displayName) }
|
||||||
}
|
}
|
||||||
if (isOwnProfile) {
|
if (isOwnProfile) {
|
||||||
item(Icons.Rounded.Edit, labelEdit) {
|
item(Icons.Rounded.Edit, labelEdit) {
|
||||||
@@ -1453,9 +1452,9 @@ private fun ProfileLoadedBody(
|
|||||||
},
|
},
|
||||||
contextMenu = {
|
contextMenu = {
|
||||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||||
clipboardManager.setText(
|
scope.launch {
|
||||||
AnnotatedString(usernameForLinks.orEmpty()),
|
clipboard.setText(usernameForLinks.orEmpty())
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
if (isOwnProfile) {
|
if (isOwnProfile) {
|
||||||
item(Icons.Rounded.Edit, labelEdit) {
|
item(Icons.Rounded.Edit, labelEdit) {
|
||||||
@@ -1491,7 +1490,7 @@ private fun ProfileLoadedBody(
|
|||||||
},
|
},
|
||||||
contextMenu = {
|
contextMenu = {
|
||||||
item(Icons.Rounded.ContentCopy, labelCopy) {
|
item(Icons.Rounded.ContentCopy, labelCopy) {
|
||||||
clipboardManager.setText(AnnotatedString(memberSinceText))
|
scope.launch { clipboard.setText(memberSinceText) }
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
)
|
)
|
||||||
@@ -1505,7 +1504,10 @@ private fun ProfileLoadedBody(
|
|||||||
headline = headlineBio,
|
headline = headlineBio,
|
||||||
supportingSlot = {
|
supportingSlot = {
|
||||||
key(resolvedProfile.id, bioContent) {
|
key(resolvedProfile.id, bioContent) {
|
||||||
ProfileBioMarkdown(content = bioContent)
|
ProfileBioMarkdown(
|
||||||
|
content = bioContent,
|
||||||
|
snackbarHostState = snackbarHostState,
|
||||||
|
)
|
||||||
}
|
}
|
||||||
},
|
},
|
||||||
divider = true,
|
divider = true,
|
||||||
@@ -1864,14 +1866,27 @@ private fun ProfileLoadedBody(
|
|||||||
@Composable
|
@Composable
|
||||||
private fun ProfileBioMarkdown(
|
private fun ProfileBioMarkdown(
|
||||||
content: String,
|
content: String,
|
||||||
|
snackbarHostState: SnackbarHostState,
|
||||||
modifier: Modifier = Modifier,
|
modifier: Modifier = Modifier,
|
||||||
) {
|
) {
|
||||||
val uriHandler = LocalUriHandler.current
|
val uriHandler = LocalUriHandler.current
|
||||||
|
val scope = rememberCoroutineScope()
|
||||||
|
val invalidLinkMessage = stringResource(Res.string.profile_invalid_link)
|
||||||
|
|
||||||
MarkdownPlain(
|
MarkdownPlain(
|
||||||
content = content,
|
content = content,
|
||||||
modifier = modifier,
|
modifier = modifier,
|
||||||
onLinkClick = { uriHandler.openUri(it) },
|
onLinkClick = { uri ->
|
||||||
|
runCatching { uriHandler.openUri(uri) }.onFailure {
|
||||||
|
scope.launch {
|
||||||
|
snackbarHostState.showReplacingSnackbar(
|
||||||
|
message = invalidLinkMessage,
|
||||||
|
withDismissAction = false,
|
||||||
|
duration = SnackbarDuration.Short,
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+7
-4
@@ -1,23 +1,26 @@
|
|||||||
package ru.fromchat.ui.auth.yandex
|
package ru.fromchat.ui.auth.oauth
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
import androidx.compose.runtime.LaunchedEffect
|
import androidx.compose.runtime.LaunchedEffect
|
||||||
import androidx.compose.ui.graphics.Color
|
import androidx.compose.ui.graphics.Color
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun YandexOAuthWebView(
|
actual fun OAuthWebView(
|
||||||
authorizeUrl: String,
|
authorizeUrl: String,
|
||||||
languageTag: String,
|
languageTag: String,
|
||||||
darkTheme: Boolean,
|
darkTheme: Boolean,
|
||||||
fallbackColor: Color,
|
fallbackColor: Color,
|
||||||
|
redirectUriPrefix: String,
|
||||||
|
isAuthNavigation: (url: String) -> Boolean,
|
||||||
clearCookies: Boolean,
|
clearCookies: Boolean,
|
||||||
|
themeCookieHosts: List<String>,
|
||||||
onPageBackgroundColor: (Color) -> Unit,
|
onPageBackgroundColor: (Color) -> Unit,
|
||||||
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
onHistoryBackAvailabilityChanged: (Boolean) -> Unit,
|
||||||
onCode: (String) -> Unit,
|
onRedirectUrl: (String) -> Unit,
|
||||||
onError: (String) -> Unit,
|
onError: (String) -> Unit,
|
||||||
onCancel: () -> Unit,
|
onCancel: () -> Unit,
|
||||||
) {
|
) {
|
||||||
LaunchedEffect(authorizeUrl) {
|
LaunchedEffect(authorizeUrl) {
|
||||||
onError("Yandex sign-in is not available on this platform yet.")
|
onError("OAuth sign-in is not available on this platform yet.")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -8,6 +8,10 @@ plugins {
|
|||||||
alias(libs.plugins.google.services) apply false
|
alias(libs.plugins.google.services) apply false
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Single source of truth for app version (APK + generated [AppBuildInfo]). */
|
||||||
|
extra["versionName"] = "1.1.3"
|
||||||
|
extra["versionCode"] = 113
|
||||||
|
|
||||||
buildscript {
|
buildscript {
|
||||||
repositories {
|
repositories {
|
||||||
google()
|
google()
|
||||||
|
|||||||
+23
-25
@@ -1,52 +1,52 @@
|
|||||||
[versions]
|
[versions]
|
||||||
agp = "9.1.1"
|
agp = "9.3.0"
|
||||||
androidx-activityCompose = "1.13.0"
|
androidx-activityCompose = "1.13.0"
|
||||||
androidx-appcompat = "1.7.1"
|
androidx-appcompat = "1.7.1"
|
||||||
androidx-core-ktx = "1.18.0"
|
androidx-core-ktx = "1.19.0"
|
||||||
androidx-exifinterface = "1.3.7"
|
androidx-exifinterface = "1.4.2"
|
||||||
coilCompose = "3.4.0"
|
coilCompose = "3.5.0"
|
||||||
compose-multiplatform = "1.10.3"
|
compose-multiplatform = "1.11.1"
|
||||||
#noinspection NewerVersionAvailable
|
#noinspection NewerVersionAvailable
|
||||||
constraintlayout = "0.6.1-shaded"
|
constraintlayout = "0.8.0-shaded"
|
||||||
coreSplashscreen = "1.2.0"
|
coreSplashscreen = "1.2.0"
|
||||||
firebaseMessaging = "25.0.2"
|
firebaseMessaging = "25.1.1"
|
||||||
googleServices = "4.4.4"
|
googleServices = "4.5.0"
|
||||||
haze = "1.7.2"
|
haze = "1.7.2"
|
||||||
kotlin = "2.3.21"
|
kotlin = "2.4.10"
|
||||||
adaptiveAndroid = "1.2.0"
|
adaptiveAndroid = "1.2.0"
|
||||||
biometric = "1.4.0-alpha07"
|
biometric = "1.4.0-alpha07"
|
||||||
gson = "2.14.0"
|
gson = "2.14.0"
|
||||||
kotlinxIoBytestring = "0.9.0"
|
kotlinxIoBytestring = "0.9.1"
|
||||||
kotlinxCoroutinesCore = "1.11.0"
|
kotlinxCoroutinesCore = "1.11.0"
|
||||||
kotlinxIoCore = "0.9.0"
|
kotlinxIoCore = "0.9.1"
|
||||||
multiplatformCryptoLibsodiumBindings = "0.9.5"
|
multiplatformCryptoLibsodiumBindings = "0.9.5"
|
||||||
multiplatformSettings = "1.3.0"
|
multiplatformSettings = "1.3.0"
|
||||||
serialization = "2.3.21"
|
serialization = "2.4.10"
|
||||||
serialization-json = "1.11.0"
|
serialization-json = "1.11.0"
|
||||||
material = "1.13.0"
|
material = "1.14.0"
|
||||||
activityKtx = "1.13.0"
|
activityKtx = "1.13.0"
|
||||||
navigationCompose = "2.9.2"
|
navigationCompose = "2.9.2"
|
||||||
datastore = "1.2.1"
|
datastore = "1.2.1"
|
||||||
security-crypto = "1.1.0"
|
security-crypto = "1.1.0"
|
||||||
ktor = "3.4.3"
|
ktor = "3.5.1"
|
||||||
slf4j = "1.7.36"
|
slf4j = "1.7.36"
|
||||||
kotlinxDatetime = "0.8.0"
|
kotlinxDatetime = "0.8.0"
|
||||||
lifecycleRuntimeKtx = "2.10.0"
|
lifecycleRuntimeKtx = "2.11.0"
|
||||||
composeBom = "2026.05.00"
|
composeBom = "2026.06.01"
|
||||||
composeMaterialIconsExtended = "1.7.3"
|
composeMaterialIconsExtended = "1.7.3"
|
||||||
composeMaterial3 = "1.10.0-alpha05"
|
composeMaterial3 = "1.12.0-alpha03"
|
||||||
composeComponents = "1.10.3"
|
composeComponents = "1.11.1"
|
||||||
playServicesBase = "18.10.0"
|
playServicesBase = "18.10.0"
|
||||||
tweetnaclJava = "1.1.3"
|
tweetnaclJava = "1.1.3"
|
||||||
androidxWork = "2.11.2"
|
androidxWork = "2.11.2"
|
||||||
cryptography-kotlin = "0.6.0"
|
cryptography-kotlin = "0.6.0"
|
||||||
krypto = "4.0.10"
|
krypto = "4.0.10"
|
||||||
sqldelight = "2.3.2"
|
sqldelight = "2.3.2"
|
||||||
livekitAndroid = "2.25.2"
|
livekitAndroid = "2.27.0"
|
||||||
livekitAndroidComposeComponents = "2.3.0"
|
livekitAndroidComposeComponents = "2.4.0"
|
||||||
markdownRendererM3 = "0.41.0"
|
markdownRendererM3 = "0.43.0"
|
||||||
bouncycastle = "1.79"
|
bouncycastle = "1.85"
|
||||||
webkit = "1.14.0"
|
webkit = "1.16.0"
|
||||||
|
|
||||||
[libraries]
|
[libraries]
|
||||||
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
androidx-core-splashscreen = { module = "androidx.core:core-splashscreen", version.ref = "coreSplashscreen" }
|
||||||
@@ -87,7 +87,6 @@ ktor-client-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-
|
|||||||
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
|
||||||
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
ktor-client-logging = { module = "io.ktor:ktor-client-logging", version.ref = "ktor" }
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
|
||||||
ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" }
|
|
||||||
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
ktor-client-darwin = { module = "io.ktor:ktor-client-darwin", version.ref = "ktor" }
|
||||||
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
|
slf4j-android = { module = "org.slf4j:slf4j-android", version.ref = "slf4j" }
|
||||||
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
kotlinx-datetime = { module = "org.jetbrains.kotlinx:kotlinx-datetime", version.ref = "kotlinxDatetime" }
|
||||||
@@ -104,7 +103,6 @@ compose-foundation = { module = "org.jetbrains.compose.foundation:foundation", v
|
|||||||
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
|
compose-material3 = { module = "org.jetbrains.compose.material3:material3", version.ref = "composeMaterial3" }
|
||||||
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
compose-ui = { module = "org.jetbrains.compose.ui:ui", version.ref = "compose-multiplatform" }
|
||||||
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
|
compose-components-resources = { module = "org.jetbrains.compose.components:components-resources", version.ref = "composeComponents" }
|
||||||
compose-ui-tooling-preview = { module = "org.jetbrains.compose.ui:ui-tooling-preview", version.ref = "compose-multiplatform" }
|
|
||||||
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
|
compose-materialIconsExtended = { module = "org.jetbrains.compose.material:material-icons-extended", version.ref = "composeMaterialIconsExtended" }
|
||||||
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
|
tweetnacl-java = { module = "org.purejava:tweetnacl-java", version.ref = "tweetnaclJava" }
|
||||||
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
krypto = { module = "com.soywiz.korlibs.krypto:krypto", version.ref = "krypto" }
|
||||||
|
|||||||
+2
-5
@@ -1,9 +1,6 @@
|
|||||||
|
#Tue Jul 21 20:27:03 MSK 2026
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.5.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-9.6.1-bin.zip
|
||||||
networkTimeout=10000
|
|
||||||
retries=0
|
|
||||||
retryBackOffMs=500
|
|
||||||
validateDistributionUrl=true
|
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
zipStorePath=wrapper/dists
|
zipStorePath=wrapper/dists
|
||||||
|
|||||||
@@ -18,8 +18,7 @@ kotlin {
|
|||||||
|
|
||||||
listOf(
|
listOf(
|
||||||
iosArm64(),
|
iosArm64(),
|
||||||
iosSimulatorArm64(),
|
iosSimulatorArm64()
|
||||||
iosX64(),
|
|
||||||
).forEach {
|
).forEach {
|
||||||
it.binaries.framework {
|
it.binaries.framework {
|
||||||
baseName = "shared"
|
baseName = "shared"
|
||||||
|
|||||||
+1
-1
@@ -17,7 +17,7 @@ actual fun Clipboard.toSupport(): SupportClipboardManager {
|
|||||||
|
|
||||||
override suspend fun getText(): String? {
|
override suspend fun getText(): String? {
|
||||||
val entry = clipboard.getClipEntry() ?: return null
|
val entry = clipboard.getClipEntry() ?: return null
|
||||||
return entry.clipData?.getItemAt(0)?.text?.toString()
|
return entry.clipData.getItemAt(0)?.text?.toString()
|
||||||
}
|
}
|
||||||
|
|
||||||
override fun setTextListener(listener: (String) -> Unit) {
|
override fun setTextListener(listener: (String) -> Unit) {
|
||||||
|
|||||||
+8
-1
@@ -1,3 +1,5 @@
|
|||||||
|
@file:Suppress("DEPRECATION")
|
||||||
|
|
||||||
package com.pr0gramm3r101.utils.settings
|
package com.pr0gramm3r101.utils.settings
|
||||||
|
|
||||||
import androidx.core.content.edit
|
import androidx.core.content.edit
|
||||||
@@ -7,6 +9,12 @@ import com.pr0gramm3r101.utils.UtilsLibrary.context
|
|||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.withContext
|
import kotlinx.coroutines.withContext
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Secure prefs via EncryptedSharedPreferences.
|
||||||
|
*
|
||||||
|
* These APIs are deprecated in favor of DataStore + Tink (`datastore-tink`, DataStore 1.3+).
|
||||||
|
* Kept until that stack is stable enough to migrate auth/identity keys without risk.
|
||||||
|
*/
|
||||||
class AndroidSecureSettings : Settings {
|
class AndroidSecureSettings : Settings {
|
||||||
private val masterKey by lazy {
|
private val masterKey by lazy {
|
||||||
MasterKey.Builder(context)
|
MasterKey.Builder(context)
|
||||||
@@ -80,4 +88,3 @@ class AndroidSecureSettings : Settings {
|
|||||||
encryptedPrefs.contains(key)
|
encryptedPrefs.contains(key)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
Reference in New Issue
Block a user