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.util.Log
import android.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
@@ -44,6 +45,7 @@ class MainActivity : Activity(), SvxService.Listener {
}
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 +64,11 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var btnMonitor: ImageButton
private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null
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 +95,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 +136,24 @@ class MainActivity : Activity(), SvxService.Listener {
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 {
if (connected) {
svc?.disconnect()
@@ -176,7 +199,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() &&
@@ -414,6 +452,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 +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() {
val active = monitoring
btnMonitor.imageTintList = ColorStateList.valueOf(

View File

@@ -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()
}
}
}

View File

@@ -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()
}
}
}

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

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"

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"

View File

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