feat: basic api, captcha, and login flow
whew, that's a lot
This commit is contained in:
parent
946429a2f5
commit
a2fb59c6f8
25 changed files with 605 additions and 50 deletions
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
@ -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>
|
||||
|
|
@ -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>
|
||||
|
|
@ -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)
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
)
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
|
|
@ -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())
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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()
|
||||
}
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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) { }
|
||||
17
ui/src/main/res/values/styles.xml
Normal file
17
ui/src/main/res/values/styles.xml
Normal 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>
|
||||
Loading…
Add table
Add a link
Reference in a new issue