13 Commits

11 changed files with 632 additions and 62 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 = 7 versionCode = 9
versionName = "1.0.5" versionName = "1.0.7"
} }
buildTypes { buildTypes {

View File

@@ -20,6 +20,7 @@ import android.os.Looper
import android.provider.Settings import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.Gravity import android.view.Gravity
import android.view.MotionEvent
import android.view.View import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.ImageButton import android.widget.ImageButton
@@ -28,6 +29,7 @@ import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import java.io.File import java.io.File
import ru.ua1zbe.svxremote.aprs.AprsMeteo
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
@@ -41,9 +43,11 @@ class MainActivity : Activity(), SvxService.Listener {
companion object { companion object {
private const val TAG = "SVXRemote" private const val TAG = "SVXRemote"
private const val TALK_LOG_MAX = 15
} }
private lateinit var statusChip: TextView private lateinit var statusChip: TextView
private lateinit var lblAppTitle: TextView
private lateinit var lblSubtitle: TextView private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView private lateinit var lblTg: TextView
@@ -62,9 +66,15 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var btnMonitor: ImageButton private lateinit var btnMonitor: ImageButton
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null
private data class TalkLogEntry(val time: Long, val callsign: String, val tg: Long)
private val talkLog = ArrayDeque<TalkLogEntry>()
private var connected = false private var connected = false
private var monitoring = false private var monitoring = false
private var connectedSettings: ConnectionSettings? = null
private var priorityActive = false private var priorityActive = false
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11) private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
@@ -83,6 +93,26 @@ class MainActivity : Activity(), SvxService.Listener {
private var pendingUpdate: UpdateChecker.UpdateInfo? = null private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = 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?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
SplashActivity.mainAlive = true SplashActivity.mainAlive = true
@@ -91,6 +121,7 @@ class MainActivity : Activity(), SvxService.Listener {
checkForUpdates() checkForUpdates()
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblAppTitle = findViewById(R.id.lbl_app_title)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
lblTalkerTitle = findViewById(R.id.lbl_talker_title) lblTalkerTitle = findViewById(R.id.lbl_talker_title)
lblTalker = findViewById(R.id.lbl_talker) lblTalker = findViewById(R.id.lbl_talker)
@@ -131,6 +162,26 @@ class MainActivity : Activity(), SvxService.Listener {
lblCallsign.setOnClickListener { showNodesDialog() } lblCallsign.setOnClickListener { showNodesDialog() }
lblTalker.setOnClickListener { showTalkLogDialog() }
// Удержание на названии программы (> 2 с) — выбор профиля настроек
lblAppTitle.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val r = Runnable { showProfileDialog() }
titleHoldRunnable = r
mainHandler.postDelayed(r, 2000)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
titleHoldRunnable?.let { mainHandler.removeCallbacks(it) }
titleHoldRunnable = null
true
}
else -> true
}
}
connectZone.setOnClickListener { connectZone.setOnClickListener {
if (connected) { if (connected) {
svc?.disconnect() svc?.disconnect()
@@ -170,14 +221,36 @@ class MainActivity : Activity(), SvxService.Listener {
}) })
refreshUi() refreshUi()
startAprsMeteo()
lastAprsCoords = Prefs.aprsCoords(this).trim()
} }
private var autoConnectAttempted = false private var autoConnectAttempted = false
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val prev = settings
settings = Prefs.load(this) settings = Prefs.load(this)
if (prev != settings && connectedSettings != null) {
if (settings.host.isBlank() || settings.callsign.isBlank()) {
monitoring = false
priorityActive = false
updateMonitorButton()
updatePttEnabled()
svc?.disconnect()
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
refreshUi() refreshUi()
val aprsCoords = Prefs.aprsCoords(this).trim()
if (aprsCoords != lastAprsCoords) {
lastAprsCoords = aprsCoords
startAprsMeteo()
}
if (!autoConnectAttempted && if (!autoConnectAttempted &&
settings.host.isNotBlank() && settings.host.isNotBlank() &&
settings.callsign.isNotBlank() settings.callsign.isNotBlank()
@@ -194,6 +267,8 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onDestroy() { override fun onDestroy() {
super.onDestroy() super.onDestroy()
hideNodeOverlay() hideNodeOverlay()
aprsMeteo?.stop()
aprsMeteo = null
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false SplashActivity.mainAlive = false
svc?.listener = null svc?.listener = null
@@ -202,7 +277,7 @@ class MainActivity : Activity(), SvxService.Listener {
private fun refreshUi() { private fun refreshUi() {
val host = settings.host val host = settings.host
lblSubtitle.text = if (host.isBlank()) { lblSubtitle.text = lastMeteo ?: if (host.isBlank()) {
getString(R.string.settings_not_configured) getString(R.string.settings_not_configured)
} else { } else {
getString(R.string.server_subtitle, host, settings.port, settings.talkGroup) getString(R.string.server_subtitle, host, settings.port, settings.talkGroup)
@@ -293,6 +368,10 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onTalkerStart(tg: Long, callsign: String) { override fun onTalkerStart(tg: Long, callsign: String) {
mainHandler.post { mainHandler.post {
talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg))
while (talkLog.size > TALK_LOG_MAX) {
talkLog.removeFirst()
}
val priority = monitoring && settings.groups.firstOrNull() == tg val priority = monitoring && settings.groups.firstOrNull() == tg
if (priority) { if (priority) {
priorityActive = true priorityActive = true
@@ -414,6 +493,7 @@ class MainActivity : Activity(), SvxService.Listener {
/** Запустить сервис и подключиться с текущими настройками. */ /** Запустить сервис и подключиться с текущими настройками. */
private fun connect() { private fun connect() {
connectedSettings = settings
startForegroundService(Intent(this, SvxService::class.java)) startForegroundService(Intent(this, SvxService::class.java))
bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE) bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE)
mainHandler.postDelayed({ mainHandler.postDelayed({
@@ -459,6 +539,45 @@ 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
priorityActive = false
updateMonitorButton()
updatePttEnabled()
refreshUi()
if (settings.host.isBlank() || settings.callsign.isBlank()) {
toast(getString(R.string.toast_need_settings))
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
}
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
private fun updateMonitorButton() { private fun updateMonitorButton() {
val active = monitoring val active = monitoring
btnMonitor.imageTintList = ColorStateList.valueOf( btnMonitor.imageTintList = ColorStateList.valueOf(
@@ -469,13 +588,16 @@ class MainActivity : Activity(), SvxService.Listener {
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor) getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
} }
/** Переключиться на другую группу: пересохранить и переподключиться. */ /** Переключиться на другую группу: отправить серверу по живому соединению. */
private fun switchGroup(tg: Long) { private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg) settings = settings.copy(talkGroup = tg)
connectedSettings = settings
Prefs.save(this, settings) Prefs.save(this, settings)
refreshUi() refreshUi()
svc?.disconnect() svc?.selectGroup(tg)
mainHandler.postDelayed({ connect() }, 500) if (!monitoring) {
svc?.setMonitorTgs(listOf(tg))
}
} }
private fun monitorTgLabel(): String = private fun monitorTgLabel(): String =
@@ -500,6 +622,23 @@ class MainActivity : Activity(), SvxService.Listener {
.show() .show()
} }
/** Лог последних выходов на передачу (до 15, время — позывной — группа). */
private fun showTalkLogDialog() {
if (talkLog.isEmpty()) {
toast(getString(R.string.talk_log_empty))
return
}
val fmt = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault())
val items = talkLog.map { e ->
getString(R.string.talk_log_entry, fmt.format(java.util.Date(e.time)), e.callsign, e.tg)
}.toTypedArray()
AlertDialog.Builder(this)
.setTitle(R.string.talk_log_title)
.setItems(items, null)
.setPositiveButton(R.string.close, null)
.show()
}
private fun showGroupsDialog() { private fun showGroupsDialog() {
val groups = (settings.groups + settings.talkGroup).distinct() val groups = (settings.groups + settings.talkGroup).distinct()
if (groups.isEmpty()) { if (groups.isEmpty()) {

View File

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

View File

@@ -89,7 +89,13 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun isRunning(): Boolean = client.isRunning() fun isRunning(): Boolean = client.isRunning()
fun selectGroup(tg: Long) { 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() { fun startTx() {

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

@@ -75,18 +75,7 @@ class OpusEngine(
// delay + seek preroll) ТОЛЬКО из первых входных буферов // delay + seek preroll) ТОЛЬКО из первых входных буферов
// (см. queueDecoderConfig); csd-ключи MediaFormat он игнорирует, и без // (см. queueDecoderConfig); csd-ключи MediaFormat он игнорирует, и без
// конфига первый же аудио-кадр ломает декодер (C2_CORRUPTED). // конфига первый же аудио-кадр ломает декодер (C2_CORRUPTED).
decoder = try { if (!startDecoderCodec(format)) return
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
Log.d(TAG, "decoder started, config=${configBuffers.size}B") Log.d(TAG, "decoder started, config=${configBuffers.size}B")
val minBuf = AudioTrack.getMinBufferSize( val minBuf = AudioTrack.getMinBufferSize(
OUT_SAMPLE_RATE, OUT_SAMPLE_RATE,
@@ -112,13 +101,51 @@ class OpusEngine(
Log.d(TAG, "decoder started") Log.d(TAG, "decoder started")
} }
/** Создать и запустить только декодер (плеер не трогаем). */
private fun startDecoderCodec(format: MediaFormat): Boolean {
val dec = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.setCallback(decoderCallback)
it.configure(format, null, null, 0)
queueDecoderConfig() // готовим конфиг ДО start: колбэки уйдут после
}
} catch (e: Exception) {
Log.e(TAG, "createDecoder failed: ${e.message}")
null
}
if (dec == null) return false
// decoder назначаем ДО start(): колбэки нового кодека сразу проходят
// проверку codec !== decoder, иначе первый конфиг-колбэк отбрасывается
// и декодер навсегда остаётся без конфига
decoder = dec
freeInputs.clear()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
try {
dec.start()
} catch (e: Exception) {
Log.e(TAG, "decoder start failed: ${e.message}")
decoder = null
try {
dec.release()
} catch (_: Exception) {
}
return false
}
return true
}
/** Асинхронные колбэки кодера: подача входных кадров + слив вывода. */ /** Асинхронные колбэки кодера: подача входных кадров + слив вывода. */
private val decoderCallback = object : MediaCodec.Callback() { private val decoderCallback = object : MediaCodec.Callback() {
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) { 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}") Log.e(TAG, "decoder error: ${e.message} code=${e.errorCode} transient=${e.isTransient}")
} }
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) { override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
// Конфиг (csd) первыми входными буферами — в async-режиме нельзя // Конфиг (csd) первыми входными буферами — в async-режиме нельзя
// использовать dequeueInputBuffer, только колбэки // использовать dequeueInputBuffer, только колбэки
if (configIdx < configBuffers.size) { if (configIdx < configBuffers.size) {
@@ -156,6 +183,7 @@ class OpusEngine(
} }
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) { override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
try { try {
if (info.size > 0) { if (info.size > 0) {
val outBuf = ByteBuffer.allocate(info.size) val outBuf = ByteBuffer.allocate(info.size)
@@ -193,6 +221,7 @@ class OpusEngine(
} }
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) { override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.d(TAG, "dec out format: sr=${format.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " + Log.d(TAG, "dec out format: sr=${format.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " +
"ch=${format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}") "ch=${format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}")
} }
@@ -262,17 +291,47 @@ class OpusEngine(
fun onAudioFlush() { fun onAudioFlush() {
try { try {
decoder?.flush() // MediaCodec.flush() возвращает входные буферы, но уже выданные индексы
// остаются у нас в freeInputs и дублируются колбэками «повторной выдачи» —
// queueInputBuffer падает (index not owned by client) и кадры теряются.
// Надёжнее пересоздать декодер: колбэки старого отсекаются проверкой
// codec !== decoder.
player?.flush() player?.flush()
rxQueue.clear() rxQueue.clear()
receivedAny = false receivedAny = false
playStarted = false playStarted = false
gateStart = 0L gateStart = 0L
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
restartDecoder()
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
/** Пересоздать только декодер (плеер и джиттер-буфер сохраняем). */
private fun restartDecoder() {
val dec = decoder
decoder = null
if (dec != null) {
try {
dec.stop()
} catch (_: Exception) {
}
try {
dec.release()
} catch (_: Exception) {
}
}
val format = MediaFormat()
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
if (startDecoderCodec(format)) {
concealLogs = 0
dbgOut = 0
ptsCounter = 20_000L
}
}
/** /**
* Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке. * Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке.
* Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM. * Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM.

View File

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

View File

@@ -104,10 +104,15 @@ class ReflectorClient(
@Synchronized @Synchronized
fun connect(settings: Settings) { fun connect(settings: Settings) {
if (running.get()) return
this.settings = settings this.settings = settings
manualDisconnect.set(false) manualDisconnect.set(false)
fatal = false fatal = false
if (running.get()) {
// Перезапуск с новыми настройками (например, смена профиля)
running.set(false)
synchronized(stopEvent) { stopEvent.notifyAll() }
abortSockets()
}
running.set(true) running.set(true)
state = STATE_DISCONNECTED state = STATE_DISCONNECTED
seqCounter = 0 seqCounter = 0
@@ -166,7 +171,23 @@ class ReflectorClient(
try { try {
Log.d(TAG, "selectGroup tg=$tg") Log.d(TAG, "selectGroup tg=$tg")
sendTcp(sock, MsgBuilder.selectTg(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}")
} }
} }

View File

@@ -29,6 +29,7 @@
android:layout_weight="1" /> android:layout_weight="1" />
<TextView <TextView
android:id="@+id/lbl_app_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:text="@string/app_title" android:text="@string/app_title"
@@ -106,9 +107,10 @@
<TextView <TextView
android:id="@+id/lbl_subtitle" android:id="@+id/lbl_subtitle"
android:layout_width="wrap_content" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="14sp" android:textSize="14sp"
android:singleLine="true" android:singleLine="true"

View File

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

View File

@@ -15,6 +15,10 @@
<string name="nodes_title">Узлы в сети (%1$d)</string> <string name="nodes_title">Узлы в сети (%1$d)</string>
<string name="toast_nodes_empty">Нет подключенных узлов</string> <string name="toast_nodes_empty">Нет подключенных узлов</string>
<string name="pref_node_toasts">Всплывающее уведомление о подключении/отключении узлов</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="toast_node_joined">Подключился: %1$s%2$s</string> <string name="toast_node_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %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_tg_suffix"> (группа %1$d)</string>
@@ -75,6 +79,16 @@
<string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string> <string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string>
<string name="toast_settings_empty">Заполните настройки подключения</string> <string name="toast_settings_empty">Заполните настройки подключения</string>
<string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string> <string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string>
<string name="profiles_label">Профиль настроек</string>
<string name="profile_name">Профиль %1$d</string>
<string name="profiles_title">Выбор профиля</string>
<string name="profile_current"> (текущий)</string>
<string name="profile_empty">не настроен</string>
<string name="cancel">Отмена</string>
<string name="talk_log_title">Последние передачи</string>
<string name="talk_log_empty">Журнала передач пока нет</string>
<string name="talk_log_entry">%1$s %2$s TG%3$d</string>
<string name="close">Закрыть</string>
<string name="settings_not_configured">Настройки не заданы</string> <string name="settings_not_configured">Настройки не заданы</string>
<string name="ptt_disabled">Нет связи</string> <string name="ptt_disabled">Нет связи</string>