feat(auth): add web captcha handling

- replace manual captcha screen with WebView-based VK captcha flow
- handle captcha error 14 by showing the captcha overlay and retrying with success_token
- pass captcha redirect/result state through AppSettings
- remove old captcha ViewModel, navigation, validation, and DI
- add ACRA crash reporting
- add WIP message edit mode UI/state
- update Gradle wrapper, SDK config, and dependencies
This commit is contained in:
2026-05-03 05:49:16 +03:00
parent 97c59a85b6
commit df2c61d8d7
51 changed files with 776 additions and 689 deletions
@@ -16,7 +16,8 @@ sealed class OAuthErrorDomain {
data class CaptchaRequiredError(
val captchaSid: String,
val captchaImageUrl: String
val captchaImageUrl: String,
val redirectUri: String?
) : OAuthErrorDomain()
data class UserBannedError(
@@ -53,6 +53,7 @@ class ResponseConverterFactory(private val converter: JsonConverter) : Converter
},
onFailure = { failure ->
if (failure is JsonDataException) {
Log.d("ResponseBodyConverter", "convertJsonDataException: $failure")
throw ApiException(
RestApiError(
errorCode = -1,
@@ -11,6 +11,7 @@ import dev.meloda.fast.network.JsonConverter
import dev.meloda.fast.network.MoshiConverter
import dev.meloda.fast.network.OAuthResultCallFactory
import dev.meloda.fast.network.ResponseConverterFactory
import dev.meloda.fast.network.interceptor.Error14HandlingInterceptor
import dev.meloda.fast.network.interceptor.LanguageInterceptor
import dev.meloda.fast.network.interceptor.VersionInterceptor
import dev.meloda.fast.network.service.account.AccountService
@@ -45,6 +46,7 @@ val networkModule = module {
single { ChuckerInterceptor.Builder(get()).collector(get()).build() }
singleOf(::VersionInterceptor)
singleOf(::LanguageInterceptor)
singleOf(::Error14HandlingInterceptor)
single<OkHttpClient>(named("auth")) {
buildHttpClient(true)
@@ -101,6 +103,7 @@ private fun Scope.buildHttpClient(forAuth: Boolean): OkHttpClient {
addInterceptor(get(named("token_interceptor")) as Interceptor)
}
}
.addInterceptor(get<Error14HandlingInterceptor>())
.addInterceptor(get<VersionInterceptor>())
.addInterceptor(get<LanguageInterceptor>())
.addInterceptor(get<ChuckerInterceptor>())
@@ -0,0 +1,145 @@
package dev.meloda.fast.network.interceptor
import android.util.Log
import dev.meloda.fast.common.extensions.listenValue
import dev.meloda.fast.datastore.AppSettings
import dev.meloda.fast.datastore.CaptchaTokenResult
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.Job
import okhttp3.Interceptor
import okhttp3.Request
import okhttp3.Response
import org.json.JSONObject
import java.util.concurrent.Executors
import java.util.concurrent.atomic.AtomicReference
class Error14HandlingInterceptor(
// private val domains: Set<String> = emptySet(),
) : Interceptor {
private val cookie = AtomicReference<String?>(null)
private companion object {
private const val CAPTCHA_ERROR_CODE = 14
private const val CAPTCHA_ERROR_KIND = "need_captcha"
private val executor = Executors.newSingleThreadExecutor()
}
override fun intercept(chain: Interceptor.Chain): Response {
val request = chain.request().withCookie()
val response = chain.proceed(request)
response.parseCookie()
if (request.shouldSkipCaptcha()) return response
val redirectUri = response.getRedirectUri() ?: return response
val token = passCaptchaAndGetToken(redirectUri)
return chain.proceed(chain.request().withCookie().withSuccessToken(token))
}
private fun passCaptchaAndGetToken(redirectUri: String): String = synchronized(this) {
val tokenResult = AtomicReference<Result<String>>(Result.failure(Exception("No result")))
executor.submit {
AppSettings.setCaptchaRedirectUri(redirectUri)
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $redirectUri")
var job: Job? = null
job = AppSettings.getCaptchaResultFlow()
.listenValue(CoroutineScope(Dispatchers.IO)) {
Log.d("Error14Interceptor", "passCaptchaAndGetToken: $it")
if (it != CaptchaTokenResult.Initial) {
synchronized(tokenResult) {
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: SYNCHRONIZED: $it"
)
tokenResult.set(wrapResult(it))
tokenResult.notifyAll()
job?.cancel()
Log.d(
"Error14Interceptor",
"passCaptchaAndGetToken: NULL RESULT"
)
AppSettings.setCaptchaResult(CaptchaTokenResult.Initial)
AppSettings.setCaptchaRedirectUri(null)
}
}
}
}
synchronized(tokenResult) {
if (tokenResult.get().getOrNull() == null) {
tokenResult.wait()
}
Log.d("Error14Interceptor", "passCaptchaAndGetToken: GET VALUE")
tokenResult.get().getOrThrow()
}
}
private fun wrapResult(result: CaptchaTokenResult): Result<String> {
return when (result) {
// TODO: 03/05/2026, Danil Nikolaev: check again?
CaptchaTokenResult.Null -> Result.success("")
CaptchaTokenResult.Cancelled, CaptchaTokenResult.Initial -> Result.success("")
is CaptchaTokenResult.Success -> Result.success(result.token)
}
}
private fun Request.withSuccessToken(token: String): Request {
return newBuilder()
.url(url.newBuilder().addQueryParameter("success_token", token).build())
.build()
}
private fun Response.getRedirectUri(): String? {
val responseBody = JSONObject(peekBody(Long.MAX_VALUE).string())
return if (responseBody.has("error")) {
val stringError = try {
responseBody.getString("error")
} catch (ignored: Exception) {
null
}
if (stringError != null) {
if (stringError == CAPTCHA_ERROR_KIND && responseBody.has("redirect_uri")) {
responseBody.getString("redirect_uri")
} else {
null
}
} else {
val error = responseBody.getJSONObject("error")
if (error.getInt("error_code") == CAPTCHA_ERROR_CODE) {
error.getString("redirect_uri")
} else {
null
}
}
} else {
null
}
}
private fun Request.shouldSkipCaptcha(): Boolean {
return false
// return !domains.contains(url.toUrl().host) && domains.isNotEmpty()
}
private fun Response.parseCookie() {
headers("Set-Cookie").firstOrNull { it.contains("remixstlid") }?.let(cookie::set)
}
private fun Request.withCookie(): Request {
return newBuilder().apply { cookie.get()?.let { addHeader("Cookie", it) } }.build()
}
}
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.wait() = (this as Object).wait()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notify() = (this as Object).notify()
@Suppress("PLATFORM_CLASS_MAPPED_TO_KOTLIN", "NOTHING_TO_INLINE")
private inline fun Any.notifyAll() = (this as Object).notifyAll()