feat: basic api, captcha, and login flow

whew, that's a lot
This commit is contained in:
Cilly Leang 2026-01-25 03:31:24 +11:00
parent 946429a2f5
commit a2fb59c6f8
Signed by: cilly
GPG key ID: 6500251E087653C9
25 changed files with 605 additions and 50 deletions

View file

@ -19,11 +19,27 @@ 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 {
@ -46,6 +62,7 @@ android {
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
}

View file

@ -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}",
)

View file

@ -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()
}

View file

@ -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<CaptchaRequest>() }
.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)
}
}

View file

@ -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

View file

@ -0,0 +1,14 @@
package moe.lava.neon.core.api.captcha
import kotlinx.serialization.Serializable
@Serializable
data class CaptchaRequest(
val captchaKey: List<String>,
val captchaService: String,
val captchaSitekey: String?,
val captchaSessionId: String?,
val captchaRqdata: String?,
val captchaRqtoken: String?,
val shouldServeInvisible: Boolean? = false,
)

View file

@ -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()
}

View file

@ -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
}

View file

@ -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()
}

View file

@ -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<CaptchaRequest, CaptchaResponse.Success>? = null,
): AuthResponse {
if (fingerprint == null) {
fingerprint = api.client.get("experiments") {
parameter("with_guild_experiments", "true")
}.body<ExperimentResponse>().fingerprint
}
suspend fun login(token: String) {
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<LoginResponse>()
logger.i { "Login success $body" }
this.token = body.token
return AuthResponse.Success(body.token)
}
suspend fun login(token: String): String {
this.token = token
return token
}
fun logout() {
token = null
}
}

View file

@ -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 = "",
)

View file

@ -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" }

View file

@ -25,6 +25,7 @@ dependencyResolutionManagement {
}
}
mavenCentral()
maven("https://jitpack.io")
}
}

View file

@ -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 {

View file

@ -17,6 +17,10 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".ui.HCaptchaActivity"
android:label="Captcha"
android:theme="@style/CaptchaTheme" />
</application>
</manifest>

View file

@ -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() {

View file

@ -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<Pair<String, CaptchaResponse>>()
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()
}
}

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M480,640Q555,640 607.5,587.5Q660,535 660,460Q660,385 607.5,332.5Q555,280 480,280Q405,280 352.5,332.5Q300,385 300,460Q300,535 352.5,587.5Q405,640 480,640ZM480,568Q435,568 403.5,536.5Q372,505 372,460Q372,415 403.5,383.5Q435,352 480,352Q525,352 556.5,383.5Q588,415 588,460Q588,505 556.5,536.5Q525,568 480,568ZM480,760Q334,760 214,678.5Q94,597 40,460Q94,323 214,241.5Q334,160 480,160Q626,160 746,241.5Q866,323 920,460Q866,597 746,678.5Q626,760 480,760ZM480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460Q480,460 480,460ZM480,680Q593,680 687.5,620.5Q782,561 832,460Q782,359 687.5,299.5Q593,240 480,240Q367,240 272.5,299.5Q178,359 128,460Q178,561 272.5,620.5Q367,680 480,680Z"/>
</vector>

View file

@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="960"
android:viewportHeight="960">
<path
android:fillColor="#000000"
android:pathData="M644,532L586,474Q595,427 559,386Q523,345 466,354L408,296Q425,288 442.5,284Q460,280 480,280Q555,280 607.5,332.5Q660,385 660,460Q660,480 656,497.5Q652,515 644,532ZM772,658L714,602Q752,573 781.5,538.5Q811,504 832,460Q782,359 688.5,299.5Q595,240 480,240Q451,240 423,244Q395,248 368,256L306,194Q347,177 390,168.5Q433,160 480,160Q631,160 749,243.5Q867,327 920,460Q897,519 859.5,569.5Q822,620 772,658ZM792,904L624,738Q589,749 553.5,754.5Q518,760 480,760Q329,760 211,676.5Q93,593 40,460Q61,407 93,361.5Q125,316 166,280L56,168L112,112L848,848L792,904ZM222,336Q193,362 169,393Q145,424 128,460Q178,561 271.5,620.5Q365,680 480,680Q500,680 519,677.5Q538,675 558,672L522,634Q511,637 501,638.5Q491,640 480,640Q405,640 352.5,587.5Q300,535 300,460Q300,449 301.5,439Q303,429 306,418L222,336ZM541,429L541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429Q541,429 541,429ZM390,504Q390,504 390,504Q390,504 390,504L390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Q390,504 390,504Z"/>
</vector>

View file

@ -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<AppUiGraph>()
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<Route.Sample> { key ->
Sample()
Sample(
onRequestLogout = {
backStack.clear()
backStack.add(Route.Login)
}
)
}
}
)

View file

@ -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)

View file

@ -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<String?>(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())
}
}
}

View file

@ -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()
}
}

View file

@ -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) { }

View file

@ -0,0 +1,17 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<style name="CaptchaTheme" parent="@style/Theme.AppCompat">
<item name="android:windowNoTitle">true</item>
<item name="android:windowBackground">@android:color/transparent</item>
<item name="android:windowContentOverlay">@null</item>
<item name="android:windowIsTranslucent">true</item>
<item name="android:windowFrame">@null</item>
<item name="android:windowIsFloating">true</item>
<item name="android:backgroundDimEnabled">true</item>
<item name="android:windowAnimationStyle">@style/Animation.AppCompat.Dialog</item>
<item name="windowActionBar">false</item>
<item name="windowActionModeOverlay">true</item>
</style>
</resources>