6 Commits

Author SHA1 Message Date
ua1zbe
884678d36d Fix RX audio for subsequent transmissions (recreate decoder on flush) 2026-08-17 13:18:50 +03:00
ua1zbe
9d7b659231 Add last talkers log (15 entries, time/callsign/TG) 2026-08-17 11:28:39 +03:00
ua1zbe
ee61d3ef6f Update monitored TG when switching group live 2026-08-17 11:14:41 +03:00
ua1zbe
3109c52bbd Fix group switching: send selectGroup on background thread 2026-08-17 11:11:30 +03:00
ua1zbe
eeffc14f1a Switch talk group without reconnecting 2026-08-17 10:55:44 +03:00
ua1zbe
9838daea2f Add multiple connection profiles (v1.0.6) 2026-08-17 10:39:15 +03:00
9 changed files with 385 additions and 58 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
@@ -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()) {

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

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

View File

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

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

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