init
This commit is contained in:
@@ -0,0 +1,48 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.ACCESS_WIFI_STATE" />
|
||||
<uses-permission android:name="android.permission.CHANGE_WIFI_MULTICAST_STATE" />
|
||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_ADMIN" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_CONNECT" />
|
||||
<uses-permission android:name="android.permission.BLUETOOTH_SCAN" />
|
||||
<uses-permission android:name="android.permission.ACCESS_FINE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.ACCESS_COARSE_LOCATION" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
|
||||
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_CONNECTED_DEVICE" />
|
||||
<uses-permission android:name="android.permission.WAKE_LOCK" />
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
<uses-permission android:name="moe.shizuku.permission.SHIZUKU" />
|
||||
|
||||
<application
|
||||
android:name=".PhoneVmApp"
|
||||
android:label="@string/app_name"
|
||||
android:allowBackup="false"
|
||||
android:theme="@style/Theme.RayNeoVm"
|
||||
android:supportsRtl="true"
|
||||
tools:targetApi="36">
|
||||
|
||||
<activity
|
||||
android:name=".MainActivity"
|
||||
android:exported="true"
|
||||
android:configChanges="orientation|screenSize|keyboardHidden"
|
||||
android:launchMode="singleTask"
|
||||
android:theme="@style/Theme.RayNeoVm">
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.MAIN" />
|
||||
<category android:name="android.intent.category.LAUNCHER" />
|
||||
</intent-filter>
|
||||
</activity>
|
||||
|
||||
<!-- Foreground service для фонового воспроизведения аудио -->
|
||||
<service
|
||||
android:name=".audio.AudioPlaybackService"
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
</application>
|
||||
</manifest>
|
||||
@@ -0,0 +1,59 @@
|
||||
package pw.binom.rayneovm.phone
|
||||
|
||||
import android.os.Bundle
|
||||
import androidx.activity.ComponentActivity
|
||||
import androidx.activity.compose.rememberLauncherForActivityResult
|
||||
import androidx.activity.compose.setContent
|
||||
import androidx.activity.enableEdgeToEdge
|
||||
import androidx.activity.result.contract.ActivityResultContracts
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.CompositionLocalProvider
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
import pw.binom.rayneovm.phone.ui.HomeScreen
|
||||
|
||||
/**
|
||||
* Single-Activity приложения. Compose host + Navigation.
|
||||
*/
|
||||
class MainActivity : ComponentActivity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
val controller = VmPhoneController.fromContext(this)
|
||||
val mercuryController = MercuryController.fromContext(this)
|
||||
setContent {
|
||||
val permissionLauncher = rememberLauncherForActivityResult(
|
||||
contract = ActivityResultContracts.RequestMultiplePermissions(),
|
||||
) { result ->
|
||||
val allGranted = result.values.all { it }
|
||||
mercuryController.onPermissionsResult(allGranted)
|
||||
}
|
||||
CompositionLocalProvider(
|
||||
VmPhoneController.Local provides controller,
|
||||
MercuryController.Local provides mercuryController,
|
||||
) {
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
androidx.compose.foundation.layout.Box(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
) {
|
||||
HomeScreen(
|
||||
controller = controller,
|
||||
mercuryController = mercuryController,
|
||||
onRequestMercuryPermissions = {
|
||||
permissionLauncher.launch(mercuryController.requiredPermissions)
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,41 @@
|
||||
package pw.binom.rayneovm.phone
|
||||
|
||||
import android.app.Application
|
||||
import android.util.Log
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryControllerProvider
|
||||
import pw.binom.rayneovm.phone.protocol.ControllerProvider
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
|
||||
/**
|
||||
* Application-класс PhoneApp.
|
||||
*
|
||||
* При старте подготавливает контроллер [VmPhoneController] (auto-reconnect к очкам через mDNS)
|
||||
* и [MercuryController] (второй канал — RayNeo Mercury BLE).
|
||||
*/
|
||||
class PhoneVmApp : Application(), ControllerProvider, MercuryControllerProvider {
|
||||
|
||||
private val controller: VmPhoneController by lazy { VmPhoneController(this) }
|
||||
private val mercuryController: MercuryController by lazy { MercuryController(this) }
|
||||
|
||||
override fun onCreate() {
|
||||
super.onCreate()
|
||||
Log.i(TAG, "PhoneVmApp onCreate, uid=${android.os.Process.myUid()}")
|
||||
// Запуск отложен до первого обращения — в lazy.
|
||||
controller.start()
|
||||
mercuryController.refresh()
|
||||
}
|
||||
|
||||
override fun onTerminate() {
|
||||
super.onTerminate()
|
||||
controller.stop()
|
||||
mercuryController.close()
|
||||
}
|
||||
|
||||
override fun provideController(): VmPhoneController = controller
|
||||
override fun provideMercuryController(): MercuryController = mercuryController
|
||||
|
||||
companion object {
|
||||
private const val TAG = "PhoneVmApp"
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,108 @@
|
||||
package pw.binom.rayneovm.phone.audio
|
||||
|
||||
import android.content.Context
|
||||
import android.media.AudioAttributes
|
||||
import android.media.AudioFormat
|
||||
import android.media.AudioTrack
|
||||
import android.net.wifi.WifiManager
|
||||
import android.os.PowerManager
|
||||
import android.util.Log
|
||||
import pw.binom.mercury.transport.ByteTransport
|
||||
import pw.binom.mercury.transport.Transports
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Менеджер воспроизведения аудио с очков на телефон (Audio mode 2).
|
||||
*
|
||||
* Держит AudioTrack + WiFi/Wake lock + WebSocket-клиент, качающий PCM с очков.
|
||||
*
|
||||
* Вызывается из [pw.binom.rayneovm.phone.protocol.VmPhoneController] при получении
|
||||
* [pw.binom.rayneovm.shared.SetAudioMode] с режимом ON_PHONE.
|
||||
*/
|
||||
class AudioPlaybackManager(private val context: Context) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private var track: AudioTrack? = null
|
||||
private var transport: ByteTransport? = null
|
||||
private var streamJob: Job? = null
|
||||
private var wifiLock: WifiManager.WifiLock? = null
|
||||
private var wakeLock: PowerManager.WakeLock? = null
|
||||
|
||||
fun start(glassesHost: String) {
|
||||
if (track != null) return // уже запущено
|
||||
|
||||
val sampleRate = SAMPLE_RATE
|
||||
val channelMask = AudioFormat.CHANNEL_OUT_STEREO
|
||||
val encoding = AudioFormat.ENCODING_PCM_16BIT
|
||||
|
||||
val minBuffer = AudioTrack.getMinBufferSize(sampleRate, channelMask, encoding)
|
||||
val bufferSize = minBuffer * 4
|
||||
|
||||
track = AudioTrack.Builder()
|
||||
.setAudioAttributes(
|
||||
AudioAttributes.Builder()
|
||||
.setUsage(AudioAttributes.USAGE_MEDIA)
|
||||
.setContentType(AudioAttributes.CONTENT_TYPE_MOVIE)
|
||||
.build()
|
||||
)
|
||||
.setAudioFormat(
|
||||
AudioFormat.Builder()
|
||||
.setSampleRate(sampleRate)
|
||||
.setEncoding(encoding)
|
||||
.setChannelMask(channelMask)
|
||||
.build()
|
||||
)
|
||||
.setBufferSizeInBytes(bufferSize)
|
||||
.setTransferMode(AudioTrack.MODE_STREAM)
|
||||
.build()
|
||||
track!!.play()
|
||||
Log.i(TAG, "AudioTrack started: $sampleRate Hz, buffer=$bufferSize")
|
||||
|
||||
// WiFi + Wake lock
|
||||
val wm = context.applicationContext.getSystemService(Context.WIFI_SERVICE) as WifiManager
|
||||
wifiLock = wm.createWifiLock(WifiManager.WIFI_MODE_FULL_HIGH_PERF, "VmAudioPlaybackLock").apply { acquire() }
|
||||
val pm = context.applicationContext.getSystemService(Context.POWER_SERVICE) as PowerManager
|
||||
wakeLock = pm.newWakeLock(PowerManager.PARTIAL_WAKE_LOCK, "VmAudioPlaybackWakeLock").apply {
|
||||
acquire(60 * 60 * 1000L)
|
||||
}
|
||||
|
||||
// Подключаемся к очкам по тому же ws://host:port/vm-audio
|
||||
val wsUrl = "ws://$glassesHost:${GLASSES_PORT}/vm-audio"
|
||||
transport = Transports.wifiBytes(wsUrl, autoReconnect = true)
|
||||
|
||||
streamJob = scope.launch {
|
||||
try {
|
||||
transport!!.connect(
|
||||
onMessage = { pcm ->
|
||||
val t = track ?: return@connect
|
||||
t.write(pcm, 0, pcm.size)
|
||||
},
|
||||
onConnected = { Log.i(TAG, "audio connected to $wsUrl") },
|
||||
onDisconnected = { Log.w(TAG, "audio disconnected") },
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
Log.e(TAG, "audio transport failed", t)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
streamJob?.cancel(); streamJob = null
|
||||
transport?.close(); transport = null
|
||||
track?.stop(); track?.release(); track = null
|
||||
wifiLock?.release(); wifiLock = null
|
||||
wakeLock?.release(); wakeLock = null
|
||||
Log.i(TAG, "AudioPlaybackManager stopped")
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AudioPlaybackMgr"
|
||||
const val SAMPLE_RATE = 44100
|
||||
const val GLASSES_PORT = 8080
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
package pw.binom.rayneovm.phone.audio
|
||||
|
||||
import android.app.Notification
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.app.Service
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.ServiceInfo
|
||||
import android.os.Build
|
||||
import android.os.IBinder
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import pw.binom.rayneovm.phone.MainActivity
|
||||
|
||||
/**
|
||||
* Stub: реальный foreground service — в [AudioPlaybackManager].
|
||||
* Класс оставлен для совместимости с AndroidManifest <service> декларацией и не вызывается.
|
||||
*/
|
||||
class AudioPlaybackService : Service() {
|
||||
|
||||
override fun onBind(intent: Intent): IBinder? = null
|
||||
|
||||
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
|
||||
startForeground(NOTIFICATION_ID, buildNotification())
|
||||
return START_NOT_STICKY
|
||||
}
|
||||
|
||||
private fun buildNotification(): Notification {
|
||||
val openApp = PendingIntent.getActivity(
|
||||
this, 0, Intent(this, MainActivity::class.java),
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
return NotificationCompat.Builder(this, "vm_audio_playback")
|
||||
.setSmallIcon(android.R.drawable.ic_media_play)
|
||||
.setContentTitle("RayNeo VM")
|
||||
.setContentText("Audio playback активен")
|
||||
.setContentIntent(openApp)
|
||||
.setOngoing(true)
|
||||
.build()
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
}
|
||||
|
||||
companion object {
|
||||
private const val NOTIFICATION_ID = 300
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,271 @@
|
||||
package pw.binom.rayneovm.phone.mercury
|
||||
|
||||
import android.Manifest
|
||||
import android.bluetooth.BluetoothAdapter
|
||||
import android.bluetooth.BluetoothManager
|
||||
import android.content.Context
|
||||
import android.content.pm.PackageManager
|
||||
import android.os.Build
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancel
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.mercury.RayNeoMercury
|
||||
|
||||
/**
|
||||
* Контроллер второго канала связи с очками — RayNeo Mercury BLE.
|
||||
*
|
||||
* В отличие от [pw.binom.rayneovm.phone.protocol.VmPhoneController], который работает
|
||||
* по нашему собственному WebSocket-протоколу поверх WiFi/mDNS, этот контроллер
|
||||
* прикидывается штатным приложением RayNeo Mercury и говорит с очками по их
|
||||
* закрытому BLE-протоколу (см. rayneo-mercury/library).
|
||||
*
|
||||
* Это bootstrap-канал (правило #4746): нужен, чтобы запустить наше приложение
|
||||
* на очках через `appMarketLaunch(pkg)`, если mDNS не нашёл WebSocket-сервис.
|
||||
*/
|
||||
class MercuryController(private val context: Context) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
private val mercury = RayNeoMercury(context, externalScope = scope)
|
||||
|
||||
private val _state = MutableStateFlow<MercuryState>(MercuryState.PermissionsMissing)
|
||||
val state: StateFlow<MercuryState> = _state.asStateFlow()
|
||||
|
||||
private var lastScanResults: List<DiscoveredGlasses> = emptyList()
|
||||
|
||||
/** Разрешения, которые нужно запросить для работы с BLE на этом API. */
|
||||
val requiredPermissions: Array<String> = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S) {
|
||||
arrayOf(
|
||||
Manifest.permission.BLUETOOTH_SCAN,
|
||||
Manifest.permission.BLUETOOTH_CONNECT,
|
||||
)
|
||||
} else {
|
||||
arrayOf(Manifest.permission.ACCESS_FINE_LOCATION)
|
||||
}
|
||||
|
||||
init {
|
||||
observeEvents()
|
||||
refreshPermissionState()
|
||||
}
|
||||
|
||||
/** Перепроверить состояние Bluetooth-адаптера и разрешений. */
|
||||
fun refresh() {
|
||||
refreshPermissionState()
|
||||
}
|
||||
|
||||
/** UI вызывает после системного диалога разрешений. */
|
||||
fun onPermissionsResult(granted: Boolean) {
|
||||
if (granted) {
|
||||
if (isBluetoothEnabled()) {
|
||||
_state.value = MercuryState.Disconnected()
|
||||
} else {
|
||||
_state.value = MercuryState.BluetoothOff
|
||||
}
|
||||
} else {
|
||||
_state.value = MercuryState.PermissionsMissing
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Сканировать ближайшие очки (RayNeo Mercury advertisement).
|
||||
* По окончании — переход в [MercuryState.Disconnected] с заполненным
|
||||
* `lastScanResults`, или в [MercuryState.Scanning] во время скана.
|
||||
*/
|
||||
fun scan() {
|
||||
if (!hasPermissions()) {
|
||||
_state.value = MercuryState.PermissionsMissing
|
||||
return
|
||||
}
|
||||
if (!isBluetoothEnabled()) {
|
||||
_state.value = MercuryState.BluetoothOff
|
||||
return
|
||||
}
|
||||
_state.value = MercuryState.Scanning(emptyList())
|
||||
scope.launch {
|
||||
val results = mutableListOf<DiscoveredGlasses>()
|
||||
try {
|
||||
mercury.scan()?.collect { glasses ->
|
||||
if (results.none { it.address == glasses.address }) {
|
||||
results += glasses
|
||||
_state.value = MercuryState.Scanning(results.toList())
|
||||
}
|
||||
} ?: run {
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Bluetooth-адаптер недоступен",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
// Скан завершился без явной отмены — фиксируем результат
|
||||
lastScanResults = results
|
||||
if (results.isEmpty()) {
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Очки не найдены. Включите их и поднесите ближе.",
|
||||
)
|
||||
} else {
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Найдено устройств: ${results.size}. Выберите для подключения.",
|
||||
)
|
||||
}
|
||||
} catch (t: Throwable) {
|
||||
_state.value = MercuryState.Disconnected(message = "Ошибка скана: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Подключиться к найденному устройству. Если оно ещё не спарено — сначала
|
||||
* создаём OS-level bond, потом открываем GATT-сессию.
|
||||
*/
|
||||
fun connect(glasses: DiscoveredGlasses) {
|
||||
if (!hasPermissions()) {
|
||||
_state.value = MercuryState.PermissionsMissing
|
||||
return
|
||||
}
|
||||
_state.value = MercuryState.Connecting(glasses)
|
||||
scope.launch {
|
||||
try {
|
||||
val paired = mercury.pair(glasses)
|
||||
if (paired is pw.binom.mercury.pair.PairManager.PairingResult.Failed) {
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Сопряжение не удалось: ${paired.reason}",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
val ok = mercury.connect(glasses)
|
||||
if (!ok) {
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Не удалось открыть GATT-сессию",
|
||||
)
|
||||
return@launch
|
||||
}
|
||||
_state.value = MercuryState.Connected(glasses, batteryPercent = null)
|
||||
// Запрашиваем актуальный статус, чтобы получить battery/wifi
|
||||
mercury.pullGeneralStatus()
|
||||
} catch (t: Throwable) {
|
||||
_state.value = MercuryState.Disconnected(message = "Ошибка: ${t.message}")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun disconnect() {
|
||||
scope.launch { mercury.disconnect() }
|
||||
_state.value = MercuryState.Disconnected()
|
||||
}
|
||||
|
||||
/**
|
||||
* Запустить приложение на очках через штатный механизм Mercury
|
||||
* (appMarketLaunch). Требует активной BLE-сессии — иначе вернёт false.
|
||||
*/
|
||||
suspend fun launchGlassesApp(packageName: String): Boolean {
|
||||
if (_state.value !is MercuryState.Connected) return false
|
||||
return mercury.appMarketLaunch(packageName)
|
||||
}
|
||||
|
||||
/** Доступ к raw event-flow библиотеки (debug/диагностика). */
|
||||
val events get() = mercury.events
|
||||
|
||||
private fun refreshPermissionState() {
|
||||
_state.value = when {
|
||||
!hasPermissions() -> MercuryState.PermissionsMissing
|
||||
!isBluetoothEnabled() -> MercuryState.BluetoothOff
|
||||
else -> if (_state.value is MercuryState.Connected) _state.value else MercuryState.Disconnected()
|
||||
}
|
||||
}
|
||||
|
||||
private fun hasPermissions(): Boolean = requiredPermissions.all {
|
||||
context.checkSelfPermission(it) == PackageManager.PERMISSION_GRANTED
|
||||
}
|
||||
|
||||
private fun isBluetoothEnabled(): Boolean {
|
||||
val adapter = (context.getSystemService(Context.BLUETOOTH_SERVICE) as? BluetoothManager)?.adapter
|
||||
return try {
|
||||
adapter?.isEnabled == true
|
||||
} catch (_: SecurityException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private fun observeEvents() {
|
||||
scope.launch {
|
||||
mercury.events.collect { event ->
|
||||
when (event) {
|
||||
is RayNeoMercury.Event.Connected -> {
|
||||
val cur = _state.value
|
||||
if (cur is MercuryState.Connecting) {
|
||||
_state.value = MercuryState.Connected(cur.device, batteryPercent = null)
|
||||
}
|
||||
}
|
||||
is RayNeoMercury.Event.Disconnected -> {
|
||||
val cur = _state.value
|
||||
if (cur is MercuryState.Connected) {
|
||||
_state.value = MercuryState.Disconnected(
|
||||
message = "Связь потеряна: ${event.cause}",
|
||||
)
|
||||
}
|
||||
}
|
||||
is RayNeoMercury.Event.GeneralStatusUpdate -> {
|
||||
val cur = _state.value
|
||||
if (cur is MercuryState.Connected) {
|
||||
_state.value = cur.copy(
|
||||
batteryPercent = event.status.battery,
|
||||
)
|
||||
}
|
||||
}
|
||||
else -> Unit
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun close() {
|
||||
scope.coroutineContext[Job]?.cancel()
|
||||
mercury.close()
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Local = staticCompositionLocalOf<MercuryController> {
|
||||
error("MercuryController not provided")
|
||||
}
|
||||
|
||||
fun fromContext(context: Context): MercuryController =
|
||||
(context.applicationContext as? MercuryControllerProvider)?.provideMercuryController()
|
||||
?: error("Application must implement MercuryControllerProvider")
|
||||
}
|
||||
}
|
||||
|
||||
interface MercuryControllerProvider {
|
||||
fun provideMercuryController(): MercuryController
|
||||
}
|
||||
|
||||
/**
|
||||
* Состояние второго канала (Mercury BLE). UI рендерит это как «Канал 2».
|
||||
*/
|
||||
sealed interface MercuryState {
|
||||
/** Нет разрешений BLUETOOTH_SCAN / BLUETOOTH_CONNECT. */
|
||||
data object PermissionsMissing : MercuryState
|
||||
|
||||
/** Разрешения есть, но Bluetooth выключен. */
|
||||
data object BluetoothOff : MercuryState
|
||||
|
||||
/** Готовы к сканированию / подключению. */
|
||||
data class Disconnected(val message: String? = null) : MercuryState
|
||||
|
||||
/** Идёт скан, [found] — то, что уже нашли. */
|
||||
data class Scanning(val found: List<DiscoveredGlasses>) : MercuryState
|
||||
|
||||
/** Устанавливаем пару + GATT. */
|
||||
data class Connecting(val device: DiscoveredGlasses) : MercuryState
|
||||
|
||||
/** GATT-сессия жива, очки отвечают. */
|
||||
data class Connected(
|
||||
val device: DiscoveredGlasses,
|
||||
val batteryPercent: Int? = null,
|
||||
) : MercuryState
|
||||
}
|
||||
@@ -0,0 +1,191 @@
|
||||
package pw.binom.rayneovm.phone.protocol
|
||||
|
||||
import android.content.Context
|
||||
import androidx.compose.runtime.staticCompositionLocalOf
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.Job
|
||||
import kotlinx.coroutines.SupervisorJob
|
||||
import kotlinx.coroutines.cancelChildren
|
||||
import kotlinx.coroutines.flow.MutableStateFlow
|
||||
import kotlinx.coroutines.flow.StateFlow
|
||||
import kotlinx.coroutines.flow.asStateFlow
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.mercury.transport.StringTransport
|
||||
import pw.binom.mercury.transport.Transports
|
||||
import pw.binom.rayneovm.phone.audio.AudioPlaybackManager
|
||||
import pw.binom.rayneovm.shared.AppCrashedEvent
|
||||
import pw.binom.rayneovm.shared.AppLaunchedAck
|
||||
import pw.binom.rayneovm.shared.GlassesStatus
|
||||
import pw.binom.rayneovm.shared.Pong
|
||||
import pw.binom.rayneovm.shared.SetAudioMode
|
||||
import pw.binom.rayneovm.shared.VmMessage
|
||||
import pw.binom.rayneovm.shared.VmProtocol
|
||||
|
||||
/**
|
||||
* Контроллер состояния и связи с очками на стороне телефона.
|
||||
*
|
||||
* Запускает фоновый цикл авто-реконнекта к очкам через mDNS + WebSocket.
|
||||
* Хранит текущее состояние связи и обрабатывает входящие/исходящие сообщения.
|
||||
*/
|
||||
class VmPhoneController(
|
||||
@Suppress("unused") private val context: Context,
|
||||
) {
|
||||
|
||||
private val scope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
|
||||
|
||||
private val _state = MutableStateFlow<ConnectionState>(ConnectionState.Searching)
|
||||
val state: StateFlow<ConnectionState> = _state.asStateFlow()
|
||||
|
||||
private val _glassesStatus = MutableStateFlow<GlassesStatusInfo?>(null)
|
||||
val glassesStatus: StateFlow<GlassesStatusInfo?> = _glassesStatus.asStateFlow()
|
||||
|
||||
private var currentTransport: StringTransport? = null
|
||||
private val audioManager by lazy { AudioPlaybackManager(context) }
|
||||
|
||||
fun start() {
|
||||
if (scope.coroutineContext[Job]?.children?.any { it.isActive } == true) return
|
||||
scope.launch { reconnectLoop() }
|
||||
}
|
||||
|
||||
fun stop() {
|
||||
currentTransport?.close()
|
||||
currentTransport = null
|
||||
audioManager.stop()
|
||||
scope.coroutineContext[Job]?.cancelChildren()
|
||||
_state.value = ConnectionState.Disconnected
|
||||
}
|
||||
|
||||
/**
|
||||
* Цикл поиска очков через mDNS + подключения.
|
||||
*
|
||||
* При разрыве — exponential backoff.
|
||||
*/
|
||||
private suspend fun reconnectLoop() {
|
||||
var attempt = 0
|
||||
val backoffMs = listOf(1_000L, 2_000L, 4_000L, 8_000L, 16_000L, 30_000L)
|
||||
while (true) {
|
||||
try {
|
||||
_state.value = ConnectionState.Searching
|
||||
val scanner = pw.binom.mercury.transport.MdnsScanner(context, "_rayneo-vm._tcp")
|
||||
// mDNS пытается 5с; если не вышло — TCP-фоллбек по подсети ещё ~5с.
|
||||
// Порт 8080 (см. docs/SPEC.md:4752 и VmService.WS_PORT).
|
||||
val service = scanner.discoverOnce(timeoutMs = 10_000, fallbackPort = 8080)
|
||||
_state.value = ConnectionState.Connecting(service.host)
|
||||
val url = "ws://${service.host}:${service.port}/vm"
|
||||
val transport = Transports.wifi(url, autoReconnect = false)
|
||||
currentTransport = transport
|
||||
_state.value = ConnectionState.Connected(service.host)
|
||||
attempt = 0
|
||||
transport.connect(
|
||||
onMessage = { json ->
|
||||
val msg = try {
|
||||
VmProtocol.decodeAny(json)
|
||||
} catch (_: Throwable) { return@connect }
|
||||
handleIncoming(msg)
|
||||
},
|
||||
onConnected = { /* уже выставлено в Connecting->Connected */ },
|
||||
onDisconnected = {
|
||||
currentTransport = null
|
||||
_state.value = ConnectionState.Disconnected
|
||||
},
|
||||
)
|
||||
} catch (t: Throwable) {
|
||||
currentTransport = null
|
||||
_state.value = ConnectionState.Disconnected
|
||||
}
|
||||
val delay = backoffMs[attempt.coerceAtMost(backoffMs.lastIndex)]
|
||||
attempt++
|
||||
kotlinx.coroutines.delay(delay)
|
||||
}
|
||||
}
|
||||
|
||||
private fun handleIncoming(msg: VmMessage) {
|
||||
when (msg) {
|
||||
is GlassesStatus -> {
|
||||
_glassesStatus.value = GlassesStatusInfo(
|
||||
batteryPercent = msg.batteryPercent,
|
||||
wifiSsid = msg.wifiSsid,
|
||||
currentApp = msg.currentApp,
|
||||
running = msg.running,
|
||||
)
|
||||
}
|
||||
is AppCrashedEvent -> {
|
||||
_appCrashed.value = msg.packageName
|
||||
}
|
||||
is AppLaunchedAck -> {
|
||||
_appLaunched.value = msg
|
||||
}
|
||||
is SetAudioMode -> {
|
||||
// Аудио режим — отдельный поток, не контролируется этим контроллером напрямую.
|
||||
// AudioCaptureService на очках стартует/останавливается.
|
||||
// На телефоне режим управляется UI (toggle на главном экране).
|
||||
}
|
||||
is Pong -> { /* keepalive */ }
|
||||
else -> { /* другие: событийные, форвардим */ }
|
||||
}
|
||||
}
|
||||
|
||||
private val _appCrashed = MutableStateFlow<String?>(null)
|
||||
val appCrashed: StateFlow<String?> = _appCrashed.asStateFlow()
|
||||
|
||||
private val _appLaunched = MutableStateFlow<AppLaunchedAck?>(null)
|
||||
val appLaunched: StateFlow<AppLaunchedAck?> = _appLaunched.asStateFlow()
|
||||
|
||||
fun clearAppCrashed() { _appCrashed.value = null }
|
||||
fun clearAppLaunched() { _appLaunched.value = null }
|
||||
|
||||
/** Отправляет сообщение очкам. */
|
||||
suspend fun send(msg: VmMessage) {
|
||||
val t = currentTransport ?: return
|
||||
try {
|
||||
t.send(VmProtocol.encode(msg))
|
||||
} catch (t: Throwable) {
|
||||
// ignore — connection state управляется через reconnect loop
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Переключает аудио режим: 0 = ON_GLASSES, 1 = ON_PHONE.
|
||||
* Шлёт SetAudioMode очкам, и на телефоне запускает/останавливает AudioPlaybackManager.
|
||||
*/
|
||||
suspend fun setAudioMode(mode: pw.binom.rayneovm.shared.AudioMode) {
|
||||
send(SetAudioMode(mode))
|
||||
when (mode) {
|
||||
pw.binom.rayneovm.shared.AudioMode.ON_GLASSES -> audioManager.stop()
|
||||
pw.binom.rayneovm.shared.AudioMode.ON_PHONE -> {
|
||||
val host = (currentTransport as? pw.binom.mercury.transport.WifiTransport)?.let { null }
|
||||
?: (_state.value as? ConnectionState.Connected)?.host
|
||||
if (host != null) audioManager.start(host)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
companion object {
|
||||
val Local = staticCompositionLocalOf<VmPhoneController> {
|
||||
error("VmPhoneController not provided")
|
||||
}
|
||||
|
||||
fun fromContext(context: Context): VmPhoneController =
|
||||
(context.applicationContext as? ControllerProvider)?.provideController()
|
||||
?: error("Application must implement ControllerProvider")
|
||||
}
|
||||
}
|
||||
|
||||
interface ControllerProvider {
|
||||
fun provideController(): VmPhoneController
|
||||
}
|
||||
|
||||
sealed interface ConnectionState {
|
||||
data object Searching : ConnectionState
|
||||
data class Connecting(val host: String) : ConnectionState
|
||||
data class Connected(val host: String) : ConnectionState
|
||||
data object Disconnected : ConnectionState
|
||||
}
|
||||
|
||||
data class GlassesStatusInfo(
|
||||
val batteryPercent: Int?,
|
||||
val wifiSsid: String?,
|
||||
val currentApp: String?,
|
||||
val running: Boolean,
|
||||
)
|
||||
@@ -0,0 +1,105 @@
|
||||
package pw.binom.rayneovm.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.clickable
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
import pw.binom.rayneovm.shared.LaunchAppCommand
|
||||
|
||||
/**
|
||||
* Список приложений, которые можно запустить на очках через VirtualDisplay.
|
||||
*
|
||||
* Сейчас — только Zona. В будущем: реестр в shared/AppProfiles.kt.
|
||||
*/
|
||||
@Composable
|
||||
fun AppsListScreen(
|
||||
controller: VmPhoneController,
|
||||
onAppClick: (String) -> Unit = {},
|
||||
) {
|
||||
val glasses by controller.glassesStatus.collectAsState()
|
||||
val isRunning = glasses?.running == true
|
||||
val coroutineScope = androidx.compose.runtime.rememberCoroutineScope()
|
||||
|
||||
val apps = listOf(
|
||||
AppEntry("free.zona", "Zona", "Кинотеатр Zona"),
|
||||
)
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(12.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Приложения",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
Text(
|
||||
text = "Тапните, чтобы запустить на очках",
|
||||
color = Color(0xFFB0B0B0),
|
||||
fontSize = 14.sp,
|
||||
)
|
||||
|
||||
apps.forEach { app ->
|
||||
Card(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.clickable {
|
||||
coroutineScope.launch {
|
||||
controller.send(LaunchAppCommand(packageName = app.packageName))
|
||||
}
|
||||
onAppClick(app.packageName)
|
||||
},
|
||||
colors = androidx.compose.material3.CardDefaults.cardColors(
|
||||
containerColor = Color(0xFF1A1A1A),
|
||||
),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp)) {
|
||||
Text(
|
||||
text = app.name,
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
Text(
|
||||
text = app.description,
|
||||
color = Color(0xFFB0B0B0),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
if (isRunning && glasses?.currentApp == app.packageName) {
|
||||
Text(
|
||||
text = "● запущено",
|
||||
color = Color(0xFF4CAF50),
|
||||
fontSize = 12.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private data class AppEntry(
|
||||
val packageName: String,
|
||||
val name: String,
|
||||
val description: String,
|
||||
)
|
||||
@@ -0,0 +1,263 @@
|
||||
package pw.binom.rayneovm.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.Spacer
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.height
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.rememberScrollState
|
||||
import androidx.compose.foundation.verticalScroll
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.ButtonDefaults
|
||||
import androidx.compose.material3.Card
|
||||
import androidx.compose.material3.CardDefaults
|
||||
import androidx.compose.material3.HorizontalDivider
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.OutlinedButton
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.font.FontWeight
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.mercury.DiscoveredGlasses
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryState
|
||||
import pw.binom.rayneovm.phone.protocol.ConnectionState
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
|
||||
/**
|
||||
* Экран «Очки»: два независимых канала связи + запуск нашего приложения.
|
||||
*
|
||||
* - **Канал 1 (WebSocket)** — наше приложение на очках подключается к нам по WiFi/mDNS.
|
||||
* Это основной control-канал (тач, клавиши, команды).
|
||||
* - **Канал 2 (Mercury BLE)** — мы прикидываемся штатным приложением RayNeo Mercury
|
||||
* и общаемся с очками по их закрытому BLE-протоколу. Это bootstrap-канал:
|
||||
* нужен, чтобы запустить наше приложение через `appMarketLaunch(pkg)`, когда
|
||||
* очки ещё не подключены к нам по WebSocket.
|
||||
*
|
||||
* Кнопка «Запустить наше приложение на очках» работает только когда жив Канал 2.
|
||||
*/
|
||||
@Composable
|
||||
fun GlassesStatusScreen(
|
||||
controller: VmPhoneController,
|
||||
mercuryController: MercuryController,
|
||||
onRequestMercuryPermissions: () -> Unit = {},
|
||||
) {
|
||||
val connection by controller.state.collectAsState()
|
||||
val glasses by controller.glassesStatus.collectAsState()
|
||||
val mercury by mercuryController.state.collectAsState()
|
||||
val scope = rememberCoroutineScope()
|
||||
|
||||
val mercuryConnected = mercury is MercuryState.Connected
|
||||
|
||||
Column(
|
||||
modifier = Modifier
|
||||
.fillMaxSize()
|
||||
.background(Color.Black)
|
||||
.verticalScroll(rememberScrollState())
|
||||
.padding(24.dp),
|
||||
verticalArrangement = Arrangement.spacedBy(16.dp),
|
||||
) {
|
||||
Text(
|
||||
text = "Очки",
|
||||
color = Color.White,
|
||||
fontSize = 24.sp,
|
||||
fontWeight = FontWeight.Bold,
|
||||
)
|
||||
|
||||
Channel1WebSocketSection(connection = connection, glasses = glasses)
|
||||
|
||||
HorizontalDivider(color = Color(0xFF2A2A2A))
|
||||
|
||||
Channel2MercurySection(
|
||||
mercury = mercury,
|
||||
onRequestPermissions = onRequestMercuryPermissions,
|
||||
onScan = { mercuryController.scan() },
|
||||
onConnect = { mercuryController.connect(it) },
|
||||
onDisconnect = { mercuryController.disconnect() },
|
||||
)
|
||||
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
|
||||
LaunchGlassesAppButton(
|
||||
enabled = mercuryConnected,
|
||||
onClick = {
|
||||
scope.launch {
|
||||
mercuryController.launchGlassesApp(GLASSES_PACKAGE)
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
private const val GLASSES_PACKAGE = "pw.binom.rayneovm.glasses"
|
||||
|
||||
@Composable
|
||||
private fun Channel1WebSocketSection(
|
||||
connection: ConnectionState,
|
||||
glasses: pw.binom.rayneovm.phone.protocol.GlassesStatusInfo?,
|
||||
) {
|
||||
SectionCard(title = "Канал 1 — наше приложение (WebSocket)") {
|
||||
StatusRow("Связь", connection.label)
|
||||
when (connection) {
|
||||
is ConnectionState.Connected -> glasses?.let { g ->
|
||||
StatusRow("Аккумулятор", g.batteryPercent?.let { "$it%" } ?: "—")
|
||||
StatusRow("WiFi", g.wifiSsid ?: "—")
|
||||
StatusRow("Запущено", g.currentApp ?: "—")
|
||||
StatusRow("Активно", if (g.running) "да" else "нет")
|
||||
} ?: run {
|
||||
Hint("Ожидаем статус от очков…")
|
||||
}
|
||||
else -> Hint(
|
||||
when (connection) {
|
||||
is ConnectionState.Searching -> "Ищем очки в сети…"
|
||||
is ConnectionState.Connecting -> "Подключаемся к ${connection.host}…"
|
||||
is ConnectionState.Disconnected ->
|
||||
"Нет связи. Проверьте, что очки и телефон в одной WiFi-сети."
|
||||
}
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Channel2MercurySection(
|
||||
mercury: MercuryState,
|
||||
onRequestPermissions: () -> Unit,
|
||||
onScan: () -> Unit,
|
||||
onConnect: (DiscoveredGlasses) -> Unit,
|
||||
onDisconnect: () -> Unit,
|
||||
) {
|
||||
SectionCard(title = "Канал 2 — очки (Mercury BLE)") {
|
||||
when (mercury) {
|
||||
is MercuryState.PermissionsMissing -> {
|
||||
Hint("Нужны разрешения Bluetooth (BLUETOOTH_SCAN, BLUETOOTH_CONNECT).")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = onRequestPermissions) { Text("Запросить разрешения") }
|
||||
}
|
||||
is MercuryState.BluetoothOff -> {
|
||||
Hint("Bluetooth выключен. Включите его в системных настройках.")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = onScan) { Text("Проверить ещё раз") }
|
||||
}
|
||||
is MercuryState.Disconnected -> {
|
||||
StatusRow("Связь", "нет связи")
|
||||
mercury.message?.let { Hint(it) }
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
Button(onClick = onScan) { Text("Сканировать") }
|
||||
}
|
||||
is MercuryState.Scanning -> {
|
||||
StatusRow("Связь", "поиск… (найдено ${mercury.found.size})")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
if (mercury.found.isNotEmpty()) {
|
||||
Text("Найденные устройства:", color = Color(0xFFB0B0B0), fontSize = 12.sp)
|
||||
mercury.found.forEach { g ->
|
||||
DeviceRow(g, onClick = { onConnect(g) })
|
||||
}
|
||||
} else {
|
||||
Hint("Идёт скан, подождите…")
|
||||
}
|
||||
}
|
||||
is MercuryState.Connecting -> {
|
||||
StatusRow("Связь", "сопрягаем ${mercury.device.name ?: mercury.device.address}…")
|
||||
Hint("Если очки запрашивают PIN — подтвердите на них.")
|
||||
}
|
||||
is MercuryState.Connected -> {
|
||||
StatusRow("Связь", "подключено (${mercury.device.name ?: mercury.device.address})")
|
||||
StatusRow("Адрес", mercury.device.address)
|
||||
StatusRow("Аккумулятор", mercury.batteryPercent?.let { "$it%" } ?: "—")
|
||||
Spacer(modifier = Modifier.height(8.dp))
|
||||
OutlinedButton(onClick = onDisconnect) { Text("Отключить") }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun LaunchGlassesAppButton(enabled: Boolean, onClick: () -> Unit) {
|
||||
Button(
|
||||
onClick = onClick,
|
||||
enabled = enabled,
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = ButtonDefaults.buttonColors(
|
||||
containerColor = Color(0xFF1565C0),
|
||||
disabledContainerColor = Color(0xFF2A2A2A),
|
||||
),
|
||||
) {
|
||||
Text(if (enabled) "Запустить наше приложение на очках" else "Запустить наше приложение на очках (нужен Канал 2)")
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun SectionCard(title: String, content: @Composable () -> Unit) {
|
||||
Card(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
colors = CardDefaults.cardColors(containerColor = Color(0xFF141414)),
|
||||
) {
|
||||
Column(modifier = Modifier.padding(16.dp), verticalArrangement = Arrangement.spacedBy(8.dp)) {
|
||||
Text(
|
||||
text = title,
|
||||
color = Color(0xFF8AB4F8),
|
||||
fontSize = 14.sp,
|
||||
fontWeight = FontWeight.SemiBold,
|
||||
)
|
||||
content()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun DeviceRow(glasses: DiscoveredGlasses, onClick: () -> Unit) {
|
||||
Row(
|
||||
modifier = Modifier
|
||||
.fillMaxWidth()
|
||||
.padding(vertical = 4.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Column {
|
||||
Text(glasses.name ?: "—", color = Color.White, fontSize = 14.sp)
|
||||
Text(glasses.address, color = Color(0xFFB0B0B0), fontSize = 11.sp)
|
||||
}
|
||||
Button(onClick = onClick) { Text("Подключить") }
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun StatusRow(label: String, value: String) {
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
horizontalArrangement = Arrangement.SpaceBetween,
|
||||
) {
|
||||
Text(text = label, color = Color(0xFFB0B0B0), fontSize = 14.sp)
|
||||
Text(text = value, color = Color.White, fontSize = 14.sp, fontWeight = FontWeight.Medium)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Hint(text: String) {
|
||||
Box(modifier = Modifier.padding(top = 4.dp)) {
|
||||
Text(text = text, color = Color(0xFFB0B0B0), fontSize = 13.sp)
|
||||
}
|
||||
}
|
||||
|
||||
private val ConnectionState.label: String
|
||||
get() = when (this) {
|
||||
is ConnectionState.Searching -> "поиск…"
|
||||
is ConnectionState.Connecting -> "подключение…"
|
||||
is ConnectionState.Connected -> "подключено ($host)"
|
||||
is ConnectionState.Disconnected -> "нет связи"
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
package pw.binom.rayneovm.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.Apps
|
||||
import androidx.compose.material.icons.filled.Settings
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.MaterialTheme
|
||||
import androidx.compose.material3.NavigationBar
|
||||
import androidx.compose.material3.NavigationBarItem
|
||||
import androidx.compose.material3.Scaffold
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.darkColorScheme
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import pw.binom.rayneovm.phone.mercury.MercuryController
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
|
||||
/**
|
||||
* Главный экран приложения: NavigationBar с двумя табами.
|
||||
*
|
||||
* - «Очки» — статус связи и состояние GlassesApp
|
||||
* - «Приложения» — список приложений для запуска
|
||||
*/
|
||||
@Composable
|
||||
fun HomeScreen(
|
||||
controller: VmPhoneController,
|
||||
mercuryController: MercuryController,
|
||||
onRequestMercuryPermissions: () -> Unit = {},
|
||||
) {
|
||||
var selectedTab by remember { mutableStateOf(Tab.GLASSES) }
|
||||
|
||||
MaterialTheme(colorScheme = darkColorScheme()) {
|
||||
Scaffold(
|
||||
modifier = Modifier.fillMaxSize().background(Color.Black),
|
||||
containerColor = Color.Black,
|
||||
bottomBar = {
|
||||
NavigationBar(containerColor = Color(0xFF101010)) {
|
||||
Tab.entries.forEach { tab ->
|
||||
NavigationBarItem(
|
||||
selected = tab == selectedTab,
|
||||
onClick = { selectedTab = tab },
|
||||
icon = { Icon(tab.icon, contentDescription = tab.label) },
|
||||
label = { Text(tab.label) },
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
) { padding ->
|
||||
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
|
||||
when (selectedTab) {
|
||||
Tab.GLASSES -> GlassesStatusScreen(
|
||||
controller = controller,
|
||||
mercuryController = mercuryController,
|
||||
onRequestMercuryPermissions = onRequestMercuryPermissions,
|
||||
)
|
||||
Tab.APPS -> AppsListScreen(controller = controller)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
private enum class Tab(val label: String, val icon: androidx.compose.ui.graphics.vector.ImageVector) {
|
||||
GLASSES("Очки", Icons.Default.Settings),
|
||||
APPS("Приложения", Icons.Default.Apps),
|
||||
}
|
||||
@@ -0,0 +1,107 @@
|
||||
package pw.binom.rayneovm.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.gestures.detectDragGestures
|
||||
import androidx.compose.foundation.gestures.detectTapGestures
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.offset
|
||||
import androidx.compose.foundation.layout.size
|
||||
import androidx.compose.foundation.shape.CircleShape
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.draw.clip
|
||||
import androidx.compose.ui.geometry.Offset
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.input.pointer.pointerInput
|
||||
import androidx.compose.ui.unit.IntOffset
|
||||
import androidx.compose.ui.unit.dp
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
import pw.binom.rayneovm.shared.TouchAction
|
||||
import pw.binom.rayneovm.shared.TouchEvent
|
||||
import pw.binom.rayneovm.shared.TouchType
|
||||
import kotlin.math.roundToInt
|
||||
|
||||
/**
|
||||
* Виртуальный тачпад: тапы/перетаскивания → [TouchEvent] на очки.
|
||||
*
|
||||
* Координаты внутри тачпада нормализуются в [0, targetWidth-1] × [0, targetHeight-1].
|
||||
* Точка касания визуализируется кружком.
|
||||
*/
|
||||
@Composable
|
||||
fun TouchpadScreen(
|
||||
controller: VmPhoneController,
|
||||
targetWidth: Int = 640,
|
||||
targetHeight: Int = 480,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
var touchPoint by remember { mutableStateOf<Offset?>(null) }
|
||||
|
||||
Box(
|
||||
modifier = modifier
|
||||
.background(Color(0xFF101010))
|
||||
.pointerInput(targetWidth, targetHeight) {
|
||||
detectTapGestures(
|
||||
onPress = { offset ->
|
||||
val x = (offset.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
|
||||
val y = (offset.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
|
||||
touchPoint = offset
|
||||
scope.launch {
|
||||
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.DOWN, x.toFloat(), y.toFloat()))))
|
||||
try { tryAwaitRelease() } finally {
|
||||
touchPoint = null
|
||||
}
|
||||
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.UP, x.toFloat(), y.toFloat()))))
|
||||
}
|
||||
},
|
||||
)
|
||||
}
|
||||
.pointerInput(targetWidth, targetHeight) {
|
||||
detectDragGestures(
|
||||
onDragStart = { offset ->
|
||||
val x = (offset.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
|
||||
val y = (offset.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
|
||||
touchPoint = offset
|
||||
scope.launch {
|
||||
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.DOWN, x.toFloat(), y.toFloat()))))
|
||||
}
|
||||
},
|
||||
onDragEnd = {
|
||||
touchPoint?.let { last ->
|
||||
val x = (last.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
|
||||
val y = (last.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
|
||||
scope.launch {
|
||||
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.UP, x.toFloat(), y.toFloat()))))
|
||||
}
|
||||
}
|
||||
touchPoint = null
|
||||
},
|
||||
onDragCancel = { touchPoint = null },
|
||||
onDrag = { change, _ ->
|
||||
val x = (change.position.x / size.width * targetWidth).roundToInt().coerceIn(0, targetWidth - 1)
|
||||
val y = (change.position.y / size.height * targetHeight).roundToInt().coerceIn(0, targetHeight - 1)
|
||||
touchPoint = change.position
|
||||
scope.launch {
|
||||
controller.send(TouchEvent(actions = listOf(TouchAction(TouchType.MOVE, x.toFloat(), y.toFloat()))))
|
||||
}
|
||||
},
|
||||
)
|
||||
},
|
||||
) {
|
||||
touchPoint?.let { pos ->
|
||||
Box(
|
||||
modifier = Modifier
|
||||
.offset { IntOffset((pos.x - 24.dp.toPx()).toInt(), (pos.y - 24.dp.toPx()).toInt()) }
|
||||
.size(48.dp)
|
||||
.clip(CircleShape)
|
||||
.background(Color(0xFF80CBC4).copy(alpha = 0.6f))
|
||||
)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,196 @@
|
||||
package pw.binom.rayneovm.phone.ui
|
||||
|
||||
import androidx.compose.foundation.background
|
||||
import androidx.compose.foundation.layout.Arrangement
|
||||
import androidx.compose.foundation.layout.Box
|
||||
import androidx.compose.foundation.layout.Column
|
||||
import androidx.compose.foundation.layout.Row
|
||||
import androidx.compose.foundation.layout.fillMaxSize
|
||||
import androidx.compose.foundation.layout.fillMaxWidth
|
||||
import androidx.compose.foundation.layout.padding
|
||||
import androidx.compose.foundation.layout.width
|
||||
import androidx.compose.material.icons.Icons
|
||||
import androidx.compose.material.icons.filled.ArrowBack
|
||||
import androidx.compose.material.icons.filled.Keyboard
|
||||
import androidx.compose.material.icons.filled.Pause
|
||||
import androidx.compose.material.icons.filled.PlayArrow
|
||||
import androidx.compose.material.icons.filled.SkipNext
|
||||
import androidx.compose.material.icons.filled.SkipPrevious
|
||||
import androidx.compose.material3.AlertDialog
|
||||
import androidx.compose.material3.Button
|
||||
import androidx.compose.material3.Icon
|
||||
import androidx.compose.material3.IconButton
|
||||
import androidx.compose.material3.OutlinedTextField
|
||||
import androidx.compose.material3.Text
|
||||
import androidx.compose.material3.TextButton
|
||||
import androidx.compose.runtime.Composable
|
||||
import androidx.compose.runtime.collectAsState
|
||||
import androidx.compose.runtime.getValue
|
||||
import androidx.compose.runtime.mutableStateOf
|
||||
import androidx.compose.runtime.remember
|
||||
import androidx.compose.runtime.rememberCoroutineScope
|
||||
import androidx.compose.runtime.setValue
|
||||
import androidx.compose.ui.Alignment
|
||||
import androidx.compose.ui.Modifier
|
||||
import androidx.compose.ui.graphics.Color
|
||||
import androidx.compose.ui.text.input.ImeAction
|
||||
import androidx.compose.ui.text.input.KeyboardType
|
||||
import androidx.compose.ui.unit.dp
|
||||
import androidx.compose.ui.unit.sp
|
||||
import androidx.compose.foundation.text.KeyboardOptions
|
||||
import kotlinx.coroutines.launch
|
||||
import pw.binom.rayneovm.phone.protocol.VmPhoneController
|
||||
import pw.binom.rayneovm.shared.KeyEvent as VmKeyEvent
|
||||
import pw.binom.rayneovm.shared.LaunchAppCommand
|
||||
|
||||
/**
|
||||
* Профиль Zona: тачпад + медиа-кнопки + клавиатура + назад.
|
||||
*/
|
||||
@Composable
|
||||
fun ZonaProfile(
|
||||
controller: VmPhoneController,
|
||||
modifier: Modifier = Modifier,
|
||||
) {
|
||||
val scope = rememberCoroutineScope()
|
||||
val glasses by controller.glassesStatus.collectAsState()
|
||||
val appCrashed by controller.appCrashed.collectAsState()
|
||||
var showKeyboard by remember { mutableStateOf(false) }
|
||||
|
||||
// Если приложение упало — сразу назад (если хочется — можно показать Snackbar)
|
||||
androidx.compose.runtime.LaunchedEffect(appCrashed) {
|
||||
if (appCrashed != null) {
|
||||
// TODO: показать Snackbar и попап обратно в список
|
||||
controller.clearAppCrashed()
|
||||
}
|
||||
}
|
||||
|
||||
Column(modifier = modifier.fillMaxSize().background(Color.Black)) {
|
||||
// Верхняя панель: назад + название + статус
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(8.dp),
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = { /* TODO: navigateBack */ }) {
|
||||
Icon(Icons.Default.ArrowBack, contentDescription = "Назад", tint = Color.White)
|
||||
}
|
||||
Spacer(modifier = Modifier.width(8.dp))
|
||||
Text(
|
||||
text = "Zona",
|
||||
color = Color.White,
|
||||
fontSize = 18.sp,
|
||||
)
|
||||
Spacer(modifier = Modifier.weight(1f))
|
||||
if (glasses?.currentApp == "free.zona") {
|
||||
Text(
|
||||
text = if (glasses?.running == true) "●" else "○",
|
||||
color = if (glasses?.running == true) Color(0xFF4CAF50) else Color(0xFFB0B0B0),
|
||||
fontSize = 20.sp,
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
// Тачпад (занимает большую часть экрана)
|
||||
TouchpadScreen(
|
||||
controller = controller,
|
||||
modifier = Modifier.weight(1f).fillMaxWidth().padding(8.dp),
|
||||
)
|
||||
|
||||
// Нижняя панель: медиа-кнопки + клавиатура
|
||||
Row(
|
||||
modifier = Modifier.fillMaxWidth().padding(horizontal = 8.dp, vertical = 4.dp),
|
||||
horizontalArrangement = Arrangement.SpaceEvenly,
|
||||
verticalAlignment = Alignment.CenterVertically,
|
||||
) {
|
||||
IconButton(onClick = {
|
||||
scope.launch { controller.send(VmKeyEvent(keyCode = 89, action = 1)) } // KEYCODE_MEDIA_REWIND
|
||||
}) { Icon(Icons.Default.SkipPrevious, contentDescription = "Назад", tint = Color.White) }
|
||||
|
||||
IconButton(onClick = {
|
||||
scope.launch { controller.send(VmKeyEvent(keyCode = 85, action = 1)) } // KEYCODE_MEDIA_PLAY_PAUSE
|
||||
}) { Icon(Icons.Default.PlayArrow, contentDescription = "Play/Pause", tint = Color.White) }
|
||||
|
||||
IconButton(onClick = {
|
||||
scope.launch { controller.send(VmKeyEvent(keyCode = 90, action = 1)) } // KEYCODE_MEDIA_FAST_FORWARD
|
||||
}) { Icon(Icons.Default.SkipNext, contentDescription = "Вперёд", tint = Color.White) }
|
||||
|
||||
IconButton(onClick = {
|
||||
scope.launch { controller.send(VmKeyEvent(keyCode = 4, action = 1)) } // KEYCODE_BACK
|
||||
}) { Icon(Icons.Default.ArrowBack, contentDescription = "Назад (в приложении)", tint = Color.White) }
|
||||
|
||||
IconButton(onClick = { showKeyboard = true }) {
|
||||
Icon(Icons.Default.Keyboard, contentDescription = "Клавиатура", tint = Color.White)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
if (showKeyboard) {
|
||||
KeyboardDialog(
|
||||
onDismiss = { showKeyboard = false },
|
||||
onSend = { text ->
|
||||
scope.launch {
|
||||
text.forEach { ch ->
|
||||
val keyCode = keyCodeForChar(ch)
|
||||
if (keyCode != -1) {
|
||||
controller.send(VmKeyEvent(keyCode = keyCode, action = 1))
|
||||
}
|
||||
}
|
||||
}
|
||||
showKeyboard = false
|
||||
},
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun KeyboardDialog(
|
||||
onDismiss: () -> Unit,
|
||||
onSend: (String) -> Unit,
|
||||
) {
|
||||
var input by remember { mutableStateOf("") }
|
||||
AlertDialog(
|
||||
onDismissRequest = onDismiss,
|
||||
title = { Text("Ввод текста") },
|
||||
text = {
|
||||
OutlinedTextField(
|
||||
value = input,
|
||||
onValueChange = { input = it },
|
||||
modifier = Modifier.fillMaxWidth(),
|
||||
singleLine = true,
|
||||
keyboardOptions = KeyboardOptions(
|
||||
keyboardType = KeyboardType.Text,
|
||||
imeAction = ImeAction.Done,
|
||||
),
|
||||
)
|
||||
},
|
||||
confirmButton = {
|
||||
TextButton(onClick = { onSend(input) }) { Text("Отправить") }
|
||||
},
|
||||
dismissButton = {
|
||||
TextButton(onClick = onDismiss) { Text("Отмена") }
|
||||
},
|
||||
)
|
||||
}
|
||||
|
||||
/**
|
||||
* ASCII char → Android keyCode. Простая таблица, без юникода и без спец.клавиш.
|
||||
* Возвращает -1 для символов, которые нельзя передать через KEYCODE.
|
||||
*/
|
||||
private fun keyCodeForChar(ch: Char): Int {
|
||||
return when (ch) {
|
||||
in 'a'..'z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'a')
|
||||
in 'A'..'Z' -> android.view.KeyEvent.KEYCODE_A + (ch - 'A')
|
||||
in '0'..'9' -> android.view.KeyEvent.KEYCODE_0 + (ch - '0')
|
||||
' ' -> android.view.KeyEvent.KEYCODE_SPACE
|
||||
'\n' -> android.view.KeyEvent.KEYCODE_ENTER
|
||||
'.' -> android.view.KeyEvent.KEYCODE_PERIOD
|
||||
',' -> android.view.KeyEvent.KEYCODE_COMMA
|
||||
'!' -> android.view.KeyEvent.KEYCODE_1 // shift+1 — отдаём как KEYCODE_1 + shift, но тут упрощённо
|
||||
'?' -> android.view.KeyEvent.KEYCODE_SLASH
|
||||
else -> -1
|
||||
}
|
||||
}
|
||||
|
||||
@Composable
|
||||
private fun Spacer(modifier: Modifier) {
|
||||
androidx.compose.foundation.layout.Spacer(modifier = modifier)
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
<resources>
|
||||
<string name="app_name">RayNeo VM</string>
|
||||
</resources>
|
||||
@@ -0,0 +1,7 @@
|
||||
<resources>
|
||||
<style name="Theme.RayNeoVm" parent="android:Theme.Material.NoActionBar">
|
||||
<item name="android:statusBarColor">@android:color/black</item>
|
||||
<item name="android:navigationBarColor">@android:color/black</item>
|
||||
<item name="android:windowBackground">@android:color/black</item>
|
||||
</style>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user