17 Commits

Author SHA1 Message Date
ua1zbe
491dff03db 1.0.13 (versionCode 15) 2026-09-16 16:09:40 +03:00
ua1zbe
fe6560e494 RuStore Push: автоподписка устройств на топик svxhomyak при старте 2026-09-16 09:38:16 +03:00
ua1zbe
fac8192499 RuStore Push SDK: получение и отображение пуш-уведомлений
- Подключён ru.rustore.sdk:pushclient:7.4.0 (репозиторий Rustore).
- SvxApp: канал svx_push и инициализация RuStorePushClient с project_id;
  push-токен устройства сохраняется в настройках.
- PushMessagingService (RuStoreMessagingService): onNewToken, onMessageReceived
  (data-only пуши показываем сами), onDeletedMessages, onError.
- Манифест: сервис, значок/канал по умолчанию, android.useAndroidX=true
  (зависимость тянет androidx).
- README: как рассылать пуши через vkpns.rustore.ru API.
2026-09-16 08:35:42 +03:00
ua1zbe
1af9bfda80 Audio: reduce RX startup clipping 2026-08-26 08:11:04 +03:00
ua1zbe
f6c37d4fc0 README: описан функционал — запись в OGG, скрытые настройки звука, мониторинг, PTT в стиле Zello, строка говорящего 2026-08-21 12:34:52 +03:00
ua1zbe
f23d8e35bb 1.0.12: UI — убрана HOLD TO TALK, PTT крупнее, строка говорящего между блоками
- Удалена надпись HOLD TO TALK (lbl_ptt_hint).
- PTT-кнопка увеличена 130→170 dp.
- Строка говорящего перенесена между блоками «на связи» и
  индикаторами; единый формат: «Говорит UA1ZBE TG12» при приёме,
  «UA1ZBE TG12» когда приёма нет (шрифт прежний).
2026-08-21 11:54:44 +03:00
ua1zbe
587d46578e 1.0.12: PTT-кнопка в стиле Zello (градиент, тень, глянец, ободок) 2026-08-21 11:39:46 +03:00
ua1zbe
7342dddd0b 1.0.12: читаемое имя записи (дата/время вместо hex)
rec_YYYY-MM-DD_HH-mm-SS.ogg вместо rec_<hex>.ogg.
2026-08-21 11:31:58 +03:00
ua1zbe
e32d6967aa 1.0.12: список записей по долгому тапу на кнопку записи
- Long-press (3 с) кнопки записи открывает диалог со списком
  файлов recordings/*.ogg.
- В диалоге: воспроизведение (MediaPlayer, переключатель
  play/stop) и удаление с подтверждением.
- Пустой список — тост «Нет записей».
- Иконки ic_play/ic_stop/ic_delete, разметка dialog_recording_item.
2026-08-21 11:28:51 +03:00
ua1zbe
99a867df6f 1.0.12: запись приёма в OGG с пропуском тишины
- RxRecorder: MediaCodec(Opus) + MediaMuxer(OGG), пишет декодированный
  RX PCM 48 кГц; pts не продвигается во время тишины, поэтому паузы
  в эфире не попадают в файл.
- OpusEngine.rxTap: перехват PCM из выхода декодера.
- SvxService: startRecording()/stopRecording()/isRecording().
- MainActivity: кнопка btn_record между позывным и монитором,
  индикация (иконка краснеет), тосты о старте/сохранении.
- Файлы: Android/data/ru.ua1zbe.svxremote/files/recordings/rec_*.ogg.
  Пустой файл (весь сеанс — тишина) удаляется.
2026-08-21 11:09:01 +03:00
ua1zbe
29734e6203 Секции с фоном: блок индикаторов и блок APRS/статуса 2026-08-21 08:29:41 +03:00
ua1zbe
af9bb48f7e Bump to 1.0.11 2026-08-19 09:40:48 +03:00
ua1zbe
c8daa276fc Фикс подсчёта потерь: повторы/инверсии seq говорящего и паузы не считаются потерями 2026-08-19 09:37:44 +03:00
ua1zbe
1bfac034cd RX-статистика включена по умолчанию 2026-08-19 09:08:32 +03:00
ua1zbe
7b8e897e8a RX-статистика (потери/джиттер) на главном экране, галочка в скрытых настройках 2026-08-19 08:57:39 +03:00
ua1zbe
c279982778 Bump to 1.0.10 2026-08-19 08:36:59 +03:00
ua1zbe
3b2a31e133 1.0.10: выбор скорости передачи Opus + авто-адаптация; фикс подрыва в начале приёма 2026-08-19 08:35:20 +03:00
24 changed files with 1035 additions and 146 deletions

View File

@@ -7,17 +7,69 @@ Android-клиент для подключения к рефлекторам **S
- Подключение к рефлектору SVXLink по протоколу v2 с авторизацией по AUTH_KEY
- Выбор группы вещания (TG) — по умолчанию 11
- Голосовая связь в формате **Opus** (8 кГц, моно) через UDP
- Кнопка PTT (Push-To-Talk) — удерживайте, чтобы говорить
- Индикаторы уровня передачи и приёма
- Отображение текущего говорящего
- Кнопка PTT (Push-To-Talk) в стиле Zello — крупная, с градиентом и тенью; удерживайте, чтобы говорить
- Индикаторы уровня передачи (микрофон) и приёма (S-метры)
- **Статистика приёма** (потери, %, и джиттер, мс) — включается в скрытых настройках звука
- **Выбор битрейта передачи** — Авто (адаптация под качество канала) либо фиксированный 6–32 кбит/с (скрытые настройки звука)
- **Мониторинг групп** — кнопка наушников: прослушивание выбранных групп с автоматическим возвратом к базовой после паузы
- **Запись приёма в OGG** с пропуском тишины (подробности ниже)
- Строка текущего говорящего между блоками экрана:
- при приёме — `Говорит UA1ZBE-M TG12`
- без приёма — `UA1ZBE-M TG12` (последний говоривший)
- Автоматическое переподключение при обрыве связи
- Уведомления о входе/выходе участников (опционально)
- Позывной регистронезависимый: `ua1zbe-m` автоматически преобразуется в `UA1ZBE-M` для отправки на сервер
## Запись приёма
- Кнопка записи расположена между позывным и кнопкой мониторинга групп.
- **Короткое нажатие** — начать/остановить запись. Файл сохраняется в
`Android/data/ru.ua1zbe.svxremote/files/recordings/rec_ГГГГ-ММ-ДД_ЧЧ-ММ-СС.ogg`.
- Записывается только реальный приём: паузы в эфире (тишина) в файл не попадают,
реплики склеиваются без разрывов.
- **Удержание кнопки 3 секунды** — открывается список записей: воспроизведение
(повторный тап останавливает) и удаление с подтверждением. Если записей нет —
выводится подсказка.
## Скрытые настройки звука
Открываются тройным тапом по заголовку «SVXHomyak» на главном экране. Позволяют:
- включить/выключить отображение статистики приёма (потери/джиттер);
- выбрать битрейт передачи (Авто или 6–32 кбит/с).
## Требования
- Android 8.0 (API 26) и выше
- Доступ к сети и микрофону
- Рефлектор SVXLink с включённой поддержкой протокола v2 (AUTH_KEY)
- Для push-уведомлений: установить и авторизоваться в приложении **RuStore** на устройстве (оно является дистрибьютором пуша)
## Push-уведомления (RuStore)
Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications) (аналог Firebase Cloud Messaging). Для доставки на устройстве должен быть установлен и авторизован RuStore (или приложение-дистрибьютор экосистемы VK).
Рассылка выполняется с сервера через API RuStore:
```
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
Authorization: Bearer {service-token}
Content-Type: application/json
{
"message": {
"token": "{push-token устройства}",
"notification": {
"title": "Говорит UA1ZBE1 TG12",
"body": "..."
}
}
}
```
- `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
- `push-token` устройства приходит в callback `onNewToken` (лог `RuStorePush`), сохраняется в настройках приложения.
- Если в запросе есть секция `notification` — уведомление отображает сам SDK; если переданы только `data` (`title`/`body`) — приложение показывает его самостоятельно.
## Установка
@@ -43,16 +95,24 @@ Android-клиент для подключения к рефлекторам **S
APK появится в `app/build/outputs/apk/debug/app-debug.apk`.
Для сборки релизного (подписанного) APK:
```bash
./gradlew :app:assembleRelease
# далее выровнять и подписать через build-tools (zipalign + apksigner)
```
Требования: JDK 17, Android SDK (compileSdk 34).
## Структура проекта
- `app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt` — основной экран
- `app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt` — основной экран и логика UI
- `app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt` — настройки подключения
- `app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt` — клиент рефлектора (TCP + UDP)
- `app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt` — бинарный протокол svxreflector v2
- `app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt` — кодирование/декодирование Opus через MediaCodec
- `app/src/main/java/ru/ua1zbe/svxremote/ui/` — кнопка PTT и индикатор уровня
- `app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt` — запись приёма в OGG (MediaCodec + MediaMuxer)
- `app/src/main/java/ru/ua1zbe/svxremote/ui/` — кнопка PTT (стиль Zello), S-метр, индикатор уровня
- `app/src/test/java/ru/ua1zbe/svxremote/` — юнит-тесты протокола и рукопожатия
## Тесты

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote"
minSdk = 26
targetSdk = 34
versionCode = 11
versionName = "1.0.9"
versionCode = 15
versionName = "1.0.13"
}
buildTypes {
@@ -43,6 +43,7 @@ kotlin {
}
dependencies {
implementation("ru.rustore.sdk:pushclient:7.4.0")
testImplementation("junit:junit:4.13.2")
}

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -9,6 +10,7 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".SvxApp"
android:label="@string/app_name"
android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true"
@@ -20,6 +22,22 @@
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<service
android:name=".PushMessagingService"
android:exported="true"
tools:ignore="ExportedService">
<intent-filter>
<action android:name="ru.rustore.sdk.pushclient.MESSAGING_EVENT" />
</intent-filter>
</service>
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_icon"
android:resource="@drawable/ic_launcher_fg" />
<meta-data
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
android:value="@string/push_channel_id" />
<activity
android:name=".SettingsActivity"
android:exported="false"

View File

@@ -2,8 +2,11 @@ package ru.ua1zbe.svxremote
import android.app.Activity
import android.os.Bundle
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.SeekBar
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.Prefs
@@ -25,6 +28,23 @@ class AudioSettingsActivity : Activity() {
val lblRxBuffer = findViewById<TextView>(R.id.lbl_rx_buffer_value)
val seekJitter = findViewById<SeekBar>(R.id.seek_jitter)
val lblJitter = findViewById<TextView>(R.id.lbl_jitter_value)
val spinBitrate = findViewById<Spinner>(R.id.spin_tx_bitrate)
// Скорость передачи Opus: Авто + фиксированные 6..32 кбит/с.
// SvxLink: Opus 8 кГц, кадр 20 мс; декодер принимает любой битрейт,
// поэтому выбор — на стороне клиента. Авто = адаптация под интернет.
val bitrates = intArrayOf(0, 6, 8, 10, 12, 16, 20, 24, 32)
val bitrateLabels = bitrates.map {
if (it == 0) getString(R.string.audio_tx_bitrate_auto)
else getString(R.string.audio_tx_bitrate_fmt, it)
}
spinBitrate.adapter = ArrayAdapter(
this, android.R.layout.simple_spinner_item, bitrateLabels
).apply {
setDropDownViewResource(android.R.layout.simple_spinner_dropdown_item)
}
val savedBitrate = Prefs.txBitrateKbps(this)
spinBitrate.setSelection(bitrates.indexOfFirst { it == savedBitrate }.coerceAtLeast(0))
fun updateMicGainLabel() {
lblMicGain.text = "${100 + seekMicGain.progress * 10}%"
@@ -59,10 +79,15 @@ class AudioSettingsActivity : Activity() {
seekRxBuffer.setOnSeekBarChangeListener(listener)
seekJitter.setOnSeekBarChangeListener(listener)
val chkRxStats = findViewById<CheckBox>(R.id.chk_rx_stats)
chkRxStats.isChecked = Prefs.rxStatsEnabled(this)
findViewById<Button>(R.id.btn_audio_save).setOnClickListener {
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20)
Prefs.setRxJitterMs(this, seekJitter.progress * 20)
Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition])
Prefs.setRxStatsEnabled(this, chkRxStats.isChecked)
Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
finish()
}

View File

@@ -8,7 +8,11 @@ import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.net.Uri
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import android.content.res.ColorStateList
import android.graphics.Color
import android.graphics.PixelFormat
@@ -53,18 +57,30 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView
private lateinit var lblDuplex: TextView
private lateinit var lblPttHint: TextView
private var lastTalkerCs = ""
private var lastTalkerTg = -1L
private lateinit var lblTxState: TextView
private lateinit var lblConnectHint: TextView
private lateinit var connectZone: View
private lateinit var rxMeter: SMeterView
private lateinit var micMeter: SMeterView
private lateinit var lblRxStats: TextView
private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: ImageButton
private lateinit var btnRecord: ImageButton
// Список записей: воспроизведение/удаление
private var recPlayer: MediaPlayer? = null
private var recPlayingPath: String? = null
private var recLongPressFired = false
private var recListAdapter: ArrayAdapter<File>? = null
private val recLongPressTask = Runnable {
recLongPressFired = true
Log.d(TAG, "recBtn long-press FIRED")
openRecordingsList()
}
private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null
@@ -90,6 +106,7 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
svc = (binder as SvxService.LocalBinder).service()
svc?.listener = this@MainActivity
updateRecordUi(svc?.isRecording() ?: false)
}
override fun onServiceDisconnected(name: ComponentName) {
@@ -130,10 +147,8 @@ class MainActivity : Activity(), SvxService.Listener {
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)
lblDuplex = findViewById(R.id.lbl_duplex)
lblPttHint = findViewById(R.id.lbl_ptt_hint)
lblTxState = findViewById(R.id.lbl_tx_state)
lblConnectHint = findViewById(R.id.lbl_connect_hint)
connectZone = findViewById(R.id.connect_zone)
@@ -142,12 +157,33 @@ class MainActivity : Activity(), SvxService.Listener {
rxMeter = findViewById(R.id.rx_meter)
micMeter = findViewById(R.id.mic_meter)
micMeter.setColor(0xFFF85149.toInt())
lblRxStats = findViewById(R.id.lbl_rx_stats)
lblRxStats.visibility = if (Prefs.rxStatsEnabled(this)) View.VISIBLE else View.GONE
pttButton = findViewById(R.id.ptt_button)
btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor)
btnRecord = findViewById(R.id.btn_record)
pttButton.enabled2 = false
btnMonitor.setOnClickListener { toggleMonitoring() }
btnRecord.setOnClickListener { toggleRecording() }
btnRecord.setOnTouchListener { _, event ->
when (event.action) {
MotionEvent.ACTION_DOWN -> {
recLongPressFired = false
Log.d(TAG, "recBtn DOWN, scheduling long-press")
mainHandler.postDelayed(recLongPressTask, 3000)
false
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
Log.d(TAG, "recBtn UP/CANCEL, longFired=$recLongPressFired")
mainHandler.removeCallbacks(recLongPressTask)
// Если сработал долгий тап — гасим клик (иначе переключит запись)
recLongPressFired
}
else -> false
}
}
updateMonitorButton()
val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) {
@@ -200,7 +236,7 @@ class MainActivity : Activity(), SvxService.Listener {
titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
val reset = Runnable { titleTaps = 0 }
titleResetRunnable = reset
mainHandler.postDelayed(reset, 1200)
mainHandler.postDelayed(reset, 2000)
if (titleTaps >= 3) {
titleTaps = 0
titleResetRunnable?.let { mainHandler.removeCallbacks(it) }
@@ -236,7 +272,6 @@ class MainActivity : Activity(), SvxService.Listener {
toast(getString(R.string.perm_mic))
return
}
lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking)
showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
svc?.startTx()
@@ -245,7 +280,6 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onPttUp() {
Log.d("SVXRemote", "PTT up")
svc?.stopTx()
lblPttHint.text = getString(R.string.hold_to_talk)
lblTxState.text =
if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected)
@@ -301,6 +335,8 @@ class MainActivity : Activity(), SvxService.Listener {
hideNodeOverlay()
aprsMeteo?.stop()
aprsMeteo = null
mainHandler.removeCallbacks(recLongPressTask)
stopRecordingPlayback()
mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false
svc?.listener = null
@@ -336,7 +372,6 @@ class MainActivity : Activity(), SvxService.Listener {
lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect)
lblPttHint.text = getString(R.string.hold_to_talk)
}
ReflectorClient.STATE_CONNECTING,
@@ -414,9 +449,9 @@ class MainActivity : Activity(), SvxService.Listener {
}
resetMonitorIdle()
}
lblTalkerTitle.text = getString(R.string.talker_now, tg)
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary))
lblTalker.text = callsign
lastTalkerCs = callsign
lastTalkerTg = tg
updateTalkerLine(active = true)
if (!pttButton.isPressed2) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS)
}
@@ -424,7 +459,20 @@ class MainActivity : Activity(), SvxService.Listener {
}
override fun onTalkerStop(tg: Long, callsign: String) {
// последний говоривший остаётся на экране
// последний говоривший остаётся на экране, но без префикса «Говорит»
mainHandler.post { updateTalkerLine(active = false) }
}
/** Единая строка говорящего: «Говорит …» при приёме, иначе позывной+TG. */
private fun updateTalkerLine(active: Boolean) {
if (lastTalkerCs.isEmpty()) {
lblTalker.text = ""
return
}
lblTalker.text = if (active)
getString(R.string.talker_active_fmt, lastTalkerCs, lastTalkerTg)
else
getString(R.string.talker_idle_fmt, lastTalkerCs, lastTalkerTg)
}
override fun onNodeJoined(callsign: String, tg: Long?) {
@@ -498,6 +546,14 @@ class MainActivity : Activity(), SvxService.Listener {
}
}
override fun onRxStats(lossPct: Double, jitterMs: Double) {
mainHandler.post {
if (!Prefs.rxStatsEnabled(this)) return@post
lblRxStats.visibility = View.VISIBLE
lblRxStats.text = getString(R.string.rx_stats_fmt, lossPct, jitterMs.toInt())
}
}
/** Сбросить таймер молчания: группа жива — продолжаем её слушать. */
private fun resetMonitorIdle() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) }
@@ -658,6 +714,127 @@ class MainActivity : Activity(), SvxService.Listener {
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
}
private fun toggleRecording() {
val s = svc ?: return
if (s.isRecording()) {
val path = s.stopRecording()
updateRecordUi(false)
toast(getString(R.string.record_saved, path ?: ""))
} else {
val path = s.startRecording()
if (path != null) {
updateRecordUi(true)
toast(getString(R.string.record_started))
} else {
toast(getString(R.string.record_failed))
}
}
}
private fun updateRecordUi(active: Boolean) {
btnRecord.imageTintList = ColorStateList.valueOf(
if (active) Color.parseColor("#F85149") else Color.parseColor("#8B949E")
)
btnRecord.contentDescription = getString(R.string.btn_record)
}
// ---------- Список записей: воспроизведение / удаление ----------
private fun recordingsDir(): File =
File(getExternalFilesDir(null) ?: filesDir, "recordings")
private fun openRecordingsList() {
Log.d(TAG, "openRecordingsList")
val dir = recordingsDir()
Log.d(TAG, "recordings dir exists=${dir.exists()} path=${dir.absolutePath}")
val files = (dir.listFiles { f -> f.extension.equals("ogg", true) }
?: emptyArray()).toMutableList()
.apply { sortByDescending { it.name } }
Log.d(TAG, "recordings count=${files.size}")
if (files.isEmpty()) {
toast("Нет записей")
return
}
val dialog = AlertDialog.Builder(this)
.setTitle(R.string.recordings_title)
.create()
val listView = ListView(this).apply {
val adapter = object : ArrayAdapter<File>(this@MainActivity, 0, files) {
override fun getView(pos: Int, convertView: View?, parent: ViewGroup): View {
val file = getItem(pos)!!
val row = convertView
?: layoutInflater.inflate(R.layout.dialog_recording_item, parent, false)
row.findViewById<TextView>(R.id.rec_name).text = file.name
val playBtn = row.findViewById<ImageButton>(R.id.rec_play)
val delBtn = row.findViewById<ImageButton>(R.id.rec_delete)
val playing = recPlayingPath == file.absolutePath && recPlayer?.isPlaying == true
playBtn.setImageResource(if (playing) R.drawable.ic_stop else R.drawable.ic_play)
playBtn.setOnClickListener { togglePlayRecording(file.absolutePath) }
delBtn.setOnClickListener { confirmDeleteRecording(file, this, dialog) }
return row
}
}
recListAdapter = adapter
this.adapter = adapter
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
(resources.displayMetrics.heightPixels * 0.6).toInt()
)
}
dialog.setView(listView)
dialog.setOnDismissListener { stopRecordingPlayback() }
dialog.show()
}
private fun togglePlayRecording(path: String) {
if (recPlayingPath == path && recPlayer?.isPlaying == true) {
stopRecordingPlayback()
refreshRecordingList()
return
}
stopRecordingPlayback()
try {
val mp = MediaPlayer().apply {
setDataSource(path)
setOnCompletionListener { stopRecordingPlayback(); refreshRecordingList() }
prepare()
start()
}
recPlayer = mp
recPlayingPath = path
refreshRecordingList()
} catch (e: Exception) {
toast(getString(R.string.record_play_failed))
}
}
private fun stopRecordingPlayback() {
try { recPlayer?.stop() } catch (_: Exception) {}
try { recPlayer?.release() } catch (_: Exception) {}
recPlayer = null
recPlayingPath = null
}
private fun refreshRecordingList() {
recListAdapter?.notifyDataSetChanged()
}
private fun confirmDeleteRecording(file: File, adapter: ArrayAdapter<File>, dialog: AlertDialog) {
AlertDialog.Builder(this)
.setTitle(R.string.record_delete_title)
.setMessage(file.name)
.setPositiveButton(R.string.record_delete_yes) { _, _ ->
if (recPlayingPath == file.absolutePath) stopRecordingPlayback()
file.delete()
adapter.remove(file)
adapter.notifyDataSetChanged()
toast(getString(R.string.record_deleted))
if (adapter.count == 0) dialog.dismiss()
}
.setNegativeButton(R.string.cancel, null)
.show()
}
/** Переключиться на другую группу: отправить серверу по живому соединению. */
private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg)

View File

@@ -0,0 +1,60 @@
package ru.ua1zbe.svxremote
import android.app.Notification
import android.app.NotificationManager
import android.util.Log
import ru.rustore.sdk.pushclient.messaging.exception.RuStorePushClientException
import ru.rustore.sdk.pushclient.messaging.model.RemoteMessage
import ru.rustore.sdk.pushclient.messaging.service.RuStoreMessagingService
import ru.ua1zbe.svxremote.prefs.Prefs
/**
* Приём пуш-уведомлений RuStore.
*
* Если сервер прислал секцию `notification` — SDK сам отображает уведомление.
* Если присланы только `data` (title/body) — отображаем уведомление сами.
*/
class PushMessagingService : RuStoreMessagingService() {
override fun onNewToken(token: String) {
Log.i(TAG, "onNewToken: $token")
Prefs.setPushToken(this, token)
}
override fun onMessageReceived(message: RemoteMessage) {
if (message.notification != null) {
Log.i(TAG, "push displayed by SDK: ${message.data}")
return
}
val title = message.data["title"] ?: getString(R.string.app_name)
val body = message.data["body"] ?: ""
if (title.isNotEmpty() || body.isNotEmpty()) {
showPush(title, body)
}
}
override fun onDeletedMessages() {
Log.i(TAG, "onDeletedMessages")
}
override fun onError(errors: List<RuStorePushClientException>) {
errors.forEach { Log.w(TAG, "error", it) }
}
private fun showPush(title: String, body: String) {
val nm = getSystemService(NotificationManager::class.java)
val n = Notification.Builder(this, CHANNEL_ID)
.setContentTitle(title)
.setContentText(body)
.setSmallIcon(R.drawable.ic_launcher_fg)
.setAutoCancel(true)
.build()
nm.notify(NOTIFICATION_ID, n)
}
companion object {
private const val TAG = "RuStorePush"
const val CHANNEL_ID = "svx_push"
private const val NOTIFICATION_ID = 2
}
}

View File

@@ -0,0 +1,63 @@
package ru.ua1zbe.svxremote
import android.app.Application
import android.app.NotificationChannel
import android.app.NotificationManager
import android.util.Log
import ru.rustore.sdk.pushclient.RuStorePushClient
import ru.ua1zbe.svxremote.prefs.Prefs
/**
* Приложение: создаём канал пуша и инициализируем RuStore Push SDK.
* project_id берём из консоли RuStore (Push-уведомления > Проекты).
*/
class SvxApp : Application() {
override fun onCreate() {
super.onCreate()
createPushChannel()
initRuStorePush()
}
private fun createPushChannel() {
getSystemService(NotificationManager::class.java).createNotificationChannel(
NotificationChannel(
PushMessagingService.CHANNEL_ID,
getString(R.string.push_channel_name),
NotificationManager.IMPORTANCE_HIGH
)
)
}
private fun initRuStorePush() {
try {
RuStorePushClient.init(
application = this,
projectId = RUSTORE_PROJECT_ID
)
RuStorePushClient.getToken()
.addOnSuccessListener { token ->
Log.i(TAG, "push token: $token")
Prefs.setPushToken(this, token)
RuStorePushClient.subscribeToTopic(PUSH_TOPIC)
.addOnSuccessListener {
Log.i(TAG, "subscribed to $PUSH_TOPIC")
}
.addOnFailureListener { e ->
Log.w(TAG, "subscribe to $PUSH_TOPIC failed", e)
}
}
.addOnFailureListener { e ->
Log.w(TAG, "getToken failed", e)
}
} catch (e: Exception) {
Log.w(TAG, "init failed", e)
}
}
companion object {
private const val TAG = "RuStorePush"
private const val RUSTORE_PROJECT_ID = "Yd7tDjVDHqsnTJwHNYN08cYIvy8fsWU-"
private const val PUSH_TOPIC = "svxhomyak"
}
}

View File

@@ -10,6 +10,7 @@ import android.os.Binder
import android.os.IBinder
import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.audio.RxRecorder
import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -31,15 +32,22 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun onAudioFlush()
fun onError(detail: String)
fun onLevel(source: Int, level: Float)
fun onRxStats(lossPct: Double, jitterMs: Double)
}
var listener: Listener? = null
private val engine by lazy {
OpusEngine { source, level -> listener?.onLevel(source, level) }
OpusEngine(
onLevel = { source, level -> listener?.onLevel(source, level) },
onRxStats = { lossPct, jitterMs -> listener?.onRxStats(lossPct, jitterMs) }
).also { it.rxTap = { pcm, real -> recorder.feed(pcm, real) } }
}
val client: ReflectorClient by lazy { ReflectorClient(this) }
/** Запись приёма в OGG (управляется из UI). */
private val recorder = RxRecorder()
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
@@ -121,6 +129,7 @@ class SvxService : Service(), ReflectorClient.Callbacks {
updateNotification(getString(R.string.notif_connected))
engine.rxBufferMs = Prefs.rxBufferMs(this)
engine.jitterFrames = (Prefs.rxJitterMs(this) / 20).coerceAtLeast(1)
engine.txBitrateKbps = Prefs.txBitrateKbps(this)
engine.startDecoder()
}
ReflectorClient.STATE_DISCONNECTED -> {
@@ -174,7 +183,7 @@ class SvxService : Service(), ReflectorClient.Callbacks {
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet)
engine.onAudioPacket(packet, seq)
}
override fun onAudioFlush() {
@@ -184,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks {
override fun onError(detail: String) {
listener?.onError(detail)
}
// ---------- Запись приёма в файл ----------
fun isRecording(): Boolean = recorder.isRecording()
/** Начать запись. Возвращает путь к файлу или null при ошибке. */
fun startRecording(): String? {
if (recorder.isRecording()) return recorder.getFilePath()
val dir = getExternalFilesDir(null) ?: filesDir
return if (recorder.start(dir)) recorder.getFilePath() else null
}
/** Остановить запись. Возвращает путь к сохранённому файлу или null. */
fun stopRecording(): String? {
if (!recorder.isRecording()) return null
recorder.stop()
return recorder.getFilePath()
}
override fun onDestroy() {
recorder.stop()
super.onDestroy()
}
}

View File

@@ -20,7 +20,8 @@ import kotlin.math.log10
* SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
*/
class OpusEngine(
private val onLevel: (source: Int, level: Float) -> Unit
private val onLevel: (source: Int, level: Float) -> Unit,
private val onRxStats: ((lossPct: Double, jitterMs: Double) -> Unit)? = null
) {
companion object {
@@ -30,6 +31,11 @@ class OpusEngine(
const val CHANNELS = 1
const val FRAME_SIZE = 160 // 20 мс в 8 кГц
const val BIT_RATE = 20000
private const val GATE_MIN_WAIT_MS = 100L
// Авто-адаптация битрейта TX (0 = авто включено в настройках)
const val AUTO_BITRATE_MIN = 8
const val AUTO_BITRATE_MAX = 24
const val AUTO_BITRATE_STEP = 4
const val SRC_MIC = 0
const val SRC_SPEAKER = 1
@@ -64,9 +70,37 @@ class OpusEngine(
var rxBufferMs = 417 // буфер AudioTrack, мс (40000 байт ≈ 417 мс при 48 кГц)
@Volatile
var jitterFrames = 10 // сколько кадров накопить до первого слива (кадр = 20 мс)
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
@Volatile
var txBitrateKbps = 0
// Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём?
@Volatile
var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
@Volatile
private var autoKbps = 16
private val configBuffers = ArrayList<ByteArray>()
private var configIdx = 0
// ---------- Статистика приёма для авто-битрейта ----------
private var lastSeq = -1
private var lastPktTime = 0L
private var lossCount = 0L
private var pktCount = 0L
private var jitterAccum = 0.0
private var jitterN = 0
private var lastEvalTime = 0L
private var goodWindows = 0
/** Размер пакета при 20 мс кадре: bitrate * 0.02 / 8 байт. */
fun bitrateBytes(kbps: Int): Int = (kbps * 20) / 8
/** Эффективный битрейт для следующей передачи, бит/с. */
fun effectiveBitrate(): Int {
val kbps = if (txBitrateKbps > 0) txBitrateKbps else autoKbps
return kbps * 1000
}
fun startDecoder() {
if (decoder != null) return
val format = MediaFormat()
@@ -212,6 +246,7 @@ class OpusEngine(
// Уровень показываем только для реальных пакетов: во время
// скрытия потерь (conceal) шкала должна быть в нуле
val real = System.currentTimeMillis() - lastRealPacketTime < 400
rxTap?.invoke(pcm, real)
val lvl = if (real) pcmLevel(pcm) else 0f
// Порог тишины: шум декодера и паузы речи — в ноль
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
@@ -283,7 +318,8 @@ class OpusEngine(
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array()
/** Подача одного Opus-пакета на декодер (с UDP-потока). */
fun onAudioPacket(packet: ByteArray) {
fun onAudioPacket(packet: ByteArray, seq: Int) {
updateRxStats(seq)
lastRealPacketTime = System.currentTimeMillis()
rxQueue.add(packet)
receivedAny = true
@@ -293,56 +329,82 @@ class OpusEngine(
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) }
}
/** Джиттер-гейт открыт: накопили кадры или вышло 150 мс с первого пакета. */
/**
* Статистика приёма (потери по seq, джиттер по интервалам пакетов).
* Раз в 5 секунд корректирует autoKbps: плохо — ниже, стабильно — выше.
*/
private fun updateRxStats(seq: Int) {
val now = System.currentTimeMillis()
if (lastSeq >= 0) {
val dt = now - lastPktTime
// seq — счётчик ГОВОРЯЩЕГО: при повторах кадров он может
// повторяться или идти назад — это не потери. Пауза > 200 мс
// между пакетами — смена реплики/говорящего, тоже не потери.
if (dt <= 200) {
val gap = (seq - lastSeq - 1).toLong()
if (gap in 1..50000) lossCount += gap
}
pktCount++
val j = abs(dt - 20).toDouble()
if (j < 100) {
jitterAccum += j
jitterN++
}
}
lastSeq = seq
lastPktTime = now
if (lastEvalTime == 0L) {
lastEvalTime = now
return
}
if (now - lastEvalTime < 5000 || pktCount < 30) return
val lossPct = lossCount * 100.0 / pktCount
val jitterMs = if (jitterN > 0) jitterAccum / jitterN else 0.0
Log.d(TAG, "stats window: pkt=$pktCount loss=$lossCount pct=$lossPct j=$jitterMs lastSeq=$lastSeq")
if (lossPct > 3.0 || jitterMs > 60.0) {
if (autoKbps > AUTO_BITRATE_MIN) {
autoKbps -= AUTO_BITRATE_STEP
Log.d(TAG, "auto bitrate down: ${autoKbps}kbps (loss=${"%.1f".format(lossPct)}% jitter=${"%.0f".format(jitterMs)}ms)")
}
goodWindows = 0
} else if (lossPct < 0.5 && jitterMs < 25.0) {
goodWindows++
if (goodWindows >= 2 && autoKbps < AUTO_BITRATE_MAX) {
autoKbps += AUTO_BITRATE_STEP
Log.d(TAG, "auto bitrate up: ${autoKbps}kbps")
goodWindows = 0
}
} else {
goodWindows = 0
}
lossCount = 0
pktCount = 0
jitterAccum = 0.0
jitterN = 0
lastEvalTime = now
onRxStats?.invoke(lossPct, jitterMs)
}
/** Джиттер-гейт открыт: накопили кадры или вышел короткий таймаут. */
private fun gateOpen(): Boolean {
if (playStarted) return true
if (rxQueue.size >= jitterFrames) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 150
val waitMs = maxOf(GATE_MIN_WAIT_MS, jitterFrames * 20L)
return gateStart != 0L && System.currentTimeMillis() - gateStart >= waitMs
}
fun onAudioFlush() {
try {
// 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.
@@ -441,11 +503,12 @@ class OpusEngine(
fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
fmt.setInteger(MediaFormat.KEY_BIT_RATE, BIT_RATE)
fmt.setInteger(MediaFormat.KEY_BIT_RATE, effectiveBitrate())
fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, FRAME_SIZE * 2)
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start()
Log.d(TAG, "encoder started, bitrate=${effectiveBitrate() / 1000}kbps")
codec
} catch (e: Exception) {
null
@@ -572,4 +635,4 @@ class OpusEngine(
val norm = (db + 60.0) / 48.0
return norm.toFloat().coerceIn(0f, 1f)
}
}
}

View File

@@ -0,0 +1,183 @@
package ru.ua1zbe.svxremote.audio
import android.media.MediaCodec
import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaMuxer
import android.util.Log
import java.io.File
import java.nio.ByteBuffer
import java.time.LocalDateTime
import java.time.format.DateTimeFormatter
import java.util.concurrent.LinkedBlockingQueue
import java.util.concurrent.atomic.AtomicBoolean
/**
* Запись декодированного RX-аудио (PCM 48 кГц моно 16 бит) в OGG/Opus.
* Тишина (отсутствие приёма) не пишется: во время пауз pts не продвигается,
* поэтому речь в файле склеивается без пропусков/пустых участков.
*/
class RxRecorder {
companion object {
const val TAG = "SVXRemote"
const val SAMPLE_RATE = 48000
const val CHANNELS = 1
}
@Volatile
private var muxer: MediaMuxer? = null
@Volatile
private var encoder: MediaCodec? = null
private val recording = AtomicBoolean(false)
private val queue = LinkedBlockingQueue<ByteArray>()
private var worker: Thread? = null
private var filePath = ""
// Накопленный pts (мкс). Растёт только при подаче реального аудио (без тишины).
private var ptsUs = 0L
private var trackIndex = -1
private var muxStarted = false
private var wroteAny = false
private val info = MediaCodec.BufferInfo()
fun isRecording(): Boolean = recording.get()
fun getFilePath(): String = filePath
/** Начать запись. dir — базовая директория (используется подкаталог recordings). */
fun start(dir: File): Boolean {
if (recording.get()) return true
val outDir = File(dir, "recordings")
if (!outDir.exists()) outDir.mkdirs()
val ts = LocalDateTime.now()
.format(DateTimeFormatter.ofPattern("yyyy-MM-dd_HH-mm-ss"))
val f = File(outDir, "rec_$ts.ogg")
filePath = f.absolutePath
val mux = try {
MediaMuxer(filePath, MediaMuxer.OutputFormat.MUXER_OUTPUT_OGG)
} catch (e: Exception) {
Log.e(TAG, "recorder muxer create failed: ${e.message}")
return false
}
val fmt = MediaFormat()
fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
fmt.setInteger(MediaFormat.KEY_BIT_RATE, 32000)
fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 1920)
val enc = try {
MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
it.start()
}
} catch (e: Exception) {
Log.e(TAG, "recorder encoder create failed: ${e.message}")
try { mux.release() } catch (_: Exception) {}
return false
}
muxer = mux
encoder = enc
trackIndex = -1
muxStarted = false
ptsUs = 0L
queue.clear()
recording.set(true)
worker = Thread({ workerLoop() }, "rx-recorder").also { it.isDaemon = true; it.start() }
Log.d(TAG, "recorder started -> $filePath")
return true
}
/** Подача декодированного PCM. isReal == false (тишина/скрытие потерь) — не пишем. */
fun feed(pcm: ByteArray, isReal: Boolean) {
if (!recording.get() || !isReal) return
queue.offer(pcm)
}
private fun workerLoop() {
val enc = encoder ?: return
val mux = muxer ?: return
val timeout = 1000L
try {
while (recording.get() || queue.isNotEmpty()) {
val pcm = queue.poll()
if (pcm != null) {
var inIdx = enc.dequeueInputBuffer(timeout)
if (inIdx >= 0) {
val buf = enc.getInputBuffer(inIdx) ?: continue
buf.clear()
buf.put(pcm)
val pts = ptsUs
enc.queueInputBuffer(inIdx, 0, pcm.size, pts, 0)
// Продвигаем pts только для реального аудио (без тишины)
ptsUs += (pcm.size / 2) * 1_000_000L / SAMPLE_RATE
} else {
queue.offer(pcm)
Thread.sleep(2)
}
}
drainEncoder(enc, mux, false)
if (!recording.get() && queue.isEmpty()) break
}
} catch (e: Exception) {
Log.e(TAG, "recorder worker error: ${e.message}")
} finally {
// Дренаж остатков после остановки
try {
drainEncoder(enc, mux, true)
} catch (_: Exception) {
}
try { enc.stop() } catch (_: Exception) {}
try { enc.release() } catch (_: Exception) {}
if (muxStarted) {
try { mux.stop() } catch (_: Exception) {}
}
try { mux.release() } catch (_: Exception) {}
encoder = null
muxer = null
Log.d(TAG, "recorder stopped -> $filePath")
}
}
private fun drainEncoder(enc: MediaCodec, mux: MediaMuxer, drainAll: Boolean) {
val timeout = if (drainAll) 0L else 1000L
var outIdx = enc.dequeueOutputBuffer(info, timeout)
while (outIdx >= 0) {
if (info.size > 0) {
if (trackIndex < 0) {
trackIndex = mux.addTrack(enc.outputFormat)
mux.start()
muxStarted = true
Log.d(TAG, "recorder muxer track added, started")
}
val outBuf = enc.getOutputBuffer(outIdx) ?: run {
enc.releaseOutputBuffer(outIdx, false)
outIdx = enc.dequeueOutputBuffer(info, timeout)
continue
}
outBuf.position(info.offset)
outBuf.limit(info.offset + info.size)
mux.writeSampleData(trackIndex, outBuf, info)
wroteAny = true
}
enc.releaseOutputBuffer(outIdx, false)
outIdx = enc.dequeueOutputBuffer(info, timeout)
}
}
fun stop() {
if (!recording.get()) return
recording.set(false)
val w = worker
if (w != null && w.isAlive) {
try { w.join(4000) } catch (_: InterruptedException) {}
}
worker = null
// Если ни одного кадра не записалось (весь сеанс — тишина) — удаляем пустой файл.
if (!wroteAny) {
try {
val f = File(filePath)
if (f.exists()) f.delete()
} catch (_: Exception) {}
}
}
}

View File

@@ -162,4 +162,30 @@ object Prefs {
fun setRxJitterMs(context: Context, ms: Int) {
sp(context).edit().putInt("rx_jitter_ms", ms.coerceIn(0, 400)).apply()
}
/** Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет). */
fun txBitrateKbps(context: Context): Int =
sp(context).getInt("tx_bitrate_kbps", 0)
fun setTxBitrateKbps(context: Context, kbps: Int) {
sp(context).edit().putInt("tx_bitrate_kbps", kbps.coerceIn(0, 32)).apply()
}
/** Показывать строку статистики приёма (потери/джиттер) на главном экране. */
fun rxStatsEnabled(context: Context): Boolean =
sp(context).getBoolean("rx_stats_enabled", true)
fun setRxStatsEnabled(context: Context, enabled: Boolean) {
sp(context).edit().putBoolean("rx_stats_enabled", enabled).apply()
}
// ---------- RuStore Push ----------
/** Пуш-токен устройства RuStore (для серверной рассылки). */
fun pushToken(context: Context): String =
sp(context).getString("push_token", "") ?: ""
fun setPushToken(context: Context, token: String) {
sp(context).edit().putString("push_token", token).apply()
}
}

View File

@@ -541,7 +541,7 @@ class ReflectorClient(
if (data.size < 6) return
val r = BinReader(data)
val type = r.u16()
r.u16() // client id
val cid = r.u16() // client id
val seq = r.u16()
val payload = data.copyOfRange(6, data.size)
when (type) {
@@ -549,7 +549,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) {
Log.d(TAG, "UDP RX audio seq=$seq len=${audio.size}")
Log.d(TAG, "UDP RX audio seq=$seq cid=$cid len=${audio.size}")
callbacks.onAudioPacket(audio, seq)
} else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")

View File

@@ -3,15 +3,18 @@ package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint
import android.graphics.Path
import android.graphics.Shader
import android.util.AttributeSet
import android.view.MotionEvent
import android.view.View
import ru.ua1zbe.svxremote.R
/**
* Большая круглая кнопка PTT. Удерживание — передача.
* Большая круглая кнопка PTT в стиле Zello: градиентная заливка с глянцем,
* мягкая тень (плавающая кнопка) и тонкий ободок.
* Состояния: idle (синяя), нажата (красная), отключена (серая).
*/
class PttButton @JvmOverloads constructor(
@@ -19,8 +22,11 @@ class PttButton @JvmOverloads constructor(
attrs: AttributeSet? = null
) : View(context, attrs) {
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val glossPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
private val clip = Path()
private var radius = 0f
private var centerX = 0f
@@ -31,7 +37,6 @@ class PttButton @JvmOverloads constructor(
set(value) {
field = value
if (!value) setPressed(false)
refreshColors()
invalidate()
}
@@ -50,22 +55,28 @@ class PttButton @JvmOverloads constructor(
}
init {
fillPaint.style = Paint.Style.FILL
fillPaint.setShadowLayer(dp(8).toFloat(), 0f, dp(4).toFloat(), Color.argb(80, 0, 0, 0))
ringPaint.style = Paint.Style.STROKE
ringPaint.strokeWidth = dp(3).toFloat()
ringPaint.color = Color.parseColor("#3D444D")
paint.style = Paint.Style.FILL
refreshColors()
ringPaint.strokeWidth = dp(2).toFloat()
glossPaint.style = Paint.Style.FILL
micPaint.style = Paint.Style.FILL
micPaint.color = Color.WHITE
isClickable = true
isFocusable = true
}
private fun refreshColors() {
paint.color = when {
!enabled2 -> Color.parseColor("#484F58")
isPressed -> Color.parseColor("#F85149")
else -> Color.parseColor("#1F6FEB")
}
invalidate()
/** (верхний цвет, нижний цвет) градиента заливки. */
private fun fillColors(): Pair<Int, Int> = when {
!enabled2 -> 0x565E68 to 0x3C424B
isPressed -> 0xFF8A80 to 0xDB3A30
else -> 0x4A9BFF to 0x1561D6
}
private fun ringColor(): Int = when {
!enabled2 -> 0x6B727B
isPressed -> 0xFFA79F
else -> 0x6FB0FF
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
@@ -74,32 +85,51 @@ class PttButton @JvmOverloads constructor(
super.onSizeChanged(w, h, oldw, oldh)
centerX = w / 2f
centerY = h / 2f
radius = minOf(w, h) / 2f - dp(16)
refreshColors()
radius = minOf(w, h) / 2f - dp(18)
}
override fun onDraw(canvas: Canvas) {
super.onDraw(canvas)
val r = radius / (1f + if (isPressed) 0.06f else 0f)
canvas.drawCircle(centerX, centerY, r, paint)
canvas.drawCircle(centerX, centerY, r, ringPaint)
val cx = centerX
val cy = centerY
val (top, bottom) = fillColors()
// Иконка микрофона (как icons8: капсула + чашка + ножка + основание)
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
micPaint.color = Color.WHITE
micPaint.style = Paint.Style.FILL
// Заливка с вертикальным градиентом (глянец)
fillPaint.shader = LinearGradient(
cx, cy - r, cx, cy + r, top, bottom, Shader.TileMode.CLAMP
)
canvas.drawCircle(cx, cy, r, fillPaint)
val x = centerX
val y = centerY
val s = r * 0.5f
// Тонкий ободок по краю
ringPaint.color = ringColor()
canvas.drawCircle(cx, cy, r - dp(1).toFloat(), ringPaint)
// Верхний глянец (белое затухание сверху)
glossPaint.shader = LinearGradient(
cx, cy - r, cx, cy + r * 0.18f,
Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP
)
clip.reset()
clip.addCircle(cx, cy, r, Path.Direction.CW)
canvas.save()
canvas.clipPath(clip)
canvas.drawRect(cx - r, cy - r, cx + r, cy + r, glossPaint)
canvas.restore()
glossPaint.shader = null
drawMic(canvas, cx, cy, r * 0.5f)
}
/** Иконка микрофона (капсула + держатель + ножка + основание). */
private fun drawMic(canvas: Canvas, x: Float, y: Float, s: Float) {
// корпус-капсула
val halfW = s * 0.34f
canvas.drawRoundRect(
x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
halfW, halfW, micPaint
)
// чашка-держатель (U-образная, заполненная)
// чашка-держатель (U-образная)
val cup = Path()
cup.fillType = Path.FillType.EVEN_ODD
cup.moveTo(x - s * 0.52f, y + s * 0.06f)
@@ -147,4 +177,4 @@ class PttButton @JvmOverloads constructor(
else -> true
}
}
}
}

View File

@@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/surface" />
<corners android:radius="14dp" />
<stroke android:width="1dp" android:color="@color/surface_light" />
</shape>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M6,7h12v12a1,1 0,0 1,-1 1H7a1,1 0,0 1,-1 -1V7zM9,9v9h1V9H9zM14,9v9h1V9h-1zM4,5h16v2H4V5z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M8,5v14l11,-7z" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8B949E"
android:pathData="M12,12m-7,0a7,7 0,1 1,14 0a7,7 0,1 1,-14 0" />
</vector>

View File

@@ -0,0 +1,9 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#FFFFFF"
android:pathData="M7,7h10v10h-10z" />
</vector>

View File

@@ -112,8 +112,38 @@
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="20"
android:layout_marginBottom="20dp" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/audio_tx_bitrate"
android:textColor="@color/text_primary"
android:textSize="15sp" />
</LinearLayout>
<Spinner
android:id="@+id/spin_tx_bitrate"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp" />
<CheckBox
android:id="@+id/chk_rx_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/audio_rx_stats"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Button
android:id="@+id/btn_audio_save"
android:layout_width="match_parent"

View File

@@ -85,10 +85,22 @@
android:ellipsize="end"
android:text="@string/settings_not_configured" />
<ImageButton
android:id="@+id/btn_record"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_record"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_record"
android:scaleType="centerInside"
android:tint="@color/text_secondary" />
<ImageButton
android:id="@+id/btn_monitor"
android:layout_width="44dp"
android:layout_height="44dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_headset"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_monitor"
@@ -104,38 +116,68 @@
android:textStyle="bold"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:id="@+id/lbl_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
<!-- Статус соединения -->
<TextView
android:id="@+id/status_chip"
<!-- Секция: APRS/подключение + статус -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:layout_marginTop="12dp"
android:orientation="vertical"
android:background="@drawable/bg_section"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/lbl_subtitle"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="end" />
<!-- Статус соединения -->
<TextView
android:id="@+id/status_chip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/danger"
android:textSize="18sp"
android:textStyle="bold"
android:text="@string/status_disconnected" />
</LinearLayout>
<!-- Говорящий (единая строка): «Говорит …» при приёме, иначе позывной+TG -->
<TextView
android:id="@+id/lbl_talker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:textColor="@color/danger"
android:textSize="18sp"
android:textColor="@color/accent"
android:textSize="22sp"
android:textStyle="bold"
android:text="@string/status_disconnected" />
android:singleLine="true"
android:ellipsize="end" />
<!-- S-метры -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:orientation="vertical">
android:layout_marginTop="16dp"
android:orientation="vertical"
android:background="@drawable/bg_section"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<LinearLayout
android:layout_width="match_parent"
@@ -181,30 +223,19 @@
android:layout_height="52dp"
android:layout_weight="1" />
</LinearLayout>
<!-- Статистика приёма (скрытые настройки, по умолчанию выключено) -->
<TextView
android:id="@+id/lbl_rx_stats"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:visibility="gone" />
</LinearLayout>
<!-- Последний говоривший -->
<TextView
android:id="@+id/lbl_talker_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:text="@string/talker_last_none" />
<TextView
android:id="@+id/lbl_talker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/accent"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end" />
<!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView
android:id="@+id/lbl_duplex"
@@ -224,19 +255,8 @@
<!-- Кнопка PTT -->
<ru.ua1zbe.svxremote.ui.PttButton
android:id="@+id/ptt_button"
android:layout_width="130dp"
android:layout_height="130dp" />
<TextView
android:id="@+id/lbl_ptt_hint"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:textStyle="bold"
android:text="@string/hold_to_talk" />
android:layout_width="170dp"
android:layout_height="170dp" />
<Space
android:layout_width="match_parent"

View File

@@ -0,0 +1,41 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical"
android:paddingStart="16dp"
android:paddingEnd="8dp"
android:paddingTop="10dp"
android:paddingBottom="10dp">
<TextView
android:id="@+id/rec_name"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="middle" />
<ImageButton
android:id="@+id/rec_play"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_play"
android:tint="@color/accent"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside"
android:contentDescription="Воспроизвести" />
<ImageButton
android:id="@+id/rec_delete"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@drawable/ic_delete"
android:tint="@color/danger"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:scaleType="centerInside"
android:contentDescription="Удалить" />
</LinearLayout>

View File

@@ -24,6 +24,11 @@
<string name="audio_mic_gain">Усиление микрофона</string>
<string name="audio_rx_buffer">Буфер воспроизведения</string>
<string name="audio_jitter">Накопление перед стартом</string>
<string name="audio_tx_bitrate">Скорость передачи (Opus)</string>
<string name="audio_tx_bitrate_auto">Авто</string>
<string name="audio_tx_bitrate_fmt">%1$d кбит/с</string>
<string name="audio_rx_stats">Показывать статистику приёма (потери/джиттер)</string>
<string name="rx_stats_fmt">RX: потери %1$.1f%%, джиттер %2$d мс</string>
<string name="toast_node_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string>
@@ -36,12 +41,22 @@
<string name="status_disconnected">Отключено</string>
<string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
<string name="monitor_off">Обычный режим</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
<string name="record_started">Запись приёма начата</string>
<string name="record_saved">Сохранено: %1$s</string>
<string name="record_failed">Не удалось начать запись (формат OGG недоступен)</string>
<string name="recordings_title">Записи приёма</string>
<string name="record_play_failed">Не удалось воспроизвести запись</string>
<string name="record_delete_title">Удалить запись?</string>
<string name="record_delete_yes">Удалить</string>
<string name="record_deleted">Удалено</string>
<string name="status_monitoring">Мониторинг: %1$s</string>
<string name="talker_now">Говорит TG%1$d</string>
<string name="talker_active_fmt">Говорит %1$s TG%2$d</string>
<string name="talker_idle_fmt">%1$s TG%2$d</string>
<string name="duplex_rx">RX</string>
<string name="duplex_tx">TX</string>
<string name="status_big_disconnected">DISCONNECTED</string>
@@ -49,7 +64,6 @@
<string name="status_big_talking">ПЕРЕДАЧА</string>
<string name="hint_connect">Нажмите, чтобы подключиться</string>
<string name="hint_disconnect">Нажмите, чтобы отключиться</string>
<string name="hold_to_talk">HOLD TO TALK</string>
<string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string>
<string name="meter_idle">---</string>
@@ -66,6 +80,8 @@
<string name="notif_connected">Соединение с рефлектором активно</string>
<string name="notif_talking">Говорит: %1$s</string>
<string name="notif_last_talker">Последний: %1$s</string>
<string name="push_channel_id">svx_push</string>
<string name="push_channel_name">Пуш-уведомления</string>
<string name="btn_connect">Подключиться</string>
<string name="btn_disconnect">Отключиться</string>

View File

@@ -5,4 +5,5 @@ org.gradle.daemon=false
org.gradle.parallel=false
org.gradle.caching=false
kotlin.compiler.execution.strategy=in-process
android.nonTransitiveRClass=true
android.nonTransitiveRClass=true
android.useAndroidX=true

View File

@@ -11,6 +11,7 @@ dependencyResolutionManagement {
repositories {
google()
mavenCentral()
maven("https://nexus-external.rustore.ru/repository/maven-rustore-exposed/")
}
}