diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt
index c8af0c7..3ce6558 100644
--- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesApp.kt
@@ -4,7 +4,13 @@ import android.app.Application
import android.content.Context
import com.rayneo.arsdk.android.MercurySDK
import java.io.File
+import kotlinx.coroutines.Dispatchers
+import kotlinx.coroutines.SupervisorJob
import kotlinx.coroutines.flow.MutableStateFlow
+import kotlinx.coroutines.CoroutineScope
+import pw.binom.viewmate.core.log.BatchingLogCollector
+import pw.binom.viewmate.core.log.FileLogSpool
+import pw.binom.viewmate.core.log.SystemClock
/** Один элемент диалога в оверлее очков (роль wire: "user"/"assistant"). */
data class ChatEntry(val role: String, val text: String)
@@ -45,6 +51,13 @@ class GlassesApp : Application() {
lateinit var downloadManager: GlassesDownloadManager
private set
+ /** Лог-коллектор очков: пачки → телефон, при обрыве связи — спул в filesDir. */
+ lateinit var logCollector: BatchingLogCollector
+ private set
+
+ /** Скоуп фоновых корутин (таймер лог-коллектора). */
+ private val bgScope = CoroutineScope(SupervisorJob() + Dispatchers.IO)
+
private val prefs by lazy {
getSharedPreferences("viewmate_prefs", Context.MODE_PRIVATE)
}
@@ -123,6 +136,20 @@ class GlassesApp : Application() {
)
hostConnection.start()
log("app", "HostConnection стартует: mDNS-поиск, fallback: ${GlassesConfig.HOST_URL}")
+
+ // Лог-коллектор: пачки → телефон; обрыв связи — спул в filesDir, ретраи таймером.
+ logCollector = BatchingLogCollector(
+ device = "glasses",
+ sink = GlassesLinkLogSink(hostConnection),
+ spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 32),
+ clock = SystemClock(),
+ flushIntervalMs = 60_000,
+ maxBatchEntries = 500,
+ )
+ logCollector.start(bgScope)
+ attachLogCollector(logCollector)
+ pw.binom.viewmate.core.logCollector = logCollector
+ log("app", "лог-коллектор запущен (пачки → телефон, спул: filesDir/logspool)")
}
companion object {
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt
index 2452ac6..bceb1fa 100644
--- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesDownloadManager.kt
@@ -99,7 +99,8 @@ internal class GlassesStateStore(private val file: File) {
/**
* Менеджер скачивания контента в память очков (аналог телефонного DownloadManager).
- * Каталог: filesDir/viewmate/{itemId}/video.mkv + audio-N.ogg. Скачивание пишется в .part
+ * Каталог: filesDir/viewmate/{itemId}/video.mkv — на очки уходит только видео,
+ * звук (audio-N.ogg) не скачивается, он лежит на телефоне. Скачивание пишется в .part
* и докачивается с места обрыва (Range). Прогресс шлём на телефон через [send].
*/
class GlassesDownloadManager(
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt
new file mode 100644
index 0000000..d878377
--- /dev/null
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/GlassesLinkLogSink.kt
@@ -0,0 +1,20 @@
+package pw.binom.viewmate.glasses
+
+import pw.binom.viewmate.core.log.LogBatch
+import pw.binom.viewmate.core.log.LogSink
+import pw.binom.viewmate.core.protocol.LogBatchMsg
+
+/**
+ * LogSink коллектора очков: пачка уходит на телефон по связи очков↔телефон
+ * ([LogBatchMsg]). Связи нет → false: пачка прихраняется в споуле очков
+ * (filesDir) и ретраится после восстановления связи.
+ */
+class GlassesLinkLogSink(
+ private val connection: HostConnection,
+) : LogSink {
+ override suspend fun send(batch: LogBatch): Boolean {
+ if (!connection.connected.value) return false
+ connection.sendToHost(LogBatchMsg(entries = batch.entries))
+ return true
+ }
+}
diff --git a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt
index a5fe211..94e8a8e 100644
--- a/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt
+++ b/app-glasses/src/main/kotlin/pw/binom/viewmate/glasses/Log.kt
@@ -1,6 +1,21 @@
package pw.binom.viewmate.glasses
-/** Единый лог очков: простой println (работает и в unit-тестах). */
+import pw.binom.viewmate.core.log.BatchingLogCollector
+
+/**
+ * Единый лог очков: println (видно в юнит-тестах) + лог-коллектор — пачки
+ * уходят на телефон ([GlassesLinkLogSink]); при обрыве связи пачки
+ * прихраняются в споуле очков (filesDir) и ретраятся позже.
+ */
+@Volatile
+private var logCollector: BatchingLogCollector? = null
+
+/** Подключить коллектор (GlassesApp.onCreate, после HostConnection). */
+fun attachLogCollector(collector: BatchingLogCollector) {
+ logCollector = collector
+}
+
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
+ logCollector?.record(tag, message)
}
diff --git a/app-phone/build.gradle.kts b/app-phone/build.gradle.kts
index 019f665..de62d16 100644
--- a/app-phone/build.gradle.kts
+++ b/app-phone/build.gradle.kts
@@ -104,9 +104,12 @@ dependencies {
// LiteRT-LM (Google) — локальный LLM (Gemma 4 E2B, .litertlm, GPU/CPU)
implementation(libs.litertlm.android)
- // Офлайн-распознавание речи (sherpa-onnx + Whisper), JitPack
+ // Silero-VAD на sherpa-onnx (SherpaStreamingVad / VadSegmenter), JitPack
implementation("com.github.k2-fsa.sherpa-onnx:sherpa-onnx:v1.13.6")
+ // Офлайн-распознавание Qwen3-ASR (asr-kmp, caffeine nexus) — Qwen3AsrStt
+ implementation("pw.binom.asr:asr-qwen3-android:4")
+
// WS-сервер телефона (CIO — работает на Android)
implementation("io.ktor:ktor-server-core:3.3.0")
implementation("io.ktor:ktor-server-cio:3.3.0")
diff --git a/app-phone/src/main/AndroidManifest.xml b/app-phone/src/main/AndroidManifest.xml
index e06869a..fc99a34 100644
--- a/app-phone/src/main/AndroidManifest.xml
+++ b/app-phone/src/main/AndroidManifest.xml
@@ -14,6 +14,7 @@
+
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt
index ae45e77..946fe1c 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/AudioSyncPlayer.kt
@@ -31,8 +31,14 @@ internal data class SyncDecision(
/** Гистерезис: внутри зоны [-TRIGGER_BAND_MS; +TRIGGER_BAND_MS] скорость 1f — без дребезга. */
internal const val TRIGGER_BAND_MS = 150L
-/** Темп догонки/отстаивания за пределами гистерезиса: 1.02 / 0.98 на каждый цикл. */
-internal const val DRIFT_RATE = 0.02f
+/**
+ * Коэффициент пропорциональной коррекции: скорость = 1 ± [DRIFT_GAIN]·|diff| (пока diff < гистерезиса — 1f).
+ * 0.0002f/мс → 300мс рассинхрона ≈ 1.06/0.94, 500мс и дальше упирается в [MAX_DRIFT_SPEED].
+ */
+internal const val DRIFT_GAIN = 0.0002f
+
+/** Потолок изменения темпа (±10%): дальше слышно, что «не в свой темп». */
+internal const val MAX_DRIFT_SPEED = 0.10f
/** Экстремальный рассинхрон: |diff| > HARD_SEEK_THRESHOLD_MS — скорость не нагонит, жёсткий seekTo. */
internal const val HARD_SEEK_THRESHOLD_MS = 2000L
@@ -40,8 +46,9 @@ internal const val HARD_SEEK_THRESHOLD_MS = 2000L
/**
* Правило коррекции аудио по мастер-таймкоду очков (чистая функция, тестируемая без Android):
* - |diff| <= [TRIGGER_BAND_MS] → speed = 1f (связь «в норме», не дёргаем темп);
- * - audio отстаёт от video (diff < -band) → speed = 1 + [DRIFT_RATE] (нагоняем);
- * - audio впереди video (diff > +band) → speed = 1 - [DRIFT_RATE] (тормозим);
+ * - иначе — пропорциональная коррекция: звук отстаёт (diff < 0) → 1 + [DRIFT_GAIN]·|diff| (нагоняем),
+ * звук впереди (diff > 0) → 1 − [DRIFT_GAIN]·|diff| (тормозим); результат ограничивается
+ * [MAX_DRIFT_SPEED] (±10%) — дальше темп не растим, чтобы не звучать чужеродно;
* - |diff| > [HARD_SEEK_THRESHOLD_MS] → seekToMs = позиция очков; speed = 1f (жёстко, далее мягко);
* - isPlaying очков отличается от состояния аудио → play/pause (как раньше, на speed не влияет).
*/
@@ -55,9 +62,8 @@ internal fun decideSync(
val seekToMs = if (abs(diff) > HARD_SEEK_THRESHOLD_MS) videoPosition else null
val speed = when {
seekToMs != null -> 1f
- diff < -TRIGGER_BAND_MS -> 1f + DRIFT_RATE
- diff > TRIGGER_BAND_MS -> 1f - DRIFT_RATE
- else -> 1f
+ abs(diff) <= TRIGGER_BAND_MS -> 1f
+ else -> (1f - DRIFT_GAIN * diff.toFloat()).coerceIn(1f - MAX_DRIFT_SPEED, 1f + MAX_DRIFT_SPEED)
}
val playing = when {
videoPlaying == audioPlaying -> null
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt
index e06c857..cab5671 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/BtServerTransport.kt
@@ -44,12 +44,17 @@ class BtServerTransport(
/**
* Runtime-пермишены Bluetooth, зависящие от версии платформы
* (TASK-transport.md п.3, п.5: Android 12+ / до 12):
- * 31+ — BLUETOOTH_CONNECT (SCAN — для future startDiscovery),
+ * 31+ — BLUETOOTH_CONNECT (SCAN — для сканирования; ADVERTISE — для GATT-сервера
+ * очков, Mercury-управление),
* ≤30 — BLUETOOTH + BLUETOOTH_ADMIN (опасные, запрашиваются в рантайме).
*/
fun runtimePermissions(): Array =
if (Build.VERSION.SDK_INT >= 31) {
- arrayOf(Manifest.permission.BLUETOOTH_CONNECT, Manifest.permission.BLUETOOTH_SCAN)
+ arrayOf(
+ Manifest.permission.BLUETOOTH_CONNECT,
+ Manifest.permission.BLUETOOTH_SCAN,
+ Manifest.permission.BLUETOOTH_ADVERTISE,
+ )
} else {
arrayOf(Manifest.permission.BLUETOOTH, Manifest.permission.BLUETOOTH_ADMIN)
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt
index c55e0ee..c4363ad 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/GlassesServer.kt
@@ -12,6 +12,7 @@ import kotlinx.coroutines.flow.asStateFlow
import kotlinx.coroutines.launch
import kotlinx.serialization.decodeFromString
import kotlinx.serialization.encodeToString
+import pw.binom.viewmate.core.log.LogEntry
import pw.binom.viewmate.core.net.GlassesServerTransport
import pw.binom.viewmate.core.phone.GlassesSender
import pw.binom.viewmate.core.phone.PhoneState
@@ -22,6 +23,7 @@ import pw.binom.viewmate.core.protocol.GlassesToHost
import pw.binom.viewmate.core.protocol.Gesture
import pw.binom.viewmate.core.protocol.Hello
import pw.binom.viewmate.core.protocol.HostToGlasses
+import pw.binom.viewmate.core.protocol.LogBatchMsg
import pw.binom.viewmate.core.protocol.PlaybackPosition
import pw.binom.viewmate.core.protocol.SttAudio
import pw.binom.viewmate.core.protocol.SttCancel
@@ -97,6 +99,13 @@ class GlassesHub(
*/
var onPlaybackPosition: ((positionMs: Long, playing: Boolean, itemId: String?, audioIndex: Int) -> Unit)? = null
+ /**
+ * Хук на лог-пачки очков: телефон прибавляет записи в свой лог-коллектор
+ * (BatchingLogCollector.recordBatch) — они уйдут на сервер вместе со своими.
+ */
+ @Volatile
+ var onLogBatch: ((List) -> Unit)? = null
+
/**
* Стриминговое распознавание речи с очков. Лениво создаётся через
* [sttFactory] (модели во filesDir/models); onSilence30s/onAutoFinished
@@ -195,6 +204,11 @@ class GlassesHub(
onPlaybackPosition?.invoke(_positionMs.value ?: 0L, false, null, 0)
}
+ is LogBatchMsg -> {
+ log("glasses", "лог-пачка с очков: ${msg.entries.size} записей")
+ onLogBatch?.invoke(msg.entries)
+ }
+
is SttAudio -> {
val s = stt ?: sttFactory?.invoke()?.also { stt = it }
s?.accept(msg.data)
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt
index d88f6cd..2bddaf0 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/Log.kt
@@ -1,13 +1,25 @@
package pw.binom.viewmate.phone
import android.util.Log
+import pw.binom.viewmate.core.log.BatchingLogCollector
/**
* Единый лог телефона.
* На устройстве — android.util.Log (видно в logcat); в JVM-юнит-тестах
- * (android.util.Log недоступен — метод без @JvmStatic фолбэкнет в println),
+ * (android.util.Log недоступен — метод без @JvmStatic фолбэктнет в println),
* чтобы не падать.
+ *
+ * Плюс лог-коллектор: пачки (свои + пришедшие от очков) уходят на сервер
+ * (HttpLogSink); при сбое — прихраняются в споуле и ретраятся позже.
*/
+@Volatile
+private var logCollector: BatchingLogCollector? = null
+
+/** Подключить коллектор (PhoneApp.onCreate). */
+fun attachLogCollector(collector: BatchingLogCollector) {
+ logCollector = collector
+}
+
fun log(tag: String, message: String) {
val full = "[view-mate] $tag $message"
runCatching {
@@ -15,4 +27,5 @@ fun log(tag: String, message: String) {
}.onFailure {
println(full)
}
+ logCollector?.record(tag, message)
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt
index 5768555..dcddd1c 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/MercuryBridge.kt
@@ -16,6 +16,8 @@ import kotlinx.coroutines.launch
import kotlinx.serialization.json.jsonObject
import pw.binom.mercury.DiscoveredGlasses
import pw.binom.mercury.RayNeoMercury
+import pw.binom.mercury.model.MyWifiInfo
+import pw.binom.mercury.pair.PairManager
/**
* Мост к либе rayneo-mercury (протокол RayNeo Mercury по BLE).
@@ -38,6 +40,11 @@ object MercuryBridge {
val glassesName: String? = null,
val statusText: String = "не подключено",
val lastError: String? = null,
+ val brightness: Int = 0,
+ val automaticBrightness: Boolean = false,
+ val wifiEnabled: Boolean = false,
+ val wifiSsid: String? = null,
+ val wifiNetworks: List = emptyList(),
)
private val _state = MutableStateFlow(UiState())
@@ -55,6 +62,9 @@ object MercuryBridge {
is RayNeoMercury.Event.Connected -> {
_state.value = _state.value.copy(connected = true)
setStatus("подключено (BLE)")
+ // Запрашиваем актуальный статус (яркость/WiFi/батарея), чтобы
+ // ползунок яркости и переключатель WiFi показывали реальные значения.
+ scope.launch { mercury?.pullGeneralStatus() }
// Включаем heartbeat очков (каждые 3с они пингуют нас) — иначе
// при гашении экрана очки рвут BLE-связь через ~3 секунды.
// Повторяем, пока очки не будут готовы (они переподключаются
@@ -94,8 +104,20 @@ object MercuryBridge {
is RayNeoMercury.Event.AppInstallProgress -> log("app", "${event.pkg}: ${event.progress}%")
is RayNeoMercury.Event.WirelessAdbAddress -> setStatus("ADB: ${event.address}")
is RayNeoMercury.Event.LogcatLine -> log("logcat", event.line)
- is RayNeoMercury.Event.GeneralStatusUpdate -> log("status", "brightness=${event.status.brightness} deepSuspend=${event.status.deepSuspend}")
- is RayNeoMercury.Event.WifiScanResult -> log("wifi", "сетей: ${event.networks.size}")
+ is RayNeoMercury.Event.GeneralStatusUpdate -> {
+ val s = event.status
+ _state.value = _state.value.copy(
+ brightness = s.brightness,
+ automaticBrightness = s.automaticBrightness,
+ wifiEnabled = s.wifiEnabled,
+ wifiSsid = s.wifiInfo?.ssid,
+ )
+ log("status", "brightness=${s.brightness} autoBrightness=${s.automaticBrightness} deepSuspend=${s.deepSuspend} wifi=${s.wifiEnabled} ssid=${s.wifiInfo?.ssid}")
+ }
+ is RayNeoMercury.Event.WifiScanResult -> {
+ _state.value = _state.value.copy(wifiNetworks = event.networks)
+ log("wifi", "сетей: ${event.networks.size}")
+ }
}
}
}
@@ -109,7 +131,8 @@ object MercuryBridge {
val m = mercury ?: return false
return m.hasPermissions() &&
ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_SCAN) == PackageManager.PERMISSION_GRANTED &&
- ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_CONNECT) == PackageManager.PERMISSION_GRANTED &&
+ ContextCompat.checkSelfPermission(context, Manifest.permission.BLUETOOTH_ADVERTISE) == PackageManager.PERMISSION_GRANTED
}
/**
@@ -141,6 +164,20 @@ object MercuryBridge {
setStatus("подключено (клиент)")
return true
}
+ // Без bond очки не отдают GATT-сервис — спариваем и пробуем ещё раз.
+ setStatus("connect не удался, спариваю…")
+ when (val res = m.pair(glasses)) {
+ is PairManager.PairingResult.Success -> {
+ if (m.connect(glasses, 8000)) {
+ _state.value = _state.value.copy(connected = true, glassesName = glasses.name)
+ setStatus("подключено (клиент, после pair)")
+ return true
+ }
+ }
+ is PairManager.PairingResult.Failed -> {
+ setStatus("спаривание не удалось: ${res.reason}")
+ }
+ }
}
}
setError("очки не подключились за ${timeoutMillis / 1000} с")
@@ -174,13 +211,68 @@ object MercuryBridge {
return ok
}
+ /** Яркость экрана очков (0..100). Авто-яркость принудительно выключается — иначе очки игнорируют ручное значение. */
+ suspend fun setBrightness(value: Int): Boolean {
+ val v = value.coerceIn(0, 100)
+ // При automaticBrightness=true очки отбрасывают brightness, поэтому выключаем авто заранее.
+ mercury?.setAutoBrightness(false)
+ val ok = mercury?.setBrightness(v) ?: false
+ if (ok) _state.value = _state.value.copy(brightness = v, automaticBrightness = false)
+ setStatus(if (ok) "яркость → $v%" else "ошибка отправки (нет связи?)")
+ return ok
+ }
+
+ /** Авто-яркость очков. */
+ suspend fun setAutoBrightness(enable: Boolean): Boolean {
+ val ok = mercury?.setAutoBrightness(enable) ?: false
+ if (ok) _state.value = _state.value.copy(automaticBrightness = enable)
+ setStatus(if (ok) "авто-яркость → $enable" else "ошибка отправки (нет связи?)")
+ return ok
+ }
+
+ /** Вкл/выкл WiFi-радио очков. */
+ suspend fun wifiToggle(enable: Boolean): Boolean {
+ val ok = mercury?.wifiToggle(enable) ?: false
+ if (ok) _state.value = _state.value.copy(wifiEnabled = enable)
+ setStatus(if (ok) "WiFi → ${if (enable) "вкл" else "выкл"}" else "ошибка отправки (нет связи?)")
+ return ok
+ }
+
+ /** Подключить очки к WiFi-сети (пароль null, если открытая). */
+ suspend fun wifiConnect(ssid: String, password: String?): Boolean {
+ val ok = mercury?.wifiConnect(ssid, password) ?: false
+ setStatus(if (ok) "WiFi: подключение к «$ssid»…" else "ошибка отправки (нет связи?)")
+ return ok
+ }
+
+ /** Забыть сохранённую WiFi-сеть на очках. */
+ suspend fun wifiRemove(ssid: String): Boolean {
+ val ok = mercury?.wifiRemove(ssid) ?: false
+ setStatus(if (ok) "WiFi: забыть «$ssid»" else "ошибка отправки (нет связи?)")
+ return ok
+ }
+
+ /** Сканировать доступные WiFi-сети (результат → state.wifiNetworks). */
+ suspend fun wifiScan(): Boolean {
+ val ok = mercury?.wifiScan() ?: false
+ setStatus(if (ok) "WiFi: сканирование…" else "ошибка отправки (нет связи?)")
+ return ok
+ }
+
+ /** Запросить актуальный статус очков (яркость/WiFi/батарея). */
+ suspend fun pullStatus(): Boolean {
+ val ok = mercury?.pullGeneralStatus() ?: false
+ if (!ok) setStatus("ошибка запроса статуса (нет связи?)")
+ return ok
+ }
+
private fun setStatus(text: String) {
log("status", text)
_state.value = _state.value.copy(statusText = text, lastError = null)
}
private fun setError(text: String) {
- Log.e(TAG, text)
+ log("mercury", text)
_state.value = _state.value.copy(statusText = text, lastError = text)
}
@@ -193,6 +285,6 @@ object MercuryBridge {
}
private fun log(tag: String, msg: String) {
- Log.d(TAG, "[$tag] $msg")
+ pw.binom.viewmate.phone.log(tag, msg)
}
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt
index 0793e86..d7c23c7 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/PhoneApp.kt
@@ -17,6 +17,11 @@ import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
import pw.binom.viewmate.core.GlassesMode
+import pw.binom.viewmate.core.log.BatchingLogCollector
+import pw.binom.viewmate.core.log.FileLogSpool
+import pw.binom.viewmate.core.log.LokiConfig
+import pw.binom.viewmate.core.log.SystemClock
+import pw.binom.viewmate.core.log.lokiLogSink
import pw.binom.viewmate.core.media.JellyfinClient
import pw.binom.viewmate.core.media.MirrorClient
import pw.binom.viewmate.core.phone.PhoneActions
@@ -49,7 +54,6 @@ import pw.binom.viewmate.phone.agent.ToolsetRegistry
import pw.binom.viewmate.phone.stt.PhraseRecognizer
import pw.binom.viewmate.phone.stt.Qwen3AsrStt
import pw.binom.viewmate.phone.stt.SttStreamer
-import pw.binom.viewmate.phone.stt.WhisperStt
/**
* Application телефона: создаёт клиентов (Jellyfin/mirror), PhoneState,
@@ -84,6 +88,10 @@ class PhoneApp : Application() {
/** Синхронизированный аудио-плеер (звук на телефоне при просмотре на очках). */
val audioSync: AudioSyncPlayer by lazy { AudioSyncPlayer(this) }
+ /** Лог-коллектор: свои записи + пришедшие от очков → сервер (спул — filesDir/logspool). */
+ lateinit var logCollector: BatchingLogCollector
+ private set
+
/** История общения с ассистентом (SQLite, "assistant.db"). */
val chatDao: ChatDao by lazy { ChatDao(ChatDb(this)) }
@@ -345,9 +353,9 @@ class PhoneApp : Application() {
}
/**
- * Лениво создаёт стриминговое распознавание (модели во filesDir/models —
+ * Лениво создаёт стриминговое распознавание (модели Qwen3-ASR во filesDir/models —
* кладутся через adb push + run-as cp, как в этапе 1). Вызывается из хаба
- * на первый SttAudio; тяжёлое создание Whisper — один раз на процесс.
+ * на первый SttAudio; тяжёлое создание Qwen3-ASR — один раз на процесс.
*/
private fun ensureStt(): SttStreamer? {
synchronized(sttLock) {
@@ -357,39 +365,24 @@ class PhoneApp : Application() {
val qwenEnc = File(modelsDir, "encoder.int8.onnx")
val qwenDec = File(modelsDir, "decoder.int8.onnx")
val qwenTok = File(modelsDir, "tokenizer")
- var stt: PhraseRecognizer
- if (STT_BACKEND == "qwen3_asr" &&
- qwenConv.isFile && qwenEnc.isFile && qwenDec.isFile && qwenTok.isDirectory
- ) {
- stt = Qwen3AsrStt(
- convFrontendPath = qwenConv.absolutePath,
- encoderPath = qwenEnc.absolutePath,
- decoderPath = qwenDec.absolutePath,
- tokenizerDir = qwenTok.absolutePath,
- numThreads = 4,
- hotwords = STT_HOTWORDS,
- )
- } else {
- val encoder = File(modelsDir, "small-encoder.int8.onnx")
- val decoder = File(modelsDir, "small-decoder.int8.onnx")
- val tokens = File(modelsDir, "small-tokens.txt")
- if (!encoder.isFile || !decoder.isFile || !tokens.isFile) {
- log("stt", "модели не найдены в ${modelsDir.absolutePath} — STT недоступен")
- return null
- }
- stt = WhisperStt(
- encoderPath = encoder.absolutePath,
- decoderPath = decoder.absolutePath,
- tokensPath = tokens.absolutePath,
- numThreads = 4,
- )
+ if (!qwenConv.isFile || !qwenEnc.isFile || !qwenDec.isFile || !qwenTok.isDirectory) {
+ log("stt", "модели Qwen3-ASR не найдены в ${modelsDir.absolutePath} — STT недоступен")
+ return null
}
+ val stt: PhraseRecognizer = Qwen3AsrStt(
+ convFrontendPath = qwenConv.absolutePath,
+ encoderPath = qwenEnc.absolutePath,
+ decoderPath = qwenDec.absolutePath,
+ tokenizerDir = qwenTok.absolutePath,
+ numThreads = 4,
+ hotwords = STT_HOTWORDS,
+ )
val dumpPcm = if (File(filesDir, "stt_dump.marker").exists()) {
File(filesDir, "stt_dump_phone.raw")
} else {
null
}
- log("stt", "бэкенд: $STT_BACKEND (hotwords: $STT_HOTWORDS)")
+ log("stt", "бэкенд: qwen3_asr (hotwords: $STT_HOTWORDS)")
val s = SttStreamer(
stt = stt,
onSilence30s = {
@@ -406,7 +399,7 @@ class PhoneApp : Application() {
)
sttStreamer = s
log("stt", "PCM-дампа (телефон): ${dumpPcm?.absolutePath ?: "off"}")
- log("stt", "SttStreamer готов ($STT_BACKEND)")
+ log("stt", "SttStreamer готов (qwen3_asr)")
return s
}
}
@@ -452,6 +445,27 @@ class PhoneApp : Application() {
nsd.publish()
server.hub.sttFactory = { ensureStt() }
server.hub.onStopFullText = { phrase -> scope.launch { assistantChannel.send(phrase) } }
+
+ // Лог-коллектор: свои записи + пачки от очков → Loki. instance в Loki
+ // выставляется по source записи ("phone"/"glasses"), спул — filesDir/logspool.
+ logCollector = BatchingLogCollector(
+ device = "phone",
+ sink = lokiLogSink(),
+ spool = FileLogSpool(File(filesDir, "logspool").absolutePath, 64),
+ clock = SystemClock(),
+ flushIntervalMs = 60_000,
+ maxBatchEntries = 500,
+ )
+ logCollector.start(scope)
+ attachLogCollector(logCollector)
+ pw.binom.viewmate.core.logCollector = logCollector
+ server.hub.onLogBatch = { entries -> logCollector.recordBatch(entries) }
+ log(
+ "server",
+ "лог-коллектор запущен (sink: loki ${LokiConfig().url}, instance по source; " +
+ "спул: filesDir/logspool)",
+ )
+
scope.launch { assistantLoop() }
val lastPositionAt = AtomicLong(0)
@@ -534,9 +548,6 @@ class PhoneApp : Application() {
lateinit var instance: PhoneApp
private set
- /** Бэкенд STT: "qwen3_asr" | "whisper" (откат). */
- private const val STT_BACKEND = "qwen3_asr"
-
/** Подсказки словаря Qwen3-ASR (запятая-разделитель). */
private const val STT_HOTWORDS = "тулсет,тулы,умеешь"
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt
index 8f20555..1663f9e 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/Qwen3AsrStt.kt
@@ -1,51 +1,29 @@
package pw.binom.viewmate.phone.stt
-import com.k2fsa.sherpa.onnx.OfflineModelConfig
-import com.k2fsa.sherpa.onnx.OfflineQwen3AsrModelConfig
-import com.k2fsa.sherpa.onnx.OfflineRecognizer
-import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
+import pw.binom.asr.qwen3.Qwen3Config
+import pw.binom.asr.qwen3.createQwen3Sherpa
-/** Офлайн-распознавание: sherpa-onnx + Qwen3-ASR-0.6B (int8), CPU. */
+/** Офлайн-распознавание: asr-qwen3 (sherpa-onnx + Qwen3-ASR-0.6B int8, CPU). */
class Qwen3AsrStt(
- private val convFrontendPath: String,
- private val encoderPath: String,
- private val decoderPath: String,
- private val tokenizerDir: String,
- private val numThreads: Int = 4,
- private val hotwords: String = "",
+ convFrontendPath: String,
+ encoderPath: String,
+ decoderPath: String,
+ tokenizerDir: String,
+ numThreads: Int = 4,
+ hotwords: String = "",
) : PhraseRecognizer {
- private val recognizer: OfflineRecognizer = OfflineRecognizer(
- null,
- OfflineRecognizerConfig(
- modelConfig = OfflineModelConfig(
- qwen3Asr = OfflineQwen3AsrModelConfig(
- convFrontend = convFrontendPath,
- encoder = encoderPath,
- decoder = decoderPath,
- tokenizer = tokenizerDir,
- maxTotalLen = 512,
- maxNewTokens = 128,
- temperature = 1e-6f,
- topP = 0.8f,
- seed = 42,
- hotwords = hotwords,
- ),
- numThreads = numThreads,
- provider = "cpu",
- modelType = "qwen3_asr",
- ),
+ /** Тяжёлая нативка создаётся один раз; recognize() — single-shot (стрим на вызов). */
+ private val recognizer = createQwen3Sherpa(
+ Qwen3Config(
+ convFrontendPath = convFrontendPath,
+ encoderPath = encoderPath,
+ decoderPath = decoderPath,
+ tokenizerDir = tokenizerDir,
+ numThreads = numThreads,
+ hotwords = hotwords,
),
)
/** Распознать 16 кГц mono PCM. Возвращает текст. */
- override fun recognize(samples: FloatArray): String {
- val stream = recognizer.createStream()
- try {
- stream.acceptWaveform(samples, 16000)
- recognizer.decode(stream)
- return recognizer.getResult(stream).text.trim()
- } finally {
- stream.release()
- }
- }
-}
\ No newline at end of file
+ override fun recognize(samples: FloatArray): String = recognizer.recognize(samples, 16000)
+}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt
index eaee2fd..e0fa08c 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/SttDebug.kt
@@ -1,15 +1,12 @@
package pw.binom.viewmate.phone.stt
import android.content.Context
-import android.util.Log
import java.io.File
import java.io.FileInputStream
/** Отладочный прогон: распознать wav-файл по фразам, печать в лог. */
object SttDebug {
- private const val TAG = "stt"
-
/**
* Распознаёт [wavPath] по фразам (VAD + Whisper).
* Возвращает весь текст (для показа на экране), ошибки логирует и возвращает null.
@@ -54,14 +51,14 @@ object SttDebug {
log("[stt] ВЕСЬ ТЕКСТ: $all")
all
} catch (t: Throwable) {
- Log.e(TAG, "[stt] ошибка: ${t.message}", t)
+ log("[stt] ошибка: ${t.message}")
t.printStackTrace()
null
}
}
private fun log(msg: String) {
- Log.i(TAG, msg)
+ pw.binom.viewmate.phone.log("stt", msg)
println(msg)
}
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt
deleted file mode 100644
index 77e26a7..0000000
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/stt/WhisperStt.kt
+++ /dev/null
@@ -1,46 +0,0 @@
-package pw.binom.viewmate.phone.stt
-
-import com.k2fsa.sherpa.onnx.OfflineModelConfig
-import com.k2fsa.sherpa.onnx.OfflineRecognizer
-import com.k2fsa.sherpa.onnx.OfflineRecognizerConfig
-import com.k2fsa.sherpa.onnx.OfflineWhisperModelConfig
-
-/** Офлайн-распознавание: sherpa-onnx + Whisper-small (int8), CPU. */
-class WhisperStt(
- private val encoderPath: String,
- private val decoderPath: String,
- private val tokensPath: String,
- private val numThreads: Int = 4,
- private val language: String = "ru",
-) : PhraseRecognizer {
- private val recognizer: OfflineRecognizer = OfflineRecognizer(
- null,
- OfflineRecognizerConfig(
- modelConfig = OfflineModelConfig(
- whisper = OfflineWhisperModelConfig(
- encoder = encoderPath,
- decoder = decoderPath,
- language = language,
- task = "transcribe",
- tailPaddings = -1,
- ),
- tokens = tokensPath,
- numThreads = numThreads,
- provider = "cpu",
- modelType = "whisper",
- ),
- ),
- )
-
- /** Распознать 16 кГц mono PCM. Возвращает текст. */
- override fun recognize(samples: FloatArray): String {
- val stream = recognizer.createStream()
- try {
- stream.acceptWaveform(samples, 16000)
- recognizer.decode(stream)
- return recognizer.getResult(stream).text.trim()
- } finally {
- stream.release()
- }
- }
-}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt
index fdab495..58138e7 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/Components.kt
@@ -31,6 +31,7 @@ import androidx.compose.ui.unit.sp
import coil.compose.AsyncImage
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
+import java.util.Locale
/** Экраны приложения (простая навигация без nav-библиотеки). */
enum class Screen {
@@ -168,7 +169,7 @@ fun bytesToSize(bytes: Long?): String {
val gb = 1024.0 * 1024 * 1024
val mb = 1024.0 * 1024
return when {
- bytes >= gb -> "%.2f ГБ".format(bytes / gb)
+ bytes >= gb -> String.format(Locale.ROOT, "%.2f ГБ", bytes / gb)
bytes >= mb -> "%d МБ".format((bytes / mb).toLong())
else -> "%d КБ".format((bytes / 1024).toLong())
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt
index a73f5e5..a47b75e 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/DetailsScreen.kt
@@ -42,7 +42,6 @@ import kotlinx.coroutines.launch
import pw.binom.viewmate.core.media.JellyfinItem
import pw.binom.viewmate.core.media.MirrorJob
import pw.binom.viewmate.core.media.MirrorStatus
-import pw.binom.viewmate.core.phone.trackFileName
import pw.binom.viewmate.core.protocol.DownloadPhase
import pw.binom.viewmate.phone.FileDownloadState
import pw.binom.viewmate.phone.PhoneApp
@@ -392,26 +391,28 @@ fun DetailsScreen(
keys.forEach { key ->
val name = finalFileName(key)
val (finalFile, partFile) = trackFiles[key] ?: return@forEach
+ val isVideo = key == "video"
val phoneRaw = phoneFileState(finalFile, partFile)
val phonePercent = if (phoneRaw.first == FileDownloadState.DOWNLOADING) {
dm.percentForFile(item.Id, name) ?: dm.overallPercent(item.Id)
} else {
phoneRaw.second
}
- val glassesState = if (glassesFully) {
- FileDownloadState.DONE to 100
- } else {
- glassesFileState(perFileProgress[name])
+ // Видео лежит на очках, звук — на телефоне: чип только у того устройства, куда файл вообще идёт
+ val state = when {
+ isVideo && glassesFully -> FileDownloadState.DONE to 100
+ isVideo -> glassesFileState(perFileProgress[name])
+ else -> phoneRaw.first to phonePercent
}
DownloadFileRow(
name = name,
- sizeLabel = fileSizeOrProgress(
- sizeBytes = knownSizeByName[name],
- phone = phoneRaw.first to phonePercent,
- glasses = glassesState,
- ),
- phone = phoneRaw.first to phonePercent,
- glasses = glassesState,
+ device = if (isVideo) "очки" else "телефон",
+ sizeLabel = if (state.first == FileDownloadState.DOWNLOADING) {
+ "⏳ ${state.second}%"
+ } else {
+ bytesToSize(knownSizeByName[name])
+ },
+ state = state,
)
}
}
@@ -498,24 +499,13 @@ fun DetailsScreen(
}
}
-/** Метка «размер/прогресс» для строки файла: при качке — ⏳ N%, иначе размер («--», если размера ещё нет). */
-private fun fileSizeOrProgress(
- sizeBytes: Long?,
- phone: Pair,
- glasses: Pair,
-): String = when {
- phone.first == FileDownloadState.DOWNLOADING -> "⏳ ${phone.second}%"
- glasses.first == FileDownloadState.DOWNLOADING -> "⏳ ${glasses.second}%"
- else -> bytesToSize(sizeBytes)
-}
-
-/** Строка файла: имя · размер, ниже — чипы «телефон» / «очки». */
+/** Строка файла: имя · размер, ниже — чип того устройства, куда файл идёт (видео — очки, звук — телефон). */
@Composable
private fun DownloadFileRow(
name: String,
+ device: String,
sizeLabel: String,
- phone: Pair,
- glasses: Pair,
+ state: Pair,
) {
Column(modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp)) {
Text("$name · $sizeLabel", color = PhoneTextDim, fontSize = 11.sp)
@@ -523,13 +513,9 @@ private fun DownloadFileRow(
modifier = Modifier.padding(start = 8.dp),
verticalAlignment = Alignment.CenterVertically,
) {
- Text("телефон", color = PhoneMuted, fontSize = 9.sp)
+ Text(device, color = PhoneMuted, fontSize = 9.sp)
Spacer(modifier = Modifier.width(6.dp))
- FileChip(phone)
- Spacer(modifier = Modifier.width(12.dp))
- Text("очки", color = PhoneMuted, fontSize = 9.sp)
- Spacer(modifier = Modifier.width(6.dp))
- FileChip(glasses)
+ FileChip(state)
}
}
}
diff --git a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt
index fe622e4..9ab2cb3 100644
--- a/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt
+++ b/app-phone/src/main/kotlin/pw/binom/viewmate/phone/ui/MercuryPanel.kt
@@ -5,34 +5,60 @@ import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.Row
import androidx.compose.foundation.layout.Spacer
import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.heightIn
import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.Arrangement
import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
+import androidx.compose.material3.Slider
+import androidx.compose.material3.Switch
import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
import androidx.compose.runtime.Composable
+import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.collectAsState
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableIntStateOf
+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.draw.clip
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 kotlin.math.roundToInt
+import kotlinx.coroutines.delay
import kotlinx.coroutines.launch
import pw.binom.viewmate.phone.MercuryBridge
/**
* Панель Mercury (BLE-протокол RayNeo X2) — полевое испытание либы rayneo-mercury.
- * Минимум: найти очки → подключить → погасить/включить экран (deepSuspend).
+ * Управление очками: подключение, экран, яркость и WiFi.
*/
@Composable
fun MercuryPanel() {
val scope = rememberCoroutineScope()
val state by MercuryBridge.state.collectAsState()
- val busy = false
+
+ var brightness by remember { mutableIntStateOf(state.brightness) }
+ var autoBright by remember { mutableStateOf(state.automaticBrightness) }
+ var wifiOn by remember { mutableStateOf(state.wifiEnabled) }
+ var ssid by remember { mutableStateOf("") }
+ var wifiPass by remember { mutableStateOf("") }
+ var scanning by remember { mutableStateOf(false) }
+
+ // Синхронизируем локальные поля со статусом очков (после pullGeneralStatus).
+ LaunchedEffect(state.brightness) { brightness = state.brightness }
+ LaunchedEffect(state.automaticBrightness) { autoBright = state.automaticBrightness }
+ LaunchedEffect(state.wifiEnabled) { wifiOn = state.wifiEnabled }
Column(
modifier = Modifier
@@ -43,7 +69,7 @@ fun MercuryPanel() {
.padding(12.dp),
) {
Text(
- text = "🛰 Mercury BLE (полевой тест)",
+ text = "🛰 Mercury BLE (управление очками)",
color = PhoneText,
fontSize = 14.sp,
fontWeight = FontWeight.SemiBold,
@@ -65,12 +91,7 @@ fun MercuryPanel() {
Row(modifier = Modifier.padding(top = 10.dp)) {
Button(
- onClick = {
- scope.launch {
- MercuryBridge.connectFirst()
- }
- },
- enabled = !busy,
+ onClick = { scope.launch { MercuryBridge.connectFirst() } },
colors = ButtonDefaults.buttonColors(
containerColor = PhoneBlue,
contentColor = PhoneBlueText,
@@ -80,9 +101,7 @@ fun MercuryPanel() {
}
Spacer(modifier = Modifier.width(8.dp))
Button(
- onClick = {
- scope.launch { MercuryBridge.disconnect() }
- },
+ onClick = { scope.launch { MercuryBridge.disconnect() } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneSurface,
@@ -95,9 +114,7 @@ fun MercuryPanel() {
Row(modifier = Modifier.padding(top = 8.dp)) {
Button(
- onClick = {
- scope.launch { MercuryBridge.screenOff() }
- },
+ onClick = { scope.launch { MercuryBridge.screenOff() } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = Color(0xFF2B2F36),
@@ -108,9 +125,7 @@ fun MercuryPanel() {
}
Spacer(modifier = Modifier.width(8.dp))
Button(
- onClick = {
- scope.launch { MercuryBridge.screenOn() }
- },
+ onClick = { scope.launch { MercuryBridge.screenOn() } },
enabled = state.connected,
colors = ButtonDefaults.buttonColors(
containerColor = PhoneAccent,
@@ -120,5 +135,177 @@ fun MercuryPanel() {
Text("☀️ включить экран", fontSize = 12.sp)
}
}
+
+ // ---- Яркость ----
+ Text(
+ text = "Яркость: $brightness%",
+ color = PhoneText,
+ fontSize = 12.sp,
+ fontWeight = FontWeight.SemiBold,
+ modifier = Modifier.padding(top = 12.dp),
+ )
+ Slider(
+ value = brightness.toFloat(),
+ onValueChange = { brightness = it.roundToInt() },
+ valueRange = 0f..100f,
+ enabled = state.connected,
+ )
+ Row(verticalAlignment = Alignment.CenterVertically) {
+ Button(
+ onClick = { scope.launch { MercuryBridge.setBrightness(brightness) } },
+ enabled = state.connected,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = PhoneBlue,
+ contentColor = PhoneBlueText,
+ ),
+ ) {
+ Text("Применить яркость", fontSize = 12.sp)
+ }
+ Spacer(modifier = Modifier.width(10.dp))
+ Switch(
+ checked = autoBright,
+ onCheckedChange = { nv ->
+ autoBright = nv
+ scope.launch { MercuryBridge.setAutoBrightness(nv) }
+ },
+ enabled = state.connected,
+ )
+ Text("авто-яркость", color = PhoneMuted, fontSize = 11.sp)
+ }
+
+ // ---- WiFi ----
+ Row(
+ modifier = Modifier.padding(top = 12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Text(
+ text = "WiFi",
+ color = PhoneText,
+ fontSize = 14.sp,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Spacer(modifier = Modifier.width(10.dp))
+ Switch(
+ checked = wifiOn,
+ onCheckedChange = { nv ->
+ wifiOn = nv
+ scope.launch { MercuryBridge.wifiToggle(nv) }
+ },
+ enabled = state.connected,
+ )
+ Text(if (wifiOn) "вкл" else "выкл", color = PhoneMuted, fontSize = 11.sp)
+ Spacer(modifier = Modifier.width(10.dp))
+ Button(
+ onClick = { scope.launch {
+ if (!state.wifiEnabled) MercuryBridge.wifiToggle(true)
+ scanning = true
+ delay(1500) // дать радио подняться
+ MercuryBridge.wifiScan()
+ delay(5000) // окно ожидания ответа очков
+ scanning = false
+ } },
+ enabled = state.connected,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = PhoneSurface,
+ contentColor = PhoneMuted,
+ ),
+ ) {
+ Text(if (scanning) "Сканируем…" else "Сканировать", fontSize = 12.sp)
+ }
+ if (state.wifiSsid != null) {
+ Text(
+ "· ${state.wifiSsid}",
+ color = PhoneBlueText,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(start = 8.dp),
+ )
+ }
+ }
+
+ TextField(
+ value = ssid,
+ onValueChange = { ssid = it },
+ label = { Text("SSID сети", fontSize = 11.sp) },
+ singleLine = true,
+ enabled = state.connected,
+ modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
+ )
+ TextField(
+ value = wifiPass,
+ onValueChange = { wifiPass = it },
+ label = { Text("Пароль (пусто = открытая сеть)", fontSize = 11.sp) },
+ singleLine = true,
+ enabled = state.connected,
+ modifier = Modifier.fillMaxWidth().padding(top = 6.dp),
+ )
+ Button(
+ onClick = { scope.launch { MercuryBridge.wifiConnect(ssid, wifiPass.ifBlank { null }) } },
+ enabled = state.connected && ssid.isNotBlank(),
+ colors = ButtonDefaults.buttonColors(
+ containerColor = PhoneBlue,
+ contentColor = PhoneBlueText,
+ ),
+ ) {
+ Text("Подключить к сети", fontSize = 12.sp)
+ }
+
+ if (state.wifiNetworks.isEmpty() && !scanning) {
+ Text(
+ "Список пуст: включи WiFi и нажми «Сканировать».",
+ color = PhoneMuted,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ }
+
+ if (state.wifiNetworks.isNotEmpty()) {
+ Text(
+ "Найдено сетей: ${state.wifiNetworks.size}",
+ color = PhoneMuted,
+ fontSize = 11.sp,
+ modifier = Modifier.padding(top = 8.dp),
+ )
+ LazyColumn(
+ modifier = Modifier.heightIn(max = 220.dp).fillMaxWidth().padding(top = 4.dp),
+ ) {
+ items(state.wifiNetworks) { net ->
+ Column(
+ modifier = Modifier.fillMaxWidth().padding(vertical = 4.dp),
+ ) {
+ Text(
+ "${net.ssid} ${net.level}dBm${if (net.saved) " · сохр." else ""}",
+ color = PhoneText,
+ fontSize = 11.sp,
+ )
+ Row(
+ modifier = Modifier.padding(top = 4.dp),
+ horizontalArrangement = Arrangement.spacedBy(4.dp),
+ ) {
+ Button(
+ onClick = { scope.launch {
+ val pwd = if (net.needsPassword()) wifiPass.ifBlank { null } else null
+ MercuryBridge.wifiConnect(net.ssid, pwd)
+ } },
+ enabled = state.connected,
+ ) {
+ Text("Подкл.", fontSize = 11.sp)
+ }
+ if (net.saved) {
+ Button(
+ onClick = { scope.launch { MercuryBridge.wifiRemove(net.ssid) } },
+ enabled = state.connected,
+ colors = ButtonDefaults.buttonColors(
+ containerColor = PhoneSurface,
+ contentColor = PhoneMuted,
+ ),
+ ) {
+ Text("Забыть", fontSize = 11.sp)
+ }
+ }
+ }
+ }
+ }
+ }
+ }
}
}
diff --git a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt
index d4ae085..0f44d92 100644
--- a/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt
+++ b/app-phone/src/test/kotlin/pw/binom/viewmate/phone/AudioSyncPlayerTest.kt
@@ -33,7 +33,7 @@ class AudioSyncPlayerTest {
@Test
fun speedsUpWhenAudioLagsBehind() {
val d = decideSync(audioPosition = 10_000, videoPosition = 10_300, audioPlaying = true, videoPlaying = true)
- assertEquals(1f + DRIFT_RATE, d.speed, "звук отстаёт — догоняем ускорением ${1f + DRIFT_RATE}")
+ assertEquals(1f + 300f * DRIFT_GAIN, d.speed, "звук отстаёт — догоняем ускорением ${1f + 300f * DRIFT_GAIN}")
assertNull(d.seekToMs, "diff=300мс внутри HARD-порога — seek не нужен")
assertNull(d.playing)
}
@@ -41,9 +41,22 @@ class AudioSyncPlayerTest {
@Test
fun slowsDownWhenAudioAhead() {
val d = decideSync(audioPosition = 10_300, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
- assertEquals(1f - DRIFT_RATE, d.speed, "звук впереди — тормозим до ${1f - DRIFT_RATE}")
+ assertEquals(1f - 300f * DRIFT_GAIN, d.speed, "звук впереди — тормозим до ${1f - 300f * DRIFT_GAIN}")
assertNull(d.seekToMs)
- assertNull(d.playing)
+ }
+
+ @Test
+ fun smallDriftJustOutsideBandIsProportional() {
+ val d = decideSync(audioPosition = 10_151, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
+ assertEquals(1f - 151f * DRIFT_GAIN, d.speed, "diff=151мс (чуть за зоной) — слабый пропорциональный темп")
+ }
+
+ @Test
+ fun speedSaturatesAtMaxDrift() {
+ val behind = decideSync(audioPosition = 9_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
+ assertEquals(1f + MAX_DRIFT_SPEED, behind.speed, "diff=−1000мс → 1+0.2 упирается в потолок ${1f + MAX_DRIFT_SPEED}")
+ val ahead = decideSync(audioPosition = 11_000, videoPosition = 10_000, audioPlaying = true, videoPlaying = true)
+ assertEquals(1f - MAX_DRIFT_SPEED, ahead.speed, "diff=+1000мс → 1−0.2 упирается в потолок ${1f - MAX_DRIFT_SPEED}")
}
@Test
@@ -73,7 +86,7 @@ class AudioSyncPlayerTest {
fun hardThresholdBoundaryDoesNotSeek() {
val d = decideSync(audioPosition = 10_000, videoPosition = 12_000, audioPlaying = true, videoPlaying = true)
assertNull(d.seekToMs, "diff=2000мс не БОЛЬШЕ порога — подгоняем скоростью, без рывка")
- assertEquals(1f + DRIFT_RATE, d.speed)
+ assertEquals(1f + MAX_DRIFT_SPEED, d.speed, "diff=2000мс → 1+0.4 — упирается в потолок ${1f + MAX_DRIFT_SPEED}")
}
@Test
diff --git a/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt
new file mode 100644
index 0000000..9fdce1f
--- /dev/null
+++ b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt
@@ -0,0 +1,68 @@
+package pw.binom.viewmate.core.log
+
+import java.io.File
+
+class FileLogSpool(
+ dir: String,
+ maxFiles: Int,
+) : LogSpool {
+ private val filesDir: File = File(dir).also { if (!it.exists()) it.mkdirs() }
+ private val limit = maxFiles
+
+ /** Сериал следующих файлов: продолжение после старейшего существующего. */
+ private var seq: Long = currentFiles().mapNotNull { it.seqNumber() }.maxOrNull() ?: 0L
+
+ override fun write(batch: LogBatch) {
+ seq += 1
+ File(filesDir, "$PREFIX$seq.json").writeText(LogBatch.encodeBatch(batch))
+ trim()
+ }
+
+ override fun peek(): LogBatch? {
+ var file = oldestFile()
+ while (file != null) {
+ val batch = decodeQuiet(file)
+ if (batch != null) return batch
+ file.delete() // битый файл не должен затыкать очередь
+ file = oldestFile()
+ }
+ return null
+ }
+
+ override fun take(): LogBatch? {
+ var file = oldestFile()
+ while (file != null) {
+ val batch = decodeQuiet(file)
+ file.delete()
+ if (batch != null) return batch
+ file = oldestFile()
+ }
+ return null
+ }
+
+ override fun pendingCount(): Int = currentFiles().size
+
+ private fun oldestFile(): File? = currentFiles().minByOrNull { it.seqNumber() }
+
+ private fun currentFiles(): List = filesDir
+ .listFiles { f -> f.isFile && f.name.startsWith(PREFIX) && f.name.endsWith(".json") }
+ ?.toList()
+ ?: emptyList()
+
+ private fun File.seqNumber(): Long =
+ name.substringAfter(PREFIX).removeSuffix(".json").toLongOrNull() ?: 0L
+
+ private fun decodeQuiet(file: File): LogBatch? =
+ runCatching { LogBatch.decodeBatch(file.readText()) }.getOrNull()
+
+ /** Потолок: выбросить старейшие файлы сверх [limit]. */
+ private fun trim() {
+ val files = currentFiles().sortedBy { it.seqNumber() }
+ if (files.size <= limit) return
+ files.take(files.size - limit).forEach { it.delete() }
+ }
+
+ private companion object {
+ const val PREFIX = "logspool-"
+ }
+}
diff --git a/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt
new file mode 100644
index 0000000..3014843
--- /dev/null
+++ b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt
@@ -0,0 +1,16 @@
+package pw.binom.viewmate.core.log
+
+import java.util.concurrent.locks.ReentrantLock
+
+actual class LogLock {
+ private val delegate = ReentrantLock()
+
+ actual fun withLock(action: () -> T): T {
+ delegate.lock()
+ return try {
+ action()
+ } finally {
+ delegate.unlock()
+ }
+ }
+}
diff --git a/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt
new file mode 100644
index 0000000..cf1a8af
--- /dev/null
+++ b/lib-core/src/androidMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt
@@ -0,0 +1,5 @@
+package pw.binom.viewmate.core.log
+
+actual class SystemClock : LogClock {
+ override fun nowMillis(): Long = System.currentTimeMillis()
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt
index 8fa7b1f..0a9ae6f 100644
--- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/Log.kt
@@ -1,6 +1,17 @@
package pw.binom.viewmate.core
-/** Единый лог общего слоя (KMP): без java.time, просто тег + сообщение. */
+import pw.binom.viewmate.core.log.BatchingLogCollector
+
+/**
+ * Единый лог общего слоя (KMP): без java.time, просто тег + сообщение.
+ *
+ * Приложение подключает свой коллектор через [logCollector] (в onCreate),
+ * и каждый вызов [log] попадает в него (дальше — в спул / на сервер / Loki).
+ * По умолчанию коллектор не задан — лог только в println.
+ */
+var logCollector: BatchingLogCollector? = null
+
fun log(tag: String, message: String) {
println("[view-mate] $tag $message")
+ logCollector?.record(tag, message)
}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt
new file mode 100644
index 0000000..0d36be8
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/BatchingLogCollector.kt
@@ -0,0 +1,121 @@
+package pw.binom.viewmate.core.log
+
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.Job
+import kotlinx.coroutines.delay
+import kotlinx.coroutines.isActive
+import kotlinx.coroutines.launch
+
+/**
+ * «Абстрактный коллектор» лог-пачек: собирает записи в батчи и по времени
+ * шлёт их «вниз» через [sink] (абстракция следующего коллектора).
+ *
+ * Смысл:
+ * - [record]/[recordBatch] — синхронно, с любой нитки: запись в буфер (лок),
+ * при переполнении [maxBatchEntries] — немедленный flush;
+ * - таймер (период [flushIntervalMs]) — собирает буфер в [LogBatch] и шлёт в [sink];
+ * - «не получилось послать» ([LogSink.send] = false / бросил) — пачка
+ * **прихраняется в [spool]** и на каждом следующем тике ретраится ДО новых
+ * пачек (FIFO: старое раньше);
+ * - «чужие» записи (например, логи очков, пришедшие на телефон) — [recordBatch]
+ * с сохранением их [LogEntry.source]/[LogEntry.ts].
+ *
+ * Поток: буфер — из любых ниток (под [LogLock]); спул и отправка — только
+ * таймер-корутина (однопоточно).
+ */
+class BatchingLogCollector(
+ /** Устройство-носитель коллектора: "glasses" / "phone". */
+ val device: String,
+ /** «Следующий» коллектор: куда шлём пачки. */
+ private val sink: LogSink,
+ /** Куда прихраним не отправленные пачки (retry позже). */
+ private val spool: LogSpool,
+ /** Часы для меток времени записей. */
+ private val clock: LogClock,
+ /** Период отправки пачек, мс. */
+ private val flushIntervalMs: Long = 30_000L,
+ /** Лимит записей в одной пачке — превышение триггерит немедленный flush. */
+ private val maxBatchEntries: Int = 500,
+) {
+ private val buffer = ArrayList()
+ private val lock = LogLock()
+
+ /** Потолок буфера в памяти (страховка, если sink долго не принимал). */
+ private val maxBuffer: Int = maxBatchEntries * 20
+
+ @Volatile
+ private var timerJob: Job? = null
+
+ @Volatile
+ private var scope: CoroutineScope? = null
+
+ /** Записать свою лог-строку (синхронно, с любой нитки). */
+ fun record(tag: String, message: String) {
+ recordBatch(listOf(LogEntry(clock.nowMillis(), tag, message, device)))
+ }
+
+ /**
+ * Прибавить «чужие» записи (готовые [LogEntry] — со своими ts/source):
+ * телефон так пересылает логи очков.
+ */
+ fun recordBatch(entries: List) {
+ if (entries.isEmpty()) return
+ val overflowed = lock.withLock {
+ buffer.addAll(entries)
+ val drop = buffer.size - maxBuffer
+ if (drop > 0) repeat(drop) { buffer.removeAt(0) }
+ buffer.size >= maxBatchEntries
+ }
+ if (overflowed) scope?.launch { flush() }
+ }
+
+ /** Запустить таймер: каждые [flushIntervalMs] — flush (спул-ретраи + новый батч). */
+ fun start(scope: CoroutineScope) {
+ if (timerJob != null) return
+ this.scope = scope
+ timerJob = scope.launch {
+ while (isActive) {
+ delay(flushIntervalMs)
+ runCatching { flush() }
+ }
+ }
+ }
+
+ /** Остановить таймер (буфер/спул не трогаются — дошлёт следующий запуск). */
+ fun stop() {
+ timerJob?.cancel()
+ timerJob = null
+ scope = null
+ }
+
+ /**
+ * Цикл отправки:
+ * 1) ретраи спула — только старейшая пачка; не слано — ждём следующий тик
+ * (новый батч не «перескакивает» через неё, порядок сохраняется);
+ * 2) новый батч из буфера; не слано — прихраняем в [spool].
+ */
+ suspend fun flush() {
+ val spooled = spool.peek()
+ if (spooled != null) {
+ if (sendBatch(spooled)) {
+ spool.take()
+ }
+ return
+ }
+ val batch = drainBuffer() ?: return
+ if (!sendBatch(batch)) {
+ spool.write(batch)
+ }
+ }
+
+ private suspend fun sendBatch(batch: LogBatch): Boolean =
+ runCatching { sink.send(batch) }.getOrDefault(false)
+
+ /** Взять все накопленные записи в одну пачку (null — пусто). */
+ private fun drainBuffer(): LogBatch? = lock.withLock {
+ if (buffer.isEmpty()) return@withLock null
+ val batch = LogBatch(device, buffer.toList())
+ buffer.clear()
+ batch
+ }
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt
new file mode 100644
index 0000000..fef1466
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/HttpLogSink.kt
@@ -0,0 +1,36 @@
+package pw.binom.viewmate.core.log
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import pw.binom.viewmate.core.media.defaultHttpClient
+
+/**
+ * [LogSink] по HTTP: POST JSON-пачки на [url] (заголовок X-API-Key, если [apiKey] задан).
+ * 2xx — принято (true); любой другой ответ/сетевая ошибка — false (пачка уйдёт в спул).
+ */
+class HttpLogSink(
+ private val client: HttpClient,
+ private val url: String,
+ private val apiKey: String? = null,
+) : LogSink {
+ override suspend fun send(batch: LogBatch): Boolean {
+ val response: HttpResponse = runCatching {
+ client.post(url) {
+ if (apiKey != null) header("X-API-Key", apiKey)
+ contentType(ContentType.Application.Json)
+ setBody(LogBatch.encodeBatch(batch))
+ }
+ }.getOrNull() ?: return false
+ return response.status.isSuccess()
+ }
+}
+
+/** Готовый HTTP-лог-синк со своим клиентом: вызывающая сторона не знает про Ktor. */
+fun httpLogSink(url: String, apiKey: String? = null): LogSink =
+ HttpLogSink(defaultHttpClient(), url, apiKey)
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt
new file mode 100644
index 0000000..002f830
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogBatch.kt
@@ -0,0 +1,44 @@
+package pw.binom.viewmate.core.log
+
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+/**
+ * Одна лог-запись.
+ * [ts] — часы устройства-источника, epoch-мс.
+ * [tag] — подсистема (stt, hub, mc, …).
+ * [source] — кто сгенерировал ("glasses" / "phone") — чтобы телефон мог пересылать
+ * логи очков на сервер с сохранением источника.
+ */
+@Serializable
+data class LogEntry(
+ val ts: Long,
+ val tag: String,
+ val message: String,
+ val source: String = "unknown",
+)
+
+/**
+ * Пачка логов — единица передачи в «следующий» коллектор.
+ * [device] — кто шлёт эту пачку (устройство-коллектор).
+ */
+@Serializable
+data class LogBatch(
+ val device: String,
+ val entries: List,
+) {
+ companion object {
+ private val json: Json = Json {
+ ignoreUnknownKeys = true
+ encodeDefaults = true
+ }
+
+ /** Пачка → JSON-строка (файл спула). */
+ fun encodeBatch(batch: LogBatch): String = json.encodeToString(serializer(), batch)
+
+ /** JSON-строка → пачка; некорректный/битый текст — null. */
+ fun decodeBatch(text: String): LogBatch? = runCatching {
+ json.decodeFromString(serializer(), text)
+ }.getOrNull()
+ }
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt
new file mode 100644
index 0000000..7ad79f0
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogClock.kt
@@ -0,0 +1,19 @@
+package pw.binom.viewmate.core.log
+
+/** Часы для лог-записей: у common-кода нет java.time — устройство-носитель подаёт свои. */
+fun interface LogClock {
+ fun nowMillis(): Long
+}
+
+/** Стенговые (wall-clock) часы — JVM-реализация (Android/JVM: `System.currentTimeMillis`). */
+expect class SystemClock() : LogClock
+
+/** Зависанный (fixed) ход времени для юнит-тестов. */
+class FixedClock(private var now: Long = 0L) : LogClock {
+ override fun nowMillis(): Long = now
+
+ /** Продвинуть «время» на [ms]. */
+ fun advance(ms: Long) {
+ now += ms
+ }
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt
new file mode 100644
index 0000000..ab6c8c8
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt
@@ -0,0 +1,10 @@
+package pw.binom.viewmate.core.log
+
+/**
+ * Лока (thread-safe) для буфера коллектора: KMP-common не даёт нативных лок-ов —
+ * каждая платформа подставляет свою (Android/JVM: `java.util.concurrent.locks.ReentrantLock`).
+ */
+expect class LogLock() {
+ /** Выполнить [action] под локом; вернуть результат. */
+ fun withLock(action: () -> T): T
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt
new file mode 100644
index 0000000..f80b2c9
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSink.kt
@@ -0,0 +1,20 @@
+package pw.binom.viewmate.core.log
+
+/**
+ * Абстракция «следующего» получателя лог-пачек (сервер у телефона, сам телефон
+ * у очков, чёрная дыра в тестах). Коллектор не знает про транспорт/протокол —
+ * только про результат отправки.
+ */
+fun interface LogSink {
+ /**
+ * Отправить [batch] вниз по цепочке.
+ * @return `true` — принято (пачка потреблена); `false` — не принято
+ * (коллектор положит пачку в спул и попробует позже).
+ */
+ suspend fun send(batch: LogBatch): Boolean
+}
+
+/** LogSink, который пачки проглатывает (логирование отключено / нет цели). */
+object NoopLogSink : LogSink {
+ override suspend fun send(batch: LogBatch): Boolean = true
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt
new file mode 100644
index 0000000..d35c1ed
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LogSpool.kt
@@ -0,0 +1,41 @@
+package pw.binom.viewmate.core.log
+
+/**
+ * Хранилище не отправленных пачек (спул): «не удалось послать → прихраним у себя
+ * и позже попробуем ещё». FIFO: первым ретраится самая старая пачка.
+ *
+ * Доступ — из одного потока (таймер-корутина коллектора): peek/take на каждом
+ * тике, write — при неудачной отправке.
+ */
+interface LogSpool {
+ /** Добавить [batch] в конец очереди. */
+ fun write(batch: LogBatch)
+
+ /** Самая старая пачка без удаления (null — пусто). */
+ fun peek(): LogBatch?
+
+ /** Самая старая пачка с удалением (null — пусто). */
+ fun take(): LogBatch?
+
+ /** Сколько пачек ждёт отправки. */
+ fun pendingCount(): Int
+}
+
+/**
+ * In-память спул без персистентности (тесты, «прихранил в процессе»):
+ * очередь с потолком [maxBatches] — при переполнении выбрасывается старейшая.
+ */
+class InMemoryLogSpool(private val maxBatches: Int = 32) : LogSpool {
+ private val queue = ArrayDeque()
+
+ override fun write(batch: LogBatch) {
+ if (queue.size >= maxBatches) queue.removeFirst()
+ queue.addLast(batch)
+ }
+
+ override fun peek(): LogBatch? = queue.firstOrNull()
+
+ override fun take(): LogBatch? = queue.removeFirstOrNull()
+
+ override fun pendingCount(): Int = queue.size
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt
new file mode 100644
index 0000000..c80d38f
--- /dev/null
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/log/LokiLogSink.kt
@@ -0,0 +1,94 @@
+package pw.binom.viewmate.core.log
+
+import io.ktor.client.HttpClient
+import io.ktor.client.request.header
+import io.ktor.client.request.post
+import io.ktor.client.request.setBody
+import io.ktor.client.statement.HttpResponse
+import io.ktor.http.ContentType
+import io.ktor.http.contentType
+import io.ktor.http.isSuccess
+import pw.binom.viewmate.core.media.defaultHttpClient
+import kotlinx.serialization.Serializable
+import kotlinx.serialization.json.Json
+
+/**
+ * Коллектор в Loki (Grafana): POST JSON в [LokiConfig.url] (Basic-auth).
+ *
+ * Важное: instance каждого stream'а = [LogEntry.source] записи ("phone"/"glasses"),
+ * то есть в Loki сразу видно, откуда лог — с телефона или с очков. Пачка на
+ * телефоне может содержать и то, и другое (телефон пересылает логи очков),
+ * поэтому группируем по source и шлём несколько stream'ов одним запросом.
+ */
+class LokiLogSink(
+ private val client: HttpClient,
+ private val config: LokiConfig = LokiConfig(),
+) : LogSink {
+
+ private val authHeader = "Basic " + base64("${config.user}:${config.password}")
+ private val json = Json { ignoreUnknownKeys = true }
+
+ override suspend fun send(batch: LogBatch): Boolean {
+ if (batch.entries.isEmpty()) return true
+ val payload = buildLokiPush(batch.entries, config.job)
+ val response: HttpResponse = runCatching {
+ client.post(config.url) {
+ header("Authorization", authHeader)
+ contentType(ContentType.Application.Json)
+ setBody(json.encodeToString(LokiPush.serializer(), payload))
+ }
+ }.getOrNull() ?: return false
+ return response.status.isSuccess()
+ }
+}
+
+/** Параметры Loki. Логин/пароль/адрес зашиты по умолчанию (см. запрос пользователя). */
+data class LokiConfig(
+ val url: String = "https://loki.binom.pw/loki/api/v1/push",
+ val user: String = "loki-ingest",
+ val password: String = "q0OH8na60bE_KhGc6c1YOsUuFPrLjw2K",
+ val job: String = "view-mate",
+)
+
+/** Готовый Loki-синк со своим клиентом: вызывающая сторона не знает про Ktor/Basic-auth. */
+fun lokiLogSink(config: LokiConfig = LokiConfig()): LogSink =
+ LokiLogSink(defaultHttpClient(), config)
+
+@Serializable
+internal data class LokiStream(val stream: Map, val values: List>)
+
+@Serializable
+internal data class LokiPush(val streams: List)
+
+internal fun buildLokiPush(entries: List, job: String): LokiPush {
+ val streams = entries
+ .groupBy { it.source.ifBlank { "phone" } }
+ .map { (source, es) ->
+ val values = es
+ .map { e -> listOf((e.ts * 1_000_000).toString(), "${e.tag}: ${e.message}") }
+ .sortedBy { it[0] }
+ LokiStream(stream = mapOf("job" to job, "instance" to source), values = values)
+ }
+ return LokiPush(streams = streams)
+}
+
+private val B64 = "ABCDEFGHIJKLMNOPQRSTUVWXYZabcdefghijklmnopqrstuvwxyz0123456789+/"
+
+/** Минимальный base64 (только кодирование) — для Basic-auth, без новых зависимостей. */
+private fun base64(input: String): String {
+ val bytes = input.encodeToByteArray()
+ val out = StringBuilder()
+ var i = 0
+ while (i < bytes.size) {
+ val b0 = bytes[i].toInt() and 0xFF
+ val b1 = if (i + 1 < bytes.size) bytes[i + 1].toInt() and 0xFF else 0
+ val b2 = if (i + 2 < bytes.size) bytes[i + 2].toInt() and 0xFF else 0
+ val triple = (b0 shl 16) or (b1 shl 8) or b2
+ out.append(B64[(triple ushr 18) and 0x3F])
+ out.append(B64[(triple ushr 12) and 0x3F])
+ out.append(if (i + 1 < bytes.size) B64[(triple ushr 6) and 0x3F] else '=')
+ out.append(if (i + 2 < bytes.size) B64[triple and 0x3F] else '=')
+ i += 3
+ }
+ return out.toString()
+}
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt
index cf434e1..721c81c 100644
--- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/phone/PhoneActions.kt
@@ -128,8 +128,9 @@ class PhoneActions(
}
/**
- * Скачать контент в память очков: DownloadFiles с presigned URL всех файлов
- * (video.mkv + audio-N.ogg) и известными размерами дорожек [sizes] (key → байт).
+ * Скачать контент в память очков: DownloadFiles с presigned URL только видео (video.mkv).
+ * Звук на очки не идёт — аудио-дорожки скачиваются только на телефон.
+ * [sizes] — известные размеры дорожек (key → байт).
*/
suspend fun downloadToGlasses(
itemId: String,
diff --git a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt
index 03a607e..af36f7b 100644
--- a/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt
+++ b/lib-core/src/commonMain/kotlin/pw/binom/viewmate/core/protocol/Messages.kt
@@ -3,6 +3,8 @@ package pw.binom.viewmate.core.protocol
import kotlinx.serialization.SerialName
import kotlinx.serialization.Serializable
+import pw.binom.viewmate.core.log.LogEntry
+
/**
* Сообщения от очков к хосту (телефону).
* Полиморфная сериализация: дискриминатор "type" со значением @SerialName.
@@ -102,3 +104,12 @@ data class SttAudio(val data: ByteArray) : GlassesToHost {
@Serializable
@SerialName("stop_stt")
data class StopStt(val cancel: Boolean) : GlassesToHost
+
+/**
+ * Лог-пачка очков (очки → телефон). Телефон прибавляет эти записи к своему
+ * коллектору и пересылает на сервер вместе со своими лог-записями;
+ * источник — [LogEntry.source]. Старые очки такое сообщение не шлют.
+ */
+@Serializable
+@SerialName("log_batch")
+data class LogBatchMsg(val entries: List) : GlassesToHost
diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt
new file mode 100644
index 0000000..610edd6
--- /dev/null
+++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/BatchingLogCollectorTest.kt
@@ -0,0 +1,140 @@
+package pw.binom.viewmate.core.log
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+import kotlinx.coroutines.CoroutineScope
+import kotlinx.coroutines.ExperimentalCoroutinesApi
+import kotlinx.coroutines.cancel
+import kotlinx.coroutines.test.UnconfinedTestDispatcher
+import kotlinx.coroutines.test.runTest
+
+class BatchingLogCollectorTest {
+
+ /** Фейк «следующего» коллектора: фиксирует пачки, [fail] — имитация сбоя. */
+ class FakeSink(var fail: Boolean = false) : LogSink {
+ val sent = mutableListOf()
+
+ override suspend fun send(batch: LogBatch): Boolean {
+ if (fail) return false
+ sent += batch
+ return true
+ }
+ }
+
+ @Test
+ fun `timer flush sends buffered entries`() = runTest {
+ val sink = FakeSink()
+ val collector = BatchingLogCollector(
+ device = "glasses",
+ sink = sink,
+ spool = InMemoryLogSpool(),
+ clock = FixedClock(1000),
+ flushIntervalMs = 100,
+ maxBatchEntries = 10,
+ )
+ @OptIn(ExperimentalCoroutinesApi::class)
+ val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))
+ collector.start(scope)
+
+ collector.record("tag1", "hello")
+ collector.record("tag2", "world")
+
+ testScheduler.advanceTimeBy(150)
+
+ val batch = sink.sent.single()
+ assertEquals(2, batch.entries.size)
+ assertEquals("glasses", batch.device)
+ assertEquals(listOf("tag1", "tag2"), batch.entries.map { it.tag })
+ assertEquals("glasses", batch.entries[0].source)
+ scope.cancel()
+ }
+
+ @Test
+ fun `failed sink spools batch, later flush retries`() = runTest {
+ val sink = FakeSink(fail = true)
+ val spool = InMemoryLogSpool()
+ val collector = BatchingLogCollector("phone", sink, spool, FixedClock())
+
+ collector.record("a", "1")
+ collector.flush()
+ assertEquals(0, sink.sent.size)
+ assertEquals(1, spool.pendingCount())
+
+ sink.fail = false
+ collector.flush() // ретраи спула
+ assertEquals(1, sink.sent.size)
+ assertEquals("a", sink.sent[0].entries.single().tag)
+ assertEquals(0, spool.pendingCount())
+ }
+
+ @Test
+ fun `spool backlog blocks new batches, FIFO order kept`() = runTest {
+ val sink = FakeSink(fail = true)
+ val spool = InMemoryLogSpool()
+ val collector = BatchingLogCollector("phone", sink, spool, FixedClock())
+
+ collector.record("a", "1")
+ collector.flush() // batch1 → спул
+ collector.record("b", "2")
+ collector.flush() // спул затык — batch2 остаётся в буфере
+ assertEquals(1, spool.pendingCount())
+
+ sink.fail = false
+ collector.flush() // доразгрузили спул (batch1)
+ collector.flush() // теперь batch2
+ assertEquals(2, sink.sent.size)
+ assertEquals(listOf("a", "b"), sink.sent.flatMap { it.entries }.map { it.tag })
+ assertEquals(0, spool.pendingCount())
+ }
+
+ @Test
+ fun `foreign entries keep their source and ts`() = runTest {
+ val sink = FakeSink()
+ val collector = BatchingLogCollector("phone", sink, InMemoryLogSpool(), FixedClock(999))
+
+ collector.recordBatch(listOf(LogEntry(42, "mc", "glasses event", "glasses")))
+ collector.record("phone", "own")
+ collector.flush()
+
+ val batch = sink.sent.single()
+ assertEquals("phone", batch.device)
+ assertEquals(2, batch.entries.size)
+ assertEquals("glasses", batch.entries[0].source)
+ assertEquals(42, batch.entries[0].ts)
+ assertEquals("phone", batch.entries[1].source)
+ }
+
+ @OptIn(ExperimentalCoroutinesApi::class)
+ @Test
+ fun `overflowing batch flushes immediately`() = runTest {
+ val sink = FakeSink()
+ val collector = BatchingLogCollector(
+ device = "glasses",
+ sink = sink,
+ spool = InMemoryLogSpool(),
+ clock = FixedClock(),
+ flushIntervalMs = 60_000,
+ maxBatchEntries = 2,
+ )
+ val scope = CoroutineScope(UnconfinedTestDispatcher(testScheduler))
+ collector.start(scope)
+
+ collector.record("a", "1")
+ collector.record("b", "2") // буфер == лимит → немедленный flush
+ testScheduler.advanceTimeBy(0)
+
+ val batch = sink.sent.single()
+ assertEquals(2, batch.entries.size)
+ scope.cancel()
+ }
+
+ @Test
+ fun `empty recordBatch is no-op`() = runTest {
+ val sink = FakeSink()
+ val collector = BatchingLogCollector("phone", sink, InMemoryLogSpool(), FixedClock())
+ collector.recordBatch(emptyList())
+ collector.flush()
+ assertNull(sink.sent.firstOrNull())
+ }
+}
diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt
new file mode 100644
index 0000000..646548c
--- /dev/null
+++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LogSpoolTest.kt
@@ -0,0 +1,50 @@
+package pw.binom.viewmate.core.log
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class LogSpoolTest {
+
+ private fun batch(tag: String) = LogBatch("phone", listOf(LogEntry(0, tag, tag, "phone")))
+
+ @Test
+ fun `in-memory spool is FIFO with cap dropping oldest`() {
+ val spool = InMemoryLogSpool(maxBatches = 2)
+ spool.write(batch("1"))
+ spool.write(batch("2"))
+ spool.write(batch("3")) // «1» выброшена
+
+ assertEquals(2, spool.pendingCount())
+ assertEquals("2", spool.peek()?.entries?.single()?.tag)
+ assertEquals("2", spool.take()?.entries?.single()?.tag)
+ assertEquals("3", spool.take()?.entries?.single()?.tag)
+ assertNull(spool.take())
+ assertEquals(0, spool.pendingCount())
+ }
+
+ @Test
+ fun `in-memory spool peek does not consume`() {
+ val spool = InMemoryLogSpool()
+ spool.write(batch("a"))
+ assertEquals("a", spool.peek()?.entries?.single()?.tag)
+ assertEquals("a", spool.peek()?.entries?.single()?.tag)
+ assertEquals(1, spool.pendingCount())
+ }
+
+ @Test
+ fun `batch json roundtrip`() {
+ val batch = LogBatch(
+ "phone",
+ listOf(LogEntry(1, "a", "x", "phone"), LogEntry(2, "b", "y", "glasses")),
+ )
+ val text = LogBatch.encodeBatch(batch)
+ assertEquals(batch, LogBatch.decodeBatch(text))
+ }
+
+ @Test
+ fun `batch json decode garbage is null`() {
+ assertNull(LogBatch.decodeBatch("garbage"))
+ assertNull(LogBatch.decodeBatch(""))
+ }
+}
diff --git a/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt
new file mode 100644
index 0000000..15e4538
--- /dev/null
+++ b/lib-core/src/commonTest/kotlin/pw/binom/viewmate/core/log/LokiLogSinkTest.kt
@@ -0,0 +1,40 @@
+package pw.binom.viewmate.core.log
+
+import kotlin.test.Test
+import kotlin.test.assertEquals
+
+class LokiLogSinkTest {
+
+ @Test
+ fun `payload groups by source and labels instance`() {
+ val entries = listOf(
+ LogEntry(1_000, "hub", "phone line", "phone"),
+ LogEntry(2_000, "mc", "glasses line", "glasses"),
+ LogEntry(3_000, "hub", "another phone", "phone"),
+ )
+ val push = buildLokiPush(entries, "view-mate")
+
+ assertEquals(2, push.streams.size)
+
+ val phoneStream = push.streams.first { it.stream["instance"] == "phone" }
+ val glassesStream = push.streams.first { it.stream["instance"] == "glasses" }
+
+ assertEquals("view-mate", phoneStream.stream["job"])
+ assertEquals(2, phoneStream.values.size)
+ // timestamp: ms * 1e6, sorted ascending
+ assertEquals("1000000000", phoneStream.values[0][0])
+ assertEquals("hub: phone line", phoneStream.values[0][1])
+ assertEquals("3000000000", phoneStream.values[1][0])
+
+ assertEquals(1, glassesStream.values.size)
+ assertEquals("2000000000", glassesStream.values[0][0])
+ assertEquals("mc: glasses line", glassesStream.values[0][1])
+ }
+
+ @Test
+ fun `blank source falls back to phone`() {
+ val push = buildLokiPush(listOf(LogEntry(0, "t", "m", "")), "job")
+ assertEquals(1, push.streams.size)
+ assertEquals("phone", push.streams[0].stream["instance"])
+ }
+}
diff --git a/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt
new file mode 100644
index 0000000..9fdce1f
--- /dev/null
+++ b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/FileLogSpool.kt
@@ -0,0 +1,68 @@
+package pw.binom.viewmate.core.log
+
+import java.io.File
+
+class FileLogSpool(
+ dir: String,
+ maxFiles: Int,
+) : LogSpool {
+ private val filesDir: File = File(dir).also { if (!it.exists()) it.mkdirs() }
+ private val limit = maxFiles
+
+ /** Сериал следующих файлов: продолжение после старейшего существующего. */
+ private var seq: Long = currentFiles().mapNotNull { it.seqNumber() }.maxOrNull() ?: 0L
+
+ override fun write(batch: LogBatch) {
+ seq += 1
+ File(filesDir, "$PREFIX$seq.json").writeText(LogBatch.encodeBatch(batch))
+ trim()
+ }
+
+ override fun peek(): LogBatch? {
+ var file = oldestFile()
+ while (file != null) {
+ val batch = decodeQuiet(file)
+ if (batch != null) return batch
+ file.delete() // битый файл не должен затыкать очередь
+ file = oldestFile()
+ }
+ return null
+ }
+
+ override fun take(): LogBatch? {
+ var file = oldestFile()
+ while (file != null) {
+ val batch = decodeQuiet(file)
+ file.delete()
+ if (batch != null) return batch
+ file = oldestFile()
+ }
+ return null
+ }
+
+ override fun pendingCount(): Int = currentFiles().size
+
+ private fun oldestFile(): File? = currentFiles().minByOrNull { it.seqNumber() }
+
+ private fun currentFiles(): List = filesDir
+ .listFiles { f -> f.isFile && f.name.startsWith(PREFIX) && f.name.endsWith(".json") }
+ ?.toList()
+ ?: emptyList()
+
+ private fun File.seqNumber(): Long =
+ name.substringAfter(PREFIX).removeSuffix(".json").toLongOrNull() ?: 0L
+
+ private fun decodeQuiet(file: File): LogBatch? =
+ runCatching { LogBatch.decodeBatch(file.readText()) }.getOrNull()
+
+ /** Потолок: выбросить старейшие файлы сверх [limit]. */
+ private fun trim() {
+ val files = currentFiles().sortedBy { it.seqNumber() }
+ if (files.size <= limit) return
+ files.take(files.size - limit).forEach { it.delete() }
+ }
+
+ private companion object {
+ const val PREFIX = "logspool-"
+ }
+}
diff --git a/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt
new file mode 100644
index 0000000..3014843
--- /dev/null
+++ b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/LogLock.kt
@@ -0,0 +1,16 @@
+package pw.binom.viewmate.core.log
+
+import java.util.concurrent.locks.ReentrantLock
+
+actual class LogLock {
+ private val delegate = ReentrantLock()
+
+ actual fun withLock(action: () -> T): T {
+ delegate.lock()
+ return try {
+ action()
+ } finally {
+ delegate.unlock()
+ }
+ }
+}
diff --git a/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt
new file mode 100644
index 0000000..cf1a8af
--- /dev/null
+++ b/lib-core/src/jvmMain/kotlin/pw/binom/viewmate/core/log/SystemClock.kt
@@ -0,0 +1,5 @@
+package pw.binom.viewmate.core.log
+
+actual class SystemClock : LogClock {
+ override fun nowMillis(): Long = System.currentTimeMillis()
+}
diff --git a/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt
new file mode 100644
index 0000000..b2c70a2
--- /dev/null
+++ b/lib-core/src/jvmTest/kotlin/pw/binom/viewmate/core/log/FileLogSpoolTest.kt
@@ -0,0 +1,66 @@
+package pw.binom.viewmate.core.log
+
+import java.io.File
+import java.nio.file.Files
+import kotlin.test.AfterTest
+import kotlin.test.Test
+import kotlin.test.assertEquals
+import kotlin.test.assertNull
+
+class FileLogSpoolTest {
+
+ private var tempDir: File? = null
+
+ private fun batch(tag: String) = LogBatch("phone", listOf(LogEntry(0, tag, tag, "phone")))
+
+ private fun freshDir(name: String): File {
+ val dir = Files.createTempDirectory("logspool-$name").toFile()
+ tempDir = dir
+ return dir
+ }
+
+ @AfterTest
+ fun cleanup() {
+ tempDir?.deleteRecursively()
+ }
+
+ @Test
+ fun `writes and drains FIFO, survives restart, trims cap`() {
+ val dir = freshDir("fif")
+ var spool = FileLogSpool(dir.absolutePath, maxFiles = 2)
+ spool.write(batch("1"))
+ spool.write(batch("2"))
+ spool.write(batch("3")) // «1» выбрасывается по потолку
+
+ assertEquals(2, spool.pendingCount())
+ assertEquals("2", spool.peek()?.entries?.single()?.tag)
+ assertEquals("2", spool.take()?.entries?.single()?.tag)
+
+ // «перезапуск»: новая инстанция видит прихранианные файлы и продолжает сериал
+ spool = FileLogSpool(dir.absolutePath, maxFiles = 2)
+ assertEquals(1, spool.pendingCount())
+ assertEquals("3", spool.take()?.entries?.single()?.tag)
+ assertNull(spool.take())
+ }
+
+ @Test
+ fun `corrupt file is skipped without blocking queue`() {
+ val dir = freshDir("corrupt")
+ File(dir, "logspool-1.json").writeText("not a json")
+ val spool = FileLogSpool(dir.absolutePath, 64)
+ spool.write(batch("a"))
+
+ assertEquals("a", spool.take()?.entries?.single()?.tag) // битый удалён, «a» дошёл
+ assertNull(spool.take())
+ assertEquals(0, spool.pendingCount())
+ }
+
+ @Test
+ fun `empty directory is empty spool`() {
+ val dir = freshDir("empty")
+ val spool = FileLogSpool(dir.absolutePath, 64)
+ assertEquals(0, spool.pendingCount())
+ assertNull(spool.peek())
+ assertNull(spool.take())
+ }
+}