Add multiple connection profiles (v1.0.6)

This commit is contained in:
ua1zbe
2026-08-17 10:39:15 +03:00
parent 531ec51fb6
commit 9838daea2f
7 changed files with 251 additions and 40 deletions

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
@@ -44,6 +45,7 @@ class MainActivity : Activity(), SvxService.Listener {
} }
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 +64,11 @@ 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 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)
@@ -91,6 +95,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 +136,24 @@ class MainActivity : Activity(), SvxService.Listener {
lblCallsign.setOnClickListener { showNodesDialog() } lblCallsign.setOnClickListener { showNodesDialog() }
// Удержание на названии программы (> 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()
@@ -176,7 +199,22 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onResume() { override fun onResume() {
super.onResume() super.onResume()
val prev = settings
settings = Prefs.load(this) settings = Prefs.load(this)
if (prev != settings && connectedSettings != null) {
if (settings.host.isBlank() || settings.callsign.isBlank()) {
monitoring = false
priorityActive = false
updateMonitorButton()
updatePttEnabled()
svc?.disconnect()
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
refreshUi() refreshUi()
if (!autoConnectAttempted && if (!autoConnectAttempted &&
settings.host.isNotBlank() && settings.host.isNotBlank() &&
@@ -414,6 +452,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 +498,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(

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

@@ -75,6 +75,12 @@
<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="settings_not_configured">Настройки не заданы</string> <string name="settings_not_configured">Настройки не заданы</string>
<string name="ptt_disabled">Нет связи</string> <string name="ptt_disabled">Нет связи</string>