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

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

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

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

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

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 resumeGatewayUrl: String,
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.decodeFromJsonElement
import moe.lava.neon.core.api.ApiConstants
import moe.lava.neon.api.ApiConstants.json
private val json = ApiConstants.json
fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
internal fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
val opcode: Int = when (this) {
is Event.Heartbeat -> 1
is Event.Identify -> 2
@ -16,7 +14,7 @@ fun <T : Event.Outgoing> T.pack(): Payload.Outgoing<T> {
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<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 dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.HttpClient
import io.ktor.client.call.body
import io.ktor.client.engine.HttpClientEngine
import io.ktor.client.plugins.HttpSend
import io.ktor.client.plugins.contentnegotiation.ContentNegotiation
import io.ktor.client.plugins.cookies.HttpCookies
@ -14,18 +12,21 @@ import io.ktor.client.plugins.plugin
import io.ktor.client.plugins.websocket.WebSockets
import io.ktor.client.request.header
import io.ktor.client.statement.bodyAsText
import io.ktor.http.ContentType
import io.ktor.http.contentType
import io.ktor.http.userAgent
import io.ktor.serialization.kotlinx.json.json
import io.ktor.util.appendAll
import kotlinx.serialization.ExperimentalSerializationApi
import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonNamingStrategy
import moe.lava.neon.core.api.captcha.CaptchaRequest
import moe.lava.neon.core.api.captcha.CaptchaResponse
import moe.lava.neon.common.captcha.CaptchaRequest
import moe.lava.neon.common.captcha.CaptchaResponse
class ApiClient internal constructor(
engine: HttpClientEngine,
assertSuccess: Boolean,
) {
constructor() : this(HttpClient().engine, true)
@SingleIn(AppScope::class)
@Inject
class ApiClient {
private val logger = Logger.withTag("neon.core.api/client")
private var captchaHandler: (suspend (CaptchaRequest) -> CaptchaResponse)? = null
@ -35,8 +36,8 @@ class ApiClient {
}
@OptIn(ExperimentalSerializationApi::class)
val client = HttpClient {
expectSuccess = true
internal val client = HttpClient(engine) {
expectSuccess = assertSuccess
install(ContentNegotiation) {
json(ApiConstants.json)
}
@ -44,6 +45,7 @@ class ApiClient {
install(HttpCookies)
defaultRequest {
url("https://discord.com/api/v9/")
contentType(ContentType.Application.Json)
userAgent(ApiConstants.userAgent)
headers.appendAll(ApiConstants.baseHeaders)
}
@ -51,6 +53,7 @@ class ApiClient {
plugin(HttpSend).intercept { req ->
logger.d { "Intercepting ${req.url.buildString()}" }
val call = execute(req)
logger.d { "recv ${call.response.bodyAsText()}" }
if (call.response.status.value != 400) return@intercept call
logger.d { "Found 400 response: ${call.response.bodyAsText()}" }
val captchaRequest = runCatching { call.response.body<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
@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}",
)

View file

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

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

View file

@ -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 = "",

View file

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

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

View file

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

View file

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

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.nullableString
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@SingleIn(AppScope::class)
@Inject
class AppSettings {
internal class AppSettings {
private val settings = Settings()
var fingerprint by settings.nullableString()

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
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
import io.ktor.client.call.body
import io.ktor.client.request.get
import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.client.request.post
import io.ktor.client.request.setBody
import io.ktor.http.ContentType
import io.ktor.http.contentType
import kotlinx.serialization.Serializable
import moe.lava.neon.api.ApiClient
import moe.lava.neon.api.endpoints.getExperiments
import moe.lava.neon.api.endpoints.login
import moe.lava.neon.core.AppSettings
import moe.lava.neon.core.api.ApiClient
@Serializable
private data class ExperimentResponse(
val fingerprint: String,
)
@Serializable
private data class LoginRequest(
val login: String,
val password: String,
val undelete: Boolean = false,
val loginSource: String? = null,
val giftCodeSkuId: String? = null,
)
@Serializable
private data class LoginResponse(
val userId: String,
val token: String,
val userSettings: UserSettings,
) {
@Serializable
data class UserSettings(val locale: String, val theme: String)
}
sealed class AuthResponse {
// TODO: Specify all possible error types here
data class Failed(val error: Throwable) : AuthResponse()
data class Success(val token: String) : AuthResponse()
// TODO
// data class MFARequested() : AuthResponse()
}
@Inject
@SingleIn(AppScope::class)
class AuthRepository(
class AuthRepository internal constructor(
private val settings: AppSettings,
private val api: ApiClient,
) {
private val logger = Logger.withTag("neon.core.repo/auth")
var token by settings::token
private set
private var token by settings::token
private var fingerprint by settings::fingerprint
var fingerprint by settings::fingerprint
private set
val loggedIn get() = token != null
suspend fun login(
email: String,
password: String,
): AuthResponse {
if (fingerprint == null) {
fingerprint = api.client.get("experiments") {
parameter("with_guild_experiments", "true")
}.body<ExperimentResponse>().fingerprint
}
try {
if (fingerprint == null) {
fingerprint = api.getExperiments().body().fingerprint
}
val res = api.client.post("auth/login") {
header("X-Fingerprint", fingerprint)
contentType(ContentType.Application.Json)
setBody(LoginRequest(
login = email,
val login = api.login(
email = email,
password = password,
))
fingerprint = fingerprint!!,
).body()
logger.i { "Login success $login" }
this.token = login.token
return AuthResponse.Success(login.token)
} catch (e: Throwable) {
return AuthResponse.Failed(e)
}
val body = res.body<LoginResponse>()
logger.i { "Login success $body" }
this.token = body.token
return AuthResponse.Success(body.token)
}
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
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.Inject
import dev.zacsweers.metro.SingleIn
@Inject
@SingleIn(AppScope::class)
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]
#noinspection AndroidGradlePluginVersion
agp = "8.13.2"
agp = "9.0.0"
android-compileSdk = "36"
android-minSdk = "24"
android-targetSdk = "36"
@ -19,12 +19,16 @@ desugar = "2.1.5"
hcaptcha = "4.4.0"
junit = "4.13.2"
kermit = "2.0.8"
koin-bom = "4.2.0-RC1"
koin-plugin = "0.3.0"
kotest = "6.1.2"
kotlin = "2.3.0"
kotlinx-coroutines = "1.10.2"
kotlinx-serialization = "1.10.0"
ksp = "2.3.4"
ktor = "3.4.0"
material3 = "1.11.0-alpha02"
material3-adaptive = "1.3.0-alpha04"
metro = "0.10.2"
settings = "1.3.0"
sqldelight = "2.2.1"
@ -54,24 +58,38 @@ desugar = { module = "com.android.tools:desugar_jdk_libs", version.ref = "desuga
hcaptcha-compose = { module = "com.github.hCaptcha.hcaptcha-android-sdk:compose-sdk", version.ref = "hcaptcha" }
junit = { module = "junit:junit", version.ref = "junit" }
kermit = { module = "co.touchlab:kermit", version.ref = "kermit" }
koin-bom = { module = "io.insert-koin:koin-bom", version.ref = "koin-bom" }
koin-compose = { module = "io.insert-koin:koin-compose" }
koin-compose-viewmodel = { module = "io.insert-koin:koin-compose-viewmodel" }
koin-compose-navigation3 = { module = "io.insert-koin:koin-compose-navigation3" }
koin-core = { module = "io.insert-koin:koin-core" }
koin-test = { module = "io.insert-koin:koin-test" }
kotest-assertions = { module = "io.kotest:kotest-assertions-core", version.ref = "kotest" }
kotest-framework = { module = "io.kotest:kotest-framework-engine", version.ref = "kotest" }
kotest-property = { module = "io.kotest:kotest-property", version.ref = "kotest" }
kotest-runner-junit5 = { module = "io.kotest:kotest-runner-junit5", version.ref = "kotest" }
kotlin-test = { module = "org.jetbrains.kotlin:kotlin-test", version.ref = "kotlin" }
kotlin-test-junit = { module = "org.jetbrains.kotlin:kotlin-test-junit", version.ref = "kotlin" }
kotlinx-coroutines-swing = { module = "org.jetbrains.kotlinx:kotlinx-coroutines-swing", version.ref = "kotlinx-coroutines" }
kotlinx-serialization-core = { module = "org.jetbrains.kotlinx:kotlinx-serialization-core", version.ref = "kotlinx-serialization" }
kotlinx-serialization-json = { module = "org.jetbrains.kotlinx:kotlinx-serialization-json", version.ref = "kotlinx-serialization" }
ktor-client-content-negotiation = { module = "io.ktor:ktor-client-content-negotiation", version.ref = "ktor" }
ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" }
ktor-client-mock = { module = "io.ktor:ktor-client-mock", version.ref = "ktor" }
ktor-client-okhttp = { module = "io.ktor:ktor-client-okhttp", version.ref = "ktor" }
ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" }
ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" }
metrox-viewmodel-compose = { module = "dev.zacsweers.metro:metrox-viewmodel-compose", version.ref = "metro" }
settings = { module = "com.russhwolf:multiplatform-settings-no-arg", version.ref = "settings" }
[plugins]
androidApplication = { id = "com.android.application", version.ref = "agp" }
androidLibrary = { id = "com.android.library", version.ref = "agp" }
androidMultiplatformLibrary = { id = "com.android.kotlin.multiplatform.library", version.ref = "agp" }
composeCompiler = { id = "org.jetbrains.kotlin.plugin.compose", version.ref = "kotlin" }
composeHotReload = { id = "org.jetbrains.compose.hot-reload", version.ref = "compose-hot-reload" }
composeMultiplatform = { id = "org.jetbrains.compose", version.ref = "compose-multiplatform" }
koinCompiler = { id = "io.insert-koin.compiler.plugin", version.ref = "koin-plugin" }
kotest = { id = "io.kotest", version.ref = "kotest" }
kotlinMultiplatform = { id = "org.jetbrains.kotlin.multiplatform", version.ref = "kotlin" }
kotlinSerialization = { id = "org.jetbrains.kotlin.plugin.serialization", version.ref = "kotlin" }
metro = { id = "dev.zacsweers.metro", version.ref = "metro" }
ksp = { id = "com.google.devtools.ksp", version.ref = "ksp" }
sqldelight = { id = "app.cash.sqldelight", version.ref = "sqldelight" }

View file

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

View file

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

View file

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

View file

@ -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<Pair<String, CaptchaResponse>>()
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)

View file

@ -5,7 +5,6 @@ import androidx.compose.material3.MaterialExpressiveTheme
import androidx.compose.material3.MotionScheme
import androidx.compose.material3.adaptive.ExperimentalMaterial3AdaptiveApi
import androidx.compose.runtime.Composable
import androidx.compose.runtime.CompositionLocalProvider
import androidx.lifecycle.viewmodel.navigation3.rememberViewModelStoreNavEntryDecorator
import androidx.navigation3.runtime.NavKey
import androidx.navigation3.runtime.entryProvider
@ -14,12 +13,11 @@ import androidx.navigation3.runtime.rememberSaveableStateHolderNavEntryDecorator
import androidx.navigation3.ui.NavDisplay
import androidx.savedstate.serialization.SavedStateConfiguration
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.createGraph
import dev.zacsweers.metrox.viewmodel.LocalMetroViewModelFactory
import kotlinx.serialization.Serializable
import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.polymorphic
import moe.lava.neon.ui.di.AppUiGraph
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.CaptchaRepository
import moe.lava.neon.ui.screens.Login
import moe.lava.neon.ui.screens.Sample
import moe.lava.neon.ui.screens.chat.Chat
@ -29,6 +27,7 @@ import moe.lava.neon.ui.screens.navigator.NavigatorModel
import moe.lava.neon.ui.screens.navigator.NavigatorPreviewProvider
import moe.lava.neon.ui.util.ThreePaneSceneStrategy
import moe.lava.neon.ui.util.rememberThreePaneSceneStrategy
import org.koin.compose.koinInject
import kotlin.system.exitProcess
object Route {
@ -68,81 +67,81 @@ fun App() {
exitProcess(1)
}
val uiGraph = createGraph<AppUiGraph>()
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<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)
}
)
}
val auth: AuthRepository = koinInject()
val captcha: CaptchaRepository = koinInject()
captcha.setHandler(getCaptchaHandler())
entry<Route.Navigator>(
metadata = ThreePaneSceneStrategy.listPane()
) { key ->
if (key.left) {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
)
)
} else {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
)
)
MaterialExpressiveTheme(
colorScheme = getColorScheme(),
motionScheme = MotionScheme.expressive(),
) {
val init = if (auth.loggedIn) Route.Sample else Route.Login
// val backStack = rememberNavBackStack(config, init)
val backStack = rememberNavBackStack(config, Route.Sample)
val threePaneStrategy = rememberThreePaneSceneStrategy<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.Chat>(
metadata = ThreePaneSceneStrategy.detailPane()
) {
Chat(
onOpenMembers = { backStack.add(Route.MembersList) }
entry<Route.Navigator>(
metadata = ThreePaneSceneStrategy.listPane()
) { key ->
if (key.left) {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.LeftSidebar
)
)
} else {
Navigator(
NavigatorPreviewProvider.base2.copy(
guildNavPosition = NavigatorModel.GuildNavPosition.BottomSheet
)
)
}
entry<Route.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.lifecycle.ViewModel
import co.touchlab.kermit.Logger
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.ContributesIntoMap
import dev.zacsweers.metro.Inject
import dev.zacsweers.metrox.viewmodel.ViewModelKey
import dev.zacsweers.metrox.viewmodel.metroViewModel
import kotlinx.coroutines.launch
import moe.lava.neon.core.repository.AuthRepository
import moe.lava.neon.core.repository.AuthResponse
@ -35,12 +30,13 @@ import moe.lava.neon.resources.Res
import moe.lava.neon.resources.visibility
import moe.lava.neon.resources.visibility_off
import org.jetbrains.compose.resources.painterResource
import org.koin.compose.viewmodel.koinViewModel
@Composable
fun Login(
onSuccess: () -> Unit,
) {
val viewModel: LoginViewModel = metroViewModel()
val viewModel: LoginViewModel = koinViewModel()
val scope = rememberCoroutineScope()
Column(
@ -113,9 +109,6 @@ fun Login(
}
}
@Inject
@ViewModelKey(LoginViewModel::class)
@ContributesIntoMap(AppScope::class)
class LoginViewModel(
private val auth: AuthRepository
) : ViewModel() {
@ -129,13 +122,13 @@ class LoginViewModel(
}
suspend fun login(email: String, password: String): LoginResult {
return try {
when (val res = auth.login(email, password)) {
is AuthResponse.Success -> LoginResult.Success
return when (val res = auth.login(email, password)) {
is AuthResponse.Success -> LoginResult.Success
is AuthResponse.Failed -> {
val e = res.error
logger.e(e) { "Login failed" }
LoginResult.Failed(e.toString())
}
} catch(e: Throwable) {
logger.e(e) { "Login failed" }
LoginResult.Failed(e.toString())
}
}
}

View file

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

View file

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

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