13 Commits

11 changed files with 694 additions and 148 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote" applicationId = "ru.ua1zbe.svxremote"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 6 versionCode = 7
versionName = "1.0.4" versionName = "1.0.5"
} }
buildTypes { buildTypes {
@@ -21,6 +21,10 @@ android {
} }
} }
buildFeatures {
buildConfig = true
}
lint { lint {
checkReleaseBuilds = false checkReleaseBuilds = false
abortOnError = false abortOnError = false

View File

@@ -20,6 +20,7 @@ import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.ImageButton import android.widget.ImageButton
@@ -41,9 +42,11 @@ class MainActivity : Activity(), SvxService.Listener {
companion object { companion object {
private const val TAG = "SVXRemote" private const val TAG = "SVXRemote"
private const val TALK_LOG_MAX = 15
} }
private lateinit var statusChip: TextView private lateinit var statusChip: TextView
private lateinit var lblAppTitle: TextView
private lateinit var lblSubtitle: TextView private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView private lateinit var lblTg: TextView
@@ -62,9 +65,16 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var btnMonitor: ImageButton private lateinit var btnMonitor: ImageButton
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null
private data class TalkLogEntry(val time: Long, val callsign: String, val tg: Long)
private val talkLog = ArrayDeque<TalkLogEntry>()
private var connected = false private var connected = false
private var monitoring = false private var monitoring = false
private var connectedSettings: ConnectionSettings? = null
private var priorityActive = false
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11) private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
private var svc: SvxService? = null private var svc: SvxService? = null
@@ -90,6 +100,7 @@ class MainActivity : Activity(), SvxService.Listener {
checkForUpdates() checkForUpdates()
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblAppTitle = findViewById(R.id.lbl_app_title)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
lblTalkerTitle = findViewById(R.id.lbl_talker_title) lblTalkerTitle = findViewById(R.id.lbl_talker_title)
lblTalker = findViewById(R.id.lbl_talker) lblTalker = findViewById(R.id.lbl_talker)
@@ -130,6 +141,26 @@ class MainActivity : Activity(), SvxService.Listener {
lblCallsign.setOnClickListener { showNodesDialog() } lblCallsign.setOnClickListener { showNodesDialog() }
lblTalker.setOnClickListener { showTalkLogDialog() }
// Удержание на названии программы (> 2 с) — выбор профиля настроек
lblAppTitle.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val r = Runnable { showProfileDialog() }
titleHoldRunnable = r
mainHandler.postDelayed(r, 2000)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
titleHoldRunnable?.let { mainHandler.removeCallbacks(it) }
titleHoldRunnable = null
true
}
else -> true
}
}
connectZone.setOnClickListener { connectZone.setOnClickListener {
if (connected) { if (connected) {
svc?.disconnect() svc?.disconnect()
@@ -159,6 +190,7 @@ class MainActivity : Activity(), SvxService.Listener {
} }
override fun onPttUp() { override fun onPttUp() {
Log.d("SVXRemote", "PTT up")
svc?.stopTx() svc?.stopTx()
lblPttHint.text = getString(R.string.hold_to_talk) lblPttHint.text = getString(R.string.hold_to_talk)
lblTxState.text = lblTxState.text =
@@ -174,7 +206,22 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val prev = settings
settings = Prefs.load(this) settings = Prefs.load(this)
if (prev != settings && connectedSettings != null) {
if (settings.host.isBlank() || settings.callsign.isBlank()) {
monitoring = false
priorityActive = false
updateMonitorButton()
updatePttEnabled()
svc?.disconnect()
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
refreshUi() refreshUi()
if (!autoConnectAttempted && if (!autoConnectAttempted &&
settings.host.isNotBlank() && settings.host.isNotBlank() &&
@@ -291,17 +338,36 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onTalkerStart(tg: Long, callsign: String) { override fun onTalkerStart(tg: Long, callsign: String) {
mainHandler.post { mainHandler.post {
lblTalkerTitle.text = if (monitoring) { talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg))
getString(R.string.talker_mon, callsign, tg) while (talkLog.size > TALK_LOG_MAX) {
} else { talkLog.removeFirst()
getString(R.string.talker_now, callsign) }
val priority = monitoring && settings.groups.firstOrNull() == tg
if (priority) {
priorityActive = true
}
if (priority || !priorityActive) {
svc?.selectGroup(tg)
lblTalkerTitle.text = when {
priority -> getString(R.string.talker_mon_priority, callsign, tg)
monitoring -> getString(R.string.talker_mon, callsign, tg)
else -> getString(R.string.talker_now, callsign)
}
lblTalkerTitle.setTextColor(
if (priority) getColor(R.color.danger) else getColor(R.color.text_secondary)
)
lblTalker.text = callsign
} }
lblTalker.text = callsign
} }
} }
override fun onTalkerStop(tg: Long, callsign: String) { override fun onTalkerStop(tg: Long, callsign: String) {
// последний говоривший остаётся на экране // последний говоривший остаётся на экране
mainHandler.post {
if (monitoring && settings.groups.firstOrNull() == tg) {
priorityActive = false
}
}
} }
override fun onNodeJoined(callsign: String, tg: Long?) { override fun onNodeJoined(callsign: String, tg: Long?) {
@@ -397,6 +463,7 @@ class MainActivity : Activity(), SvxService.Listener {
/** Запустить сервис и подключиться с текущими настройками. */ /** Запустить сервис и подключиться с текущими настройками. */
private fun connect() { private fun connect() {
connectedSettings = settings
startForegroundService(Intent(this, SvxService::class.java)) startForegroundService(Intent(this, SvxService::class.java))
bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE) bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE)
mainHandler.postDelayed({ mainHandler.postDelayed({
@@ -415,7 +482,14 @@ class MainActivity : Activity(), SvxService.Listener {
/** Включить/выключить режим мониторинга групп с переподключением. */ /** Включить/выключить режим мониторинга групп с переподключением. */
private fun toggleMonitoring() { private fun toggleMonitoring() {
monitoring = !monitoring val enabling = !monitoring
if (enabling) {
showToast(R.string.monitoring_dev_warning, 10000)
} else {
showToast(R.string.monitor_off)
}
monitoring = enabling
priorityActive = false
updateMonitorButton() updateMonitorButton()
updatePttEnabled() updatePttEnabled()
if (connected) { if (connected) {
@@ -426,6 +500,54 @@ class MainActivity : Activity(), SvxService.Listener {
} }
} }
/** Окно с сообщением на заданное время (мс). */
private fun showToast(resId: Int, durationMs: Int = Toast.LENGTH_SHORT) {
val toast = Toast.makeText(this, resId, Toast.LENGTH_SHORT)
toast.show()
if (durationMs > Toast.LENGTH_SHORT) {
mainHandler.postDelayed({ toast.cancel() }, durationMs.toLong())
}
}
/** Список профилей: выбор сразу подключается с новыми настройками. */
private fun showProfileDialog() {
val profiles = Prefs.loadProfiles(this)
val active = Prefs.activeProfileIndex(this)
val items = profiles.mapIndexed { i, p ->
val s = p.settings
val detail = if (s.callsign.isBlank()) {
getString(R.string.profile_empty)
} else {
"${s.callsign} @ ${s.host}:${s.port}"
}
val marker = if (i == active) getString(R.string.profile_current) else ""
"${p.name} — $detail$marker"
}.toTypedArray()
AlertDialog.Builder(this)
.setTitle(getString(R.string.profiles_title))
.setItems(items) { _, which ->
if (which != active) {
Prefs.setActiveProfile(this, which)
settings = Prefs.load(this)
monitoring = false
priorityActive = false
updateMonitorButton()
updatePttEnabled()
refreshUi()
if (settings.host.isBlank() || settings.callsign.isBlank()) {
toast(getString(R.string.toast_need_settings))
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
}
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
private fun updateMonitorButton() { private fun updateMonitorButton() {
val active = monitoring val active = monitoring
btnMonitor.imageTintList = ColorStateList.valueOf( btnMonitor.imageTintList = ColorStateList.valueOf(
@@ -436,13 +558,16 @@ class MainActivity : Activity(), SvxService.Listener {
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor) getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
} }
/** Переключиться на другую группу: пересохранить и переподключиться. */ /** Переключиться на другую группу: отправить серверу по живому соединению. */
private fun switchGroup(tg: Long) { private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg) settings = settings.copy(talkGroup = tg)
connectedSettings = settings
Prefs.save(this, settings) Prefs.save(this, settings)
refreshUi() refreshUi()
svc?.disconnect() svc?.selectGroup(tg)
mainHandler.postDelayed({ connect() }, 500) if (!monitoring) {
svc?.setMonitorTgs(listOf(tg))
}
} }
private fun monitorTgLabel(): String = private fun monitorTgLabel(): String =
@@ -467,6 +592,23 @@ class MainActivity : Activity(), SvxService.Listener {
.show() .show()
} }
/** Лог последних выходов на передачу (до 15, время — позывной — группа). */
private fun showTalkLogDialog() {
if (talkLog.isEmpty()) {
toast(getString(R.string.talk_log_empty))
return
}
val fmt = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault())
val items = talkLog.map { e ->
getString(R.string.talk_log_entry, fmt.format(java.util.Date(e.time)), e.callsign, e.tg)
}.toTypedArray()
AlertDialog.Builder(this)
.setTitle(R.string.talk_log_title)
.setItems(items, null)
.setPositiveButton(R.string.close, null)
.show()
}
private fun showGroupsDialog() { private fun showGroupsDialog() {
val groups = (settings.groups + settings.talkGroup).distinct() val groups = (settings.groups + settings.talkGroup).distinct()
if (groups.isEmpty()) { if (groups.isEmpty()) {

View File

@@ -2,26 +2,34 @@ package ru.ua1zbe.svxremote
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.CheckBox import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import android.widget.Spinner
import android.widget.Toast import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.prefs.Profile
class SettingsActivity : Activity() { class SettingsActivity : Activity() {
private lateinit var spinnerProfile: Spinner
private lateinit var inputHost: EditText private lateinit var inputHost: EditText
private lateinit var inputPort: EditText private lateinit var inputPort: EditText
private lateinit var inputCallsign: EditText private lateinit var inputCallsign: EditText
private lateinit var inputAuthKey: EditText private lateinit var inputAuthKey: EditText
private lateinit var inputTg: EditText private lateinit var inputTg: EditText
private lateinit var inputGroups: EditText private lateinit var inputGroups: EditText
private var currentProfile = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
spinnerProfile = findViewById(R.id.spinner_profile)
inputHost = findViewById(R.id.input_host) inputHost = findViewById(R.id.input_host)
inputPort = findViewById(R.id.input_port) inputPort = findViewById(R.id.input_port)
inputCallsign = findViewById(R.id.input_callsign) inputCallsign = findViewById(R.id.input_callsign)
@@ -31,13 +39,24 @@ class SettingsActivity : Activity() {
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts) val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this) chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
val s = Prefs.load(this) val profileNames = (1..Prefs.PROFILE_COUNT)
inputHost.setText(s.host) .map { getString(R.string.profile_name, it) }
inputPort.setText(s.port.toString()) spinnerProfile.adapter = ArrayAdapter(
inputCallsign.setText(s.callsign) this, android.R.layout.simple_spinner_dropdown_item, profileNames
inputAuthKey.setText(s.authKey) )
inputTg.setText(s.talkGroup.toString()) currentProfile = Prefs.activeProfileIndex(this)
inputGroups.setText(s.groups.joinToString(", ")) spinnerProfile.setSelection(currentProfile)
spinnerProfile.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (position != currentProfile) {
currentProfile = position
loadFields(Prefs.loadProfile(this@SettingsActivity, position).settings)
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
loadFields(Prefs.loadProfile(this, currentProfile).settings)
findViewById<Button>(R.id.btn_save).setOnClickListener { findViewById<Button>(R.id.btn_save).setOnClickListener {
val host = inputHost.text.toString().trim() val host = inputHost.text.toString().trim()
@@ -55,9 +74,9 @@ class SettingsActivity : Activity() {
return@setOnClickListener return@setOnClickListener
} }
Prefs.save( val profile = Profile(
this, name = getString(R.string.profile_name, currentProfile + 1),
ConnectionSettings( settings = ConnectionSettings(
host = host, host = host,
port = port.coerceIn(1, 65535), port = port.coerceIn(1, 65535),
callsign = callsign, callsign = callsign,
@@ -66,13 +85,24 @@ class SettingsActivity : Activity() {
groups = groups groups = groups
) )
) )
Prefs.saveProfile(this, currentProfile, profile)
Prefs.setActiveProfile(this, currentProfile)
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked) Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
toast(getString(R.string.saved)) toast(getString(R.string.saved))
finish() finish()
} }
} }
private fun loadFields(s: ConnectionSettings) {
inputHost.setText(s.host)
inputPort.setText(s.port.toString())
inputCallsign.setText(s.callsign)
inputAuthKey.setText(s.authKey)
inputTg.setText(s.talkGroup.toString())
inputGroups.setText(s.groups.joinToString(", "))
}
private fun toast(msg: String) { private fun toast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
} }
} }

View File

@@ -8,6 +8,7 @@ import android.app.Service
import android.content.Intent import android.content.Intent
import android.os.Binder import android.os.Binder
import android.os.IBinder import android.os.IBinder
import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -87,13 +88,28 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun isRunning(): Boolean = client.isRunning() fun isRunning(): Boolean = client.isRunning()
fun selectGroup(tg: Long) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.selectGroup(tg) }.start()
}
fun setMonitorTgs(tgs: List<Long>) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.setMonitorTgs(tgs) }.start()
}
fun startTx() { fun startTx() {
engine.startCapture { packet -> client.sendAudio(packet) } engine.startCapture { packet -> client.sendAudio(packet) }
} }
fun stopTx() { fun stopTx() {
engine.stopCapture() Log.d("SVXRemote", "stopTx")
client.sendAudioFlush() // В фоне: сеть запрещена в главном потоке (NetworkOnMainThreadException),
// а join потока захвата блокировал бы UI
Thread {
engine.stopCapture()
client.sendAudioFlush()
}.start()
} }
// ---------- Callbacks (фоновые потоки) ---------- // ---------- Callbacks (фоновые потоки) ----------
@@ -165,4 +181,4 @@ class SvxService : Service(), ReflectorClient.Callbacks {
override fun onError(detail: String) { override fun onError(detail: String) {
listener?.onError(detail) listener?.onError(detail)
} }
} }

View File

@@ -47,40 +47,36 @@ class OpusEngine(
private var recThread: Thread? = null private var recThread: Thread? = null
private val encodeBufferInfo = MediaCodec.BufferInfo() private val encodeBufferInfo = MediaCodec.BufferInfo()
private val decodeBufferInfo = MediaCodec.BufferInfo()
// ---------- Воспроизведение (RX) ---------- // ---------- Воспроизведение (RX) ----------
private var headSent = false
private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue<ByteArray>() private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue<ByteArray>()
private var drainThread: Thread? = null private val freeInputs = java.util.concurrent.ConcurrentLinkedQueue<Int>()
private var ptsCounter = 0L private var ptsCounter = 0L
private var receivedAny = false
private var concealLogs = 0
private var dbgOut = 0
private var playStarted = false
private var gateStart = 0L
private val jitterTarget = 6 // ≈ 120 мс накапливаем до первого слива
private val configBuffers = ArrayList<ByteArray>()
private var configIdx = 0
fun startDecoder() { fun startDecoder() {
if (decoder != null) return if (decoder != null) return
val format = MediaFormat() val format = MediaFormat()
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS) format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) // ВАЖНО: c2.android.opus.decoder (Android 16) выводит только 48 кГц
// (kRate=48000); KEY_SAMPLE_RATE=8000 вызывает конфликт конфига
// («We have a failed config») и фатальную ошибку кодера.
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192) // Android 16: c2.android.opus.decoder читает конфиг (OpusHead + codec
// Android 16: c2.android.opus.decoder ждёт 3 конфиг-буфера: // delay + seek preroll) ТОЛЬКО из первых входных буферов
// csd-0 OpusHead, csd-1 CodecDelay (int64 LE нс), csd-2 SeekPreRoll (int64 LE нс). // (см. queueDecoderConfig); csd-ключи MediaFormat он игнорирует, и без
// Без csd-1/2 декодер берёт мусорные значения из пакетов и молча // конфига первый же аудио-кадр ломает декодер (C2_CORRUPTED).
// выкидывает весь вывод (mSamplesToDiscard). if (!startDecoderCodec(format)) return
format.setByteBuffer("csd-0", ByteBuffer.wrap(opusHead())) Log.d(TAG, "decoder started, config=${configBuffers.size}B")
// Прескип 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( val minBuf = AudioTrack.getMinBufferSize(
OUT_SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
@@ -92,89 +88,259 @@ class OpusEngine(
OUT_SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, FRAME_SIZE * 2 * 8), maxOf(minBuf, 20000), // ~208 мс — буфер AudioTrack и есть джиттер-буфер
AudioTrack.MODE_STREAM AudioTrack.MODE_STREAM
).also { it.play() } ) // play() включаем на первом выходном кадре — буфер успевает накопиться
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "AudioTrack create failed: ${e.message}") Log.e(TAG, "AudioTrack create failed: ${e.message}")
null null
} }
headSent = true // csd уходит через формат, не через буферы concealLogs = 0
drainThread = Thread({ drainLoop() }, "opus-out").also { it.start() } dbgOut = 0
ptsCounter = 20_000L // первый кадр: pts!=0, иначе декодер повторно сбросит codecDelay
Log.d(TAG, "decoder started") Log.d(TAG, "decoder started")
} }
/** Создать и запустить только декодер (плеер не трогаем). */
private fun startDecoderCodec(format: MediaFormat): Boolean {
val dec = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.setCallback(decoderCallback)
it.configure(format, null, null, 0)
queueDecoderConfig() // готовим конфиг ДО start: колбэки уйдут после
}
} catch (e: Exception) {
Log.e(TAG, "createDecoder failed: ${e.message}")
null
}
if (dec == null) return false
// decoder назначаем ДО start(): колбэки нового кодека сразу проходят
// проверку codec !== decoder, иначе первый конфиг-колбэк отбрасывается
// и декодер навсегда остаётся без конфига
decoder = dec
freeInputs.clear()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
try {
dec.start()
} catch (e: Exception) {
Log.e(TAG, "decoder start failed: ${e.message}")
decoder = null
try {
dec.release()
} catch (_: Exception) {
}
return false
}
return true
}
/** Асинхронные колбэки кодера: подача входных кадров + слив вывода. */
private val decoderCallback = object : MediaCodec.Callback() {
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.e(TAG, "decoder error: ${e.message} code=${e.errorCode} transient=${e.isTransient}")
}
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
// Конфиг (csd) первыми входными буферами — в async-режиме нельзя
// использовать dequeueInputBuffer, только колбэки
if (configIdx < configBuffers.size) {
val data = configBuffers[configIdx++]
try {
val buf = codec.getInputBuffer(index) ?: return
buf.clear()
buf.put(data)
codec.queueInputBuffer(index, 0, data.size, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)
Log.d(TAG, "cfg ${data.size}B")
} catch (e: Exception) {
Log.e(TAG, "cfg error: ${e.message}")
}
return
}
val pkt = rxQueue.poll()
when {
// Джиттер-гейт: пока не накопили jitterTarget кадров (или не
// вышло 80 мс с первого пакета) — не кормим декодер, чтобы
// буфер AudioTrack успел заполниться до старта воспроизведения
pkt != null && gateOpen() -> feedDecoder(codec, index, pkt)
pkt != null -> {
rxQueue.add(pkt)
freeInputs.add(index)
}
receivedAny -> {
if (concealLogs < 3) {
Log.d(TAG, "conceal (no packet)")
concealLogs++
}
feedDecoder(codec, index, silenceFrame()) // скрытие потерь
}
else -> freeInputs.add(index) // ждём первый пакет
}
}
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
try {
if (info.size > 0) {
val outBuf = ByteBuffer.allocate(info.size)
outBuf.order(ByteOrder.LITTLE_ENDIAN)
codec.getOutputBuffer(index)?.also { src ->
src.position(info.offset)
src.limit(info.offset + info.size)
outBuf.put(src)
}
val pcm = outBuf.array()
if (!playStarted) {
playStarted = true
Log.d(TAG, "play start")
try {
player?.play()
} catch (e: Exception) {
Log.e(TAG, "play error: ${e.message}")
}
}
player?.write(pcm, 0, pcm.size)
onLevel(SRC_SPEAKER, pcmLevel(pcm))
if (dbgOut < 5) {
Log.d(TAG, "out ${pcm.size}B")
dbgOut++
}
}
} catch (e: Exception) {
Log.e(TAG, "out error: ${e.message}")
} finally {
try {
codec.releaseOutputBuffer(index, false)
} catch (_: Exception) {
}
}
}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.d(TAG, "dec out format: sr=${format.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " +
"ch=${format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}")
}
}
private fun feedDecoder(codec: MediaCodec, index: Int, pkt: ByteArray) {
try {
val buf = codec.getInputBuffer(index) ?: return
buf.clear()
buf.put(pkt)
codec.queueInputBuffer(index, 0, pkt.size, ptsCounter, 0)
ptsCounter += 20_000L // 20 мс кадра
if (concealLogs < 3 && rxQueue.isEmpty() && pkt === silenceFrame()) {
Log.d(TAG, "conceal (no packet)")
concealLogs++
}
} catch (e: Exception) {
Log.e(TAG, "feed error: ${e.message}")
}
}
/**
* Android 16: c2.android.opus.decoder читает конфиг из первых входных
* буферов. Единый CSD-блоб (формат AOSP WriteOpusHeaders): OpusHead (19
* байт) + codecDelay (int64 LE нс) + seekPreroll (int64 LE нс) — БЕЗ
* u32-префиксов длины. После него декодер уже сконфигурирован; лишние
* конфиг-буферы игнорируются как повторный CSD, поэтому шлём и их.
*/
private fun queueDecoderConfig() {
configBuffers.clear()
try {
val head = opusHead()
val delay = longBytes(39_000_000L) // прескип 312 сэмплов при 8 кГц = 39 мс
val preroll = longBytes(0L)
val w = java.io.ByteArrayOutputStream()
w.write(head)
w.write(delay)
w.write(preroll)
configBuffers.add(w.toByteArray())
configBuffers.add(delay)
configBuffers.add(preroll)
configIdx = 0
} catch (_: Exception) {
}
}
/** int64 в little-endian. */ /** int64 в little-endian. */
private fun longBytes(v: Long): ByteArray = private fun longBytes(v: Long): ByteArray =
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array() ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array()
/** Отдельный поток: голова + входная очередь + слив вывода. */ /** Подача одного Opus-пакета на декодер (с UDP-потока). */
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) { fun onAudioPacket(packet: ByteArray) {
rxQueue.add(packet) rxQueue.add(packet)
receivedAny = true
if (gateStart == 0L) gateStart = System.currentTimeMillis()
if (!gateOpen()) return
val idx = freeInputs.poll() ?: return // если слот свободен — кормим сразу
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) }
}
/** Джиттер-гейт открыт: накопили кадры или вышло 80 мс с первого пакета. */
private fun gateOpen(): Boolean {
if (playStarted) return true
if (rxQueue.size >= jitterTarget) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 80
} }
fun onAudioFlush() { fun onAudioFlush() {
try { try {
decoder?.flush() // MediaCodec.flush() возвращает входные буферы, но уже выданные индексы
// остаются у нас в freeInputs и дублируются колбэками «повторной выдачи» —
// queueInputBuffer падает (index not owned by client) и кадры теряются.
// Надёжнее пересоздать декодер: колбэки старого отсекаются проверкой
// codec !== decoder.
player?.flush() player?.flush()
rxQueue.clear() rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
restartDecoder()
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
/** Пересоздать только декодер (плеер и джиттер-буфер сохраняем). */
private fun restartDecoder() {
val dec = decoder
decoder = null
if (dec != null) {
try {
dec.stop()
} catch (_: Exception) {
}
try {
dec.release()
} catch (_: Exception) {
}
}
val format = MediaFormat()
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
if (startDecoderCodec(format)) {
concealLogs = 0
dbgOut = 0
ptsCounter = 20_000L
}
}
/**
* Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке.
* Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM.
*/
private fun silenceFrame(): ByteArray = byteArrayOf(
0x08.toByte(), 0x0B.toByte(), 0xE4.toByte(), 0xB9.toByte(),
0xA0.toByte(), 0xBC.toByte(), 0x84.toByte()
)
/** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */ /** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */
private fun opusHead(): ByteArray { private fun opusHead(): ByteArray {
val head = "OpusHead".toByteArray(Charsets.US_ASCII) val head = "OpusHead".toByteArray(Charsets.US_ASCII)
@@ -197,8 +363,12 @@ class OpusEngine(
/** Остановить декодер и плеер (при отключении). */ /** Остановить декодер и плеер (при отключении). */
fun stopDecoder() { fun stopDecoder() {
val dec = decoder val dec = decoder
decoder = null // поток разбора выйдет из цикла до release decoder = null
rxQueue.clear() rxQueue.clear()
freeInputs.clear()
receivedAny = false
playStarted = false
gateStart = 0L
if (dec != null) { if (dec != null) {
try { try {
dec.stop() dec.stop()
@@ -308,14 +478,43 @@ class OpusEngine(
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
// Дренаж: отдаём пакеты, оставшиеся в кодере после остановки записи
try {
var outIdx = enc.dequeueOutputBuffer(encodeBufferInfo, 0)
while (outIdx >= 0) {
if (encodeBufferInfo.size > 0) {
val outBuf = ByteBuffer.allocate(encodeBufferInfo.size)
enc.getOutputBuffer(outIdx)?.also { src ->
src.position(encodeBufferInfo.offset)
src.limit(encodeBufferInfo.offset + encodeBufferInfo.size)
outBuf.put(src)
}
onPacket(outBuf.array())
}
enc.releaseOutputBuffer(outIdx, false)
outIdx = enc.dequeueOutputBuffer(encodeBufferInfo, 0)
}
} catch (_: Exception) {
}
} }
fun stopCapture() { fun stopCapture() {
if (!encoding && recThread == null) return
encoding = false encoding = false
try { try {
recorder?.stop() recorder?.stop()
} catch (_: Exception) { } catch (_: Exception) {
} }
// Ждём завершения потока захвата: он должен отправить последние пакеты,
// чтобы flush (окончание передачи) ушёл после всего аудио
val t = recThread
recThread = null
if (t != null && t.isAlive) {
try {
t.join(1000)
} catch (_: InterruptedException) {
}
}
try { try {
recorder?.release() recorder?.release()
} catch (_: Exception) { } catch (_: Exception) {
@@ -330,8 +529,6 @@ class OpusEngine(
} catch (_: Exception) { } catch (_: Exception) {
} }
encoder = null encoder = null
recThread?.interrupt()
recThread = null
onLevel(SRC_MIC, 0f) onLevel(SRC_MIC, 0f)
} }

View File

@@ -12,44 +12,118 @@ data class ConnectionSettings(
val groups: List<Long> = emptyList() val groups: List<Long> = emptyList()
) )
/** Профиль настроек: имя + параметры подключения. */
data class Profile(
val name: String,
val settings: ConnectionSettings
)
object Prefs { object Prefs {
private const val PREFS_NAME = "svxremote" private const val PREFS_NAME = "svxremote"
const val PROFILE_COUNT = 5
fun load(context: Context): ConnectionSettings { private fun sp(context: Context): SharedPreferences =
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return ConnectionSettings(
host = sp.getString("host", "") ?: "", /** Индекс активного профиля (0..PROFILE_COUNT-1). */
port = sp.getInt("port", 5300), fun activeProfileIndex(context: Context): Int =
callsign = sp.getString("callsign", "") ?: "", sp(context).getInt("active_profile", 0).coerceIn(0, PROFILE_COUNT - 1)
authKey = sp.getString("auth_key", "") ?: "",
talkGroup = sp.getLong("tg", 11), fun setActiveProfile(context: Context, index: Int) {
groups = (sp.getString("groups", "") ?: "") sp(context).edit().putInt("active_profile", index.coerceIn(0, PROFILE_COUNT - 1)).apply()
.split(',') }
.mapNotNull { it.trim().toLongOrNull() }
.distinct() /** Настройки активного профиля. */
.ifEmpty { listOf(9L, 11L, 12L, 13L) } fun load(context: Context): ConnectionSettings =
loadProfile(context, activeProfileIndex(context)).settings
/** Сохранить в активный профиль. */
fun save(context: Context, s: ConnectionSettings) {
val index = activeProfileIndex(context)
val profile = loadProfile(context, index)
saveProfile(context, index, Profile(profile.name, s))
}
fun loadProfile(context: Context, index: Int): Profile =
loadProfile(sp(context), index.coerceIn(0, PROFILE_COUNT - 1))
fun saveProfile(context: Context, index: Int, profile: Profile) {
val profiles = loadProfiles(context)
profiles[index.coerceIn(0, PROFILE_COUNT - 1)] = profile
saveProfiles(context, profiles)
}
/** Все профили (5 слотов). */
fun loadProfiles(context: Context): MutableList<Profile> {
val prefs = sp(context)
migrateIfNeeded(context, prefs)
return (0 until PROFILE_COUNT).map { loadProfile(prefs, it) }.toMutableList()
}
private fun loadProfile(prefs: SharedPreferences, i: Int): Profile {
val host = prefs.getString("p${i}_host", null)
if (host == null) {
return Profile("Профиль ${i + 1}", ConnectionSettings("", 5300, "", "", 11))
}
return Profile(
name = prefs.getString("p${i}_name", null) ?: "Профиль ${i + 1}",
settings = ConnectionSettings(
host = host,
port = prefs.getInt("p${i}_port", 5300),
callsign = prefs.getString("p${i}_callsign", "") ?: "",
authKey = prefs.getString("p${i}_auth", "") ?: "",
talkGroup = prefs.getLong("p${i}_tg", 11),
groups = (prefs.getString("p${i}_groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
)
) )
} }
fun save(context: Context, s: ConnectionSettings) { private fun saveProfiles(context: Context, profiles: List<Profile>) {
val sp: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val e = sp(context).edit()
sp.edit() profiles.forEachIndexed { i, p ->
.putString("host", s.host.trim()) e.putString("p${i}_name", p.name)
.putInt("port", s.port) .putString("p${i}_host", p.settings.host.trim())
.putString("callsign", s.callsign.trim()) .putInt("p${i}_port", p.settings.port)
.putString("auth_key", s.authKey) .putString("p${i}_callsign", p.settings.callsign.trim())
.putLong("tg", s.talkGroup) .putString("p${i}_auth", p.settings.authKey)
.putString("groups", s.groups.distinct().joinToString(",")) .putLong("p${i}_tg", p.settings.talkGroup)
.apply() .putString("p${i}_groups", p.settings.groups.distinct().joinToString(","))
}
e.apply()
}
/** Перенос старых настроек (до v1.0.6) в профиль 1 — один раз. */
private fun migrateIfNeeded(context: Context, prefs: SharedPreferences) {
if (prefs.getBoolean("migrated_v106", false)) return
val host = prefs.getString("host", null)
if (host != null) {
val legacy = ConnectionSettings(
host = host,
port = prefs.getInt("port", 5300),
callsign = prefs.getString("callsign", "") ?: "",
authKey = prefs.getString("auth_key", "") ?: "",
talkGroup = prefs.getLong("tg", 11),
groups = (prefs.getString("groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.ifEmpty { listOf(9L, 11L, 12L, 13L) }
)
val profiles = (0 until PROFILE_COUNT).map { loadProfile(prefs, it) }.toMutableList()
profiles[0] = Profile("Профиль 1", legacy)
saveProfiles(context, profiles)
}
prefs.edit().putBoolean("migrated_v106", true).apply()
} }
fun nodeToastsEnabled(context: Context): Boolean { fun nodeToastsEnabled(context: Context): Boolean {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) return sp(context).getBoolean("node_toasts", true)
.getBoolean("node_toasts", true)
} }
fun setNodeToastsEnabled(context: Context, enabled: Boolean) { fun setNodeToastsEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) sp(context).edit().putBoolean("node_toasts", enabled).apply()
.edit().putBoolean("node_toasts", enabled).apply()
} }
} }

View File

@@ -1,5 +1,6 @@
package ru.ua1zbe.svxremote.proto package ru.ua1zbe.svxremote.proto
import android.util.Log
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.InetAddress import java.net.InetAddress
@@ -7,7 +8,7 @@ import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log import ru.ua1zbe.svxremote.BuildConfig
/** /**
* Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP). * Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP).
@@ -50,7 +51,9 @@ class ReflectorClient(
const val RECONNECT_DELAY_MS = 5000L const val RECONNECT_DELAY_MS = 5000L
const val CLIENT_SW_INFO = "SVXHomyak" const val CLIENT_SW_INFO = "SVXHomyak"
const val CLIENT_SW_VERSION = "1.0.0" // Версия ПО передаётся на сервер для дашборда; берём из BuildConfig,
// чтобы не расходилась с versionName в build.gradle.kts
val CLIENT_SW_VERSION = BuildConfig.VERSION_NAME
} }
data class Settings( data class Settings(
@@ -101,10 +104,15 @@ class ReflectorClient(
@Synchronized @Synchronized
fun connect(settings: Settings) { fun connect(settings: Settings) {
if (running.get()) return
this.settings = settings this.settings = settings
manualDisconnect.set(false) manualDisconnect.set(false)
fatal = false fatal = false
if (running.get()) {
// Перезапуск с новыми настройками (например, смена профиля)
running.set(false)
synchronized(stopEvent) { stopEvent.notifyAll() }
abortSockets()
}
running.set(true) running.set(true)
state = STATE_DISCONNECTED state = STATE_DISCONNECTED
seqCounter = 0 seqCounter = 0
@@ -153,12 +161,46 @@ class ReflectorClient(
udpSend(sock, s, data) udpSend(sock, s, data)
} }
/** Переключить принимаемую группу на живом соединении. */
fun selectGroup(tg: Long) {
val sock = tcpSocket
if (sock == null || !running.get()) {
Log.d(TAG, "selectGroup tg=$tg skipped (sock=${sock != null} running=${running.get()})")
return
}
try {
Log.d(TAG, "selectGroup tg=$tg")
sendTcp(sock, MsgBuilder.selectTg(tg))
} catch (e: Exception) {
Log.w(TAG, "selectGroup send failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
/** Переключить группы мониторинга на живом соединении. */
fun setMonitorTgs(tgs: List<Long>) {
val sock = tcpSocket
if (sock == null || !running.get()) {
Log.d(TAG, "setMonitorTgs $tgs skipped (sock=${sock != null} running=${running.get()})")
return
}
try {
Log.d(TAG, "setMonitorTgs $tgs")
sendTcp(sock, MsgBuilder.tgMonitor(tgs))
} catch (e: Exception) {
Log.w(TAG, "setMonitorTgs failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
/** Конец передачи. */ /** Конец передачи. */
fun sendAudioFlush() { fun sendAudioFlush() {
val sock = udpSocket val sock = udpSocket
val s = settings val s = settings
val id = clientId val id = clientId
if (sock == null || s == null || id == 0) return if (sock == null || s == null || id == 0) {
Log.w(TAG, "sendAudioFlush skipped (sock=${sock != null} s=${s != null} id=$id)")
return
}
Log.d(TAG, "sendAudioFlush")
udpSend(sock, s, MsgBuilder.udpFlush(id, nextSeq())) udpSend(sock, s, MsgBuilder.udpFlush(id, nextSeq()))
} }
@@ -368,7 +410,7 @@ class ReflectorClient(
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup)) sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup))) sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
} else { } else {
sendTcp(socket, MsgBuilder.selectTg(0)) sendTcp(socket, MsgBuilder.selectTg(monitor.first()))
sendTcp(socket, MsgBuilder.tgMonitor(monitor)) sendTcp(socket, MsgBuilder.tgMonitor(monitor))
} }
openUdp(s) openUdp(s)
@@ -396,6 +438,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_ERROR -> { ReflectorProtocol.MSG_ERROR -> {
val err = MsgParser.errorMessage(msg.fields) val err = MsgParser.errorMessage(msg.fields)
Log.w(TAG, "MSG_ERROR: $err")
if (state < STATE_READY) { if (state < STATE_READY) {
stopFatal(err.ifEmpty { "Access denied" }) stopFatal(err.ifEmpty { "Access denied" })
return false return false
@@ -455,6 +498,7 @@ class ReflectorClient(
stopUdp() stopUdp()
val ds = DatagramSocket() val ds = DatagramSocket()
udpSocket = ds udpSocket = ds
Log.d(TAG, "UDP bound port=${ds.localPort}")
// Первый пакет регистрирует наш UDP-адрес у сервера // Первый пакет регистрирует наш UDP-адрес у сервера
val addr = resolvedAddr ?: InetAddress.getByName(s.host) val addr = resolvedAddr ?: InetAddress.getByName(s.host)
sendRawUdp(ds, addr, s.port, MsgBuilder.udpHeartbeat(clientId, nextSeq())) sendRawUdp(ds, addr, s.port, MsgBuilder.udpHeartbeat(clientId, nextSeq()))
@@ -498,7 +542,7 @@ class ReflectorClient(
val seq = r.u16() val seq = r.u16()
val payload = data.copyOfRange(6, data.size) val payload = data.copyOfRange(6, data.size)
when (type) { when (type) {
ReflectorProtocol.MSG_UDP_HEARTBEAT -> Unit ReflectorProtocol.MSG_UDP_HEARTBEAT -> Log.d(TAG, "UDP RX heartbeat")
ReflectorProtocol.MSG_UDP_AUDIO -> { ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload) val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) { if (audio != null && audio.isNotEmpty()) {
@@ -512,7 +556,7 @@ class ReflectorClient(
Log.d(TAG, "UDP RX flush") Log.d(TAG, "UDP RX flush")
callbacks.onAudioFlush() callbacks.onAudioFlush()
} }
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Log.d(TAG, "UDP RX all-flushed seq=$seq")
else -> Log.d(TAG, "UDP RX type=$type len=${data.size}") else -> Log.d(TAG, "UDP RX type=$type len=${data.size}")
} }
} }
@@ -536,10 +580,16 @@ class ReflectorClient(
} }
private fun sendRawUdp(sock: DatagramSocket, addr: InetAddress, port: Int, data: ByteArray) { private fun sendRawUdp(sock: DatagramSocket, addr: InetAddress, port: Int, data: ByteArray) {
if (sock.isClosed) return if (sock.isClosed) {
Log.w(TAG, "UDP TX skipped: socket closed")
return
}
try { try {
sock.send(DatagramPacket(data, data.size, addr, port)) sock.send(DatagramPacket(data, data.size, addr, port))
val typ = if (data.size >= 2) ((data[0].toInt() shl 8) or (data[1].toInt() and 0xFF)) else -1
Log.d(TAG, "UDP TX type=$typ len=${data.size}")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "UDP send error: ${e.message}", e)
callbacks.onError("UDP send: ${e.message}") callbacks.onError("UDP send: ${e.message}")
} }
} }

View File

@@ -195,9 +195,11 @@ object MsgBuilder {
return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray()) return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray())
} }
/** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.). */ /** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.).
* Рефлектор вливает JSON в статус узла, дашборд показывает поле
* «swVer» (как у svxlink-узлов). */
fun nodeInfo(swInfo: String, swVersion: String): RcMessage { fun nodeInfo(swInfo: String, swVersion: String): RcMessage {
val json = "{\"swInfo\":\"$swInfo\",\"swVersion\":\"$swVersion\"}" val json = "{\"swInfo\":\"$swInfo\",\"swVer\":\"$swVersion\"}"
return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).toByteArray()) return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).toByteArray())
} }

View File

@@ -29,6 +29,7 @@
android:layout_weight="1" /> android:layout_weight="1" />
<TextView <TextView
android:id="@+id/lbl_app_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/app_title" android:text="@string/app_title"

View File

@@ -11,6 +11,23 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/profiles_label"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Spinner
android:id="@+id/spinner_profile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_input"
android:padding="12dp"
android:spinnerMode="dropdown"
android:textColor="@color/text_primary" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"

View File

@@ -20,7 +20,7 @@
<string name="node_tg_suffix"> (группа %1$d)</string> <string name="node_tg_suffix"> (группа %1$d)</string>
<string name="node_with_tg">%1$s — TG%2$d</string> <string name="node_with_tg">%1$s — TG%2$d</string>
<string name="btn_save">Сохранить</string> <string name="btn_save">Сохранить</string>
<string name="app_version">SVXhomyak — v1.0.4</string> <string name="app_version">SVXhomyak — v1.0.5</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="pref_callsign_hint">например UA1ZBE</string> <string name="pref_callsign_hint">например UA1ZBE</string>
@@ -28,9 +28,12 @@
<string name="app_title">SVXHomyak</string> <string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string> <string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string> <string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Функция находится в разработке. Может работать некорректно!</string>
<string name="monitor_off">Обычный режим</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string> <string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
<string name="status_monitoring">Мониторинг: %1$s</string> <string name="status_monitoring">Мониторинг: %1$s</string>
<string name="talker_mon">Говорит: %1$s · TG%2$d</string> <string name="talker_mon">Говорит: %1$s · TG%2$d</string>
<string name="talker_mon_priority">ПРИОРИТЕТ: %1$s · TG%2$d</string>
<string name="status_big_disconnected">DISCONNECTED</string> <string name="status_big_disconnected">DISCONNECTED</string>
<string name="status_big_connected">CONNECTED</string> <string name="status_big_connected">CONNECTED</string>
<string name="status_big_talking">ПЕРЕДАЧА</string> <string name="status_big_talking">ПЕРЕДАЧА</string>
@@ -72,6 +75,16 @@
<string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string> <string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string>
<string name="toast_settings_empty">Заполните настройки подключения</string> <string name="toast_settings_empty">Заполните настройки подключения</string>
<string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string> <string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string>
<string name="profiles_label">Профиль настроек</string>
<string name="profile_name">Профиль %1$d</string>
<string name="profiles_title">Выбор профиля</string>
<string name="profile_current"> (текущий)</string>
<string name="profile_empty">не настроен</string>
<string name="cancel">Отмена</string>
<string name="talk_log_title">Последние передачи</string>
<string name="talk_log_empty">Журнала передач пока нет</string>
<string name="talk_log_entry">%1$s %2$s TG%3$d</string>
<string name="close">Закрыть</string>
<string name="settings_not_configured">Настройки не заданы</string> <string name="settings_not_configured">Настройки не заданы</string>
<string name="ptt_disabled">Нет связи</string> <string name="ptt_disabled">Нет связи</string>