Compare commits

...

10 commits

Author SHA1 Message Date
fcdd237809
refactor(api/gateway): use flows for dispatch handling 2026-02-22 18:10:55 +11:00
053b24a614
test(api/rest): add captcha tests 2026-02-16 22:33:33 +11:00
46218aa3c7
test(api/rest): add basic endpoint tests 2026-02-16 22:33:33 +11:00
c7fb2817fc
refactor(api/rest): create internal constructor for tests 2026-02-16 21:46:31 +11:00
db1f469a4f
feat(api): expose response info in requests 2026-02-16 17:32:39 +11:00
48b69c88a9
refactor: update to agp 9 2026-02-05 02:46:13 +11:00
0a5b0f532a
refactor: move api request logic completely out of core 2026-02-05 01:52:14 +11:00
f606eb2e33
refactor: delete some starter app stuff 2026-02-05 01:11:37 +11:00
0d84411f14
refactor: split up core into multiple modules 2026-02-05 01:05:02 +11:00
2725342c3f
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.
2026-02-01 00:50:57 +11:00
78 changed files with 925 additions and 508 deletions

63
android/build.gradle.kts Normal file
View file

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

View file

@ -4,23 +4,23 @@ import android.os.Bundle
import androidx.activity.ComponentActivity import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge import androidx.activity.enableEdgeToEdge
import androidx.compose.runtime.Composable
import androidx.compose.ui.tooling.preview.Preview
import moe.lava.neon.ui.App import moe.lava.neon.ui.App
import moe.lava.neon.ui.di.initKoin
import org.koin.android.ext.koin.androidContext
import org.koin.android.ext.koin.androidLogger
class MainActivity : ComponentActivity() { class MainActivity : ComponentActivity() {
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
enableEdgeToEdge() enableEdgeToEdge()
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
initKoin {
androidContext(this@MainActivity)
androidLogger()
}
setContent { setContent {
App() App()
} }
} }
} }
@Preview
@Composable
fun AppAndroidPreview() {
App()
}

View file

Before

Width:  |  Height:  |  Size: 3.5 KiB

After

Width:  |  Height:  |  Size: 3.5 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 5.2 KiB

After

Width:  |  Height:  |  Size: 5.2 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 2.6 KiB

After

Width:  |  Height:  |  Size: 2.6 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 3.3 KiB

After

Width:  |  Height:  |  Size: 3.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 4.8 KiB

After

Width:  |  Height:  |  Size: 4.8 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 7.3 KiB

After

Width:  |  Height:  |  Size: 7.3 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 7.7 KiB

After

Width:  |  Height:  |  Size: 7.7 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 12 KiB

After

Width:  |  Height:  |  Size: 12 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 10 KiB

After

Width:  |  Height:  |  Size: 10 KiB

Before After
Before After

View file

Before

Width:  |  Height:  |  Size: 16 KiB

After

Width:  |  Height:  |  Size: 16 KiB

Before After
Before After

View file

@ -0,0 +1,41 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidLibrary {
namespace = "moe.lava.neon.api.gateway"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm()
sourceSets {
commonMain.dependencies {
implementation(project(":api:shared"))
implementation(libs.kermit)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.client.websockets)
implementation(libs.ktor.serialization.kotlinx.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
}
jvmMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
}

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.gateway package moe.lava.neon.api.gateway
@Suppress("unused") @Suppress("unused")
object Capability { object Capability {

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.gateway package moe.lava.neon.api.gateway
import io.ktor.websocket.CloseReason import io.ktor.websocket.CloseReason

View file

@ -1,22 +1,22 @@
package moe.lava.neon.core.api.gateway package moe.lava.neon.api.gateway
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.delay import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.asSharedFlow
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import moe.lava.neon.core.di.EventHandlerGraph import moe.lava.neon.api.gateway.handlers.Handler
import moe.lava.neon.core.repository.AuthRepository
import kotlin.math.pow import kotlin.math.pow
import kotlin.reflect.KClass
import kotlin.time.Duration.Companion.seconds import kotlin.time.Duration.Companion.seconds
@Inject class GatewayHandler {
class GatewayHandler( private val mEvents = MutableSharedFlow<Event.Dispatch>()
private val auth: AuthRepository, val events = mEvents.asSharedFlow()
private val handlers: EventHandlerGraph,
) {
private val logger = Logger.withTag("neon.core.api.gateway/handler") private val logger = Logger.withTag("neon.core.api.gateway/handler")
private val scope = CoroutineScope(Dispatchers.IO) private val scope = CoroutineScope(Dispatchers.IO)
private var session: GatewaySession? = null private var session: GatewaySession? = null
@ -25,22 +25,20 @@ class GatewayHandler(
private var retryAttempts: Int = 0 private var retryAttempts: Int = 0
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
suspend fun connect() { suspend fun connect(token: String) {
if (session != null) { if (session != null) {
logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." } logger.w(Throwable()) { "Attempted to connect, but client already connected, ignoring..." }
return return
} }
val token = auth.token
?: throw IllegalStateException("Tried to connect to gateway with no token")
session = GatewaySession.start( session = GatewaySession.start(
token = token, token = token,
eventHandlers = handlers,
resumeProps = resumeProps, resumeProps = resumeProps,
onSuccess = { onSuccess = {
logger.d { "Successful session start" } logger.d { "Successful session start" }
retryAttempts = 0 retryAttempts = 0
}, },
onDispatch = { scope.launch { mEvents.emit(it) } },
onDestroy = { reason, resumeProps -> onDestroy = { reason, resumeProps ->
session = null session = null
@ -61,7 +59,7 @@ class GatewayHandler(
logger.d { "Reconnecting in ${dur.inWholeMilliseconds}ms" } logger.d { "Reconnecting in ${dur.inWholeMilliseconds}ms" }
delay(dur) delay(dur)
retryAttempts += 1 retryAttempts += 1
res = runCatching { connect() } res = runCatching { connect(token) }
res.exceptionOrNull()?.let { res.exceptionOrNull()?.let {
logger.e(it) { "Reconnect failed" } logger.e(it) { "Reconnect failed" }
} }

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.gateway package moe.lava.neon.api.gateway
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
@ -22,20 +22,19 @@ import kotlinx.coroutines.flow.onCompletion
import kotlinx.coroutines.flow.onEach import kotlinx.coroutines.flow.onEach
import kotlinx.coroutines.isActive import kotlinx.coroutines.isActive
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.lava.neon.core.api.ApiConstants import moe.lava.neon.api.ApiConstants
import moe.lava.neon.core.api.ApiConstants.json import moe.lava.neon.api.ApiConstants.json
import moe.lava.neon.core.di.EventHandlerGraph
import kotlin.random.Random import kotlin.random.Random
import kotlin.time.Duration.Companion.milliseconds import kotlin.time.Duration.Companion.milliseconds
private val logger = Logger.withTag("neon.core.api.gateway/session") private val logger = Logger.withTag("neon.core.api.gateway/session")
class GatewaySession private constructor( internal class GatewaySession private constructor(
private var ws: DefaultClientWebSocketSession, private var ws: DefaultClientWebSocketSession,
private val token: String, private val token: String,
private val handlers: EventHandlerGraph,
private val scope: CoroutineScope, private val scope: CoroutineScope,
private var resumeProps: ResumeProperties?, private var resumeProps: ResumeProperties?,
private val onDispatch: (Event.Dispatch) -> Unit,
private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit, private val onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
private val onSuccess: () -> Unit, private val onSuccess: () -> Unit,
) { ) {
@ -46,13 +45,13 @@ class GatewaySession private constructor(
companion object { companion object {
suspend fun start( suspend fun start(
token: String, token: String,
eventHandlers: EventHandlerGraph,
client: HttpClient = HttpClient { client: HttpClient = HttpClient {
install(HttpCookies) install(HttpCookies)
install(WebSockets) install(WebSockets)
}, },
scope: CoroutineScope = CoroutineScope(Dispatchers.IO), scope: CoroutineScope = CoroutineScope(Dispatchers.IO),
resumeProps: ResumeProperties? = null, resumeProps: ResumeProperties? = null,
onDispatch: (Event.Dispatch) -> Unit,
onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit, onDestroy: (GatewayCloseReason, ResumeProperties?) -> Unit,
onSuccess: () -> Unit, onSuccess: () -> Unit,
): GatewaySession { ): GatewaySession {
@ -67,7 +66,7 @@ class GatewaySession private constructor(
} }
} }
return GatewaySession(ws, token, eventHandlers, scope, resumeProps, onDestroy, onSuccess) return GatewaySession(ws, token, scope, resumeProps, onDispatch, onDestroy, onSuccess)
} }
} }
@ -100,19 +99,27 @@ class GatewaySession private constructor(
private suspend fun handlePayload(payload: Payload.Incoming<*>) { private suspend fun handlePayload(payload: Payload.Incoming<*>) {
logger.d { payload.toString() } logger.d { payload.toString() }
when (val event = payload.d) { val event = payload.d
when (event) {
is Event.Heartbeat -> handleHeartbeat() is Event.Heartbeat -> handleHeartbeat()
is Event.Reconnect -> close(GatewayCloseReason.ServerReconnect) is Event.Reconnect -> close(GatewayCloseReason.ServerReconnect)
is Event.InvalidSession -> close(GatewayCloseReason.InvalidSession(event.resumable)) is Event.InvalidSession -> close(GatewayCloseReason.InvalidSession(event.resumable))
is Event.Hello -> handleHello(event) is Event.Hello -> handleHello(event)
is Event.HeartbeatAck -> { missedHeartbeats -= 1 } is Event.HeartbeatAck -> { missedHeartbeats -= 1 }
is Event.Ready -> handlers.ready.handle(event) { is Event.Ready -> {
resumeProps = it resumeProps = ResumeProperties(
sessionId = event.sessionId,
resumeGatewayUrl = event.resumeGatewayUrl,
lastSequence = 0,
)
onSuccess() onSuccess()
} }
is Event.Resumed -> onSuccess() is Event.Resumed -> onSuccess()
} }
if (event is Event.Dispatch) {
onDispatch(event)
}
} }
private suspend fun handleUnknownPayload(payload: Payload.Unknown) { private suspend fun handleUnknownPayload(payload: Payload.Unknown) {

View file

@ -1,9 +1,9 @@
package moe.lava.neon.core.api.gateway package moe.lava.neon.api.gateway
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.json.JsonElement import kotlinx.serialization.json.JsonElement
import moe.lava.neon.core.api.ApiConstants import moe.lava.neon.api.ApiConstants
import moe.lava.neon.core.api.structures.User import moe.lava.neon.api.objects.User
sealed interface Payload { sealed interface Payload {
val op: Int val op: Int

View file

@ -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 sessionId: String,
val resumeGatewayUrl: String, val resumeGatewayUrl: String,
val lastSequence: Int, val lastSequence: Int,

View file

@ -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.JsonNull
import kotlinx.serialization.json.decodeFromJsonElement import kotlinx.serialization.json.decodeFromJsonElement
import moe.lava.neon.core.api.ApiConstants import moe.lava.neon.api.ApiConstants.json
private val json = ApiConstants.json internal fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
val opcode: Int = when (this) { val opcode: Int = when (this) {
is Event.Heartbeat -> 1 is Event.Heartbeat -> 1
is Event.Identify -> 2 is Event.Identify -> 2
@ -16,7 +14,7 @@ fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
return Payload.Outgoing(op = opcode, d = this) return Payload.Outgoing(op = opcode, d = this)
} }
fun Payload.Unknown.asIncoming() : Payload.WithSequence { internal fun Payload.Unknown.asIncoming() : Payload.WithSequence {
return when (op) { return when (op) {
0 -> when (t) { 0 -> when (t) {
"READY" -> decode<Event.Ready>() "READY" -> decode<Event.Ready>()

View file

@ -0,0 +1,7 @@
package moe.lava.neon.api.gateway.handlers
import moe.lava.neon.api.gateway.Event
sealed interface Handler<T: Event.Dispatch> {
suspend fun handle(event: T)
}

59
api/rest/build.gradle.kts Normal file
View file

@ -0,0 +1,59 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotest)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.ksp)
}
kotlin {
androidLibrary {
namespace = "moe.lava.neon.api.rest"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm()
sourceSets {
commonMain.dependencies {
implementation(project(":api:shared"))
implementation(project(":common"))
implementation(libs.kermit)
implementation(libs.ktor.client.core)
implementation(libs.ktor.client.content.negotiation)
implementation(libs.ktor.serialization.kotlinx.json)
}
commonTest.dependencies {
implementation(libs.kotlin.test)
implementation(libs.kotest.assertions)
implementation(libs.kotest.framework)
implementation(libs.kotest.property)
implementation(libs.ktor.client.mock)
}
jvmMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
jvmTest.dependencies {
implementation(libs.kotest.runner.junit5)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
}
tasks.named<Test>("jvmTest") {
useJUnitPlatform()
}
//tasks.withType<Test>().configureEach {
// logger.lifecycle("UP-TO-DATE check for $name is disabled, forcing it to run.")
// outputs.upToDateWhen { false }
//}

View file

@ -1,11 +1,9 @@
package moe.lava.neon.core.api package moe.lava.neon.api
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient import io.ktor.client.HttpClient
import io.ktor.client.call.body import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpSend import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.cookies.HttpCookies import io.ktor.client.plugins.cookies.HttpCookies
@ -14,18 +12,21 @@ import io.ktor.client.plugins.plugin
import io.ktor.client.plugins.websocket.WebSockets import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.header import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.userAgent import io.ktor.http.userAgent
import io.ktor.serialization.kotlinx.json.json import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.appendAll import io.ktor.util.appendAll
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json import moe.lava.neon.common.captcha.CaptchaRequest
import kotlinx.serialization.json.JsonNamingStrategy import moe.lava.neon.common.captcha.CaptchaResponse
import moe.lava.neon.core.api.captcha.CaptchaRequest
import moe.lava.neon.core.api.captcha.CaptchaResponse class ApiClient internal constructor(
engine: HttpClientEngine,
assertSuccess: Boolean,
) {
constructor() : this(HttpClient().engine, true)
@SingleIn(AppScope::class)
@Inject
class ApiClient {
private val logger = Logger.withTag("neon.core.api/client") private val logger = Logger.withTag("neon.core.api/client")
private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null
@ -35,8 +36,8 @@ class ApiClient {
} }
@OptIn(ExperimentalSerializationApi::class) @OptIn(ExperimentalSerializationApi::class)
val client = HttpClient { internal val client = HttpClient(engine) {
expectSuccess = true expectSuccess = assertSuccess
install(ContentNegotiation) { install(ContentNegotiation) {
json(ApiConstants.json) json(ApiConstants.json)
} }
@ -44,6 +45,7 @@ class ApiClient {
install(HttpCookies) install(HttpCookies)
defaultRequest { defaultRequest {
url("https://discord.com/api/v9/") url("https://discord.com/api/v9/")
contentType(ContentType.Application.Json)
userAgent(ApiConstants.userAgent) userAgent(ApiConstants.userAgent)
headers.appendAll(ApiConstants.baseHeaders) headers.appendAll(ApiConstants.baseHeaders)
} }
@ -51,6 +53,7 @@ class ApiClient {
plugin(HttpSend).intercept { req -> plugin(HttpSend).intercept { req ->
logger.d { "Intercepting ${req.url.buildString()}" } logger.d { "Intercepting ${req.url.buildString()}" }
val call = execute(req) val call = execute(req)
logger.d { "recv ${call.response.bodyAsText()}" }
if (call.response.status.value != 400) return@intercept call if (call.response.status.value != 400) return@intercept call
logger.d { "Found 400 response: ${call.response.bodyAsText()}" } logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
val captchaRequest = runCatching { call.response.body<CaptchaRequest>() } val captchaRequest = runCatching { call.response.body<CaptchaRequest>() }
@ -86,20 +89,3 @@ class ApiClient {
} }
} }
} }
@OptIn(ExperimentalSerializationApi::class)
fun buildApiClient() = HttpClient {
expectSuccess = true
install(ContentNegotiation) {
json(Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
})
}
install(WebSockets)
install(HttpCookies)
defaultRequest {
url("https://discord.com/api/v9/")
headers.appendAll(ApiConstants.baseHeaders)
}
}

View file

@ -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<T>(
val response: HttpResponse,
private val bodyType: TypeInfo,
) {
suspend fun body() = response.body(bodyType) as T
}
inline fun <reified T> HttpResponse.wrap() = ApiResponse<T>(this, typeInfo<T>())

View file

@ -0,0 +1,46 @@
package moe.lava.neon.api.endpoints
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import kotlinx.serialization.Serializable
import moe.lava.neon.api.ApiClient
import moe.lava.neon.api.wrap
@Serializable
data class ExperimentResponse(
val fingerprint: String,
)
@Serializable
private data class LoginRequest(
val login: String,
val password: String,
val undelete: Boolean = false,
val loginSource: String? = null,
val giftCodeSkuId: String? = null,
)
@Serializable
data class LoginResponse(
val userId: String,
val token: String,
val userSettings: UserSettings,
) {
@Serializable
data class UserSettings(val locale: String, val theme: String)
}
suspend fun ApiClient.getExperiments() = client.get("experiments") {
parameter("with_guild_experiments", "true")
}.wrap<ExperimentResponse>()
suspend fun ApiClient.login(email: String, password: String, fingerprint: String) = client.post("auth/login") {
header("X-Fingerprint", fingerprint)
setBody(LoginRequest(
login = email,
password = password,
))
}.wrap<LoginResponse>()

View file

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

View file

@ -0,0 +1,105 @@
package moe.lava.neon.tests.api
import io.kotest.property.Arb
import io.kotest.property.arbitrary.long
import io.kotest.property.arbitrary.next
import io.kotest.property.arbitrary.single
import io.kotest.property.arbitrary.string
import io.kotest.property.arbitrary.stringPattern
import io.ktor.client.engine.mock.MockEngine
import io.ktor.client.engine.mock.MockRequestHandleScope
import io.ktor.client.engine.mock.respond
import io.ktor.client.engine.mock.respondError
import io.ktor.client.request.HttpResponseData
import io.ktor.http.HttpHeaders
import io.ktor.http.HttpStatusCode
import io.ktor.http.content.TextContent
import io.ktor.http.headersOf
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import kotlinx.serialization.json.jsonObject
import kotlinx.serialization.json.jsonPrimitive
import moe.lava.neon.common.captcha.CaptchaRequest
import moe.lava.neon.common.captcha.CaptchaResponse
import moe.lava.neon.tests.api.mock.AuthResponse
@OptIn(ExperimentalSerializationApi::class)
private val JsonWithSnakecase = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
}
private val JsonHeader = headersOf(HttpHeaders.ContentType, "application/json")
val idArb = Arb.long(1e18.toLong(), 1e20.toLong())
// Good enough
val tokenArb = Arb.stringPattern("(mfa\\.[a-zA-Z0-9_-]{20,})|([a-zA-Z0-9_-]{23,28}\\.[a-zA-Z0-9_-]{6,7}\\.[a-zA-Z0-9_-]{38})")
class DiscordApiMock {
private val fingerprints = mutableListOf<String>()
private val logins = mutableMapOf<String, String>()
private var captcha: Pair<CaptchaRequest, CaptchaResponse.Success> = generateCaptcha()
var isCaptchaEnabled = false
fun createLogin(email: String, password: String) {
logins[email] = password
}
fun generateCaptcha(): Pair<CaptchaRequest, CaptchaResponse.Success> {
val req = CaptchaRequest(
listOf(Arb.string().single()),
Arb.string().single(),
Arb.string().single(),
Arb.string().single(),
Arb.string().single(),
Arb.string().single(),
true,
)
val res = CaptchaResponse.Success(Arb.string().single())
captcha = req to res
return req to res
}
val engine = MockEngine { req ->
if (!req.url.toString().startsWith("https://discord.com/api/v9")) {
return@MockEngine respondError(HttpStatusCode.NotFound)
}
if (isCaptchaEnabled) {
if (req.headers["X-Captcha-Key"] != captcha.second.token) {
return@MockEngine respondJson(JsonWithSnakecase.encodeToString(captcha.first), HttpStatusCode.BadRequest)
}
}
val path = req.url.encodedPath.replaceFirst("/api/v9", "")
return@MockEngine when (path) {
"/experiments" -> {
val fp = Arb.string(18..20, "123456789").single()
fingerprints.add(fp)
respondJson(AuthResponse.Experiments(fp))
}
"/auth/login" -> {
val body = req.body as? TextContent
?: return@MockEngine badReq("No body")
val json = Json.parseToJsonElement(body.text).jsonObject
val login = json["login"]?.jsonPrimitive?.content
?: return@MockEngine badReq("No login")
val password = json["password"]?.jsonPrimitive?.content
?: return@MockEngine badReq("No password")
if (logins[login] != password) {
return@MockEngine badReq("Unknown credentials")
}
respondJson(AuthResponse.Login(idArb.next(), tokenArb.next()))
}
else -> respondError(HttpStatusCode.NotFound)
}
}
@Suppress("NOTHING_TO_INLINE")
private inline fun MockRequestHandleScope.badReq(msg: String): HttpResponseData =
respondError(HttpStatusCode.BadRequest, content = "[Neon] $msg")
@Suppress("NOTHING_TO_INLINE")
private inline fun MockRequestHandleScope.respondJson(content: String, status: HttpStatusCode = HttpStatusCode.OK): HttpResponseData =
respond(content = content, status = status, headers = JsonHeader)
}

View file

@ -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 <T> endpointTestFactory(
endpoint: String,
testName: String?,
response: suspend () -> ApiResponse<T>,
also: suspend FunSpecContainerScope.(response: ApiResponse<T>) -> Unit,
) = funSpec {
context(testName ?: endpoint) {
val res = response()
val req = res.response.request
test("has correct base url") {
req.url.toString().startsWith("https://discord.com/api/v9/") shouldBe true
}
test("uses correct endpoint") {
req.url.encodedPath.replace("/api/v9/", "") shouldBe endpoint
}
test("has valid super props") {
val props = req.headers["X-Super-Properties"]
props.shouldNotBeNull()
val decoded = withClue("should be decodable") {
shouldNotThrowAny {
Base64.decode(props).decodeToString()
}
}
val parsed = withClue("should be parsable") {
shouldNotThrowAny {
Json.parseToJsonElement(decoded).jsonObject
}
}
withClue("has props") {
parsed.keys shouldContainAllInAnyOrder setOf(
"os",
"browser",
"browser_user_agent",
"browser_version",
"client_build_number",
"release_channel",
"system_locale",
)
}
val userAgent = withClue("has valid user agent") {
val agent = parsed["browser_user_agent"]?.jsonPrimitive
agent?.isString.shouldBeTrue()
agent.content
}
withClue("has matching user agent") {
userAgent shouldBeOneOf setOf(req.headers["User-Agent"], "")
}
}
test("has correct body") {
shouldNotThrowAny { res.body() }
}
also(res)
}
}
fun <T> FunSpec.withFactory(
testName: String? = null,
endpoint: String,
response: suspend () -> ApiResponse<T>,
also: suspend FunSpecContainerScope.(response: ApiResponse<T>) -> Unit = {},
) {
include(endpointTestFactory(endpoint, testName, response, also))
}

View file

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

View file

@ -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"}}"""
}

View file

@ -0,0 +1,26 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins {
alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
androidLibrary {
namespace = "moe.lava.neon.api"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions {
jvmTarget.set(JvmTarget.JVM_11)
}
}
jvm()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.json)
}
}
}

View file

@ -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 import java.util.Locale
@Suppress("ConstantLocale") @SuppressLint("ConstantLocale")
internal actual val platformSuperProps = PlatformProps( 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) // TODO: this only outputs language but not country (e.g. en instead of en-AU)
// .toLanguageTag() is close, but returns too much junk (e.g. en-AU-u-fw-mon) // .toLanguageTag() is close, but returns too much junk (e.g. en-AU-u-fw-mon)
systemLocale = Locale.getDefault().language, systemLocale = Locale.getDefault().language,
osVersion = "${android.os.Build.VERSION.SDK_INT}", osVersion = "${Build.VERSION.SDK_INT}",
) )

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api package moe.lava.neon.api
import kotlinx.serialization.ExperimentalSerializationApi import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
@ -24,7 +24,14 @@ internal data class PlatformProps(
internal expect val platformSuperProps: PlatformProps internal expect val platformSuperProps: PlatformProps
object ApiConstants { 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( val baseHeaders = mapOf(
"X-Debug-Options" to "bugReporterEnabled", "X-Debug-Options" to "bugReporterEnabled",
"X-Discord-Locale" to "en-US", "X-Discord-Locale" to "en-US",
@ -34,13 +41,6 @@ object ApiConstants {
const val userAgent = "Discord-Android/311020;RNA" const val userAgent = "Discord-Android/311020;RNA"
const val gatewayUserAgent = "okhttp/4.12.0" const val gatewayUserAgent = "okhttp/4.12.0"
@OptIn(ExperimentalSerializationApi::class)
val json = Json {
namingStrategy = JsonNamingStrategy.SnakeCase
ignoreUnknownKeys = true
encodeDefaults = true
}
// TODO: Desktop uses separate properties // TODO: Desktop uses separate properties
@Suppress("PropertyName") @Suppress("PropertyName")
@Serializable @Serializable

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.structures package moe.lava.neon.api.objects
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.builtins.LongAsStringSerializer import kotlinx.serialization.builtins.LongAsStringSerializer

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.structures package moe.lava.neon.api.objects
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View file

@ -1,7 +1,8 @@
package moe.lava.neon.core.api package moe.lava.neon.api
import java.util.Locale import java.util.Locale
// TODO
@Suppress("ConstantLocale") @Suppress("ConstantLocale")
internal actual val platformSuperProps = PlatformProps( internal actual val platformSuperProps = PlatformProps(
device = "", device = "",

View file

@ -2,12 +2,14 @@ plugins {
// this is necessary to avoid the plugins to be loaded multiple times // this is necessary to avoid the plugins to be loaded multiple times
// in each subproject's classloader // in each subproject's classloader
alias(libs.plugins.androidApplication) apply false alias(libs.plugins.androidApplication) apply false
alias(libs.plugins.androidLibrary) apply false alias(libs.plugins.androidMultiplatformLibrary) apply false
alias(libs.plugins.composeHotReload) apply false alias(libs.plugins.composeHotReload) apply false
alias(libs.plugins.composeMultiplatform) apply false alias(libs.plugins.composeMultiplatform) apply false
alias(libs.plugins.composeCompiler) apply false alias(libs.plugins.composeCompiler) apply false
alias(libs.plugins.koinCompiler) apply false
alias(libs.plugins.kotest) apply false
alias(libs.plugins.kotlinMultiplatform) apply false alias(libs.plugins.kotlinMultiplatform) apply false
alias(libs.plugins.kotlinSerialization) apply false alias(libs.plugins.kotlinSerialization) apply false
alias(libs.plugins.metro) apply false alias(libs.plugins.ksp) apply false
alias(libs.plugins.sqldelight) apply false alias(libs.plugins.sqldelight) apply false
} }

14
common/build.gradle.kts Normal file
View file

@ -0,0 +1,14 @@
plugins {
alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization)
}
kotlin {
jvm()
sourceSets {
commonMain.dependencies {
implementation(libs.kotlinx.serialization.core)
}
}
}

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.captcha package moe.lava.neon.common.captcha
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable

View file

@ -1,4 +1,4 @@
package moe.lava.neon.core.api.captcha package moe.lava.neon.common.captcha
sealed class CaptchaResponse { sealed class CaptchaResponse {
data class Success(val token: String) : CaptchaResponse() data class Success(val token: String) : CaptchaResponse()

View file

@ -1,27 +1,33 @@
import org.jetbrains.kotlin.gradle.dsl.JvmTarget import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.androidLibrary) alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.koinCompiler)
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.metro)
alias(libs.plugins.sqldelight) alias(libs.plugins.sqldelight)
} }
kotlin { kotlin {
jvm() androidLibrary {
androidTarget { namespace = "moe.lava.neon.core"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) jvmTarget.set(JvmTarget.JVM_11)
} }
} }
jvm()
sourceSets { sourceSets {
commonMain.dependencies { commonMain.dependencies {
implementation(libs.ktor.client.core) implementation(project(":api:gateway"))
implementation(libs.ktor.client.content.negotiation) implementation(project(":api:rest"))
implementation(libs.ktor.client.websockets) implementation(project(":common"))
implementation(libs.ktor.serialization.kotlinx.json)
implementation(project.dependencies.platform(libs.koin.bom))
implementation(libs.koin.core)
implementation(libs.kermit) implementation(libs.kermit)
implementation(libs.settings) implementation(libs.settings)
@ -29,40 +35,6 @@ kotlin {
commonTest.dependencies { commonTest.dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
} }
jvmMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
androidMain.dependencies {
implementation(libs.ktor.client.okhttp)
}
}
}
dependencies {
coreLibraryDesugaring(libs.desugar)
}
android {
namespace = "moe.lava.neon.core"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
minSdk = libs.versions.android.minSdk.get().toInt()
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
} }
} }

View file

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

View file

@ -2,13 +2,8 @@ package moe.lava.neon.core
import com.russhwolf.settings.Settings import com.russhwolf.settings.Settings
import com.russhwolf.settings.nullableString import com.russhwolf.settings.nullableString
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@SingleIn(AppScope::class) internal class AppSettings {
@Inject
class AppSettings {
private val settings = Settings() private val settings = Settings()
var fingerprint by settings.nullableString() var fingerprint by settings.nullableString()

View file

@ -1,7 +0,0 @@
package moe.lava.neon.core
interface Platform {
val name: String
}
expect fun getPlatform(): Platform

View file

@ -1,5 +0,0 @@
package moe.lava.neon.core.api.gateway.handlers
import moe.lava.neon.core.api.gateway.Event
sealed interface Handler<T: Event.Incoming>

View file

@ -1,20 +0,0 @@
package moe.lava.neon.core.api.gateway.handlers
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.Inject
import moe.lava.neon.core.api.gateway.Event
import moe.lava.neon.core.api.gateway.ResumeProperties
private val logger = Logger.withTag("neon.core.api.events/ready")
@Inject
class ReadyHandler : Handler<Event.Ready> {
fun handle(event: Event.Ready, updateResumeProps: (ResumeProperties) -> Unit) {
logger.i { "Received payload $event" }
updateResumeProps(ResumeProperties(
sessionId = event.sessionId,
resumeGatewayUrl = event.resumeGatewayUrl,
lastSequence = 0,
))
}
}

View file

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

View file

@ -0,0 +1,23 @@
package moe.lava.neon.core.di
import moe.lava.neon.api.ApiClient
import moe.lava.neon.api.gateway.GatewayHandler
import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.CaptchaRepository
import moe.lava.neon.core.repository.GatewayRepository
import moe.lava.neon.core.repository.UserRepository
import org.koin.dsl.module
import org.koin.plugin.module.dsl.single
val coreModule = module {
factory { ApiClient() }
single<AppSettings>()
single<AuthRepository>()
single<CaptchaRepository>()
single<GatewayRepository>()
single<UserRepository>()
single<GatewayHandler>()
}

View file

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

View file

@ -1,86 +1,50 @@
package moe.lava.neon.core.repository package moe.lava.neon.core.repository
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope import moe.lava.neon.api.ApiClient
import dev.zacsweers.metro.Inject import moe.lava.neon.api.endpoints.getExperiments
import dev.zacsweers.metro.SingleIn import moe.lava.neon.api.endpoints.login
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.Serializable
import moe.lava.neon.core.AppSettings import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.api.ApiClient
@Serializable
private data class ExperimentResponse(
val fingerprint: String,
)
@Serializable
private data class LoginRequest(
val login: String,
val password: String,
val undelete: Boolean = false,
val loginSource: String? = null,
val giftCodeSkuId: String? = null,
)
@Serializable
private data class LoginResponse(
val userId: String,
val token: String,
val userSettings: UserSettings,
) {
@Serializable
data class UserSettings(val locale: String, val theme: String)
}
sealed class AuthResponse { sealed class AuthResponse {
// TODO: Specify all possible error types here
data class Failed(val error: Throwable) : AuthResponse()
data class Success(val token: String) : AuthResponse() data class Success(val token: String) : AuthResponse()
// TODO // TODO
// data class MFARequested() : AuthResponse() // data class MFARequested() : AuthResponse()
} }
@Inject class AuthRepository internal constructor(
@SingleIn(AppScope::class)
class AuthRepository(
private val settings: AppSettings, private val settings: AppSettings,
private val api: ApiClient, private val api: ApiClient,
) { ) {
private val logger = Logger.withTag("neon.core.repo/auth") private val logger = Logger.withTag("neon.core.repo/auth")
var token by settings::token private var token by settings::token
private set private var fingerprint by settings::fingerprint
var fingerprint by settings::fingerprint val loggedIn get() = token != null
private set
suspend fun login( suspend fun login(
email: String, email: String,
password: String, password: String,
): AuthResponse { ): AuthResponse {
if (fingerprint == null) { try {
fingerprint = api.client.get("experiments") { if (fingerprint == null) {
parameter("with_guild_experiments", "true") fingerprint = api.getExperiments().body().fingerprint
}.body<ExperimentResponse>().fingerprint }
}
val res = api.client.post("auth/login") { val login = api.login(
header("X-Fingerprint", fingerprint) email = email,
contentType(ContentType.Application.Json)
setBody(LoginRequest(
login = email,
password = password, password = password,
)) fingerprint = fingerprint!!,
).body()
logger.i { "Login success $login" }
this.token = login.token
return AuthResponse.Success(login.token)
} catch (e: Throwable) {
return AuthResponse.Failed(e)
} }
val body = res.body<LoginResponse>()
logger.i { "Login success $body" }
this.token = body.token
return AuthResponse.Success(body.token)
} }
fun login(token: String): String { fun login(token: String): String {

View file

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

View file

@ -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<Unit> = runCatching {
val token = settings.token
?: throw IllegalArgumentException("Tried to start gateway with no token")
gateway.connect(token)
}
suspend fun pause() = runCatching { gateway.disconnect() }
}

View file

@ -1,10 +1,4 @@
package moe.lava.neon.core.repository package moe.lava.neon.core.repository
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@Inject
@SingleIn(AppScope::class)
class UserRepository { class UserRepository {
} }

View file

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

View file

@ -1,6 +1,6 @@
[versions] [versions]
#noinspection AndroidGradlePluginVersion #noinspection AndroidGradlePluginVersion
agp = "8.13.2" agp = "9.0.0"
android-compileSdk = "36" android-compileSdk = "36"
android-minSdk = "24" android-minSdk = "24"
android-targetSdk = "36" android-targetSdk = "36"
@ -19,12 +19,16 @@ desugar = "2.1.5"
hcaptcha = "4.4.0" hcaptcha = "4.4.0"
junit = "4.13.2" junit = "4.13.2"
kermit = "2.0.8" kermit = "2.0.8"
koin-bom = "4.2.0-RC1"
koin-plugin = "0.3.0"
kotest = "6.1.2"
kotlin = "2.3.0" kotlin = "2.3.0"
kotlinx-coroutines = "1.10.2" kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.10.0"
ksp = "2.3.4"
ktor = "3.4.0" ktor = "3.4.0"
material3 = "1.11.0-alpha02" material3 = "1.11.0-alpha02"
material3-adaptive = "1.3.0-alpha04" material3-adaptive = "1.3.0-alpha04"
metro = "0.10.2"
settings = "1.3.0" settings = "1.3.0"
sqldelight = "2.2.1" sqldelight = "2.2.1"
@ -54,24 +58,38 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga
hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" } hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" }
junit = { module = "junit:junit", version.ref = "junit" } junit = { module = "junit:junit", version.ref = "junit" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" } kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-test = { module = "io.insert-koin:koin-test" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
kotest-framework = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" } kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" } kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" } kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" } ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" } ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" }
settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" } settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
[plugins] [plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" } androidApplication = { id = "com.android.application", version.ref = "agp" }
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" } composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" } composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" } composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
koinCompiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
kotest = { id = "io.kotest", version.ref = "kotest" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" } kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" } kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" } ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" } sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }

View file

@ -1,6 +1,6 @@
distributionBase=GRADLE_USER_HOME distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.14.3-bin.zip distributionUrl=https\://services.gradle.org/distributions/gradle-9.1.0-bin.zip
networkTimeout=10000 networkTimeout=10000
validateDistributionUrl=true validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME zipStoreBase=GRADLE_USER_HOME

View file

@ -33,5 +33,10 @@ plugins {
id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0" id("org.gradle.toolchains.foojay-resolver-convention") version "1.0.0"
} }
include(":android")
include(":api:gateway")
include(":api:rest")
include(":api:shared")
include(":common")
include(":core") include(":core")
include(":ui") include(":ui")

View file

@ -4,22 +4,29 @@ import org.jetbrains.kotlin.gradle.dsl.JvmTarget
plugins { plugins {
alias(libs.plugins.kotlinMultiplatform) alias(libs.plugins.kotlinMultiplatform)
alias(libs.plugins.kotlinSerialization) alias(libs.plugins.kotlinSerialization)
alias(libs.plugins.androidApplication) alias(libs.plugins.androidMultiplatformLibrary)
alias(libs.plugins.composeMultiplatform) alias(libs.plugins.composeMultiplatform)
alias(libs.plugins.composeCompiler) alias(libs.plugins.composeCompiler)
alias(libs.plugins.composeHotReload) alias(libs.plugins.composeHotReload)
alias(libs.plugins.metro) alias(libs.plugins.koinCompiler)
} }
kotlin { kotlin {
androidTarget { androidLibrary {
namespace = "moe.lava.neon.ui"
compileSdk = libs.versions.android.compileSdk.get().toInt()
compilerOptions { compilerOptions {
jvmTarget.set(JvmTarget.JVM_11) jvmTarget.set(JvmTarget.JVM_11)
} }
androidResources {
enable = true
}
} }
jvm() jvm()
sourceSets { sourceSets {
androidMain.dependencies { androidMain.dependencies {
implementation(libs.compose.ui.tooling.preview) implementation(libs.compose.ui.tooling.preview)
@ -31,6 +38,7 @@ kotlin {
implementation(libs.ktor.client.okhttp) implementation(libs.ktor.client.okhttp)
} }
commonMain.dependencies { commonMain.dependencies {
implementation(project(":common"))
implementation(project(":core")) implementation(project(":core"))
implementation(libs.compose.components.resources) implementation(libs.compose.components.resources)
implementation(libs.compose.foundation) implementation(libs.compose.foundation)
@ -54,8 +62,10 @@ kotlin {
implementation(libs.kermit) 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 { commonTest.dependencies {
implementation(libs.kotlin.test) implementation(libs.kotlin.test)
@ -69,38 +79,8 @@ kotlin {
} }
} }
android {
namespace = "moe.lava.neon"
compileSdk = libs.versions.android.compileSdk.get().toInt()
defaultConfig {
applicationId = "moe.lava.neon"
minSdk = libs.versions.android.minSdk.get().toInt()
targetSdk = libs.versions.android.targetSdk.get().toInt()
versionCode = 1
versionName = "1.0"
}
packaging {
resources {
excludes += "/META-INF/{AL2.0,LGPL2.1}"
}
}
buildTypes {
getByName("release") {
isMinifyEnabled = false
signingConfig = signingConfigs.getByName("debug")
}
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_11
targetCompatibility = JavaVersion.VERSION_11
isCoreLibraryDesugaringEnabled = true
}
}
dependencies { dependencies {
debugImplementation(libs.compose.ui.tooling) androidRuntimeClasspath(libs.compose.ui.tooling)
coreLibraryDesugaring(libs.desugar)
} }
compose.desktop { compose.desktop {

View file

@ -19,8 +19,8 @@ import com.hcaptcha.sdk.HCaptchaVerifyParams
import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.flow.MutableSharedFlow
import kotlinx.coroutines.flow.first import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.lava.neon.core.api.ApiClient import moe.lava.neon.common.captcha.CaptchaRequest
import moe.lava.neon.core.api.captcha.CaptchaResponse import moe.lava.neon.common.captcha.CaptchaResponse
private val logger = Logger.withTag("neon.ui.app/captcha") private val logger = Logger.withTag("neon.ui.app/captcha")
@ -31,7 +31,7 @@ private const val EXTRA_RESULT_TOKEN = "extra_result_token"
private const val EXTRA_RESULT_ERROR = "extra_result_error" private const val EXTRA_RESULT_ERROR = "extra_result_error"
@Composable @Composable
actual fun CaptchaBinder(api: ApiClient) { actual fun getCaptchaHandler(): suspend (CaptchaRequest) -> CaptchaResponse {
val context = LocalContext.current val context = LocalContext.current
val queue = MutableSharedFlow<Pair<String, CaptchaResponse>>() val queue = MutableSharedFlow<Pair<String, CaptchaResponse>>()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
@ -67,7 +67,7 @@ actual fun CaptchaBinder(api: ApiClient) {
} }
} }
api.setCaptchaHandler { captcha -> return { captcha ->
val intent = Intent(context, HCaptchaActivity::class.java).apply { val intent = Intent(context, HCaptchaActivity::class.java).apply {
putExtra(EXTRA_SITE_KEY, captcha.captchaSitekey) putExtra(EXTRA_SITE_KEY, captcha.captchaSitekey)
putExtra(EXTRA_RQ_DATA, captcha.captchaRqdata) putExtra(EXTRA_RQ_DATA, captcha.captchaRqdata)

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme import androidx.compose.material3.MotionScheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider import androidx.navigation3.runtime.entryProvider
@ -14,12 +13,11 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration import androidx.savedstate.serialization.SavedStateConfiguration
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.zacsweers.metro.createGraph
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
import kotlinx.serialization.Serializable import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic import kotlinx.serialization.modules.polymorphic
import moe.lava.neon.ui.di.AppUiGraph import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.CaptchaRepository
import moe.lava.neon.ui.screens.Login import moe.lava.neon.ui.screens.Login
import moe.lava.neon.ui.screens.Sample import moe.lava.neon.ui.screens.Sample
import moe.lava.neon.ui.screens.chat.Chat import moe.lava.neon.ui.screens.chat.Chat
@ -29,6 +27,7 @@ import moe.lava.neon.ui.screens.navigator.NavigatorModel
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
import moe.lava.neon.ui.util.ThreePaneSceneStrategy import moe.lava.neon.ui.util.ThreePaneSceneStrategy
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
import org.koin.compose.koinInject
import kotlin.system.exitProcess import kotlin.system.exitProcess
object Route { object Route {
@ -68,81 +67,81 @@ fun App() {
exitProcess(1) exitProcess(1)
} }
val uiGraph = createGraph<AppUiGraph>() val auth: AuthRepository = koinInject()
val graph = uiGraph.core val captcha: CaptchaRepository = koinInject()
CaptchaBinder(graph.api) captcha.setHandler(getCaptchaHandler())
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<NavKey>()
NavDisplay(
backStack = backStack,
entryDecorators = listOf(
rememberSaveableStateHolderNavEntryDecorator(),
rememberViewModelStoreNavEntryDecorator(),
),
onBack = { backStack.removeLastOrNull() },
sceneStrategy = threePaneStrategy,
entryProvider = entryProvider {
entry<Route.Login> {
Login(
onSuccess = {
backStack.clear()
backStack.add(Route.Sample)
}
)
}
entry<Route.Sample> {
Sample(
navTest = {
backStack.add(Route.Navigator(it))
backStack.add(Route.Chat)
backStack.add(Route.MembersList)
},
onRequestLogout = {
backStack.clear()
backStack.add(Route.Login)
}
)
}
entry<Route.Navigator>( MaterialExpressiveTheme(
metadata = ThreePaneSceneStrategy.listPane() colorScheme = getColorScheme(),
) { key -> motionScheme = MotionScheme.expressive(),
if (key.left) { ) {
Navigator( val init = if (auth.loggedIn) Route.Sample else Route.Login
NavigatorPreviewProvider.base2.copy( // val backStack = rememberNavBackStack(config, init)
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar val backStack = rememberNavBackStack(config, Route.Sample)
) val threePaneStrategy = rememberThreePaneSceneStrategy<NavKey>()
) NavDisplay(
} else { backStack = backStack,
Navigator( entryDecorators = listOf(
NavigatorPreviewProvider.base2.copy( rememberSaveableStateHolderNavEntryDecorator(),
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet rememberViewModelStoreNavEntryDecorator(),
) ),
) onBack = { backStack.removeLastOrNull() },
sceneStrategy = threePaneStrategy,
entryProvider = entryProvider {
entry<Route.Login> {
Login(
onSuccess = {
backStack.clear()
backStack.add(Route.Sample)
} }
} )
}
entry<Route.Sample> {
Sample(
navTest = {
backStack.add(Route.Navigator(it))
backStack.add(Route.Chat)
backStack.add(Route.MembersList)
},
onRequestLogout = {
backStack.clear()
backStack.add(Route.Login)
}
)
}
entry<Route.Chat>( entry<Route.Navigator>(
metadata = ThreePaneSceneStrategy.detailPane() metadata = ThreePaneSceneStrategy.listPane()
) { ) { key ->
Chat( if (key.left) {
onOpenMembers = { backStack.add(Route.MembersList) } Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
)
)
} else {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
)
) )
}
entry<Route.MembersList>(
metadata = ThreePaneSceneStrategy.extraPane()
) {
MembersList()
} }
} }
)
} entry<Route.Chat>(
metadata = ThreePaneSceneStrategy.detailPane()
) {
Chat(
onOpenMembers = { backStack.add(Route.MembersList) }
)
}
entry<Route.MembersList>(
metadata = ThreePaneSceneStrategy.extraPane()
) {
MembersList()
}
}
)
} }
} }

View file

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

View file

@ -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

View file

@ -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}!"
}
}

View file

@ -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<KClass<out ViewModel>, Provider<ViewModel>>,
override val assistedFactoryProviders: Map<KClass<out ViewModel>, Provider<ViewModelAssistedFactory>>,
override val manualAssistedFactoryProviders: Map<KClass<out ManualViewModelAssistedFactory>, Provider<ManualViewModelAssistedFactory>>,
) : MetroViewModelFactory()

View file

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

View file

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

View file

@ -23,11 +23,6 @@ import androidx.compose.ui.text.input.VisualTransformation
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.lava.neon.core.repository.AuthRepository import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.AuthResponse import moe.lava.neon.core.repository.AuthResponse
@ -35,12 +30,13 @@ import moe.lava.neon.resources.Res
import moe.lava.neon.resources.visibility import moe.lava.neon.resources.visibility
import moe.lava.neon.resources.visibility_off import moe.lava.neon.resources.visibility_off
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun Login( fun Login(
onSuccess: () -> Unit, onSuccess: () -> Unit,
) { ) {
val viewModel: LoginViewModel = metroViewModel() val viewModel: LoginViewModel = koinViewModel()
val scope = rememberCoroutineScope() val scope = rememberCoroutineScope()
Column( Column(
@ -113,9 +109,6 @@ fun Login(
} }
} }
@Inject
@ViewModelKey(LoginViewModel::class)
@ContributesIntoMap(AppScope::class)
class LoginViewModel( class LoginViewModel(
private val auth: AuthRepository private val auth: AuthRepository
) : ViewModel() { ) : ViewModel() {
@ -129,13 +122,13 @@ class LoginViewModel(
} }
suspend fun login(email: String, password: String): LoginResult { suspend fun login(email: String, password: String): LoginResult {
return try { return when (val res = auth.login(email, password)) {
when (val res = auth.login(email, password)) { is AuthResponse.Success -> LoginResult.Success
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())
} }
} }
} }

View file

@ -20,25 +20,20 @@ import androidx.compose.ui.Modifier
import androidx.lifecycle.ViewModel import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope import androidx.lifecycle.viewModelScope
import co.touchlab.kermit.Logger import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import moe.lava.neon.core.api.gateway.GatewayHandler
import moe.lava.neon.core.repository.AuthRepository import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.GatewayRepository
import moe.lava.neon.resources.Res import moe.lava.neon.resources.Res
import moe.lava.neon.resources.compose_multiplatform import moe.lava.neon.resources.compose_multiplatform
import moe.lava.neon.ui.Greeting
import org.jetbrains.compose.resources.painterResource import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable @Composable
fun Sample( fun Sample(
navTest: (Boolean) -> Unit, navTest: (Boolean) -> Unit,
onRequestLogout: () -> Unit, onRequestLogout: () -> Unit,
) { ) {
val viewModel: SampleViewModel = metroViewModel() val viewModel: SampleViewModel = koinViewModel()
var showContent by remember { mutableStateOf(false) } var showContent by remember { mutableStateOf(false) }
Column( Column(
modifier = Modifier modifier = Modifier
@ -57,14 +52,12 @@ fun Sample(
Text("Click me (bottom!") Text("Click me (bottom!")
} }
AnimatedVisibility(showContent) { AnimatedVisibility(showContent) {
val greeting = remember { Greeting().greet() }
Column( Column(
modifier = Modifier.fillMaxWidth(), modifier = Modifier.fillMaxWidth(),
horizontalAlignment = Alignment.CenterHorizontally, horizontalAlignment = Alignment.CenterHorizontally,
) { ) {
Image(painterResource(Res.drawable.compose_multiplatform), null) Image(painterResource(Res.drawable.compose_multiplatform), null)
Text("Compose: $greeting") Text("Logged in: ${viewModel.loggedIn}")
Text("Passed token: ${viewModel.token?.slice(0..10)}...")
} }
} }
Button(onClick = { Button(onClick = {
@ -86,31 +79,26 @@ fun Sample(
} }
} }
@Inject
@ViewModelKey(SampleViewModel::class)
@ContributesIntoMap(AppScope::class)
class SampleViewModel( class SampleViewModel(
private val auth: AuthRepository, private val auth: AuthRepository,
private val gateway: GatewayHandler, private val gateway: GatewayRepository,
) : ViewModel() { ) : ViewModel() {
private val logger = Logger.withTag("neon.ui.screens/Sample") private val logger = Logger.withTag("neon.ui.screens/Sample")
val token get() = auth.token val loggedIn by auth::loggedIn
fun connect() { fun connect() {
viewModelScope.launch { viewModelScope.launch {
try { val exception = gateway.start().exceptionOrNull()
gateway.connect() if (exception != null) {
} catch(e: Throwable) { logger.e(exception) { "Failed to connect to gateway: ${exception.stackTraceToString()}" }
logger.e(e) { "Failed to connect to gateway: ${e.stackTraceToString()}" }
} }
} }
} }
fun disconnect() { fun disconnect() {
viewModelScope.launch { viewModelScope.launch {
try { val exception = gateway.pause().exceptionOrNull()
gateway.disconnect() if (exception != null) {
} catch(e: Throwable) { logger.e(exception) { "Failed to disconnect from gateway: ${exception.stackTraceToString()}" }
logger.e(e) { "Failed to connect to gateway: ${e.stackTraceToString()}" }
} }
} }
} }

View file

@ -6,12 +6,16 @@ import androidx.compose.ui.unit.Density
import androidx.compose.ui.window.Window import androidx.compose.ui.window.Window
import androidx.compose.ui.window.application import androidx.compose.ui.window.application
import moe.lava.neon.ui.App import moe.lava.neon.ui.App
import moe.lava.neon.ui.di.initKoin
// The UI is designed with touchscreens in mind; on desktop elements may look gigantic // The UI is designed with touchscreens in mind; on desktop elements may look gigantic
// So scale them down a bit // So scale them down a bit
const val scaleFactor = 0.75f const val scaleFactor = 0.75f
fun main() = application { fun main() = application {
initKoin {
printLogger()
}
Window( Window(
onCloseRequest = ::exitApplication, onCloseRequest = ::exitApplication,
title = "Neon", title = "Neon",

View file

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

View file

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