15 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
24 changed files with 901 additions and 145 deletions

View File

@@ -7,17 +7,69 @@ Android-клиент для подключения к рефлекторам **S
- Подключение к рефлектору SVXLink по протоколу v2 с авторизацией по AUTH_KEY - Подключение к рефлектору SVXLink по протоколу v2 с авторизацией по AUTH_KEY
- Выбор группы вещания (TG) — по умолчанию 11 - Выбор группы вещания (TG) — по умолчанию 11
- Голосовая связь в формате **Opus** (8 кГц, моно) через UDP - Голосовая связь в формате **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` для отправки на сервер - Позывной регистронезависимый: `ua1zbe-m` автоматически преобразуется в `UA1ZBE-M` для отправки на сервер
## Запись приёма
- Кнопка записи расположена между позывным и кнопкой мониторинга групп.
- **Короткое нажатие** — начать/остановить запись. Файл сохраняется в
`Android/data/ru.ua1zbe.svxremote/files/recordings/rec_ГГГГ-ММ-ДД_ЧЧ-ММ-СС.ogg`.
- Записывается только реальный приём: паузы в эфире (тишина) в файл не попадают,
реплики склеиваются без разрывов.
- **Удержание кнопки 3 секунды** — открывается список записей: воспроизведение
(повторный тап останавливает) и удаление с подтверждением. Если записей нет —
выводится подсказка.
## Скрытые настройки звука
Открываются тройным тапом по заголовку «SVXHomyak» на главном экране. Позволяют:
- включить/выключить отображение статистики приёма (потери/джиттер);
- выбрать битрейт передачи (Авто или 6–32 кбит/с).
## Требования ## Требования
- Android 8.0 (API 26) и выше - Android 8.0 (API 26) и выше
- Доступ к сети и микрофону - Доступ к сети и микрофону
- Рефлектор SVXLink с включённой поддержкой протокола v2 (AUTH_KEY) - Рефлектор 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 появится в `app/build/outputs/apk/debug/app-debug.apk`.
Для сборки релизного (подписанного) APK:
```bash
./gradlew :app:assembleRelease
# далее выровнять и подписать через build-tools (zipalign + apksigner)
```
Требования: JDK 17, Android SDK (compileSdk 34). Требования: 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/SettingsActivity.kt` — настройки подключения
- `app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt` — клиент рефлектора (TCP + UDP) - `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/proto/ReflectorProtocol.kt` — бинарный протокол svxreflector v2
- `app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt` — кодирование/декодирование Opus через MediaCodec - `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/` — юнит-тесты протокола и рукопожатия - `app/src/test/java/ru/ua1zbe/svxremote/` — юнит-тесты протокола и рукопожатия
## Тесты ## Тесты

View File

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

View File

@@ -1,5 +1,6 @@
<?xml version="1.0" encoding="utf-8"?> <?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.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" /> <uses-permission android:name="android.permission.RECORD_AUDIO" />
@@ -9,6 +10,7 @@
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" /> <uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:name=".SvxApp"
android:label="@string/app_name" android:label="@string/app_name"
android:icon="@mipmap/ic_launcher" android:icon="@mipmap/ic_launcher"
android:usesCleartextTraffic="true" android:usesCleartextTraffic="true"
@@ -20,6 +22,22 @@
android:exported="false" android:exported="false"
android:foregroundServiceType="mediaPlayback" /> 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 <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" android:exported="false"

View File

@@ -4,6 +4,7 @@ import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.widget.ArrayAdapter import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.CheckBox
import android.widget.SeekBar import android.widget.SeekBar
import android.widget.Spinner import android.widget.Spinner
import android.widget.TextView import android.widget.TextView
@@ -78,11 +79,15 @@ class AudioSettingsActivity : Activity() {
seekRxBuffer.setOnSeekBarChangeListener(listener) seekRxBuffer.setOnSeekBarChangeListener(listener)
seekJitter.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 { findViewById<Button>(R.id.btn_audio_save).setOnClickListener {
Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f) Prefs.setMicGain(this, 1.0f + seekMicGain.progress * 0.1f)
Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20) Prefs.setRxBufferMs(this, 100 + seekRxBuffer.progress * 20)
Prefs.setRxJitterMs(this, seekJitter.progress * 20) Prefs.setRxJitterMs(this, seekJitter.progress * 20)
Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition]) Prefs.setTxBitrateKbps(this, bitrates[spinBitrate.selectedItemPosition])
Prefs.setRxStatsEnabled(this, chkRxStats.isChecked)
Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show() Toast.makeText(this, getString(R.string.saved), Toast.LENGTH_SHORT).show()
finish() finish()
} }

View File

@@ -8,7 +8,11 @@ import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.media.MediaPlayer
import android.net.Uri import android.net.Uri
import android.view.ViewGroup
import android.widget.ArrayAdapter
import android.widget.ListView
import android.content.res.ColorStateList import android.content.res.ColorStateList
import android.graphics.Color import android.graphics.Color
import android.graphics.PixelFormat import android.graphics.PixelFormat
@@ -53,18 +57,30 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var lblSubtitle: TextView private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView private lateinit var lblTalker: TextView
private lateinit var lblDuplex: 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 lblTxState: TextView
private lateinit var lblConnectHint: TextView private lateinit var lblConnectHint: TextView
private lateinit var connectZone: View private lateinit var connectZone: View
private lateinit var rxMeter: SMeterView private lateinit var rxMeter: SMeterView
private lateinit var micMeter: SMeterView private lateinit var micMeter: SMeterView
private lateinit var lblRxStats: TextView
private lateinit var pttButton: PttButton private lateinit var pttButton: PttButton
private lateinit var btnSettings: ImageButton private lateinit var btnSettings: ImageButton
private lateinit var btnMonitor: 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 val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null private var titleHoldRunnable: Runnable? = null
@@ -90,6 +106,7 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onServiceConnected(name: ComponentName, binder: IBinder) { override fun onServiceConnected(name: ComponentName, binder: IBinder) {
svc = (binder as SvxService.LocalBinder).service() svc = (binder as SvxService.LocalBinder).service()
svc?.listener = this@MainActivity svc?.listener = this@MainActivity
updateRecordUi(svc?.isRecording() ?: false)
} }
override fun onServiceDisconnected(name: ComponentName) { override fun onServiceDisconnected(name: ComponentName) {
@@ -130,10 +147,8 @@ class MainActivity : Activity(), SvxService.Listener {
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblAppTitle = findViewById(R.id.lbl_app_title) lblAppTitle = findViewById(R.id.lbl_app_title)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
lblTalkerTitle = findViewById(R.id.lbl_talker_title)
lblTalker = findViewById(R.id.lbl_talker) lblTalker = findViewById(R.id.lbl_talker)
lblDuplex = findViewById(R.id.lbl_duplex) lblDuplex = findViewById(R.id.lbl_duplex)
lblPttHint = findViewById(R.id.lbl_ptt_hint)
lblTxState = findViewById(R.id.lbl_tx_state) lblTxState = findViewById(R.id.lbl_tx_state)
lblConnectHint = findViewById(R.id.lbl_connect_hint) lblConnectHint = findViewById(R.id.lbl_connect_hint)
connectZone = findViewById(R.id.connect_zone) connectZone = findViewById(R.id.connect_zone)
@@ -142,12 +157,33 @@ class MainActivity : Activity(), SvxService.Listener {
rxMeter = findViewById(R.id.rx_meter) rxMeter = findViewById(R.id.rx_meter)
micMeter = findViewById(R.id.mic_meter) micMeter = findViewById(R.id.mic_meter)
micMeter.setColor(0xFFF85149.toInt()) 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) pttButton = findViewById(R.id.ptt_button)
btnSettings = findViewById(R.id.btn_settings) btnSettings = findViewById(R.id.btn_settings)
btnMonitor = findViewById(R.id.btn_monitor) btnMonitor = findViewById(R.id.btn_monitor)
btnRecord = findViewById(R.id.btn_record)
pttButton.enabled2 = false pttButton.enabled2 = false
btnMonitor.setOnClickListener { toggleMonitoring() } 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() updateMonitorButton()
val testText = intent?.getStringExtra("node_toast_test") val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) { if (testText != null) {
@@ -236,7 +272,6 @@ class MainActivity : Activity(), SvxService.Listener {
toast(getString(R.string.perm_mic)) toast(getString(R.string.perm_mic))
return return
} }
lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking) lblTxState.text = getString(R.string.status_big_talking)
showDuplex(getString(R.string.duplex_tx), R.color.danger, null) showDuplex(getString(R.string.duplex_tx), R.color.danger, null)
svc?.startTx() svc?.startTx()
@@ -245,7 +280,6 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onPttUp() { override fun onPttUp() {
Log.d("SVXRemote", "PTT up") Log.d("SVXRemote", "PTT up")
svc?.stopTx() svc?.stopTx()
lblPttHint.text = getString(R.string.hold_to_talk)
lblTxState.text = lblTxState.text =
if (connected) getString(R.string.status_big_connected) if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected) else getString(R.string.status_big_disconnected)
@@ -301,6 +335,8 @@ class MainActivity : Activity(), SvxService.Listener {
hideNodeOverlay() hideNodeOverlay()
aprsMeteo?.stop() aprsMeteo?.stop()
aprsMeteo = null aprsMeteo = null
mainHandler.removeCallbacks(recLongPressTask)
stopRecordingPlayback()
mainHandler.removeCallbacksAndMessages(null) mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false SplashActivity.mainAlive = false
svc?.listener = null svc?.listener = null
@@ -336,7 +372,6 @@ class MainActivity : Activity(), SvxService.Listener {
lblTxState.text = getString(R.string.status_big_disconnected) lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger)) lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect) lblConnectHint.text = getString(R.string.hint_connect)
lblPttHint.text = getString(R.string.hold_to_talk)
} }
ReflectorClient.STATE_CONNECTING, ReflectorClient.STATE_CONNECTING,
@@ -414,9 +449,9 @@ class MainActivity : Activity(), SvxService.Listener {
} }
resetMonitorIdle() resetMonitorIdle()
} }
lblTalkerTitle.text = getString(R.string.talker_now, tg) lastTalkerCs = callsign
lblTalkerTitle.setTextColor(getColor(R.color.text_secondary)) lastTalkerTg = tg
lblTalker.text = callsign updateTalkerLine(active = true)
if (!pttButton.isPressed2) { if (!pttButton.isPressed2) {
showDuplex(getString(R.string.duplex_rx), R.color.accent, DUPLEX_RX_HIDE_MS) 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) { 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?) { 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() { private fun resetMonitorIdle() {
monitorIdleTask?.let { mainHandler.removeCallbacks(it) } 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) 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) { private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg) 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.os.IBinder
import android.util.Log import android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.audio.RxRecorder
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -31,15 +32,22 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun onAudioFlush() fun onAudioFlush()
fun onError(detail: String) fun onError(detail: String)
fun onLevel(source: Int, level: Float) fun onLevel(source: Int, level: Float)
fun onRxStats(lossPct: Double, jitterMs: Double)
} }
var listener: Listener? = null var listener: Listener? = null
private val engine by lazy { 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) } val client: ReflectorClient by lazy { ReflectorClient(this) }
/** Запись приёма в OGG (управляется из UI). */
private val recorder = RxRecorder()
private val binder = LocalBinder() private val binder = LocalBinder()
inner class LocalBinder : Binder() { inner class LocalBinder : Binder() {
@@ -185,4 +193,27 @@ class SvxService : Service(), ReflectorClient.Callbacks {
override fun onError(detail: String) { override fun onError(detail: String) {
listener?.onError(detail) 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. * SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
*/ */
class OpusEngine( 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 { companion object {
@@ -30,6 +31,7 @@ class OpusEngine(
const val CHANNELS = 1 const val CHANNELS = 1
const val FRAME_SIZE = 160 // 20 мс в 8 кГц const val FRAME_SIZE = 160 // 20 мс в 8 кГц
const val BIT_RATE = 20000 const val BIT_RATE = 20000
private const val GATE_MIN_WAIT_MS = 100L
// Авто-адаптация битрейта TX (0 = авто включено в настройках) // Авто-адаптация битрейта TX (0 = авто включено в настройках)
const val AUTO_BITRATE_MIN = 8 const val AUTO_BITRATE_MIN = 8
const val AUTO_BITRATE_MAX = 24 const val AUTO_BITRATE_MAX = 24
@@ -71,6 +73,9 @@ class OpusEngine(
// Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет). // Скорость передачи Opus, кбит/с. 0 = Авто (адаптация под интернет).
@Volatile @Volatile
var txBitrateKbps = 0 var txBitrateKbps = 0
// Перехват декодированного RX PCM (для записи в файл). Аргументы: PCM, реальный приём?
@Volatile
var rxTap: ((pcm: ByteArray, isReal: Boolean) -> Unit)? = null
// Текущий битрейт в режиме Авто (пересчитывается по статистике приёма) // Текущий битрейт в режиме Авто (пересчитывается по статистике приёма)
@Volatile @Volatile
private var autoKbps = 16 private var autoKbps = 16
@@ -241,6 +246,7 @@ class OpusEngine(
// Уровень показываем только для реальных пакетов: во время // Уровень показываем только для реальных пакетов: во время
// скрытия потерь (conceal) шкала должна быть в нуле // скрытия потерь (conceal) шкала должна быть в нуле
val real = System.currentTimeMillis() - lastRealPacketTime < 400 val real = System.currentTimeMillis() - lastRealPacketTime < 400
rxTap?.invoke(pcm, real)
val lvl = if (real) pcmLevel(pcm) else 0f val lvl = if (real) pcmLevel(pcm) else 0f
// Порог тишины: шум декодера и паузы речи — в ноль // Порог тишины: шум декодера и паузы речи — в ноль
onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl) onLevel(SRC_SPEAKER, if (lvl < 0.02f) 0f else lvl)
@@ -330,10 +336,15 @@ class OpusEngine(
private fun updateRxStats(seq: Int) { private fun updateRxStats(seq: Int) {
val now = System.currentTimeMillis() val now = System.currentTimeMillis()
if (lastSeq >= 0) { if (lastSeq >= 0) {
val gap = ((seq - lastSeq - 1) and 0xFFFF).toLong()
if (gap > 0) lossCount += gap
pktCount++
val dt = now - lastPktTime 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() val j = abs(dt - 20).toDouble()
if (j < 100) { if (j < 100) {
jitterAccum += j jitterAccum += j
@@ -349,6 +360,7 @@ class OpusEngine(
if (now - lastEvalTime < 5000 || pktCount < 30) return if (now - lastEvalTime < 5000 || pktCount < 30) return
val lossPct = lossCount * 100.0 / pktCount val lossPct = lossCount * 100.0 / pktCount
val jitterMs = if (jitterN > 0) jitterAccum / jitterN else 0.0 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 (lossPct > 3.0 || jitterMs > 60.0) {
if (autoKbps > AUTO_BITRATE_MIN) { if (autoKbps > AUTO_BITRATE_MIN) {
autoKbps -= AUTO_BITRATE_STEP autoKbps -= AUTO_BITRATE_STEP
@@ -370,58 +382,29 @@ class OpusEngine(
jitterAccum = 0.0 jitterAccum = 0.0
jitterN = 0 jitterN = 0
lastEvalTime = now lastEvalTime = now
onRxStats?.invoke(lossPct, jitterMs)
} }
/** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */ /** Джиттер-гейт открыт: накопили кадры или вышел короткий таймаут. */
private fun gateOpen(): Boolean { private fun gateOpen(): Boolean {
if (playStarted) return true if (playStarted) return true
if (rxQueue.size >= jitterFrames) return true if (rxQueue.size >= jitterFrames) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 300 val waitMs = maxOf(GATE_MIN_WAIT_MS, jitterFrames * 20L)
return gateStart != 0L && System.currentTimeMillis() - gateStart >= waitMs
} }
fun onAudioFlush() { fun onAudioFlush() {
try { try {
// MediaCodec.flush() возвращает входные буферы, но уже выданные индексы
// остаются у нас в freeInputs и дублируются колбэками «повторной выдачи» —
// queueInputBuffer падает (index not owned by client) и кадры теряются.
// Надёжнее пересоздать декодер: колбэки старого отсекаются проверкой
// codec !== decoder.
player?.flush() player?.flush()
rxQueue.clear() rxQueue.clear()
receivedAny = false receivedAny = false
playStarted = false playStarted = false
gateStart = 0L gateStart = 0L
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
restartDecoder()
} catch (_: Exception) { } 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 мс) для скрытия потерь вместо паузы в звуке. * Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке.
* Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM. * Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM.

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

@@ -170,4 +170,22 @@ object Prefs {
fun setTxBitrateKbps(context: Context, kbps: Int) { fun setTxBitrateKbps(context: Context, kbps: Int) {
sp(context).edit().putInt("tx_bitrate_kbps", kbps.coerceIn(0, 32)).apply() 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 if (data.size < 6) return
val r = BinReader(data) val r = BinReader(data)
val type = r.u16() val type = r.u16()
r.u16() // client id val cid = r.u16() // client id
val seq = r.u16() val seq = r.u16()
val payload = data.copyOfRange(6, data.size) val payload = data.copyOfRange(6, data.size)
when (type) { when (type) {
@@ -549,7 +549,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_UDP_AUDIO -> { ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload) val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) { 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) callbacks.onAudioPacket(audio, seq)
} else { } else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}") 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.content.Context
import android.graphics.Canvas import android.graphics.Canvas
import android.graphics.Color import android.graphics.Color
import android.graphics.LinearGradient
import android.graphics.Paint import android.graphics.Paint
import android.graphics.Path import android.graphics.Path
import android.graphics.Shader
import android.util.AttributeSet import android.util.AttributeSet
import android.view.MotionEvent import android.view.MotionEvent
import android.view.View import android.view.View
import ru.ua1zbe.svxremote.R import ru.ua1zbe.svxremote.R
/** /**
* Большая круглая кнопка PTT. Удерживание — передача. * Большая круглая кнопка PTT в стиле Zello: градиентная заливка с глянцем,
* мягкая тень (плавающая кнопка) и тонкий ободок.
* Состояния: idle (синяя), нажата (красная), отключена (серая). * Состояния: idle (синяя), нажата (красная), отключена (серая).
*/ */
class PttButton @JvmOverloads constructor( class PttButton @JvmOverloads constructor(
@@ -19,8 +22,11 @@ class PttButton @JvmOverloads constructor(
attrs: AttributeSet? = null attrs: AttributeSet? = null
) : View(context, attrs) { ) : 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 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 radius = 0f
private var centerX = 0f private var centerX = 0f
@@ -31,7 +37,6 @@ class PttButton @JvmOverloads constructor(
set(value) { set(value) {
field = value field = value
if (!value) setPressed(false) if (!value) setPressed(false)
refreshColors()
invalidate() invalidate()
} }
@@ -50,22 +55,28 @@ class PttButton @JvmOverloads constructor(
} }
init { 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.style = Paint.Style.STROKE
ringPaint.strokeWidth = dp(3).toFloat() ringPaint.strokeWidth = dp(2).toFloat()
ringPaint.color = Color.parseColor("#3D444D") glossPaint.style = Paint.Style.FILL
paint.style = Paint.Style.FILL micPaint.style = Paint.Style.FILL
refreshColors() micPaint.color = Color.WHITE
isClickable = true isClickable = true
isFocusable = true isFocusable = true
} }
private fun refreshColors() { /** (верхний цвет, нижний цвет) градиента заливки. */
paint.color = when { private fun fillColors(): Pair<Int, Int> = when {
!enabled2 -> Color.parseColor("#484F58") !enabled2 -> 0x565E68 to 0x3C424B
isPressed -> Color.parseColor("#F85149") isPressed -> 0xFF8A80 to 0xDB3A30
else -> Color.parseColor("#1F6FEB") else -> 0x4A9BFF to 0x1561D6
} }
invalidate()
private fun ringColor(): Int = when {
!enabled2 -> 0x6B727B
isPressed -> 0xFFA79F
else -> 0x6FB0FF
} }
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() 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) super.onSizeChanged(w, h, oldw, oldh)
centerX = w / 2f centerX = w / 2f
centerY = h / 2f centerY = h / 2f
radius = minOf(w, h) / 2f - dp(16) radius = minOf(w, h) / 2f - dp(18)
refreshColors()
} }
override fun onDraw(canvas: Canvas) { override fun onDraw(canvas: Canvas) {
super.onDraw(canvas) super.onDraw(canvas)
val r = radius / (1f + if (isPressed) 0.06f else 0f) val r = radius / (1f + if (isPressed) 0.06f else 0f)
canvas.drawCircle(centerX, centerY, r, paint) val cx = centerX
canvas.drawCircle(centerX, centerY, r, ringPaint) val cy = centerY
val (top, bottom) = fillColors()
// Иконка микрофона (как icons8: капсула + чашка + ножка + основание) // Заливка с вертикальным градиентом (глянец)
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG) fillPaint.shader = LinearGradient(
micPaint.color = Color.WHITE cx, cy - r, cx, cy + r, top, bottom, Shader.TileMode.CLAMP
micPaint.style = Paint.Style.FILL )
canvas.drawCircle(cx, cy, r, fillPaint)
val x = centerX // Тонкий ободок по краю
val y = centerY ringPaint.color = ringColor()
val s = r * 0.5f 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 val halfW = s * 0.34f
canvas.drawRoundRect( canvas.drawRoundRect(
x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f, x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
halfW, halfW, micPaint halfW, halfW, micPaint
) )
// чашка-держатель (U-образная, заполненная) // чашка-держатель (U-образная)
val cup = Path() val cup = Path()
cup.fillType = Path.FillType.EVEN_ODD cup.fillType = Path.FillType.EVEN_ODD
cup.moveTo(x - s * 0.52f, y + s * 0.06f) cup.moveTo(x - s * 0.52f, y + s * 0.06f)

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

@@ -135,6 +135,15 @@
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp" /> 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 <Button
android:id="@+id/btn_audio_save" android:id="@+id/btn_audio_save"
android:layout_width="match_parent" android:layout_width="match_parent"

View File

@@ -85,10 +85,22 @@
android:ellipsize="end" android:ellipsize="end"
android:text="@string/settings_not_configured" /> 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 <ImageButton
android:id="@+id/btn_monitor" android:id="@+id/btn_monitor"
android:layout_width="44dp" android:layout_width="44dp"
android:layout_height="44dp" android:layout_height="44dp"
android:layout_marginStart="8dp"
android:src="@drawable/ic_headset" android:src="@drawable/ic_headset"
android:background="?android:attr/selectableItemBackgroundBorderless" android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_monitor" android:contentDescription="@string/btn_monitor"
@@ -104,38 +116,68 @@
android:textStyle="bold" android:textStyle="bold"
android:fontFamily="monospace" /> android:fontFamily="monospace" />
</LinearLayout> </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>
</LinearLayout> </LinearLayout>
<!-- Статус соединения --> <!-- Секция: APRS/подключение + статус -->
<TextView <LinearLayout
android:id="@+id/status_chip"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" 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:gravity="center"
android:textColor="@color/danger" android:textColor="@color/accent"
android:textSize="18sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
android:text="@string/status_disconnected" /> android:singleLine="true"
android:ellipsize="end" />
<!-- S-метры --> <!-- S-метры -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="28dp" android:layout_marginTop="16dp"
android:orientation="vertical"> android:orientation="vertical"
android:background="@drawable/bg_section"
android:paddingStart="14dp"
android:paddingEnd="14dp"
android:paddingTop="12dp"
android:paddingBottom="12dp">
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -181,30 +223,19 @@
android:layout_height="52dp" android:layout_height="52dp"
android:layout_weight="1" /> android:layout_weight="1" />
</LinearLayout> </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> </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 (передача) --> <!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView <TextView
android:id="@+id/lbl_duplex" android:id="@+id/lbl_duplex"
@@ -224,19 +255,8 @@
<!-- Кнопка PTT --> <!-- Кнопка PTT -->
<ru.ua1zbe.svxremote.ui.PttButton <ru.ua1zbe.svxremote.ui.PttButton
android:id="@+id/ptt_button" android:id="@+id/ptt_button"
android:layout_width="130dp" android:layout_width="170dp"
android:layout_height="130dp" /> android:layout_height="170dp" />
<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" />
<Space <Space
android:layout_width="match_parent" 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

@@ -27,6 +27,8 @@
<string name="audio_tx_bitrate">Скорость передачи (Opus)</string> <string name="audio_tx_bitrate">Скорость передачи (Opus)</string>
<string name="audio_tx_bitrate_auto">Авто</string> <string name="audio_tx_bitrate_auto">Авто</string>
<string name="audio_tx_bitrate_fmt">%1$d кбит/с</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_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string> <string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string> <string name="node_tg_suffix"> (группа %1$d)</string>
@@ -39,12 +41,22 @@
<string name="status_disconnected">Отключено</string> <string name="status_disconnected">Отключено</string>
<string name="app_title">SVXHomyak</string> <string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string> <string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="btn_record">Запись приёма в файл (нажмите, чтобы начать/остановить)</string>
<string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string> <string name="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Мониторинг выбранных групп!</string> <string name="monitoring_dev_warning">Мониторинг выбранных групп!</string>
<string name="monitor_off">Обычный режим</string> <string name="monitor_off">Обычный режим</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</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="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_rx">RX</string>
<string name="duplex_tx">TX</string> <string name="duplex_tx">TX</string>
<string name="status_big_disconnected">DISCONNECTED</string> <string name="status_big_disconnected">DISCONNECTED</string>
@@ -52,7 +64,6 @@
<string name="status_big_talking">ПЕРЕДАЧА</string> <string name="status_big_talking">ПЕРЕДАЧА</string>
<string name="hint_connect">Нажмите, чтобы подключиться</string> <string name="hint_connect">Нажмите, чтобы подключиться</string>
<string name="hint_disconnect">Нажмите, чтобы отключиться</string> <string name="hint_disconnect">Нажмите, чтобы отключиться</string>
<string name="hold_to_talk">HOLD TO TALK</string>
<string name="meter_rx">RX</string> <string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string> <string name="meter_mic">MIC</string>
<string name="meter_idle">---</string> <string name="meter_idle">---</string>
@@ -69,6 +80,8 @@
<string name="notif_connected">Соединение с рефлектором активно</string> <string name="notif_connected">Соединение с рефлектором активно</string>
<string name="notif_talking">Говорит: %1$s</string> <string name="notif_talking">Говорит: %1$s</string>
<string name="notif_last_talker">Последний: %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_connect">Подключиться</string>
<string name="btn_disconnect">Отключиться</string> <string name="btn_disconnect">Отключиться</string>

View File

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

View File

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