diff --git a/android/build.gradle.kts b/android/build.gradle.kts new file mode 100644 index 0000000..2f262b1 --- /dev/null +++ b/android/build.gradle.kts @@ -0,0 +1,63 @@ +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 + } +} diff --git a/ui/src/androidMain/AndroidManifest.xml b/android/src/main/AndroidManifest.xml similarity index 100% rename from ui/src/androidMain/AndroidManifest.xml rename to android/src/main/AndroidManifest.xml diff --git a/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt b/android/src/main/kotlin/moe/lava/neon/MainActivity.kt similarity index 65% rename from ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt rename to android/src/main/kotlin/moe/lava/neon/MainActivity.kt index 45b77df..cc4947f 100644 --- a/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt +++ b/android/src/main/kotlin/moe/lava/neon/MainActivity.kt @@ -4,23 +4,23 @@ import android.os.Bundle import androidx.activity.ComponentActivity import androidx.activity.compose.setContent 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.di.initKoin +import org.koin.android.ext.koin.androidContext +import org.koin.android.ext.koin.androidLogger class MainActivity : ComponentActivity() { override fun onCreate(savedInstanceState: Bundle?) { enableEdgeToEdge() super.onCreate(savedInstanceState) + initKoin { + androidContext(this@MainActivity) + androidLogger() + } + setContent { App() } } } - -@Preview -@Composable -fun AppAndroidPreview() { - App() -} diff --git a/ui/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml b/android/src/main/res/drawable-v24/ic_launcher_foreground.xml similarity index 100% rename from ui/src/androidMain/res/drawable-v24/ic_launcher_foreground.xml rename to android/src/main/res/drawable-v24/ic_launcher_foreground.xml diff --git a/ui/src/androidMain/res/drawable/ic_launcher_background.xml b/android/src/main/res/drawable/ic_launcher_background.xml similarity index 100% rename from ui/src/androidMain/res/drawable/ic_launcher_background.xml rename to android/src/main/res/drawable/ic_launcher_background.xml diff --git a/ui/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml similarity index 100% rename from ui/src/androidMain/res/mipmap-anydpi-v26/ic_launcher.xml rename to android/src/main/res/mipmap-anydpi-v26/ic_launcher.xml diff --git a/ui/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml b/android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml similarity index 100% rename from ui/src/androidMain/res/mipmap-anydpi-v26/ic_launcher_round.xml rename to android/src/main/res/mipmap-anydpi-v26/ic_launcher_round.xml diff --git a/ui/src/androidMain/res/mipmap-hdpi/ic_launcher.png b/android/src/main/res/mipmap-hdpi/ic_launcher.png similarity index 100% rename from ui/src/androidMain/res/mipmap-hdpi/ic_launcher.png rename to android/src/main/res/mipmap-hdpi/ic_launcher.png diff --git a/ui/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png b/android/src/main/res/mipmap-hdpi/ic_launcher_round.png similarity index 100% rename from ui/src/androidMain/res/mipmap-hdpi/ic_launcher_round.png rename to android/src/main/res/mipmap-hdpi/ic_launcher_round.png diff --git a/ui/src/androidMain/res/mipmap-mdpi/ic_launcher.png b/android/src/main/res/mipmap-mdpi/ic_launcher.png similarity index 100% rename from ui/src/androidMain/res/mipmap-mdpi/ic_launcher.png rename to android/src/main/res/mipmap-mdpi/ic_launcher.png diff --git a/ui/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png b/android/src/main/res/mipmap-mdpi/ic_launcher_round.png similarity index 100% rename from ui/src/androidMain/res/mipmap-mdpi/ic_launcher_round.png rename to android/src/main/res/mipmap-mdpi/ic_launcher_round.png diff --git a/ui/src/androidMain/res/mipmap-xhdpi/ic_launcher.png b/android/src/main/res/mipmap-xhdpi/ic_launcher.png similarity index 100% rename from ui/src/androidMain/res/mipmap-xhdpi/ic_launcher.png rename to android/src/main/res/mipmap-xhdpi/ic_launcher.png diff --git a/ui/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xhdpi/ic_launcher_round.png similarity index 100% rename from ui/src/androidMain/res/mipmap-xhdpi/ic_launcher_round.png rename to android/src/main/res/mipmap-xhdpi/ic_launcher_round.png diff --git a/ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher.png similarity index 100% rename from ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher.png rename to android/src/main/res/mipmap-xxhdpi/ic_launcher.png diff --git a/ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png similarity index 100% rename from ui/src/androidMain/res/mipmap-xxhdpi/ic_launcher_round.png rename to android/src/main/res/mipmap-xxhdpi/ic_launcher_round.png diff --git a/ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher.png similarity index 100% rename from ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher.png rename to android/src/main/res/mipmap-xxxhdpi/ic_launcher.png diff --git a/ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png b/android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png similarity index 100% rename from ui/src/androidMain/res/mipmap-xxxhdpi/ic_launcher_round.png rename to android/src/main/res/mipmap-xxxhdpi/ic_launcher_round.png diff --git a/ui/src/androidMain/res/values/strings.xml b/android/src/main/res/values/strings.xml similarity index 100% rename from ui/src/androidMain/res/values/strings.xml rename to android/src/main/res/values/strings.xml diff --git a/ui/src/main/res/values/styles.xml b/android/src/main/res/values/styles.xml similarity index 100% rename from ui/src/main/res/values/styles.xml rename to android/src/main/res/values/styles.xml diff --git a/api/gateway/build.gradle.kts b/api/gateway/build.gradle.kts new file mode 100644 index 0000000..75a3458 --- /dev/null +++ b/api/gateway/build.gradle.kts @@ -0,0 +1,41 @@ +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) + } + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Capability.kt similarity index 97% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Capability.kt index 76347ee..ced4e64 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Capability.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Capability.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway @Suppress("unused") object Capability { diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayCloseReason.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayCloseReason.kt similarity index 95% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayCloseReason.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayCloseReason.kt index 2e1a826..396752d 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayCloseReason.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayCloseReason.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway import io.ktor.websocket.CloseReason diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt similarity index 81% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt index 40dc00e..41054be 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt @@ -1,22 +1,22 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway import co.touchlab.kermit.Logger -import dev.zacsweers.metro.Inject import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow import kotlinx.coroutines.launch import kotlinx.serialization.ExperimentalSerializationApi -import moe.lava.neon.core.di.EventHandlerGraph -import moe.lava.neon.core.repository.AuthRepository +import moe.lava.neon.api.gateway.handlers.Handler import kotlin.math.pow +import kotlin.reflect.KClass import kotlin.time.Duration.Companion.seconds -@Inject -class GatewayHandler( - private val auth: AuthRepository, - private val handlers: EventHandlerGraph, -) { +class GatewayHandler { + private val mEvents = MutableSharedFlow() + val events = mEvents.asSharedFlow() + private val logger = Logger.withTag("neon.core.api.gateway/handler") private val scope = CoroutineScope(Dispatchers.IO) private var session: GatewaySession? = null @@ -25,22 +25,20 @@ class GatewayHandler( private var retryAttempts: Int = 0 @OptIn(ExperimentalSerializationApi::class) - suspend fun connect() { + suspend fun connect(token: String) { if (session != null) { logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." } return } - val token = auth.token - ?: throw IllegalStateException("Tried to connect to gateway with no token") session = GatewaySession.start( token = token, - eventHandlers = handlers, resumeProps = resumeProps, onSuccess = { logger.d { "Successful session start" } retryAttempts = 0 }, + onDispatch = { scope.launch { mEvents.emit(it) } }, onDestroy = { reason, resumeProps -> session = null @@ -61,7 +59,7 @@ class GatewayHandler( logger.d { "Reconnecting in ${dur.inWholeMilliseconds}ms" } delay(dur) retryAttempts += 1 - res = runCatching { connect() } + res = runCatching { connect(token) } res.exceptionOrNull()?.let { logger.e(it) { "Reconnect failed" } } diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt similarity index 90% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt index 1ac638b..ce40047 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway import co.touchlab.kermit.Logger import io.ktor.client.HttpClient @@ -22,20 +22,19 @@ import kotlinx.coroutines.flow.onCompletion import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.isActive import kotlinx.coroutines.launch -import moe.lava.neon.core.api.ApiConstants -import moe.lava.neon.core.api.ApiConstants.json -import moe.lava.neon.core.di.EventHandlerGraph +import moe.lava.neon.api.ApiConstants +import moe.lava.neon.api.ApiConstants.json import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds private val logger = Logger.withTag("neon.core.api.gateway/session") -class GatewaySession private constructor( +internal class GatewaySession private constructor( private var ws: DefaultClientWebSocketSession, private val token: String, - private val handlers: EventHandlerGraph, private val scope: CoroutineScope, private var resumeProps: ResumeProperties?, + private val onDispatch: (Event.Dispatch) -> Unit, private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit, private val onSuccess: () -> Unit, ) { @@ -46,13 +45,13 @@ class GatewaySession private constructor( companion object { suspend fun start( token: String, - eventHandlers: EventHandlerGraph, client: HttpClient = HttpClient { install(HttpCookies) install(WebSockets) }, scope: CoroutineScope = CoroutineScope(Dispatchers.IO), resumeProps: ResumeProperties? = null, + onDispatch: (Event.Dispatch) -> Unit, onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit, onSuccess: () -> Unit, ): GatewaySession { @@ -67,7 +66,7 @@ class GatewaySession private constructor( } } - return GatewaySession(ws, token, eventHandlers, scope, resumeProps, onDestroy, onSuccess) + return GatewaySession(ws, token, scope, resumeProps, onDispatch, onDestroy, onSuccess) } } @@ -100,19 +99,27 @@ class GatewaySession private constructor( private suspend fun handlePayload(payload: Payload.Incoming<*>) { logger.d { payload.toString() } - when (val event = payload.d) { + val event = payload.d + when (event) { is Event.Heartbeat -> handleHeartbeat() is Event.Reconnect -> close(GatewayCloseReason.ServerReconnect) is Event.InvalidSession -> close(GatewayCloseReason.InvalidSession(event.resumable)) is Event.Hello -> handleHello(event) is Event.HeartbeatAck -> { missedHeartbeats -= 1 } - is Event.Ready -> handlers.ready.handle(event) { - resumeProps = it + is Event.Ready -> { + resumeProps = ResumeProperties( + sessionId = event.sessionId, + resumeGatewayUrl = event.resumeGatewayUrl, + lastSequence = 0, + ) onSuccess() } is Event.Resumed -> onSuccess() } + if (event is Event.Dispatch) { + onDispatch(event) + } } private suspend fun handleUnknownPayload(payload: Payload.Unknown) { diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt similarity index 95% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt index 446980b..e65fa3b 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/Payloads.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/Payloads.kt @@ -1,9 +1,9 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway import kotlinx.serialization.Serializable import kotlinx.serialization.json.JsonElement -import moe.lava.neon.core.api.ApiConstants -import moe.lava.neon.core.api.structures.User +import moe.lava.neon.api.ApiConstants +import moe.lava.neon.api.objects.User sealed interface Payload { val op: Int diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/ResumeProperties.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/ResumeProperties.kt similarity index 55% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/ResumeProperties.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/ResumeProperties.kt index 092497d..f962e3d 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/ResumeProperties.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/ResumeProperties.kt @@ -1,6 +1,6 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway -data class ResumeProperties( +internal data class ResumeProperties( val sessionId: String, val resumeGatewayUrl: String, val lastSequence: Int, diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/SerializingExtensions.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/SerializingExtensions.kt similarity index 83% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/SerializingExtensions.kt rename to api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/SerializingExtensions.kt index f95aaa3..78eab17 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/SerializingExtensions.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/SerializingExtensions.kt @@ -1,12 +1,10 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway import kotlinx.serialization.json.JsonNull import kotlinx.serialization.json.decodeFromJsonElement -import moe.lava.neon.core.api.ApiConstants +import moe.lava.neon.api.ApiConstants.json -private val json = ApiConstants.json - -fun T.pack(): Payload.Outgoing { +internal fun T.pack(): Payload.Outgoing { val opcode: Int = when (this) { is Event.Heartbeat -> 1 is Event.Identify -> 2 @@ -16,7 +14,7 @@ fun T.pack(): Payload.Outgoing { return Payload.Outgoing(op = opcode, d = this) } -fun Payload.Unknown.asIncoming() : Payload.WithSequence { +internal fun Payload.Unknown.asIncoming() : Payload.WithSequence { return when (op) { 0 -> when (t) { "READY" -> decode() diff --git a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/handlers/EventHandlers.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/handlers/EventHandlers.kt new file mode 100644 index 0000000..5c721fd --- /dev/null +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/handlers/EventHandlers.kt @@ -0,0 +1,7 @@ +package moe.lava.neon.api.gateway.handlers + +import moe.lava.neon.api.gateway.Event + +sealed interface Handler { + suspend fun handle(event: T) +} diff --git a/api/rest/build.gradle.kts b/api/rest/build.gradle.kts new file mode 100644 index 0000000..028e304 --- /dev/null +++ b/api/rest/build.gradle.kts @@ -0,0 +1,59 @@ +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("jvmTest") { + useJUnitPlatform() +} + +//tasks.withType().configureEach { +// logger.lifecycle("UP-TO-DATE check for $name is disabled, forcing it to run.") +// outputs.upToDateWhen { false } +//} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt similarity index 76% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt rename to api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt index d34dbf3..ffbdc86 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt @@ -1,11 +1,9 @@ -package moe.lava.neon.core.api +package moe.lava.neon.api import co.touchlab.kermit.Logger -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn import io.ktor.client.HttpClient import io.ktor.client.call.body +import io.ktor.client.engine.HttpClientEngine import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.cookies.HttpCookies @@ -14,18 +12,21 @@ import io.ktor.client.plugins.plugin import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.request.header import io.ktor.client.statement.bodyAsText +import io.ktor.http.ContentType +import io.ktor.http.contentType import io.ktor.http.userAgent import io.ktor.serialization.kotlinx.json.json import io.ktor.util.appendAll import kotlinx.serialization.ExperimentalSerializationApi -import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNamingStrategy -import moe.lava.neon.core.api.captcha.CaptchaRequest -import moe.lava.neon.core.api.captcha.CaptchaResponse +import moe.lava.neon.common.captcha.CaptchaRequest +import moe.lava.neon.common.captcha.CaptchaResponse + +class ApiClient internal constructor( + 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 var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null @@ -35,8 +36,8 @@ class ApiClient { } @OptIn(ExperimentalSerializationApi::class) - val client = HttpClient { - expectSuccess = true + internal val client = HttpClient(engine) { + expectSuccess = assertSuccess install(ContentNegotiation) { json(ApiConstants.json) } @@ -44,6 +45,7 @@ class ApiClient { install(HttpCookies) defaultRequest { url("https://discord.com/api/v9/") + contentType(ContentType.Application.Json) userAgent(ApiConstants.userAgent) headers.appendAll(ApiConstants.baseHeaders) } @@ -51,6 +53,7 @@ class ApiClient { plugin(HttpSend).intercept { req -> logger.d { "Intercepting ${req.url.buildString()}" } val call = execute(req) + logger.d { "recv ${call.response.bodyAsText()}" } if (call.response.status.value != 400) return@intercept call logger.d { "Found 400 response: ${call.response.bodyAsText()}" } val captchaRequest = runCatching { call.response.body() } @@ -86,20 +89,3 @@ class ApiClient { } } } - -@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) - } -} diff --git a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiResponse.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiResponse.kt new file mode 100644 index 0000000..f9c69c1 --- /dev/null +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiResponse.kt @@ -0,0 +1,15 @@ +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( + val response: HttpResponse, + private val bodyType: TypeInfo, +) { + suspend fun body() = response.body(bodyType) as T +} + +inline fun HttpResponse.wrap() = ApiResponse(this, typeInfo()) diff --git a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/endpoints/Auth.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/endpoints/Auth.kt new file mode 100644 index 0000000..ed00a39 --- /dev/null +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/endpoints/Auth.kt @@ -0,0 +1,46 @@ +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() + +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() diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt new file mode 100644 index 0000000..5c01a25 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt @@ -0,0 +1,30 @@ +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 + } +}) diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt new file mode 100644 index 0000000..ba65bc1 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt @@ -0,0 +1,105 @@ +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() + private val logins = mutableMapOf() + private var captcha: Pair = generateCaptcha() + + var isCaptchaEnabled = false + + fun createLogin(email: String, password: String) { + logins[email] = password + } + + fun generateCaptcha(): Pair { + 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) +} diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt new file mode 100644 index 0000000..f5cf9ee --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt @@ -0,0 +1,84 @@ +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 endpointTestFactory( + endpoint: String, + testName: String?, + response: suspend () -> ApiResponse, + also: suspend FunSpecContainerScope.(response: ApiResponse) -> 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 FunSpec.withFactory( + testName: String? = null, + endpoint: String, + response: suspend () -> ApiResponse, + also: suspend FunSpecContainerScope.(response: ApiResponse) -> Unit = {}, +) { + include(endpointTestFactory(endpoint, testName, response, also)) +} + diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt new file mode 100644 index 0000000..760c244 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt @@ -0,0 +1,54 @@ +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 + } + } + } +}) diff --git a/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt new file mode 100644 index 0000000..5ec810d --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt @@ -0,0 +1,6 @@ +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"}}""" +} diff --git a/api/shared/build.gradle.kts b/api/shared/build.gradle.kts new file mode 100644 index 0000000..6b1f922 --- /dev/null +++ b/api/shared/build.gradle.kts @@ -0,0 +1,26 @@ +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) + } + } +} diff --git a/core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt b/api/shared/src/androidMain/kotlin/moe/lava/neon/api/ApiConstants.android.kt similarity index 60% rename from core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt rename to api/shared/src/androidMain/kotlin/moe/lava/neon/api/ApiConstants.android.kt index 54b2c9c..37847a8 100644 --- a/core/src/androidMain/kotlin/moe/lava/neon/core/api/ApiConstants.android.kt +++ b/api/shared/src/androidMain/kotlin/moe/lava/neon/api/ApiConstants.android.kt @@ -1,12 +1,14 @@ -package moe.lava.neon.core.api +package moe.lava.neon.api +import android.annotation.SuppressLint +import android.os.Build import java.util.Locale -@Suppress("ConstantLocale") +@SuppressLint("ConstantLocale") internal actual val platformSuperProps = PlatformProps( - device = android.os.Build.DEVICE, + device = Build.DEVICE, // 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) systemLocale = Locale.getDefault().language, - osVersion = "${android.os.Build.VERSION.SDK_INT}", + osVersion = "${Build.VERSION.SDK_INT}", ) diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt similarity index 97% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt rename to api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt index 42fc251..dbbe8f2 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiConstants.kt +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api +package moe.lava.neon.api import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.Serializable @@ -24,7 +24,14 @@ internal data class PlatformProps( internal expect val platformSuperProps: PlatformProps object ApiConstants { - val superProps = Base64.encode(Json.encodeToString(SuperProperties()).encodeToByteArray()) + @OptIn(ExperimentalSerializationApi::class) + val json = Json { + namingStrategy = JsonNamingStrategy.SnakeCase + ignoreUnknownKeys = true + encodeDefaults = true + } + + val superProps = Base64.encode(json.encodeToString(SuperProperties()).encodeToByteArray()) val baseHeaders = mapOf( "X-Debug-Options" to "bugReporterEnabled", "X-Discord-Locale" to "en-US", @@ -34,13 +41,6 @@ object ApiConstants { const val userAgent = "Discord-Android/311020;RNA" 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 @Suppress("PropertyName") @Serializable diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Snowflake.kt similarity index 80% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt rename to api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Snowflake.kt index 4d8dec5..3172955 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/Snowflake.kt +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/Snowflake.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.structures +package moe.lava.neon.api.objects import kotlinx.serialization.Serializable import kotlinx.serialization.builtins.LongAsStringSerializer diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/User.kt similarity index 78% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt rename to api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/User.kt index eb78a93..3168445 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/structures/User.kt +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects/User.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.structures +package moe.lava.neon.api.objects import kotlinx.serialization.Serializable diff --git a/core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt b/api/shared/src/jvmMain/kotlin/moe/lava/neon/api/ApiConstants.jvm.kt similarity index 85% rename from core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt rename to api/shared/src/jvmMain/kotlin/moe/lava/neon/api/ApiConstants.jvm.kt index 4e2e603..70ff314 100644 --- a/core/src/jvmMain/kotlin/moe/lava/neon/core/api/ApiConstants.jvm.kt +++ b/api/shared/src/jvmMain/kotlin/moe/lava/neon/api/ApiConstants.jvm.kt @@ -1,7 +1,8 @@ -package moe.lava.neon.core.api +package moe.lava.neon.api import java.util.Locale +// TODO @Suppress("ConstantLocale") internal actual val platformSuperProps = PlatformProps( device = "", diff --git a/build.gradle.kts b/build.gradle.kts index 8c3efbb..62884ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,12 +2,14 @@ plugins { // this is necessary to avoid the plugins to be loaded multiple times // in each subproject's classloader alias(libs.plugins.androidApplication) apply false - alias(libs.plugins.androidLibrary) apply false + alias(libs.plugins.androidMultiplatformLibrary) apply false alias(libs.plugins.composeHotReload) apply false alias(libs.plugins.composeMultiplatform) 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.kotlinSerialization) apply false - alias(libs.plugins.metro) apply false + alias(libs.plugins.ksp) apply false alias(libs.plugins.sqldelight) apply false } diff --git a/common/build.gradle.kts b/common/build.gradle.kts new file mode 100644 index 0000000..84aa4ff --- /dev/null +++ b/common/build.gradle.kts @@ -0,0 +1,14 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvm() + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.core) + } + } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt b/common/src/commonMain/kotlin/moe/lava/neon/common/captcha/CaptchaRequest.kt similarity index 89% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt rename to common/src/commonMain/kotlin/moe/lava/neon/common/captcha/CaptchaRequest.kt index 4357d6d..9ded522 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaRequest.kt +++ b/common/src/commonMain/kotlin/moe/lava/neon/common/captcha/CaptchaRequest.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.captcha +package moe.lava.neon.common.captcha import kotlinx.serialization.Serializable diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt b/common/src/commonMain/kotlin/moe/lava/neon/common/captcha/CaptchaResponse.kt similarity index 80% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt rename to common/src/commonMain/kotlin/moe/lava/neon/common/captcha/CaptchaResponse.kt index 7bc9d59..85f93df 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/captcha/CaptchaResponse.kt +++ b/common/src/commonMain/kotlin/moe/lava/neon/common/captcha/CaptchaResponse.kt @@ -1,4 +1,4 @@ -package moe.lava.neon.core.api.captcha +package moe.lava.neon.common.captcha sealed class CaptchaResponse { data class Success(val token: String) : CaptchaResponse() diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 78e5ec2..97f594e 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,27 +1,33 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.androidLibrary) + alias(libs.plugins.androidMultiplatformLibrary) + alias(libs.plugins.koinCompiler) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) - alias(libs.plugins.metro) alias(libs.plugins.sqldelight) } kotlin { - jvm() - androidTarget { + androidLibrary { + namespace = "moe.lava.neon.core" + compileSdk = libs.versions.android.compileSdk.get().toInt() + compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } } + jvm() + sourceSets { commonMain.dependencies { - implementation(libs.ktor.client.core) - implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.websockets) - implementation(libs.ktor.serialization.kotlinx.json) + implementation(project(":api:gateway")) + implementation(project(":api:rest")) + implementation(project(":common")) + + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.core) implementation(libs.kermit) implementation(libs.settings) @@ -29,40 +35,6 @@ kotlin { commonTest.dependencies { implementation(libs.kotlin.test) } - jvmMain.dependencies { - implementation(libs.ktor.client.okhttp) - } - androidMain.dependencies { - implementation(libs.ktor.client.okhttp) - } - } -} - -dependencies { - coreLibraryDesugaring(libs.desugar) -} - -android { - 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 } } diff --git a/core/src/androidMain/kotlin/moe/lava/neon/core/Platform.android.kt b/core/src/androidMain/kotlin/moe/lava/neon/core/Platform.android.kt deleted file mode 100644 index 544fc61..0000000 --- a/core/src/androidMain/kotlin/moe/lava/neon/core/Platform.android.kt +++ /dev/null @@ -1,9 +0,0 @@ -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() diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt index 5d5040b..3492b1a 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt @@ -2,13 +2,8 @@ package moe.lava.neon.core import com.russhwolf.settings.Settings import com.russhwolf.settings.nullableString -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -@SingleIn(AppScope::class) -@Inject -class AppSettings { +internal class AppSettings { private val settings = Settings() var fingerprint by settings.nullableString() diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/Platform.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/Platform.kt deleted file mode 100644 index aae09e6..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/Platform.kt +++ /dev/null @@ -1,7 +0,0 @@ -package moe.lava.neon.core - -interface Platform { - val name: String -} - -expect fun getPlatform(): Platform diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt deleted file mode 100644 index 5f6a6ac..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt +++ /dev/null @@ -1,5 +0,0 @@ -package moe.lava.neon.core.api.gateway.handlers - -import moe.lava.neon.core.api.gateway.Event - -sealed interface Handler diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt deleted file mode 100644 index f9b5afc..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt +++ /dev/null @@ -1,20 +0,0 @@ -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 { - fun handle(event: Event.Ready, updateResumeProps: (ResumeProperties) -> Unit) { - logger.i { "Received payload $event" } - updateResumeProps(ResumeProperties( - sessionId = event.sessionId, - resumeGatewayUrl = event.resumeGatewayUrl, - lastSequence = 0, - )) - } -} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt deleted file mode 100644 index 1b38b5a..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt +++ /dev/null @@ -1,18 +0,0 @@ -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 -} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt new file mode 100644 index 0000000..600b7f2 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt @@ -0,0 +1,23 @@ +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() + + single() + single() + single() + single() + + single() +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/di/EventHandlerGraph.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/EventHandlerGraph.kt deleted file mode 100644 index dfa03dd..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/EventHandlerGraph.kt +++ /dev/null @@ -1,12 +0,0 @@ -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 -} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt index 93bbae6..0137c4e 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/AuthRepository.kt @@ -1,86 +1,50 @@ package moe.lava.neon.core.repository import co.touchlab.kermit.Logger -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.Inject -import dev.zacsweers.metro.SingleIn -import io.ktor.client.call.body -import io.ktor.client.request.get -import io.ktor.client.request.header -import io.ktor.client.request.parameter -import io.ktor.client.request.post -import io.ktor.client.request.setBody -import io.ktor.http.ContentType -import io.ktor.http.contentType -import kotlinx.serialization.Serializable +import moe.lava.neon.api.ApiClient +import moe.lava.neon.api.endpoints.getExperiments +import moe.lava.neon.api.endpoints.login 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 { + // TODO: Specify all possible error types here + data class Failed(val error: Throwable) : AuthResponse() data class Success(val token: String) : AuthResponse() // TODO // data class MFARequested() : AuthResponse() } -@Inject -@SingleIn(AppScope::class) -class AuthRepository( +class AuthRepository internal constructor( private val settings: AppSettings, private val api: ApiClient, ) { private val logger = Logger.withTag("neon.core.repo/auth") - var token by settings::token - private set + private var token by settings::token + private var fingerprint by settings::fingerprint - var fingerprint by settings::fingerprint - private set + val loggedIn get() = token != null suspend fun login( email: String, password: String, ): AuthResponse { - if (fingerprint == null) { - fingerprint = api.client.get("experiments") { - parameter("with_guild_experiments", "true") - }.body().fingerprint - } + try { + if (fingerprint == null) { + fingerprint = api.getExperiments().body().fingerprint + } - val res = api.client.post("auth/login") { - header("X-Fingerprint", fingerprint) - contentType(ContentType.Application.Json) - setBody(LoginRequest( - login = email, + 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 body = res.body() - logger.i { "Login success $body" } - this.token = body.token - return AuthResponse.Success(body.token) } fun login(token: String): String { diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/CaptchaRepository.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/CaptchaRepository.kt new file mode 100644 index 0000000..6e5911f --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/CaptchaRepository.kt @@ -0,0 +1,11 @@ +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) +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/GatewayRepository.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/GatewayRepository.kt new file mode 100644 index 0000000..84f14f5 --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/GatewayRepository.kt @@ -0,0 +1,18 @@ +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 = runCatching { + val token = settings.token + ?: throw IllegalArgumentException("Tried to start gateway with no token") + + gateway.connect(token) + } + + suspend fun pause() = runCatching { gateway.disconnect() } +} diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/UserRepository.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/UserRepository.kt index 53ff0c6..1392010 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/repository/UserRepository.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/repository/UserRepository.kt @@ -1,10 +1,4 @@ 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 { } diff --git a/core/src/jvmMain/kotlin/moe/lava/neon/core/Platform.jvm.kt b/core/src/jvmMain/kotlin/moe/lava/neon/core/Platform.jvm.kt deleted file mode 100644 index 509cf56..0000000 --- a/core/src/jvmMain/kotlin/moe/lava/neon/core/Platform.jvm.kt +++ /dev/null @@ -1,7 +0,0 @@ -package moe.lava.neon.core - -class JVMPlatform : Platform { - override val name: String = "Java ${System.getProperty("java.version")}" -} - -actual fun getPlatform(): Platform = JVMPlatform() diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e1d141..189f3c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -1,6 +1,6 @@ [versions] #noinspection AndroidGradlePluginVersion -agp = "8.13.2" +agp = "9.0.0" android-compileSdk = "36" android-minSdk = "24" android-targetSdk = "36" @@ -19,12 +19,16 @@ desugar = "2.1.5" hcaptcha = "4.4.0" junit = "4.13.2" kermit = "2.0.8" +koin-bom = "4.2.0-RC1" +koin-plugin = "0.3.0" +kotest = "6.1.2" kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.10.0" +ksp = "2.3.4" ktor = "3.4.0" material3 = "1.11.0-alpha02" material3-adaptive = "1.3.0-alpha04" -metro = "0.10.2" settings = "1.3.0" sqldelight = "2.2.1" @@ -54,24 +58,38 @@ 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" } junit = { module = "junit:junit", version.ref = "junit" } 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-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-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-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-websockets = { module = "io.ktor:ktor-client-websockets", 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" } [plugins] androidApplication = { id = "com.android.application", version.ref = "agp" } -androidLibrary = { id = "com.android.library", version.ref = "agp" } +androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" } composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } 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" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } -metro = { id = "dev.zacsweers.metro", version.ref = "metro" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } diff --git a/gradle/wrapper/gradle-wrapper.properties b/gradle/wrapper/gradle-wrapper.properties index d4081da..2e11132 100644 --- a/gradle/wrapper/gradle-wrapper.properties +++ b/gradle/wrapper/gradle-wrapper.properties @@ -1,6 +1,6 @@ distributionBase=GRADLE_USER_HOME distributionPath=wrapper/dists -distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip +distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip networkTimeout=10000 validateDistributionUrl=true zipStoreBase=GRADLE_USER_HOME diff --git a/settings.gradle.kts b/settings.gradle.kts index e405915..2b18bad 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,5 +33,10 @@ plugins { 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(":ui") diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index ed73203..dfd7b9b 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -4,22 +4,29 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) - alias(libs.plugins.androidApplication) + alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.composeHotReload) - alias(libs.plugins.metro) + alias(libs.plugins.koinCompiler) } kotlin { - androidTarget { + androidLibrary { + namespace = "moe.lava.neon.ui" + compileSdk = libs.versions.android.compileSdk.get().toInt() + compilerOptions { jvmTarget.set(JvmTarget.JVM_11) } + + androidResources { + enable = true + } } - + jvm() - + sourceSets { androidMain.dependencies { implementation(libs.compose.ui.tooling.preview) @@ -31,6 +38,7 @@ kotlin { implementation(libs.ktor.client.okhttp) } commonMain.dependencies { + implementation(project(":common")) implementation(project(":core")) implementation(libs.compose.components.resources) implementation(libs.compose.foundation) @@ -54,8 +62,10 @@ kotlin { implementation(libs.kermit) - implementation(libs.metrox.viewmodel.compose) - + implementation(project.dependencies.platform(libs.koin.bom)) + implementation(libs.koin.compose) + implementation(libs.koin.compose.viewmodel) + implementation(libs.koin.compose.navigation3) } commonTest.dependencies { implementation(libs.kotlin.test) @@ -69,38 +79,8 @@ 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 { - debugImplementation(libs.compose.ui.tooling) - coreLibraryDesugaring(libs.desugar) + androidRuntimeClasspath(libs.compose.ui.tooling) } compose.desktop { diff --git a/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt b/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaHandler.android.kt similarity index 96% rename from ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt rename to ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaHandler.android.kt index 6baa380..185be4a 100644 --- a/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaBinder.android.kt +++ b/ui/src/androidMain/kotlin/moe/lava/neon/ui/CaptchaHandler.android.kt @@ -19,8 +19,8 @@ import com.hcaptcha.sdk.HCaptchaVerifyParams import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch -import moe.lava.neon.core.api.ApiClient -import moe.lava.neon.core.api.captcha.CaptchaResponse +import moe.lava.neon.common.captcha.CaptchaRequest +import moe.lava.neon.common.captcha.CaptchaResponse 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" @Composable -actual fun CaptchaBinder(api: ApiClient) { +actual fun getCaptchaHandler(): suspend (CaptchaRequest) -> CaptchaResponse { val context = LocalContext.current val queue = MutableSharedFlow>() val scope = rememberCoroutineScope() @@ -67,7 +67,7 @@ actual fun CaptchaBinder(api: ApiClient) { } } - api.setCaptchaHandler { captcha -> + return { captcha -> val intent = Intent(context, HCaptchaActivity::class.java).apply { putExtra(EXTRA_SITE_KEY, captcha.captchaSitekey) putExtra(EXTRA_RQ_DATA, captcha.captchaRqdata) diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt index a24fac4..cc2f999 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt @@ -5,7 +5,6 @@ import androidx.compose.material3.MaterialExpressiveTheme import androidx.compose.material3.MotionScheme import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.runtime.Composable -import androidx.compose.runtime.CompositionLocalProvider import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.entryProvider @@ -14,12 +13,11 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator import androidx.navigation3.ui.NavDisplay import androidx.savedstate.serialization.SavedStateConfiguration import co.touchlab.kermit.Logger -import dev.zacsweers.metro.createGraph -import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic -import moe.lava.neon.ui.di.AppUiGraph +import moe.lava.neon.core.repository.AuthRepository +import moe.lava.neon.core.repository.CaptchaRepository import moe.lava.neon.ui.screens.Login import moe.lava.neon.ui.screens.Sample import moe.lava.neon.ui.screens.chat.Chat @@ -29,6 +27,7 @@ import moe.lava.neon.ui.screens.navigator.NavigatorModel import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider import moe.lava.neon.ui.util.ThreePaneSceneStrategy import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy +import org.koin.compose.koinInject import kotlin.system.exitProcess object Route { @@ -68,81 +67,81 @@ fun App() { exitProcess(1) } - val uiGraph = createGraph() - val graph = uiGraph.core - CaptchaBinder(graph.api) - CompositionLocalProvider(LocalMetroViewModelFactory provides uiGraph.metroViewModelFactory) { - MaterialExpressiveTheme( - colorScheme = getColorScheme(), - motionScheme = MotionScheme.expressive(), - ) { - val init = if (graph.auth.token != null) Route.Sample else Route.Login - val backStack = rememberNavBackStack(config, Route.Sample) - val threePaneStrategy = rememberThreePaneSceneStrategy() - NavDisplay( - backStack = backStack, - entryDecorators = listOf( - rememberSaveableStateHolderNavEntryDecorator(), - rememberViewModelStoreNavEntryDecorator(), - ), - onBack = { backStack.removeLastOrNull() }, - sceneStrategy = threePaneStrategy, - entryProvider = entryProvider { - entry { - Login( - onSuccess = { - backStack.clear() - backStack.add(Route.Sample) - } - ) - } - entry { - Sample( - navTest = { - backStack.add(Route.Navigator(it)) - backStack.add(Route.Chat) - backStack.add(Route.MembersList) - }, - onRequestLogout = { - backStack.clear() - backStack.add(Route.Login) - } - ) - } + val auth: AuthRepository = koinInject() + val captcha: CaptchaRepository = koinInject() + captcha.setHandler(getCaptchaHandler()) - entry( - metadata = ThreePaneSceneStrategy.listPane() - ) { key -> - if (key.left) { - Navigator( - NavigatorPreviewProvider.base2.copy( - guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar - ) - ) - } else { - Navigator( - NavigatorPreviewProvider.base2.copy( - guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet - ) - ) + MaterialExpressiveTheme( + colorScheme = getColorScheme(), + motionScheme = MotionScheme.expressive(), + ) { + val init = if (auth.loggedIn) Route.Sample else Route.Login +// val backStack = rememberNavBackStack(config, init) + val backStack = rememberNavBackStack(config, Route.Sample) + val threePaneStrategy = rememberThreePaneSceneStrategy() + NavDisplay( + backStack = backStack, + entryDecorators = listOf( + rememberSaveableStateHolderNavEntryDecorator(), + rememberViewModelStoreNavEntryDecorator(), + ), + onBack = { backStack.removeLastOrNull() }, + sceneStrategy = threePaneStrategy, + entryProvider = entryProvider { + entry { + Login( + onSuccess = { + backStack.clear() + backStack.add(Route.Sample) } - } + ) + } + entry { + Sample( + navTest = { + backStack.add(Route.Navigator(it)) + backStack.add(Route.Chat) + backStack.add(Route.MembersList) + }, + onRequestLogout = { + backStack.clear() + backStack.add(Route.Login) + } + ) + } - entry( - metadata = ThreePaneSceneStrategy.detailPane() - ) { - Chat( - onOpenMembers = { backStack.add(Route.MembersList) } + entry( + 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( - metadata = ThreePaneSceneStrategy.extraPane() - ) { - MembersList() } } - ) - } + + entry( + metadata = ThreePaneSceneStrategy.detailPane() + ) { + Chat( + onOpenMembers = { backStack.add(Route.MembersList) } + ) + } + + entry( + metadata = ThreePaneSceneStrategy.extraPane() + ) { + MembersList() + } + } + ) } } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt deleted file mode 100644 index f8e6966..0000000 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt +++ /dev/null @@ -1,7 +0,0 @@ -package moe.lava.neon.ui - -import androidx.compose.runtime.Composable -import moe.lava.neon.core.api.ApiClient - -@Composable -expect fun CaptchaBinder(api: ApiClient) diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaHandler.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaHandler.kt new file mode 100644 index 0000000..70b32bb --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaHandler.kt @@ -0,0 +1,8 @@ +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 diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/Greeting.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/Greeting.kt deleted file mode 100644 index 63612da..0000000 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/Greeting.kt +++ /dev/null @@ -1,11 +0,0 @@ -package moe.lava.neon.ui - -import moe.lava.neon.core.getPlatform - -class Greeting { - private val platform = getPlatform() - - fun greet(): String { - return "Hello, ${platform.name}!" - } -} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/AppUiGraph.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/AppUiGraph.kt deleted file mode 100644 index 344555a..0000000 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/AppUiGraph.kt +++ /dev/null @@ -1,29 +0,0 @@ -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, Provider>, - override val assistedFactoryProviders: Map, Provider>, - override val manualAssistedFactoryProviders: Map, Provider>, -) : MetroViewModelFactory() diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/KoinInit.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/KoinInit.kt new file mode 100644 index 0000000..ca4a856 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/KoinInit.kt @@ -0,0 +1,13 @@ +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) + } +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/UiModule.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/UiModule.kt new file mode 100644 index 0000000..fd25012 --- /dev/null +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/di/UiModule.kt @@ -0,0 +1,13 @@ +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() + viewModel() +} diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt index 8ef0335..5e0078d 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt @@ -23,11 +23,6 @@ import androidx.compose.ui.text.input.VisualTransformation import androidx.compose.ui.unit.dp import androidx.lifecycle.ViewModel import co.touchlab.kermit.Logger -import dev.zacsweers.metro.AppScope -import dev.zacsweers.metro.ContributesIntoMap -import dev.zacsweers.metro.Inject -import dev.zacsweers.metrox.viewmodel.ViewModelKey -import dev.zacsweers.metrox.viewmodel.metroViewModel import kotlinx.coroutines.launch import moe.lava.neon.core.repository.AuthRepository import moe.lava.neon.core.repository.AuthResponse @@ -35,12 +30,13 @@ import moe.lava.neon.resources.Res import moe.lava.neon.resources.visibility import moe.lava.neon.resources.visibility_off import org.jetbrains.compose.resources.painterResource +import org.koin.compose.viewmodel.koinViewModel @Composable fun Login( onSuccess: () -> Unit, ) { - val viewModel: LoginViewModel = metroViewModel() + val viewModel: LoginViewModel = koinViewModel() val scope = rememberCoroutineScope() Column( @@ -113,9 +109,6 @@ fun Login( } } -@Inject -@ViewModelKey(LoginViewModel::class) -@ContributesIntoMap(AppScope::class) class LoginViewModel( private val auth: AuthRepository ) : ViewModel() { @@ -129,13 +122,13 @@ class LoginViewModel( } suspend fun login(email: String, password: String): LoginResult { - return try { - when (val res = auth.login(email, password)) { - is AuthResponse.Success -> LoginResult.Success + return when (val res = auth.login(email, password)) { + is AuthResponse.Success -> LoginResult.Success + is AuthResponse.Failed -> { + 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()) } } } diff --git a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index 2a5531f..73467fc 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt @@ -20,25 +20,20 @@ import androidx.compose.ui.Modifier import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope 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 moe.lava.neon.core.api.gateway.GatewayHandler 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.compose_multiplatform -import moe.lava.neon.ui.Greeting import org.jetbrains.compose.resources.painterResource +import org.koin.compose.viewmodel.koinViewModel @Composable fun Sample( navTest: (Boolean) -> Unit, onRequestLogout: () -> Unit, ) { - val viewModel: SampleViewModel = metroViewModel() + val viewModel: SampleViewModel = koinViewModel() var showContent by remember { mutableStateOf(false) } Column( modifier = Modifier @@ -57,14 +52,12 @@ fun Sample( Text("Click me (bottom!") } AnimatedVisibility(showContent) { - val greeting = remember { Greeting().greet() } Column( modifier = Modifier.fillMaxWidth(), horizontalAlignment = Alignment.CenterHorizontally, ) { Image(painterResource(Res.drawable.compose_multiplatform), null) - Text("Compose: $greeting") - Text("Passed token: ${viewModel.token?.slice(0..10)}...") + Text("Logged in: ${viewModel.loggedIn}") } } Button(onClick = { @@ -86,31 +79,26 @@ fun Sample( } } -@Inject -@ViewModelKey(SampleViewModel::class) -@ContributesIntoMap(AppScope::class) class SampleViewModel( private val auth: AuthRepository, - private val gateway: GatewayHandler, + private val gateway: GatewayRepository, ) : ViewModel() { private val logger = Logger.withTag("neon.ui.screens/Sample") - val token get() = auth.token + val loggedIn by auth::loggedIn fun connect() { viewModelScope.launch { - try { - gateway.connect() - } catch(e: Throwable) { - logger.e(e) { "Failed to connect to gateway: ${e.stackTraceToString()}" } + val exception = gateway.start().exceptionOrNull() + if (exception != null) { + logger.e(exception) { "Failed to connect to gateway: ${exception.stackTraceToString()}" } } } } fun disconnect() { viewModelScope.launch { - try { - gateway.disconnect() - } catch(e: Throwable) { - logger.e(e) { "Failed to connect to gateway: ${e.stackTraceToString()}" } + val exception = gateway.pause().exceptionOrNull() + if (exception != null) { + logger.e(exception) { "Failed to disconnect from gateway: ${exception.stackTraceToString()}" } } } } diff --git a/ui/src/jvmMain/kotlin/moe/lava/neon/main.kt b/ui/src/jvmMain/kotlin/moe/lava/neon/main.kt index 0db2ea1..9c3c679 100644 --- a/ui/src/jvmMain/kotlin/moe/lava/neon/main.kt +++ b/ui/src/jvmMain/kotlin/moe/lava/neon/main.kt @@ -6,12 +6,16 @@ import androidx.compose.ui.unit.Density import androidx.compose.ui.window.Window import androidx.compose.ui.window.application 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 // So scale them down a bit const val scaleFactor = 0.75f fun main() = application { + initKoin { + printLogger() + } Window( onCloseRequest = ::exitApplication, title = "Neon", diff --git a/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt deleted file mode 100644 index 397f534..0000000 --- a/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt +++ /dev/null @@ -1,13 +0,0 @@ -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()) - } -} diff --git a/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaHandler.jvm.kt b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaHandler.jvm.kt new file mode 100644 index 0000000..56044e0 --- /dev/null +++ b/ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaHandler.jvm.kt @@ -0,0 +1,13 @@ +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()) + } +}