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:
@@ -23,5 +23,6 @@ interface OAuthRepository {
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?,
|
||||
successToken: String?
|
||||
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain>
|
||||
}
|
||||
|
||||
@@ -79,7 +79,8 @@ class OAuthRepositoryImpl(
|
||||
VkOAuthError.NEED_CAPTCHA -> {
|
||||
OAuthErrorDomain.CaptchaRequiredError(
|
||||
captchaSid = response.captchaSid.orEmpty(),
|
||||
captchaImageUrl = response.captchaImage.orEmpty()
|
||||
captchaImageUrl = response.captchaImage.orEmpty(),
|
||||
redirectUri = response.redirectUri
|
||||
)
|
||||
}
|
||||
|
||||
@@ -122,6 +123,7 @@ class OAuthRepositoryImpl(
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?,
|
||||
successToken: String?
|
||||
): ApiResult<GetSilentTokenResponse, OAuthErrorDomain> =
|
||||
withContext(Dispatchers.IO) {
|
||||
val requestModel = AuthDirectRequest(
|
||||
@@ -135,6 +137,7 @@ class OAuthRepositoryImpl(
|
||||
validationCode = validationCode,
|
||||
captchaSid = captchaSid,
|
||||
captchaKey = captchaKey,
|
||||
successToken = successToken
|
||||
)
|
||||
|
||||
oAuthService.getSilentToken(requestModel.map).mapResult(
|
||||
@@ -175,7 +178,8 @@ class OAuthRepositoryImpl(
|
||||
VkOAuthError.NEED_CAPTCHA -> {
|
||||
OAuthErrorDomain.CaptchaRequiredError(
|
||||
captchaSid = response.captchaSid.orEmpty(),
|
||||
captchaImageUrl = response.captchaImage.orEmpty()
|
||||
captchaImageUrl = response.captchaImage.orEmpty(),
|
||||
redirectUri = response.redirectUri
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -4,13 +4,32 @@ import android.content.SharedPreferences
|
||||
import androidx.core.content.edit
|
||||
import dev.meloda.fast.common.model.DarkMode
|
||||
import dev.meloda.fast.common.model.LogLevel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.flow.update
|
||||
import kotlin.properties.Delegates
|
||||
import kotlin.reflect.KClass
|
||||
|
||||
sealed class CaptchaTokenResult {
|
||||
data object Initial : CaptchaTokenResult()
|
||||
data object Null : CaptchaTokenResult()
|
||||
data object Cancelled : CaptchaTokenResult()
|
||||
data class Success(val token: String) : CaptchaTokenResult()
|
||||
}
|
||||
|
||||
object AppSettings {
|
||||
|
||||
private var preferences: SharedPreferences by Delegates.notNull()
|
||||
|
||||
private val captchaResult = MutableStateFlow<CaptchaTokenResult>(CaptchaTokenResult.Initial)
|
||||
fun getCaptchaResultFlow(): StateFlow<CaptchaTokenResult> = captchaResult.asStateFlow()
|
||||
fun setCaptchaResult(result: CaptchaTokenResult) = captchaResult.update { result }
|
||||
|
||||
private val captchaRedirectUri = MutableStateFlow<String?>(null)
|
||||
fun getCaptchaRedirectUriFlow() = captchaRedirectUri.asStateFlow()
|
||||
fun setCaptchaRedirectUri(redirectUri: String?) = captchaRedirectUri.update { redirectUri }
|
||||
|
||||
fun init(preferences: SharedPreferences) {
|
||||
this.preferences = preferences
|
||||
}
|
||||
|
||||
@@ -21,7 +21,8 @@ interface OAuthUseCase {
|
||||
password: String,
|
||||
forceSms: Boolean,
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?
|
||||
captchaSid: String? = null,
|
||||
captchaKey: String? = null,
|
||||
successToken: String? = null
|
||||
): Flow<State<GetSilentTokenResponse>>
|
||||
}
|
||||
|
||||
@@ -48,7 +48,8 @@ class OAuthUseCaseImpl(
|
||||
forceSms: Boolean,
|
||||
validationCode: String?,
|
||||
captchaSid: String?,
|
||||
captchaKey: String?
|
||||
captchaKey: String?,
|
||||
successToken: String?
|
||||
): Flow<State<GetSilentTokenResponse>> = flow {
|
||||
emit(State.Loading)
|
||||
|
||||
@@ -58,7 +59,8 @@ class OAuthUseCaseImpl(
|
||||
forceSms = forceSms,
|
||||
validationCode = validationCode,
|
||||
captchaSid = captchaSid,
|
||||
captchaKey = captchaKey
|
||||
captchaKey = captchaKey,
|
||||
successToken = successToken
|
||||
).asState()
|
||||
|
||||
emit(newState)
|
||||
|
||||
@@ -5,7 +5,7 @@ import com.squareup.moshi.JsonClass
|
||||
|
||||
@JsonClass(generateAdapter = true)
|
||||
data class VkWidgetData(
|
||||
val id: Long
|
||||
val id: Long?
|
||||
) : VkAttachmentData {
|
||||
|
||||
fun toDomain() = VkWidgetDomain(id)
|
||||
|
||||
@@ -3,7 +3,7 @@ package dev.meloda.fast.model.api.domain
|
||||
import dev.meloda.fast.model.api.data.AttachmentType
|
||||
|
||||
data class VkWidgetDomain(
|
||||
val id: Long
|
||||
val id: Long?
|
||||
) : VkAttachment {
|
||||
|
||||
override val type: AttachmentType = AttachmentType.WIDGET
|
||||
|
||||
@@ -12,7 +12,8 @@ data class AuthDirectRequest(
|
||||
val validationCode: String? = null,
|
||||
val captchaSid: String? = null,
|
||||
val captchaKey: String? = null,
|
||||
val trustedHash: String? = null
|
||||
val trustedHash: String? = null,
|
||||
val successToken: String? = null
|
||||
) {
|
||||
|
||||
val map
|
||||
@@ -31,6 +32,7 @@ data class AuthDirectRequest(
|
||||
captchaSid?.let { this["captcha_sid"] = it }
|
||||
captchaKey?.let { this["captcha_key"] = it }
|
||||
trustedHash?.let { this["trusted_hash"] = it }
|
||||
successToken?.let { this["success_token"] = it }
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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>())
|
||||
|
||||
+145
@@ -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()
|
||||
@@ -303,4 +303,6 @@
|
||||
|
||||
<string name="confirm_chat_create_with_title">Are you sure you want to create chat «%s»?</string>
|
||||
<string name="confirm_chat_create_empty_with_title">Are you sure you want to create chat «%s» only with yourself?</string>
|
||||
|
||||
<string name="title_edit_message">Edit message</string>
|
||||
</resources>
|
||||
|
||||
Reference in New Issue
Block a user