init
This commit is contained in:
@@ -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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user