12 Commits

Author SHA1 Message Date
ua1zbe
0b51e63262 Fix Opus decoding and RX jitter buffer on Android 16 (v1.0.5) 2026-08-17 09:22:47 +03:00
ua1zbe
268c9d76af Fix flush on PTT release; monitoring priority tweaks (v1.0.5) 2026-08-17 08:22:47 +03:00
ua1zbe
11446a92a5 Add group monitoring mode (v1.0.5)
- Monitor button between callsign and TG selector
- On: reconnects with TG_MONITOR for all switch-list groups,
  status shows 'Мониторинг: TG...', PTT disabled
- Talker display shows callsign and its TG
- Off: back to normal talk mode
2026-08-14 15:01:46 +03:00
ua1zbe
b988e7cea0 Match callsign size to TG selector (22sp) (v1.0.4) 2026-08-14 13:50:20 +03:00
ua1zbe
f4996bef8e Enlarge TG selector to 22sp and push to right edge (v1.0.4) 2026-08-14 13:48:38 +03:00
ua1zbe
27169e9d0f Move TG selector inline with callsign, enlarge to 18sp (v1.0.4) 2026-08-14 13:46:16 +03:00
ua1zbe
a083660e03 Center app title between power and settings buttons (v1.0.4) 2026-08-14 13:43:13 +03:00
ua1zbe
3de3d7e893 Align power button to left edge (v1.0.4) 2026-08-14 13:40:10 +03:00
ua1zbe
efcec098bd Add power button next to app title (v1.0.4)
- Tap: disconnect from server, stop service and close app
2026-08-14 13:38:04 +03:00
ua1zbe
d0c055fec9 Show talk group next to nodes in list (v1.0.4)
- SvxService tracks node->TG mapping learned from talker events
- Own callsign always shown with its current TG
- Protocol carries no TG in join/leave, so others appear once they talk
2026-08-14 13:32:41 +03:00
ua1zbe
9790f9d69c Show centered node join/leave overlay for 5s (v1.0.4)
- Replaces bottom toast: dark rounded card centered on screen,
  auto-hides after 5 seconds
- Optional test trigger via node_toast_test intent extra
2026-08-14 13:28:55 +03:00
ua1zbe
ea36319fe3 Add connected nodes list dialog (v1.0.4)
- Tap callsign on main screen shows dialog with connected nodes
- SvxService tracks node list: ServerInfo + NODE_JOINED/NODE_LEFT events
- Bump to 1.0.4
2026-08-14 13:05:05 +03:00
16 changed files with 600 additions and 123 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 = 5 versionCode = 6
versionName = "1.0.3" versionName = "1.0.4"
} }
buildTypes { buildTypes {

View File

@@ -9,6 +9,9 @@ import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri import android.net.Uri
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.PixelFormat
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
@@ -16,6 +19,7 @@ import android.os.IBinder
import android.os.Looper 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.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.ImageButton import android.widget.ImageButton
@@ -55,10 +59,13 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var micDb: TextView private lateinit var micDb: TextView
private lateinit var pttButton: PttButton private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: ImageButton
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var connected = false private var connected = false
private var monitoring = false
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
@@ -99,14 +106,31 @@ class MainActivity : Activity(), SvxService.Listener {
micDb = findViewById(R.id.mic_db) micDb = findViewById(R.id.mic_db)
pttButton = findViewById(R.id.ptt_button) pttButton = findViewById(R.id.ptt_button)
btnSettings = findViewById(R.id.btn_settings) btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor)
pttButton.enabled2 = false pttButton.enabled2 = false
btnMonitor.setOnClickListener { toggleMonitoring() }
updateMonitorButton()
val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) {
mainHandler.postDelayed({ showNodeOverlay(testText) }, 1500)
}
btnSettings.setOnClickListener { btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
findViewById<ImageButton>(R.id.btn_power).setOnClickListener {
svc?.disconnect()
stopService(Intent(this, SvxService::class.java))
SplashActivity.mainAlive = false
finishAffinity()
}
lblTg.setOnClickListener { showGroupsDialog() } lblTg.setOnClickListener { showGroupsDialog() }
lblCallsign.setOnClickListener { showNodesDialog() }
connectZone.setOnClickListener { connectZone.setOnClickListener {
if (connected) { if (connected) {
svc?.disconnect() svc?.disconnect()
@@ -136,6 +160,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 =
@@ -168,6 +193,8 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
hideNodeOverlay()
mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false SplashActivity.mainAlive = false
svc?.listener = null svc?.listener = null
unbindService(svcConn) unbindService(svcConn)
@@ -221,7 +248,11 @@ class MainActivity : Activity(), SvxService.Listener {
ReflectorClient.STATE_READY -> { ReflectorClient.STATE_READY -> {
connected = true connected = true
updatePttEnabled() updatePttEnabled()
statusChip.text = getString(R.string.status_connected) statusChip.text = if (monitoring) {
getString(R.string.status_monitoring, monitorTgLabel())
} else {
getString(R.string.status_connected)
}
statusChip.setTextColor(getColor(R.color.accent)) statusChip.setTextColor(getColor(R.color.accent))
lblTxState.text = getString(R.string.status_big_connected) lblTxState.text = getString(R.string.status_big_connected)
lblTxState.setTextColor(getColor(R.color.accent)) lblTxState.setTextColor(getColor(R.color.accent))
@@ -262,13 +293,79 @@ 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 = getString(R.string.talker_now, callsign) val priority = monitoring && settings.groups.firstOrNull() == tg
lblTalker.text = callsign 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
}
} }
} }
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?) {
showNodeToast(R.string.toast_node_joined, callsign, tg)
}
override fun onNodeLeft(callsign: String, tg: Long?) {
showNodeToast(R.string.toast_node_left, callsign, tg)
}
private fun showNodeToast(tpl: Int, callsign: String, tg: Long?) {
if (!Prefs.nodeToastsEnabled(this)) return
if (callsign.equals(settings.callsign, ignoreCase = true)) return
val suffix = if (tg != null) getString(R.string.node_tg_suffix, tg) else ""
mainHandler.post { showNodeOverlay(getString(tpl, callsign, suffix)) }
}
private var nodeOverlay: View? = null
private fun showNodeOverlay(text: String) {
hideNodeOverlay()
val view = layoutInflater.inflate(R.layout.view_node_toast, null, false)
view.findViewById<TextView>(R.id.lbl_node_toast).text = text
try {
windowManager.addView(
view,
WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT
).apply { gravity = Gravity.CENTER }
)
nodeOverlay = view
mainHandler.postDelayed({ hideNodeOverlay() }, 5000)
} catch (_: Exception) {
toast(text)
}
}
private fun hideNodeOverlay() {
nodeOverlay?.let { v ->
try { windowManager.removeView(v) } catch (_: Exception) { }
nodeOverlay = null
}
} }
override fun onAudioPacket(packet: ByteArray, seq: Int) { override fun onAudioPacket(packet: ByteArray, seq: Int) {
@@ -301,7 +398,7 @@ class MainActivity : Activity(), SvxService.Listener {
} }
private fun updatePttEnabled() { private fun updatePttEnabled() {
pttButton.enabled2 = connected pttButton.enabled2 = connected && !monitoring
} }
private fun requestPermissionsIfNeeded() { private fun requestPermissionsIfNeeded() {
@@ -326,12 +423,37 @@ class MainActivity : Activity(), SvxService.Listener {
port = settings.port, port = settings.port,
callsign = settings.callsign, callsign = settings.callsign,
authKey = settings.authKey, authKey = settings.authKey,
talkGroup = settings.talkGroup talkGroup = settings.talkGroup,
monitorTgs = if (monitoring) settings.groups else null
) )
) )
}, 300) }, 300)
} }
/** Включить/выключить режим мониторинга групп с переподключением. */
private fun toggleMonitoring() {
monitoring = !monitoring
priorityActive = false
updateMonitorButton()
updatePttEnabled()
if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
private fun updateMonitorButton() {
val active = monitoring
btnMonitor.imageTintList = ColorStateList.valueOf(
if (active) Color.parseColor("#58A6FF") else Color.parseColor("#8B949E")
)
btnMonitor.setBackgroundResource(if (active) R.drawable.bg_monitor_on else 0)
btnMonitor.contentDescription =
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)
@@ -341,6 +463,28 @@ class MainActivity : Activity(), SvxService.Listener {
mainHandler.postDelayed({ connect() }, 500) mainHandler.postDelayed({ connect() }, 500)
} }
private fun monitorTgLabel(): String =
settings.groups.joinToString(", ") { "TG$it" }
private fun showNodesDialog() {
val nodes = svc?.nodeList().orEmpty()
if (nodes.isEmpty()) {
toast(getString(R.string.toast_nodes_empty))
return
}
val myCs = settings.callsign.trim()
val items = nodes
.map { (cs, tg) ->
val t = tg ?: if (cs.equals(myCs, ignoreCase = true)) settings.talkGroup else null
if (t != null) getString(R.string.node_with_tg, cs, t) else cs
}
.sorted()
AlertDialog.Builder(this)
.setTitle(getString(R.string.nodes_title, items.size))
.setItems(items.toTypedArray(), 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

@@ -3,6 +3,7 @@ package ru.ua1zbe.svxremote
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.widget.Button import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import android.widget.Toast import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
@@ -27,6 +28,8 @@ class SettingsActivity : Activity() {
inputAuthKey = findViewById(R.id.input_auth_key) inputAuthKey = findViewById(R.id.input_auth_key)
inputTg = findViewById(R.id.input_tg) inputTg = findViewById(R.id.input_tg)
inputGroups = findViewById(R.id.input_groups) inputGroups = findViewById(R.id.input_groups)
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
val s = Prefs.load(this) val s = Prefs.load(this)
inputHost.setText(s.host) inputHost.setText(s.host)
@@ -63,6 +66,7 @@ class SettingsActivity : Activity() {
groups = groups groups = groups
) )
) )
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
toast(getString(R.string.saved)) toast(getString(R.string.saved))
finish() finish()
} }

View File

@@ -63,6 +63,8 @@ class SplashActivity : Activity() {
Intent.FLAG_ACTIVITY_CLEAR_TOP or Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP Intent.FLAG_ACTIVITY_SINGLE_TOP
) )
val testText = this.intent?.getStringExtra("node_toast_test")
if (testText != null) intent.putExtra("node_toast_test", testText)
startActivity(intent) startActivity(intent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out) overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
finish() finish()

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
@@ -23,6 +24,8 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun onServerInfo(info: MsgParser.ServerInfo) fun onServerInfo(info: MsgParser.ServerInfo)
fun onTalkerStart(tg: Long, callsign: String) fun onTalkerStart(tg: Long, callsign: String)
fun onTalkerStop(tg: Long, callsign: String) fun onTalkerStop(tg: Long, callsign: String)
fun onNodeJoined(callsign: String, tg: Long?)
fun onNodeLeft(callsign: String, tg: Long?)
fun onAudioPacket(packet: ByteArray, seq: Int) fun onAudioPacket(packet: ByteArray, seq: Int)
fun onAudioFlush() fun onAudioFlush()
fun onError(detail: String) fun onError(detail: String)
@@ -85,13 +88,22 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun isRunning(): Boolean = client.isRunning() fun isRunning(): Boolean = client.isRunning()
fun selectGroup(tg: Long) {
client.selectGroup(tg)
}
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 (фоновые потоки) ----------
@@ -111,10 +123,38 @@ class SvxService : Service(), ReflectorClient.Callbacks {
} }
override fun onServerInfo(info: MsgParser.ServerInfo) { override fun onServerInfo(info: MsgParser.ServerInfo) {
synchronized(nodeSet) {
nodeSet.clear()
nodeSet.addAll(info.nodes)
}
listener?.onServerInfo(info) listener?.onServerInfo(info)
} }
private val nodeSet = LinkedHashSet<String>()
private val nodeTgs = HashMap<String, Long>()
/** Текущий список узлов и известные группы (копия для UI). */
fun nodeList(): List<Pair<String, Long?>> = synchronized(nodeSet) {
nodeSet.map { it to nodeTgs[it] }
}
override fun onNodeJoined(callsign: String, tg: Long?) {
synchronized(nodeSet) { nodeSet.add(callsign) }
listener?.onNodeJoined(callsign, tg)
}
override fun onNodeLeft(callsign: String, tg: Long?) {
synchronized(nodeSet) {
nodeSet.remove(callsign)
nodeTgs.remove(callsign)
}
listener?.onNodeLeft(callsign, tg)
}
override fun onTalkerStart(tg: Long, callsign: String) { override fun onTalkerStart(tg: Long, callsign: String) {
synchronized(nodeSet) {
if (callsign in nodeSet) nodeTgs[callsign] = tg
}
updateNotification(getString(R.string.notif_talking, callsign)) updateNotification(getString(R.string.notif_talking, callsign))
listener?.onTalkerStart(tg, callsign) listener?.onTalkerStart(tg, callsign)
} }
@@ -124,12 +164,6 @@ class SvxService : Service(), ReflectorClient.Callbacks {
listener?.onTalkerStop(tg, callsign) listener?.onTalkerStop(tg, callsign)
} }
override fun onNodeJoined(callsign: String) {
}
override fun onNodeLeft(callsign: String) {
}
override fun onAudioPacket(packet: ByteArray, seq: Int) { override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet) engine.onAudioPacket(packet)
} }

View File

@@ -47,33 +47,39 @@ 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).
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 { decoder = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also { MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.setCallback(decoderCallback)
it.configure(format, null, null, 0) it.configure(format, null, null, 0)
queueDecoderConfig() // готовим конфиг ДО start: колбэки уйдут после
it.start() it.start()
} }
} catch (e: Exception) { } catch (e: Exception) {
@@ -81,6 +87,7 @@ class OpusEngine(
null null
} }
if (decoder == null) return if (decoder == null) return
Log.d(TAG, "decoder started, config=${configBuffers.size}B")
val minBuf = AudioTrack.getMinBufferSize( val minBuf = AudioTrack.getMinBufferSize(
OUT_SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
@@ -92,77 +99,165 @@ 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 val decoderCallback = object : MediaCodec.Callback() {
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
Log.e(TAG, "decoder error: ${e.message} code=${e.errorCode} transient=${e.isTransient}")
}
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
// Конфиг (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) {
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) {
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() {
@@ -170,11 +265,23 @@ class OpusEngine(
decoder?.flush() decoder?.flush()
player?.flush() player?.flush()
rxQueue.clear() rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
/**
* Один кадр тишины (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 +304,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 +419,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 +470,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

@@ -42,4 +42,14 @@ object Prefs {
.putString("groups", s.groups.distinct().joinToString(",")) .putString("groups", s.groups.distinct().joinToString(","))
.apply() .apply()
} }
fun nodeToastsEnabled(context: Context): Boolean {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean("node_toasts", true)
}
fun setNodeToastsEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean("node_toasts", enabled).apply()
}
} }

View File

@@ -23,8 +23,8 @@ class ReflectorClient(
fun onServerInfo(info: MsgParser.ServerInfo) fun onServerInfo(info: MsgParser.ServerInfo)
fun onTalkerStart(tg: Long, callsign: String) fun onTalkerStart(tg: Long, callsign: String)
fun onTalkerStop(tg: Long, callsign: String) fun onTalkerStop(tg: Long, callsign: String)
fun onNodeJoined(callsign: String) fun onNodeJoined(callsign: String, tg: Long?)
fun onNodeLeft(callsign: String) fun onNodeLeft(callsign: String, tg: Long?)
/** Входящий аудиопакет Opus. */ /** Входящий аудиопакет Opus. */
fun onAudioPacket(packet: ByteArray, seq: Int) fun onAudioPacket(packet: ByteArray, seq: Int)
fun onAudioFlush() fun onAudioFlush()
@@ -58,7 +58,9 @@ class ReflectorClient(
val port: Int, val port: Int,
val callsign: String, val callsign: String,
val authKey: String, val authKey: String,
val talkGroup: Long val talkGroup: Long,
/** Список групп для мониторинга; null = обычный режим (своя группа). */
val monitorTgs: List<Long>? = null
) )
@Volatile @Volatile
@@ -151,12 +153,30 @@ 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 (_: Exception) {
}
}
/** Конец передачи. */ /** Конец передачи. */
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()))
} }
@@ -361,19 +381,25 @@ class ReflectorClient(
// (иначе сервер не шлёт аудио группы и не показывает // (иначе сервер не шлёт аудио группы и не показывает
// подписку в статусе: monitoredTGs остаётся пустым). // подписку в статусе: monitoredTGs остаётся пустым).
sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION)) sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION))
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup)) val monitor = s.monitorTgs
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup))) if (monitor.isNullOrEmpty()) {
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
} else {
sendTcp(socket, MsgBuilder.selectTg(monitor.first()))
sendTcp(socket, MsgBuilder.tgMonitor(monitor))
}
openUdp(s) openUdp(s)
} }
ReflectorProtocol.MSG_NODE_JOINED -> { ReflectorProtocol.MSG_NODE_JOINED -> {
val cs = parseSimpleString(msg.fields) val (cs, tg) = parseNodeEvent(msg.fields)
callbacks.onNodeJoined(cs) callbacks.onNodeJoined(cs, tg)
} }
ReflectorProtocol.MSG_NODE_LEFT -> { ReflectorProtocol.MSG_NODE_LEFT -> {
val cs = parseSimpleString(msg.fields) val (cs, tg) = parseNodeEvent(msg.fields)
callbacks.onNodeLeft(cs) callbacks.onNodeLeft(cs, tg)
} }
ReflectorProtocol.MSG_TALKER_START -> { ReflectorProtocol.MSG_TALKER_START -> {
@@ -388,6 +414,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
@@ -404,6 +431,17 @@ class ReflectorClient(
return true return true
} }
private fun parseNodeEvent(fields: ByteArray): Pair<String, Long?> {
return try {
val r = BinReader(fields)
val cs = r.str().ifEmpty { "?" }
val tg = try { r.str().toLongOrNull() } catch (_: Exception) { null }
cs to tg
} catch (_: Exception) {
"?" to null
}
}
private fun parseSimpleString(fields: ByteArray): String { private fun parseSimpleString(fields: ByteArray): String {
return try { return try {
BinReader(fields).str().ifEmpty { "?" } BinReader(fields).str().ifEmpty { "?" }
@@ -436,6 +474,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()))
@@ -479,7 +518,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()) {
@@ -493,7 +532,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}")
} }
} }
@@ -517,10 +556,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

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#3358A6FF" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E61B222A" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="#40FFFFFF" />
</shape>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8B949E"
android:pathData="M12,1c-4.97,0 -9,4.03 -9,9v7c0,1.66 1.34,3 3,3h3v-8L5,12v-2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v2h-4v8h3c1.66,0 3,-1.34 3,-3v-7c0,-4.97 -4.03,-9 -9,-9z" />
</vector>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8B949E"
android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.41L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9C21,9.26 19.77,6.82 17.83,5.17z" />
</vector>

View File

@@ -14,6 +14,15 @@
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<ImageButton
android:id="@+id/btn_power"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_power"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_power"
android:scaleType="centerInside" />
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"
android:layout_height="1dp" android:layout_height="1dp"
@@ -56,17 +65,44 @@
android:layout_weight="1" android:layout_weight="1"
android:orientation="vertical"> android:orientation="vertical">
<TextView <LinearLayout
android:id="@+id/lbl_callsign" android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:fontFamily="monospace" android:orientation="horizontal"
android:textColor="@color/text_primary" android:gravity="center_vertical">
android:textSize="28sp"
android:textStyle="bold" <TextView
android:singleLine="true" android:id="@+id/lbl_callsign"
android:ellipsize="end" android:layout_width="0dp"
android:text="@string/settings_not_configured" /> android:layout_weight="1"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/settings_not_configured" />
<ImageButton
android:id="@+id/btn_monitor"
android:layout_width="44dp"
android:layout_height="44dp"
android:src="@drawable/ic_headset"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_monitor"
android:scaleType="centerInside" />
<TextView
android:id="@+id/lbl_tg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:textColor="@color/warn"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace" />
</LinearLayout>
<TextView <TextView
android:id="@+id/lbl_subtitle" android:id="@+id/lbl_subtitle"
@@ -78,16 +114,6 @@
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" /> android:ellipsize="end" />
</LinearLayout> </LinearLayout>
<TextView
android:id="@+id/lbl_tg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="12dp"
android:textColor="@color/warn"
android:textSize="16sp"
android:textStyle="bold"
android:fontFamily="monospace" />
</LinearLayout> </LinearLayout>
<!-- Статус соединения --> <!-- Статус соединения -->

View File

@@ -139,6 +139,15 @@
android:padding="12dp" android:padding="12dp"
android:hint="9, 11, 12, 13" /> android:hint="9, 11, 12, 13" />
<CheckBox
android:id="@+id/chk_node_toasts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/pref_node_toasts"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Button <Button
android:id="@+id/btn_save" android:id="@+id/btn_save"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/node_toast_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_node_toast"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="14dp"
android:paddingBottom="14dp">
<TextView
android:id="@+id/lbl_node_toast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#F0F0F0"
android:textSize="16sp"
android:textStyle="bold" />
</FrameLayout>

View File

@@ -12,13 +12,26 @@
<string name="pref_groups">Группы для переключения (через запятую)</string> <string name="pref_groups">Группы для переключения (через запятую)</string>
<string name="select_tg">Выбор группы</string> <string name="select_tg">Выбор группы</string>
<string name="toast_groups_empty">Добавьте группы в настройках</string> <string name="toast_groups_empty">Добавьте группы в настройках</string>
<string name="nodes_title">Узлы в сети (%1$d)</string>
<string name="toast_nodes_empty">Нет подключенных узлов</string>
<string name="pref_node_toasts">Всплывающее уведомление о подключении/отключении узлов</string>
<string name="toast_node_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$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.3</string> <string name="app_version">SVXhomyak — v1.0.4</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="pref_callsign_hint">например UA1ZBE</string> <string name="pref_callsign_hint">например UA1ZBE</string>
<string name="status_disconnected">Отключено</string> <string name="status_disconnected">Отключено</string>
<string name="app_title">SVXHomyak</string> <string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
<string name="status_monitoring">Мониторинг: %1$s</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>