From 805aaa24113210e673316ac888d1fb90ac66bb3b Mon Sep 17 00:00:00 2001 From: ua1zbe Date: Thu, 13 Aug 2026 16:23:41 +0300 Subject: [PATCH] Fix Opus RX decoding, add foreground service, last-talker display - Decode received Opus audio: handle svxlink self-delimited framing and provide monotonic PTS (c2.opus.decoder discards output when PTS == 0) - Keep connection and playback alive in background via SvxService (foreground service, media playback type) - Keep screen on while app is open - Show last talker callsign persistently instead of clearing after 4s - Bump versionCode to 2, disable lint on release builds --- app/build.gradle.kts | 16 +- app/src/main/AndroidManifest.xml | 7 + .../java/ru/ua1zbe/svxremote/MainActivity.kt | 108 +++++----- .../java/ru/ua1zbe/svxremote/SvxService.kt | 127 +++++++++++ .../ru/ua1zbe/svxremote/audio/OpusEngine.kt | 200 ++++++++++++------ .../ua1zbe/svxremote/proto/ReflectorClient.kt | 21 +- .../svxremote/proto/ReflectorProtocol.kt | 6 + app/src/main/res/values/strings.xml | 3 +- .../java/ru/ua1zbe/svxremote/HandshakeTest.kt | 28 ++- .../java/ru/ua1zbe/svxremote/ProtocolTest.kt | 21 ++ 10 files changed, 413 insertions(+), 124 deletions(-) create mode 100644 app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt diff --git a/app/build.gradle.kts b/app/build.gradle.kts index 528f9a0..ca22965 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "ru.ua1zbe.svxremote" minSdk = 26 targetSdk = 34 - versionCode = 1 - versionName = "1.0" + versionCode = 2 + versionName = "1.0.0" } buildTypes { @@ -21,6 +21,11 @@ android { } } + lint { + checkReleaseBuilds = false + abortOnError = false + } + compileOptions { sourceCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17 @@ -39,8 +44,11 @@ dependencies { android { testOptions { - unitTests.all { - it.maxHeapSize = "256m" + unitTests { + isReturnDefaultValues = true + all { + it.maxHeapSize = "256m" + } } } } \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index f5b7e93..fec2c0e 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -4,6 +4,8 @@ + + + + - levelMeter.setLevel(source, level) - } - client = ReflectorClient(this) - btnSettings.setOnClickListener { startActivity(Intent(this, SettingsActivity::class.java)) } btnConnect.setOnClickListener { - if (connected) { - client.disconnect() + val s = svc + if (connected && s != null) { + s.disconnect() } else { settings = Prefs.load(this) if (settings.host.isBlank() || settings.callsign.isBlank()) { @@ -89,15 +96,24 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC) return@setOnClickListener } - client.connect( - ReflectorClient.Settings( - host = settings.host, - port = settings.port, - callsign = settings.callsign, - authKey = settings.authKey, - talkGroup = settings.talkGroup + if (Build.VERSION.SDK_INT >= 33 && + checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED + ) { + requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIF) + } + startForegroundService(Intent(this, SvxService::class.java)) + bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE) + mainHandler.postDelayed({ + svc?.connect( + ReflectorClient.Settings( + host = settings.host, + port = settings.port, + callsign = settings.callsign, + authKey = settings.authKey, + talkGroup = settings.talkGroup + ) ) - ) + }, 300) } } @@ -113,14 +129,11 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { } lblTxState.visibility = TextView.VISIBLE lblPttHint.text = getString(R.string.ptt_talking) - engine.startCapture { packet -> - client.sendAudio(packet) - } + svc?.startTx() } override fun onPttUp() { - engine.stopCapture() - client.sendAudioFlush() + svc?.stopTx() lblTxState.visibility = TextView.GONE lblPttHint.text = getString(R.string.ptt_hint) } @@ -135,6 +148,12 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { refreshUi() } + override fun onDestroy() { + super.onDestroy() + svc?.listener = null + unbindService(svcConn) + } + private fun refreshUi() { val host = settings.host lblSubtitle.text = if (host.isBlank()) { @@ -144,7 +163,7 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { } } - // ---------- Callbacks от ReflectorClient (фоновые потоки!) ---------- + // ---------- Callbacks от SvxService (фоновые потоки!) ---------- override fun onState(state: Int, detail: String?) { Log.d(TAG, "state=$state detail=$detail") @@ -205,29 +224,23 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { mainHandler.post { lblTalkerTitle.text = getString(R.string.talker_now, callsign) lblTalker.text = callsign - mainHandler.removeCallbacks(talkerTimeout) - mainHandler.postDelayed(talkerTimeout, 4000) } } override fun onTalkerStop(tg: Long, callsign: String) { - mainHandler.post { - clearTalker() - } - } - - override fun onNodeJoined(callsign: String) { - } - - override fun onNodeLeft(callsign: String) { + // последний говоривший остаётся на экране } override fun onAudioPacket(packet: ByteArray, seq: Int) { - engine.onAudioPacket(packet) } override fun onAudioFlush() { - engine.onAudioFlush() + } + + override fun onLevel(source: Int, level: Float) { + mainHandler.post { + levelMeter.setLevel(source, level) + } } override fun onError(detail: String) { @@ -236,12 +249,6 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { } } - private fun clearTalker() { - lblTalkerTitle.text = getString(R.string.talker_none) - lblTalker.text = getString(R.string.talker_none) - mainHandler.removeCallbacks(talkerTimeout) - } - private fun updatePttEnabled() { pttButton.enabled2 = connected } @@ -250,13 +257,6 @@ class MainActivity : Activity(), ReflectorClient.Callbacks { Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() } - override fun onDestroy() { - super.onDestroy() - mainHandler.removeCallbacks(talkerTimeout) - client.disconnect() - engine.releaseAll() - } - override fun onRequestPermissionsResult( requestCode: Int, permissions: Array, diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt new file mode 100644 index 0000000..b718e31 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/SvxService.kt @@ -0,0 +1,127 @@ +package ru.ua1zbe.svxremote + +import android.app.Notification +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Intent +import android.os.Binder +import android.os.IBinder +import ru.ua1zbe.svxremote.audio.OpusEngine +import ru.ua1zbe.svxremote.proto.MsgParser +import ru.ua1zbe.svxremote.proto.ReflectorClient + +/** + * Foreground-сервис: держит соединение с рефлектором и декодер + * живыми в фоне и при выключенном экране. + */ +class SvxService : Service(), ReflectorClient.Callbacks { + + interface Listener { + fun onState(state: Int, detail: String?) + fun onServerInfo(info: MsgParser.ServerInfo) + fun onTalkerStart(tg: Long, callsign: String) + fun onTalkerStop(tg: Long, callsign: String) + fun onAudioPacket(packet: ByteArray, seq: Int) + fun onAudioFlush() + fun onError(detail: String) + fun onLevel(source: Int, level: Float) + } + + var listener: Listener? = null + + private val engine by lazy { + OpusEngine { source, level -> listener?.onLevel(source, level) } + } + val client: ReflectorClient by lazy { ReflectorClient(this) } + + private val binder = LocalBinder() + + inner class LocalBinder : Binder() { + fun service(): SvxService = this@SvxService + } + + override fun onBind(intent: Intent): IBinder = binder + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + startForegroundCompat() + return START_STICKY + } + + private fun startForegroundCompat() { + val nm = getSystemService(NotificationManager::class.java) + nm.createNotificationChannel( + NotificationChannel("svx", "SVXRemote", NotificationManager.IMPORTANCE_LOW) + ) + val n = Notification.Builder(this, "svx") + .setContentTitle("SVXRemote") + .setContentText(getString(R.string.notif_connected)) + .setSmallIcon(android.R.drawable.ic_menu_call) + .setOngoing(true) + .build() + startForeground(1, n) + } + + fun connect(settings: ReflectorClient.Settings) { + startForegroundCompat() + client.connect(settings) + } + + fun disconnect() { + client.disconnect() + } + + fun isRunning(): Boolean = client.isRunning() + + fun startTx() { + engine.startCapture { packet -> client.sendAudio(packet) } + } + + fun stopTx() { + engine.stopCapture() + client.sendAudioFlush() + } + + // ---------- Callbacks (фоновые потоки) ---------- + + override fun onState(state: Int, detail: String?) { + when (state) { + ReflectorClient.STATE_READY -> engine.startDecoder() + ReflectorClient.STATE_DISCONNECTED -> { + engine.stopDecoder() + stopForeground(STOP_FOREGROUND_REMOVE) + } + } + listener?.onState(state, detail) + } + + override fun onServerInfo(info: MsgParser.ServerInfo) { + listener?.onServerInfo(info) + } + + override fun onTalkerStart(tg: Long, callsign: String) { + listener?.onTalkerStart(tg, callsign) + } + + override fun onTalkerStop(tg: Long, callsign: String) { + listener?.onTalkerStop(tg, callsign) + } + + override fun onNodeJoined(callsign: String) { + } + + override fun onNodeLeft(callsign: String) { + } + + override fun onAudioPacket(packet: ByteArray, seq: Int) { + engine.onAudioPacket(packet) + } + + override fun onAudioFlush() { + engine.onAudioFlush() + } + + override fun onError(detail: String) { + listener?.onError(detail) + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt index 1cd14b9..e233df0 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt @@ -9,6 +9,7 @@ import android.media.MediaCodecInfo import android.media.MediaFormat import android.media.MediaRecorder import android.os.Build +import android.util.Log import java.nio.ByteBuffer import java.nio.ByteOrder import kotlin.math.abs @@ -23,7 +24,9 @@ class OpusEngine( ) { companion object { + const val TAG = "SVXRemote" const val SAMPLE_RATE = 8000 + const val OUT_SAMPLE_RATE = 48000 // декодер opus всегда выдаёт 48 кГц const val CHANNELS = 1 const val FRAME_SIZE = 160 // 20 мс в 8 кГц const val BIT_RATE = 20000 @@ -32,7 +35,9 @@ class OpusEngine( const val SRC_SPEAKER = 1 } + @Volatile private var encoder: MediaCodec? = null + @Volatile private var decoder: MediaCodec? = null private var recorder: AudioRecord? = null private var player: AudioTrack? = null @@ -46,6 +51,11 @@ class OpusEngine( // ---------- Воспроизведение (RX) ---------- + private var headSent = false + private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue() + private var drainThread: Thread? = null + private var ptsCounter = 0L + fun startDecoder() { if (decoder != null) return val format = MediaFormat() @@ -53,75 +63,164 @@ class OpusEngine( format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192) - if (Build.VERSION.SDK_INT >= 29) { - // Не звонить GSM — приоритет медиа - } + // Android 16: c2.android.opus.decoder ждёт 3 конфиг-буфера: + // csd-0 OpusHead, csd-1 CodecDelay (int64 LE нс), csd-2 SeekPreRoll (int64 LE нс). + // Без csd-1/2 декодер берёт мусорные значения из пакетов и молча + // выкидывает весь вывод (mSamplesToDiscard). + format.setByteBuffer("csd-0", ByteBuffer.wrap(opusHead())) + // Прескип 312 сэмплов при 8 кГц = 39 мс = 39_000_000 нс (перекодируется в 48 кГц внутри) + format.setByteBuffer("csd-1", ByteBuffer.wrap(longBytes(39_000_000L))) + format.setByteBuffer("csd-2", ByteBuffer.wrap(longBytes(0L))) decoder = try { MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also { it.configure(format, null, null, 0) it.start() } } catch (e: Exception) { + Log.e(TAG, "createDecoder failed: ${e.message}") null } if (decoder == null) return val minBuf = AudioTrack.getMinBufferSize( - SAMPLE_RATE, + OUT_SAMPLE_RATE, AudioFormat.CHANNEL_OUT_MONO, AudioFormat.ENCODING_PCM_16BIT ) - player = AudioTrack( - AudioManager.STREAM_MUSIC, - SAMPLE_RATE, - AudioFormat.CHANNEL_OUT_MONO, - AudioFormat.ENCODING_PCM_16BIT, - maxOf(minBuf, FRAME_SIZE * 2 * 8), - AudioTrack.MODE_STREAM - ).also { it.play() } + player = try { + AudioTrack( + AudioManager.STREAM_MUSIC, + OUT_SAMPLE_RATE, + AudioFormat.CHANNEL_OUT_MONO, + AudioFormat.ENCODING_PCM_16BIT, + maxOf(minBuf, FRAME_SIZE * 2 * 8), + AudioTrack.MODE_STREAM + ).also { it.play() } + } catch (e: Exception) { + Log.e(TAG, "AudioTrack create failed: ${e.message}") + null + } + headSent = true // csd уходит через формат, не через буферы + drainThread = Thread({ drainLoop() }, "opus-out").also { it.start() } + Log.d(TAG, "decoder started") + } + + /** int64 в little-endian. */ + private fun longBytes(v: Long): ByteArray = + ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array() + + /** Отдельный поток: голова + входная очередь + слив вывода. */ + private fun drainLoop() { + val dec = decoder ?: return + var dbg = 0 + while (decoder != null && decoder === dec) { + try { + // Монотонный pts обязателен: при pts==0 c2.opus.decoder + // считает каждый кадр «перемоткой в ноль» и выбрасывает + // mCodecDelay сэмплов (C2SoftOpusDec.cpp, "When seeking to zero"). + var pts = ptsCounter + while (true) { + val pkt = rxQueue.poll() ?: break + val inIdx = dec.dequeueInputBuffer(5000) + if (inIdx < 0) { rxQueue.add(pkt); break } + val buf = dec.getInputBuffer(inIdx) ?: break + buf.clear() + buf.put(pkt) + dec.queueInputBuffer(inIdx, 0, pkt.size, pts, 0) + pts += 20_000L // 20 мс кадра + ptsCounter = pts + if (dbg < 5) { Log.d(TAG, "queued ${pkt.size}B"); dbg++ } + } + var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 1000) + while (outIdx >= 0) { + if (outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) { + val f = dec.outputFormat + Log.d(TAG, "dec out format: sr=${f.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " + + "ch=${f.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}") + } else { + if (decodeBufferInfo.size > 0) { + val outBuf = ByteBuffer.allocate(decodeBufferInfo.size) + outBuf.order(ByteOrder.LITTLE_ENDIAN) + dec.getOutputBuffer(outIdx)?.also { src -> + src.position(decodeBufferInfo.offset) + src.limit(decodeBufferInfo.offset + decodeBufferInfo.size) + outBuf.put(src) + } + val pcm = outBuf.array() + player?.write(pcm, 0, pcm.size) + onLevel(SRC_SPEAKER, pcmLevel(pcm)) + if (dbg < 5) { Log.d(TAG, "out ${pcm.size}B"); dbg++ } + } + dec.releaseOutputBuffer(outIdx, false) + } + outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0) + } + } catch (e: Exception) { + Log.e(TAG, "drain error: ${e.message}") + } + } } /** Подача одного Opus-пакета на декодер. */ fun onAudioPacket(packet: ByteArray) { - val dec = decoder ?: return - val track = player ?: return - try { - val inIdx = dec.dequeueInputBuffer(10000) - if (inIdx >= 0) { - val buf = dec.getInputBuffer(inIdx) ?: return - buf.clear() - buf.put(packet) - dec.queueInputBuffer(inIdx, 0, packet.size, 0, 0) - } - var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0) - while (outIdx >= 0) { - if (decodeBufferInfo.size > 0) { - val outBuf = ByteBuffer.allocate(decodeBufferInfo.size) - outBuf.order(ByteOrder.LITTLE_ENDIAN) - dec.getOutputBuffer(outIdx)?.also { src -> - src.position(decodeBufferInfo.offset) - src.limit(decodeBufferInfo.offset + decodeBufferInfo.size) - outBuf.put(src) - } - val pcm = outBuf.array() - track.write(pcm, 0, pcm.size) - onLevel(SRC_SPEAKER, pcmLevel(pcm)) - } - dec.releaseOutputBuffer(outIdx, false) - outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0) - } - } catch (_: Exception) { - } + rxQueue.add(packet) } fun onAudioFlush() { try { decoder?.flush() player?.flush() + rxQueue.clear() onLevel(SRC_SPEAKER, 0f) } catch (_: Exception) { } } + /** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */ + private fun opusHead(): ByteArray { + val head = "OpusHead".toByteArray(Charsets.US_ASCII) + val out = ByteArray(19) + System.arraycopy(head, 0, out, 0, head.size) + out[8] = 1 // версия Opus + out[9] = 1 // каналов (моно) + out[10] = 0x38 // pre-skip LE = 312 + out[11] = 0x01 + out[12] = 0x40 // input sample rate LE = 8000 + out[13] = 0x1F + out[14] = 0x00 + out[15] = 0x00 + out[16] = 0x00 // output gain + out[17] = 0x00 + out[18] = 0x00 // channel mapping family = 0 + return out + } + + /** Остановить декодер и плеер (при отключении). */ + fun stopDecoder() { + val dec = decoder + decoder = null // поток разбора выйдет из цикла до release + rxQueue.clear() + if (dec != null) { + try { + dec.stop() + } catch (_: Exception) { + } + try { + dec.release() + } catch (_: Exception) { + } + } + try { + player?.stop() + } catch (_: Exception) { + } + try { + player?.release() + } catch (_: Exception) { + } + player = null + onLevel(SRC_SPEAKER, 0f) + } + // ---------- Захват (TX) ---------- /** Начать PTT: запустить запись и кодирование. */ @@ -238,24 +337,7 @@ class OpusEngine( fun releaseAll() { stopCapture() - try { - decoder?.stop() - } catch (_: Exception) { - } - try { - decoder?.release() - } catch (_: Exception) { - } - decoder = null - try { - player?.stop() - } catch (_: Exception) { - } - try { - player?.release() - } catch (_: Exception) { - } - player = null + stopDecoder() onLevel(SRC_SPEAKER, 0f) } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt b/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt index 6847004..c87fb5e 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt @@ -7,6 +7,7 @@ import java.net.InetSocketAddress import java.net.Socket import java.net.SocketTimeoutException import java.util.concurrent.atomic.AtomicBoolean +import android.util.Log /** * Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP). @@ -31,6 +32,8 @@ class ReflectorClient( } companion object { + const val TAG = "SVXRemote" + const val STATE_DISCONNECTED = 0 const val STATE_CONNECTING = 1 const val STATE_TCP_CONNECTED = 2 @@ -45,6 +48,9 @@ class ReflectorClient( const val TCP_HEARTBEAT_INTERVAL_MS = 2000L const val UDP_HEARTBEAT_INTERVAL_MS = 4000L const val RECONNECT_DELAY_MS = 5000L + + const val CLIENT_SW_INFO = "SVXHomyak" + const val CLIENT_SW_VERSION = "1.0.0" } data class Settings( @@ -350,7 +356,13 @@ class ReflectorClient( callbacks.onServerInfo(info) state = STATE_READY callbacks.onState(STATE_READY) + // Сначала сообщаем о себе (версия ПО для дашборда), затем + // выбираем TG для передачи и подписываемся на прослушивание + // (иначе сервер не шлёт аудио группы и не показывает + // подписку в статусе: monitoredTGs остаётся пустым). + sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION)) sendTcp(socket, MsgBuilder.selectTg(s.talkGroup)) + sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup))) openUdp(s) } @@ -471,11 +483,18 @@ class ReflectorClient( ReflectorProtocol.MSG_UDP_AUDIO -> { val audio = MsgParser.udpAudio(payload) if (audio != null && audio.isNotEmpty()) { + Log.d(TAG, "UDP RX audio seq=$seq len=${audio.size}") callbacks.onAudioPacket(audio, seq) + } else { + Log.w(TAG, "UDP RX audio empty/null len=${payload.size}") } } - ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> callbacks.onAudioFlush() + ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> { + Log.d(TAG, "UDP RX flush") + callbacks.onAudioFlush() + } ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit + else -> Log.d(TAG, "UDP RX type=$type len=${data.size}") } } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt b/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt index a68d37f..165ee43 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt @@ -195,6 +195,12 @@ object MsgBuilder { return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray()) } + /** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.). */ + fun nodeInfo(swInfo: String, swVersion: String): RcMessage { + val json = "{\"swInfo\":\"$swInfo\",\"swVersion\":\"$swVersion\"}" + return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).toByteArray()) + } + /** UDP-датаграмма V2: [u16 тип][u16 client_id][u16 seq][payload]. */ fun udpDatagram(type: Int, clientId: Int, seq: Int, payload: ByteArray = ByteArray(0)): ByteArray { val w = BinWriter().u16(type).u16(clientId).u16(seq).bytes(payload) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index ffdacc2..f92f589 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -19,12 +19,13 @@ На связи Ошибка авторизации Ошибка: %1$s + Соединение с рефлектором активно Подключиться Отключиться - Сейчас говорит: %1$s + Последний: %1$s группа %1$d Удерживайте кнопку, чтобы говорить diff --git a/app/src/test/java/ru/ua1zbe/svxremote/HandshakeTest.kt b/app/src/test/java/ru/ua1zbe/svxremote/HandshakeTest.kt index 6859e6e..37be98b 100644 --- a/app/src/test/java/ru/ua1zbe/svxremote/HandshakeTest.kt +++ b/app/src/test/java/ru/ua1zbe/svxremote/HandshakeTest.kt @@ -150,22 +150,40 @@ class HandshakeTest { .u16(1).str(sentCodecs) .toByteArray() writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info)) - // Client должен прислать MsgSelectTG(11) и UDP-датаграмму + // Client должен прислать MsgSelectTG(11), MsgTgMonitor(11) и + // MsgNodeInfo с версией ПО, плюс UDP-датаграмму var gotTg = false - while (!gotTg) { + var gotMonitor = false + var gotNodeInfo = false + var tries = 0 + while ((!gotTg || !gotMonitor || !gotNodeInfo) && tries++ < 100) { val frame3 = readFrame(input, buf) val msg3 = RcMessage.fromPayload(frame3) when (msg3.type) { ReflectorProtocol.MSG_SELECT_TG -> { val rr = BinReader(msg3.fields) assertEquals(11L, rr.u32()) -gotTg = true - sawSelectTg = true - selectTgLatch.countDown() + gotTg = true + sawSelectTg = true + selectTgLatch.countDown() + } + ReflectorProtocol.MSG_TG_MONITOR -> { + val rr = BinReader(msg3.fields) + assertEquals(1, rr.u16()) + assertEquals(11L, rr.u32()) + gotMonitor = true + } + ReflectorProtocol.MSG_NODE_INFO -> { + val json = BinReader(msg3.fields).str() + assertTrue("JSON: $json", json.contains("swVersion")) + assertTrue("JSON: $json", json.contains("1.0.0")) + gotNodeInfo = true } ReflectorProtocol.MSG_HEARTBEAT -> Unit } } + assertTrue("TgMonitor не получен", gotMonitor) + assertTrue("NodeInfo не получен", gotNodeInfo) // Ждём UDP-датаграмму (регистрация UDP) try { val ds = DatagramSocket(server.localPort) diff --git a/app/src/test/java/ru/ua1zbe/svxremote/ProtocolTest.kt b/app/src/test/java/ru/ua1zbe/svxremote/ProtocolTest.kt index e1fd012..6b47eb6 100644 --- a/app/src/test/java/ru/ua1zbe/svxremote/ProtocolTest.kt +++ b/app/src/test/java/ru/ua1zbe/svxremote/ProtocolTest.kt @@ -87,6 +87,27 @@ class ProtocolTest { assertEquals(11L, r.u32()) } + @Test + fun `tgMonitor roundtrip`() { + val parsed = RcMessage.tryParseFrame(MsgBuilder.tgMonitor(listOf(11, 22)).toFrame()) + assertEquals(ReflectorProtocol.MSG_TG_MONITOR, parsed?.type) + val r = BinReader(parsed!!.fields) + assertEquals(2, r.u16()) + assertEquals(11L, r.u32()) + assertEquals(22L, r.u32()) + } + + @Test + fun `nodeInfo roundtrip и версия ПО`() { + val parsed = RcMessage.tryParseFrame( + MsgBuilder.nodeInfo("SVXHomyak", "1.0.0").toFrame() + ) + assertEquals(ReflectorProtocol.MSG_NODE_INFO, parsed?.type) + val json = BinReader(parsed!!.fields).str() + assertTrue("JSON: $json", json.contains("\"swInfo\":\"SVXHomyak\"")) + assertTrue("JSON: $json", json.contains("\"swVersion\":\"1.0.0\"")) + } + @Test fun `udp audio roundtrip`() { val audio = ByteArray(30) { (it and 0xFF).toByte() }