From 2725342c3ffd0981c11e6968944a5022013329c1 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Sun, 1 Feb 2026 00:50:57 +1100 Subject: [PATCH 01/10] refactor: switch from metro to koin Honestly metro looks too overcomplicated and I still don't know how to use it properly. Switching to koin for now as I'm more comfortable with it. --- build.gradle.kts | 2 +- core/build.gradle.kts | 5 +- .../kotlin/moe/lava/neon/core/AppSettings.kt | 5 - .../moe/lava/neon/core/api/ApiClient.kt | 5 - .../neon/core/api/gateway/GatewayHandler.kt | 8 +- .../neon/core/api/gateway/GatewaySession.kt | 6 +- .../handlers/{Handler.kt => EventHandlers.kt} | 4 + .../core/api/gateway/handlers/ReadyHandler.kt | 2 - .../kotlin/moe/lava/neon/core/di/AppGraph.kt | 18 --- .../moe/lava/neon/core/di/CoreModule.kt | 24 +++ .../lava/neon/core/di/EventHandlerGraph.kt | 12 -- .../neon/core/repository/AuthRepository.kt | 5 - .../neon/core/repository/UserRepository.kt | 6 - gradle/libs.versions.toml | 12 +- ui/build.gradle.kts | 8 +- .../kotlin/moe/lava/neon/MainActivity.kt | 16 +- .../commonMain/kotlin/moe/lava/neon/ui/App.kt | 145 +++++++++--------- .../kotlin/moe/lava/neon/ui/di/AppUiGraph.kt | 29 ---- .../kotlin/moe/lava/neon/ui/di/KoinInit.kt | 13 ++ .../kotlin/moe/lava/neon/ui/di/UiModule.kt | 13 ++ .../kotlin/moe/lava/neon/ui/screens/Login.kt | 11 +- .../kotlin/moe/lava/neon/ui/screens/Sample.kt | 11 +- ui/src/jvmMain/kotlin/moe/lava/neon/main.kt | 4 + 23 files changed, 165 insertions(+), 199 deletions(-) rename core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/{Handler.kt => EventHandlers.kt} (72%) delete mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/di/AppGraph.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt delete mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/di/EventHandlerGraph.kt delete mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/di/AppUiGraph.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/di/KoinInit.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/di/UiModule.kt diff --git a/build.gradle.kts b/build.gradle.kts index 8c3efbb..342cac8 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -6,8 +6,8 @@ plugins { 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.kotlinMultiplatform) apply false alias(libs.plugins.kotlinSerialization) apply false - alias(libs.plugins.metro) apply false alias(libs.plugins.sqldelight) apply false } diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 78e5ec2..ed7c496 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -2,9 +2,9 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { alias(libs.plugins.androidLibrary) + alias(libs.plugins.koinCompiler) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) - alias(libs.plugins.metro) alias(libs.plugins.sqldelight) } @@ -23,6 +23,9 @@ kotlin { 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.settings) } 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..2ca64da 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt @@ -2,12 +2,7 @@ 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 { private val settings = Settings() diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt index d34dbf3..727b4e2 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt @@ -1,9 +1,6 @@ package moe.lava.neon.core.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.plugins.HttpSend @@ -23,8 +20,6 @@ import kotlinx.serialization.json.JsonNamingStrategy import moe.lava.neon.core.api.captcha.CaptchaRequest import moe.lava.neon.core.api.captcha.CaptchaResponse -@SingleIn(AppScope::class) -@Inject class ApiClient { private val logger = Logger.withTag("neon.core.api/client") diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt index 40dc00e..9126635 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewayHandler.kt @@ -1,21 +1,19 @@ package moe.lava.neon.core.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.launch import kotlinx.serialization.ExperimentalSerializationApi -import moe.lava.neon.core.di.EventHandlerGraph +import moe.lava.neon.core.api.gateway.handlers.EventHandlers import moe.lava.neon.core.repository.AuthRepository import kotlin.math.pow import kotlin.time.Duration.Companion.seconds -@Inject class GatewayHandler( private val auth: AuthRepository, - private val handlers: EventHandlerGraph, + private val eventHandlers: EventHandlers, ) { private val logger = Logger.withTag("neon.core.api.gateway/handler") private val scope = CoroutineScope(Dispatchers.IO) @@ -35,7 +33,7 @@ class GatewayHandler( session = GatewaySession.start( token = token, - eventHandlers = handlers, + eventHandlers = eventHandlers, resumeProps = resumeProps, onSuccess = { logger.d { "Successful session start" } diff --git a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt index 1ac638b..c2fc7b7 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/GatewaySession.kt @@ -24,7 +24,7 @@ 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.core.api.gateway.handlers.EventHandlers import kotlin.random.Random import kotlin.time.Duration.Companion.milliseconds @@ -33,7 +33,7 @@ private val logger = Logger.withTag("neon.core.api.gateway/session") class GatewaySession private constructor( private var ws: DefaultClientWebSocketSession, private val token: String, - private val handlers: EventHandlerGraph, + private val handlers: EventHandlers, private val scope: CoroutineScope, private var resumeProps: ResumeProperties?, private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit, @@ -46,7 +46,7 @@ class GatewaySession private constructor( companion object { suspend fun start( token: String, - eventHandlers: EventHandlerGraph, + eventHandlers: EventHandlers, client: HttpClient = HttpClient { install(HttpCookies) install(WebSockets) 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/EventHandlers.kt similarity index 72% rename from core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/Handler.kt rename to core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/EventHandlers.kt index 5f6a6ac..87504bf 100644 --- 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/EventHandlers.kt @@ -3,3 +3,7 @@ package moe.lava.neon.core.api.gateway.handlers import moe.lava.neon.core.api.gateway.Event sealed interface Handler + +class EventHandlers( + val ready: ReadyHandler +) 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 index f9b5afc..e3b2ef9 100644 --- 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 @@ -1,13 +1,11 @@ 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" } 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..c16a33e --- /dev/null +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt @@ -0,0 +1,24 @@ +package moe.lava.neon.core.di + +import moe.lava.neon.core.AppSettings +import moe.lava.neon.core.api.ApiClient +import moe.lava.neon.core.api.gateway.GatewayHandler +import moe.lava.neon.core.api.gateway.handlers.EventHandlers +import moe.lava.neon.core.api.gateway.handlers.ReadyHandler +import moe.lava.neon.core.repository.AuthRepository +import moe.lava.neon.core.repository.UserRepository +import org.koin.dsl.module +import org.koin.plugin.module.dsl.single + +val coreModule = module { + single() + 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..9375cd2 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,9 +1,6 @@ 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 @@ -46,8 +43,6 @@ sealed class AuthResponse { // data class MFARequested() : AuthResponse() } -@Inject -@SingleIn(AppScope::class) class AuthRepository( private val settings: AppSettings, private val api: ApiClient, 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index 9e1d141..ef8ff17 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -19,12 +19,13 @@ 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" kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" 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,6 +55,12 @@ 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" } 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" } @@ -62,7 +69,6 @@ ktor-client-core = { module = "io.ktor:ktor-client-core", 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] @@ -71,7 +77,7 @@ androidLibrary = { id = "com.android.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" } 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" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index ed73203..31edb2d 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -8,7 +8,7 @@ plugins { alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeCompiler) alias(libs.plugins.composeHotReload) - alias(libs.plugins.metro) + alias(libs.plugins.koinCompiler) } kotlin { @@ -54,8 +54,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) diff --git a/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt b/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt index 45b77df..cc4947f 100644 --- a/ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt +++ b/ui/src/androidMain/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/commonMain/kotlin/moe/lava/neon/ui/App.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt index a24fac4..c42a2cc 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,10 @@ 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.ui.screens.Login import moe.lava.neon.ui.screens.Sample import moe.lava.neon.ui.screens.chat.Chat @@ -29,6 +26,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 +66,78 @@ 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) - } - ) - } - - 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 - ) - ) + val auth: AuthRepository = koinInject() + CaptchaBinder(koinInject()) + MaterialExpressiveTheme( + colorScheme = getColorScheme(), + motionScheme = MotionScheme.expressive(), + ) { + val init = if (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) + } + ) + } - 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/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..b04a8fe 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() { 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..64579ba 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,11 +20,6 @@ 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 @@ -32,13 +27,14 @@ 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 @@ -86,9 +82,6 @@ fun Sample( } } -@Inject -@ViewModelKey(SampleViewModel::class) -@ContributesIntoMap(AppScope::class) class SampleViewModel( private val auth: AuthRepository, private val gateway: GatewayHandler, 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", From 0d84411f143e9d820edeb5cb03ef4fd0659bf34f Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Thu, 5 Feb 2026 01:05:02 +1100 Subject: [PATCH 02/10] refactor: split up core into multiple modules --- api/gateway/build.gradle.kts | 65 +++++++++++++++++++ .../moe/lava/neon}/api/gateway/Capability.kt | 2 +- .../neon}/api/gateway/GatewayCloseReason.kt | 2 +- .../lava/neon}/api/gateway/GatewayHandler.kt | 20 +++--- .../lava/neon}/api/gateway/GatewaySession.kt | 24 ++++--- .../moe/lava/neon}/api/gateway/Payloads.kt | 6 +- .../neon}/api/gateway/ResumeProperties.kt | 4 +- .../api/gateway/SerializingExtensions.kt | 10 ++- .../api/gateway/handlers/EventHandlers.kt | 7 ++ api/rest/build.gradle.kts | 65 +++++++++++++++++++ .../kotlin/moe/lava/neon}/api/ApiClient.kt | 25 +------ api/shared/build.gradle.kts | 50 ++++++++++++++ .../lava/neon}/api/ApiConstants.android.kt | 10 +-- .../kotlin/moe/lava/neon}/api/ApiConstants.kt | 2 +- .../moe/lava/neon/api/objects}/Snowflake.kt | 2 +- .../kotlin/moe/lava/neon/api/objects}/User.kt | 2 +- .../moe/lava/neon}/api/ApiConstants.jvm.kt | 3 +- common/build.gradle.kts | 14 ++++ .../neon/common}/captcha/CaptchaRequest.kt | 2 +- .../neon/common}/captcha/CaptchaResponse.kt | 2 +- core/build.gradle.kts | 11 +--- .../kotlin/moe/lava/neon/core/AppSettings.kt | 2 +- .../api/gateway/handlers/EventHandlers.kt | 9 --- .../core/api/gateway/handlers/ReadyHandler.kt | 18 ----- .../moe/lava/neon/core/di/CoreModule.kt | 13 ++-- .../neon/core/repository/AuthRepository.kt | 11 ++-- .../neon/core/repository/CaptchaRepository.kt | 11 ++++ .../neon/core/repository/GatewayRepository.kt | 18 +++++ gradle/libs.versions.toml | 3 + settings.gradle.kts | 4 ++ ui/build.gradle.kts | 1 + ...r.android.kt => CaptchaHandler.android.kt} | 8 +-- .../commonMain/kotlin/moe/lava/neon/ui/App.kt | 8 ++- .../kotlin/moe/lava/neon/ui/CaptchaBinder.kt | 7 -- .../kotlin/moe/lava/neon/ui/CaptchaHandler.kt | 8 +++ .../kotlin/moe/lava/neon/ui/screens/Sample.kt | 18 +++-- .../moe/lava/neon/ui/CaptchaBinder.jvm.kt | 13 ---- .../moe/lava/neon/ui/CaptchaHandler.jvm.kt | 13 ++++ 38 files changed, 344 insertions(+), 149 deletions(-) create mode 100644 api/gateway/build.gradle.kts rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/Capability.kt (97%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/GatewayCloseReason.kt (95%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/GatewayHandler.kt (83%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/GatewaySession.kt (91%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/Payloads.kt (95%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/ResumeProperties.kt (55%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/gateway/src/commonMain/kotlin/moe/lava/neon}/api/gateway/SerializingExtensions.kt (83%) create mode 100644 api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/handlers/EventHandlers.kt create mode 100644 api/rest/build.gradle.kts rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/rest/src/commonMain/kotlin/moe/lava/neon}/api/ApiClient.kt (81%) create mode 100644 api/shared/build.gradle.kts rename {core/src/androidMain/kotlin/moe/lava/neon/core => api/shared/src/androidMain/kotlin/moe/lava/neon}/api/ApiConstants.android.kt (60%) rename {core/src/commonMain/kotlin/moe/lava/neon/core => api/shared/src/commonMain/kotlin/moe/lava/neon}/api/ApiConstants.kt (99%) rename {core/src/commonMain/kotlin/moe/lava/neon/core/api/structures => api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects}/Snowflake.kt (80%) rename {core/src/commonMain/kotlin/moe/lava/neon/core/api/structures => api/shared/src/commonMain/kotlin/moe/lava/neon/api/objects}/User.kt (78%) rename {core/src/jvmMain/kotlin/moe/lava/neon/core => api/shared/src/jvmMain/kotlin/moe/lava/neon}/api/ApiConstants.jvm.kt (85%) create mode 100644 common/build.gradle.kts rename {core/src/commonMain/kotlin/moe/lava/neon/core/api => common/src/commonMain/kotlin/moe/lava/neon/common}/captcha/CaptchaRequest.kt (89%) rename {core/src/commonMain/kotlin/moe/lava/neon/core/api => common/src/commonMain/kotlin/moe/lava/neon/common}/captcha/CaptchaResponse.kt (80%) delete mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/EventHandlers.kt delete mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/repository/CaptchaRepository.kt create mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/repository/GatewayRepository.kt rename ui/src/androidMain/kotlin/moe/lava/neon/ui/{CaptchaBinder.android.kt => CaptchaHandler.android.kt} (96%) delete mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaBinder.kt create mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/CaptchaHandler.kt delete mode 100644 ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaBinder.jvm.kt create mode 100644 ui/src/jvmMain/kotlin/moe/lava/neon/ui/CaptchaHandler.jvm.kt diff --git a/api/gateway/build.gradle.kts b/api/gateway/build.gradle.kts new file mode 100644 index 0000000..101843d --- /dev/null +++ b/api/gateway/build.gradle.kts @@ -0,0 +1,65 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvm() + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + 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) + } + } +} + +dependencies { + coreLibraryDesugaring(libs.desugar) +} + +android { + namespace = "moe.lava.neon.api.gateway" + 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/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 83% 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 9126635..1d1d1bc 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,4 +1,4 @@ -package moe.lava.neon.core.api.gateway +package moe.lava.neon.api.gateway import co.touchlab.kermit.Logger import kotlinx.coroutines.CoroutineScope @@ -6,15 +6,15 @@ import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay import kotlinx.coroutines.launch import kotlinx.serialization.ExperimentalSerializationApi -import moe.lava.neon.core.api.gateway.handlers.EventHandlers -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 -class GatewayHandler( - private val auth: AuthRepository, - private val eventHandlers: EventHandlers, -) { +typealias EventHandlers = Map, MutableList>> + +class GatewayHandler { + private val eventHandlers: EventHandlers = mutableMapOf() private val logger = Logger.withTag("neon.core.api.gateway/handler") private val scope = CoroutineScope(Dispatchers.IO) private var session: GatewaySession? = null @@ -23,13 +23,11 @@ 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, @@ -59,7 +57,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 91% 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 c2fc7b7..f0c83a0 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,15 +22,14 @@ 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.api.gateway.handlers.EventHandlers +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: EventHandlers, @@ -100,19 +99,28 @@ 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) { + val eventHandlers = handlers[event::class] ?: return + eventHandlers.forEach { it.handle(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..1587873 --- /dev/null +++ b/api/rest/build.gradle.kts @@ -0,0 +1,65 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvm() + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + 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) + } + jvmMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + androidMain.dependencies { + implementation(libs.ktor.client.okhttp) + } + } +} + +dependencies { + coreLibraryDesugaring(libs.desugar) +} + +android { + namespace = "moe.lava.neon.api.rest" + 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/commonMain/kotlin/moe/lava/neon/core/api/ApiClient.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt similarity index 81% 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 727b4e2..7122519 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,4 +1,4 @@ -package moe.lava.neon.core.api +package moe.lava.neon.api import co.touchlab.kermit.Logger import io.ktor.client.HttpClient @@ -15,10 +15,8 @@ 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 { private val logger = Logger.withTag("neon.core.api/client") @@ -81,20 +79,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/shared/build.gradle.kts b/api/shared/build.gradle.kts new file mode 100644 index 0000000..23b67e6 --- /dev/null +++ b/api/shared/build.gradle.kts @@ -0,0 +1,50 @@ +import org.jetbrains.kotlin.gradle.dsl.JvmTarget + +plugins { + alias(libs.plugins.androidLibrary) + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) +} + +kotlin { + jvm() + androidTarget { + compilerOptions { + jvmTarget.set(JvmTarget.JVM_11) + } + } + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + } + } +} + +dependencies { + coreLibraryDesugaring(libs.desugar) +} + +android { + namespace = "moe.lava.neon.api" + 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/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 99% 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..36258d9 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 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/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 ed7c496..00d01b0 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -18,9 +18,10 @@ kotlin { sourceSets { commonMain.dependencies { + implementation(project(":api:gateway")) + implementation(project(":api:rest")) + implementation(project(":common")) implementation(libs.ktor.client.core) - implementation(libs.ktor.client.content.negotiation) - implementation(libs.ktor.client.websockets) implementation(libs.ktor.serialization.kotlinx.json) implementation(project.dependencies.platform(libs.koin.bom)) @@ -32,12 +33,6 @@ kotlin { 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/AppSettings.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt index 2ca64da..3492b1a 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/AppSettings.kt @@ -3,7 +3,7 @@ package moe.lava.neon.core import com.russhwolf.settings.Settings import com.russhwolf.settings.nullableString -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/api/gateway/handlers/EventHandlers.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/EventHandlers.kt deleted file mode 100644 index 87504bf..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/EventHandlers.kt +++ /dev/null @@ -1,9 +0,0 @@ -package moe.lava.neon.core.api.gateway.handlers - -import moe.lava.neon.core.api.gateway.Event - -sealed interface Handler - -class EventHandlers( - val ready: ReadyHandler -) 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 e3b2ef9..0000000 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/api/gateway/handlers/ReadyHandler.kt +++ /dev/null @@ -1,18 +0,0 @@ -package moe.lava.neon.core.api.gateway.handlers - -import co.touchlab.kermit.Logger -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") - -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/CoreModule.kt b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt index c16a33e..6e79c70 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt @@ -1,11 +1,11 @@ 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.api.ApiClient -import moe.lava.neon.core.api.gateway.GatewayHandler -import moe.lava.neon.core.api.gateway.handlers.EventHandlers -import moe.lava.neon.core.api.gateway.handlers.ReadyHandler 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 @@ -15,10 +15,9 @@ val coreModule = module { single() single() + single() + single() single() single() - - single() - single() } 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 9375cd2..e2ecb90 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 @@ -10,8 +10,8 @@ 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.core.AppSettings -import moe.lava.neon.core.api.ApiClient @Serializable private data class ExperimentResponse( @@ -43,16 +43,15 @@ sealed class AuthResponse { // data class MFARequested() : AuthResponse() } -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, 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/gradle/libs.versions.toml b/gradle/libs.versions.toml index ef8ff17..f04b531 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -23,6 +23,7 @@ koin-bom = "4.2.0-RC1" koin-plugin = "0.3.0" kotlin = "2.3.0" kotlinx-coroutines = "1.10.2" +kotlinx-serialization = "1.10.0" ktor = "3.4.0" material3 = "1.11.0-alpha02" material3-adaptive = "1.3.0-alpha04" @@ -64,6 +65,8 @@ koin-test = { module = "io.insert-koin:koin-test" } 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-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } diff --git a/settings.gradle.kts b/settings.gradle.kts index e405915..0811cc7 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,5 +33,9 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +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 31edb2d..d69d7f9 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -31,6 +31,7 @@ kotlin { implementation(libs.ktor.client.okhttp) } commonMain.dependencies { + implementation(project(":common")) implementation(project(":core")) implementation(libs.compose.components.resources) implementation(libs.compose.foundation) 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 c42a2cc..cc2f999 100644 --- a/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt +++ b/ui/src/commonMain/kotlin/moe/lava/neon/ui/App.kt @@ -17,6 +17,7 @@ import kotlinx.serialization.Serializable import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.polymorphic 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 @@ -67,12 +68,15 @@ fun App() { } val auth: AuthRepository = koinInject() - CaptchaBinder(koinInject()) + val captcha: CaptchaRepository = koinInject() + captcha.setHandler(getCaptchaHandler()) + MaterialExpressiveTheme( colorScheme = getColorScheme(), motionScheme = MotionScheme.expressive(), ) { - val init = if (auth.token != null) Route.Sample else Route.Login + 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( 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/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index 64579ba..56f4151 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 @@ -21,8 +21,8 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import co.touchlab.kermit.Logger 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 @@ -84,26 +84,24 @@ fun Sample( 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 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/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()) + } +} From f606eb2e33c0c757492e1715be9d6bacc2f61c70 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Thu, 5 Feb 2026 01:11:37 +1100 Subject: [PATCH 03/10] refactor: delete some starter app stuff --- .../kotlin/moe/lava/neon/core/Platform.android.kt | 9 --------- .../commonMain/kotlin/moe/lava/neon/core/Platform.kt | 7 ------- .../jvmMain/kotlin/moe/lava/neon/core/Platform.jvm.kt | 7 ------- ui/src/commonMain/kotlin/moe/lava/neon/ui/Greeting.kt | 11 ----------- .../kotlin/moe/lava/neon/ui/screens/Sample.kt | 7 ++----- 5 files changed, 2 insertions(+), 39 deletions(-) delete mode 100644 core/src/androidMain/kotlin/moe/lava/neon/core/Platform.android.kt delete mode 100644 core/src/commonMain/kotlin/moe/lava/neon/core/Platform.kt delete mode 100644 core/src/jvmMain/kotlin/moe/lava/neon/core/Platform.jvm.kt delete mode 100644 ui/src/commonMain/kotlin/moe/lava/neon/ui/Greeting.kt 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/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/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/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/screens/Sample.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Sample.kt index 56f4151..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 @@ -25,7 +25,6 @@ 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 @@ -53,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 = { @@ -87,7 +84,7 @@ class SampleViewModel( 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 { From 0a5b0f532afb942d3c3a215c053dbb76dd581cee Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Thu, 5 Feb 2026 01:52:14 +1100 Subject: [PATCH 04/10] refactor: move api request logic completely out of core --- .../kotlin/moe/lava/neon/api/ApiClient.kt | 5 +- .../moe/lava/neon/api/endpoints/Auth.kt | 46 +++++++++++++ core/build.gradle.kts | 2 - .../neon/core/repository/AuthRepository.kt | 66 +++++-------------- .../kotlin/moe/lava/neon/ui/screens/Login.kt | 12 ++-- 5 files changed, 74 insertions(+), 57 deletions(-) create mode 100644 api/rest/src/commonMain/kotlin/moe/lava/neon/api/endpoints/Auth.kt diff --git a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt index 7122519..371b589 100644 --- a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt @@ -11,6 +11,8 @@ 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 @@ -28,7 +30,7 @@ class ApiClient { } @OptIn(ExperimentalSerializationApi::class) - val client = HttpClient { + internal val client = HttpClient { expectSuccess = true install(ContentNegotiation) { json(ApiConstants.json) @@ -37,6 +39,7 @@ class ApiClient { install(HttpCookies) defaultRequest { url("https://discord.com/api/v9/") + contentType(ContentType.Application.Json) userAgent(ApiConstants.userAgent) headers.appendAll(ApiConstants.baseHeaders) } 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..d6a613e --- /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.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 kotlinx.serialization.Serializable +import moe.lava.neon.api.ApiClient + +@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") +}.body() + +suspend fun ApiClient.login(email: String, password: String, fingerprint: String) = client.post("auth/login") { + header("X-Fingerprint", fingerprint) + setBody(LoginRequest( + login = email, + password = password, + )) +}.body() diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 00d01b0..8075553 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -21,8 +21,6 @@ kotlin { implementation(project(":api:gateway")) implementation(project(":api:rest")) implementation(project(":common")) - implementation(libs.ktor.client.core) - implementation(libs.ktor.serialization.kotlinx.json) implementation(project.dependencies.platform(libs.koin.bom)) implementation(libs.koin.core) 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 e2ecb90..eeb458d 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,43 +1,14 @@ package moe.lava.neon.core.repository import co.touchlab.kermit.Logger -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 -@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() @@ -57,24 +28,23 @@ class AuthRepository internal constructor( 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().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!!, + ) + 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/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt b/ui/src/commonMain/kotlin/moe/lava/neon/ui/screens/Login.kt index b04a8fe..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 @@ -122,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()) } } } From 48b69c88a9fc8eb0a54c9b9a026f551cceb909a4 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Thu, 5 Feb 2026 02:46:13 +1100 Subject: [PATCH 05/10] refactor: update to agp 9 --- android/build.gradle.kts | 63 ++++++++++++++++++ .../src/main}/AndroidManifest.xml | 0 .../kotlin/moe/lava/neon/MainActivity.kt | 0 .../drawable-v24/ic_launcher_foreground.xml | 0 .../res/drawable/ic_launcher_background.xml | 0 .../res/mipmap-anydpi-v26/ic_launcher.xml | 0 .../mipmap-anydpi-v26/ic_launcher_round.xml | 0 .../src/main}/res/mipmap-hdpi/ic_launcher.png | Bin .../res/mipmap-hdpi/ic_launcher_round.png | Bin .../src/main}/res/mipmap-mdpi/ic_launcher.png | Bin .../res/mipmap-mdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xhdpi/ic_launcher.png | Bin .../res/mipmap-xhdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xxhdpi/ic_launcher.png | Bin .../res/mipmap-xxhdpi/ic_launcher_round.png | Bin .../main}/res/mipmap-xxxhdpi/ic_launcher.png | Bin .../res/mipmap-xxxhdpi/ic_launcher_round.png | Bin .../src/main}/res/values/strings.xml | 0 .../src/main/res/values/styles.xml | 0 api/gateway/build.gradle.kts | 38 ++--------- api/rest/build.gradle.kts | 38 ++--------- api/shared/build.gradle.kts | 38 ++--------- build.gradle.kts | 2 +- core/build.gradle.kts | 38 ++--------- gradle/libs.versions.toml | 4 +- gradle/wrapper/gradle-wrapper.properties | 2 +- settings.gradle.kts | 1 + ui/build.gradle.kts | 47 ++++--------- 28 files changed, 108 insertions(+), 163 deletions(-) create mode 100644 android/build.gradle.kts rename {ui/src/androidMain => android/src/main}/AndroidManifest.xml (100%) rename {ui/src/androidMain => android/src/main}/kotlin/moe/lava/neon/MainActivity.kt (100%) rename {ui/src/androidMain => android/src/main}/res/drawable-v24/ic_launcher_foreground.xml (100%) rename {ui/src/androidMain => android/src/main}/res/drawable/ic_launcher_background.xml (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-anydpi-v26/ic_launcher.xml (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-anydpi-v26/ic_launcher_round.xml (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-hdpi/ic_launcher.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-hdpi/ic_launcher_round.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-mdpi/ic_launcher.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-mdpi/ic_launcher_round.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-xhdpi/ic_launcher.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-xhdpi/ic_launcher_round.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-xxhdpi/ic_launcher.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-xxhdpi/ic_launcher_round.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-xxxhdpi/ic_launcher.png (100%) rename {ui/src/androidMain => android/src/main}/res/mipmap-xxxhdpi/ic_launcher_round.png (100%) rename {ui/src/androidMain => android/src/main}/res/values/strings.xml (100%) rename {ui => android}/src/main/res/values/styles.xml (100%) 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 100% rename from ui/src/androidMain/kotlin/moe/lava/neon/MainActivity.kt rename to android/src/main/kotlin/moe/lava/neon/MainActivity.kt 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 index 101843d..75a3458 100644 --- a/api/gateway/build.gradle.kts +++ b/api/gateway/build.gradle.kts @@ -1,19 +1,23 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.androidLibrary) + alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) } kotlin { - jvm() - androidTarget { + 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")) @@ -35,31 +39,3 @@ kotlin { } } } - -dependencies { - coreLibraryDesugaring(libs.desugar) -} - -android { - namespace = "moe.lava.neon.api.gateway" - 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/api/rest/build.gradle.kts b/api/rest/build.gradle.kts index 1587873..a7f75e5 100644 --- a/api/rest/build.gradle.kts +++ b/api/rest/build.gradle.kts @@ -1,19 +1,23 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.androidLibrary) + alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) } kotlin { - jvm() - androidTarget { + 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")) @@ -35,31 +39,3 @@ kotlin { } } } - -dependencies { - coreLibraryDesugaring(libs.desugar) -} - -android { - namespace = "moe.lava.neon.api.rest" - 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/api/shared/build.gradle.kts b/api/shared/build.gradle.kts index 23b67e6..6b1f922 100644 --- a/api/shared/build.gradle.kts +++ b/api/shared/build.gradle.kts @@ -1,50 +1,26 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget plugins { - alias(libs.plugins.androidLibrary) + alias(libs.plugins.androidMultiplatformLibrary) alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinSerialization) } kotlin { - jvm() - androidTarget { + 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) } } } - -dependencies { - coreLibraryDesugaring(libs.desugar) -} - -android { - namespace = "moe.lava.neon.api" - 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/build.gradle.kts b/build.gradle.kts index 342cac8..d225e2b 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -2,7 +2,7 @@ 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 diff --git a/core/build.gradle.kts b/core/build.gradle.kts index 8075553..97f594e 100644 --- a/core/build.gradle.kts +++ b/core/build.gradle.kts @@ -1,7 +1,7 @@ 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) @@ -9,13 +9,17 @@ plugins { } 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(project(":api:gateway")) @@ -34,34 +38,6 @@ kotlin { } } -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 - } -} - sqldelight { databases { create("Database") { diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f04b531..dca678f 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" @@ -76,7 +76,7 @@ settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref [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" } 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 0811cc7..2b18bad 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -33,6 +33,7 @@ plugins { id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" } +include(":android") include(":api:gateway") include(":api:rest") include(":api:shared") diff --git a/ui/build.gradle.kts b/ui/build.gradle.kts index d69d7f9..dfd7b9b 100644 --- a/ui/build.gradle.kts +++ b/ui/build.gradle.kts @@ -4,7 +4,7 @@ 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) @@ -12,14 +12,21 @@ plugins { } 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) @@ -72,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 { From db1f469a4f3392cd43531104d2e5f43465772824 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Mon, 16 Feb 2026 17:32:39 +1100 Subject: [PATCH 06/10] feat(api): expose response info in requests --- .../kotlin/moe/lava/neon/api/ApiResponse.kt | 15 +++++++++++++++ .../kotlin/moe/lava/neon/api/endpoints/Auth.kt | 6 +++--- .../lava/neon/core/repository/AuthRepository.kt | 4 ++-- 3 files changed, 20 insertions(+), 5 deletions(-) create mode 100644 api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiResponse.kt 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 index d6a613e..ed00a39 100644 --- 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 @@ -1,6 +1,5 @@ package moe.lava.neon.api.endpoints -import io.ktor.client.call.body import io.ktor.client.request.get import io.ktor.client.request.header import io.ktor.client.request.parameter @@ -8,6 +7,7 @@ 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( @@ -35,7 +35,7 @@ data class LoginResponse( suspend fun ApiClient.getExperiments() = client.get("experiments") { parameter("with_guild_experiments", "true") -}.body() +}.wrap() suspend fun ApiClient.login(email: String, password: String, fingerprint: String) = client.post("auth/login") { header("X-Fingerprint", fingerprint) @@ -43,4 +43,4 @@ suspend fun ApiClient.login(email: String, password: String, fingerprint: String login = email, password = password, )) -}.body() +}.wrap() 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 eeb458d..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 @@ -30,14 +30,14 @@ class AuthRepository internal constructor( ): AuthResponse { try { if (fingerprint == null) { - fingerprint = api.getExperiments().fingerprint + fingerprint = api.getExperiments().body().fingerprint } val login = api.login( email = email, password = password, fingerprint = fingerprint!!, - ) + ).body() logger.i { "Login success $login" } this.token = login.token From c7fb2817fc1518c05e05e269f0ca3cff8f549002 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Mon, 16 Feb 2026 21:46:31 +1100 Subject: [PATCH 07/10] refactor(api/rest): create internal constructor for tests --- .../commonMain/kotlin/moe/lava/neon/api/ApiClient.kt | 11 ++++++++--- .../kotlin/moe/lava/neon/core/di/CoreModule.kt | 2 +- 2 files changed, 9 insertions(+), 4 deletions(-) diff --git a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt index 371b589..bdad65b 100644 --- a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt @@ -20,7 +20,12 @@ import kotlinx.serialization.ExperimentalSerializationApi import moe.lava.neon.common.captcha.CaptchaRequest import moe.lava.neon.common.captcha.CaptchaResponse -class ApiClient { +class ApiClient internal constructor( + engine: HttpClientEngine, + assertSuccess: Boolean, +) { + constructor() : this(HttpClient().engine, true) + private val logger = Logger.withTag("neon.core.api/client") private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null @@ -30,8 +35,8 @@ class ApiClient { } @OptIn(ExperimentalSerializationApi::class) - internal val client = HttpClient { - expectSuccess = true + internal val client = HttpClient(engine) { + expectSuccess = assertSuccess install(ContentNegotiation) { json(ApiConstants.json) } 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 index 6e79c70..600b7f2 100644 --- a/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt +++ b/core/src/commonMain/kotlin/moe/lava/neon/core/di/CoreModule.kt @@ -11,7 +11,7 @@ import org.koin.dsl.module import org.koin.plugin.module.dsl.single val coreModule = module { - single() + factory { ApiClient() } single() single() From 46218aa3c750d89835e00eccd6a2b6bd469173b0 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Mon, 16 Feb 2026 15:51:03 +1100 Subject: [PATCH 08/10] test(api/rest): add basic endpoint tests --- api/rest/build.gradle.kts | 18 ++++ .../kotlin/moe/lava/neon/api/ApiClient.kt | 2 + .../moe/lava/neon/tests/api/DiscordApiMock.kt | 70 ++++++++++++++++ .../neon/tests/api/EndpointTestFactory.kt | 84 +++++++++++++++++++ .../lava/neon/tests/api/endpoints/AuthTest.kt | 54 ++++++++++++ .../lava/neon/tests/api/mock/AuthResponse.kt | 6 ++ .../kotlin/moe/lava/neon/api/ApiConstants.kt | 16 ++-- build.gradle.kts | 2 + gradle/libs.versions.toml | 9 ++ 9 files changed, 253 insertions(+), 8 deletions(-) create mode 100644 api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt create mode 100644 api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/EndpointTestFactory.kt create mode 100644 api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/endpoints/AuthTest.kt create mode 100644 api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/mock/AuthResponse.kt diff --git a/api/rest/build.gradle.kts b/api/rest/build.gradle.kts index a7f75e5..028e304 100644 --- a/api/rest/build.gradle.kts +++ b/api/rest/build.gradle.kts @@ -2,8 +2,10 @@ 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 { @@ -30,12 +32,28 @@ kotlin { } 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/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt index bdad65b..ffbdc86 100644 --- a/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt +++ b/api/rest/src/commonMain/kotlin/moe/lava/neon/api/ApiClient.kt @@ -3,6 +3,7 @@ package moe.lava.neon.api import co.touchlab.kermit.Logger 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 @@ -52,6 +53,7 @@ class ApiClient internal constructor( 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() } 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..4bf32d8 --- /dev/null +++ b/api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/DiscordApiMock.kt @@ -0,0 +1,70 @@ +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.json.Json +import kotlinx.serialization.json.jsonObject +import kotlinx.serialization.json.jsonPrimitive +import moe.lava.neon.tests.api.mock.AuthResponse + +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() + + fun createLogin(email: String, password: String) { + logins[email] = password + } + + val engine = MockEngine { req -> + if (!req.url.toString().startsWith("https://discord.com/api/v9")) { + return@MockEngine respondError(HttpStatusCode.NotFound) + } + val path = req.url.encodedPath.replaceFirst("/api/v9", "") + return@MockEngine when (path) { + "/experiments" -> { + val fp = Arb.string(18..20, "123456789").single() + fingerprints.add(fp) + respond(AuthResponse.Experiments(fp), headers = JsonHeader) + } + "/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") + } + + respond(AuthResponse.Login(idArb.next(), tokenArb.next()), headers = JsonHeader) + } + else -> respondError(HttpStatusCode.NotFound) + } + } + + @Suppress("NOTHING_TO_INLINE") + inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData = + respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg") +} 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/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt index 36258d9..dbbe8f2 100644 --- a/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt +++ b/api/shared/src/commonMain/kotlin/moe/lava/neon/api/ApiConstants.kt @@ -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/build.gradle.kts b/build.gradle.kts index d225e2b..62884ba 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,7 +7,9 @@ plugins { 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.ksp) apply false alias(libs.plugins.sqldelight) apply false } diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index dca678f..189f3c9 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -21,9 +21,11 @@ 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" @@ -62,6 +64,10 @@ 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" } @@ -69,6 +75,7 @@ kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serializa 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" } @@ -81,6 +88,8 @@ composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "k 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" } +ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } From 053b24a614e09a3bd78dc88b46512dcafadff215 Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Mon, 16 Feb 2026 22:32:43 +1100 Subject: [PATCH 09/10] test(api/rest): add captcha tests --- .../moe/lava/neon/tests/api/CaptchaTest.kt | 30 ++++++++++++++ .../moe/lava/neon/tests/api/DiscordApiMock.kt | 41 +++++++++++++++++-- 2 files changed, 68 insertions(+), 3 deletions(-) create mode 100644 api/rest/src/commonTest/kotlin/moe/lava/neon/tests/api/CaptchaTest.kt 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 index 4bf32d8..ba65bc1 100644 --- 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 @@ -15,11 +15,19 @@ 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()) @@ -29,21 +37,44 @@ val tokenArb = Arb.stringPattern("(mfa\\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,2 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) - respond(AuthResponse.Experiments(fp), headers = JsonHeader) + respondJson(AuthResponse.Experiments(fp)) } "/auth/login" -> { val body = req.body as? TextContent @@ -58,13 +89,17 @@ class DiscordApiMock { return@MockEngine badReq("Unknown credentials") } - respond(AuthResponse.Login(idArb.next(), tokenArb.next()), headers = JsonHeader) + respondJson(AuthResponse.Login(idArb.next(), tokenArb.next())) } else -> respondError(HttpStatusCode.NotFound) } } @Suppress("NOTHING_TO_INLINE") - inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData = + 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) } From fcdd237809eb2b978ec3a37e2f5c2f517fc6808b Mon Sep 17 00:00:00 2001 From: Cilly Leang Date: Sun, 22 Feb 2026 18:10:55 +1100 Subject: [PATCH 10/10] refactor(api/gateway): use flows for dispatch handling --- .../kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt | 10 ++++++---- .../kotlin/moe/lava/neon/api/gateway/GatewaySession.kt | 9 ++++----- 2 files changed, 10 insertions(+), 9 deletions(-) diff --git a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt index 1d1d1bc..41054be 100644 --- a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewayHandler.kt @@ -4,6 +4,8 @@ import co.touchlab.kermit.Logger 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.api.gateway.handlers.Handler @@ -11,10 +13,10 @@ import kotlin.math.pow import kotlin.reflect.KClass import kotlin.time.Duration.Companion.seconds -typealias EventHandlers = Map, MutableList>> - class GatewayHandler { - private val eventHandlers: EventHandlers = mutableMapOf() + 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 @@ -31,12 +33,12 @@ class GatewayHandler { session = GatewaySession.start( token = token, - eventHandlers = eventHandlers, resumeProps = resumeProps, onSuccess = { logger.d { "Successful session start" } retryAttempts = 0 }, + onDispatch = { scope.launch { mEvents.emit(it) } }, onDestroy = { reason, resumeProps -> session = null diff --git a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt index f0c83a0..ce40047 100644 --- a/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt +++ b/api/gateway/src/commonMain/kotlin/moe/lava/neon/api/gateway/GatewaySession.kt @@ -32,9 +32,9 @@ private val logger = Logger.withTag("neon.core.api.gateway/session") internal class GatewaySession private constructor( private var ws: DefaultClientWebSocketSession, private val token: String, - private val handlers: EventHandlers, private val scope: CoroutineScope, private var resumeProps: ResumeProperties?, + private val onDispatch: (Event.Dispatch) -> Unit, private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit, private val onSuccess: () -> Unit, ) { @@ -45,13 +45,13 @@ internal class GatewaySession private constructor( companion object { suspend fun start( token: String, - eventHandlers: EventHandlers, 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 { @@ -66,7 +66,7 @@ internal class GatewaySession private constructor( } } - return GatewaySession(ws, token, eventHandlers, scope, resumeProps, onDestroy, onSuccess) + return GatewaySession(ws, token, scope, resumeProps, onDispatch, onDestroy, onSuccess) } } @@ -118,8 +118,7 @@ internal class GatewaySession private constructor( is Event.Resumed -> onSuccess() } if (event is Event.Dispatch) { - val eventHandlers = handlers[event::class] ?: return - eventHandlers.forEach { it.handle(event) } + onDispatch(event) } }