From a2fb59c6f89f2477c8522988554410579d7c944a Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Sun, 25 Jan 2026 03:31:24 +1100 Subject: [PATCH] feat: basic api, captcha, and login flow whew, that's a lot --- core/build.gradle.kts | 17 +++ .../neon/core/api/ApiConstants.android.kt | 10 ++ .../kotlin/moe/lava/neon/core/AppSettings.kt | 2 +- .../moe/lava/neon/core/api/ApiClient.kt | 108 ++++++++++++++ .../moe/lava/neon/core/api/ApiConstants.kt | 57 ++++++++ .../neon/core/api/captcha/CaptchaRequest.kt | 14 ++ .../neon/core/api/captcha/CaptchaResponse.kt | 6 + .../kotlin/moe/lava/neon/core/di/AppGraph.kt | 9 +- .../kotlin/moe/lava/neon/core/di/Providers.kt | 12 -- .../neon/core/repository/AuthRepository.kt | 85 ++++++++++- .../lava/neon/core/api/ApiConstants.jvm.kt | 10 ++ gradle/libs.versions.toml | 10 ++ settings.gradle.kts | 1 + ui/build.gradle.kts | 5 + ui/src/androidMain/AndroidManifest.xml | 6 +- .../kotlin/moe/lava/neon/MainActivity.kt | 5 - .../moe/lava/neon/ui/CaptchaBinder.android.kt | 133 ++++++++++++++++++ .../composeResources/drawable/visibility.xml | 9 ++ .../drawable/visibility_off.xml | 9 ++ .../commonMain/kotlin/moe/lava/neon/ui/App.kt | 10 +- .../kotlin/moe/lava/neon/ui/CaptchaBinder.kt | 7 + .../kotlin/moe/lava/neon/ui/screens/Login.kt | 89 ++++++++++-- .../kotlin/moe/lava/neon/ui/screens/Sample.kt | 16 ++- .../moe/lava/neon/ui/CaptchaBinder.jvm.kt | 8 ++ ui/src/main/res/values/styles.xml | 17 +++ 25 files changed, 605 insertions(+), 50 deletions(-) create mode 100644 core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt delete mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/di/Providers.kt create mode 100644 core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt create mode 100644 ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt create mode 100644 ui/src/commonMain/composeResources/drawable/visibility.xml create mode 100644 ui/src/commonMain/composeResources/drawable/visibility_off.xml create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt create mode 100644 ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt create mode 100644 ui/src/main/res/values/styles.xml diff --git a/core/build.gradle.kts b/core/build.gradle.kts index df6021d..5620389 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -19,13 +19,29 @@ kotlin { sourceSets { commonMain.dependencies { implementation(libs.settings) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.content.negotiation) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.serialization.kotlinx.json) + + implementation(libs.kermit) } commonTest.dependencies { implementation(libs.kotlin.test) } + jvmMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + } } } +dependencies { + coreLibraryDesugaring(libs.desugar) +} + android { namespace = "moe.lava.neon.core" compileSdk = libs.versions.android.compileSdk.get().toInt() @@ -46,6 +62,7 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } } diff --git a/core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt b/core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt new file mode 100644 index 0000000..2c719ff --- /dev/null +++ b/core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt @@ -0,0 +1,10 @@ +package moe.lava.neon.core.api + +import java.util.Locale + +@Suppress("ConstantLocale") +internal actual val platformSuperProps = PlatformProps( + device = android.os.Build.DEVICE, + systemLocale = Locale.getDefault().language, + osVersion = "${android.os.Build.VERSION.SDK_INT}", +) diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt index d7fecfe..5d5040b 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt @@ -2,7 +2,6 @@ package moe.lava.neon.core import com.russhwolf.settings.Settings import com.russhwolf.settings.nullableString -import com.russhwolf.settings.string import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn @@ -12,5 +11,6 @@ import dev.zacsweers.metro.SingleIn class AppSettings { private val settings = Settings() + var fingerprint by settings.nullableString() var token by settings.nullableString() } diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt new file mode 100644 index 0000000..cd79099 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt @@ -0,0 +1,108 @@ +package moe.lava.neon.core.api + +import co.touchlab.kermit.Logger +import dev.zacsweers.metro.AppScope +import dev.zacsweers.metro.Inject +import dev.zacsweers.metro.SingleIn +import io.ktor.client.HttpClient +import io.ktor.client.call.body +import io.ktor.client.plugins.HttpSend +import io.ktor.client.plugins.contentnegotiation.ContentNegotiation +import io.ktor.client.plugins.cookies.HttpCookies +import io.ktor.client.plugins.defaultRequest +import io.ktor.client.plugins.plugin +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.request.header +import io.ktor.client.statement.bodyAsText +import io.ktor.http.userAgent +import io.ktor.serialization.kotlinx.json.json +import io.ktor.util.appendAll +import kotlinx.serialization.ExperimentalSerializationApi +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy +import moe.lava.neon.core.api.captcha.CaptchaRequest +import moe.lava.neon.core.api.captcha.CaptchaResponse + +@SingleIn(AppScope::class) +@Inject +class ApiClient { + private val logger = Logger.withTag("neon.core.api/client") + + private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null + + fun setCaptchaHandler(handler: suspend (CaptchaRequest) -> CaptchaResponse) { + this.captchaHandler = handler + } + + @OptIn(ExperimentalSerializationApi::class) + val client = HttpClient { + expectSuccess = true + install(ContentNegotiation) { + json(Json { + namingStrategy = JsonNamingStrategy.SnakeCase + ignoreUnknownKeys = true + }) + } + install(WebSockets) + install(HttpCookies) + defaultRequest { + url("https://discord.com/api/v9/") + userAgent(ApiConstants.userAgent) + headers.appendAll(ApiConstants.baseHeaders) + } + }.apply { + plugin(HttpSend).intercept { req -> + logger.d { "Intercepting ${req.url.buildString()}" } + val call = execute(req) + if (call.response.status.value != 400) return@intercept call + logger.d { "Found 400 response: ${call.response.bodyAsText()}" } + val captchaRequest = runCatching { call.response.body() } + .getOrNull() + ?: return@intercept call + + logger.d { "Starting captcha flow for: $captchaRequest" } + + val captcha = captchaHandler + if (captcha == null) { + logger.w { "Captcha handler not found, passing through!" } + return@intercept call + } + + val solved = captcha(captchaRequest) + logger.d { "Captcha solved $solved" } + if (solved !is CaptchaResponse.Success) { + val failure = solved as CaptchaResponse.Failed + logger.w(failure.error) { "Captcha failed" } + return@intercept call + } + + logger.d { "Refiring" } + req.apply { + header("X-Captcha-Key", solved.token) + if (captchaRequest.captchaSessionId != null) { + header("X-Captcha-Session-Id", captchaRequest.captchaSessionId) + } + if (captchaRequest.captchaRqtoken != null) { + header("X-Captcha-Rqtoken", captchaRequest.captchaRqtoken) + } + }.let { execute(it) } + } + } +} + +@OptIn(ExperimentalSerializationApi::class) +fun buildApiClient() = HttpClient { + expectSuccess = true + install(ContentNegotiation) { + json(Json { + namingStrategy = JsonNamingStrategy.SnakeCase + ignoreUnknownKeys = true + }) + } + install(WebSockets) + install(HttpCookies) + defaultRequest { + url("https://discord.com/api/v9/") + headers.appendAll(ApiConstants.baseHeaders) + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt new file mode 100644 index 0000000..5c5777e --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt @@ -0,0 +1,57 @@ +package moe.lava.neon.core.api + +import kotlinx.serialization.Serializable +import kotlinx.serialization.json.Json +import java.time.ZoneId +import kotlin.io.encoding.Base64 +import kotlin.uuid.ExperimentalUuidApi +import kotlin.uuid.Uuid + +object ApiConstants { + val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray()) + val baseHeaders = mapOf( + "X-Debug-Options" to "bugReporterEnabled", + "X-Discord-Locale" to "en-US", + "X-Discord-Timezone" to ZoneId.systemDefault().id, + "X-Super-Properties" to superProps, + ) + const val userAgent = "Discord-Android/311020;RNA" +} + +// TODO: Desktop uses separate properties +@Suppress("PropertyName") +@Serializable +data class SuperProperties( + val os: String = "Android", + val browser: String = "Discord Android", + val device: String = platformSuperProps.device, + val system_locale: String = platformSuperProps.systemLocale, + val has_client_mods: Boolean = false, + val client_version: String = "311.20 - rn", + val release_channel: String = "googleRelease", + val device_vendor_id: String = storedVendorId, + val design_id: Int = 2, + val browser_user_agent: String = "", + val browser_version: String = "", + val os_version: String = platformSuperProps.osVersion, + val client_build_number: Long = 31102000334720, + val client_event_source: String? = null, + val client_launch_id: String = storedLaunchId, + // TODO: this is a random snowflake + val launch_signature: String = "1769227908736837151", + val client_app_state: String = "active", +) + +@OptIn(ExperimentalUuidApi::class) +private val storedVendorId = Uuid.random().toString().lowercase() + +@OptIn(ExperimentalUuidApi::class) +private val storedLaunchId = Uuid.random().toString().lowercase() + +internal data class PlatformProps( + val device: String, + val systemLocale: String, + val osVersion: String, +) + +internal expect val platformSuperProps: PlatformProps diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt new file mode 100644 index 0000000..4357d6d --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt @@ -0,0 +1,14 @@ +package moe.lava.neon.core.api.captcha + +import kotlinx.serialization.Serializable + +@Serializable +data class CaptchaRequest( + val captchaKey: List, + val captchaService: String, + val captchaSitekey: String?, + val captchaSessionId: String?, + val captchaRqdata: String?, + val captchaRqtoken: String?, + val shouldServeInvisible: Boolean? = false, +) diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt new file mode 100644 index 0000000..7bc9d59 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt @@ -0,0 +1,6 @@ +package moe.lava.neon.core.api.captcha + +sealed class CaptchaResponse { + data class Success(val token: String) : CaptchaResponse() + data class Failed(val error: Throwable) : CaptchaResponse() +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt index c10be50..4719db1 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt @@ -1,17 +1,16 @@ package moe.lava.neon.core.di -import com.russhwolf.settings.Settings -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.DependencyGraph import dev.zacsweers.metro.GraphExtension -import dev.zacsweers.metro.SingleIn import moe.lava.neon.core.AppSettings +import moe.lava.neon.core.api.ApiClient import moe.lava.neon.core.repository.AuthRepository import moe.lava.neon.core.repository.UserRepository @GraphExtension interface AppGraph { + val api: ApiClient + val settings: AppSettings + val auth: AuthRepository val users: UserRepository - val settings: AppSettings } diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/Providers.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/Providers.kt deleted file mode 100644 index 67f1673..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/Providers.kt +++ /dev/null @@ -1,12 +0,0 @@ -package moe.lava.neon.core.di - -import com.russhwolf.settings.Settings -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesTo -import dev.zacsweers.metro.Provides - -@ContributesTo(AppScope::class) -interface Providers { -// @Provides -// fun providesSettings(): Settings = Settings() -} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt index cf0b5b4..39310d3 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt @@ -1,20 +1,97 @@ package moe.lava.neon.core.repository +import co.touchlab.kermit.Logger import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.Inject import dev.zacsweers.metro.SingleIn +import io.ktor.client.call.body +import io.ktor.client.request.get +import io.ktor.client.request.header +import io.ktor.client.request.parameter +import io.ktor.client.request.post +import io.ktor.client.request.setBody +import io.ktor.http.ContentType +import io.ktor.http.contentType +import kotlinx.serialization.Serializable import moe.lava.neon.core.AppSettings +import moe.lava.neon.core.api.ApiClient +import moe.lava.neon.core.api.captcha.CaptchaRequest +import moe.lava.neon.core.api.captcha.CaptchaResponse + +@Serializable +private data class ExperimentResponse( + val fingerprint: String, +) + +@Serializable +private data class LoginRequest( + val login: String, + val password: String, + val undelete: Boolean = false, + val loginSource: String? = null, + val giftCodeSkuId: String? = null, +) + +@Serializable +private data class LoginResponse( + val userId: String, + val token: String, + val userSettings: UserSettings, +) { + @Serializable + data class UserSettings(val locale: String, val theme: String) +} + +sealed class AuthResponse { + data class Success(val token: String) : AuthResponse() + // TODO +// data class MFARequested() : AuthResponse() +} @Inject @SingleIn(AppScope::class) -class AuthRepository(private val settings: AppSettings) { +class AuthRepository( + private val settings: AppSettings, + private val api: ApiClient, +) { + private val logger = Logger.withTag("neon.core.repo/auth") var token by settings::token private set - suspend fun login(username: String, password: String) { - // api.login(username, password) + var fingerprint by settings::fingerprint + private set + + suspend fun login( + email: String, + password: String, + captcha: Pair? = null, + ): AuthResponse { + if (fingerprint == null) { + fingerprint = api.client.get("experiments") { + parameter("with_guild_experiments", "true") + }.body().fingerprint + } + + val res = api.client.post("auth/login") { + header("X-Fingerprint", fingerprint) + contentType(ContentType.Application.Json) + setBody(LoginRequest( + login = email, + password = password, + )) + } + val body = res.body() + logger.i { "Login success $body" } + this.token = body.token + return AuthResponse.Success(body.token) } - suspend fun login(token: String) { + + suspend fun login(token: String): String { this.token = token + return token + } + + fun logout() { + token = null } } diff --git a/core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt b/core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt new file mode 100644 index 0000000..4e2e603 --- /dev/null +++ b/core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt @@ -0,0 +1,10 @@ +package moe.lava.neon.core.api + +import java.util.Locale + +@Suppress("ConstantLocale") +internal actual val platformSuperProps = PlatformProps( + device = "", + systemLocale = Locale.getDefault().language, + osVersion = "", +) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 281001a..3d13e8a 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -13,6 +13,8 @@ androidx-nav3 = "1.0.0-alpha06" androidx-testExt = "1.3.0" composeHotReload = "1.0.0" composeMultiplatform = "1.10.0" +desugar = "2.1.5" +hcaptcha = "4.4.0" junit = "4.13.2" kermit = "2.0.8" kotlin = "2.3.0" @@ -22,8 +24,11 @@ material3-adaptive = "1.3.0-alpha03" metro = "0.10.0" settings = "1.3.0" sqldelight = "2.2.1" +ktor = "3.4.0" [libraries] +desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desugar" } +hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-testJunit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } @@ -49,6 +54,11 @@ compose-uiToolingPreview = { module = "org.jetbrains.compose.ui:ui-tooling-previ kotlinx-coroutinesSwing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" } settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" } +ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } diff --git a/settings.gradle.kts b/settings.gradle.kts index c4af316..e405915 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -25,6 +25,7 @@ dependencyResolutionManagement { } } mavenCentral() + maven("https://jitpack.io") } } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index f526867..135cbe1 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -24,6 +24,9 @@ kotlin { androidMain.dependencies { implementation(libs.compose.uiToolingPreview) implementation(libs.androidx.activity.compose) + + implementation(libs.androidx.appcompat) + implementation(libs.hcaptcha.compose) } commonMain.dependencies { implementation(project(":core")) @@ -79,11 +82,13 @@ android { compileOptions { sourceCompatibility = JavaVersion.VERSION_11 targetCompatibility = JavaVersion.VERSION_11 + isCoreLibraryDesugaringEnabled = true } } dependencies { debugImplementation(libs.compose.uiTooling) + coreLibraryDesugaring(libs.desugar) } compose.desktop { diff --git a/ui/src/androidMain/AndroidManifest.xml b/ui/src/androidMain/AndroidManifest.xml index 26403a7..a591e00 100644 --- a/ui/src/androidMain/AndroidManifest.xml +++ b/ui/src/androidMain/AndroidManifest.xml @@ -17,6 +17,10 @@ + - \ No newline at end of file + diff --git a/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt b/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt index 050f8b0..45b77df 100644 --- a/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt +++ b/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt @@ -5,12 +5,7 @@ import androidx.activity.ComponentActivity import androidx.activity.compose.setContent import androidx.activity.enableEdgeToEdge import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.compose.ui.tooling.preview.Preview -import androidx.navigation3.ui.defaultPredictivePopTransitionSpec -import dev.zacsweers.metro.Inject -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory -import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory import moe.lava.neon.ui.App class MainActivity : ComponentActivity() { diff --git a/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt b/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt new file mode 100644 index 0000000..6baa380 --- /dev/null +++ b/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt @@ -0,0 +1,133 @@ +package moe.lava.neon.ui + +import android.app.Activity +import android.content.Intent +import android.os.Build +import android.os.Bundle +import androidx.activity.compose.rememberLauncherForActivityResult +import androidx.activity.result.contract.ActivityResultContracts +import androidx.appcompat.app.AppCompatActivity +import androidx.compose.runtime.Composable +import androidx.compose.runtime.rememberCoroutineScope +import androidx.compose.ui.platform.LocalContext +import co.touchlab.kermit.Logger +import com.hcaptcha.sdk.HCaptcha +import com.hcaptcha.sdk.HCaptchaConfig +import com.hcaptcha.sdk.HCaptchaSize +import com.hcaptcha.sdk.HCaptchaTokenResponse +import com.hcaptcha.sdk.HCaptchaVerifyParams +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.launch +import moe.lava.neon.core.api.ApiClient +import moe.lava.neon.core.api.captcha.CaptchaResponse + +private val logger = Logger.withTag("neon.ui.app/captcha") + +private const val EXTRA_SITE_KEY = "extra_site_key" +private const val EXTRA_RQ_DATA = "extra_rq_data" +private const val EXTRA_IS_INVISIBLE = "extra_is_invisible" +private const val EXTRA_RESULT_TOKEN = "extra_result_token" +private const val EXTRA_RESULT_ERROR = "extra_result_error" + +@Composable +actual fun CaptchaBinder(api: ApiClient) { + val context = LocalContext.current + val queue = MutableSharedFlow>() + val scope = rememberCoroutineScope() + + val launcher = rememberLauncherForActivityResult( + contract = ActivityResultContracts.StartActivityForResult() + ) { result -> + val rq = result.data?.getStringExtra(EXTRA_RQ_DATA) + if (result.resultCode == Activity.RESULT_OK) { + val token = result.data?.getStringExtra(EXTRA_RESULT_TOKEN) + scope.launch { + if (rq == null) { + logger.e { "Captcha failed: No rq in result" } + queue.emit("" to CaptchaResponse.Failed(Throwable("No rq?"))) + } else if (token == null) { + logger.d { "Captcha failed: No token in result" } + queue.emit(rq to CaptchaResponse.Failed(Throwable("No token returned?"))) + } else { + logger.d { "Captcha success; token $token" } + queue.emit(rq to CaptchaResponse.Success(token)) + } + } + } else { + val error = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + result.data?.getSerializableExtra(EXTRA_RESULT_ERROR, Throwable::class.java) + } else { + result.data?.getSerializableExtra(EXTRA_RESULT_ERROR) as? Throwable + } + logger.e(error) { "Captcha failed" } + scope.launch { + queue.emit((rq ?: "") to CaptchaResponse.Failed(error ?: Throwable("No error returned"))) + } + } + } + + api.setCaptchaHandler { captcha -> + val intent = Intent(context, HCaptchaActivity::class.java).apply { + putExtra(EXTRA_SITE_KEY, captcha.captchaSitekey) + putExtra(EXTRA_RQ_DATA, captcha.captchaRqdata) + putExtra(EXTRA_IS_INVISIBLE, captcha.shouldServeInvisible) + } + launcher.launch(intent) + queue + .first { (rqdata) -> rqdata == captcha.captchaRqdata } + .second + } +} + +class HCaptchaActivity : AppCompatActivity() { + private val hCaptcha = HCaptcha.getClient(this) + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + val siteKey = intent.getStringExtra(EXTRA_SITE_KEY) + ?: return finishWithError("", IllegalArgumentException("Missing site key")) + + val rqData = intent.getStringExtra(EXTRA_RQ_DATA) + ?: return finishWithError("", IllegalArgumentException("Missing rq data")) + + val isInvisible = intent.getBooleanExtra(EXTRA_IS_INVISIBLE, false) + + val config = HCaptchaConfig.builder() + .siteKey(siteKey) + .size(if (isInvisible) HCaptchaSize.INVISIBLE else HCaptchaSize.NORMAL) + .build() + + val params = HCaptchaVerifyParams.builder() + .rqdata(rqData) + .build() + + hCaptcha + .addOnOpenListener { logger.d { "hCaptcha is now visible." } } + .addOnSuccessListener { finishWithSuccess(rqData, it) } + .addOnFailureListener { finishWithError(rqData, it) } + + hCaptcha.setup(config).verifyWithHCaptcha(params) + } + + private fun finishWithSuccess(rq: String, res: HCaptchaTokenResponse) { + logger.d { "Captcha success; token ${res.tokenResult}" } + val resultIntent = Intent().apply { + putExtra(EXTRA_RQ_DATA, rq) + putExtra(EXTRA_RESULT_TOKEN, res.tokenResult) + } + setResult(RESULT_OK, resultIntent) + finish() + } + + private fun finishWithError(rq: String, exception: Throwable) { + logger.e(exception) { "Captcha failed" } + val resultIntent = Intent().apply { + putExtra(EXTRA_RQ_DATA, rq) + putExtra(EXTRA_RESULT_ERROR, exception) + } + setResult(RESULT_CANCELED, resultIntent) + finish() + } +} diff --git a/ui/src/commonMain/composeResources/drawable/visibility.xml b/ui/src/commonMain/composeResources/drawable/visibility.xml new file mode 100644 index 0000000..4f806ab --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/visibility.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/composeResources/drawable/visibility_off.xml b/ui/src/commonMain/composeResources/drawable/visibility_off.xml new file mode 100644 index 0000000..521e039 --- /dev/null +++ b/ui/src/commonMain/composeResources/drawable/visibility_off.xml @@ -0,0 +1,9 @@ + + + diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt index aae905c..acb7bb8 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt @@ -15,7 +15,6 @@ import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic -import moe.lava.neon.core.di.AppGraph import moe.lava.neon.ui.di.AppUiGraph import moe.lava.neon.ui.screens.Login import moe.lava.neon.ui.screens.Sample @@ -37,11 +36,11 @@ private val config = SavedStateConfiguration { } } - @Composable fun App() { val uiGraph = createGraph() val graph = uiGraph.core + CaptchaBinder(graph.api) CompositionLocalProvider(LocalMetroViewModelFactory provides uiGraph.metroViewModelFactory) { MaterialTheme { val init = if (graph.auth.token != null) Route.Sample else Route.Login @@ -63,7 +62,12 @@ fun App() { ) } entry { key -> - Sample() + Sample( + onRequestLogout = { + backStack.clear() + backStack.add(Route.Login) + } + ) } } ) diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt new file mode 100644 index 0000000..f8e6966 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt @@ -0,0 +1,7 @@ +package moe.lava.neon.ui + +import androidx.compose.runtime.Composable +import moe.lava.neon.core.api.ApiClient + +@Composable +expect fun CaptchaBinder(api: ApiClient) diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt index 6edb676..3f246d1 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt @@ -1,9 +1,13 @@ package moe.lava.neon.ui.screens import androidx.compose.foundation.layout.Column +import androidx.compose.foundation.layout.Spacer import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.foundation.layout.height import androidx.compose.foundation.layout.safeContentPadding import androidx.compose.material3.Button +import androidx.compose.material3.Icon +import androidx.compose.material3.IconButton import androidx.compose.material3.OutlinedTextField import androidx.compose.material3.Text import androidx.compose.runtime.Composable @@ -13,16 +17,24 @@ import androidx.compose.runtime.rememberCoroutineScope import androidx.compose.runtime.saveable.rememberSaveable import androidx.compose.runtime.setValue import androidx.compose.ui.Modifier +import androidx.compose.ui.graphics.Color +import androidx.compose.ui.text.input.PasswordVisualTransformation +import androidx.compose.ui.text.input.VisualTransformation +import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import co.touchlab.kermit.Logger import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Inject import dev.zacsweers.metrox.viewmodel.ViewModelKey import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch import moe.lava.neon.core.repository.AuthRepository +import moe.lava.neon.core.repository.AuthResponse +import moe.lava.neon.resources.Res +import moe.lava.neon.resources.visibility +import moe.lava.neon.resources.visibility_off +import org.jetbrains.compose.resources.painterResource @Composable fun Login( @@ -38,22 +50,63 @@ fun Login( .fillMaxSize() ) { Text("Login!") + Spacer(Modifier.height(4.dp)) + + var email by rememberSaveable { mutableStateOf("") } + var password by rememberSaveable { mutableStateOf("") } + var passwordVisible by rememberSaveable { mutableStateOf(false) } + var failMessage by rememberSaveable { mutableStateOf(null) } + var loginEnabled by rememberSaveable { mutableStateOf(true) } - var token by rememberSaveable { mutableStateOf("") } OutlinedTextField( - value = token, - onValueChange = { token = it }, - label = { Text("Enter token") }, + value = email, + onValueChange = { email = it }, + label = { Text("Enter email") }, ) - Button(onClick = { + OutlinedTextField( + value = password, + onValueChange = { password = it }, + label = { Text("Enter password") }, + visualTransformation = if (passwordVisible) VisualTransformation.None else PasswordVisualTransformation(), + trailingIcon = { + val image = if (passwordVisible) { + Res.drawable.visibility + } else { + Res.drawable.visibility_off + } + + val description = if (passwordVisible) "Hide password" else "Show password" + + IconButton(onClick = { passwordVisible = !passwordVisible }) { + Icon(painter = painterResource(image), description) + } + } + ) + if (failMessage != null) { + Text("Login failed (${failMessage})", color = Color.Red) + } + Spacer(Modifier.height(4.dp)) + + fun login() { + loginEnabled = false scope.launch { - val res = viewModel.login(token) + val res = viewModel.login(email, password) + loginEnabled = true when (res) { - LoginViewModel.LoginResult.Failed -> {} + is LoginViewModel.LoginResult.Failed -> { failMessage = res.message } LoginViewModel.LoginResult.Success -> onSuccess() } } - }) { + } + + Button( + enabled = loginEnabled, + onClick = { + failMessage = null + loginEnabled = false + login() + } + ) { Text("Submit") } } @@ -65,17 +118,23 @@ fun Login( class LoginViewModel( private val auth: AuthRepository ) : ViewModel() { + private val logger = Logger.withTag("neon.ui.screens/login") + sealed interface LoginResult { - data object Failed : LoginResult + data class Failed(val message: String) : LoginResult + // TODO +// data class MFARequested() : LoginResult data object Success : LoginResult } - suspend fun login(token: String): LoginResult { + suspend fun login(email: String, password: String): LoginResult { return try { - auth.login(token) - LoginResult.Success - } catch(_: Throwable) { - LoginResult.Failed + when (val res = auth.login(email, password)) { + is AuthResponse.Success -> LoginResult.Success + } + } catch(e: Throwable) { + logger.e(e) { "Login failed" } + LoginResult.Failed(e.toString()) } } } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index edb321f..7ee5ad9 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt @@ -18,10 +18,8 @@ import androidx.compose.runtime.setValue import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModel -import co.touchlab.kermit.Logger import dev.zacsweers.metro.AppScope import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.ContributesTo import dev.zacsweers.metro.Inject import dev.zacsweers.metrox.viewmodel.ViewModelKey import dev.zacsweers.metrox.viewmodel.metroViewModel @@ -32,7 +30,7 @@ import moe.lava.neon.ui.Greeting import org.jetbrains.compose.resources.painterResource @Composable -fun Sample() { +fun Sample(onRequestLogout: () -> Unit) { val viewModel: SampleViewModel = metroViewModel() var showContent by remember { mutableStateOf(false) } Column( @@ -53,9 +51,15 @@ fun Sample() { ) { Image(painterResource(Res.drawable.compose_multiplatform), null) Text("Compose: $greeting") - Text("Passed token: ${viewModel.token}") + Text("Passed token: ${viewModel.token?.slice(0..10)}...") } } + Button(onClick = { + viewModel.logout() + onRequestLogout() + }) { + Text("Logout!") + } } } @@ -66,4 +70,8 @@ class SampleViewModel( private val auth: AuthRepository ) : ViewModel() { val token get() = auth.token + + fun logout() { + auth.logout() + } } diff --git a/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt new file mode 100644 index 0000000..2baf9c3 --- /dev/null +++ b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt @@ -0,0 +1,8 @@ +package moe.lava.neon.ui + +import androidx.compose.runtime.Composable +import moe.lava.neon.core.api.ApiClient + +@Composable +// TODO +actual fun CaptchaBinder(api: ApiClient) { } diff --git a/ui/src/main/res/values/styles.xml b/ui/src/main/res/values/styles.xml new file mode 100644 index 0000000..812c8fd --- /dev/null +++ b/ui/src/main/res/values/styles.xml @@ -0,0 +1,17 @@ + + + +