30 Commits

Author SHA1 Message Date
ua1zbe
f5f51bce0c Bump to 1.0.9 2026-08-18 16:31:51 +03:00
ua1zbe
fbded33b67 Enlarge RX/TX indicator to 24sp 2026-08-18 16:26:11 +03:00
ua1zbe
6cee5999b8 Show 'Говорит TG..' + callsign + RX/TX duplex indicator 2026-08-18 16:22:41 +03:00
ua1zbe
2d85b0587b Fix talker label in normal mode; keep base group during own TX 2026-08-18 16:10:32 +03:00
ua1zbe
43890117b2 Monitoring toast: 'Мониторинг выбранных групп!' 2026-08-18 16:02:15 +03:00
ua1zbe
e5f964dff7 Toggle monitoring over live connection without reconnect 2026-08-18 15:58:09 +03:00
ua1zbe
05ba30e0c6 Enable PTT in monitoring mode; don't switch group while transmitting 2026-08-18 15:50:13 +03:00
ua1zbe
e53186ea52 Fix group monitoring: per-group 15s idle timeout, drop priority lock 2026-08-18 15:42:52 +03:00
ua1zbe
b0bf837a92 Force RX meter to zero 600ms after signal ends; silence threshold 2026-08-18 15:21:29 +03:00
ua1zbe
88739f17a9 Replace RX/MIC bars with S-meter (S1-S9, +20..+60, peak-hold) 2026-08-18 15:15:34 +03:00
ua1zbe
b63ef121f9 Sound meter preview dialog (hold on 'На связи'): S-meter, clipping, jitter/loss demo 2026-08-18 15:07:53 +03:00
ua1zbe
b45f871544 Bump to 1.0.8 2026-08-18 13:35:30 +03:00
ua1zbe
516fb7b932 Show RX/MIC meters as +dB; zero RX meter during conceal 2026-08-18 13:26:05 +03:00
ua1zbe
3de296b5d2 Shorten APRS coords hint 2026-08-18 13:18:33 +03:00
ua1zbe
37ecd0fa6e Separate hidden audio settings screen (mic gain, RX buffer, jitter) via triple-tap; remove from main settings 2026-08-18 13:16:04 +03:00
ua1zbe
467dabe509 Triple-tap on app title opens settings; add mic gain slider (100-500%) 2026-08-18 13:10:43 +03:00
ua1zbe
0f827f7a9c Show real app version in settings 2026-08-18 09:25:28 +03:00
ua1zbe
78ac8648a6 Default APRS coords to Saint Petersburg center; bump to 1.0.7 2026-08-18 09:14:19 +03:00
ua1zbe
ac10ffc7fa Remove APRS callsign setting; connect to APRS-IS with SVX callsign, keep only coords 2026-08-18 09:01:30 +03:00
ua1zbe
f5cb74ba25 Show meteo line centered with WX prefix 2026-08-18 08:53:09 +03:00
ua1zbe
4102140fa0 Fix APRS meteo temp: t-field is whole degrees Fahrenheit (was dividing by 10); round to whole Celsius 2026-08-18 08:46:48 +03:00
ua1zbe
985cd9eb1a Convert APRS meteo temperature from Fahrenheit to Celsius 2026-08-18 07:58:17 +03:00
ua1zbe
7564c8d092 Add APRS weather stations (experimental): receive meteo in 250 km radius, show temperature in main screen line 2026-08-17 16:58:00 +03:00
ua1zbe
fac3c7d692 Bump version to 1.0.6 2026-08-17 13:23:15 +03:00
ua1zbe
884678d36d Fix RX audio for subsequent transmissions (recreate decoder on flush) 2026-08-17 13:18:50 +03:00
ua1zbe
9d7b659231 Add last talkers log (15 entries, time/callsign/TG) 2026-08-17 11:28:39 +03:00
ua1zbe
ee61d3ef6f Update monitored TG when switching group live 2026-08-17 11:14:41 +03:00
ua1zbe
3109c52bbd Fix group switching: send selectGroup on background thread 2026-08-17 11:11:30 +03:00
ua1zbe
eeffc14f1a Switch talk group without reconnecting 2026-08-17 10:55:44 +03:00
ua1zbe
9838daea2f Add multiple connection profiles (v1.0.6) 2026-08-17 10:39:15 +03:00
16 changed files with 1343 additions and 169 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote"
minSdk = 26
targetSdk = 34
versionCode = 7
versionName = "1.0.5"
versionCode = 11
versionName = "1.0.9"
}
buildTypes {

View File

@@ -25,6 +25,12 @@
android:exported="false"
android:label="@string/settings_title" />
<activity
android:name=".AudioSettingsActivity"
android:exported="false"
android:label="@string/audio_settings_title"
android:screenOrientation="portrait" />
<activity
android:name=".SplashActivity"
android:exported="true"

View File

@@ -0,0 +1,70 @@
package ru.ua1zbe.svxremote
import android.app.Activity
import android.os.Bundle
import android.widget.Button
import android.widget.SeekBar
import android.widget.TextView
import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.Prefs
/**
* Скрытые настройки звука (тройной тап по названию программы на главном
* экране): усиление микрофона, буфер воспроизведения (джиттер), накопление
* перед стартом. Применяются при следующем подключении/передаче.
*/
class AudioSettingsActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_audio_settings)
val seekMicGain = findViewById<SeekBar>(R.id.seek_mic_gain)
val lblMicGain = findViewById<TextView>(R.id.lbl_mic_gain_value)
val seekRxBuffer = findViewById<SeekBar>(R.id.seek_rx_buffer)
val lblRxBuffer = findViewById<TextView>(R.id.lbl_rx_buffer_value)
val seekJitter = findViewById<SeekBar>(R.id.seek_jitter)
val lblJitter = findViewById<TextView>(R.id.lbl_jitter_value)
fun updateMicGainLabel() {
lblMicGain.text = "${100 + seekMicGain.progress * 10}%"
}
fun updateRxBufferLabel() {
lblRxBuffer.text = "${100 + seekRxBuffer.progress * 20} мс"
}
fun updateJitterLabel() {
lblJitter.text = "${seekJitter.progress * 20} мс"
}
seekMicGain.progress = ((Prefs.micGain(this) - 1.0f) * 10).toInt().coerceIn(0, 40)
seekRxBuffer.progress = ((Prefs.rxBufferMs(this) - 100) / 20).coerceIn(0, 25)
seekJitter.progress = (Prefs.rxJitterMs(this) / 20).coerceIn(0, 20)
updateMicGainLabel()
updateRxBufferLabel()
updateJitterLabel()
val listener = object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
updateMicGainLabel()
updateRxBufferLabel()
updateJitterLabel()
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
}
seekMicGain.setOnSeekBarChangeListener(listener)
seekRxBuffer.setOnSeekBarChangeListener(listener)
seekJitter.setOnSeekBarChangeListener(listener)
findViewById<Button>(R.id.btn_audio_save).setOnClickListener {
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20)
Prefs.setRxJitterMs(this, seekJitter.progress * 20)
Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
finish()
}
}
}

View File

@@ -20,6 +20,7 @@ import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
@@ -28,11 +29,14 @@ import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import java.io.File
import ru.ua1zbe.svxremote.aprs.AprsMeteo
import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient
import ru.ua1zbe.svxremote.ui.PttButton
import ru.ua1zbe.svxremote.ui.SMeterView
import ru.ua1zbe.svxremote.ui.SoundMeterPreviewView
private const val REQ_MIC = 1001
private const val REQ_NOTIF = 1002
@@ -41,33 +45,46 @@ class MainActivity : Activity(), SvxService.Listener {
companion object {
private const val TAG = "SVXRemote"
private const val TALK_LOG_MAX = 15
}
private lateinit var statusChip: TextView
private lateinit var lblAppTitle: TextView
private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView
private lateinit var lblDuplex: TextView
private lateinit var lblPttHint: TextView
private lateinit var lblTxState: TextView
private lateinit var lblConnectHint: TextView
private lateinit var connectZone: View
private lateinit var rxBarFill: View
private lateinit var rxDb: TextView
private lateinit var micBarFill: View
private lateinit var micDb: TextView
private lateinit var rxMeter: SMeterView
private lateinit var micMeter: SMeterView
private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: ImageButton
private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null
private var titleResetRunnable: Runnable? = null
private var titleTaps = 0
private data class TalkLogEntry(val time: Long, val callsign: String, val tg: Long)
private val talkLog = ArrayDeque<TalkLogEntry>()
private var connected = false
private var monitoring = false
private var priorityActive = false
private var connectedSettings: ConnectionSettings? = null
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
// Динамический мониторинг: если на группе нет голоса 15 секунд,
// возвращаемся к мониторингу всех групп (базовая группа).
private val MONITOR_IDLE_MS = 15_000L
private var monitorIdleTask: Runnable? = null
private var svc: SvxService? = null
private val svcConn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
@@ -83,6 +100,26 @@ class MainActivity : Activity(), SvxService.Listener {
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = null
// ---------- APRS метеостанции (экспериментально) ----------
private var aprsMeteo: AprsMeteo? = null
private var lastMeteo: String? = null
private var lastAprsCoords = ""
private fun startAprsMeteo() {
aprsMeteo?.stop()
aprsMeteo = null
// Пустые координаты — центр Санкт-Петербурга
val coords = Prefs.aprsCoords(this)
.split(',')
.mapNotNull { it.trim().toDoubleOrNull() }
.take(2)
.let { if (it.size == 2) it[0] to it[1] else 59.95 to 30.30 }
aprsMeteo = AprsMeteo(coords) { wx ->
lastMeteo = wx
lblSubtitle.text = wx
}.also { it.start() }
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SplashActivity.mainAlive = true
@@ -91,19 +128,20 @@ class MainActivity : Activity(), SvxService.Listener {
checkForUpdates()
statusChip = findViewById(R.id.status_chip)
lblAppTitle = findViewById(R.id.lbl_app_title)
lblSubtitle = findViewById(R.id.lbl_subtitle)
lblTalkerTitle = findViewById(R.id.lbl_talker_title)
lblTalker = findViewById(R.id.lbl_talker)
lblDuplex = findViewById(R.id.lbl_duplex)
lblPttHint = findViewById(R.id.lbl_ptt_hint)
lblTxState = findViewById(R.id.lbl_tx_state)
lblConnectHint = findViewById(R.id.lbl_connect_hint)
connectZone = findViewById(R.id.connect_zone)
lblCallsign = findViewById(R.id.lbl_callsign)
lblTg = findViewById(R.id.lbl_tg)
rxBarFill = findViewById(R.id.rx_bar_fill)
rxDb = findViewById(R.id.rx_db)
micBarFill = findViewById(R.id.mic_bar_fill)
micDb = findViewById(R.id.mic_db)
rxMeter = findViewById(R.id.rx_meter)
micMeter = findViewById(R.id.mic_meter)
micMeter.setColor(0xFFF85149.toInt())
pttButton = findViewById(R.id.ptt_button)
btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor)
@@ -131,6 +169,50 @@ class MainActivity : Activity(), SvxService.Listener {
lblCallsign.setOnClickListener { showNodesDialog() }
lblTalker.setOnClickListener { showTalkLogDialog() }
// Удержание на надписи «На связи» — демо-окно индикаторов звука
lblTxState.setOnLongClickListener {
showSoundMetersPreview()
true
}
// Удержание на названии программы (> 2 с) — выбор профиля настроек;
// короткие тапы считаем: тройной тап — настройки
lblAppTitle.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val r = Runnable {
titleHoldRunnable = null
showProfileDialog()
}
titleHoldRunnable = r
mainHandler.postDelayed(r, 2000)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
val wasHold = titleHoldRunnable == null
titleHoldRunnable?.let { mainHandler.removeCallbacks(it) }
titleHoldRunnable = null
if (!wasHold) {
titleTaps++
// Скользящее окно: сброс от последнего тапа
titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
val reset = Runnable { titleTaps = 0 }
titleResetRunnable = reset
mainHandler.postDelayed(reset, 1200)
if (titleTaps >= 3) {
titleTaps = 0
titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
startActivity(Intent(this, AudioSettingsActivity::class.java))
}
}
true
}
else -> true
}
}
connectZone.setOnClickListener {
if (connected) {
svc?.disconnect()
@@ -156,6 +238,7 @@ class MainActivity : Activity(), SvxService.Listener {
}
lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking)
showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
svc?.startTx()
}
@@ -166,18 +249,40 @@ class MainActivity : Activity(), SvxService.Listener {
lblTxState.text =
if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected)
hideDuplex()
}
})
refreshUi()
startAprsMeteo()
lastAprsCoords = Prefs.aprsCoords(this).trim()
}
private var autoConnectAttempted = false
override fun onResume() {
super.onResume()
val prev = settings
settings = Prefs.load(this)
if (prev != settings && connectedSettings != null) {
if (settings.host.isBlank() || settings.callsign.isBlank()) {
monitoring = false
updateMonitorButton()
updatePttEnabled()
svc?.disconnect()
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
refreshUi()
val aprsCoords = Prefs.aprsCoords(this).trim()
if (aprsCoords != lastAprsCoords) {
lastAprsCoords = aprsCoords
startAprsMeteo()
}
if (!autoConnectAttempted &&
settings.host.isNotBlank() &&
settings.callsign.isNotBlank()
@@ -194,6 +299,8 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onDestroy() {
super.onDestroy()
hideNodeOverlay()
aprsMeteo?.stop()
aprsMeteo = null
mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false
svc?.listener = null
@@ -202,7 +309,7 @@ class MainActivity : Activity(), SvxService.Listener {
private fun refreshUi() {
val host = settings.host
lblSubtitle.text = if (host.isBlank()) {
lblSubtitle.text = lastMeteo ?: if (host.isBlank()) {
getString(R.string.settings_not_configured)
} else {
getString(R.string.server_subtitle, host, settings.port, settings.talkGroup)
@@ -293,32 +400,31 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onTalkerStart(tg: Long, callsign: String) {
mainHandler.post {
val priority = monitoring && settings.groups.firstOrNull() == tg
if (priority) {
priorityActive = true
Log.d("SVXRemote", "talkerStart tg=$tg cs=$callsign monitoring=$monitoring ptt=${pttButton.isPressed2}")
talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg))
while (talkLog.size > TALK_LOG_MAX) {
talkLog.removeFirst()
}
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)
if (monitoring) {
// Переключаемся на группу, где началась передача, и даём ей
// 15 секунд; после тишины вернёмся ко всем группам.
// Во время своей передачи группу не меняем.
if (!pttButton.isPressed2) {
svc?.selectGroup(tg)
}
lblTalkerTitle.setTextColor(
if (priority) getColor(R.color.danger) else getColor(R.color.text_secondary)
)
lblTalker.text = callsign
resetMonitorIdle()
}
lblTalkerTitle.text = getString(R.string.talker_now, tg)
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary))
lblTalker.text = callsign
if (!pttButton.isPressed2) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
}
}
}
override fun onTalkerStop(tg: Long, callsign: String) {
// последний говоривший остаётся на экране
mainHandler.post {
if (monitoring && settings.groups.firstOrNull() == tg) {
priorityActive = false
}
}
}
override fun onNodeJoined(callsign: String, tg: Long?) {
@@ -376,21 +482,55 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onLevel(source: Int, level: Float) {
mainHandler.post {
val db = if (level <= 0.001f) {
getString(R.string.meter_idle)
} else {
getString(R.string.meter_db, (level * 60).toInt().coerceIn(1, 60))
}
if (source == 0) { // SRC_MIC
micBarFill.scaleX = level.coerceIn(0f, 1f)
micDb.text = db
micMeter.setLevel(level)
} else { // SRC_SPEAKER
rxBarFill.scaleX = level.coerceIn(0f, 1f)
rxDb.text = db
rxMeter.setLevel(level)
// «RX» пока идёт голос; при своей передаче не показываем
if (!pttButton.isPressed2 && level > 0.03f) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
}
// Голос на текущей группе — откладываем возврат ко всем группам
if (monitoring && connected && level > 0.03f) {
resetMonitorIdle()
}
}
}
}
/** Сбросить таймер молчания: группа жива — продолжаем её слушать. */
private fun resetMonitorIdle() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) }
val task = Runnable {
// Во время своей передачи на базу не возвращаемся
if (monitoring && connected && !pttButton.isPressed2) {
svc?.selectGroup(settings.talkGroup)
}
}
monitorIdleTask = task
mainHandler.postDelayed(task, MONITOR_IDLE_MS)
}
// ---------- Индикатор RX/TX ----------
private val DUPLEX_RX_HIDE_MS = 1500L
private var duplexHideTask: Runnable? = null
private fun showDuplex(text: String, colorRes: Int, hideAfterMs: Long?) {
lblDuplex.text = text
lblDuplex.setTextColor(getColor(colorRes))
lblDuplex.visibility = View.VISIBLE
duplexHideTask?.let { mainHandler.removeCallbacks(it) }
if (hideAfterMs != null) {
val t = Runnable { lblDuplex.visibility = View.INVISIBLE }
duplexHideTask = t
mainHandler.postDelayed(t, hideAfterMs)
}
}
private fun hideDuplex() {
duplexHideTask?.let { mainHandler.removeCallbacks(it) }
lblDuplex.visibility = View.INVISIBLE
}
override fun onError(detail: String) {
mainHandler.post {
toast(detail)
@@ -398,7 +538,8 @@ class MainActivity : Activity(), SvxService.Listener {
}
private fun updatePttEnabled() {
pttButton.enabled2 = connected && !monitoring
// PTT работает и в режиме мониторинга: передача идёт на выбранной группе
pttButton.enabled2 = connected
}
private fun requestPermissionsIfNeeded() {
@@ -414,6 +555,7 @@ class MainActivity : Activity(), SvxService.Listener {
/** Запустить сервис и подключиться с текущими настройками. */
private fun connect() {
connectedSettings = settings
startForegroundService(Intent(this, SvxService::class.java))
bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE)
mainHandler.postDelayed({
@@ -430,7 +572,7 @@ class MainActivity : Activity(), SvxService.Listener {
}, 300)
}
/** Включить/выключить режим мониторинга групп с переподключением. */
/** Включить/выключить режим мониторинга групп по живому соединению. */
private fun toggleMonitoring() {
val enabling = !monitoring
if (enabling) {
@@ -439,12 +581,21 @@ class MainActivity : Activity(), SvxService.Listener {
showToast(R.string.monitor_off)
}
monitoring = enabling
priorityActive = false
updateMonitorButton()
updatePttEnabled()
if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
monitorIdleTask?.let { mainHandler.removeCallbacks(it) }
if (enabling) {
// Без переподключения: сервер начнёт присылать уведомления
// со всех групп, сами сидим на базовой.
svc?.setMonitorTgs(settings.groups)
svc?.selectGroup(settings.talkGroup)
statusChip.text = getString(R.string.status_monitoring, monitorTgLabel())
} else {
svc?.selectGroup(settings.talkGroup)
svc?.setMonitorTgs(listOf(settings.talkGroup))
statusChip.text = getString(R.string.status_connected)
}
} else {
connect()
}
@@ -459,6 +610,44 @@ class MainActivity : Activity(), SvxService.Listener {
}
}
/** Список профилей: выбор сразу подключается с новыми настройками. */
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
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() {
val active = monitoring
btnMonitor.imageTintList = ColorStateList.valueOf(
@@ -469,13 +658,18 @@ class MainActivity : Activity(), SvxService.Listener {
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
}
/** Переключиться на другую группу: пересохранить и переподключиться. */
/** Переключиться на другую группу: отправить серверу по живому соединению. */
private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg)
connectedSettings = settings
Prefs.save(this, settings)
refreshUi()
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
svc?.selectGroup(tg)
if (!monitoring) {
svc?.setMonitorTgs(listOf(tg))
} else {
resetMonitorIdle()
}
}
private fun monitorTgLabel(): String =
@@ -500,6 +694,33 @@ class MainActivity : Activity(), SvxService.Listener {
.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 showSoundMetersPreview() {
AlertDialog.Builder(this)
.setTitle(R.string.meter_preview_title)
.setMessage(R.string.meter_preview_hint)
.setView(SoundMeterPreviewView(this))
.setPositiveButton(R.string.close, null)
.show()
}
private fun showGroupsDialog() {
val groups = (settings.groups + settings.talkGroup).distinct()
if (groups.isEmpty()) {

View File

@@ -2,42 +2,67 @@ package ru.ua1zbe.svxremote
import android.app.Activity
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.prefs.Profile
class SettingsActivity : Activity() {
private lateinit var spinnerProfile: Spinner
private lateinit var inputHost: EditText
private lateinit var inputPort: EditText
private lateinit var inputCallsign: EditText
private lateinit var inputAuthKey: EditText
private lateinit var inputTg: EditText
private lateinit var inputGroups: EditText
private lateinit var inputAprsCoords: EditText
private var currentProfile = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
spinnerProfile = findViewById(R.id.spinner_profile)
inputHost = findViewById(R.id.input_host)
inputPort = findViewById(R.id.input_port)
inputCallsign = findViewById(R.id.input_callsign)
inputAuthKey = findViewById(R.id.input_auth_key)
inputTg = findViewById(R.id.input_tg)
inputGroups = findViewById(R.id.input_groups)
inputAprsCoords = findViewById(R.id.input_aprs_coords)
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
inputAprsCoords.setText(Prefs.aprsCoords(this))
findViewById<TextView>(R.id.lbl_version).text =
getString(R.string.app_version_fmt, BuildConfig.VERSION_NAME)
val s = Prefs.load(this)
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(", "))
val profileNames = (1..Prefs.PROFILE_COUNT)
.map { getString(R.string.profile_name, it) }
spinnerProfile.adapter = ArrayAdapter(
this, android.R.layout.simple_spinner_dropdown_item, profileNames
)
currentProfile = Prefs.activeProfileIndex(this)
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 {
val host = inputHost.text.toString().trim()
@@ -55,9 +80,9 @@ class SettingsActivity : Activity() {
return@setOnClickListener
}
Prefs.save(
this,
ConnectionSettings(
val profile = Profile(
name = getString(R.string.profile_name, currentProfile + 1),
settings = ConnectionSettings(
host = host,
port = port.coerceIn(1, 65535),
callsign = callsign,
@@ -66,13 +91,25 @@ class SettingsActivity : Activity() {
groups = groups
)
)
Prefs.saveProfile(this, currentProfile, profile)
Prefs.setActiveProfile(this, currentProfile)
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
Prefs.setAprsCoords(this, inputAprsCoords.text.toString())
toast(getString(R.string.saved))
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) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}
}

View File

@@ -10,6 +10,7 @@ import android.os.Binder
import android.os.IBinder
import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -89,11 +90,17 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun isRunning(): Boolean = client.isRunning()
fun selectGroup(tg: Long) {
client.selectGroup(tg)
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.selectGroup(tg) }.start()
}
fun setMonitorTgs(tgs: List<Long>) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.setMonitorTgs(tgs) }.start()
}
fun startTx() {
engine.startCapture { packet -> client.sendAudio(packet) }
engine.startCapture(Prefs.micGain(this)) { packet -> client.sendAudio(packet) }
}
fun stopTx() {
@@ -112,6 +119,8 @@ class SvxService : Service(), ReflectorClient.Callbacks {
when (state) {
ReflectorClient.STATE_READY -> {
updateNotification(getString(R.string.notif_connected))
engine.rxBufferMs = Prefs.rxBufferMs(this)
engine.jitterFrames = (Prefs.rxJitterMs(this) / 20).coerceAtLeast(1)
engine.startDecoder()
}
ReflectorClient.STATE_DISCONNECTED -> {

View File

@@ -0,0 +1,157 @@
package ru.ua1zbe.svxremote.aprs
import android.os.Handler
import android.os.Looper
import android.util.Log
import ru.ua1zbe.svxremote.BuildConfig
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetSocketAddress
import java.net.Socket
/**
* Экспериментально: приём метеостанций с APRS-IS (aprs2.net) в радиусе 250 км
* от координат (фильтр r/). Весь код — в этом файле, удаляется вместе с
* вызовом в MainActivity и полями в настройках.
*/
class AprsMeteo(
private val coords: Pair<Double, Double>?,
private val onWeather: (String) -> Unit
) {
private val tag = "AprsMeteo"
private val main = Handler(Looper.getMainLooper())
@Volatile private var running = false
private var hostIdx = 0
fun start() {
if (running) return
running = true
Thread({ run() }, "aprs-meteo").start()
}
fun stop() {
running = false
}
private fun run() {
while (running) {
try {
runOnce()
} catch (e: Exception) {
if (running) Log.d(tag, "err: ${e.message}")
}
if (running) {
try {
Thread.sleep(30_000)
} catch (_: InterruptedException) {
break
}
}
}
}
private fun runOnce() {
val sock = Socket()
try {
sock.connect(InetSocketAddress(APRS_HOSTS[hostIdx], APRS_PORT), 10_000)
sock.soTimeout = 90_000
val filter = if (coords != null) {
"r/${coords.first}/${coords.second}/250"
} else {
// Координаты не заданы — функция отключена (MainActivity не запускает)
"b/$APRS_CALL/250"
}
// ВАЖНО: без "vers" — сервер пользователя (T2SPB/T2APATITY)
// не принимает фильтр и молчит; с "vers" — фильтр игнорируется
val login = "user $APRS_CALL pass ${passcode(APRS_CALL)} " +
"filter $filter\r\n"
sock.getOutputStream().write(login.toByteArray())
sock.getOutputStream().flush()
Log.d(tag, "connected to ${APRS_HOSTS[hostIdx]}, filter=$filter")
val reader = BufferedReader(InputStreamReader(sock.getInputStream()))
val start = System.currentTimeMillis()
var sawPacket = false
while (running) {
val line = try {
reader.readLine()
} catch (e: java.net.SocketTimeoutException) {
// Нет строк (даже heartbeat) — канал мёртв, переподключаемся
break
} ?: break
if (line.startsWith("#")) {
if (line.contains("logresp")) {
val server = line.substringAfter("server", "?").trim()
Log.d(tag, "server=$server")
}
// Сервер шлёт heartbeat, но не отдаёт пакеты. На основном
// сервере слушаем долго (станции передают редко), на
// запасном — 3 минуты, и возвращаемся на основной
val wait = if (hostIdx == 0) 900_000L else 180_000L
if (!sawPacket && System.currentTimeMillis() - start > wait) break
continue
}
sawPacket = true
if (BuildConfig.DEBUG) Log.d(tag, "pkt: " + line.substringBefore('>'))
parse(line)?.let { wx ->
Log.d(tag, "wx: $wx RAW=${line.length} $line")
main.post { onWeather(wx) }
}
}
// Сервер принял логин, но не отдал ни одного пакета (аплинк молчит) —
// в следующий раз пробуем следующий сервер. С пакетами — возвращаемся
// к первому (сервер пользователя)
hostIdx = if (sawPacket) 0 else (hostIdx + 1) % APRS_HOSTS.size
} finally {
try {
sock.close()
} catch (_: Exception) {
}
}
}
/** Парсинг: CALL>...:!|@|=<lat/lon>_<метео> -> "<call> Температура <T>C". */
private fun parse(line: String): String? {
if (line.startsWith("#") || line.isEmpty()) return null
// Разделитель заголовка — ПЕРВЫЙ ':' после '>'; в info могут быть свои ':'
// (например "Temp: 16.4°C Uptime: 319h")
val gt = line.indexOf('>')
val idx = line.indexOf(':', gt)
if (idx < 0 || idx + 1 >= line.length) return null
val body = line.substring(idx + 1)
if (body.isEmpty() || body[0] !in "!@=") return null
val temp = parseTemp(body) ?: return null
// В APRS метео-часть температура в градусах Фаренгейта
val c = (temp - 32.0) * 5.0 / 9.0
val t = Math.round(c).toString()
return "WX " + line.substringBefore('>') + " Температура: " + t + " °C"
}
/** Температура из метео-части после символа '_': t057=57°F, t06=6°F, t-08=-8°F.
* В APRS метео-формате температура — целые градусы Фаренгейта. */
private fun parseTemp(body: String): Double? {
val u = body.indexOf('_')
if (u < 0) return null
val meta = body.substring(u + 1).substringBefore(' ') // до комментария
val m = Regex("[tT]([+-]?)(\\d{2,3})").find(meta) ?: return null
val sign = if (m.groupValues[1] == "-") -1 else 1
return sign * m.groupValues[2].toInt().toDouble()
}
/** APRS-IS passcode по позывному (алгоритм aprsc). */
private fun passcode(call: String): Int {
var hash = 0x73E2
for (c in call.uppercase()) {
hash = hash xor (c.code shl 8)
hash = ((hash and 0xFFFF) * 0x89) and 0xFFFF
}
return hash and 0x7FFF
}
companion object {
// Позывной для подключения к APRS-IS (SVX). Сначала сервер пользователя;
// если он не отдаёт трафик (аплинк молчит), переходим на публичный aprs2.net
private const val APRS_CALL = "SVX"
private val APRS_HOSTS = listOf("rotate.radiorubka.org", "rotate.aprs2.net")
private const val APRS_PORT = 14580
}
}

View File

@@ -58,7 +58,12 @@ class OpusEngine(
private var dbgOut = 0
private var playStarted = false
private var gateStart = 0L
private val jitterTarget = 6 // ≈ 120 мс накапливаем до первого слива
private var lastRealPacketTime = 0L
// Настраивается из скрытых настроек звука (AudioSettingsActivity)
@Volatile
var rxBufferMs = 417 // буфер AudioTrack, мс (40000 байт ≈ 417 мс при 48 кГц)
@Volatile
var jitterFrames = 10 // сколько кадров накопить до первого слива (кадр = 20 мс)
private val configBuffers = ArrayList<ByteArray>()
private var configIdx = 0
@@ -75,18 +80,7 @@ class OpusEngine(
// delay + seek preroll) ТОЛЬКО из первых входных буферов
// (см. queueDecoderConfig); csd-ключи MediaFormat он игнорирует, и без
// конфига первый же аудио-кадр ломает декодер (C2_CORRUPTED).
decoder = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.setCallback(decoderCallback)
it.configure(format, null, null, 0)
queueDecoderConfig() // готовим конфиг ДО start: колбэки уйдут после
it.start()
}
} catch (e: Exception) {
Log.e(TAG, "createDecoder failed: ${e.message}")
null
}
if (decoder == null) return
if (!startDecoderCodec(format)) return
Log.d(TAG, "decoder started, config=${configBuffers.size}B")
val minBuf = AudioTrack.getMinBufferSize(
OUT_SAMPLE_RATE,
@@ -99,7 +93,7 @@ class OpusEngine(
OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, 20000), // ~208 мс — буфер AudioTrack и есть джиттер-буфер
maxOf(minBuf, rxBufferMs * 96), // 96 байт/мс при 48 кГц моно 16 бит
AudioTrack.MODE_STREAM
) // play() включаем на первом выходном кадре — буфер успевает накопиться
} catch (e: Exception) {
@@ -112,13 +106,51 @@ class OpusEngine(
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) {
@@ -156,6 +188,7 @@ class OpusEngine(
}
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)
@@ -176,7 +209,12 @@ class OpusEngine(
}
}
player?.write(pcm, 0, pcm.size)
onLevel(SRC_SPEAKER, pcmLevel(pcm))
// Уровень показываем только для реальных пакетов: во время
// скрытия потерь (conceal) шкала должна быть в нуле
val real = System.currentTimeMillis() - lastRealPacketTime < 400
val lvl = if (real) pcmLevel(pcm) else 0f
// Порог тишины: шум декодера и паузы речи — в ноль
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
if (dbgOut < 5) {
Log.d(TAG, "out ${pcm.size}B")
dbgOut++
@@ -193,6 +231,7 @@ class OpusEngine(
}
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)}")
}
@@ -245,6 +284,7 @@ class OpusEngine(
/** Подача одного Opus-пакета на декодер (с UDP-потока). */
fun onAudioPacket(packet: ByteArray) {
lastRealPacketTime = System.currentTimeMillis()
rxQueue.add(packet)
receivedAny = true
if (gateStart == 0L) gateStart = System.currentTimeMillis()
@@ -253,26 +293,56 @@ class OpusEngine(
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) }
}
/** Джиттер-гейт открыт: накопили кадры или вышло 80 мс с первого пакета. */
/** Джиттер-гейт открыт: накопили кадры или вышло 150 мс с первого пакета. */
private fun gateOpen(): Boolean {
if (playStarted) return true
if (rxQueue.size >= jitterTarget) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 80
if (rxQueue.size >= jitterFrames) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 150
}
fun onAudioFlush() {
try {
decoder?.flush()
// MediaCodec.flush() возвращает входные буферы, но уже выданные индексы
// остаются у нас в freeInputs и дублируются колбэками «повторной выдачи» —
// queueInputBuffer падает (index not owned by client) и кадры теряются.
// Надёжнее пересоздать декодер: колбэки старого отсекаются проверкой
// codec !== decoder.
player?.flush()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
onLevel(SRC_SPEAKER, 0f)
restartDecoder()
} 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.
@@ -335,7 +405,7 @@ class OpusEngine(
// ---------- Захват (TX) ----------
/** Начать PTT: запустить запись и кодирование. */
fun startCapture(onPacket: (ByteArray) -> Unit) {
fun startCapture(micGain: Float = 1.0f, onPacket: (ByteArray) -> Unit) {
if (encoding) return
val enc = createEncoder()
encoder = enc
@@ -358,7 +428,7 @@ class OpusEngine(
rec.startRecording()
encoding = true
val t = Thread({
captureLoop(enc, rec, onPacket)
captureLoop(enc, rec, micGain, onPacket)
}, "svx-capture")
t.isDaemon = true
recThread = t
@@ -382,7 +452,7 @@ class OpusEngine(
}
}
private fun captureLoop(enc: MediaCodec, rec: AudioRecord, onPacket: (ByteArray) -> Unit) {
private fun captureLoop(enc: MediaCodec, rec: AudioRecord, micGain: Float, onPacket: (ByteArray) -> Unit) {
val pcmBuf = ByteBuffer.allocateDirect(FRAME_SIZE * 2).order(ByteOrder.LITTLE_ENDIAN)
val micLevel = FloatArray(1)
while (encoding) {
@@ -392,6 +462,14 @@ class OpusEngine(
pcmBuf.limit(n)
val pcm = ByteArray(n)
pcmBuf.get(pcm)
if (micGain != 1.0f) {
for (i in 0 until n step 2) {
val s = ((pcm[i + 1].toInt() shl 8) or (pcm[i].toInt() and 0xFF)).toShort()
val g = (s * micGain).toInt().coerceIn(-32768, 32767)
pcm[i] = (g and 0xFF).toByte()
pcm[i + 1] = ((g shr 8) and 0xFF).toByte()
}
}
micLevel[0] = pcmLevel(pcm)
onLevel(SRC_MIC, micLevel[0])
try {

View File

@@ -12,44 +12,154 @@ data class ConnectionSettings(
val groups: List<Long> = emptyList()
)
/** Профиль настроек: имя + параметры подключения. */
data class Profile(
val name: String,
val settings: ConnectionSettings
)
object Prefs {
private const val PREFS_NAME = "svxremote"
const val PROFILE_COUNT = 5
fun load(context: Context): ConnectionSettings {
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return ConnectionSettings(
host = sp.getString("host", "") ?: "",
port = sp.getInt("port", 5300),
callsign = sp.getString("callsign", "") ?: "",
authKey = sp.getString("auth_key", "") ?: "",
talkGroup = sp.getLong("tg", 11),
groups = (sp.getString("groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.ifEmpty { listOf(9L, 11L, 12L, 13L) }
private fun sp(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/** Индекс активного профиля (0..PROFILE_COUNT-1). */
fun activeProfileIndex(context: Context): Int =
sp(context).getInt("active_profile", 0).coerceIn(0, PROFILE_COUNT - 1)
fun setActiveProfile(context: Context, index: Int) {
sp(context).edit().putInt("active_profile", index.coerceIn(0, PROFILE_COUNT - 1)).apply()
}
/** Настройки активного профиля. */
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) {
val sp: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
sp.edit()
.putString("host", s.host.trim())
.putInt("port", s.port)
.putString("callsign", s.callsign.trim())
.putString("auth_key", s.authKey)
.putLong("tg", s.talkGroup)
.putString("groups", s.groups.distinct().joinToString(","))
.apply()
private fun saveProfiles(context: Context, profiles: List<Profile>) {
val e = sp(context).edit()
profiles.forEachIndexed { i, p ->
e.putString("p${i}_name", p.name)
.putString("p${i}_host", p.settings.host.trim())
.putInt("p${i}_port", p.settings.port)
.putString("p${i}_callsign", p.settings.callsign.trim())
.putString("p${i}_auth", p.settings.authKey)
.putLong("p${i}_tg", p.settings.talkGroup)
.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 {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean("node_toasts", true)
return sp(context).getBoolean("node_toasts", true)
}
fun setNodeToastsEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean("node_toasts", enabled).apply()
sp(context).edit().putBoolean("node_toasts", enabled).apply()
}
}
// ---------- APRS метеостанции (экспериментально) ----------
/** Координаты "lat,lon" (пусто = функция выключена). */
fun aprsCoords(context: Context): String =
sp(context).getString("aprs_coords", "") ?: ""
fun setAprsCoords(context: Context, coords: String) {
sp(context).edit().putString("aprs_coords", coords.trim()).apply()
}
/** Усиление микрофона (1.0 = без усиления). */
fun micGain(context: Context): Float =
sp(context).getFloat("mic_gain", 1.0f)
fun setMicGain(context: Context, gain: Float) {
sp(context).edit().putFloat("mic_gain", gain.coerceIn(0.5f, 5.0f)).apply()
}
// ---------- Скрытые настройки звука (AudioSettingsActivity) ----------
/** Буфер воспроизведения RX, мс. */
fun rxBufferMs(context: Context): Int =
sp(context).getInt("rx_buffer_ms", 417)
fun setRxBufferMs(context: Context, ms: Int) {
sp(context).edit().putInt("rx_buffer_ms", ms.coerceIn(100, 600)).apply()
}
/** Накопление перед стартом воспроизведения, мс (кратно 20). */
fun rxJitterMs(context: Context): Int =
sp(context).getInt("rx_jitter_ms", 200)
fun setRxJitterMs(context: Context, ms: Int) {
sp(context).edit().putInt("rx_jitter_ms", ms.coerceIn(0, 400)).apply()
}
}

View File

@@ -104,10 +104,15 @@ class ReflectorClient(
@Synchronized
fun connect(settings: Settings) {
if (running.get()) return
this.settings = settings
manualDisconnect.set(false)
fatal = false
if (running.get()) {
// Перезапуск с новыми настройками (например, смена профиля)
running.set(false)
synchronized(stopEvent) { stopEvent.notifyAll() }
abortSockets()
}
running.set(true)
state = STATE_DISCONNECTED
seqCounter = 0
@@ -166,7 +171,23 @@ class ReflectorClient(
try {
Log.d(TAG, "selectGroup tg=$tg")
sendTcp(sock, MsgBuilder.selectTg(tg))
} catch (_: Exception) {
} 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}")
}
}
@@ -389,7 +410,10 @@ class ReflectorClient(
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
} else {
sendTcp(socket, MsgBuilder.selectTg(monitor.first()))
// Мониторинг: сидим на базовой группе и слушаем
// уведомления обо всех группах (аудио идёт только
// с выбранной, поэтому переключение — в MainActivity).
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(monitor))
}
openUdp(s)

View File

@@ -0,0 +1,128 @@
package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import kotlin.math.roundToInt
/**
* S-метр: полоса со шкалой S1–S9, +20…+60, заливкой уровня, белой пик-меткой
* (peak-hold) и цифрой текущего уровня справа («S9», «+43» или «---»).
* Уровень 0..1; S9 = 55% пути, +60 = 88%.
*/
class SMeterView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var fillColor = 0xFF58A6FF.toInt()
private val track = Paint().apply { color = 0xFF21262D.toInt() }
private val fill = Paint().apply { color = fillColor }
private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1f) }
private val tickMajor = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1.5f) }
private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2f) }
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(9)
}
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFE6EDF3.toInt()
textSize = sp(12)
textAlign = Paint.Align.RIGHT
}
private val idlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(12)
textAlign = Paint.Align.RIGHT
}
@Volatile
private var target = 0f
private var shown = 0f
private var peak = 0f
private var lastActive = 0L
private val handler = Handler(Looper.getMainLooper())
private val ticker = object : Runnable {
override fun run() {
// Гарантированный сброс: если сигнала не было 600 мс — в ноль
if (System.currentTimeMillis() - lastActive > 600 && target != 0f) target = 0f
// Сглаживание уровня и медленный спад пика
shown += (target - shown) * 0.35f
if (Math.abs(target - shown) < 0.002f) shown = target
peak = maxOf(peak * 0.985f, shown)
if (peak < 0.003f) peak = 0f
invalidate()
handler.postDelayed(this, 100)
}
}
fun setColor(color: Int) {
fillColor = color
fill.color = color
}
fun setLevel(level: Float) {
if (level > 0.01f) lastActive = System.currentTimeMillis()
target = level.coerceIn(0f, 1f)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
handler.post(ticker)
}
override fun onDetachedFromWindow() {
handler.removeCallbacksAndMessages(null)
super.onDetachedFromWindow()
}
private fun dp(v: Float): Float = v * resources.displayMetrics.density
private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity
private fun levelText(level: Float): String = when {
level < 0.005f -> "---"
level < 0.55f -> "S${(level / 0.55f * 9f).roundToInt().coerceIn(1, 9)}"
else -> "+${(((level - 0.55f) / 0.45f) * 60f).roundToInt().coerceIn(1, 99)}"
}
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
val left = dp(2f)
val right = w - dp(2f)
val bandTop = dp(4f)
val bandH = dp(14f)
// полоса
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val lvl = shown.coerceIn(0f, 1f)
if (lvl > 0.002f) {
canvas.drawRect(left, bandTop, left + lvl * (right - left), bandTop + bandH, fill)
}
// деления: S1..S9, +20..+60
val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f,
"S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f)
for ((text, pos) in marks) {
val mx = left + pos * (right - left)
val major = pos == 0.55f || pos == 0.88f
canvas.drawLine(mx, bandTop, mx, bandTop + bandH, if (major) tickMajor else tick)
canvas.drawText(text, mx - dp(8f), bandTop + bandH + dp(11f), label)
}
// пик-метка
if (peak > 0.003f) {
val px = left + peak.coerceIn(0f, 1f) * (right - left)
canvas.drawLine(px, bandTop - dp(3f), px, bandTop + bandH + dp(3f), peakPaint)
}
// цифра уровня справа от полосы (и чуть выше делений)
val p = if (target < 0.005f) idlePaint else valuePaint
canvas.drawText(levelText(target), right, bandTop + bandH - dp(3f), p)
}
}

View File

@@ -0,0 +1,156 @@
package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import kotlin.random.Random
/**
* Временное демо-окно с примерами индикаторов звука (S-метр, клиппинг,
* джиттер/потери). Открывается удержанием на надписи «На связи».
* Уровни симулируются, чтобы было видно, как индикаторы работают.
*/
class SoundMeterPreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val bg = Paint().apply { color = 0xFF0D1117.toInt() }
private val track = Paint().apply { color = 0xFF21262D.toInt() }
private val fill = Paint().apply { color = 0xFF58A6FF.toInt() }
private val fillHot = Paint().apply { color = 0xFFF85149.toInt() }
private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2) }
private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1) }
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(11)
}
private val labelSmall = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(9)
}
private val clipDot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFF85149.toInt()
}
private val clipDotOff = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF30363D.toInt()
}
private val handler = Handler(Looper.getMainLooper())
private var simLevel = 0.0f
private var simPeak = 0.0f
private var simJitter = 30.0f
private var simLoss = 0.1f
private var t = 0.0
private val anim = object : Runnable {
override fun run() {
t += 0.08
val wave = ((Math.sin(t) + Math.sin(t * 2.3)) / 2 + 1) / 2
simLevel = (wave * 0.62 + Random.nextFloat() * 0.12).toFloat()
// Периодически «перегруз» — клиппинг и пики
if (Random.nextFloat() > 0.92f) simLevel = 0.98f
simPeak = maxOf(simLevel, simPeak - 0.02f)
simJitter = 25 + Random.nextFloat() * 120
simLoss = (Random.nextFloat() * 0.5f).coerceAtLeast(0.03f)
invalidate()
handler.postDelayed(this, 50)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
handler.post(anim)
}
override fun onDetachedFromWindow() {
handler.removeCallbacksAndMessages(null)
super.onDetachedFromWindow()
}
private fun dp(v: Int): Float = v * resources.displayMetrics.density
private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
canvas.drawRect(0f, 0f, w, h, bg)
// --- S-метр ---
var y = dp(18)
canvas.drawText("S-метр (peak-hold)", dp(16), y, label)
y += dp(8)
drawSMeter(canvas, w, y)
// --- Уровень + клиппинг ---
y += dp(58)
canvas.drawText("Уровень + клиппинг", dp(16), y, label)
y += dp(8)
drawVUMeter(canvas, w, y)
// --- Джиттер / потери ---
y += dp(58)
canvas.drawText("Джиттер и потери пакетов", dp(16), y, label)
y += dp(18)
canvas.drawText(
"джиттер %d мс · потери %.1f%%".format(simJitter.toInt(), simLoss),
dp(16), y, label
)
val bar = RectF(dp(16), y + dp(6), w - dp(16), y + dp(16))
canvas.drawRect(bar, track)
val jw = (simJitter / 250f).coerceIn(0f, 1f) * bar.width()
canvas.drawRect(bar.left, bar.top, bar.left + jw, bar.bottom, fill)
}
/** S-метр: полоса со шкалой S1..S9, +20..+60 и пик-меткой. */
private fun drawSMeter(canvas: Canvas, w: Float, top: Float) {
val left = dp(16)
val right = w - dp(16)
val bandTop = top + dp(2)
val bandH = dp(14)
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val level = (simLevel).coerceIn(0f, 1f)
val fw = level * (right - left)
canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, if (level > 0.85f) fillHot else fill)
// пик-метка
val px = left + simPeak.coerceIn(0f, 1f) * (right - left)
canvas.drawLine(px, bandTop - dp(3), px, bandTop + bandH + dp(3), peakPaint)
// деления и шкала
val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f,
"S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f)
for ((text, pos) in marks) {
val mx = left + pos * (right - left)
canvas.drawLine(mx, bandTop, mx, bandTop + bandH, tick)
canvas.drawText(text, mx - dp(10), bandTop + bandH + dp(12), labelSmall)
}
canvas.drawText("+%.0f".format(level * 60), left, bandTop - dp(4), labelSmall)
}
/** VU-полоса с зоной перегрузки и красной точкой клиппинга. */
private fun drawVUMeter(canvas: Canvas, w: Float, top: Float) {
val left = dp(16)
val right = w - dp(52)
val bandTop = top + dp(2)
val bandH = dp(14)
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val level = simLevel.coerceIn(0f, 1f)
val fw = level * (right - left)
canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, fill)
// красная зона последних 15%
canvas.drawRect(
right - (right - left) * 0.15f, bandTop, right, bandTop + bandH,
if (level > 0.85f) fillHot else track
)
// клиппинг-точка
val cxp = w - dp(24)
val cyp = bandTop + bandH / 2
canvas.drawCircle(cxp, cyp, dp(7), if (level > 0.92f) clipDot else clipDotOff)
canvas.drawText("CLIP", cxp - dp(20), cyp + dp(10), labelSmall)
}
}

View File

@@ -0,0 +1,127 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/audio_settings_title"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/audio_settings_hint"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginBottom="20dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/audio_mic_gain"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_mic_gain_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_mic_gain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="40"
android:layout_marginBottom="20dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/audio_rx_buffer"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_rx_buffer_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_rx_buffer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="25"
android:layout_marginBottom="20dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/audio_jitter"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_jitter_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_jitter"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="20"
android:layout_marginBottom="24dp" />
<Button
android:id="@+id/btn_audio_save"
android:layout_width="match_parent"
android:layout_height="52dp"
android:text="@string/btn_save"
android:textColor="#0D1117"
android:background="@drawable/bg_primary_btn"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>

View File

@@ -29,6 +29,7 @@
android:layout_weight="1" />
<TextView
android:id="@+id/lbl_app_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/app_title"
@@ -106,9 +107,10 @@
<TextView
android:id="@+id/lbl_subtitle"
android:layout_width="wrap_content"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:singleLine="true"
@@ -139,40 +141,22 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
android:gravity="top">
<TextView
android:layout_width="34dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/meter_rx"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
<FrameLayout
<ru.ua1zbe.svxremote.ui.SMeterView
android:id="@+id/rx_meter"
android:layout_width="0dp"
android:layout_height="16dp"
android:layout_weight="1"
android:background="@drawable/bg_meter">
<View
android:id="@+id/rx_bar_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_meter_fill_rx"
android:pivotX="0"
android:scaleX="0" />
</FrameLayout>
<TextView
android:id="@+id/rx_db"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:gravity="end"
android:fontFamily="monospace"
android:text="@string/meter_idle"
android:textColor="@color/text_primary"
android:textSize="13sp" />
android:layout_height="52dp"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
@@ -180,40 +164,22 @@
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:gravity="center_vertical">
android:gravity="top">
<TextView
android:layout_width="34dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/meter_mic"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
<FrameLayout
<ru.ua1zbe.svxremote.ui.SMeterView
android:id="@+id/mic_meter"
android:layout_width="0dp"
android:layout_height="16dp"
android:layout_weight="1"
android:background="@drawable/bg_meter">
<View
android:id="@+id/mic_bar_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_meter_fill_mic"
android:pivotX="0"
android:scaleX="0" />
</FrameLayout>
<TextView
android:id="@+id/mic_db"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:gravity="end"
android:fontFamily="monospace"
android:text="@string/meter_idle"
android:textColor="@color/text_primary"
android:textSize="13sp" />
android:layout_height="52dp"
android:layout_weight="1" />
</LinearLayout>
</LinearLayout>
@@ -239,6 +205,17 @@
android:singleLine="true"
android:ellipsize="end" />
<!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView
android:id="@+id/lbl_duplex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:textSize="24sp"
android:textStyle="bold"
android:visibility="invisible" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"

View File

@@ -11,6 +11,23 @@
android:orientation="vertical"
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
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -148,6 +165,43 @@
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_label"
android:textColor="@color/accent"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_hint"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_coords"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<EditText
android:id="@+id/input_aprs_coords"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:inputType="text"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="16sp"
android:background="@drawable/bg_input"
android:padding="12dp"
android:hint="@string/pref_aprs_coords_hint" />
<Button
android:id="@+id/btn_save"
android:layout_width="match_parent"
@@ -164,7 +218,6 @@
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/app_version"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>

View File

@@ -15,12 +15,21 @@
<string name="nodes_title">Узлы в сети (%1$d)</string>
<string name="toast_nodes_empty">Нет подключенных узлов</string>
<string name="pref_node_toasts">Всплывающее уведомление о подключении/отключении узлов</string>
<string name="pref_aprs_label">Метеостанции (APRS)</string>
<string name="pref_aprs_hint">Экспериментально: принимает метеостанции в радиусе 250 км. Последняя станция показывается вместо строки сервера на главном экране.</string>
<string name="pref_aprs_coords">Координаты (широта, долгота)</string>
<string name="pref_aprs_coords_hint">пусто = СПб, напр. 59.95, 30.30</string>
<string name="audio_settings_title">Настройки звука</string>
<string name="audio_settings_hint">Скрытые настройки: применяются при следующем подключении/передаче.</string>
<string name="audio_mic_gain">Усиление микрофона</string>
<string name="audio_rx_buffer">Буфер воспроизведения</string>
<string name="audio_jitter">Накопление перед стартом</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="app_version">SVXhomyak — v1.0.5</string>
<string name="app_version_fmt">SVXhomyak — v%1$s</string>
<string name="saved">Сохранено</string>
<string name="pref_callsign_hint">например UA1ZBE</string>
@@ -28,12 +37,13 @@
<string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Функция находится в разработке. Может работать некорректно!</string>
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
<string name="monitor_off">Обычный режим</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="talker_now">Говорит TG%1$d</string>
<string name="duplex_rx">RX</string>
<string name="duplex_tx">TX</string>
<string name="status_big_disconnected">DISCONNECTED</string>
<string name="status_big_connected">CONNECTED</string>
<string name="status_big_talking">ПЕРЕДАЧА</string>
@@ -43,7 +53,9 @@
<string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string>
<string name="meter_idle">---</string>
<string name="meter_db">-%1$d dB</string>
<string name="meter_db">+%1$d dB</string>
<string name="meter_preview_title">Примеры индикаторов звука</string>
<string name="meter_preview_hint">Временное демо (уровни симулируются). Скажи, что реализовать по-настоящему.</string>
<string name="tg_badge">TG%1$d ▾</string>
<string name="talker_last_none">Последний: —</string>
<string name="status_connecting">Подключение…</string>
@@ -59,7 +71,6 @@
<string name="btn_disconnect">Отключиться</string>
<string name="talker_none">—</string>
<string name="talker_now">Последний: %1$s</string>
<string name="talker_on_tg">группа %1$d</string>
<string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string>
@@ -75,6 +86,16 @@
<string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string>
<string name="toast_settings_empty">Заполните настройки подключения</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="ptt_disabled">Нет связи</string>