Compare commits
No commits in common. "fcdd237809eb2b978ec3a37e2f5c2f517fc6808b" and "53abaccd21a9258568303cc5ddd034c73aa89075" have entirely different histories.
fcdd237809
...
53abaccd21
|
|
@ -1,63 +0,0 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.androidApplication)
|
|
||||||
alias(libs.plugins.composeMultiplatform)
|
|
||||||
alias(libs.plugins.composeCompiler)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
target {
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
implementation(projects.ui)
|
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
|
||||||
implementation(libs.androidx.activity.compose)
|
|
||||||
|
|
||||||
implementation(libs.androidx.appcompat)
|
|
||||||
implementation(libs.hcaptcha.compose)
|
|
||||||
|
|
||||||
implementation(libs.ktor.client.okhttp)
|
|
||||||
|
|
||||||
implementation(project.dependencies.platform(libs.koin.bom))
|
|
||||||
implementation(libs.koin.compose)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
dependencies {
|
|
||||||
debugImplementation(libs.compose.ui.tooling)
|
|
||||||
coreLibraryDesugaring(libs.desugar)
|
|
||||||
}
|
|
||||||
|
|
||||||
android {
|
|
||||||
namespace = "moe.lava.neon"
|
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
defaultConfig {
|
|
||||||
applicationId = "moe.lava.neon"
|
|
||||||
minSdk = libs.versions.android.minSdk.get().toInt()
|
|
||||||
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
|
||||||
versionCode = 1
|
|
||||||
versionName = "1.0"
|
|
||||||
}
|
|
||||||
packaging {
|
|
||||||
resources {
|
|
||||||
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
|
||||||
}
|
|
||||||
}
|
|
||||||
buildTypes {
|
|
||||||
getByName("release") {
|
|
||||||
isMinifyEnabled = false
|
|
||||||
signingConfig = signingConfigs.getByName("debug")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
compileOptions {
|
|
||||||
sourceCompatibility = JavaVersion.VERSION_11
|
|
||||||
targetCompatibility = JavaVersion.VERSION_11
|
|
||||||
isCoreLibraryDesugaringEnabled = true
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,41 +0,0 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.androidMultiplatformLibrary)
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
androidLibrary {
|
|
||||||
namespace = "moe.lava.neon.api.gateway"
|
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
implementation(project(":api:shared"))
|
|
||||||
|
|
||||||
implementation(libs.kermit)
|
|
||||||
implementation(libs.ktor.client.core)
|
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
|
||||||
implementation(libs.ktor.client.websockets)
|
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
|
||||||
}
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
}
|
|
||||||
jvmMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.okhttp)
|
|
||||||
}
|
|
||||||
androidMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.okhttp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,7 +0,0 @@
|
||||||
package moe.lava.neon.api.gateway.handlers
|
|
||||||
|
|
||||||
import moe.lava.neon.api.gateway.Event
|
|
||||||
|
|
||||||
sealed interface Handler<T: Event.Dispatch> {
|
|
||||||
suspend fun handle(event: T)
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.androidMultiplatformLibrary)
|
|
||||||
alias(libs.plugins.kotest)
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
|
||||||
alias(libs.plugins.ksp)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
androidLibrary {
|
|
||||||
namespace = "moe.lava.neon.api.rest"
|
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
implementation(project(":api:shared"))
|
|
||||||
implementation(project(":common"))
|
|
||||||
|
|
||||||
implementation(libs.kermit)
|
|
||||||
implementation(libs.ktor.client.core)
|
|
||||||
implementation(libs.ktor.client.content.negotiation)
|
|
||||||
implementation(libs.ktor.serialization.kotlinx.json)
|
|
||||||
}
|
|
||||||
commonTest.dependencies {
|
|
||||||
implementation(libs.kotlin.test)
|
|
||||||
implementation(libs.kotest.assertions)
|
|
||||||
implementation(libs.kotest.framework)
|
|
||||||
implementation(libs.kotest.property)
|
|
||||||
implementation(libs.ktor.client.mock)
|
|
||||||
}
|
|
||||||
jvmMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.okhttp)
|
|
||||||
}
|
|
||||||
jvmTest.dependencies {
|
|
||||||
implementation(libs.kotest.runner.junit5)
|
|
||||||
}
|
|
||||||
androidMain.dependencies {
|
|
||||||
implementation(libs.ktor.client.okhttp)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
tasks.named<Test>("jvmTest") {
|
|
||||||
useJUnitPlatform()
|
|
||||||
}
|
|
||||||
|
|
||||||
//tasks.withType<Test>().configureEach {
|
|
||||||
// logger.lifecycle("UP-TO-DATE check for $name is disabled, forcing it to run.")
|
|
||||||
// outputs.upToDateWhen { false }
|
|
||||||
//}
|
|
||||||
|
|
@ -1,15 +0,0 @@
|
||||||
package moe.lava.neon.api
|
|
||||||
|
|
||||||
import io.ktor.client.call.body
|
|
||||||
import io.ktor.client.statement.HttpResponse
|
|
||||||
import io.ktor.util.reflect.TypeInfo
|
|
||||||
import io.ktor.util.reflect.typeInfo
|
|
||||||
|
|
||||||
class ApiResponse<T>(
|
|
||||||
val response: HttpResponse,
|
|
||||||
private val bodyType: TypeInfo,
|
|
||||||
) {
|
|
||||||
suspend fun body() = response.body(bodyType) as T
|
|
||||||
}
|
|
||||||
|
|
||||||
inline fun <reified T> HttpResponse.wrap() = ApiResponse<T>(this, typeInfo<T>())
|
|
||||||
|
|
@ -1,46 +0,0 @@
|
||||||
package moe.lava.neon.api.endpoints
|
|
||||||
|
|
||||||
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 kotlinx.serialization.Serializable
|
|
||||||
import moe.lava.neon.api.ApiClient
|
|
||||||
import moe.lava.neon.api.wrap
|
|
||||||
|
|
||||||
@Serializable
|
|
||||||
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
|
|
||||||
data class LoginResponse(
|
|
||||||
val userId: String,
|
|
||||||
val token: String,
|
|
||||||
val userSettings: UserSettings,
|
|
||||||
) {
|
|
||||||
@Serializable
|
|
||||||
data class UserSettings(val locale: String, val theme: String)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun ApiClient.getExperiments() = client.get("experiments") {
|
|
||||||
parameter("with_guild_experiments", "true")
|
|
||||||
}.wrap<ExperimentResponse>()
|
|
||||||
|
|
||||||
suspend fun ApiClient.login(email: String, password: String, fingerprint: String) = client.post("auth/login") {
|
|
||||||
header("X-Fingerprint", fingerprint)
|
|
||||||
setBody(LoginRequest(
|
|
||||||
login = email,
|
|
||||||
password = password,
|
|
||||||
))
|
|
||||||
}.wrap<LoginResponse>()
|
|
||||||
|
|
@ -1,30 +0,0 @@
|
||||||
package moe.lava.neon.tests.api
|
|
||||||
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import moe.lava.neon.api.ApiClient
|
|
||||||
import moe.lava.neon.api.endpoints.getExperiments
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
|
||||||
|
|
||||||
class CaptchaTest : FunSpec({
|
|
||||||
val mock = DiscordApiMock()
|
|
||||||
val client = ApiClient(mock.engine, false)
|
|
||||||
|
|
||||||
val (captchaReq, captchaRes) = mock.generateCaptcha()
|
|
||||||
mock.isCaptchaEnabled = true
|
|
||||||
|
|
||||||
test("captcha should not be handled") {
|
|
||||||
val res = client.getExperiments().response
|
|
||||||
res.status.value shouldBe 400
|
|
||||||
}
|
|
||||||
|
|
||||||
test("captcha should be handled") {
|
|
||||||
client.setCaptchaHandler { req ->
|
|
||||||
captchaRes
|
|
||||||
.takeIf { req == captchaReq }
|
|
||||||
?: CaptchaResponse.Failed(Throwable())
|
|
||||||
}
|
|
||||||
val res = client.getExperiments().response
|
|
||||||
res.status.value shouldBe 200
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,105 +0,0 @@
|
||||||
package moe.lava.neon.tests.api
|
|
||||||
|
|
||||||
import io.kotest.property.Arb
|
|
||||||
import io.kotest.property.arbitrary.long
|
|
||||||
import io.kotest.property.arbitrary.next
|
|
||||||
import io.kotest.property.arbitrary.single
|
|
||||||
import io.kotest.property.arbitrary.string
|
|
||||||
import io.kotest.property.arbitrary.stringPattern
|
|
||||||
import io.ktor.client.engine.mock.MockEngine
|
|
||||||
import io.ktor.client.engine.mock.MockRequestHandleScope
|
|
||||||
import io.ktor.client.engine.mock.respond
|
|
||||||
import io.ktor.client.engine.mock.respondError
|
|
||||||
import io.ktor.client.request.HttpResponseData
|
|
||||||
import io.ktor.http.HttpHeaders
|
|
||||||
import io.ktor.http.HttpStatusCode
|
|
||||||
import io.ktor.http.content.TextContent
|
|
||||||
import io.ktor.http.headersOf
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.JsonNamingStrategy
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
|
||||||
import moe.lava.neon.tests.api.mock.AuthResponse
|
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
|
||||||
private val JsonWithSnakecase = Json {
|
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
|
||||||
}
|
|
||||||
private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json")
|
|
||||||
|
|
||||||
val idArb = Arb.long(1e18.toLong(), 1e20.toLong())
|
|
||||||
// Good enough
|
|
||||||
val tokenArb = Arb.stringPattern("(mfa\\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,28}\\.[a-zA-Z0-9_-]{6,7}\\.[a-zA-Z0-9_-]{38})")
|
|
||||||
|
|
||||||
class DiscordApiMock {
|
|
||||||
private val fingerprints = mutableListOf<String>()
|
|
||||||
private val logins = mutableMapOf<String, String>()
|
|
||||||
private var captcha: Pair<CaptchaRequest, CaptchaResponse.Success> = generateCaptcha()
|
|
||||||
|
|
||||||
var isCaptchaEnabled = false
|
|
||||||
|
|
||||||
fun createLogin(email: String, password: String) {
|
|
||||||
logins[email] = password
|
|
||||||
}
|
|
||||||
|
|
||||||
fun generateCaptcha(): Pair<CaptchaRequest, CaptchaResponse.Success> {
|
|
||||||
val req = CaptchaRequest(
|
|
||||||
listOf(Arb.string().single()),
|
|
||||||
Arb.string().single(),
|
|
||||||
Arb.string().single(),
|
|
||||||
Arb.string().single(),
|
|
||||||
Arb.string().single(),
|
|
||||||
Arb.string().single(),
|
|
||||||
true,
|
|
||||||
)
|
|
||||||
val res = CaptchaResponse.Success(Arb.string().single())
|
|
||||||
captcha = req to res
|
|
||||||
return req to res
|
|
||||||
}
|
|
||||||
|
|
||||||
val engine = MockEngine { req ->
|
|
||||||
if (!req.url.toString().startsWith("https://discord.com/api/v9")) {
|
|
||||||
return@MockEngine respondError(HttpStatusCode.NotFound)
|
|
||||||
}
|
|
||||||
if (isCaptchaEnabled) {
|
|
||||||
if (req.headers["X-Captcha-Key"] != captcha.second.token) {
|
|
||||||
return@MockEngine respondJson(JsonWithSnakecase.encodeToString(captcha.first), HttpStatusCode.BadRequest)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val path = req.url.encodedPath.replaceFirst("/api/v9", "")
|
|
||||||
return@MockEngine when (path) {
|
|
||||||
"/experiments" -> {
|
|
||||||
val fp = Arb.string(18..20, "123456789").single()
|
|
||||||
fingerprints.add(fp)
|
|
||||||
respondJson(AuthResponse.Experiments(fp))
|
|
||||||
}
|
|
||||||
"/auth/login" -> {
|
|
||||||
val body = req.body as? TextContent
|
|
||||||
?: return@MockEngine badReq("No body")
|
|
||||||
val json = Json.parseToJsonElement(body.text).jsonObject
|
|
||||||
val login = json["login"]?.jsonPrimitive?.content
|
|
||||||
?: return@MockEngine badReq("No login")
|
|
||||||
val password = json["password"]?.jsonPrimitive?.content
|
|
||||||
?: return@MockEngine badReq("No password")
|
|
||||||
|
|
||||||
if (logins[login] != password) {
|
|
||||||
return@MockEngine badReq("Unknown credentials")
|
|
||||||
}
|
|
||||||
|
|
||||||
respondJson(AuthResponse.Login(idArb.next(), tokenArb.next()))
|
|
||||||
}
|
|
||||||
else -> respondError(HttpStatusCode.NotFound)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
private inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData =
|
|
||||||
respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg")
|
|
||||||
|
|
||||||
@Suppress("NOTHING_TO_INLINE")
|
|
||||||
private inline fun MockRequestHandleScope.respondJson(content: String, status: HttpStatusCode = HttpStatusCode.OK): HttpResponseData =
|
|
||||||
respond(content = content, status = status, headers = JsonHeader)
|
|
||||||
}
|
|
||||||
|
|
@ -1,84 +0,0 @@
|
||||||
package moe.lava.neon.tests.api
|
|
||||||
|
|
||||||
import io.kotest.assertions.throwables.shouldNotThrowAny
|
|
||||||
import io.kotest.assertions.withClue
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.core.spec.style.funSpec
|
|
||||||
import io.kotest.core.spec.style.scopes.FunSpecContainerScope
|
|
||||||
import io.kotest.matchers.booleans.shouldBeTrue
|
|
||||||
import io.kotest.matchers.collections.shouldBeOneOf
|
|
||||||
import io.kotest.matchers.collections.shouldContainAllInAnyOrder
|
|
||||||
import io.kotest.matchers.nulls.shouldNotBeNull
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import io.ktor.client.statement.request
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import moe.lava.neon.api.ApiResponse
|
|
||||||
import kotlin.io.encoding.Base64
|
|
||||||
|
|
||||||
fun <T> endpointTestFactory(
|
|
||||||
endpoint: String,
|
|
||||||
testName: String?,
|
|
||||||
response: suspend () -> ApiResponse<T>,
|
|
||||||
also: suspend FunSpecContainerScope.(response: ApiResponse<T>) -> Unit,
|
|
||||||
) = funSpec {
|
|
||||||
context(testName ?: endpoint) {
|
|
||||||
val res = response()
|
|
||||||
val req = res.response.request
|
|
||||||
|
|
||||||
test("has correct base url") {
|
|
||||||
req.url.toString().startsWith("https://discord.com/api/v9/") shouldBe true
|
|
||||||
}
|
|
||||||
test("uses correct endpoint") {
|
|
||||||
req.url.encodedPath.replace("/api/v9/", "") shouldBe endpoint
|
|
||||||
}
|
|
||||||
test("has valid super props") {
|
|
||||||
val props = req.headers["X-Super-Properties"]
|
|
||||||
props.shouldNotBeNull()
|
|
||||||
val decoded = withClue("should be decodable") {
|
|
||||||
shouldNotThrowAny {
|
|
||||||
Base64.decode(props).decodeToString()
|
|
||||||
}
|
|
||||||
}
|
|
||||||
val parsed = withClue("should be parsable") {
|
|
||||||
shouldNotThrowAny {
|
|
||||||
Json.parseToJsonElement(decoded).jsonObject
|
|
||||||
}
|
|
||||||
}
|
|
||||||
withClue("has props") {
|
|
||||||
parsed.keys shouldContainAllInAnyOrder setOf(
|
|
||||||
"os",
|
|
||||||
"browser",
|
|
||||||
"browser_user_agent",
|
|
||||||
"browser_version",
|
|
||||||
"client_build_number",
|
|
||||||
"release_channel",
|
|
||||||
"system_locale",
|
|
||||||
)
|
|
||||||
}
|
|
||||||
val userAgent = withClue("has valid user agent") {
|
|
||||||
val agent = parsed["browser_user_agent"]?.jsonPrimitive
|
|
||||||
agent?.isString.shouldBeTrue()
|
|
||||||
agent.content
|
|
||||||
}
|
|
||||||
withClue("has matching user agent") {
|
|
||||||
userAgent shouldBeOneOf setOf(req.headers["User-Agent"], "")
|
|
||||||
}
|
|
||||||
}
|
|
||||||
test("has correct body") {
|
|
||||||
shouldNotThrowAny { res.body() }
|
|
||||||
}
|
|
||||||
also(res)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
fun <T> FunSpec.withFactory(
|
|
||||||
testName: String? = null,
|
|
||||||
endpoint: String,
|
|
||||||
response: suspend () -> ApiResponse<T>,
|
|
||||||
also: suspend FunSpecContainerScope.(response: ApiResponse<T>) -> Unit = {},
|
|
||||||
) {
|
|
||||||
include(endpointTestFactory(endpoint, testName, response, also))
|
|
||||||
}
|
|
||||||
|
|
||||||
|
|
@ -1,54 +0,0 @@
|
||||||
package moe.lava.neon.tests.api.endpoints
|
|
||||||
|
|
||||||
import io.kotest.assertions.throwables.shouldNotThrowAny
|
|
||||||
import io.kotest.core.spec.style.FunSpec
|
|
||||||
import io.kotest.matchers.shouldBe
|
|
||||||
import io.ktor.client.statement.request
|
|
||||||
import io.ktor.http.content.TextContent
|
|
||||||
import kotlinx.serialization.json.Json
|
|
||||||
import kotlinx.serialization.json.jsonObject
|
|
||||||
import kotlinx.serialization.json.jsonPrimitive
|
|
||||||
import moe.lava.neon.api.ApiClient
|
|
||||||
import moe.lava.neon.api.endpoints.getExperiments
|
|
||||||
import moe.lava.neon.api.endpoints.login
|
|
||||||
import moe.lava.neon.tests.api.DiscordApiMock
|
|
||||||
import moe.lava.neon.tests.api.withFactory
|
|
||||||
|
|
||||||
class AuthTest : FunSpec({
|
|
||||||
val mock = DiscordApiMock()
|
|
||||||
val client = ApiClient(mock.engine, false)
|
|
||||||
|
|
||||||
var fp: String? = null
|
|
||||||
withFactory(
|
|
||||||
testName = "get experiments",
|
|
||||||
endpoint = "experiments",
|
|
||||||
response = client::getExperiments
|
|
||||||
) { res ->
|
|
||||||
val body = res.body()
|
|
||||||
fp = body.fingerprint
|
|
||||||
}
|
|
||||||
|
|
||||||
val email = "hello@example.com"
|
|
||||||
val password = "supersecurepassword"
|
|
||||||
mock.createLogin(email, password)
|
|
||||||
withFactory(
|
|
||||||
testName = "login with real creds",
|
|
||||||
endpoint = "auth/login",
|
|
||||||
response = { client.login(email, password, fp!!) }
|
|
||||||
) { res ->
|
|
||||||
val req = res.response.request
|
|
||||||
val headers = req.headers
|
|
||||||
test("has correct fingerprint") {
|
|
||||||
headers["X-Fingerprint"] shouldBe fp
|
|
||||||
}
|
|
||||||
context("has correct body") {
|
|
||||||
val body = shouldNotThrowAny { Json.parseToJsonElement((req.content as TextContent).text).jsonObject }
|
|
||||||
test("has correct login") {
|
|
||||||
body["login"]?.jsonPrimitive?.content shouldBe email
|
|
||||||
}
|
|
||||||
test("has correct password") {
|
|
||||||
body["password"]?.jsonPrimitive?.content shouldBe password
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
})
|
|
||||||
|
|
@ -1,6 +0,0 @@
|
||||||
package moe.lava.neon.tests.api.mock
|
|
||||||
|
|
||||||
object AuthResponse {
|
|
||||||
fun Experiments(fp: String) = """{"fingerprint":"$fp","assignments":[[3643362751,0,1,-1,0,4342,0,0,null,null,null],[1428438599,0,1,-1,2,3832,0,0,null,null,null],[1567199723,0,1,-1,1,1775,0,0,null,null,null],[4179344527,3,1,-1,0,3584,0,0,null,null,null],[1814483290,0,1,-1,0,6281,0,0,null,null,null],[4221006726,0,1,-1,0,4318,0,0,null,null,null],[1398673921,1,1,-1,0,4649,0,0,null,null,null],[1034661306,0,1,-1,0,1792,0,0,null,null,null],[3124003316,1,1,-1,0,1427,0,0,null,null,null],[2676348506,0,1,-1,0,4412,0,0,null,null,null],[4136574802,0,1,-1,1,4337,0,0,null,null,null],[4049571159,0,1,-1,0,504,0,0,null,null,null],[2539540256,0,1,-1,3,25,0,0,null,null,null],[1549543958,2,1,-1,0,6992,0,0,null,null,null],[1333727,0,1,-1,0,4978,0,0,null,null,null],[3029387945,1,1,-1,0,8670,0,0,null,null,null],[738080167,0,1,-1,1,9441,0,0,null,null,null],[3283745071,0,1,-1,1,9135,0,0,null,null,null],[373531156,0,3,-1,0,7954,0,0,null,null,null],[1617749743,0,1,-1,0,8281,0,0,null,null,null],[288968706,0,1,-1,3,7867,0,0,null,null,null],[2091202574,0,1,-1,0,8443,0,0,null,null,null],[4265918989,0,1,-1,0,2580,0,0,null,null,null],[1083932689,0,1,-1,0,6167,0,0,null,null,null],[1884426471,0,1,-1,0,5258,0,0,null,null,null],[2180379513,0,1,-1,4,5727,0,0,null,null,null],[759064140,0,1,-1,0,6050,0,0,null,null,null],[1680860120,0,1,-1,1,6633,0,0,null,null,null],[151550492,0,1,-1,2,102,0,1,null,null,null],[2054293512,0,0,-1,0,193,0,1,null,null,null],[2848826960,0,1,-1,0,7093,0,0,null,null,null],[3775594731,3,1,-1,0,1418,0,0,null,null,null],[878040044,1,1,-1,0,6738,0,1,null,null,null],[2990331215,0,1,-1,0,2786,0,0,null,null,null],[996399186,2,1,-1,0,5255,0,0,null,null,null],[3173338335,0,0,-1,0,178,0,1,null,null,null],[640084831,3,1,-1,0,5284,0,0,null,null,null],[1112953678,0,1,-1,0,3640,0,0,null,null,null],[4285324985,0,1,-1,0,8618,0,0,null,null,null],[4206392105,4,1,-1,0,5758,0,0,null,null,null],[1714347921,0,1,-1,0,2255,0,0,null,null,null],[3936291300,3,1,-1,0,3408,0,0,null,null,null],[2660711063,0,1,-1,0,8317,0,0,null,null,null],[1644303758,0,1,-1,0,2365,0,1,null,null,null],[114771571,0,1,-1,0,796,0,0,null,null,null],[437074334,5,1,-1,2,2482,0,1,null,null,null],[1978990512,3,1,-1,0,9101,0,0,null,null,null],[3378028029,3,2,-1,0,1997,0,1,null,null,null],[1046173986,0,1,-1,0,9264,0,0,null,null,null],[1757800499,1,1,-1,0,9654,0,0,null,null,null],[2849514387,0,1,-1,0,275,0,1,null,null,null],[2613104049,0,1,-1,0,8339,0,1,null,null,null],[2482010813,0,1,-1,0,4372,0,1,null,null,null],[1778984745,0,1,-1,0,1685,0,1,null,null,null],[2870923171,0,1,-1,0,101,0,0,null,null,null],[1598219105,1,2,-1,0,3159,0,1,null,null,null],[641666131,1,1,-1,0,2501,0,0,null,null,null]]}"""
|
|
||||||
fun Login(userId: Long, token: String) = """{"user_id":"$userId","token":"$token","user_settings":{"locale":"en-US","theme":"dark"}}"""
|
|
||||||
}
|
|
||||||
|
|
@ -1,26 +0,0 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
|
||||||
|
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.androidMultiplatformLibrary)
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
androidLibrary {
|
|
||||||
namespace = "moe.lava.neon.api"
|
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
compilerOptions {
|
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
implementation(libs.kotlinx.serialization.json)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -2,14 +2,12 @@ plugins {
|
||||||
// this is necessary to avoid the plugins to be loaded multiple times
|
// this is necessary to avoid the plugins to be loaded multiple times
|
||||||
// in each subproject's classloader
|
// in each subproject's classloader
|
||||||
alias(libs.plugins.androidApplication) apply false
|
alias(libs.plugins.androidApplication) apply false
|
||||||
alias(libs.plugins.androidMultiplatformLibrary) apply false
|
alias(libs.plugins.androidLibrary) apply false
|
||||||
alias(libs.plugins.composeHotReload) apply false
|
alias(libs.plugins.composeHotReload) apply false
|
||||||
alias(libs.plugins.composeMultiplatform) apply false
|
alias(libs.plugins.composeMultiplatform) apply false
|
||||||
alias(libs.plugins.composeCompiler) apply false
|
alias(libs.plugins.composeCompiler) apply false
|
||||||
alias(libs.plugins.koinCompiler) apply false
|
|
||||||
alias(libs.plugins.kotest) apply false
|
|
||||||
alias(libs.plugins.kotlinMultiplatform) apply false
|
alias(libs.plugins.kotlinMultiplatform) apply false
|
||||||
alias(libs.plugins.kotlinSerialization) apply false
|
alias(libs.plugins.kotlinSerialization) apply false
|
||||||
alias(libs.plugins.ksp) apply false
|
alias(libs.plugins.metro) apply false
|
||||||
alias(libs.plugins.sqldelight) apply false
|
alias(libs.plugins.sqldelight) apply false
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,14 +0,0 @@
|
||||||
plugins {
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
|
||||||
alias(libs.plugins.kotlinSerialization)
|
|
||||||
}
|
|
||||||
|
|
||||||
kotlin {
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
sourceSets {
|
|
||||||
commonMain.dependencies {
|
|
||||||
implementation(libs.kotlinx.serialization.core)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,33 +1,27 @@
|
||||||
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
|
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.androidMultiplatformLibrary)
|
alias(libs.plugins.androidLibrary)
|
||||||
alias(libs.plugins.koinCompiler)
|
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
|
alias(libs.plugins.metro)
|
||||||
alias(libs.plugins.sqldelight)
|
alias(libs.plugins.sqldelight)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidLibrary {
|
jvm()
|
||||||
namespace = "moe.lava.neon.core"
|
androidTarget {
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
jvm()
|
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":api:gateway"))
|
implementation(libs.ktor.client.core)
|
||||||
implementation(project(":api:rest"))
|
implementation(libs.ktor.client.content.negotiation)
|
||||||
implementation(project(":common"))
|
implementation(libs.ktor.client.websockets)
|
||||||
|
implementation(libs.ktor.serialization.kotlinx.json)
|
||||||
implementation(project.dependencies.platform(libs.koin.bom))
|
|
||||||
implementation(libs.koin.core)
|
|
||||||
|
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
implementation(libs.settings)
|
implementation(libs.settings)
|
||||||
|
|
@ -35,6 +29,40 @@ kotlin {
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
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()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
package moe.lava.neon.core
|
||||||
|
|
||||||
|
import android.os.Build
|
||||||
|
|
||||||
|
class AndroidPlatform : Platform {
|
||||||
|
override val name: String = "Android ${Build.VERSION.SDK_INT}"
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getPlatform(): Platform = AndroidPlatform()
|
||||||
|
|
@ -1,14 +1,12 @@
|
||||||
package moe.lava.neon.api
|
package moe.lava.neon.core.api
|
||||||
|
|
||||||
import android.annotation.SuppressLint
|
|
||||||
import android.os.Build
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
@SuppressLint("ConstantLocale")
|
@Suppress("ConstantLocale")
|
||||||
internal actual val platformSuperProps = PlatformProps(
|
internal actual val platformSuperProps = PlatformProps(
|
||||||
device = Build.DEVICE,
|
device = android.os.Build.DEVICE,
|
||||||
// TODO: this only outputs language but not country (e.g. en instead of en-AU)
|
// TODO: this only outputs language but not country (e.g. en instead of en-AU)
|
||||||
// .toLanguageTag() is close, but returns too much junk (e.g. en-AU-u-fw-mon)
|
// .toLanguageTag() is close, but returns too much junk (e.g. en-AU-u-fw-mon)
|
||||||
systemLocale = Locale.getDefault().language,
|
systemLocale = Locale.getDefault().language,
|
||||||
osVersion = "${Build.VERSION.SDK_INT}",
|
osVersion = "${android.os.Build.VERSION.SDK_INT}",
|
||||||
)
|
)
|
||||||
|
|
@ -2,8 +2,13 @@ package moe.lava.neon.core
|
||||||
|
|
||||||
import com.russhwolf.settings.Settings
|
import com.russhwolf.settings.Settings
|
||||||
import com.russhwolf.settings.nullableString
|
import com.russhwolf.settings.nullableString
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
|
||||||
internal class AppSettings {
|
@SingleIn(AppScope::class)
|
||||||
|
@Inject
|
||||||
|
class AppSettings {
|
||||||
private val settings = Settings()
|
private val settings = Settings()
|
||||||
|
|
||||||
var fingerprint by settings.nullableString()
|
var fingerprint by settings.nullableString()
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package moe.lava.neon.core
|
||||||
|
|
||||||
|
interface Platform {
|
||||||
|
val name: String
|
||||||
|
}
|
||||||
|
|
||||||
|
expect fun getPlatform(): Platform
|
||||||
|
|
@ -1,9 +1,11 @@
|
||||||
package moe.lava.neon.api
|
package moe.lava.neon.core.api
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
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.HttpClient
|
||||||
import io.ktor.client.call.body
|
import io.ktor.client.call.body
|
||||||
import io.ktor.client.engine.HttpClientEngine
|
|
||||||
import io.ktor.client.plugins.HttpSend
|
import io.ktor.client.plugins.HttpSend
|
||||||
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
|
||||||
import io.ktor.client.plugins.cookies.HttpCookies
|
import io.ktor.client.plugins.cookies.HttpCookies
|
||||||
|
|
@ -12,21 +14,18 @@ import io.ktor.client.plugins.plugin
|
||||||
import io.ktor.client.plugins.websocket.WebSockets
|
import io.ktor.client.plugins.websocket.WebSockets
|
||||||
import io.ktor.client.request.header
|
import io.ktor.client.request.header
|
||||||
import io.ktor.client.statement.bodyAsText
|
import io.ktor.client.statement.bodyAsText
|
||||||
import io.ktor.http.ContentType
|
|
||||||
import io.ktor.http.contentType
|
|
||||||
import io.ktor.http.userAgent
|
import io.ktor.http.userAgent
|
||||||
import io.ktor.serialization.kotlinx.json.json
|
import io.ktor.serialization.kotlinx.json.json
|
||||||
import io.ktor.util.appendAll
|
import io.ktor.util.appendAll
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
import kotlinx.serialization.json.Json
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
import kotlinx.serialization.json.JsonNamingStrategy
|
||||||
|
import moe.lava.neon.core.api.captcha.CaptchaRequest
|
||||||
class ApiClient internal constructor(
|
import moe.lava.neon.core.api.captcha.CaptchaResponse
|
||||||
engine: HttpClientEngine,
|
|
||||||
assertSuccess: Boolean,
|
|
||||||
) {
|
|
||||||
constructor() : this(HttpClient().engine, true)
|
|
||||||
|
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
@Inject
|
||||||
|
class ApiClient {
|
||||||
private val logger = Logger.withTag("neon.core.api/client")
|
private val logger = Logger.withTag("neon.core.api/client")
|
||||||
|
|
||||||
private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null
|
private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null
|
||||||
|
|
@ -36,8 +35,8 @@ class ApiClient internal constructor(
|
||||||
}
|
}
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
internal val client = HttpClient(engine) {
|
val client = HttpClient {
|
||||||
expectSuccess = assertSuccess
|
expectSuccess = true
|
||||||
install(ContentNegotiation) {
|
install(ContentNegotiation) {
|
||||||
json(ApiConstants.json)
|
json(ApiConstants.json)
|
||||||
}
|
}
|
||||||
|
|
@ -45,7 +44,6 @@ class ApiClient internal constructor(
|
||||||
install(HttpCookies)
|
install(HttpCookies)
|
||||||
defaultRequest {
|
defaultRequest {
|
||||||
url("https://discord.com/api/v9/")
|
url("https://discord.com/api/v9/")
|
||||||
contentType(ContentType.Application.Json)
|
|
||||||
userAgent(ApiConstants.userAgent)
|
userAgent(ApiConstants.userAgent)
|
||||||
headers.appendAll(ApiConstants.baseHeaders)
|
headers.appendAll(ApiConstants.baseHeaders)
|
||||||
}
|
}
|
||||||
|
|
@ -53,7 +51,6 @@ class ApiClient internal constructor(
|
||||||
plugin(HttpSend).intercept { req ->
|
plugin(HttpSend).intercept { req ->
|
||||||
logger.d { "Intercepting ${req.url.buildString()}" }
|
logger.d { "Intercepting ${req.url.buildString()}" }
|
||||||
val call = execute(req)
|
val call = execute(req)
|
||||||
logger.d { "recv ${call.response.bodyAsText()}" }
|
|
||||||
if (call.response.status.value != 400) return@intercept call
|
if (call.response.status.value != 400) return@intercept call
|
||||||
logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
|
logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
|
||||||
val captchaRequest = runCatching { call.response.body<CaptchaRequest>() }
|
val captchaRequest = runCatching { call.response.body<CaptchaRequest>() }
|
||||||
|
|
@ -89,3 +86,20 @@ class ApiClient internal constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.api
|
package moe.lava.neon.core.api
|
||||||
|
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
@ -24,14 +24,7 @@ internal data class PlatformProps(
|
||||||
internal expect val platformSuperProps: PlatformProps
|
internal expect val platformSuperProps: PlatformProps
|
||||||
|
|
||||||
object ApiConstants {
|
object ApiConstants {
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray())
|
||||||
val json = Json {
|
|
||||||
namingStrategy = JsonNamingStrategy.SnakeCase
|
|
||||||
ignoreUnknownKeys = true
|
|
||||||
encodeDefaults = true
|
|
||||||
}
|
|
||||||
|
|
||||||
val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray())
|
|
||||||
val baseHeaders = mapOf(
|
val baseHeaders = mapOf(
|
||||||
"X-Debug-Options" to "bugReporterEnabled",
|
"X-Debug-Options" to "bugReporterEnabled",
|
||||||
"X-Discord-Locale" to "en-US",
|
"X-Discord-Locale" to "en-US",
|
||||||
|
|
@ -41,6 +34,13 @@ object ApiConstants {
|
||||||
const val userAgent = "Discord-Android/311020;RNA"
|
const val userAgent = "Discord-Android/311020;RNA"
|
||||||
const val gatewayUserAgent = "okhttp/4.12.0"
|
const val gatewayUserAgent = "okhttp/4.12.0"
|
||||||
|
|
||||||
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
|
val json = Json {
|
||||||
|
namingStrategy = JsonNamingStrategy.SnakeCase
|
||||||
|
ignoreUnknownKeys = true
|
||||||
|
encodeDefaults = true
|
||||||
|
}
|
||||||
|
|
||||||
// TODO: Desktop uses separate properties
|
// TODO: Desktop uses separate properties
|
||||||
@Suppress("PropertyName")
|
@Suppress("PropertyName")
|
||||||
@Serializable
|
@Serializable
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.common.captcha
|
package moe.lava.neon.core.api.captcha
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.common.captcha
|
package moe.lava.neon.core.api.captcha
|
||||||
|
|
||||||
sealed class CaptchaResponse {
|
sealed class CaptchaResponse {
|
||||||
data class Success(val token: String) : CaptchaResponse()
|
data class Success(val token: String) : CaptchaResponse()
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
@Suppress("unused")
|
@Suppress("unused")
|
||||||
object Capability {
|
object Capability {
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
import io.ktor.websocket.CloseReason
|
import io.ktor.websocket.CloseReason
|
||||||
|
|
||||||
|
|
@ -1,22 +1,22 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
import kotlinx.coroutines.CoroutineScope
|
import kotlinx.coroutines.CoroutineScope
|
||||||
import kotlinx.coroutines.Dispatchers
|
import kotlinx.coroutines.Dispatchers
|
||||||
import kotlinx.coroutines.delay
|
import kotlinx.coroutines.delay
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
|
||||||
import kotlinx.coroutines.flow.asSharedFlow
|
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import kotlinx.serialization.ExperimentalSerializationApi
|
import kotlinx.serialization.ExperimentalSerializationApi
|
||||||
import moe.lava.neon.api.gateway.handlers.Handler
|
import moe.lava.neon.core.di.EventHandlerGraph
|
||||||
|
import moe.lava.neon.core.repository.AuthRepository
|
||||||
import kotlin.math.pow
|
import kotlin.math.pow
|
||||||
import kotlin.reflect.KClass
|
|
||||||
import kotlin.time.Duration.Companion.seconds
|
import kotlin.time.Duration.Companion.seconds
|
||||||
|
|
||||||
class GatewayHandler {
|
@Inject
|
||||||
private val mEvents = MutableSharedFlow<Event.Dispatch>()
|
class GatewayHandler(
|
||||||
val events = mEvents.asSharedFlow()
|
private val auth: AuthRepository,
|
||||||
|
private val handlers: EventHandlerGraph,
|
||||||
|
) {
|
||||||
private val logger = Logger.withTag("neon.core.api.gateway/handler")
|
private val logger = Logger.withTag("neon.core.api.gateway/handler")
|
||||||
private val scope = CoroutineScope(Dispatchers.IO)
|
private val scope = CoroutineScope(Dispatchers.IO)
|
||||||
private var session: GatewaySession? = null
|
private var session: GatewaySession? = null
|
||||||
|
|
@ -25,20 +25,22 @@ class GatewayHandler {
|
||||||
private var retryAttempts: Int = 0
|
private var retryAttempts: Int = 0
|
||||||
|
|
||||||
@OptIn(ExperimentalSerializationApi::class)
|
@OptIn(ExperimentalSerializationApi::class)
|
||||||
suspend fun connect(token: String) {
|
suspend fun connect() {
|
||||||
if (session != null) {
|
if (session != null) {
|
||||||
logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." }
|
logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." }
|
||||||
return
|
return
|
||||||
}
|
}
|
||||||
|
val token = auth.token
|
||||||
|
?: throw IllegalStateException("Tried to connect to gateway with no token")
|
||||||
|
|
||||||
session = GatewaySession.start(
|
session = GatewaySession.start(
|
||||||
token = token,
|
token = token,
|
||||||
|
eventHandlers = handlers,
|
||||||
resumeProps = resumeProps,
|
resumeProps = resumeProps,
|
||||||
onSuccess = {
|
onSuccess = {
|
||||||
logger.d { "Successful session start" }
|
logger.d { "Successful session start" }
|
||||||
retryAttempts = 0
|
retryAttempts = 0
|
||||||
},
|
},
|
||||||
onDispatch = { scope.launch { mEvents.emit(it) } },
|
|
||||||
onDestroy = { reason, resumeProps ->
|
onDestroy = { reason, resumeProps ->
|
||||||
session = null
|
session = null
|
||||||
|
|
||||||
|
|
@ -59,7 +61,7 @@ class GatewayHandler {
|
||||||
logger.d { "Reconnecting in ${dur.inWholeMilliseconds}ms" }
|
logger.d { "Reconnecting in ${dur.inWholeMilliseconds}ms" }
|
||||||
delay(dur)
|
delay(dur)
|
||||||
retryAttempts += 1
|
retryAttempts += 1
|
||||||
res = runCatching { connect(token) }
|
res = runCatching { connect() }
|
||||||
res.exceptionOrNull()?.let {
|
res.exceptionOrNull()?.let {
|
||||||
logger.e(it) { "Reconnect failed" }
|
logger.e(it) { "Reconnect failed" }
|
||||||
}
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import io.ktor.client.HttpClient
|
import io.ktor.client.HttpClient
|
||||||
|
|
@ -22,19 +22,20 @@ import kotlinx.coroutines.flow.onCompletion
|
||||||
import kotlinx.coroutines.flow.onEach
|
import kotlinx.coroutines.flow.onEach
|
||||||
import kotlinx.coroutines.isActive
|
import kotlinx.coroutines.isActive
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import moe.lava.neon.api.ApiConstants
|
import moe.lava.neon.core.api.ApiConstants
|
||||||
import moe.lava.neon.api.ApiConstants.json
|
import moe.lava.neon.core.api.ApiConstants.json
|
||||||
|
import moe.lava.neon.core.di.EventHandlerGraph
|
||||||
import kotlin.random.Random
|
import kotlin.random.Random
|
||||||
import kotlin.time.Duration.Companion.milliseconds
|
import kotlin.time.Duration.Companion.milliseconds
|
||||||
|
|
||||||
private val logger = Logger.withTag("neon.core.api.gateway/session")
|
private val logger = Logger.withTag("neon.core.api.gateway/session")
|
||||||
|
|
||||||
internal class GatewaySession private constructor(
|
class GatewaySession private constructor(
|
||||||
private var ws: DefaultClientWebSocketSession,
|
private var ws: DefaultClientWebSocketSession,
|
||||||
private val token: String,
|
private val token: String,
|
||||||
|
private val handlers: EventHandlerGraph,
|
||||||
private val scope: CoroutineScope,
|
private val scope: CoroutineScope,
|
||||||
private var resumeProps: ResumeProperties?,
|
private var resumeProps: ResumeProperties?,
|
||||||
private val onDispatch: (Event.Dispatch) -> Unit,
|
|
||||||
private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
|
private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
|
||||||
private val onSuccess: () -> Unit,
|
private val onSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
|
|
@ -45,13 +46,13 @@ internal class GatewaySession private constructor(
|
||||||
companion object {
|
companion object {
|
||||||
suspend fun start(
|
suspend fun start(
|
||||||
token: String,
|
token: String,
|
||||||
|
eventHandlers: EventHandlerGraph,
|
||||||
client: HttpClient = HttpClient {
|
client: HttpClient = HttpClient {
|
||||||
install(HttpCookies)
|
install(HttpCookies)
|
||||||
install(WebSockets)
|
install(WebSockets)
|
||||||
},
|
},
|
||||||
scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
|
scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
|
||||||
resumeProps: ResumeProperties? = null,
|
resumeProps: ResumeProperties? = null,
|
||||||
onDispatch: (Event.Dispatch) -> Unit,
|
|
||||||
onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
|
onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
|
||||||
onSuccess: () -> Unit,
|
onSuccess: () -> Unit,
|
||||||
): GatewaySession {
|
): GatewaySession {
|
||||||
|
|
@ -66,7 +67,7 @@ internal class GatewaySession private constructor(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return GatewaySession(ws, token, scope, resumeProps, onDispatch, onDestroy, onSuccess)
|
return GatewaySession(ws, token, eventHandlers, scope, resumeProps, onDestroy, onSuccess)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -99,27 +100,19 @@ internal class GatewaySession private constructor(
|
||||||
|
|
||||||
private suspend fun handlePayload(payload: Payload.Incoming<*>) {
|
private suspend fun handlePayload(payload: Payload.Incoming<*>) {
|
||||||
logger.d { payload.toString() }
|
logger.d { payload.toString() }
|
||||||
val event = payload.d
|
when (val event = payload.d) {
|
||||||
when (event) {
|
|
||||||
is Event.Heartbeat -> handleHeartbeat()
|
is Event.Heartbeat -> handleHeartbeat()
|
||||||
is Event.Reconnect -> close(GatewayCloseReason.ServerReconnect)
|
is Event.Reconnect -> close(GatewayCloseReason.ServerReconnect)
|
||||||
is Event.InvalidSession -> close(GatewayCloseReason.InvalidSession(event.resumable))
|
is Event.InvalidSession -> close(GatewayCloseReason.InvalidSession(event.resumable))
|
||||||
is Event.Hello -> handleHello(event)
|
is Event.Hello -> handleHello(event)
|
||||||
is Event.HeartbeatAck -> { missedHeartbeats -= 1 }
|
is Event.HeartbeatAck -> { missedHeartbeats -= 1 }
|
||||||
|
|
||||||
is Event.Ready -> {
|
is Event.Ready -> handlers.ready.handle(event) {
|
||||||
resumeProps = ResumeProperties(
|
resumeProps = it
|
||||||
sessionId = event.sessionId,
|
|
||||||
resumeGatewayUrl = event.resumeGatewayUrl,
|
|
||||||
lastSequence = 0,
|
|
||||||
)
|
|
||||||
onSuccess()
|
onSuccess()
|
||||||
}
|
}
|
||||||
is Event.Resumed -> onSuccess()
|
is Event.Resumed -> onSuccess()
|
||||||
}
|
}
|
||||||
if (event is Event.Dispatch) {
|
|
||||||
onDispatch(event)
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
private suspend fun handleUnknownPayload(payload: Payload.Unknown) {
|
private suspend fun handleUnknownPayload(payload: Payload.Unknown) {
|
||||||
|
|
@ -1,9 +1,9 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.json.JsonElement
|
import kotlinx.serialization.json.JsonElement
|
||||||
import moe.lava.neon.api.ApiConstants
|
import moe.lava.neon.core.api.ApiConstants
|
||||||
import moe.lava.neon.api.objects.User
|
import moe.lava.neon.core.api.structures.User
|
||||||
|
|
||||||
sealed interface Payload {
|
sealed interface Payload {
|
||||||
val op: Int
|
val op: Int
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
internal data class ResumeProperties(
|
data class ResumeProperties(
|
||||||
val sessionId: String,
|
val sessionId: String,
|
||||||
val resumeGatewayUrl: String,
|
val resumeGatewayUrl: String,
|
||||||
val lastSequence: Int,
|
val lastSequence: Int,
|
||||||
|
|
@ -1,10 +1,12 @@
|
||||||
package moe.lava.neon.api.gateway
|
package moe.lava.neon.core.api.gateway
|
||||||
|
|
||||||
import kotlinx.serialization.json.JsonNull
|
import kotlinx.serialization.json.JsonNull
|
||||||
import kotlinx.serialization.json.decodeFromJsonElement
|
import kotlinx.serialization.json.decodeFromJsonElement
|
||||||
import moe.lava.neon.api.ApiConstants.json
|
import moe.lava.neon.core.api.ApiConstants
|
||||||
|
|
||||||
internal fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
|
private val json = ApiConstants.json
|
||||||
|
|
||||||
|
fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
|
||||||
val opcode: Int = when (this) {
|
val opcode: Int = when (this) {
|
||||||
is Event.Heartbeat -> 1
|
is Event.Heartbeat -> 1
|
||||||
is Event.Identify -> 2
|
is Event.Identify -> 2
|
||||||
|
|
@ -14,7 +16,7 @@ internal fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
|
||||||
return Payload.Outgoing(op = opcode, d = this)
|
return Payload.Outgoing(op = opcode, d = this)
|
||||||
}
|
}
|
||||||
|
|
||||||
internal fun Payload.Unknown.asIncoming() : Payload.WithSequence {
|
fun Payload.Unknown.asIncoming() : Payload.WithSequence {
|
||||||
return when (op) {
|
return when (op) {
|
||||||
0 -> when (t) {
|
0 -> when (t) {
|
||||||
"READY" -> decode<Event.Ready>()
|
"READY" -> decode<Event.Ready>()
|
||||||
|
|
@ -0,0 +1,5 @@
|
||||||
|
package moe.lava.neon.core.api.gateway.handlers
|
||||||
|
|
||||||
|
import moe.lava.neon.core.api.gateway.Event
|
||||||
|
|
||||||
|
sealed interface Handler<T: Event.Incoming>
|
||||||
|
|
@ -0,0 +1,20 @@
|
||||||
|
package moe.lava.neon.core.api.gateway.handlers
|
||||||
|
|
||||||
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import moe.lava.neon.core.api.gateway.Event
|
||||||
|
import moe.lava.neon.core.api.gateway.ResumeProperties
|
||||||
|
|
||||||
|
private val logger = Logger.withTag("neon.core.api.events/ready")
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
class ReadyHandler : Handler<Event.Ready> {
|
||||||
|
fun handle(event: Event.Ready, updateResumeProps: (ResumeProperties) -> Unit) {
|
||||||
|
logger.i { "Received payload $event" }
|
||||||
|
updateResumeProps(ResumeProperties(
|
||||||
|
sessionId = event.sessionId,
|
||||||
|
resumeGatewayUrl = event.resumeGatewayUrl,
|
||||||
|
lastSequence = 0,
|
||||||
|
))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.api.objects
|
package moe.lava.neon.core.api.structures
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.builtins.LongAsStringSerializer
|
import kotlinx.serialization.builtins.LongAsStringSerializer
|
||||||
|
|
@ -1,4 +1,4 @@
|
||||||
package moe.lava.neon.api.objects
|
package moe.lava.neon.core.api.structures
|
||||||
|
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
|
|
||||||
18
core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt
Normal file
|
|
@ -0,0 +1,18 @@
|
||||||
|
package moe.lava.neon.core.di
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.GraphExtension
|
||||||
|
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 gatewayHandlers: EventHandlerGraph
|
||||||
|
}
|
||||||
|
|
@ -1,23 +0,0 @@
|
||||||
package moe.lava.neon.core.di
|
|
||||||
|
|
||||||
import moe.lava.neon.api.ApiClient
|
|
||||||
import moe.lava.neon.api.gateway.GatewayHandler
|
|
||||||
import moe.lava.neon.core.AppSettings
|
|
||||||
import moe.lava.neon.core.repository.AuthRepository
|
|
||||||
import moe.lava.neon.core.repository.CaptchaRepository
|
|
||||||
import moe.lava.neon.core.repository.GatewayRepository
|
|
||||||
import moe.lava.neon.core.repository.UserRepository
|
|
||||||
import org.koin.dsl.module
|
|
||||||
import org.koin.plugin.module.dsl.single
|
|
||||||
|
|
||||||
val coreModule = module {
|
|
||||||
factory { ApiClient() }
|
|
||||||
single<AppSettings>()
|
|
||||||
|
|
||||||
single<AuthRepository>()
|
|
||||||
single<CaptchaRepository>()
|
|
||||||
single<GatewayRepository>()
|
|
||||||
single<UserRepository>()
|
|
||||||
|
|
||||||
single<GatewayHandler>()
|
|
||||||
}
|
|
||||||
|
|
@ -0,0 +1,12 @@
|
||||||
|
package moe.lava.neon.core.di
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesTo
|
||||||
|
import dev.zacsweers.metro.GraphExtension
|
||||||
|
import moe.lava.neon.core.api.gateway.handlers.ReadyHandler
|
||||||
|
|
||||||
|
@GraphExtension
|
||||||
|
@ContributesTo(AppScope::class)
|
||||||
|
interface EventHandlerGraph {
|
||||||
|
val ready: ReadyHandler
|
||||||
|
}
|
||||||
|
|
@ -1,50 +1,86 @@
|
||||||
package moe.lava.neon.core.repository
|
package moe.lava.neon.core.repository
|
||||||
|
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
import moe.lava.neon.api.ApiClient
|
import dev.zacsweers.metro.AppScope
|
||||||
import moe.lava.neon.api.endpoints.getExperiments
|
import dev.zacsweers.metro.Inject
|
||||||
import moe.lava.neon.api.endpoints.login
|
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.AppSettings
|
||||||
|
import moe.lava.neon.core.api.ApiClient
|
||||||
|
|
||||||
|
@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 {
|
sealed class AuthResponse {
|
||||||
// TODO: Specify all possible error types here
|
|
||||||
data class Failed(val error: Throwable) : AuthResponse()
|
|
||||||
data class Success(val token: String) : AuthResponse()
|
data class Success(val token: String) : AuthResponse()
|
||||||
// TODO
|
// TODO
|
||||||
// data class MFARequested() : AuthResponse()
|
// data class MFARequested() : AuthResponse()
|
||||||
}
|
}
|
||||||
|
|
||||||
class AuthRepository internal constructor(
|
@Inject
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class AuthRepository(
|
||||||
private val settings: AppSettings,
|
private val settings: AppSettings,
|
||||||
private val api: ApiClient,
|
private val api: ApiClient,
|
||||||
) {
|
) {
|
||||||
private val logger = Logger.withTag("neon.core.repo/auth")
|
private val logger = Logger.withTag("neon.core.repo/auth")
|
||||||
private var token by settings::token
|
var token by settings::token
|
||||||
private var fingerprint by settings::fingerprint
|
private set
|
||||||
|
|
||||||
val loggedIn get() = token != null
|
var fingerprint by settings::fingerprint
|
||||||
|
private set
|
||||||
|
|
||||||
suspend fun login(
|
suspend fun login(
|
||||||
email: String,
|
email: String,
|
||||||
password: String,
|
password: String,
|
||||||
): AuthResponse {
|
): AuthResponse {
|
||||||
try {
|
if (fingerprint == null) {
|
||||||
if (fingerprint == null) {
|
fingerprint = api.client.get("experiments") {
|
||||||
fingerprint = api.getExperiments().body().fingerprint
|
parameter("with_guild_experiments", "true")
|
||||||
}
|
}.body<ExperimentResponse>().fingerprint
|
||||||
|
|
||||||
val login = api.login(
|
|
||||||
email = email,
|
|
||||||
password = password,
|
|
||||||
fingerprint = fingerprint!!,
|
|
||||||
).body()
|
|
||||||
logger.i { "Login success $login" }
|
|
||||||
this.token = login.token
|
|
||||||
|
|
||||||
return AuthResponse.Success(login.token)
|
|
||||||
} catch (e: Throwable) {
|
|
||||||
return AuthResponse.Failed(e)
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
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)
|
||||||
}
|
}
|
||||||
|
|
||||||
fun login(token: String): String {
|
fun login(token: String): String {
|
||||||
|
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
package moe.lava.neon.core.repository
|
|
||||||
|
|
||||||
import moe.lava.neon.api.ApiClient
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
|
||||||
|
|
||||||
class CaptchaRepository(
|
|
||||||
private val api: ApiClient,
|
|
||||||
) {
|
|
||||||
fun setHandler(handler: suspend (CaptchaRequest) -> CaptchaResponse) = api.setCaptchaHandler(handler)
|
|
||||||
}
|
|
||||||
|
|
@ -1,18 +0,0 @@
|
||||||
package moe.lava.neon.core.repository
|
|
||||||
|
|
||||||
import moe.lava.neon.api.gateway.GatewayHandler
|
|
||||||
import moe.lava.neon.core.AppSettings
|
|
||||||
|
|
||||||
class GatewayRepository internal constructor(
|
|
||||||
private val gateway: GatewayHandler,
|
|
||||||
private val settings: AppSettings,
|
|
||||||
) {
|
|
||||||
suspend fun start(): Result<Unit> = runCatching {
|
|
||||||
val token = settings.token
|
|
||||||
?: throw IllegalArgumentException("Tried to start gateway with no token")
|
|
||||||
|
|
||||||
gateway.connect(token)
|
|
||||||
}
|
|
||||||
|
|
||||||
suspend fun pause() = runCatching { gateway.disconnect() }
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,10 @@
|
||||||
package moe.lava.neon.core.repository
|
package moe.lava.neon.core.repository
|
||||||
|
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
class UserRepository {
|
class UserRepository {
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,7 @@
|
||||||
|
package moe.lava.neon.core
|
||||||
|
|
||||||
|
class JVMPlatform : Platform {
|
||||||
|
override val name: String = "Java ${System.getProperty("java.version")}"
|
||||||
|
}
|
||||||
|
|
||||||
|
actual fun getPlatform(): Platform = JVMPlatform()
|
||||||
|
|
@ -1,8 +1,7 @@
|
||||||
package moe.lava.neon.api
|
package moe.lava.neon.core.api
|
||||||
|
|
||||||
import java.util.Locale
|
import java.util.Locale
|
||||||
|
|
||||||
// TODO
|
|
||||||
@Suppress("ConstantLocale")
|
@Suppress("ConstantLocale")
|
||||||
internal actual val platformSuperProps = PlatformProps(
|
internal actual val platformSuperProps = PlatformProps(
|
||||||
device = "",
|
device = "",
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
[versions]
|
[versions]
|
||||||
#noinspection AndroidGradlePluginVersion
|
#noinspection AndroidGradlePluginVersion
|
||||||
agp = "9.0.0"
|
agp = "8.13.2"
|
||||||
android-compileSdk = "36"
|
android-compileSdk = "36"
|
||||||
android-minSdk = "24"
|
android-minSdk = "24"
|
||||||
android-targetSdk = "36"
|
android-targetSdk = "36"
|
||||||
|
|
@ -19,16 +19,12 @@ desugar = "2.1.5"
|
||||||
hcaptcha = "4.4.0"
|
hcaptcha = "4.4.0"
|
||||||
junit = "4.13.2"
|
junit = "4.13.2"
|
||||||
kermit = "2.0.8"
|
kermit = "2.0.8"
|
||||||
koin-bom = "4.2.0-RC1"
|
|
||||||
koin-plugin = "0.3.0"
|
|
||||||
kotest = "6.1.2"
|
|
||||||
kotlin = "2.3.0"
|
kotlin = "2.3.0"
|
||||||
kotlinx-coroutines = "1.10.2"
|
kotlinx-coroutines = "1.10.2"
|
||||||
kotlinx-serialization = "1.10.0"
|
|
||||||
ksp = "2.3.4"
|
|
||||||
ktor = "3.4.0"
|
ktor = "3.4.0"
|
||||||
material3 = "1.11.0-alpha02"
|
material3 = "1.11.0-alpha02"
|
||||||
material3-adaptive = "1.3.0-alpha04"
|
material3-adaptive = "1.3.0-alpha04"
|
||||||
|
metro = "0.10.2"
|
||||||
settings = "1.3.0"
|
settings = "1.3.0"
|
||||||
sqldelight = "2.2.1"
|
sqldelight = "2.2.1"
|
||||||
|
|
||||||
|
|
@ -58,38 +54,24 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga
|
||||||
hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" }
|
hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" }
|
||||||
junit = { module = "junit:junit", version.ref = "junit" }
|
junit = { module = "junit:junit", version.ref = "junit" }
|
||||||
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
|
||||||
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
|
|
||||||
koin-compose = { module = "io.insert-koin:koin-compose" }
|
|
||||||
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
|
|
||||||
koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3" }
|
|
||||||
koin-core = { module = "io.insert-koin:koin-core" }
|
|
||||||
koin-test = { module = "io.insert-koin:koin-test" }
|
|
||||||
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
|
|
||||||
kotest-framework = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
|
|
||||||
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
|
|
||||||
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
|
|
||||||
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
|
||||||
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
|
||||||
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
|
||||||
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
|
|
||||||
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
|
|
||||||
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
|
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-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
|
||||||
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
|
|
||||||
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", 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-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" }
|
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
|
||||||
|
metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" }
|
||||||
settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
|
settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
|
||||||
|
|
||||||
[plugins]
|
[plugins]
|
||||||
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
androidApplication = { id = "com.android.application", version.ref = "agp" }
|
||||||
androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
|
androidLibrary = { id = "com.android.library", version.ref = "agp" }
|
||||||
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
|
||||||
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
|
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
|
||||||
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
|
||||||
koinCompiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
|
|
||||||
kotest = { id = "io.kotest", version.ref = "kotest" }
|
|
||||||
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
|
||||||
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
|
||||||
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
|
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
|
||||||
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }
|
||||||
|
|
|
||||||
2
gradle/wrapper/gradle-wrapper.properties
vendored
|
|
@ -1,6 +1,6 @@
|
||||||
distributionBase=GRADLE_USER_HOME
|
distributionBase=GRADLE_USER_HOME
|
||||||
distributionPath=wrapper/dists
|
distributionPath=wrapper/dists
|
||||||
distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
|
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip
|
||||||
networkTimeout=10000
|
networkTimeout=10000
|
||||||
validateDistributionUrl=true
|
validateDistributionUrl=true
|
||||||
zipStoreBase=GRADLE_USER_HOME
|
zipStoreBase=GRADLE_USER_HOME
|
||||||
|
|
|
||||||
|
|
@ -33,10 +33,5 @@ plugins {
|
||||||
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
|
||||||
}
|
}
|
||||||
|
|
||||||
include(":android")
|
|
||||||
include(":api:gateway")
|
|
||||||
include(":api:rest")
|
|
||||||
include(":api:shared")
|
|
||||||
include(":common")
|
|
||||||
include(":core")
|
include(":core")
|
||||||
include(":ui")
|
include(":ui")
|
||||||
|
|
|
||||||
|
|
@ -4,29 +4,22 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
|
||||||
plugins {
|
plugins {
|
||||||
alias(libs.plugins.kotlinMultiplatform)
|
alias(libs.plugins.kotlinMultiplatform)
|
||||||
alias(libs.plugins.kotlinSerialization)
|
alias(libs.plugins.kotlinSerialization)
|
||||||
alias(libs.plugins.androidMultiplatformLibrary)
|
alias(libs.plugins.androidApplication)
|
||||||
alias(libs.plugins.composeMultiplatform)
|
alias(libs.plugins.composeMultiplatform)
|
||||||
alias(libs.plugins.composeCompiler)
|
alias(libs.plugins.composeCompiler)
|
||||||
alias(libs.plugins.composeHotReload)
|
alias(libs.plugins.composeHotReload)
|
||||||
alias(libs.plugins.koinCompiler)
|
alias(libs.plugins.metro)
|
||||||
}
|
}
|
||||||
|
|
||||||
kotlin {
|
kotlin {
|
||||||
androidLibrary {
|
androidTarget {
|
||||||
namespace = "moe.lava.neon.ui"
|
|
||||||
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
|
||||||
|
|
||||||
compilerOptions {
|
compilerOptions {
|
||||||
jvmTarget.set(JvmTarget.JVM_11)
|
jvmTarget.set(JvmTarget.JVM_11)
|
||||||
}
|
}
|
||||||
|
|
||||||
androidResources {
|
|
||||||
enable = true
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
jvm()
|
jvm()
|
||||||
|
|
||||||
sourceSets {
|
sourceSets {
|
||||||
androidMain.dependencies {
|
androidMain.dependencies {
|
||||||
implementation(libs.compose.ui.tooling.preview)
|
implementation(libs.compose.ui.tooling.preview)
|
||||||
|
|
@ -38,7 +31,6 @@ kotlin {
|
||||||
implementation(libs.ktor.client.okhttp)
|
implementation(libs.ktor.client.okhttp)
|
||||||
}
|
}
|
||||||
commonMain.dependencies {
|
commonMain.dependencies {
|
||||||
implementation(project(":common"))
|
|
||||||
implementation(project(":core"))
|
implementation(project(":core"))
|
||||||
implementation(libs.compose.components.resources)
|
implementation(libs.compose.components.resources)
|
||||||
implementation(libs.compose.foundation)
|
implementation(libs.compose.foundation)
|
||||||
|
|
@ -62,10 +54,8 @@ kotlin {
|
||||||
|
|
||||||
implementation(libs.kermit)
|
implementation(libs.kermit)
|
||||||
|
|
||||||
implementation(project.dependencies.platform(libs.koin.bom))
|
implementation(libs.metrox.viewmodel.compose)
|
||||||
implementation(libs.koin.compose)
|
|
||||||
implementation(libs.koin.compose.viewmodel)
|
|
||||||
implementation(libs.koin.compose.navigation3)
|
|
||||||
}
|
}
|
||||||
commonTest.dependencies {
|
commonTest.dependencies {
|
||||||
implementation(libs.kotlin.test)
|
implementation(libs.kotlin.test)
|
||||||
|
|
@ -79,8 +69,38 @@ kotlin {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "moe.lava.neon"
|
||||||
|
compileSdk = libs.versions.android.compileSdk.get().toInt()
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "moe.lava.neon"
|
||||||
|
minSdk = libs.versions.android.minSdk.get().toInt()
|
||||||
|
targetSdk = libs.versions.android.targetSdk.get().toInt()
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
packaging {
|
||||||
|
resources {
|
||||||
|
excludes += "/META-INF/{AL2.0,LGPL2.1}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
buildTypes {
|
||||||
|
getByName("release") {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
signingConfig = signingConfigs.getByName("debug")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_11
|
||||||
|
targetCompatibility = JavaVersion.VERSION_11
|
||||||
|
isCoreLibraryDesugaringEnabled = true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
dependencies {
|
dependencies {
|
||||||
androidRuntimeClasspath(libs.compose.ui.tooling)
|
debugImplementation(libs.compose.ui.tooling)
|
||||||
|
coreLibraryDesugaring(libs.desugar)
|
||||||
}
|
}
|
||||||
|
|
||||||
compose.desktop {
|
compose.desktop {
|
||||||
|
|
|
||||||
|
|
@ -4,23 +4,23 @@ import android.os.Bundle
|
||||||
import androidx.activity.ComponentActivity
|
import androidx.activity.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.ui.tooling.preview.Preview
|
||||||
import moe.lava.neon.ui.App
|
import moe.lava.neon.ui.App
|
||||||
import moe.lava.neon.ui.di.initKoin
|
|
||||||
import org.koin.android.ext.koin.androidContext
|
|
||||||
import org.koin.android.ext.koin.androidLogger
|
|
||||||
|
|
||||||
class MainActivity : ComponentActivity() {
|
class MainActivity : ComponentActivity() {
|
||||||
override fun onCreate(savedInstanceState: Bundle?) {
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
|
|
||||||
initKoin {
|
|
||||||
androidContext(this@MainActivity)
|
|
||||||
androidLogger()
|
|
||||||
}
|
|
||||||
|
|
||||||
setContent {
|
setContent {
|
||||||
App()
|
App()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Preview
|
||||||
|
@Composable
|
||||||
|
fun AppAndroidPreview() {
|
||||||
|
App()
|
||||||
|
}
|
||||||
|
|
@ -19,8 +19,8 @@ import com.hcaptcha.sdk.HCaptchaVerifyParams
|
||||||
import kotlinx.coroutines.flow.MutableSharedFlow
|
import kotlinx.coroutines.flow.MutableSharedFlow
|
||||||
import kotlinx.coroutines.flow.first
|
import kotlinx.coroutines.flow.first
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
import moe.lava.neon.core.api.ApiClient
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
import moe.lava.neon.core.api.captcha.CaptchaResponse
|
||||||
|
|
||||||
private val logger = Logger.withTag("neon.ui.app/captcha")
|
private val logger = Logger.withTag("neon.ui.app/captcha")
|
||||||
|
|
||||||
|
|
@ -31,7 +31,7 @@ private const val EXTRA_RESULT_TOKEN = "extra_result_token"
|
||||||
private const val EXTRA_RESULT_ERROR = "extra_result_error"
|
private const val EXTRA_RESULT_ERROR = "extra_result_error"
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
actual fun getCaptchaHandler(): suspend (CaptchaRequest) -> CaptchaResponse {
|
actual fun CaptchaBinder(api: ApiClient) {
|
||||||
val context = LocalContext.current
|
val context = LocalContext.current
|
||||||
val queue = MutableSharedFlow<Pair<String, CaptchaResponse>>()
|
val queue = MutableSharedFlow<Pair<String, CaptchaResponse>>()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
@ -67,7 +67,7 @@ actual fun getCaptchaHandler(): suspend (CaptchaRequest) -> CaptchaResponse {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return { captcha ->
|
api.setCaptchaHandler { captcha ->
|
||||||
val intent = Intent(context, HCaptchaActivity::class.java).apply {
|
val intent = Intent(context, HCaptchaActivity::class.java).apply {
|
||||||
putExtra(EXTRA_SITE_KEY, captcha.captchaSitekey)
|
putExtra(EXTRA_SITE_KEY, captcha.captchaSitekey)
|
||||||
putExtra(EXTRA_RQ_DATA, captcha.captchaRqdata)
|
putExtra(EXTRA_RQ_DATA, captcha.captchaRqdata)
|
||||||
|
Before Width: | Height: | Size: 3.5 KiB After Width: | Height: | Size: 3.5 KiB |
|
Before Width: | Height: | Size: 5.2 KiB After Width: | Height: | Size: 5.2 KiB |
|
Before Width: | Height: | Size: 2.6 KiB After Width: | Height: | Size: 2.6 KiB |
|
Before Width: | Height: | Size: 3.3 KiB After Width: | Height: | Size: 3.3 KiB |
|
Before Width: | Height: | Size: 4.8 KiB After Width: | Height: | Size: 4.8 KiB |
|
Before Width: | Height: | Size: 7.3 KiB After Width: | Height: | Size: 7.3 KiB |
|
Before Width: | Height: | Size: 7.7 KiB After Width: | Height: | Size: 7.7 KiB |
|
Before Width: | Height: | Size: 12 KiB After Width: | Height: | Size: 12 KiB |
|
Before Width: | Height: | Size: 10 KiB After Width: | Height: | Size: 10 KiB |
|
Before Width: | Height: | Size: 16 KiB After Width: | Height: | Size: 16 KiB |
|
|
@ -5,6 +5,7 @@ import androidx.compose.material3.MaterialExpressiveTheme
|
||||||
import androidx.compose.material3.MotionScheme
|
import androidx.compose.material3.MotionScheme
|
||||||
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
|
||||||
import androidx.compose.runtime.Composable
|
import androidx.compose.runtime.Composable
|
||||||
|
import androidx.compose.runtime.CompositionLocalProvider
|
||||||
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
|
||||||
import androidx.navigation3.runtime.NavKey
|
import androidx.navigation3.runtime.NavKey
|
||||||
import androidx.navigation3.runtime.entryProvider
|
import androidx.navigation3.runtime.entryProvider
|
||||||
|
|
@ -13,11 +14,12 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
|
||||||
import androidx.navigation3.ui.NavDisplay
|
import androidx.navigation3.ui.NavDisplay
|
||||||
import androidx.savedstate.serialization.SavedStateConfiguration
|
import androidx.savedstate.serialization.SavedStateConfiguration
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.zacsweers.metro.createGraph
|
||||||
|
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
|
||||||
import kotlinx.serialization.Serializable
|
import kotlinx.serialization.Serializable
|
||||||
import kotlinx.serialization.modules.SerializersModule
|
import kotlinx.serialization.modules.SerializersModule
|
||||||
import kotlinx.serialization.modules.polymorphic
|
import kotlinx.serialization.modules.polymorphic
|
||||||
import moe.lava.neon.core.repository.AuthRepository
|
import moe.lava.neon.ui.di.AppUiGraph
|
||||||
import moe.lava.neon.core.repository.CaptchaRepository
|
|
||||||
import moe.lava.neon.ui.screens.Login
|
import moe.lava.neon.ui.screens.Login
|
||||||
import moe.lava.neon.ui.screens.Sample
|
import moe.lava.neon.ui.screens.Sample
|
||||||
import moe.lava.neon.ui.screens.chat.Chat
|
import moe.lava.neon.ui.screens.chat.Chat
|
||||||
|
|
@ -27,7 +29,6 @@ import moe.lava.neon.ui.screens.navigator.NavigatorModel
|
||||||
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
|
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
|
||||||
import moe.lava.neon.ui.util.ThreePaneSceneStrategy
|
import moe.lava.neon.ui.util.ThreePaneSceneStrategy
|
||||||
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
|
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
|
||||||
import org.koin.compose.koinInject
|
|
||||||
import kotlin.system.exitProcess
|
import kotlin.system.exitProcess
|
||||||
|
|
||||||
object Route {
|
object Route {
|
||||||
|
|
@ -67,81 +68,81 @@ fun App() {
|
||||||
exitProcess(1)
|
exitProcess(1)
|
||||||
}
|
}
|
||||||
|
|
||||||
val auth: AuthRepository = koinInject()
|
val uiGraph = createGraph<AppUiGraph>()
|
||||||
val captcha: CaptchaRepository = koinInject()
|
val graph = uiGraph.core
|
||||||
captcha.setHandler(getCaptchaHandler())
|
CaptchaBinder(graph.api)
|
||||||
|
CompositionLocalProvider(LocalMetroViewModelFactory provides uiGraph.metroViewModelFactory) {
|
||||||
MaterialExpressiveTheme(
|
MaterialExpressiveTheme(
|
||||||
colorScheme = getColorScheme(),
|
colorScheme = getColorScheme(),
|
||||||
motionScheme = MotionScheme.expressive(),
|
motionScheme = MotionScheme.expressive(),
|
||||||
) {
|
) {
|
||||||
val init = if (auth.loggedIn) Route.Sample else Route.Login
|
val init = if (graph.auth.token != null) Route.Sample else Route.Login
|
||||||
// val backStack = rememberNavBackStack(config, init)
|
val backStack = rememberNavBackStack(config, Route.Sample)
|
||||||
val backStack = rememberNavBackStack(config, Route.Sample)
|
val threePaneStrategy = rememberThreePaneSceneStrategy<NavKey>()
|
||||||
val threePaneStrategy = rememberThreePaneSceneStrategy<NavKey>()
|
NavDisplay(
|
||||||
NavDisplay(
|
backStack = backStack,
|
||||||
backStack = backStack,
|
entryDecorators = listOf(
|
||||||
entryDecorators = listOf(
|
rememberSaveableStateHolderNavEntryDecorator(),
|
||||||
rememberSaveableStateHolderNavEntryDecorator(),
|
rememberViewModelStoreNavEntryDecorator(),
|
||||||
rememberViewModelStoreNavEntryDecorator(),
|
),
|
||||||
),
|
onBack = { backStack.removeLastOrNull() },
|
||||||
onBack = { backStack.removeLastOrNull() },
|
sceneStrategy = threePaneStrategy,
|
||||||
sceneStrategy = threePaneStrategy,
|
entryProvider = entryProvider {
|
||||||
entryProvider = entryProvider {
|
entry<Route.Login> {
|
||||||
entry<Route.Login> {
|
Login(
|
||||||
Login(
|
onSuccess = {
|
||||||
onSuccess = {
|
backStack.clear()
|
||||||
backStack.clear()
|
backStack.add(Route.Sample)
|
||||||
backStack.add(Route.Sample)
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
entry<Route.Sample> {
|
||||||
entry<Route.Sample> {
|
Sample(
|
||||||
Sample(
|
navTest = {
|
||||||
navTest = {
|
backStack.add(Route.Navigator(it))
|
||||||
backStack.add(Route.Navigator(it))
|
backStack.add(Route.Chat)
|
||||||
backStack.add(Route.Chat)
|
backStack.add(Route.MembersList)
|
||||||
backStack.add(Route.MembersList)
|
},
|
||||||
},
|
onRequestLogout = {
|
||||||
onRequestLogout = {
|
backStack.clear()
|
||||||
backStack.clear()
|
backStack.add(Route.Login)
|
||||||
backStack.add(Route.Login)
|
}
|
||||||
}
|
|
||||||
)
|
|
||||||
}
|
|
||||||
|
|
||||||
entry<Route.Navigator>(
|
|
||||||
metadata = ThreePaneSceneStrategy.listPane()
|
|
||||||
) { key ->
|
|
||||||
if (key.left) {
|
|
||||||
Navigator(
|
|
||||||
NavigatorPreviewProvider.base2.copy(
|
|
||||||
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
|
|
||||||
)
|
|
||||||
)
|
|
||||||
} else {
|
|
||||||
Navigator(
|
|
||||||
NavigatorPreviewProvider.base2.copy(
|
|
||||||
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
|
|
||||||
)
|
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
}
|
|
||||||
|
|
||||||
entry<Route.Chat>(
|
entry<Route.Navigator>(
|
||||||
metadata = ThreePaneSceneStrategy.detailPane()
|
metadata = ThreePaneSceneStrategy.listPane()
|
||||||
) {
|
) { key ->
|
||||||
Chat(
|
if (key.left) {
|
||||||
onOpenMembers = { backStack.add(Route.MembersList) }
|
Navigator(
|
||||||
)
|
NavigatorPreviewProvider.base2.copy(
|
||||||
}
|
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
|
||||||
|
)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
Navigator(
|
||||||
|
NavigatorPreviewProvider.base2.copy(
|
||||||
|
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
entry<Route.MembersList>(
|
entry<Route.Chat>(
|
||||||
metadata = ThreePaneSceneStrategy.extraPane()
|
metadata = ThreePaneSceneStrategy.detailPane()
|
||||||
) {
|
) {
|
||||||
MembersList()
|
Chat(
|
||||||
|
onOpenMembers = { backStack.add(Route.MembersList) }
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
entry<Route.MembersList>(
|
||||||
|
metadata = ThreePaneSceneStrategy.extraPane()
|
||||||
|
) {
|
||||||
|
MembersList()
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
)
|
||||||
)
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -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,8 +0,0 @@
|
||||||
package moe.lava.neon.ui
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
expect fun getCaptchaHandler(): suspend (CaptchaRequest) -> CaptchaResponse
|
|
||||||
11
ui/src/commonMain/kotlin/moe/lava/neon/ui/Greeting.kt
Normal file
|
|
@ -0,0 +1,11 @@
|
||||||
|
package moe.lava.neon.ui
|
||||||
|
|
||||||
|
import moe.lava.neon.core.getPlatform
|
||||||
|
|
||||||
|
class Greeting {
|
||||||
|
private val platform = getPlatform()
|
||||||
|
|
||||||
|
fun greet(): String {
|
||||||
|
return "Hello, ${platform.name}!"
|
||||||
|
}
|
||||||
|
}
|
||||||
29
ui/src/commonMain/kotlin/moe/lava/neon/ui/di/AppUiGraph.kt
Normal file
|
|
@ -0,0 +1,29 @@
|
||||||
|
package moe.lava.neon.ui.di
|
||||||
|
|
||||||
|
import androidx.lifecycle.ViewModel
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesBinding
|
||||||
|
import dev.zacsweers.metro.DependencyGraph
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metro.Provider
|
||||||
|
import dev.zacsweers.metro.SingleIn
|
||||||
|
import dev.zacsweers.metrox.viewmodel.ManualViewModelAssistedFactory
|
||||||
|
import dev.zacsweers.metrox.viewmodel.MetroViewModelFactory
|
||||||
|
import dev.zacsweers.metrox.viewmodel.ViewModelAssistedFactory
|
||||||
|
import dev.zacsweers.metrox.viewmodel.ViewModelGraph
|
||||||
|
import moe.lava.neon.core.di.AppGraph
|
||||||
|
import kotlin.reflect.KClass
|
||||||
|
|
||||||
|
@DependencyGraph(AppScope::class)
|
||||||
|
interface AppUiGraph : ViewModelGraph {
|
||||||
|
val core: AppGraph
|
||||||
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@ContributesBinding(AppScope::class)
|
||||||
|
@SingleIn(AppScope::class)
|
||||||
|
class AppViewModelFactory(
|
||||||
|
override val viewModelProviders: Map<KClass<out ViewModel>, Provider<ViewModel>>,
|
||||||
|
override val assistedFactoryProviders: Map<KClass<out ViewModel>, Provider<ViewModelAssistedFactory>>,
|
||||||
|
override val manualAssistedFactoryProviders: Map<KClass<out ManualViewModelAssistedFactory>, Provider<ManualViewModelAssistedFactory>>,
|
||||||
|
) : MetroViewModelFactory()
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package moe.lava.neon.ui.di
|
|
||||||
|
|
||||||
import org.koin.core.KoinApplication
|
|
||||||
import org.koin.core.context.startKoin
|
|
||||||
import org.koin.dsl.KoinAppDeclaration
|
|
||||||
import org.koin.dsl.includes
|
|
||||||
|
|
||||||
fun initKoin(config: KoinAppDeclaration? = null): KoinApplication {
|
|
||||||
return startKoin {
|
|
||||||
includes(config)
|
|
||||||
modules(uiModule)
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package moe.lava.neon.ui.di
|
|
||||||
|
|
||||||
import moe.lava.neon.core.di.coreModule
|
|
||||||
import moe.lava.neon.ui.screens.LoginViewModel
|
|
||||||
import moe.lava.neon.ui.screens.SampleViewModel
|
|
||||||
import org.koin.dsl.module
|
|
||||||
import org.koin.plugin.module.dsl.viewModel
|
|
||||||
|
|
||||||
val uiModule = module {
|
|
||||||
includes(coreModule)
|
|
||||||
viewModel<LoginViewModel>()
|
|
||||||
viewModel<SampleViewModel>()
|
|
||||||
}
|
|
||||||
|
|
@ -23,6 +23,11 @@ import androidx.compose.ui.text.input.VisualTransformation
|
||||||
import androidx.compose.ui.unit.dp
|
import androidx.compose.ui.unit.dp
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesIntoMap
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||||
|
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
import moe.lava.neon.core.repository.AuthRepository
|
import moe.lava.neon.core.repository.AuthRepository
|
||||||
import moe.lava.neon.core.repository.AuthResponse
|
import moe.lava.neon.core.repository.AuthResponse
|
||||||
|
|
@ -30,13 +35,12 @@ import moe.lava.neon.resources.Res
|
||||||
import moe.lava.neon.resources.visibility
|
import moe.lava.neon.resources.visibility
|
||||||
import moe.lava.neon.resources.visibility_off
|
import moe.lava.neon.resources.visibility_off
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Login(
|
fun Login(
|
||||||
onSuccess: () -> Unit,
|
onSuccess: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel: LoginViewModel = koinViewModel()
|
val viewModel: LoginViewModel = metroViewModel()
|
||||||
val scope = rememberCoroutineScope()
|
val scope = rememberCoroutineScope()
|
||||||
|
|
||||||
Column(
|
Column(
|
||||||
|
|
@ -109,6 +113,9 @@ fun Login(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@ViewModelKey(LoginViewModel::class)
|
||||||
|
@ContributesIntoMap(AppScope::class)
|
||||||
class LoginViewModel(
|
class LoginViewModel(
|
||||||
private val auth: AuthRepository
|
private val auth: AuthRepository
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
|
|
@ -122,13 +129,13 @@ class LoginViewModel(
|
||||||
}
|
}
|
||||||
|
|
||||||
suspend fun login(email: String, password: String): LoginResult {
|
suspend fun login(email: String, password: String): LoginResult {
|
||||||
return when (val res = auth.login(email, password)) {
|
return try {
|
||||||
is AuthResponse.Success -> LoginResult.Success
|
when (val res = auth.login(email, password)) {
|
||||||
is AuthResponse.Failed -> {
|
is AuthResponse.Success -> LoginResult.Success
|
||||||
val e = res.error
|
|
||||||
logger.e(e) { "Login failed" }
|
|
||||||
LoginResult.Failed(e.toString())
|
|
||||||
}
|
}
|
||||||
|
} catch(e: Throwable) {
|
||||||
|
logger.e(e) { "Login failed" }
|
||||||
|
LoginResult.Failed(e.toString())
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -20,20 +20,25 @@ import androidx.compose.ui.Modifier
|
||||||
import androidx.lifecycle.ViewModel
|
import androidx.lifecycle.ViewModel
|
||||||
import androidx.lifecycle.viewModelScope
|
import androidx.lifecycle.viewModelScope
|
||||||
import co.touchlab.kermit.Logger
|
import co.touchlab.kermit.Logger
|
||||||
|
import dev.zacsweers.metro.AppScope
|
||||||
|
import dev.zacsweers.metro.ContributesIntoMap
|
||||||
|
import dev.zacsweers.metro.Inject
|
||||||
|
import dev.zacsweers.metrox.viewmodel.ViewModelKey
|
||||||
|
import dev.zacsweers.metrox.viewmodel.metroViewModel
|
||||||
import kotlinx.coroutines.launch
|
import kotlinx.coroutines.launch
|
||||||
|
import moe.lava.neon.core.api.gateway.GatewayHandler
|
||||||
import moe.lava.neon.core.repository.AuthRepository
|
import moe.lava.neon.core.repository.AuthRepository
|
||||||
import moe.lava.neon.core.repository.GatewayRepository
|
|
||||||
import moe.lava.neon.resources.Res
|
import moe.lava.neon.resources.Res
|
||||||
import moe.lava.neon.resources.compose_multiplatform
|
import moe.lava.neon.resources.compose_multiplatform
|
||||||
|
import moe.lava.neon.ui.Greeting
|
||||||
import org.jetbrains.compose.resources.painterResource
|
import org.jetbrains.compose.resources.painterResource
|
||||||
import org.koin.compose.viewmodel.koinViewModel
|
|
||||||
|
|
||||||
@Composable
|
@Composable
|
||||||
fun Sample(
|
fun Sample(
|
||||||
navTest: (Boolean) -> Unit,
|
navTest: (Boolean) -> Unit,
|
||||||
onRequestLogout: () -> Unit,
|
onRequestLogout: () -> Unit,
|
||||||
) {
|
) {
|
||||||
val viewModel: SampleViewModel = koinViewModel()
|
val viewModel: SampleViewModel = metroViewModel()
|
||||||
var showContent by remember { mutableStateOf(false) }
|
var showContent by remember { mutableStateOf(false) }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier
|
modifier = Modifier
|
||||||
|
|
@ -52,12 +57,14 @@ fun Sample(
|
||||||
Text("Click me (bottom!")
|
Text("Click me (bottom!")
|
||||||
}
|
}
|
||||||
AnimatedVisibility(showContent) {
|
AnimatedVisibility(showContent) {
|
||||||
|
val greeting = remember { Greeting().greet() }
|
||||||
Column(
|
Column(
|
||||||
modifier = Modifier.fillMaxWidth(),
|
modifier = Modifier.fillMaxWidth(),
|
||||||
horizontalAlignment = Alignment.CenterHorizontally,
|
horizontalAlignment = Alignment.CenterHorizontally,
|
||||||
) {
|
) {
|
||||||
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
Image(painterResource(Res.drawable.compose_multiplatform), null)
|
||||||
Text("Logged in: ${viewModel.loggedIn}")
|
Text("Compose: $greeting")
|
||||||
|
Text("Passed token: ${viewModel.token?.slice(0..10)}...")
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
Button(onClick = {
|
Button(onClick = {
|
||||||
|
|
@ -79,26 +86,31 @@ fun Sample(
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@Inject
|
||||||
|
@ViewModelKey(SampleViewModel::class)
|
||||||
|
@ContributesIntoMap(AppScope::class)
|
||||||
class SampleViewModel(
|
class SampleViewModel(
|
||||||
private val auth: AuthRepository,
|
private val auth: AuthRepository,
|
||||||
private val gateway: GatewayRepository,
|
private val gateway: GatewayHandler,
|
||||||
) : ViewModel() {
|
) : ViewModel() {
|
||||||
private val logger = Logger.withTag("neon.ui.screens/Sample")
|
private val logger = Logger.withTag("neon.ui.screens/Sample")
|
||||||
val loggedIn by auth::loggedIn
|
val token get() = auth.token
|
||||||
|
|
||||||
fun connect() {
|
fun connect() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val exception = gateway.start().exceptionOrNull()
|
try {
|
||||||
if (exception != null) {
|
gateway.connect()
|
||||||
logger.e(exception) { "Failed to connect to gateway: ${exception.stackTraceToString()}" }
|
} catch(e: Throwable) {
|
||||||
|
logger.e(e) { "Failed to connect to gateway: ${e.stackTraceToString()}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
fun disconnect() {
|
fun disconnect() {
|
||||||
viewModelScope.launch {
|
viewModelScope.launch {
|
||||||
val exception = gateway.pause().exceptionOrNull()
|
try {
|
||||||
if (exception != null) {
|
gateway.disconnect()
|
||||||
logger.e(exception) { "Failed to disconnect from gateway: ${exception.stackTraceToString()}" }
|
} catch(e: Throwable) {
|
||||||
|
logger.e(e) { "Failed to connect to gateway: ${e.stackTraceToString()}" }
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -6,16 +6,12 @@ import androidx.compose.ui.unit.Density
|
||||||
import androidx.compose.ui.window.Window
|
import androidx.compose.ui.window.Window
|
||||||
import androidx.compose.ui.window.application
|
import androidx.compose.ui.window.application
|
||||||
import moe.lava.neon.ui.App
|
import moe.lava.neon.ui.App
|
||||||
import moe.lava.neon.ui.di.initKoin
|
|
||||||
|
|
||||||
// The UI is designed with touchscreens in mind; on desktop elements may look gigantic
|
// The UI is designed with touchscreens in mind; on desktop elements may look gigantic
|
||||||
// So scale them down a bit
|
// So scale them down a bit
|
||||||
const val scaleFactor = 0.75f
|
const val scaleFactor = 0.75f
|
||||||
|
|
||||||
fun main() = application {
|
fun main() = application {
|
||||||
initKoin {
|
|
||||||
printLogger()
|
|
||||||
}
|
|
||||||
Window(
|
Window(
|
||||||
onCloseRequest = ::exitApplication,
|
onCloseRequest = ::exitApplication,
|
||||||
title = "Neon",
|
title = "Neon",
|
||||||
|
|
|
||||||
13
ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt
Normal file
|
|
@ -0,0 +1,13 @@
|
||||||
|
package moe.lava.neon.ui
|
||||||
|
|
||||||
|
import androidx.compose.runtime.Composable
|
||||||
|
import moe.lava.neon.core.api.ApiClient
|
||||||
|
import moe.lava.neon.core.api.captcha.CaptchaResponse
|
||||||
|
|
||||||
|
@Composable
|
||||||
|
// TODO
|
||||||
|
actual fun CaptchaBinder(api: ApiClient) {
|
||||||
|
api.setCaptchaHandler {
|
||||||
|
CaptchaResponse.Failed(NotImplementedError())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
@ -1,13 +0,0 @@
|
||||||
package moe.lava.neon.ui
|
|
||||||
|
|
||||||
import androidx.compose.runtime.Composable
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaRequest
|
|
||||||
import moe.lava.neon.common.captcha.CaptchaResponse
|
|
||||||
|
|
||||||
@Composable
|
|
||||||
// TODO
|
|
||||||
actual fun getCaptchaHandler(): suspend (CaptchaRequest) -> CaptchaResponse {
|
|
||||||
return {
|
|
||||||
CaptchaResponse.Failed(NotImplementedError())
|
|
||||||
}
|
|
||||||
}
|
|
||||||