diff --git a/backintime-websocket/client/build.gradle.kts b/backintime-websocket/client/build.gradle.kts new file mode 100644 index 00000000..9078cb4b --- /dev/null +++ b/backintime-websocket/client/build.gradle.kts @@ -0,0 +1,30 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} + +kotlin { + jvm() + androidTarget() + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvmToolchain(17) + + sourceSets { + commonMain.dependencies { + implementation(project(":backintime-websocket:event")) + implementation(libs.ktor.client.core) + implementation(libs.ktor.client.cio) + implementation(libs.ktor.client.websockets) + implementation(libs.ktor.serialization.kotlinx.json) + } + } +} + +android { + namespace = "com.github.kitakkun.backintime.websocket.client" + compileSdk = 34 +} diff --git a/backintime-websocket/client/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/client/BackInTimeWebSocketClient.kt b/backintime-websocket/client/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/client/BackInTimeWebSocketClient.kt new file mode 100644 index 00000000..da18ed64 --- /dev/null +++ b/backintime-websocket/client/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/client/BackInTimeWebSocketClient.kt @@ -0,0 +1,75 @@ +package com.github.kitakkun.backintime.websocket.client + +import com.github.kitakkun.backintime.websocket.event.AppToDebuggerEvent +import com.github.kitakkun.backintime.websocket.event.DebuggerToAppEvent +import io.ktor.client.HttpClient +import io.ktor.client.plugins.websocket.DefaultClientWebSocketSession +import io.ktor.client.plugins.websocket.WebSockets +import io.ktor.client.plugins.websocket.webSocket +import io.ktor.serialization.kotlinx.KotlinxWebsocketSerializationConverter +import io.ktor.websocket.Frame +import io.ktor.websocket.close +import io.ktor.websocket.readText +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.coroutines.CoroutineContext + +class BackInTimeWebSocketClient : CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + + private val client = HttpClient { + install(WebSockets) { + maxFrameSize = Long.MAX_VALUE + contentConverter = KotlinxWebsocketSerializationConverter(Json) + } + } + + private var session: DefaultClientWebSocketSession? = null + val connected: Boolean get() = session != null + + private val mutableReceivedEventFlow = MutableSharedFlow() + val receivedEventFlow = mutableReceivedEventFlow.asSharedFlow() + + fun connect(host: String, port: Int) { + launch { + client.webSocket( + host = host, + port = port, + path = "/backintime", + ) { + session = this + + incoming + .consumeAsFlow() + .filterIsInstance() + .map { Json.decodeFromString(it.readText()) } + .collect { + mutableReceivedEventFlow.emit(it) + } + } + } + } + + fun send(event: AppToDebuggerEvent) { + launch { + session?.outgoing?.send(Frame.Text(Json.encodeToString(event))) + } + } + + fun close() { + launch { + session?.close() + } + session = null + } +} diff --git a/backintime-websocket/event/build.gradle.kts b/backintime-websocket/event/build.gradle.kts new file mode 100644 index 00000000..c898682b --- /dev/null +++ b/backintime-websocket/event/build.gradle.kts @@ -0,0 +1,27 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.kotlinSerialization) + alias(libs.plugins.androidLibrary) +} + +kotlin { + jvm() + androidTarget() + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvmToolchain(17) + + sourceSets { + commonMain.dependencies { + implementation(libs.kotlinx.serialization.json) + } + } +} + +android { + namespace = "com.github.kitakkun.backintime.websocket.event" + compileSdk = 34 +} diff --git a/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/AppToDebuggerEvent.kt b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/AppToDebuggerEvent.kt new file mode 100644 index 00000000..4ef98a32 --- /dev/null +++ b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/AppToDebuggerEvent.kt @@ -0,0 +1,45 @@ +package com.github.kitakkun.backintime.websocket.event + +import com.github.kitakkun.backintime.websocket.event.model.MethodInfo +import com.github.kitakkun.backintime.websocket.event.model.PropertyInfo +import kotlinx.serialization.Serializable + +@Serializable +sealed interface AppToDebuggerEvent { + @Serializable + data object Ping : AppToDebuggerEvent + + @Serializable + data class RegisterInstance( + val instanceId: String, + val className: String, + ) : AppToDebuggerEvent + + @Serializable + data class RegisterClassInfo( + val className: String, + val superClassName: String, + val methods: List, + val properties: List, + ) : AppToDebuggerEvent + + @Serializable + data class MethodCall( + val instanceId: String, + val methodName: String, + val arguments: List, + ) : AppToDebuggerEvent + + @Serializable + data class UpdatePropertyValue( + val instanceId: String, + val propertyName: String, + val newValue: String, + ) : AppToDebuggerEvent + + @Serializable + data class UpdateInstanceState( + val instanceId: String, + val alive: Boolean, + ) : AppToDebuggerEvent +} diff --git a/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/DebuggerToAppEvent.kt b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/DebuggerToAppEvent.kt new file mode 100644 index 00000000..b3aaf751 --- /dev/null +++ b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/DebuggerToAppEvent.kt @@ -0,0 +1,20 @@ +package com.github.kitakkun.backintime.websocket.event + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface DebuggerToAppEvent { + @Serializable + data object Ping : DebuggerToAppEvent + + @Serializable + data class ForceSetProperty( + val propertyName: String, + val jsonValue: String, + ) : DebuggerToAppEvent + + @Serializable + data class RequestGetInstanceState( + val instanceIds: List, + ) : DebuggerToAppEvent +} diff --git a/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/model/MethodInfo.kt b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/model/MethodInfo.kt new file mode 100644 index 00000000..460423cc --- /dev/null +++ b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/model/MethodInfo.kt @@ -0,0 +1,10 @@ +package com.github.kitakkun.backintime.websocket.event.model + +import kotlinx.serialization.Serializable + +@Serializable +data class MethodInfo( + val name: String, + val arguments: Map, + val returnType: String, +) diff --git a/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/model/PropertyInfo.kt b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/model/PropertyInfo.kt new file mode 100644 index 00000000..af0812c6 --- /dev/null +++ b/backintime-websocket/event/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/event/model/PropertyInfo.kt @@ -0,0 +1,11 @@ +package com.github.kitakkun.backintime.websocket.event.model + +import kotlinx.serialization.Serializable + +@Serializable +data class PropertyInfo( + val name: String, + val type: String, + val valueType: String, + val isNullable: Boolean, +) diff --git a/backintime-websocket/server/build.gradle.kts b/backintime-websocket/server/build.gradle.kts new file mode 100644 index 00000000..61831b86 --- /dev/null +++ b/backintime-websocket/server/build.gradle.kts @@ -0,0 +1,31 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} + +kotlin { + jvm() + androidTarget() + + iosArm64() + iosX64() + iosSimulatorArm64() + + jvmToolchain(17) + + sourceSets { + commonMain.dependencies { + implementation(project(":backintime-websocket:event")) + implementation(libs.kotlinx.coroutines.core) + implementation(libs.ktor.server.cio) + implementation(libs.ktor.server.websockets) + implementation(libs.ktor.serialization.kotlinx.json) + implementation(libs.uuid) + } + } +} + +android { + namespace = "com.github.kitakkun.backintime.websocket.server" + compileSdk = 34 +} diff --git a/backintime-websocket/server/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/server/BackInTimeWebSocketServer.kt b/backintime-websocket/server/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/server/BackInTimeWebSocketServer.kt new file mode 100644 index 00000000..721189ca --- /dev/null +++ b/backintime-websocket/server/src/commonMain/kotlin/com/github/kitakkun/backintime/websocket/server/BackInTimeWebSocketServer.kt @@ -0,0 +1,95 @@ +package com.github.kitakkun.backintime.websocket.server + +import com.benasher44.uuid.uuid4 +import com.github.kitakkun.backintime.websocket.event.AppToDebuggerEvent +import com.github.kitakkun.backintime.websocket.event.DebuggerToAppEvent +import io.ktor.server.application.install +import io.ktor.server.cio.CIO +import io.ktor.server.engine.ApplicationEngine +import io.ktor.server.engine.embeddedServer +import io.ktor.server.routing.routing +import io.ktor.server.websocket.DefaultWebSocketServerSession +import io.ktor.server.websocket.WebSockets +import io.ktor.server.websocket.webSocket +import io.ktor.websocket.Frame +import io.ktor.websocket.readText +import io.ktor.websocket.send +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.IO +import kotlinx.coroutines.SupervisorJob +import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.asSharedFlow +import kotlinx.coroutines.flow.consumeAsFlow +import kotlinx.coroutines.flow.filterIsInstance +import kotlinx.coroutines.flow.map +import kotlinx.coroutines.isActive +import kotlinx.coroutines.launch +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import kotlin.coroutines.CoroutineContext + +data class Connection( + val session: DefaultWebSocketServerSession, + val id: String = uuid4().toString(), +) + +class BackInTimeWebSocketServer : CoroutineScope { + override val coroutineContext: CoroutineContext = Dispatchers.IO + SupervisorJob() + private var server: ApplicationEngine? = null + + suspend fun getActualPort() = server?.resolvedConnectors()?.firstOrNull()?.port + suspend fun getActualHost() = server?.resolvedConnectors()?.firstOrNull()?.host + + val isRunning: Boolean get() = server?.application?.isActive == true + private val connections = mutableListOf() + + private val mutableConnectionEstablishedFlow = MutableSharedFlow() + val connectionEstablishedFlow = mutableConnectionEstablishedFlow.asSharedFlow() + + private val mutableReceivedEventFlow = MutableSharedFlow>() + val receivedEventFlow = mutableReceivedEventFlow.asSharedFlow() + + fun start(port: Int) { + server = embeddedServer( + factory = CIO, + port = port, + host = "127.0.0.1", + ) { + install(WebSockets) { + timeoutMillis = 1000 * 60 * 10 + } + routing { + webSocket("/backintime") { + val connection = Connection(this) + connections += connection + + mutableConnectionEstablishedFlow.emit(connection.id) + + incoming + .consumeAsFlow() + .filterIsInstance() + .map { Json.decodeFromString(it.readText()) } + .collect { + mutableReceivedEventFlow.emit(connection.id to it) + } + } + } + } + + server?.start() + } + + fun send(event: DebuggerToAppEvent) { + launch { + connections.forEach { + it.session.send(Json.encodeToString(event)) + } + } + } + + fun stop() { + server?.stop() + server = null + } +} diff --git a/backintime-websocket/test/build.gradle.kts b/backintime-websocket/test/build.gradle.kts new file mode 100644 index 00000000..85e0271f --- /dev/null +++ b/backintime-websocket/test/build.gradle.kts @@ -0,0 +1,33 @@ +plugins { + alias(libs.plugins.kotlinMultiplatform) + alias(libs.plugins.androidLibrary) +} + +kotlin { + jvm() + androidTarget() + + iosX64() + iosArm64() + iosSimulatorArm64() + + jvmToolchain(17) + + sourceSets { + commonMain.dependencies { + implementation(project(":backintime-websocket:event")) + implementation(project(":backintime-websocket:server")) + implementation(project(":backintime-websocket:client")) + } + + commonTest.dependencies { + implementation(libs.kotlin.test) + implementation(libs.kotlinx.coroutines.test) + } + } +} + +android { + namespace = "com.github.kitakkun.backintime.websocket.test" + compileSdk = 34 +} diff --git a/backintime-websocket/test/src/commonTest/kotlin/com/github/kitakkun/backintime/websocket/test/WebSocketCommunicationTest.kt b/backintime-websocket/test/src/commonTest/kotlin/com/github/kitakkun/backintime/websocket/test/WebSocketCommunicationTest.kt new file mode 100644 index 00000000..758a3820 --- /dev/null +++ b/backintime-websocket/test/src/commonTest/kotlin/com/github/kitakkun/backintime/websocket/test/WebSocketCommunicationTest.kt @@ -0,0 +1,77 @@ +package com.github.kitakkun.backintime.websocket.test + +import com.github.kitakkun.backintime.websocket.client.BackInTimeWebSocketClient +import com.github.kitakkun.backintime.websocket.event.AppToDebuggerEvent +import com.github.kitakkun.backintime.websocket.event.DebuggerToAppEvent +import com.github.kitakkun.backintime.websocket.server.BackInTimeWebSocketServer +import kotlinx.coroutines.delay +import kotlinx.coroutines.launch +import kotlinx.coroutines.runBlocking +import kotlin.test.AfterTest +import kotlin.test.BeforeTest +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNotNull +import kotlin.test.assertTrue + +class WebSocketCommunicationTest { + private val server = BackInTimeWebSocketServer() + private val client = BackInTimeWebSocketClient() + + @BeforeTest + fun setup() = runBlocking { + server.start(0) + assertTrue(server.isRunning) + + val host = server.getActualHost() + val port = server.getActualPort() + assertNotNull(host) + assertNotNull(port) + + client.connect(host = host, port = port) + + while (!client.connected) { + // Wait for the client to connect + delay(500) + } + assertTrue(client.connected) + } + + @AfterTest + fun tearDown() { + runBlocking { + client.close() + server.stop() + } + } + + @Test + fun sendEventFromDebuggerToApp() = runBlocking { + val collectedEvents = mutableListOf() + + val job = launch { client.receivedEventFlow.collect(collectedEvents::add) } + + val event = DebuggerToAppEvent.Ping + server.send(event) + delay(50) // FIXME: This is a hack to wait for the event to be processed + job.cancel() + + assertEquals(expected = 1, actual = collectedEvents.size) + assertEquals(expected = event, actual = collectedEvents[0]) + } + + @Test + fun sendEventFromAppToDebugger() = runBlocking { + val collectedEvents = mutableListOf() + + val job = launch { server.receivedEventFlow.collect { collectedEvents.add(it.second) } } + + val event = AppToDebuggerEvent.Ping + client.send(event) + delay(50) // FIXME: This is a hack to wait for the event to be processed + job.cancel() + + assertEquals(expected = 1, actual = collectedEvents.size) + assertEquals(expected = event, actual = collectedEvents[0]) + } +} diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index f3dbb615..5d7cab4e 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -24,6 +24,9 @@ room-runtime = "2.6.1" koin-bom = "3.5.0" soloader = "0.10.5" +ktor = "2.3.9" +uuid = "0.8.4" + ktlint-gradle = "12.1.0" ktlint = "1.2.1" @@ -73,6 +76,17 @@ koin-bom = { group = "io.insert-koin", name = "koin-bom", version.ref = "koin-bo koin-android = { group = "io.insert-koin", name = "koin-android" } koin-compose = { group = "io.insert-koin", name = "koin-androidx-compose" } +# ktor server +ktor-server-cio = { module = "io.ktor:ktor-server-cio", version.ref = "ktor" } +ktor-server-websockets = { module = "io.ktor:ktor-server-websockets", version.ref = "ktor" } +ktor-serialization-kotlinx-json = { module = "io.ktor:ktor-serialization-kotlinx-json", version.ref = "ktor" } +# ktor client +ktor-client-core = { module = "io.ktor:ktor-client-core", version.ref = "ktor" } +ktor-client-cio = { module = "io.ktor:ktor-client-cio", version.ref = "ktor" } +ktor-client-websockets = { module = "io.ktor:ktor-client-websockets", version.ref = "ktor" } +# multiplatform uuid +uuid = { module = "com.benasher44:uuid", version.ref = "uuid" } + [plugins] # convention backintimeLint = { id = "backintime.lint", version = "unspecified" } diff --git a/settings.gradle.kts b/settings.gradle.kts index 01c42a7c..24d7dfe5 100644 --- a/settings.gradle.kts +++ b/settings.gradle.kts @@ -27,5 +27,9 @@ include( ":backintime-annotations", ":test", ":backintime-demo", - ":backintime-demo:app" -) \ No newline at end of file + ":backintime-demo:app", + ":backintime-websocket:server", + ":backintime-websocket:client", + ":backintime-websocket:event", + ":backintime-websocket:test", +)