Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
884678d36d | ||
|
|
9d7b659231 | ||
|
|
ee61d3ef6f | ||
|
|
3109c52bbd | ||
|
|
eeffc14f1a | ||
|
|
9838daea2f |
@@ -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
|
||||
@@ -41,9 +42,11 @@ 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
|
||||
@@ -62,9 +65,15 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
private lateinit var btnMonitor: ImageButton
|
||||
|
||||
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 monitoring = false
|
||||
private var connectedSettings: ConnectionSettings? = null
|
||||
private var priorityActive = false
|
||||
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
|
||||
|
||||
@@ -91,6 +100,7 @@ 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)
|
||||
@@ -131,6 +141,26 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
|
||||
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 {
|
||||
if (connected) {
|
||||
svc?.disconnect()
|
||||
@@ -176,7 +206,22 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
|
||||
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
|
||||
priorityActive = false
|
||||
updateMonitorButton()
|
||||
updatePttEnabled()
|
||||
svc?.disconnect()
|
||||
} else if (connected) {
|
||||
svc?.disconnect()
|
||||
mainHandler.postDelayed({ connect() }, 500)
|
||||
} else {
|
||||
connect()
|
||||
}
|
||||
}
|
||||
refreshUi()
|
||||
if (!autoConnectAttempted &&
|
||||
settings.host.isNotBlank() &&
|
||||
@@ -293,6 +338,10 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
|
||||
override fun onTalkerStart(tg: Long, callsign: String) {
|
||||
mainHandler.post {
|
||||
talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg))
|
||||
while (talkLog.size > TALK_LOG_MAX) {
|
||||
talkLog.removeFirst()
|
||||
}
|
||||
val priority = monitoring && settings.groups.firstOrNull() == tg
|
||||
if (priority) {
|
||||
priorityActive = true
|
||||
@@ -414,6 +463,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({
|
||||
@@ -459,6 +509,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() {
|
||||
val active = monitoring
|
||||
btnMonitor.imageTintList = ColorStateList.valueOf(
|
||||
@@ -469,13 +558,16 @@ 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))
|
||||
}
|
||||
}
|
||||
|
||||
private fun monitorTgLabel(): String =
|
||||
@@ -500,6 +592,23 @@ 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 showGroupsDialog() {
|
||||
val groups = (settings.groups + settings.talkGroup).distinct()
|
||||
if (groups.isEmpty()) {
|
||||
|
||||
@@ -2,26 +2,34 @@ 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.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 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)
|
||||
@@ -31,13 +39,24 @@ class SettingsActivity : Activity() {
|
||||
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
|
||||
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
|
||||
|
||||
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 +74,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 +85,24 @@ class SettingsActivity : Activity() {
|
||||
groups = groups
|
||||
)
|
||||
)
|
||||
Prefs.saveProfile(this, currentProfile, profile)
|
||||
Prefs.setActiveProfile(this, currentProfile)
|
||||
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -89,7 +89,13 @@ 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() {
|
||||
|
||||
@@ -75,18 +75,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,
|
||||
@@ -112,13 +101,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 +183,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)
|
||||
@@ -193,6 +221,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)}")
|
||||
}
|
||||
@@ -262,17 +291,47 @@ class OpusEngine(
|
||||
|
||||
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.
|
||||
|
||||
@@ -12,44 +12,118 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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}")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -75,6 +75,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>
|
||||
|
||||
Reference in New Issue
Block a user