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

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