74 Commits

Author SHA1 Message Date
ua1zbe
0a24a6f6c0 Bump version to 1.0.14 (16) 2026-10-09 13:20:28 +03:00
ua1zbe
b847320360 reconnect: соблюдать паузу между попытками (backoff)
tryConnectOnce игнорировала результат runTcpLoop и всегда возвращала true,
из-за чего reconnectLoop не делал паузу и переподключался заново каждые
~30 мс, заваливая сервер MSG_ERROR 'Access denied'. Теперь возвращаем
!running, поэтому после разрыва/отказа выдерживается RECONNECT_DELAY_MS.
2026-10-09 13:17:50 +03:00
ua1zbe
203e5f6ed5 reconnect: не уходить в «ошибку авторизации» при коротком обрыве
После короткого обрыва сервер ещё держит старую сессию и на рукопожатии
присылает MSG_ERROR. Клиент трактовал любую ошибку до STATE_READY как
фатальную (stopFatal → STATE_AUTH_FAILED) и прекращал переподключения.

Теперь ошибки рукопожатия (MSG_ERROR, протокольные сбои, слишком большой
кадр) считаются временными, если успешная сессия уже была (everReady):
показываем STATE_ERROR и продолжаем автопереподключение. Фатально — только
до первой успешной авторизации (реально неверные креды).
2026-10-09 13:05:05 +03:00
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
ua1zbe
f5f51bce0c Bump to 1.0.9 2026-08-18 16:31:51 +03:00
ua1zbe
fbded33b67 Enlarge RX/TX indicator to 24sp 2026-08-18 16:26:11 +03:00
ua1zbe
6cee5999b8 Show 'Говорит TG..' + callsign + RX/TX duplex indicator 2026-08-18 16:22:41 +03:00
ua1zbe
2d85b0587b Fix talker label in normal mode; keep base group during own TX 2026-08-18 16:10:32 +03:00
ua1zbe
43890117b2 Monitoring toast: 'Мониторинг выбранных групп!' 2026-08-18 16:02:15 +03:00
ua1zbe
e5f964dff7 Toggle monitoring over live connection without reconnect 2026-08-18 15:58:09 +03:00
ua1zbe
05ba30e0c6 Enable PTT in monitoring mode; don't switch group while transmitting 2026-08-18 15:50:13 +03:00
ua1zbe
e53186ea52 Fix group monitoring: per-group 15s idle timeout, drop priority lock 2026-08-18 15:42:52 +03:00
ua1zbe
b0bf837a92 Force RX meter to zero 600ms after signal ends; silence threshold 2026-08-18 15:21:29 +03:00
ua1zbe
88739f17a9 Replace RX/MIC bars with S-meter (S1-S9, +20..+60, peak-hold) 2026-08-18 15:15:34 +03:00
ua1zbe
b63ef121f9 Sound meter preview dialog (hold on 'На связи'): S-meter, clipping, jitter/loss demo 2026-08-18 15:07:53 +03:00
ua1zbe
b45f871544 Bump to 1.0.8 2026-08-18 13:35:30 +03:00
ua1zbe
516fb7b932 Show RX/MIC meters as +dB; zero RX meter during conceal 2026-08-18 13:26:05 +03:00
ua1zbe
3de296b5d2 Shorten APRS coords hint 2026-08-18 13:18:33 +03:00
ua1zbe
37ecd0fa6e Separate hidden audio settings screen (mic gain, RX buffer, jitter) via triple-tap; remove from main settings 2026-08-18 13:16:04 +03:00
ua1zbe
467dabe509 Triple-tap on app title opens settings; add mic gain slider (100-500%) 2026-08-18 13:10:43 +03:00
ua1zbe
0f827f7a9c Show real app version in settings 2026-08-18 09:25:28 +03:00
ua1zbe
78ac8648a6 Default APRS coords to Saint Petersburg center; bump to 1.0.7 2026-08-18 09:14:19 +03:00
ua1zbe
ac10ffc7fa Remove APRS callsign setting; connect to APRS-IS with SVX callsign, keep only coords 2026-08-18 09:01:30 +03:00
ua1zbe
f5cb74ba25 Show meteo line centered with WX prefix 2026-08-18 08:53:09 +03:00
ua1zbe
4102140fa0 Fix APRS meteo temp: t-field is whole degrees Fahrenheit (was dividing by 10); round to whole Celsius 2026-08-18 08:46:48 +03:00
ua1zbe
985cd9eb1a Convert APRS meteo temperature from Fahrenheit to Celsius 2026-08-18 07:58:17 +03:00
ua1zbe
7564c8d092 Add APRS weather stations (experimental): receive meteo in 250 km radius, show temperature in main screen line 2026-08-17 16:58:00 +03:00
ua1zbe
fac3c7d692 Bump version to 1.0.6 2026-08-17 13:23:15 +03:00
ua1zbe
884678d36d Fix RX audio for subsequent transmissions (recreate decoder on flush) 2026-08-17 13:18:50 +03:00
ua1zbe
9d7b659231 Add last talkers log (15 entries, time/callsign/TG) 2026-08-17 11:28:39 +03:00
ua1zbe
ee61d3ef6f Update monitored TG when switching group live 2026-08-17 11:14:41 +03:00
ua1zbe
3109c52bbd Fix group switching: send selectGroup on background thread 2026-08-17 11:11:30 +03:00
ua1zbe
eeffc14f1a Switch talk group without reconnecting 2026-08-17 10:55:44 +03:00
ua1zbe
9838daea2f Add multiple connection profiles (v1.0.6) 2026-08-17 10:39:15 +03:00
ua1zbe
531ec51fb6 Release v1.0.5 (versionCode 7) 2026-08-17 09:55:24 +03:00
ua1zbe
e523bf756b Monitoring toggle toasts: 10s dev warning on enable, normal mode on disable (v1.0.5) 2026-08-17 09:50:26 +03:00
ua1zbe
ef72a2dc7f Show dev-warning toast on monitoring button press (v1.0.5) 2026-08-17 09:46:51 +03:00
ua1zbe
ef4fa24ba5 Fix node info field name: swVer is shown on reflector dashboard (v1.0.5) 2026-08-17 09:39:46 +03:00
ua1zbe
a1b2bdaa1c Report actual app version to reflector (BuildConfig.VERSION_NAME) (v1.0.5) 2026-08-17 09:34:26 +03:00
ua1zbe
0b51e63262 Fix Opus decoding and RX jitter buffer on Android 16 (v1.0.5) 2026-08-17 09:22:47 +03:00
ua1zbe
268c9d76af Fix flush on PTT release; monitoring priority tweaks (v1.0.5) 2026-08-17 08:22:47 +03:00
ua1zbe
11446a92a5 Add group monitoring mode (v1.0.5)
- Monitor button between callsign and TG selector
- On: reconnects with TG_MONITOR for all switch-list groups,
  status shows 'Мониторинг: TG...', PTT disabled
- Talker display shows callsign and its TG
- Off: back to normal talk mode
2026-08-14 15:01:46 +03:00
ua1zbe
b988e7cea0 Match callsign size to TG selector (22sp) (v1.0.4) 2026-08-14 13:50:20 +03:00
ua1zbe
f4996bef8e Enlarge TG selector to 22sp and push to right edge (v1.0.4) 2026-08-14 13:48:38 +03:00
ua1zbe
27169e9d0f Move TG selector inline with callsign, enlarge to 18sp (v1.0.4) 2026-08-14 13:46:16 +03:00
ua1zbe
a083660e03 Center app title between power and settings buttons (v1.0.4) 2026-08-14 13:43:13 +03:00
ua1zbe
3de3d7e893 Align power button to left edge (v1.0.4) 2026-08-14 13:40:10 +03:00
ua1zbe
efcec098bd Add power button next to app title (v1.0.4)
- Tap: disconnect from server, stop service and close app
2026-08-14 13:38:04 +03:00
ua1zbe
d0c055fec9 Show talk group next to nodes in list (v1.0.4)
- SvxService tracks node->TG mapping learned from talker events
- Own callsign always shown with its current TG
- Protocol carries no TG in join/leave, so others appear once they talk
2026-08-14 13:32:41 +03:00
ua1zbe
9790f9d69c Show centered node join/leave overlay for 5s (v1.0.4)
- Replaces bottom toast: dark rounded card centered on screen,
  auto-hides after 5 seconds
- Optional test trigger via node_toast_test intent extra
2026-08-14 13:28:55 +03:00
ua1zbe
ea36319fe3 Add connected nodes list dialog (v1.0.4)
- Tap callsign on main screen shows dialog with connected nodes
- SvxService tracks node list: ServerInfo + NODE_JOINED/NODE_LEFT events
- Bump to 1.0.4
2026-08-14 13:05:05 +03:00
ua1zbe
fc224e3e09 Replace app icon with walkie-talkie (icons8 style)
- Adaptive icon foreground is now the purple walkie-talkie PNG on the
  dark background, replaces the white mic vector
- Notification small icon now uses the walkie silhouette
2026-08-14 11:15:35 +03:00
ua1zbe
583ec62e39 Bump version to 1.0.3 2026-08-14 09:37:33 +03:00
ua1zbe
bff0d29ee8 Talk group switcher on main screen, auto-connect on launch
- Tapping the TG badge opens a group list; picking a group saves it and
  reconnects to the server with the new group
- Groups configured in settings (comma-separated), defaults 9,11,12,13
- Auto-connect at app launch to the default group from settings
2026-08-14 09:34:26 +03:00
ua1zbe
abb6f254bc Single-instance app, talker info in notification
- MainActivity singleTask, SplashActivity singleTop: re-launching from
  launcher or notification never creates a second instance
- Splash skips the 4s animation when the main screen is already open
- Foreground notification shows 'Говорит: CALLSIGN' / 'Последний:
  CALLSIGN'; tapping it brings the app back to the front
2026-08-14 09:19:23 +03:00
ua1zbe
1a9dd9a5a4 Add in-app update checker via Gitea API (v1.0.2)
- Check latest release on startup, show 'update available' dialog with
  Update/ Later buttons
- Download APK with progress and install via ACTION_VIEW + custom
  UpdateFileProvider (no androidx)
- Request unknown-app-sources permission if needed, resume download on
  return to the app
2026-08-14 08:54:17 +03:00
ua1zbe
68e149638e Add animated splash screen (4s), show app version on settings page 2026-08-13 16:33:22 +03:00
ua1zbe
805aaa2411 Fix Opus RX decoding, add foreground service, last-talker display
- Decode received Opus audio: handle svxlink self-delimited framing and
  provide monotonic PTS (c2.opus.decoder discards output when PTS == 0)
- Keep connection and playback alive in background via SvxService
  (foreground service, media playback type)
- Keep screen on while app is open
- Show last talker callsign persistently instead of clearing after 4s
- Bump versionCode to 2, disable lint on release builds
2026-08-13 16:23:41 +03:00
47 changed files with 3952 additions and 336 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 = 1 versionCode = 16
versionName = "1.0" versionName = "1.0.14"
} }
buildTypes { buildTypes {
@@ -21,6 +21,15 @@ android {
} }
} }
buildFeatures {
buildConfig = true
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
compileOptions { compileOptions {
sourceCompatibility = JavaVersion.VERSION_17 sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17 targetCompatibility = JavaVersion.VERSION_17
@@ -34,13 +43,17 @@ kotlin {
} }
dependencies { dependencies {
implementation("ru.rustore.sdk:pushclient:7.4.0")
testImplementation("junit:junit:4.13.2") testImplementation("junit:junit:4.13.2")
} }
android { android {
testOptions { testOptions {
unitTests.all { unitTests {
isReturnDefaultValues = true
all {
it.maxHeapSize = "256m" it.maxHeapSize = "256m"
} }
} }
}
} }

View File

@@ -1,31 +1,77 @@
<?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" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<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"
android:supportsRtl="true" android:supportsRtl="true"
android:theme="@style/Theme.SvxRemote"> android:theme="@style/Theme.SvxRemote">
<service
android:name=".SvxService"
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 <activity
android:name=".SettingsActivity" android:name=".SettingsActivity"
android:exported="false" android:exported="false"
android:label="@string/settings_title" /> android:label="@string/settings_title" />
<activity <activity
android:name=".MainActivity" android:name=".AudioSettingsActivity"
android:exported="false"
android:label="@string/audio_settings_title"
android:screenOrientation="portrait" />
<activity
android:name=".SplashActivity"
android:exported="true" android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden"> android:theme="@style/Theme.SvxRemote">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".MainActivity"
android:exported="false"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden" />
<provider
android:name=".UpdateFileProvider"
android:authorities="ru.ua1zbe.svxremote.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
</application> </application>
</manifest> </manifest>

View File

@@ -0,0 +1,95 @@
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
/**
* Скрытые настройки звука (тройной тап по названию программы на главном
* экране): усиление микрофона, буфер воспроизведения (джиттер), накопление
* перед стартом. Применяются при следующем подключении/передаче.
*/
class AudioSettingsActivity : Activity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_audio_settings)
val seekMicGain = findViewById<SeekBar>(R.id.seek_mic_gain)
val lblMicGain = findViewById<TextView>(R.id.lbl_mic_gain_value)
val seekRxBuffer = findViewById<SeekBar>(R.id.seek_rx_buffer)
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}%"
}
fun updateRxBufferLabel() {
lblRxBuffer.text = "${100 + seekRxBuffer.progress * 20} мс"
}
fun updateJitterLabel() {
lblJitter.text = "${seekJitter.progress * 20} мс"
}
seekMicGain.progress = ((Prefs.micGain(this) - 1.0f) * 10).toInt().coerceIn(0, 40)
seekRxBuffer.progress = ((Prefs.rxBufferMs(this) - 100) / 20).coerceIn(0, 25)
seekJitter.progress = (Prefs.rxJitterMs(this) / 20).coerceIn(0, 20)
updateMicGainLabel()
updateRxBufferLabel()
updateJitterLabel()
val listener = object : SeekBar.OnSeekBarChangeListener {
override fun onProgressChanged(seekBar: SeekBar, progress: Int, fromUser: Boolean) {
updateMicGainLabel()
updateRxBufferLabel()
updateJitterLabel()
}
override fun onStartTrackingTouch(seekBar: SeekBar) {}
override fun onStopTrackingTouch(seekBar: SeekBar) {}
}
seekMicGain.setOnSeekBarChangeListener(listener)
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()
}
}
}

File diff suppressed because it is too large Load Diff

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

@@ -2,36 +2,67 @@ package ru.ua1zbe.svxremote
import android.app.Activity import android.app.Activity
import android.os.Bundle import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText import android.widget.EditText
import android.widget.Spinner
import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.prefs.Profile
class SettingsActivity : Activity() { class SettingsActivity : Activity() {
private lateinit var spinnerProfile: Spinner
private lateinit var inputHost: EditText private lateinit var inputHost: EditText
private lateinit var inputPort: EditText private lateinit var inputPort: EditText
private lateinit var inputCallsign: EditText private lateinit var inputCallsign: EditText
private lateinit var inputAuthKey: EditText private lateinit var inputAuthKey: EditText
private lateinit var inputTg: EditText private lateinit var inputTg: EditText
private lateinit var inputGroups: EditText
private lateinit var inputAprsCoords: EditText
private var currentProfile = 0
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings) setContentView(R.layout.activity_settings)
spinnerProfile = findViewById(R.id.spinner_profile)
inputHost = findViewById(R.id.input_host) inputHost = findViewById(R.id.input_host)
inputPort = findViewById(R.id.input_port) inputPort = findViewById(R.id.input_port)
inputCallsign = findViewById(R.id.input_callsign) inputCallsign = findViewById(R.id.input_callsign)
inputAuthKey = findViewById(R.id.input_auth_key) inputAuthKey = findViewById(R.id.input_auth_key)
inputTg = findViewById(R.id.input_tg) inputTg = findViewById(R.id.input_tg)
inputGroups = findViewById(R.id.input_groups)
inputAprsCoords = findViewById(R.id.input_aprs_coords)
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
inputAprsCoords.setText(Prefs.aprsCoords(this))
findViewById<TextView>(R.id.lbl_version).text =
getString(R.string.app_version_fmt, BuildConfig.VERSION_NAME)
val s = Prefs.load(this) val profileNames = (1..Prefs.PROFILE_COUNT)
inputHost.setText(s.host) .map { getString(R.string.profile_name, it) }
inputPort.setText(s.port.toString()) spinnerProfile.adapter = ArrayAdapter(
inputCallsign.setText(s.callsign) this, android.R.layout.simple_spinner_dropdown_item, profileNames
inputAuthKey.setText(s.authKey) )
inputTg.setText(s.talkGroup.toString()) currentProfile = Prefs.activeProfileIndex(this)
spinnerProfile.setSelection(currentProfile)
spinnerProfile.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (position != currentProfile) {
currentProfile = position
loadFields(Prefs.loadProfile(this@SettingsActivity, position).settings)
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
loadFields(Prefs.loadProfile(this, currentProfile).settings)
findViewById<Button>(R.id.btn_save).setOnClickListener { findViewById<Button>(R.id.btn_save).setOnClickListener {
val host = inputHost.text.toString().trim() val host = inputHost.text.toString().trim()
@@ -39,27 +70,45 @@ class SettingsActivity : Activity() {
val callsign = inputCallsign.text.toString().trim() val callsign = inputCallsign.text.toString().trim()
val authKey = inputAuthKey.text.toString() val authKey = inputAuthKey.text.toString()
val tg = inputTg.text.toString().trim().toLongOrNull() ?: 11 val tg = inputTg.text.toString().trim().toLongOrNull() ?: 11
val groups = inputGroups.text.toString()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
if (host.isBlank() || callsign.isBlank()) { if (host.isBlank() || callsign.isBlank()) {
toast(getString(R.string.toast_settings_empty)) toast(getString(R.string.toast_settings_empty))
return@setOnClickListener return@setOnClickListener
} }
Prefs.save( val profile = Profile(
this, name = getString(R.string.profile_name, currentProfile + 1),
ConnectionSettings( settings = ConnectionSettings(
host = host, host = host,
port = port.coerceIn(1, 65535), port = port.coerceIn(1, 65535),
callsign = callsign, callsign = callsign,
authKey = authKey, authKey = authKey,
talkGroup = tg.coerceAtLeast(0) talkGroup = tg.coerceAtLeast(0),
groups = groups
) )
) )
Prefs.saveProfile(this, currentProfile, profile)
Prefs.setActiveProfile(this, currentProfile)
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
Prefs.setAprsCoords(this, inputAprsCoords.text.toString())
toast(getString(R.string.saved)) toast(getString(R.string.saved))
finish() finish()
} }
} }
private fun loadFields(s: ConnectionSettings) {
inputHost.setText(s.host)
inputPort.setText(s.port.toString())
inputCallsign.setText(s.callsign)
inputAuthKey.setText(s.authKey)
inputTg.setText(s.talkGroup.toString())
inputGroups.setText(s.groups.joinToString(", "))
}
private fun toast(msg: String) { private fun toast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
} }

View File

@@ -0,0 +1,72 @@
package ru.ua1zbe.svxremote
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
/** Красивая заставка с плавным появлением логотипа. */
class SplashActivity : Activity() {
companion object {
@Volatile
var mainAlive = false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (mainAlive) {
openMain()
return
}
setContentView(R.layout.activity_splash)
val title = findViewById<View>(R.id.splash_title)
val subtitle = findViewById<View>(R.id.splash_subtitle)
val line = findViewById<View>(R.id.splash_line)
title.alpha = 0f
subtitle.alpha = 0f
line.alpha = 0f
title.animate()
.alpha(1f)
.scaleX(1.0f)
.scaleY(1.0f)
.setStartDelay(150)
.setDuration(700)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
subtitle.animate()
.alpha(1f)
.setStartDelay(600)
.setDuration(600)
.start()
line.animate()
.alpha(1f)
.setStartDelay(900)
.setDuration(500)
.start()
window.decorView.postDelayed({
openMain()
}, 4000)
}
private fun openMain() {
val intent = Intent(this, MainActivity::class.java)
.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
)
val testText = this.intent?.getStringExtra("node_toast_test")
if (testText != null) intent.putExtra("node_toast_test", testText)
startActivity(intent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
finish()
}
}

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

@@ -0,0 +1,219 @@
package ru.ua1zbe.svxremote
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
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
/**
* Foreground-сервис: держит соединение с рефлектором и декодер
* живыми в фоне и при выключенном экране.
*/
class SvxService : Service(), ReflectorClient.Callbacks {
interface Listener {
fun onState(state: Int, detail: String?)
fun onServerInfo(info: MsgParser.ServerInfo)
fun onTalkerStart(tg: Long, callsign: String)
fun onTalkerStop(tg: Long, callsign: String)
fun onNodeJoined(callsign: String, tg: Long?)
fun onNodeLeft(callsign: String, tg: Long?)
fun onAudioPacket(packet: ByteArray, seq: Int)
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(
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() {
fun service(): SvxService = this@SvxService
}
override fun onBind(intent: Intent): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
return START_STICKY
}
private fun startForegroundCompat() {
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel("svx", "SVXRemote", NotificationManager.IMPORTANCE_LOW)
)
updateNotification(getString(R.string.notif_connected))
}
private fun updateNotification(text: String) {
val contentIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val n = Notification.Builder(this, "svx")
.setContentTitle("SVXRemote")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher_fg)
.setOngoing(true)
.setContentIntent(contentIntent)
.build()
startForeground(1, n)
}
fun connect(settings: ReflectorClient.Settings) {
startForegroundCompat()
client.connect(settings)
}
fun disconnect() {
client.disconnect()
}
fun isRunning(): Boolean = client.isRunning()
fun selectGroup(tg: Long) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.selectGroup(tg) }.start()
}
fun setMonitorTgs(tgs: List<Long>) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.setMonitorTgs(tgs) }.start()
}
fun startTx() {
engine.startCapture(Prefs.micGain(this)) { packet -> client.sendAudio(packet) }
}
fun stopTx() {
Log.d("SVXRemote", "stopTx")
// В фоне: сеть запрещена в главном потоке (NetworkOnMainThreadException),
// а join потока захвата блокировал бы UI
Thread {
engine.stopCapture()
client.sendAudioFlush()
}.start()
}
// ---------- Callbacks (фоновые потоки) ----------
override fun onState(state: Int, detail: String?) {
when (state) {
ReflectorClient.STATE_READY -> {
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 -> {
engine.stopDecoder()
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
listener?.onState(state, detail)
}
override fun onServerInfo(info: MsgParser.ServerInfo) {
synchronized(nodeSet) {
nodeSet.clear()
nodeSet.addAll(info.nodes)
}
listener?.onServerInfo(info)
}
private val nodeSet = LinkedHashSet<String>()
private val nodeTgs = HashMap<String, Long>()
/** Текущий список узлов и известные группы (копия для UI). */
fun nodeList(): List<Pair<String, Long?>> = synchronized(nodeSet) {
nodeSet.map { it to nodeTgs[it] }
}
override fun onNodeJoined(callsign: String, tg: Long?) {
synchronized(nodeSet) { nodeSet.add(callsign) }
listener?.onNodeJoined(callsign, tg)
}
override fun onNodeLeft(callsign: String, tg: Long?) {
synchronized(nodeSet) {
nodeSet.remove(callsign)
nodeTgs.remove(callsign)
}
listener?.onNodeLeft(callsign, tg)
}
override fun onTalkerStart(tg: Long, callsign: String) {
synchronized(nodeSet) {
if (callsign in nodeSet) nodeTgs[callsign] = tg
}
updateNotification(getString(R.string.notif_talking, callsign))
listener?.onTalkerStart(tg, callsign)
}
override fun onTalkerStop(tg: Long, callsign: String) {
updateNotification(getString(R.string.notif_last_talker, callsign))
listener?.onTalkerStop(tg, callsign)
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet, seq)
}
override fun onAudioFlush() {
engine.onAudioFlush()
}
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

@@ -0,0 +1,77 @@
package ru.ua1zbe.svxremote
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/** Проверка и загрузка обновлений через API Gitea. */
object UpdateChecker {
data class UpdateInfo(val version: String, val apkUrl: String)
private const val API_URL =
"https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/svxhomyak/releases/latest"
/** Вернуть информацию об обновлении, если доступна более новая версия. */
fun check(currentVersion: String): UpdateInfo? {
val conn = URL(API_URL).openConnection() as HttpURLConnection
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
return try {
if (conn.responseCode != 200) return null
val json = JSONObject(conn.inputStream.bufferedReader().readText())
val version = json.optString("tag_name", "").removePrefix("v")
if (!isNewer(version, currentVersion)) return null
val assets = json.optJSONArray("assets") ?: JSONArray()
var url: String? = null
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
if (a.optString("name").endsWith(".apk")) {
url = a.optString("browser_download_url")
break
}
}
url?.let { UpdateInfo(version, it) }
} finally {
conn.disconnect()
}
}
/** Скачать APK в файл, сообщая прогресс 0..1. */
fun download(url: String, target: File, onProgress: (Float) -> Unit) {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
try {
val total = conn.contentLengthLong
var done = 0L
conn.inputStream.use { input ->
target.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
done += n
if (total > 0) onProgress(done.toFloat() / total)
}
}
}
} finally {
conn.disconnect()
}
}
private fun isNewer(remote: String, current: String): Boolean {
val r = remote.split('.').mapNotNull { it.toIntOrNull() }
val c = current.split('.').mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(r.size, c.size)) {
val a = r.getOrElse(i) { 0 }
val b = c.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
}

View File

@@ -0,0 +1,34 @@
package ru.ua1zbe.svxremote
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
/** Минимальный FileProvider для установки APK (без androidx). */
class UpdateFileProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
val name = uri.lastPathSegment
?: throw IllegalArgumentException("bad uri")
if (name != name.substringAfterLast('/')) {
throw IllegalArgumentException("bad uri")
}
val path = File(context?.cacheDir, "updates/$name")
return ParcelFileDescriptor.open(path, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun getType(uri: Uri): String = "application/vnd.android.package-archive"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun query(
uri: Uri, projection: Array<out String>?, selection: String?,
selectionArgs: Array<out String>?, sortOrder: String?
): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}

View File

@@ -0,0 +1,157 @@
package ru.ua1zbe.svxremote.aprs
import android.os.Handler
import android.os.Looper
import android.util.Log
import ru.ua1zbe.svxremote.BuildConfig
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetSocketAddress
import java.net.Socket
/**
* Экспериментально: приём метеостанций с APRS-IS (aprs2.net) в радиусе 250 км
* от координат (фильтр r/). Весь код — в этом файле, удаляется вместе с
* вызовом в MainActivity и полями в настройках.
*/
class AprsMeteo(
private val coords: Pair<Double, Double>?,
private val onWeather: (String) -> Unit
) {
private val tag = "AprsMeteo"
private val main = Handler(Looper.getMainLooper())
@Volatile private var running = false
private var hostIdx = 0
fun start() {
if (running) return
running = true
Thread({ run() }, "aprs-meteo").start()
}
fun stop() {
running = false
}
private fun run() {
while (running) {
try {
runOnce()
} catch (e: Exception) {
if (running) Log.d(tag, "err: ${e.message}")
}
if (running) {
try {
Thread.sleep(30_000)
} catch (_: InterruptedException) {
break
}
}
}
}
private fun runOnce() {
val sock = Socket()
try {
sock.connect(InetSocketAddress(APRS_HOSTS[hostIdx], APRS_PORT), 10_000)
sock.soTimeout = 90_000
val filter = if (coords != null) {
"r/${coords.first}/${coords.second}/250"
} else {
// Координаты не заданы — функция отключена (MainActivity не запускает)
"b/$APRS_CALL/250"
}
// ВАЖНО: без "vers" — сервер пользователя (T2SPB/T2APATITY)
// не принимает фильтр и молчит; с "vers" — фильтр игнорируется
val login = "user $APRS_CALL pass ${passcode(APRS_CALL)} " +
"filter $filter\r\n"
sock.getOutputStream().write(login.toByteArray())
sock.getOutputStream().flush()
Log.d(tag, "connected to ${APRS_HOSTS[hostIdx]}, filter=$filter")
val reader = BufferedReader(InputStreamReader(sock.getInputStream()))
val start = System.currentTimeMillis()
var sawPacket = false
while (running) {
val line = try {
reader.readLine()
} catch (e: java.net.SocketTimeoutException) {
// Нет строк (даже heartbeat) — канал мёртв, переподключаемся
break
} ?: break
if (line.startsWith("#")) {
if (line.contains("logresp")) {
val server = line.substringAfter("server", "?").trim()
Log.d(tag, "server=$server")
}
// Сервер шлёт heartbeat, но не отдаёт пакеты. На основном
// сервере слушаем долго (станции передают редко), на
// запасном — 3 минуты, и возвращаемся на основной
val wait = if (hostIdx == 0) 900_000L else 180_000L
if (!sawPacket && System.currentTimeMillis() - start > wait) break
continue
}
sawPacket = true
if (BuildConfig.DEBUG) Log.d(tag, "pkt: " + line.substringBefore('>'))
parse(line)?.let { wx ->
Log.d(tag, "wx: $wx RAW=${line.length} $line")
main.post { onWeather(wx) }
}
}
// Сервер принял логин, но не отдал ни одного пакета (аплинк молчит) —
// в следующий раз пробуем следующий сервер. С пакетами — возвращаемся
// к первому (сервер пользователя)
hostIdx = if (sawPacket) 0 else (hostIdx + 1) % APRS_HOSTS.size
} finally {
try {
sock.close()
} catch (_: Exception) {
}
}
}
/** Парсинг: CALL>...:!|@|=<lat/lon>_<метео> -> "<call> Температура <T>C". */
private fun parse(line: String): String? {
if (line.startsWith("#") || line.isEmpty()) return null
// Разделитель заголовка — ПЕРВЫЙ ':' после '>'; в info могут быть свои ':'
// (например "Temp: 16.4°C Uptime: 319h")
val gt = line.indexOf('>')
val idx = line.indexOf(':', gt)
if (idx < 0 || idx + 1 >= line.length) return null
val body = line.substring(idx + 1)
if (body.isEmpty() || body[0] !in "!@=") return null
val temp = parseTemp(body) ?: return null
// В APRS метео-часть температура в градусах Фаренгейта
val c = (temp - 32.0) * 5.0 / 9.0
val t = Math.round(c).toString()
return "WX " + line.substringBefore('>') + " Температура: " + t + " °C"
}
/** Температура из метео-части после символа '_': t057=57°F, t06=6°F, t-08=-8°F.
* В APRS метео-формате температура — целые градусы Фаренгейта. */
private fun parseTemp(body: String): Double? {
val u = body.indexOf('_')
if (u < 0) return null
val meta = body.substring(u + 1).substringBefore(' ') // до комментария
val m = Regex("[tT]([+-]?)(\\d{2,3})").find(meta) ?: return null
val sign = if (m.groupValues[1] == "-") -1 else 1
return sign * m.groupValues[2].toInt().toDouble()
}
/** APRS-IS passcode по позывному (алгоритм aprsc). */
private fun passcode(call: String): Int {
var hash = 0x73E2
for (c in call.uppercase()) {
hash = hash xor (c.code shl 8)
hash = ((hash and 0xFFFF) * 0x89) and 0xFFFF
}
return hash and 0x7FFF
}
companion object {
// Позывной для подключения к APRS-IS (SVX). Сначала сервер пользователя;
// если он не отдаёт трафик (аплинк молчит), переходим на публичный aprs2.net
private const val APRS_CALL = "SVX"
private val APRS_HOSTS = listOf("rotate.radiorubka.org", "rotate.aprs2.net")
private const val APRS_PORT = 14580
}
}

View File

@@ -9,6 +9,7 @@ import android.media.MediaCodecInfo
import android.media.MediaFormat import android.media.MediaFormat
import android.media.MediaRecorder import android.media.MediaRecorder
import android.os.Build import android.os.Build
import android.util.Log
import java.nio.ByteBuffer import java.nio.ByteBuffer
import java.nio.ByteOrder import java.nio.ByteOrder
import kotlin.math.abs import kotlin.math.abs
@@ -19,20 +20,30 @@ 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 {
const val TAG = "SVXRemote"
const val SAMPLE_RATE = 8000 const val SAMPLE_RATE = 8000
const val OUT_SAMPLE_RATE = 48000 // декодер opus всегда выдаёт 48 кГц
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 = авто включено в настройках)
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_MIC = 0
const val SRC_SPEAKER = 1 const val SRC_SPEAKER = 1
} }
@Volatile
private var encoder: MediaCodec? = null private var encoder: MediaCodec? = null
@Volatile
private var decoder: MediaCodec? = null private var decoder: MediaCodec? = null
private var recorder: AudioRecord? = null private var recorder: AudioRecord? = null
private var player: AudioTrack? = null private var player: AudioTrack? = null
@@ -42,90 +53,421 @@ class OpusEngine(
private var recThread: Thread? = null private var recThread: Thread? = null
private val encodeBufferInfo = MediaCodec.BufferInfo() private val encodeBufferInfo = MediaCodec.BufferInfo()
private val decodeBufferInfo = MediaCodec.BufferInfo()
// ---------- Воспроизведение (RX) ---------- // ---------- Воспроизведение (RX) ----------
private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue<ByteArray>()
private val freeInputs = java.util.concurrent.ConcurrentLinkedQueue<Int>()
private var ptsCounter = 0L
private var receivedAny = false
private var concealLogs = 0
private var dbgOut = 0
private var playStarted = false
private var gateStart = 0L
private var lastRealPacketTime = 0L
// Настраивается из скрытых настроек звука (AudioSettingsActivity)
@Volatile
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() { fun startDecoder() {
if (decoder != null) return if (decoder != null) return
val format = MediaFormat() val format = MediaFormat()
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS) format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) // ВАЖНО: c2.android.opus.decoder (Android 16) выводит только 48 кГц
// (kRate=48000); KEY_SAMPLE_RATE=8000 вызывает конфликт конфига
// («We have a failed config») и фатальную ошибку кодера.
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192) // Android 16: c2.android.opus.decoder читает конфиг (OpusHead + codec
if (Build.VERSION.SDK_INT >= 29) { // delay + seek preroll) ТОЛЬКО из первых входных буферов
// Не звонить GSM — приоритет медиа // (см. queueDecoderConfig); csd-ключи MediaFormat он игнорирует, и без
} // конфига первый же аудио-кадр ломает декодер (C2_CORRUPTED).
decoder = try { if (!startDecoderCodec(format)) return
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also { Log.d(TAG, "decoder started, config=${configBuffers.size}B")
it.configure(format, null, null, 0)
it.start()
}
} catch (e: Exception) {
null
}
if (decoder == null) return
val minBuf = AudioTrack.getMinBufferSize( val minBuf = AudioTrack.getMinBufferSize(
SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT AudioFormat.ENCODING_PCM_16BIT
) )
player = AudioTrack( player = try {
AudioTrack(
AudioManager.STREAM_MUSIC, AudioManager.STREAM_MUSIC,
SAMPLE_RATE, OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO, AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT, AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, FRAME_SIZE * 2 * 8), maxOf(minBuf, rxBufferMs * 96), // 96 байт/мс при 48 кГц моно 16 бит
AudioTrack.MODE_STREAM AudioTrack.MODE_STREAM
).also { it.play() } ) // play() включаем на первом выходном кадре — буфер успевает накопиться
} catch (e: Exception) {
Log.e(TAG, "AudioTrack create failed: ${e.message}")
null
}
concealLogs = 0
dbgOut = 0
ptsCounter = 20_000L // первый кадр: pts!=0, иначе декодер повторно сбросит codecDelay
Log.d(TAG, "decoder started")
} }
/** Подача одного Opus-пакета на декодер. */ /** Создать и запустить только декодер (плеер не трогаем). */
fun onAudioPacket(packet: ByteArray) { private fun startDecoderCodec(format: MediaFormat): Boolean {
val dec = decoder ?: return val dec = try {
val track = player ?: return MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
try { it.setCallback(decoderCallback)
val inIdx = dec.dequeueInputBuffer(10000) it.configure(format, null, null, 0)
if (inIdx >= 0) { queueDecoderConfig() // готовим конфиг ДО start: колбэки уйдут после
val buf = dec.getInputBuffer(inIdx) ?: return
buf.clear()
buf.put(packet)
dec.queueInputBuffer(inIdx, 0, packet.size, 0, 0)
} }
var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0) } catch (e: Exception) {
while (outIdx >= 0) { Log.e(TAG, "createDecoder failed: ${e.message}")
if (decodeBufferInfo.size > 0) { null
val outBuf = ByteBuffer.allocate(decodeBufferInfo.size) }
if (dec == null) return false
// decoder назначаем ДО start(): колбэки нового кодека сразу проходят
// проверку codec !== decoder, иначе первый конфиг-колбэк отбрасывается
// и декодер навсегда остаётся без конфига
decoder = dec
freeInputs.clear()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
try {
dec.start()
} catch (e: Exception) {
Log.e(TAG, "decoder start failed: ${e.message}")
decoder = null
try {
dec.release()
} catch (_: Exception) {
}
return false
}
return true
}
/** Асинхронные колбэки кодера: подача входных кадров + слив вывода. */
private val decoderCallback = object : MediaCodec.Callback() {
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.e(TAG, "decoder error: ${e.message} code=${e.errorCode} transient=${e.isTransient}")
}
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
// Конфиг (csd) первыми входными буферами — в async-режиме нельзя
// использовать dequeueInputBuffer, только колбэки
if (configIdx < configBuffers.size) {
val data = configBuffers[configIdx++]
try {
val buf = codec.getInputBuffer(index) ?: return
buf.clear()
buf.put(data)
codec.queueInputBuffer(index, 0, data.size, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)
Log.d(TAG, "cfg ${data.size}B")
} catch (e: Exception) {
Log.e(TAG, "cfg error: ${e.message}")
}
return
}
val pkt = rxQueue.poll()
when {
// Джиттер-гейт: пока не накопили jitterTarget кадров (или не
// вышло 80 мс с первого пакета) — не кормим декодер, чтобы
// буфер AudioTrack успел заполниться до старта воспроизведения
pkt != null && gateOpen() -> feedDecoder(codec, index, pkt)
pkt != null -> {
rxQueue.add(pkt)
freeInputs.add(index)
}
receivedAny -> {
if (concealLogs < 3) {
Log.d(TAG, "conceal (no packet)")
concealLogs++
}
feedDecoder(codec, index, silenceFrame()) // скрытие потерь
}
else -> freeInputs.add(index) // ждём первый пакет
}
}
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
try {
if (info.size > 0) {
val outBuf = ByteBuffer.allocate(info.size)
outBuf.order(ByteOrder.LITTLE_ENDIAN) outBuf.order(ByteOrder.LITTLE_ENDIAN)
dec.getOutputBuffer(outIdx)?.also { src -> codec.getOutputBuffer(index)?.also { src ->
src.position(decodeBufferInfo.offset) src.position(info.offset)
src.limit(decodeBufferInfo.offset + decodeBufferInfo.size) src.limit(info.offset + info.size)
outBuf.put(src) outBuf.put(src)
} }
val pcm = outBuf.array() val pcm = outBuf.array()
track.write(pcm, 0, pcm.size) if (!playStarted) {
onLevel(SRC_SPEAKER, pcmLevel(pcm)) playStarted = true
Log.d(TAG, "play start")
try {
player?.play()
} catch (e: Exception) {
Log.e(TAG, "play error: ${e.message}")
} }
dec.releaseOutputBuffer(outIdx, false)
outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
} }
player?.write(pcm, 0, pcm.size)
// Уровень показываем только для реальных пакетов: во время
// скрытия потерь (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)
if (dbgOut < 5) {
Log.d(TAG, "out ${pcm.size}B")
dbgOut++
}
}
} catch (e: Exception) {
Log.e(TAG, "out error: ${e.message}")
} finally {
try {
codec.releaseOutputBuffer(index, false)
} catch (_: Exception) {
}
}
}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.d(TAG, "dec out format: sr=${format.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " +
"ch=${format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}")
}
}
private fun feedDecoder(codec: MediaCodec, index: Int, pkt: ByteArray) {
try {
val buf = codec.getInputBuffer(index) ?: return
buf.clear()
buf.put(pkt)
codec.queueInputBuffer(index, 0, pkt.size, ptsCounter, 0)
ptsCounter += 20_000L // 20 мс кадра
if (concealLogs < 3 && rxQueue.isEmpty() && pkt === silenceFrame()) {
Log.d(TAG, "conceal (no packet)")
concealLogs++
}
} catch (e: Exception) {
Log.e(TAG, "feed error: ${e.message}")
}
}
/**
* Android 16: c2.android.opus.decoder читает конфиг из первых входных
* буферов. Единый CSD-блоб (формат AOSP WriteOpusHeaders): OpusHead (19
* байт) + codecDelay (int64 LE нс) + seekPreroll (int64 LE нс) — БЕЗ
* u32-префиксов длины. После него декодер уже сконфигурирован; лишние
* конфиг-буферы игнорируются как повторный CSD, поэтому шлём и их.
*/
private fun queueDecoderConfig() {
configBuffers.clear()
try {
val head = opusHead()
val delay = longBytes(39_000_000L) // прескип 312 сэмплов при 8 кГц = 39 мс
val preroll = longBytes(0L)
val w = java.io.ByteArrayOutputStream()
w.write(head)
w.write(delay)
w.write(preroll)
configBuffers.add(w.toByteArray())
configBuffers.add(delay)
configBuffers.add(preroll)
configIdx = 0
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
/** int64 в little-endian. */
private fun longBytes(v: Long): ByteArray =
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array()
/** Подача одного Opus-пакета на декодер (с UDP-потока). */
fun onAudioPacket(packet: ByteArray, seq: Int) {
updateRxStats(seq)
lastRealPacketTime = System.currentTimeMillis()
rxQueue.add(packet)
receivedAny = true
if (gateStart == 0L) gateStart = System.currentTimeMillis()
if (!gateOpen()) return
val idx = freeInputs.poll() ?: return // если слот свободен — кормим сразу
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) }
}
/**
* Статистика приёма (потери по 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
val waitMs = maxOf(GATE_MIN_WAIT_MS, jitterFrames * 20L)
return gateStart != 0L && System.currentTimeMillis() - gateStart >= waitMs
}
fun onAudioFlush() { fun onAudioFlush() {
try { try {
decoder?.flush()
player?.flush() player?.flush()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
/**
* Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке.
* Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM.
*/
private fun silenceFrame(): ByteArray = byteArrayOf(
0x08.toByte(), 0x0B.toByte(), 0xE4.toByte(), 0xB9.toByte(),
0xA0.toByte(), 0xBC.toByte(), 0x84.toByte()
)
/** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */
private fun opusHead(): ByteArray {
val head = "OpusHead".toByteArray(Charsets.US_ASCII)
val out = ByteArray(19)
System.arraycopy(head, 0, out, 0, head.size)
out[8] = 1 // версия Opus
out[9] = 1 // каналов (моно)
out[10] = 0x38 // pre-skip LE = 312
out[11] = 0x01
out[12] = 0x40 // input sample rate LE = 8000
out[13] = 0x1F
out[14] = 0x00
out[15] = 0x00
out[16] = 0x00 // output gain
out[17] = 0x00
out[18] = 0x00 // channel mapping family = 0
return out
}
/** Остановить декодер и плеер (при отключении). */
fun stopDecoder() {
val dec = decoder
decoder = null
rxQueue.clear()
freeInputs.clear()
receivedAny = false
playStarted = false
gateStart = 0L
if (dec != null) {
try {
dec.stop()
} catch (_: Exception) {
}
try {
dec.release()
} catch (_: Exception) {
}
}
try {
player?.stop()
} catch (_: Exception) {
}
try {
player?.release()
} catch (_: Exception) {
}
player = null
onLevel(SRC_SPEAKER, 0f)
}
// ---------- Захват (TX) ---------- // ---------- Захват (TX) ----------
/** Начать PTT: запустить запись и кодирование. */ /** Начать PTT: запустить запись и кодирование. */
fun startCapture(onPacket: (ByteArray) -> Unit) { fun startCapture(micGain: Float = 1.0f, onPacket: (ByteArray) -> Unit) {
if (encoding) return if (encoding) return
val enc = createEncoder() val enc = createEncoder()
encoder = enc encoder = enc
@@ -148,7 +490,7 @@ class OpusEngine(
rec.startRecording() rec.startRecording()
encoding = true encoding = true
val t = Thread({ val t = Thread({
captureLoop(enc, rec, onPacket) captureLoop(enc, rec, micGain, onPacket)
}, "svx-capture") }, "svx-capture")
t.isDaemon = true t.isDaemon = true
recThread = t recThread = t
@@ -161,18 +503,19 @@ class OpusEngine(
fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS) fmt.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE) fmt.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
fmt.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS) 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) fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, FRAME_SIZE * 2)
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS) val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE) codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
codec.start() codec.start()
Log.d(TAG, "encoder started, bitrate=${effectiveBitrate() / 1000}kbps")
codec codec
} catch (e: Exception) { } catch (e: Exception) {
null null
} }
} }
private fun captureLoop(enc: MediaCodec, rec: AudioRecord, onPacket: (ByteArray) -> Unit) { private fun captureLoop(enc: MediaCodec, rec: AudioRecord, micGain: Float, onPacket: (ByteArray) -> Unit) {
val pcmBuf = ByteBuffer.allocateDirect(FRAME_SIZE * 2).order(ByteOrder.LITTLE_ENDIAN) val pcmBuf = ByteBuffer.allocateDirect(FRAME_SIZE * 2).order(ByteOrder.LITTLE_ENDIAN)
val micLevel = FloatArray(1) val micLevel = FloatArray(1)
while (encoding) { while (encoding) {
@@ -182,6 +525,14 @@ class OpusEngine(
pcmBuf.limit(n) pcmBuf.limit(n)
val pcm = ByteArray(n) val pcm = ByteArray(n)
pcmBuf.get(pcm) pcmBuf.get(pcm)
if (micGain != 1.0f) {
for (i in 0 until n step 2) {
val s = ((pcm[i + 1].toInt() shl 8) or (pcm[i].toInt() and 0xFF)).toShort()
val g = (s * micGain).toInt().coerceIn(-32768, 32767)
pcm[i] = (g and 0xFF).toByte()
pcm[i + 1] = ((g shr 8) and 0xFF).toByte()
}
}
micLevel[0] = pcmLevel(pcm) micLevel[0] = pcmLevel(pcm)
onLevel(SRC_MIC, micLevel[0]) onLevel(SRC_MIC, micLevel[0])
try { try {
@@ -209,14 +560,43 @@ class OpusEngine(
} catch (_: Exception) { } catch (_: Exception) {
} }
} }
// Дренаж: отдаём пакеты, оставшиеся в кодере после остановки записи
try {
var outIdx = enc.dequeueOutputBuffer(encodeBufferInfo, 0)
while (outIdx >= 0) {
if (encodeBufferInfo.size > 0) {
val outBuf = ByteBuffer.allocate(encodeBufferInfo.size)
enc.getOutputBuffer(outIdx)?.also { src ->
src.position(encodeBufferInfo.offset)
src.limit(encodeBufferInfo.offset + encodeBufferInfo.size)
outBuf.put(src)
}
onPacket(outBuf.array())
}
enc.releaseOutputBuffer(outIdx, false)
outIdx = enc.dequeueOutputBuffer(encodeBufferInfo, 0)
}
} catch (_: Exception) {
}
} }
fun stopCapture() { fun stopCapture() {
if (!encoding && recThread == null) return
encoding = false encoding = false
try { try {
recorder?.stop() recorder?.stop()
} catch (_: Exception) { } catch (_: Exception) {
} }
// Ждём завершения потока захвата: он должен отправить последние пакеты,
// чтобы flush (окончание передачи) ушёл после всего аудио
val t = recThread
recThread = null
if (t != null && t.isAlive) {
try {
t.join(1000)
} catch (_: InterruptedException) {
}
}
try { try {
recorder?.release() recorder?.release()
} catch (_: Exception) { } catch (_: Exception) {
@@ -231,31 +611,12 @@ class OpusEngine(
} catch (_: Exception) { } catch (_: Exception) {
} }
encoder = null encoder = null
recThread?.interrupt()
recThread = null
onLevel(SRC_MIC, 0f) onLevel(SRC_MIC, 0f)
} }
fun releaseAll() { fun releaseAll() {
stopCapture() stopCapture()
try { stopDecoder()
decoder?.stop()
} catch (_: Exception) {
}
try {
decoder?.release()
} catch (_: Exception) {
}
decoder = null
try {
player?.stop()
} catch (_: Exception) {
}
try {
player?.release()
} catch (_: Exception) {
}
player = null
onLevel(SRC_SPEAKER, 0f) onLevel(SRC_SPEAKER, 0f)
} }

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

@@ -8,31 +8,184 @@ data class ConnectionSettings(
val port: Int, val port: Int,
val callsign: String, val callsign: String,
val authKey: String, val authKey: String,
val talkGroup: Long val talkGroup: Long,
val groups: List<Long> = emptyList()
)
/** Профиль настроек: имя + параметры подключения. */
data class Profile(
val name: String,
val settings: ConnectionSettings
) )
object Prefs { object Prefs {
private const val PREFS_NAME = "svxremote" private const val PREFS_NAME = "svxremote"
const val PROFILE_COUNT = 5
fun load(context: Context): ConnectionSettings { private fun sp(context: Context): SharedPreferences =
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
return ConnectionSettings(
host = sp.getString("host", "") ?: "", /** Индекс активного профиля (0..PROFILE_COUNT-1). */
port = sp.getInt("port", 5300), fun activeProfileIndex(context: Context): Int =
callsign = sp.getString("callsign", "") ?: "", sp(context).getInt("active_profile", 0).coerceIn(0, PROFILE_COUNT - 1)
authKey = sp.getString("auth_key", "") ?: "",
talkGroup = sp.getLong("tg", 11) fun setActiveProfile(context: Context, index: Int) {
sp(context).edit().putInt("active_profile", index.coerceIn(0, PROFILE_COUNT - 1)).apply()
}
/** Настройки активного профиля. */
fun load(context: Context): ConnectionSettings =
loadProfile(context, activeProfileIndex(context)).settings
/** Сохранить в активный профиль. */
fun save(context: Context, s: ConnectionSettings) {
val index = activeProfileIndex(context)
val profile = loadProfile(context, index)
saveProfile(context, index, Profile(profile.name, s))
}
fun loadProfile(context: Context, index: Int): Profile =
loadProfile(sp(context), index.coerceIn(0, PROFILE_COUNT - 1))
fun saveProfile(context: Context, index: Int, profile: Profile) {
val profiles = loadProfiles(context)
profiles[index.coerceIn(0, PROFILE_COUNT - 1)] = profile
saveProfiles(context, profiles)
}
/** Все профили (5 слотов). */
fun loadProfiles(context: Context): MutableList<Profile> {
val prefs = sp(context)
migrateIfNeeded(context, prefs)
return (0 until PROFILE_COUNT).map { loadProfile(prefs, it) }.toMutableList()
}
private fun loadProfile(prefs: SharedPreferences, i: Int): Profile {
val host = prefs.getString("p${i}_host", null)
if (host == null) {
return Profile("Профиль ${i + 1}", ConnectionSettings("", 5300, "", "", 11))
}
return Profile(
name = prefs.getString("p${i}_name", null) ?: "Профиль ${i + 1}",
settings = ConnectionSettings(
host = host,
port = prefs.getInt("p${i}_port", 5300),
callsign = prefs.getString("p${i}_callsign", "") ?: "",
authKey = prefs.getString("p${i}_auth", "") ?: "",
talkGroup = prefs.getLong("p${i}_tg", 11),
groups = (prefs.getString("p${i}_groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
)
) )
} }
fun save(context: Context, s: ConnectionSettings) { private fun saveProfiles(context: Context, profiles: List<Profile>) {
val sp: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE) val e = sp(context).edit()
sp.edit() profiles.forEachIndexed { i, p ->
.putString("host", s.host.trim()) e.putString("p${i}_name", p.name)
.putInt("port", s.port) .putString("p${i}_host", p.settings.host.trim())
.putString("callsign", s.callsign.trim()) .putInt("p${i}_port", p.settings.port)
.putString("auth_key", s.authKey) .putString("p${i}_callsign", p.settings.callsign.trim())
.putLong("tg", s.talkGroup) .putString("p${i}_auth", p.settings.authKey)
.apply() .putLong("p${i}_tg", p.settings.talkGroup)
.putString("p${i}_groups", p.settings.groups.distinct().joinToString(","))
}
e.apply()
}
/** Перенос старых настроек (до v1.0.6) в профиль 1 — один раз. */
private fun migrateIfNeeded(context: Context, prefs: SharedPreferences) {
if (prefs.getBoolean("migrated_v106", false)) return
val host = prefs.getString("host", null)
if (host != null) {
val legacy = ConnectionSettings(
host = host,
port = prefs.getInt("port", 5300),
callsign = prefs.getString("callsign", "") ?: "",
authKey = prefs.getString("auth_key", "") ?: "",
talkGroup = prefs.getLong("tg", 11),
groups = (prefs.getString("groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.ifEmpty { listOf(9L, 11L, 12L, 13L) }
)
val profiles = (0 until PROFILE_COUNT).map { loadProfile(prefs, it) }.toMutableList()
profiles[0] = Profile("Профиль 1", legacy)
saveProfiles(context, profiles)
}
prefs.edit().putBoolean("migrated_v106", true).apply()
}
fun nodeToastsEnabled(context: Context): Boolean {
return sp(context).getBoolean("node_toasts", true)
}
fun setNodeToastsEnabled(context: Context, enabled: Boolean) {
sp(context).edit().putBoolean("node_toasts", enabled).apply()
}
// ---------- APRS метеостанции (экспериментально) ----------
/** Координаты "lat,lon" (пусто = функция выключена). */
fun aprsCoords(context: Context): String =
sp(context).getString("aprs_coords", "") ?: ""
fun setAprsCoords(context: Context, coords: String) {
sp(context).edit().putString("aprs_coords", coords.trim()).apply()
}
/** Усиление микрофона (1.0 = без усиления). */
fun micGain(context: Context): Float =
sp(context).getFloat("mic_gain", 1.0f)
fun setMicGain(context: Context, gain: Float) {
sp(context).edit().putFloat("mic_gain", gain.coerceIn(0.5f, 5.0f)).apply()
}
// ---------- Скрытые настройки звука (AudioSettingsActivity) ----------
/** Буфер воспроизведения RX, мс. */
fun rxBufferMs(context: Context): Int =
sp(context).getInt("rx_buffer_ms", 417)
fun setRxBufferMs(context: Context, ms: Int) {
sp(context).edit().putInt("rx_buffer_ms", ms.coerceIn(100, 600)).apply()
}
/** Накопление перед стартом воспроизведения, мс (кратно 20). */
fun rxJitterMs(context: Context): Int =
sp(context).getInt("rx_jitter_ms", 200)
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

@@ -1,5 +1,6 @@
package ru.ua1zbe.svxremote.proto package ru.ua1zbe.svxremote.proto
import android.util.Log
import java.net.DatagramPacket import java.net.DatagramPacket
import java.net.DatagramSocket import java.net.DatagramSocket
import java.net.InetAddress import java.net.InetAddress
@@ -7,6 +8,7 @@ import java.net.InetSocketAddress
import java.net.Socket import java.net.Socket
import java.net.SocketTimeoutException import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean import java.util.concurrent.atomic.AtomicBoolean
import ru.ua1zbe.svxremote.BuildConfig
/** /**
* Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP). * Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP).
@@ -22,8 +24,8 @@ class ReflectorClient(
fun onServerInfo(info: MsgParser.ServerInfo) fun onServerInfo(info: MsgParser.ServerInfo)
fun onTalkerStart(tg: Long, callsign: String) fun onTalkerStart(tg: Long, callsign: String)
fun onTalkerStop(tg: Long, callsign: String) fun onTalkerStop(tg: Long, callsign: String)
fun onNodeJoined(callsign: String) fun onNodeJoined(callsign: String, tg: Long?)
fun onNodeLeft(callsign: String) fun onNodeLeft(callsign: String, tg: Long?)
/** Входящий аудиопакет Opus. */ /** Входящий аудиопакет Opus. */
fun onAudioPacket(packet: ByteArray, seq: Int) fun onAudioPacket(packet: ByteArray, seq: Int)
fun onAudioFlush() fun onAudioFlush()
@@ -31,6 +33,8 @@ class ReflectorClient(
} }
companion object { companion object {
const val TAG = "SVXRemote"
const val STATE_DISCONNECTED = 0 const val STATE_DISCONNECTED = 0
const val STATE_CONNECTING = 1 const val STATE_CONNECTING = 1
const val STATE_TCP_CONNECTED = 2 const val STATE_TCP_CONNECTED = 2
@@ -45,6 +49,11 @@ class ReflectorClient(
const val TCP_HEARTBEAT_INTERVAL_MS = 2000L const val TCP_HEARTBEAT_INTERVAL_MS = 2000L
const val UDP_HEARTBEAT_INTERVAL_MS = 4000L const val UDP_HEARTBEAT_INTERVAL_MS = 4000L
const val RECONNECT_DELAY_MS = 5000L const val RECONNECT_DELAY_MS = 5000L
const val CLIENT_SW_INFO = "SVXHomyak"
// Версия ПО передаётся на сервер для дашборда; берём из BuildConfig,
// чтобы не расходилась с versionName в build.gradle.kts
val CLIENT_SW_VERSION = BuildConfig.VERSION_NAME
} }
data class Settings( data class Settings(
@@ -52,7 +61,9 @@ class ReflectorClient(
val port: Int, val port: Int,
val callsign: String, val callsign: String,
val authKey: String, val authKey: String,
val talkGroup: Long val talkGroup: Long,
/** Список групп для мониторинга; null = обычный режим (своя группа). */
val monitorTgs: List<Long>? = null
) )
@Volatile @Volatile
@@ -74,6 +85,15 @@ class ReflectorClient(
@Volatile @Volatile
private var fatal = false private var fatal = false
/**
* Была ли хотя бы одна успешная сессия за текущее подключение.
* Если да — креды уже проверены, поэтому ошибки на рукопожатии считаем
* временными (сервер мог ещё держать старую сессию после обрыва) и
* продолжаем автопереподключение вместо показа «ошибки авторизации».
*/
@Volatile
private var everReady = false
@Volatile @Volatile
private var tcpSocket: Socket? = null private var tcpSocket: Socket? = null
private var udpSocket: DatagramSocket? = null private var udpSocket: DatagramSocket? = null
@@ -93,10 +113,16 @@ class ReflectorClient(
@Synchronized @Synchronized
fun connect(settings: Settings) { fun connect(settings: Settings) {
if (running.get()) return
this.settings = settings this.settings = settings
manualDisconnect.set(false) manualDisconnect.set(false)
fatal = false fatal = false
everReady = false
if (running.get()) {
// Перезапуск с новыми настройками (например, смена профиля)
running.set(false)
synchronized(stopEvent) { stopEvent.notifyAll() }
abortSockets()
}
running.set(true) running.set(true)
state = STATE_DISCONNECTED state = STATE_DISCONNECTED
seqCounter = 0 seqCounter = 0
@@ -145,12 +171,46 @@ class ReflectorClient(
udpSend(sock, s, data) udpSend(sock, s, data)
} }
/** Переключить принимаемую группу на живом соединении. */
fun selectGroup(tg: Long) {
val sock = tcpSocket
if (sock == null || !running.get()) {
Log.d(TAG, "selectGroup tg=$tg skipped (sock=${sock != null} running=${running.get()})")
return
}
try {
Log.d(TAG, "selectGroup tg=$tg")
sendTcp(sock, MsgBuilder.selectTg(tg))
} catch (e: Exception) {
Log.w(TAG, "selectGroup send failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
/** Переключить группы мониторинга на живом соединении. */
fun setMonitorTgs(tgs: List<Long>) {
val sock = tcpSocket
if (sock == null || !running.get()) {
Log.d(TAG, "setMonitorTgs $tgs skipped (sock=${sock != null} running=${running.get()})")
return
}
try {
Log.d(TAG, "setMonitorTgs $tgs")
sendTcp(sock, MsgBuilder.tgMonitor(tgs))
} catch (e: Exception) {
Log.w(TAG, "setMonitorTgs failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
/** Конец передачи. */ /** Конец передачи. */
fun sendAudioFlush() { fun sendAudioFlush() {
val sock = udpSocket val sock = udpSocket
val s = settings val s = settings
val id = clientId val id = clientId
if (sock == null || s == null || id == 0) return if (sock == null || s == null || id == 0) {
Log.w(TAG, "sendAudioFlush skipped (sock=${sock != null} s=${s != null} id=$id)")
return
}
Log.d(TAG, "sendAudioFlush")
udpSend(sock, s, MsgBuilder.udpFlush(id, nextSeq())) udpSend(sock, s, MsgBuilder.udpFlush(id, nextSeq()))
} }
@@ -204,7 +264,10 @@ class ReflectorClient(
callbacks.onState(STATE_TCP_CONNECTED) callbacks.onState(STATE_TCP_CONNECTED)
startTcpSenderThread(socket, s) startTcpSenderThread(socket, s)
runTcpLoop(socket, s) runTcpLoop(socket, s)
true // runTcpLoop завершилась: если всё ещё должны работать — значит
// соединение разорвано/отклонено, вернём false, чтобы reconnectLoop
// выдержал RECONNECT_DELAY_MS и не бомбил сервер переподключениями.
!running.get()
} catch (e: Exception) { } catch (e: Exception) {
try { try {
socket.close() socket.close()
@@ -349,19 +412,35 @@ class ReflectorClient(
serverCodecs = info.codecs serverCodecs = info.codecs
callbacks.onServerInfo(info) callbacks.onServerInfo(info)
state = STATE_READY state = STATE_READY
everReady = true
callbacks.onState(STATE_READY) callbacks.onState(STATE_READY)
// Сначала сообщаем о себе (версия ПО для дашборда), затем
// выбираем TG для передачи и подписываемся на прослушивание
// (иначе сервер не шлёт аудио группы и не показывает
// подписку в статусе: monitoredTGs остаётся пустым).
sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION))
val monitor = s.monitorTgs
if (monitor.isNullOrEmpty()) {
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup)) sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
} else {
// Мониторинг: сидим на базовой группе и слушаем
// уведомления обо всех группах (аудио идёт только
// с выбранной, поэтому переключение — в MainActivity).
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(monitor))
}
openUdp(s) openUdp(s)
} }
ReflectorProtocol.MSG_NODE_JOINED -> { ReflectorProtocol.MSG_NODE_JOINED -> {
val cs = parseSimpleString(msg.fields) val (cs, tg) = parseNodeEvent(msg.fields)
callbacks.onNodeJoined(cs) callbacks.onNodeJoined(cs, tg)
} }
ReflectorProtocol.MSG_NODE_LEFT -> { ReflectorProtocol.MSG_NODE_LEFT -> {
val cs = parseSimpleString(msg.fields) val (cs, tg) = parseNodeEvent(msg.fields)
callbacks.onNodeLeft(cs) callbacks.onNodeLeft(cs, tg)
} }
ReflectorProtocol.MSG_TALKER_START -> { ReflectorProtocol.MSG_TALKER_START -> {
@@ -376,8 +455,17 @@ class ReflectorClient(
ReflectorProtocol.MSG_ERROR -> { ReflectorProtocol.MSG_ERROR -> {
val err = MsgParser.errorMessage(msg.fields) val err = MsgParser.errorMessage(msg.fields)
Log.w(TAG, "MSG_ERROR: $err (state=$state, everReady=$everReady)")
if (state < STATE_READY) { if (state < STATE_READY) {
if (everReady) {
// Креды проверены ранее: ошибка на рукопожатии почти
// всегда временная (сервер ещё держит старую сессию
// после короткого обрыва) — переподключаемся заново.
callbacks.onState(STATE_ERROR, err.ifEmpty { "Переподключение" })
abortSockets()
} else {
stopFatal(err.ifEmpty { "Access denied" }) stopFatal(err.ifEmpty { "Access denied" })
}
return false return false
} }
callbacks.onError(err) callbacks.onError(err)
@@ -386,12 +474,29 @@ class ReflectorClient(
else -> Unit // неизвестные сообщения игнорируются else -> Unit // неизвестные сообщения игнорируются
} }
} catch (e: Exception) { } catch (e: Exception) {
if (everReady) {
// Сбой после успешного подключения — не фатально, переподключаемся.
callbacks.onState(STATE_ERROR, "Ошибка протокола: ${e.message}")
abortSockets()
} else {
stopFatal("Ошибка протокола: ${e.message}") stopFatal("Ошибка протокола: ${e.message}")
}
return false return false
} }
return true return true
} }
private fun parseNodeEvent(fields: ByteArray): Pair<String, Long?> {
return try {
val r = BinReader(fields)
val cs = r.str().ifEmpty { "?" }
val tg = try { r.str().toLongOrNull() } catch (_: Exception) { null }
cs to tg
} catch (_: Exception) {
"?" to null
}
}
private fun parseSimpleString(fields: ByteArray): String { private fun parseSimpleString(fields: ByteArray): String {
return try { return try {
BinReader(fields).str().ifEmpty { "?" } BinReader(fields).str().ifEmpty { "?" }
@@ -401,7 +506,12 @@ class ReflectorClient(
} }
private fun protocolError(detail: String): Boolean { private fun protocolError(detail: String): Boolean {
if (everReady) {
callbacks.onState(STATE_ERROR, detail)
abortSockets()
} else {
stopFatal(detail) stopFatal(detail)
}
return false return false
} }
@@ -424,6 +534,7 @@ class ReflectorClient(
stopUdp() stopUdp()
val ds = DatagramSocket() val ds = DatagramSocket()
udpSocket = ds udpSocket = ds
Log.d(TAG, "UDP bound port=${ds.localPort}")
// Первый пакет регистрирует наш UDP-адрес у сервера // Первый пакет регистрирует наш UDP-адрес у сервера
val addr = resolvedAddr ?: InetAddress.getByName(s.host) val addr = resolvedAddr ?: InetAddress.getByName(s.host)
sendRawUdp(ds, addr, s.port, MsgBuilder.udpHeartbeat(clientId, nextSeq())) sendRawUdp(ds, addr, s.port, MsgBuilder.udpHeartbeat(clientId, nextSeq()))
@@ -463,19 +574,26 @@ 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) {
ReflectorProtocol.MSG_UDP_HEARTBEAT -> Unit ReflectorProtocol.MSG_UDP_HEARTBEAT -> Log.d(TAG, "UDP RX heartbeat")
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 cid=$cid len=${audio.size}")
callbacks.onAudioPacket(audio, seq) callbacks.onAudioPacket(audio, seq)
} else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")
} }
} }
ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> callbacks.onAudioFlush() ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> {
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit Log.d(TAG, "UDP RX flush")
callbacks.onAudioFlush()
}
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Log.d(TAG, "UDP RX all-flushed seq=$seq")
else -> Log.d(TAG, "UDP RX type=$type len=${data.size}")
} }
} }
@@ -498,10 +616,16 @@ class ReflectorClient(
} }
private fun sendRawUdp(sock: DatagramSocket, addr: InetAddress, port: Int, data: ByteArray) { private fun sendRawUdp(sock: DatagramSocket, addr: InetAddress, port: Int, data: ByteArray) {
if (sock.isClosed) return if (sock.isClosed) {
Log.w(TAG, "UDP TX skipped: socket closed")
return
}
try { try {
sock.send(DatagramPacket(data, data.size, addr, port)) sock.send(DatagramPacket(data, data.size, addr, port))
val typ = if (data.size >= 2) ((data[0].toInt() shl 8) or (data[1].toInt() and 0xFF)) else -1
Log.d(TAG, "UDP TX type=$typ len=${data.size}")
} catch (e: Exception) { } catch (e: Exception) {
Log.e(TAG, "UDP send error: ${e.message}", e)
callbacks.onError("UDP send: ${e.message}") callbacks.onError("UDP send: ${e.message}")
} }
} }

View File

@@ -195,6 +195,14 @@ object MsgBuilder {
return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray()) return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray())
} }
/** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.).
* Рефлектор вливает JSON в статус узла, дашборд показывает поле
* «swVer» (как у svxlink-узлов). */
fun nodeInfo(swInfo: String, swVersion: String): RcMessage {
val json = "{\"swInfo\":\"$swInfo\",\"swVer\":\"$swVersion\"}"
return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).toByteArray())
}
/** UDP-датаграмма V2: [u16 тип][u16 client_id][u16 seq][payload]. */ /** UDP-датаграмма V2: [u16 тип][u16 client_id][u16 seq][payload]. */
fun udpDatagram(type: Int, clientId: Int, seq: Int, payload: ByteArray = ByteArray(0)): ByteArray { fun udpDatagram(type: Int, clientId: Int, seq: Int, payload: ByteArray = ByteArray(0)): ByteArray {
val w = BinWriter().u16(type).u16(clientId).u16(seq).bytes(payload) val w = BinWriter().u16(type).u16(clientId).u16(seq).bytes(payload)

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,38 +85,75 @@ 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()
// Иконка микрофона // Заливка с вертикальным градиентом (глянец)
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 )
val s = r * 0.55f canvas.drawCircle(cx, cy, r, fillPaint)
val path = Path()
// Простой микрофон // Тонкий ободок по краю
val x = centerX ringPaint.color = ringColor()
val y = centerY canvas.drawCircle(cx, cy, r - dp(1).toFloat(), ringPaint)
path.moveTo(x - s * 0.30f, y - s * 1.05f)
path.lineTo(x + s * 0.30f, y - s * 1.05f) // Верхний глянец (белое затухание сверху)
path.lineTo(x + s * 0.30f, y - s * 0.20f) glossPaint.shader = LinearGradient(
path.cubicTo(x + s * 0.30f, y + s * 0.55f, x - s * 0.30f, y + s * 0.55f, x - s * 0.30f, y - s * 0.20f) cx, cy - r, cx, cy + r * 0.18f,
path.close() Color.argb(70, 255, 255, 255), Color.TRANSPARENT, Shader.TileMode.CLAMP
canvas.drawPath(path, micPaint) )
canvas.drawRect(x - s * 0.06f, y - s * 1.15f, x + s * 0.06f, y - s * 0.28f, micPaint) clip.reset()
// ножка и дуга clip.addCircle(cx, cy, r, Path.Direction.CW)
canvas.drawRect(x - s * 0.05f, y + s * 0.28f, x + s * 0.05f, y + s * 0.55f, micPaint) canvas.save()
canvas.drawArc( canvas.clipPath(clip)
x - s * 0.55f, y + s * 0.30f, canvas.drawRect(cx - r, cy - r, cx + r, cy + r, glossPaint)
x + s * 0.55f, y + s * 1.20f, canvas.restore()
0f, 180f, false, micPaint 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-образная)
val cup = Path()
cup.fillType = Path.FillType.EVEN_ODD
cup.moveTo(x - s * 0.52f, y + s * 0.06f)
cup.lineTo(x - s * 0.52f, y + s * 0.48f)
cup.quadTo(x - s * 0.52f, y + s * 0.70f, x, y + s * 0.70f)
cup.quadTo(x + s * 0.52f, y + s * 0.70f, x + s * 0.52f, y + s * 0.48f)
cup.lineTo(x + s * 0.52f, y + s * 0.06f)
cup.close()
cup.moveTo(x - s * 0.38f, y + s * 0.06f)
cup.lineTo(x - s * 0.38f, y + s * 0.44f)
cup.quadTo(x - s * 0.38f, y + s * 0.56f, x, y + s * 0.56f)
cup.quadTo(x + s * 0.38f, y + s * 0.56f, x + s * 0.38f, y + s * 0.44f)
cup.lineTo(x + s * 0.38f, y + s * 0.06f)
cup.close()
canvas.drawPath(cup, micPaint)
// ножка
canvas.drawRoundRect(
x - s * 0.08f, y + s * 0.46f, x + s * 0.08f, y + s * 0.72f,
s * 0.08f, s * 0.08f, micPaint
)
// основание
canvas.drawRoundRect(
x - s * 0.30f, y + s * 0.66f, x + s * 0.30f, y + s * 0.90f,
s * 0.12f, s * 0.12f, micPaint
) )
} }

View File

@@ -0,0 +1,128 @@
package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Paint
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import kotlin.math.roundToInt
/**
* S-метр: полоса со шкалой S1–S9, +20…+60, заливкой уровня, белой пик-меткой
* (peak-hold) и цифрой текущего уровня справа («S9», «+43» или «---»).
* Уровень 0..1; S9 = 55% пути, +60 = 88%.
*/
class SMeterView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private var fillColor = 0xFF58A6FF.toInt()
private val track = Paint().apply { color = 0xFF21262D.toInt() }
private val fill = Paint().apply { color = fillColor }
private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1f) }
private val tickMajor = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1.5f) }
private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2f) }
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(9)
}
private val valuePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFE6EDF3.toInt()
textSize = sp(12)
textAlign = Paint.Align.RIGHT
}
private val idlePaint = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(12)
textAlign = Paint.Align.RIGHT
}
@Volatile
private var target = 0f
private var shown = 0f
private var peak = 0f
private var lastActive = 0L
private val handler = Handler(Looper.getMainLooper())
private val ticker = object : Runnable {
override fun run() {
// Гарантированный сброс: если сигнала не было 600 мс — в ноль
if (System.currentTimeMillis() - lastActive > 600 && target != 0f) target = 0f
// Сглаживание уровня и медленный спад пика
shown += (target - shown) * 0.35f
if (Math.abs(target - shown) < 0.002f) shown = target
peak = maxOf(peak * 0.985f, shown)
if (peak < 0.003f) peak = 0f
invalidate()
handler.postDelayed(this, 100)
}
}
fun setColor(color: Int) {
fillColor = color
fill.color = color
}
fun setLevel(level: Float) {
if (level > 0.01f) lastActive = System.currentTimeMillis()
target = level.coerceIn(0f, 1f)
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
handler.post(ticker)
}
override fun onDetachedFromWindow() {
handler.removeCallbacksAndMessages(null)
super.onDetachedFromWindow()
}
private fun dp(v: Float): Float = v * resources.displayMetrics.density
private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity
private fun levelText(level: Float): String = when {
level < 0.005f -> "---"
level < 0.55f -> "S${(level / 0.55f * 9f).roundToInt().coerceIn(1, 9)}"
else -> "+${(((level - 0.55f) / 0.45f) * 60f).roundToInt().coerceIn(1, 99)}"
}
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
val left = dp(2f)
val right = w - dp(2f)
val bandTop = dp(4f)
val bandH = dp(14f)
// полоса
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val lvl = shown.coerceIn(0f, 1f)
if (lvl > 0.002f) {
canvas.drawRect(left, bandTop, left + lvl * (right - left), bandTop + bandH, fill)
}
// деления: S1..S9, +20..+60
val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f,
"S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f)
for ((text, pos) in marks) {
val mx = left + pos * (right - left)
val major = pos == 0.55f || pos == 0.88f
canvas.drawLine(mx, bandTop, mx, bandTop + bandH, if (major) tickMajor else tick)
canvas.drawText(text, mx - dp(8f), bandTop + bandH + dp(11f), label)
}
// пик-метка
if (peak > 0.003f) {
val px = left + peak.coerceIn(0f, 1f) * (right - left)
canvas.drawLine(px, bandTop - dp(3f), px, bandTop + bandH + dp(3f), peakPaint)
}
// цифра уровня справа от полосы (и чуть выше делений)
val p = if (target < 0.005f) idlePaint else valuePaint
canvas.drawText(levelText(target), right, bandTop + bandH - dp(3f), p)
}
}

View File

@@ -0,0 +1,156 @@
package ru.ua1zbe.svxremote.ui
import android.content.Context
import android.graphics.Canvas
import android.graphics.Color
import android.graphics.Paint
import android.graphics.RectF
import android.os.Handler
import android.os.Looper
import android.util.AttributeSet
import android.view.View
import kotlin.random.Random
/**
* Временное демо-окно с примерами индикаторов звука (S-метр, клиппинг,
* джиттер/потери). Открывается удержанием на надписи «На связи».
* Уровни симулируются, чтобы было видно, как индикаторы работают.
*/
class SoundMeterPreviewView @JvmOverloads constructor(
context: Context,
attrs: AttributeSet? = null
) : View(context, attrs) {
private val bg = Paint().apply { color = 0xFF0D1117.toInt() }
private val track = Paint().apply { color = 0xFF21262D.toInt() }
private val fill = Paint().apply { color = 0xFF58A6FF.toInt() }
private val fillHot = Paint().apply { color = 0xFFF85149.toInt() }
private val peakPaint = Paint().apply { color = 0xFFFFFFFF.toInt(); strokeWidth = dp(2) }
private val tick = Paint().apply { color = 0xFF8B949E.toInt(); strokeWidth = dp(1) }
private val label = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(11)
}
private val labelSmall = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF8B949E.toInt()
textSize = sp(9)
}
private val clipDot = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFFF85149.toInt()
}
private val clipDotOff = Paint(Paint.ANTI_ALIAS_FLAG).apply {
color = 0xFF30363D.toInt()
}
private val handler = Handler(Looper.getMainLooper())
private var simLevel = 0.0f
private var simPeak = 0.0f
private var simJitter = 30.0f
private var simLoss = 0.1f
private var t = 0.0
private val anim = object : Runnable {
override fun run() {
t += 0.08
val wave = ((Math.sin(t) + Math.sin(t * 2.3)) / 2 + 1) / 2
simLevel = (wave * 0.62 + Random.nextFloat() * 0.12).toFloat()
// Периодически «перегруз» — клиппинг и пики
if (Random.nextFloat() > 0.92f) simLevel = 0.98f
simPeak = maxOf(simLevel, simPeak - 0.02f)
simJitter = 25 + Random.nextFloat() * 120
simLoss = (Random.nextFloat() * 0.5f).coerceAtLeast(0.03f)
invalidate()
handler.postDelayed(this, 50)
}
}
override fun onAttachedToWindow() {
super.onAttachedToWindow()
handler.post(anim)
}
override fun onDetachedFromWindow() {
handler.removeCallbacksAndMessages(null)
super.onDetachedFromWindow()
}
private fun dp(v: Int): Float = v * resources.displayMetrics.density
private fun sp(v: Int): Float = v * resources.displayMetrics.scaledDensity
override fun onDraw(canvas: Canvas) {
val w = width.toFloat()
val h = height.toFloat()
canvas.drawRect(0f, 0f, w, h, bg)
// --- S-метр ---
var y = dp(18)
canvas.drawText("S-метр (peak-hold)", dp(16), y, label)
y += dp(8)
drawSMeter(canvas, w, y)
// --- Уровень + клиппинг ---
y += dp(58)
canvas.drawText("Уровень + клиппинг", dp(16), y, label)
y += dp(8)
drawVUMeter(canvas, w, y)
// --- Джиттер / потери ---
y += dp(58)
canvas.drawText("Джиттер и потери пакетов", dp(16), y, label)
y += dp(18)
canvas.drawText(
"джиттер %d мс · потери %.1f%%".format(simJitter.toInt(), simLoss),
dp(16), y, label
)
val bar = RectF(dp(16), y + dp(6), w - dp(16), y + dp(16))
canvas.drawRect(bar, track)
val jw = (simJitter / 250f).coerceIn(0f, 1f) * bar.width()
canvas.drawRect(bar.left, bar.top, bar.left + jw, bar.bottom, fill)
}
/** S-метр: полоса со шкалой S1..S9, +20..+60 и пик-меткой. */
private fun drawSMeter(canvas: Canvas, w: Float, top: Float) {
val left = dp(16)
val right = w - dp(16)
val bandTop = top + dp(2)
val bandH = dp(14)
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val level = (simLevel).coerceIn(0f, 1f)
val fw = level * (right - left)
canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, if (level > 0.85f) fillHot else fill)
// пик-метка
val px = left + simPeak.coerceIn(0f, 1f) * (right - left)
canvas.drawLine(px, bandTop - dp(3), px, bandTop + bandH + dp(3), peakPaint)
// деления и шкала
val marks = listOf("S1" to 0.11f, "S3" to 0.22f, "S5" to 0.33f, "S7" to 0.44f,
"S9" to 0.55f, "+20" to 0.66f, "+40" to 0.77f, "+60" to 0.88f)
for ((text, pos) in marks) {
val mx = left + pos * (right - left)
canvas.drawLine(mx, bandTop, mx, bandTop + bandH, tick)
canvas.drawText(text, mx - dp(10), bandTop + bandH + dp(12), labelSmall)
}
canvas.drawText("+%.0f".format(level * 60), left, bandTop - dp(4), labelSmall)
}
/** VU-полоса с зоной перегрузки и красной точкой клиппинга. */
private fun drawVUMeter(canvas: Canvas, w: Float, top: Float) {
val left = dp(16)
val right = w - dp(52)
val bandTop = top + dp(2)
val bandH = dp(14)
canvas.drawRect(left, bandTop, right, bandTop + bandH, track)
val level = simLevel.coerceIn(0f, 1f)
val fw = level * (right - left)
canvas.drawRect(left, bandTop, left + fw, bandTop + bandH, fill)
// красная зона последних 15%
canvas.drawRect(
right - (right - left) * 0.15f, bandTop, right, bandTop + bandH,
if (level > 0.85f) fillHot else track
)
// клиппинг-точка
val cxp = w - dp(24)
val cyp = bandTop + bandH / 2
canvas.drawCircle(cxp, cyp, dp(7), if (level > 0.92f) clipDot else clipDotOff)
canvas.drawText("CLIP", cxp - dp(20), cyp + dp(10), labelSmall)
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

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" />
<stroke android:width="1dp" android:color="@color/surface_light" />
<corners android:radius="8dp" />
</shape>

View File

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

View File

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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#3358A6FF" />
</shape>

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<solid android:color="#E61B222A" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="#40FFFFFF" />
</shape>

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,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="135"
android:startColor="#0D1117"
android:centerColor="#101826"
android:endColor="#0D1117" />
</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,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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,1c-4.97,0 -9,4.03 -9,9v7c0,1.66 1.34,3 3,3h3v-8L5,12v-2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v2h-4v8h3c1.66,0 3,-1.34 3,-3v-7c0,-4.97 -4.03,-9 -9,-9z" />
</vector>

View File

@@ -1,16 +0,0 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<group
android:translateX="30"
android:translateY="30"
android:scaleX="1.5"
android:scaleY="1.5">
<path
android:fillColor="#FFFFFF"
android:pathData="M12,14c1.66,0 2.99,-1.34 2.99,-3L15,5c0,-1.66 -1.34,-3 -3,-3S9,3.34 9,5v6c0,1.66 1.34,3 3,3zM17.3,11c0,3 -2.54,5.1 -5.3,5.1S6.7,14 6.7,11L5,11c0,3.41 2.72,6.23 6,6.72L11,21h2v-3.28c3.28,-0.48 6,-3.3 6,-6.72h-1.7z" />
</group>
</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,10 @@
<?xml version="1.0" encoding="utf-8"?>
<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="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.41L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9C21,9.26 19.77,6.82 17.83,5.17z" />
</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

@@ -0,0 +1,157 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@color/bg">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/audio_settings_title"
android:textColor="@color/text_primary"
android:textSize="20sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/audio_settings_hint"
android:textColor="@color/text_secondary"
android:textSize="13sp"
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_mic_gain"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_mic_gain_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_mic_gain"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="40"
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_rx_buffer"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_rx_buffer_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_rx_buffer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:max="25"
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_jitter"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:id="@+id/lbl_jitter_value"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textColor="@color/text_secondary"
android:textSize="14sp" />
</LinearLayout>
<SeekBar
android:id="@+id/seek_jitter"
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"
android:layout_height="52dp"
android:text="@string/btn_save"
android:textColor="#0D1117"
android:background="@drawable/bg_primary_btn"
android:textSize="16sp"
android:textStyle="bold" />
</LinearLayout>
</ScrollView>

View File

@@ -4,24 +4,38 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:background="@color/bg" android:background="@color/bg"
android:gravity="center_horizontal"
android:padding="16dp"> android:padding="16dp">
<!-- Верхняя панель -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<ImageButton
android:id="@+id/btn_power"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_power"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_power"
android:scaleType="centerInside" />
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView <TextView
android:id="@+id/status_chip" android:id="@+id/lbl_app_title"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="14dp" android:text="@string/app_title"
android:paddingVertical="6dp"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="14sp" android:textSize="20sp"
android:background="@drawable/bg_chip" android:textStyle="bold" />
android:text="@string/status_disconnected" />
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"
@@ -38,63 +52,226 @@
android:scaleType="centerInside" /> android:scaleType="centerInside" />
</LinearLayout> </LinearLayout>
<TextView <!-- Позывной + комната + группа -->
android:id="@+id/lbl_subtitle" <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="36dp"
android:textColor="@color/text_secondary" android:orientation="horizontal"
android:textSize="13sp" android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/lbl_callsign"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true" android:singleLine="true"
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
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"
android:scaleType="centerInside" />
<TextView <TextView
android:id="@+id/lbl_talker_title" android:id="@+id/lbl_tg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:textColor="@color/warn"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace" />
</LinearLayout>
</LinearLayout>
</LinearLayout>
<!-- Секция: APRS/подключение + статус -->
<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="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:textColor="@color/text_secondary"
android:textSize="14sp" android:textSize="14sp"
android:text="@string/talker_none" /> 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 <TextView
android:id="@+id/lbl_talker" android:id="@+id/lbl_talker"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:layout_marginTop="14dp"
android:gravity="center"
android:textColor="@color/accent" android:textColor="@color/accent"
android:textSize="28sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" android:ellipsize="end" />
android:text="@string/talker_none" />
<!-- S-метры -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
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"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="top">
<TextView
android:layout_width="34dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/meter_rx"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
<ru.ua1zbe.svxremote.ui.SMeterView
android:id="@+id/rx_meter"
android:layout_width="0dp"
android:layout_height="52dp"
android:layout_weight="1" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:gravity="top">
<TextView
android:layout_width="34dp"
android:layout_height="wrap_content"
android:layout_marginTop="6dp"
android:text="@string/meter_mic"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
<ru.ua1zbe.svxremote.ui.SMeterView
android:id="@+id/mic_meter"
android:layout_width="0dp"
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>
<!-- Индикация направления: RX (приём) / TX (передача) -->
<TextView
android:id="@+id/lbl_duplex"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:gravity="center"
android:textSize="24sp"
android:textStyle="bold"
android:visibility="invisible" />
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<ru.ua1zbe.svxremote.ui.LevelMeter <!-- Кнопка PTT -->
android:id="@+id/level_meter"
android:layout_width="match_parent"
android:layout_height="30dp" />
<ru.ua1zbe.svxremote.ui.PttButton <ru.ua1zbe.svxremote.ui.PttButton
android:id="@+id/ptt_button" android:id="@+id/ptt_button"
android:layout_width="match_parent" android:layout_width="170dp"
android:layout_height="220dp" android:layout_height="170dp" />
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<TextView <Space
android:id="@+id/lbl_ptt_hint" android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Нижняя зона статуса (кликабельна) -->
<LinearLayout
android:id="@+id/connect_zone"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:gravity="center" android:orientation="vertical"
android:textColor="@color/text_secondary" android:padding="8dp"
android:textSize="13sp" android:clickable="true"
android:text="@string/ptt_hint" /> android:focusable="true">
<TextView <TextView
android:id="@+id/lbl_tx_state" android:id="@+id/lbl_tx_state"
@@ -104,17 +281,17 @@
android:textColor="@color/danger" android:textColor="@color/danger"
android:textSize="14sp" android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:visibility="gone" android:text="@string/status_big_disconnected" />
android:text="@string/ptt_talking" />
<Button <TextView
android:id="@+id/btn_connect" android:id="@+id/lbl_connect_hint"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="4dp"
android:text="@string/btn_connect" android:gravity="center"
android:textColor="#0D1117" android:textColor="@color/text_secondary"
android:background="@drawable/bg_primary_btn" android:textSize="12sp"
android:textSize="16sp" android:textStyle="italic"
android:textStyle="bold" /> android:text="@string/hint_connect" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -11,6 +11,23 @@
android:orientation="vertical" android:orientation="vertical"
android:padding="20dp"> android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/profiles_label"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Spinner
android:id="@+id/spinner_profile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_input"
android:padding="12dp"
android:spinnerMode="dropdown"
android:textColor="@color/text_primary" />
<TextView <TextView
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
@@ -110,7 +127,7 @@
android:id="@+id/input_tg" android:id="@+id/input_tg"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginBottom="24dp" android:layout_marginBottom="16dp"
android:inputType="number" android:inputType="number"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary" android:textColorHint="@color/text_secondary"
@@ -119,6 +136,72 @@
android:padding="12dp" android:padding="12dp"
android:hint="11" /> android:hint="11" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_groups"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<EditText
android:id="@+id/input_groups"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:inputType="text"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="16sp"
android:background="@drawable/bg_input"
android:padding="12dp"
android:hint="9, 11, 12, 13" />
<CheckBox
android:id="@+id/chk_node_toasts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/pref_node_toasts"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_label"
android:textColor="@color/accent"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_hint"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_coords"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<EditText
android:id="@+id/input_aprs_coords"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:inputType="text"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
android:textSize="16sp"
android:background="@drawable/bg_input"
android:padding="12dp"
android:hint="@string/pref_aprs_coords_hint" />
<Button <Button
android:id="@+id/btn_save" android:id="@+id/btn_save"
android:layout_width="match_parent" android:layout_width="match_parent"
@@ -128,5 +211,14 @@
android:background="@drawable/bg_primary_btn" android:background="@drawable/bg_primary_btn"
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> android:textStyle="bold" />
<TextView
android:id="@+id/lbl_version"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout> </LinearLayout>
</ScrollView> </ScrollView>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_splash">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/splash_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SVXhomyak"
android:textColor="@color/text_primary"
android:textSize="34sp"
android:textStyle="bold"
android:letterSpacing="0.08"
android:alpha="0" />
<TextView
android:id="@+id/splash_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="СВЯЗЬ ЧЕРЕЗ СЕРВЕР"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:letterSpacing="0.3"
android:alpha="0" />
<View
android:id="@+id/splash_line"
android:layout_width="48dp"
android:layout_height="2dp"
android:layout_marginTop="16dp"
android:background="@color/info"
android:alpha="0" />
</LinearLayout>
</FrameLayout>

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

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/node_toast_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_node_toast"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="14dp"
android:paddingBottom="14dp">
<TextView
android:id="@+id/lbl_node_toast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#F0F0F0"
android:textSize="16sp"
android:textStyle="bold" />
</FrameLayout>

View File

@@ -9,22 +9,84 @@
<string name="pref_callsign">Позывной</string> <string name="pref_callsign">Позывной</string>
<string name="pref_auth_key">Пароль (AUTH_KEY)</string> <string name="pref_auth_key">Пароль (AUTH_KEY)</string>
<string name="pref_tg">Группа вещания (TG)</string> <string name="pref_tg">Группа вещания (TG)</string>
<string name="pref_groups">Группы для переключения (через запятую)</string>
<string name="select_tg">Выбор группы</string>
<string name="toast_groups_empty">Добавьте группы в настройках</string>
<string name="nodes_title">Узлы в сети (%1$d)</string>
<string name="toast_nodes_empty">Нет подключенных узлов</string>
<string name="pref_node_toasts">Всплывающее уведомление о подключении/отключении узлов</string>
<string name="pref_aprs_label">Метеостанции (APRS)</string>
<string name="pref_aprs_hint">Экспериментально: принимает метеостанции в радиусе 250 км. Последняя станция показывается вместо строки сервера на главном экране.</string>
<string name="pref_aprs_coords">Координаты (широта, долгота)</string>
<string name="pref_aprs_coords_hint">пусто = СПб, напр. 59.95, 30.30</string>
<string name="audio_settings_title">Настройки звука</string>
<string name="audio_settings_hint">Скрытые настройки: применяются при следующем подключении/передаче.</string>
<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>
<string name="node_with_tg">%1$s — TG%2$d</string>
<string name="btn_save">Сохранить</string> <string name="btn_save">Сохранить</string>
<string name="app_version_fmt">SVXhomyak — v%1$s</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="pref_callsign_hint">например UA1ZBE</string> <string name="pref_callsign_hint">например UA1ZBE</string>
<string name="status_disconnected">Отключено</string> <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_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>
<string name="status_big_connected">CONNECTED</string>
<string name="status_big_talking">ПЕРЕДАЧА</string>
<string name="hint_connect">Нажмите, чтобы подключиться</string>
<string name="hint_disconnect">Нажмите, чтобы отключиться</string>
<string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string>
<string name="meter_idle">---</string>
<string name="meter_db">+%1$d dB</string>
<string name="meter_preview_title">Примеры индикаторов звука</string>
<string name="meter_preview_hint">Временное демо (уровни симулируются). Скажи, что реализовать по-настоящему.</string>
<string name="tg_badge">TG%1$d ▾</string>
<string name="talker_last_none">Последний: —</string>
<string name="status_connecting">Подключение…</string> <string name="status_connecting">Подключение…</string>
<string name="status_connecting_ssl">Проверка ключа…</string> <string name="status_connecting_ssl">Проверка ключа…</string>
<string name="status_connected">На связи</string> <string name="status_connected">На связи</string>
<string name="status_auth_failed">Ошибка авторизации</string> <string name="status_auth_failed">Ошибка авторизации</string>
<string name="status_error">Ошибка: %1$s</string> <string name="status_error">Ошибка: %1$s</string>
<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_connect">Подключиться</string>
<string name="btn_disconnect">Отключиться</string> <string name="btn_disconnect">Отключиться</string>
<string name="talker_none">—</string> <string name="talker_none">—</string>
<string name="talker_now">Сейчас говорит: %1$s</string>
<string name="talker_on_tg">группа %1$d</string> <string name="talker_on_tg">группа %1$d</string>
<string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string> <string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string>
@@ -40,7 +102,24 @@
<string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string> <string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string>
<string name="toast_settings_empty">Заполните настройки подключения</string> <string name="toast_settings_empty">Заполните настройки подключения</string>
<string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string> <string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string>
<string name="profiles_label">Профиль настроек</string>
<string name="profile_name">Профиль %1$d</string>
<string name="profiles_title">Выбор профиля</string>
<string name="profile_current"> (текущий)</string>
<string name="profile_empty">не настроен</string>
<string name="cancel">Отмена</string>
<string name="talk_log_title">Последние передачи</string>
<string name="talk_log_empty">Журнала передач пока нет</string>
<string name="talk_log_entry">%1$s %2$s TG%3$d</string>
<string name="close">Закрыть</string>
<string name="settings_not_configured">Настройки не заданы</string> <string name="settings_not_configured">Настройки не заданы</string>
<string name="ptt_disabled">Нет связи</string> <string name="ptt_disabled">Нет связи</string>
<string name="update_available_title">Доступно обновление</string>
<string name="update_available_msg">Версия %1$s уже вышла. Обновить приложение?</string>
<string name="update_btn">Обновить</string>
<string name="update_later">Позже</string>
<string name="update_downloading">Загрузка обновления…</string>
<string name="update_failed">Не удалось загрузить обновление: %1$s</string>
</resources> </resources>

View File

@@ -150,22 +150,40 @@ class HandshakeTest {
.u16(1).str(sentCodecs) .u16(1).str(sentCodecs)
.toByteArray() .toByteArray()
writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info)) writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info))
// Client должен прислать MsgSelectTG(11) и UDP-датаграмму // Client должен прислать MsgSelectTG(11), MsgTgMonitor(11) и
// MsgNodeInfo с версией ПО, плюс UDP-датаграмму
var gotTg = false var gotTg = false
while (!gotTg) { var gotMonitor = false
var gotNodeInfo = false
var tries = 0
while ((!gotTg || !gotMonitor || !gotNodeInfo) && tries++ < 100) {
val frame3 = readFrame(input, buf) val frame3 = readFrame(input, buf)
val msg3 = RcMessage.fromPayload(frame3) val msg3 = RcMessage.fromPayload(frame3)
when (msg3.type) { when (msg3.type) {
ReflectorProtocol.MSG_SELECT_TG -> { ReflectorProtocol.MSG_SELECT_TG -> {
val rr = BinReader(msg3.fields) val rr = BinReader(msg3.fields)
assertEquals(11L, rr.u32()) assertEquals(11L, rr.u32())
gotTg = true gotTg = true
sawSelectTg = true sawSelectTg = true
selectTgLatch.countDown() selectTgLatch.countDown()
} }
ReflectorProtocol.MSG_TG_MONITOR -> {
val rr = BinReader(msg3.fields)
assertEquals(1, rr.u16())
assertEquals(11L, rr.u32())
gotMonitor = true
}
ReflectorProtocol.MSG_NODE_INFO -> {
val json = BinReader(msg3.fields).str()
assertTrue("JSON: $json", json.contains("swVersion"))
assertTrue("JSON: $json", json.contains("1.0.0"))
gotNodeInfo = true
}
ReflectorProtocol.MSG_HEARTBEAT -> Unit ReflectorProtocol.MSG_HEARTBEAT -> Unit
} }
} }
assertTrue("TgMonitor не получен", gotMonitor)
assertTrue("NodeInfo не получен", gotNodeInfo)
// Ждём UDP-датаграмму (регистрация UDP) // Ждём UDP-датаграмму (регистрация UDP)
try { try {
val ds = DatagramSocket(server.localPort) val ds = DatagramSocket(server.localPort)

View File

@@ -87,6 +87,27 @@ class ProtocolTest {
assertEquals(11L, r.u32()) assertEquals(11L, r.u32())
} }
@Test
fun `tgMonitor roundtrip`() {
val parsed = RcMessage.tryParseFrame(MsgBuilder.tgMonitor(listOf(11, 22)).toFrame())
assertEquals(ReflectorProtocol.MSG_TG_MONITOR, parsed?.type)
val r = BinReader(parsed!!.fields)
assertEquals(2, r.u16())
assertEquals(11L, r.u32())
assertEquals(22L, r.u32())
}
@Test
fun `nodeInfo roundtrip и версия ПО`() {
val parsed = RcMessage.tryParseFrame(
MsgBuilder.nodeInfo("SVXHomyak", "1.0.0").toFrame()
)
assertEquals(ReflectorProtocol.MSG_NODE_INFO, parsed?.type)
val json = BinReader(parsed!!.fields).str()
assertTrue("JSON: $json", json.contains("\"swInfo\":\"SVXHomyak\""))
assertTrue("JSON: $json", json.contains("\"swVersion\":\"1.0.0\""))
}
@Test @Test
fun `udp audio roundtrip`() { fun `udp audio roundtrip`() {
val audio = ByteArray(30) { (it and 0xFF).toByte() } val audio = ByteArray(30) { (it and 0xFF).toByte() }

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