This commit is contained in:
2026-09-25 13:24:56 +03:00
parent 45066a65a1
commit 987f9e5547
47 changed files with 4998 additions and 1 deletions
+44
View File
@@ -0,0 +1,44 @@
plugins {
alias(libs.plugins.android.library)
alias(libs.plugins.kotlin.serialization)
}
android {
namespace = "pw.binom.rayneovm.shared"
compileSdk = 37
defaultConfig {
minSdk = 26
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
}
testOptions {
unitTests.all {
it.useJUnitPlatform()
}
}
}
kotlin {
compilerOptions {
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
}
}
dependencies {
api(libs.kotlinx.coroutines.core)
api(libs.kotlinx.serialization.json)
api(libs.mercury.transport)
testImplementation(libs.kotlinx.coroutines.test)
testImplementation(libs.junit.jupiter)
testImplementation(libs.kotlin.test) // need for kotlin.test.Test
testImplementation(libs.mockk)
testImplementation(libs.turbine)
testRuntimeOnly(libs.junit.platform.launcher)
}
+2
View File
@@ -0,0 +1,2 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android" />
@@ -0,0 +1,113 @@
package pw.binom.rayneovm.shared
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
/**
* Корневой sealed-интерфейс сообщения между телефоном и очками.
*
* Все DTO — сериализуемые в JSON. Полиморфная дискриминация по полю `type`
* (настраивается в [VmProtocol]).
*
* Потоки:
* - Phone → Glasses: [TouchEvent], [KeyEvent], [LaunchAppCommand], [SetAudioMode], [Shutdown]
* - Glasses → Phone: [GlassesStatus], [AppCrashedEvent], [AppLaunchedAck]
* - Двунаправленные: [Ping], [Pong]
*/
@Serializable
sealed interface VmMessage
// =====================================================================
// Phone → Glasses
// =====================================================================
@Serializable
@SerialName("touch")
data class TouchEvent(
val actions: List<TouchAction>,
) : VmMessage
@Serializable
data class TouchAction(
val type: TouchType,
/** X в координатах виртуального дисплея гостевого приложения (0..width-1). */
val x: Float,
/** Y в координатах виртуального дисплея гостевого приложения (0..height-1). */
val y: Float,
/** Pointer ID (для multi-touch; для простого тачпада всегда 0). */
val pointerId: Int = 0,
)
@Serializable
enum class TouchType { DOWN, MOVE, UP, CANCEL }
@Serializable
@SerialName("key")
data class KeyEvent(
/** KeyCode из android.view.KeyEvent. */
val keyCode: Int,
/** MotionEvent action: ACTION_DOWN = 0, ACTION_UP = 1. */
val action: Int,
) : VmMessage {
companion object {
const val ACTION_DOWN = 0
const val ACTION_UP = 1
}
}
@Serializable
@SerialName("launch_app")
data class LaunchAppCommand(
val packageName: String,
) : VmMessage
@Serializable
@SerialName("set_audio_mode")
data class SetAudioMode(
val mode: AudioMode,
) : VmMessage
@Serializable
enum class AudioMode { ON_GLASSES, ON_PHONE }
@Serializable
@SerialName("shutdown")
data object Shutdown : VmMessage
// =====================================================================
// Glasses → Phone
// =====================================================================
@Serializable
@SerialName("glasses_status")
data class GlassesStatus(
val batteryPercent: Int? = null,
val wifiSsid: String? = null,
val currentApp: String? = null,
val running: Boolean = false,
) : VmMessage
@Serializable
@SerialName("app_crashed")
data class AppCrashedEvent(
val packageName: String,
) : VmMessage
@Serializable
@SerialName("app_launched")
data class AppLaunchedAck(
val packageName: String,
val success: Boolean,
) : VmMessage
// =====================================================================
// Bidirectional
// =====================================================================
@Serializable
@SerialName("ping")
data object Ping : VmMessage
@Serializable
@SerialName("pong")
data object Pong : VmMessage
@@ -0,0 +1,51 @@
package pw.binom.rayneovm.shared
import kotlinx.serialization.encodeToString
import kotlinx.serialization.json.Json
/**
* Протокол общения Phone ↔ Glasses.
*
* Надстройка над JSON-сериализацией для [VmMessage].
* Не делает никакой логики доставки — только упаковка/распаковка.
*
* Используется поверх любого [pw.binom.mercury.transport.StringTransport] (WebSocket, BT).
*/
object VmProtocol {
val json: Json = Json {
ignoreUnknownKeys = true
encodeDefaults = false
classDiscriminator = "type"
}
/**
* Сериализует сообщение в JSON-строку.
*/
fun encode(message: VmMessage): String =
json.encodeToString(message)
/**
* Десериализует JSON-строку в [VmMessage].
*
* @throws VmProtocolException если JSON невалидный или тип неизвестен
*/
inline fun <reified T : VmMessage> decode(jsonString: String): T =
try {
json.decodeFromString<T>(jsonString)
} catch (t: Throwable) {
throw VmProtocolException("Cannot decode VmMessage: ${t.message}", t)
}
/**
* Десериализует JSON-строку в базовый [VmMessage] (полиморфная дискриминация).
*/
fun decodeAny(jsonString: String): VmMessage =
try {
json.decodeFromString<VmMessage>(jsonString)
} catch (t: Throwable) {
throw VmProtocolException("Cannot decode VmMessage: ${t.message}", t)
}
}
class VmProtocolException(message: String, cause: Throwable? = null) : RuntimeException(message, cause)
@@ -0,0 +1,136 @@
package pw.binom.rayneovm.shared
import org.junit.jupiter.api.Test
import kotlin.test.assertEquals
import kotlin.test.assertTrue
class VmProtocolTest {
@Test
fun `TouchEvent roundtrip`() {
val msg = TouchEvent(
actions = listOf(
TouchAction(TouchType.DOWN, 100f, 200f, pointerId = 0),
TouchAction(TouchType.MOVE, 110f, 205f, pointerId = 0),
TouchAction(TouchType.UP, 120f, 210f, pointerId = 0),
)
)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<TouchEvent>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `KeyEvent roundtrip`() {
val msg = KeyEvent(keyCode = 66, action = KeyEvent.ACTION_DOWN) // 66 = ENTER
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<KeyEvent>(encoded)
assertEquals(msg, decoded)
assertEquals(66, decoded.keyCode)
}
@Test
fun `LaunchAppCommand roundtrip`() {
val msg = LaunchAppCommand(packageName = "free.zona")
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<LaunchAppCommand>(encoded)
assertEquals(msg, decoded)
assertEquals("free.zona", decoded.packageName)
}
@Test
fun `SetAudioMode roundtrip`() {
val msg = SetAudioMode(mode = AudioMode.ON_PHONE)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<SetAudioMode>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `GlassesStatus roundtrip`() {
val msg = GlassesStatus(
batteryPercent = 87,
wifiSsid = "HomeNetwork",
currentApp = "free.zona",
running = true,
)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<GlassesStatus>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `AppCrashedEvent roundtrip`() {
val msg = AppCrashedEvent(packageName = "free.zona")
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<AppCrashedEvent>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `AppLaunchedAck roundtrip`() {
val msg = AppLaunchedAck(packageName = "free.zona", success = true)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<AppLaunchedAck>(encoded)
assertEquals(msg, decoded)
}
@Test
fun `Ping and Pong encode as data object`() {
assertEquals("""{"type":"ping"}""", VmProtocol.encode(Ping))
assertEquals("""{"type":"pong"}""", VmProtocol.encode(Pong))
}
@Test
fun `decodeAny for TouchEvent uses type discriminator`() {
val json = """{"type":"touch","actions":[{"type":"DOWN","x":10,"y":20,"pointerId":0}]}"""
val msg = VmProtocol.decodeAny(json)
assertTrue(msg is TouchEvent)
assertEquals(1, msg.actions.size)
assertEquals(TouchType.DOWN, msg.actions[0].type)
assertEquals(10f, msg.actions[0].x)
}
@Test
fun `decodeAny for LaunchAppCommand`() {
val json = """{"type":"launch_app","packageName":"free.zona"}"""
val msg = VmProtocol.decodeAny(json)
assertTrue(msg is LaunchAppCommand)
assertEquals("free.zona", msg.packageName)
}
@Test
fun `decodeAny for AppCrashedEvent`() {
val json = """{"type":"app_crashed","packageName":"free.zona"}"""
val msg = VmProtocol.decodeAny(json)
assertTrue(msg is AppCrashedEvent)
assertEquals("free.zona", msg.packageName)
}
@Test
fun `Shutdown data object roundtrip`() {
val encoded = VmProtocol.encode(Shutdown)
val decoded = VmProtocol.decodeAny(encoded)
assertEquals(Shutdown, decoded)
}
@Test
fun `SetAudioMode ON_GLASSES roundtrip`() {
val msg = SetAudioMode(mode = AudioMode.ON_GLASSES)
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decodeAny(encoded)
assertEquals(msg, decoded)
assertEquals(AudioMode.ON_GLASSES, (decoded as SetAudioMode).mode)
}
@Test
fun `GlassesStatus without fields default to nulls and false`() {
val msg = GlassesStatus()
val encoded = VmProtocol.encode(msg)
val decoded = VmProtocol.decode<GlassesStatus>(encoded)
assertEquals(null, decoded.batteryPercent)
assertEquals(null, decoded.wifiSsid)
assertEquals(null, decoded.currentApp)
assertEquals(false, decoded.running)
}
}