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

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