Compare commits
14 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fcaf7355b5 | ||
|
|
78bac27175 | ||
|
|
2d332f10bb | ||
|
|
96ab5c76e2 | ||
|
|
4e7bf86f37 | ||
|
|
b6e4fb7e3a | ||
|
|
0a24a6f6c0 | ||
|
|
b847320360 | ||
|
|
203e5f6ed5 | ||
|
|
491dff03db | ||
|
|
fe6560e494 | ||
|
|
fac8192499 | ||
|
|
1af9bfda80 | ||
|
|
f6c37d4fc0 |
70
README.md
70
README.md
@@ -7,17 +7,69 @@ Android-клиент для подключения к рефлекторам **S
|
||||
- Подключение к рефлектору SVXLink по протоколу v2 с авторизацией по AUTH_KEY
|
||||
- Выбор группы вещания (TG) — по умолчанию 11
|
||||
- Голосовая связь в формате **Opus** (8 кГц, моно) через UDP
|
||||
- Кнопка PTT (Push-To-Talk) — удерживайте, чтобы говорить
|
||||
- Индикаторы уровня передачи и приёма
|
||||
- Отображение текущего говорящего
|
||||
- Кнопка PTT (Push-To-Talk) в стиле Zello — крупная, с градиентом и тенью; удерживайте, чтобы говорить
|
||||
- Индикаторы уровня передачи (микрофон) и приёма (S-метры)
|
||||
- **Статистика приёма** (потери, %, и джиттер, мс) — включается в скрытых настройках звука
|
||||
- **Выбор битрейта передачи** — Авто (адаптация под качество канала) либо фиксированный 6–32 кбит/с (скрытые настройки звука)
|
||||
- **Мониторинг групп** — кнопка наушников: прослушивание выбранных групп с автоматическим возвратом к базовой после паузы
|
||||
- **Запись приёма в OGG** с пропуском тишины (подробности ниже)
|
||||
- Строка текущего говорящего между блоками экрана:
|
||||
- при приёме — `Говорит UA1ZBE-M TG12`
|
||||
- без приёма — `UA1ZBE-M TG12` (последний говоривший)
|
||||
- Автоматическое переподключение при обрыве связи
|
||||
- Уведомления о входе/выходе участников (опционально)
|
||||
- Позывной регистронезависимый: `ua1zbe-m` автоматически преобразуется в `UA1ZBE-M` для отправки на сервер
|
||||
|
||||
## Запись приёма
|
||||
|
||||
- Кнопка записи расположена между позывным и кнопкой мониторинга групп.
|
||||
- **Короткое нажатие** — начать/остановить запись. Файл сохраняется в
|
||||
`Android/data/ru.ua1zbe.svxremote/files/recordings/rec_ГГГГ-ММ-ДД_ЧЧ-ММ-СС.ogg`.
|
||||
- Записывается только реальный приём: паузы в эфире (тишина) в файл не попадают,
|
||||
реплики склеиваются без разрывов.
|
||||
- **Удержание кнопки 3 секунды** — открывается список записей: воспроизведение
|
||||
(повторный тап останавливает) и удаление с подтверждением. Если записей нет —
|
||||
выводится подсказка.
|
||||
|
||||
## Скрытые настройки звука
|
||||
|
||||
Открываются тройным тапом по заголовку «SVXHomyak» на главном экране. Позволяют:
|
||||
|
||||
- включить/выключить отображение статистики приёма (потери/джиттер);
|
||||
- выбрать битрейт передачи (Авто или 6–32 кбит/с).
|
||||
|
||||
## Требования
|
||||
|
||||
- Android 8.0 (API 26) и выше
|
||||
- Доступ к сети и микрофону
|
||||
- Рефлектор SVXLink с включённой поддержкой протокола v2 (AUTH_KEY)
|
||||
- Для push-уведомлений: установить и авторизоваться в приложении **RuStore** на устройстве (оно является дистрибьютором пуша)
|
||||
|
||||
## Push-уведомления (RuStore)
|
||||
|
||||
Приложение интегрировано с [RuStore Push SDK](https://www.rustore.ru/help/sdk/push-notifications) (аналог Firebase Cloud Messaging). Для доставки на устройстве должен быть установлен и авторизован RuStore (или приложение-дистрибьютор экосистемы VK).
|
||||
|
||||
Рассылка выполняется с сервера через API RuStore:
|
||||
|
||||
```
|
||||
POST https://vkpns.rustore.ru/v1/projects/{project_id}/messages:send
|
||||
Authorization: Bearer {service-token}
|
||||
Content-Type: application/json
|
||||
|
||||
{
|
||||
"message": {
|
||||
"token": "{push-token устройства}",
|
||||
"notification": {
|
||||
"title": "Говорит UA1ZBE1 TG12",
|
||||
"body": "..."
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
- `project_id` и `service-token` берутся в консоли RuStore: **Push-уведомления > Проекты**.
|
||||
- `push-token` устройства приходит в callback `onNewToken` (лог `RuStorePush`), сохраняется в настройках приложения.
|
||||
- Если в запросе есть секция `notification` — уведомление отображает сам SDK; если переданы только `data` (`title`/`body`) — приложение показывает его самостоятельно.
|
||||
|
||||
## Установка
|
||||
|
||||
@@ -43,16 +95,24 @@ Android-клиент для подключения к рефлекторам **S
|
||||
|
||||
APK появится в `app/build/outputs/apk/debug/app-debug.apk`.
|
||||
|
||||
Для сборки релизного (подписанного) APK:
|
||||
|
||||
```bash
|
||||
./gradlew :app:assembleRelease
|
||||
# далее выровнять и подписать через build-tools (zipalign + apksigner)
|
||||
```
|
||||
|
||||
Требования: JDK 17, Android SDK (compileSdk 34).
|
||||
|
||||
## Структура проекта
|
||||
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt` — основной экран
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt` — основной экран и логика UI
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt` — настройки подключения
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt` — клиент рефлектора (TCP + UDP)
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt` — бинарный протокол svxreflector v2
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt` — кодирование/декодирование Opus через MediaCodec
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/ui/` — кнопка PTT и индикатор уровня
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/audio/RxRecorder.kt` — запись приёма в OGG (MediaCodec + MediaMuxer)
|
||||
- `app/src/main/java/ru/ua1zbe/svxremote/ui/` — кнопка PTT (стиль Zello), S-метр, индикатор уровня
|
||||
- `app/src/test/java/ru/ua1zbe/svxremote/` — юнит-тесты протокола и рукопожатия
|
||||
|
||||
## Тесты
|
||||
|
||||
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "ru.ua1zbe.svxremote"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 14
|
||||
versionName = "1.0.12"
|
||||
versionCode = 17
|
||||
versionName = "1.0.15"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
@@ -43,6 +43,7 @@ kotlin {
|
||||
}
|
||||
|
||||
dependencies {
|
||||
implementation("ru.rustore.sdk:pushclient:7.4.0")
|
||||
testImplementation("junit:junit:4.13.2")
|
||||
}
|
||||
|
||||
|
||||
@@ -1,5 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
|
||||
xmlns:tools="http://schemas.android.com/tools">
|
||||
|
||||
<uses-permission android:name="android.permission.INTERNET" />
|
||||
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||
@@ -9,6 +10,7 @@
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<application
|
||||
android:name=".SvxApp"
|
||||
android:label="@string/app_name"
|
||||
android:icon="@mipmap/ic_launcher"
|
||||
android:usesCleartextTraffic="true"
|
||||
@@ -20,6 +22,22 @@
|
||||
android:exported="false"
|
||||
android:foregroundServiceType="mediaPlayback" />
|
||||
|
||||
<service
|
||||
android:name=".PushMessagingService"
|
||||
android:exported="true"
|
||||
tools:ignore="ExportedService">
|
||||
<intent-filter>
|
||||
<action android:name="ru.rustore.sdk.pushclient.MESSAGING_EVENT" />
|
||||
</intent-filter>
|
||||
</service>
|
||||
|
||||
<meta-data
|
||||
android:name="ru.rustore.sdk.pushclient.default_notification_icon"
|
||||
android:resource="@drawable/ic_launcher_fg" />
|
||||
<meta-data
|
||||
android:name="ru.rustore.sdk.pushclient.default_notification_channel_id"
|
||||
android:value="@string/push_channel_id" />
|
||||
|
||||
<activity
|
||||
android:name=".SettingsActivity"
|
||||
android:exported="false"
|
||||
|
||||
@@ -330,6 +330,12 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
@Suppress("DEPRECATION")
|
||||
override fun onBackPressed() {
|
||||
// Кнопка «Назад» сворачивает приложение в фон, а не закрывает его.
|
||||
moveTaskToBack(true)
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
hideNodeOverlay()
|
||||
|
||||
@@ -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
|
||||
}
|
||||
}
|
||||
@@ -16,6 +16,24 @@ class SplashActivity : Activity() {
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
|
||||
// Тап по иконке при уже запущенном приложении: система добавляет
|
||||
// SplashActivity поверх существующей задачи. Не создаём дубликат —
|
||||
// закрываемся и показываем текущий экран (как «развернуть», без splash).
|
||||
if (!isTaskRoot &&
|
||||
intent?.action == Intent.ACTION_MAIN &&
|
||||
intent?.hasCategory(Intent.CATEGORY_LAUNCHER) == true
|
||||
) {
|
||||
finish()
|
||||
return
|
||||
}
|
||||
|
||||
// Засчитываем запуск только при холодном старте (процесс был выгружен).
|
||||
// Выход из фона или повторный тап по иконке (процесс жив) не считаем.
|
||||
if (SvxApp.coldStart) {
|
||||
SvxApp.coldStart = false
|
||||
StatsReporter.report(this)
|
||||
}
|
||||
if (mainAlive) {
|
||||
openMain()
|
||||
return
|
||||
|
||||
99
app/src/main/java/ru/ua1zbe/svxremote/StatsReporter.kt
Normal file
99
app/src/main/java/ru/ua1zbe/svxremote/StatsReporter.kt
Normal file
@@ -0,0 +1,99 @@
|
||||
package ru.ua1zbe.svxremote
|
||||
|
||||
import android.content.Context
|
||||
import android.util.Base64
|
||||
import android.util.Log
|
||||
import org.json.JSONObject
|
||||
import ru.ua1zbe.svxremote.prefs.Prefs
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
import java.text.SimpleDateFormat
|
||||
import java.util.Date
|
||||
import java.util.Locale
|
||||
import java.util.TimeZone
|
||||
|
||||
/**
|
||||
* Учёт запусков по позывному.
|
||||
*
|
||||
* При старте приложения обновляет во внешнем документе запись своего позывного
|
||||
* (версия ПО, время последнего запуска, число запусков). Записи других
|
||||
* позывных не изменяются.
|
||||
*/
|
||||
object StatsReporter {
|
||||
|
||||
private const val TAG = "SVXRemote"
|
||||
private const val API =
|
||||
"https://git2.ua1zbe.ru/api/v1/repos/svxhomyak/svxx/contents/.svxstats.json"
|
||||
private const val TOKEN = "d1ef9a06c6471f009aefd3097772d263c267b008"
|
||||
private const val BRANCH = "main"
|
||||
|
||||
/** Запускает фоновое обновление статистики (не блокирует старт приложения). */
|
||||
fun report(context: Context) {
|
||||
val callsign = Prefs.load(context).callsign.trim().uppercase()
|
||||
if (callsign.isEmpty()) return
|
||||
Thread({
|
||||
repeat(3) { attempt ->
|
||||
if (runCatching { update(callsign) }.getOrDefault(false)) return@Thread
|
||||
Thread.sleep(1000L * (attempt + 1))
|
||||
}
|
||||
}, "svx-stats").apply { isDaemon = true }.start()
|
||||
}
|
||||
|
||||
private fun update(callsign: String): Boolean {
|
||||
var sha: String? = null
|
||||
val root = JSONObject()
|
||||
|
||||
val get = URL(API).openConnection() as HttpURLConnection
|
||||
try {
|
||||
get.setRequestProperty("Authorization", "token $TOKEN")
|
||||
get.connectTimeout = 10_000
|
||||
get.readTimeout = 10_000
|
||||
if (get.responseCode == 200) {
|
||||
val obj = JSONObject(get.inputStream.bufferedReader().readText())
|
||||
sha = obj.optString("sha").ifEmpty { null }
|
||||
val encoded = obj.optString("content", "").replace("\n", "")
|
||||
if (encoded.isNotEmpty()) {
|
||||
val decoded = String(Base64.decode(encoded, Base64.DEFAULT), Charsets.UTF_8)
|
||||
runCatching { JSONObject(decoded) }.getOrNull()?.let { parsed ->
|
||||
for (key in parsed.keys()) root.put(key, parsed.get(key))
|
||||
}
|
||||
}
|
||||
}
|
||||
} finally {
|
||||
get.disconnect()
|
||||
}
|
||||
|
||||
val now = SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ssXXX", Locale.US).apply {
|
||||
timeZone = TimeZone.getTimeZone("Europe/Moscow")
|
||||
}.format(Date())
|
||||
val launches = (root.optJSONObject(callsign)?.optInt("launches", 0) ?: 0) + 1
|
||||
root.put(callsign, JSONObject().apply {
|
||||
put("callsign", callsign)
|
||||
put("version", BuildConfig.VERSION_NAME)
|
||||
put("lastLaunch", now)
|
||||
put("launches", launches)
|
||||
})
|
||||
|
||||
val body = JSONObject().apply {
|
||||
put("content", Base64.encodeToString(root.toString().toByteArray(), Base64.NO_WRAP))
|
||||
put("message", "stats")
|
||||
put("branch", BRANCH)
|
||||
if (sha != null) put("sha", sha)
|
||||
}
|
||||
val put = URL(API).openConnection() as HttpURLConnection
|
||||
return try {
|
||||
put.requestMethod = "PUT"
|
||||
put.doOutput = true
|
||||
put.setRequestProperty("Authorization", "token $TOKEN")
|
||||
put.setRequestProperty("Content-Type", "application/json")
|
||||
put.connectTimeout = 10_000
|
||||
put.readTimeout = 10_000
|
||||
put.outputStream.use { it.write(body.toString().toByteArray()) }
|
||||
val code = put.responseCode
|
||||
Log.i(TAG, "stats $callsign launches=$launches http=$code")
|
||||
code in 200..299
|
||||
} finally {
|
||||
put.disconnect()
|
||||
}
|
||||
}
|
||||
}
|
||||
70
app/src/main/java/ru/ua1zbe/svxremote/SvxApp.kt
Normal file
70
app/src/main/java/ru/ua1zbe/svxremote/SvxApp.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
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()
|
||||
// Процесс только что создан — значит приложение было выгружено и
|
||||
// запускается заново (а не выходит из фона/по иконке).
|
||||
coldStart = true
|
||||
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"
|
||||
|
||||
/** true, если процесс создан заново (приложение было выгружено). */
|
||||
@Volatile
|
||||
var coldStart = false
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,7 @@ class OpusEngine(
|
||||
const val CHANNELS = 1
|
||||
const val FRAME_SIZE = 160 // 20 мс в 8 кГц
|
||||
const val BIT_RATE = 20000
|
||||
private const val GATE_MIN_WAIT_MS = 100L
|
||||
// Авто-адаптация битрейта TX (0 = авто включено в настройках)
|
||||
const val AUTO_BITRATE_MIN = 8
|
||||
const val AUTO_BITRATE_MAX = 24
|
||||
@@ -384,56 +385,26 @@ class OpusEngine(
|
||||
onRxStats?.invoke(lossPct, jitterMs)
|
||||
}
|
||||
|
||||
/** Джиттер-гейт открыт: накопили кадры или вышло 300 мс с первого пакета. */
|
||||
/** Джиттер-гейт открыт: накопили кадры или вышел короткий таймаут. */
|
||||
private fun gateOpen(): Boolean {
|
||||
if (playStarted) return true
|
||||
if (rxQueue.size >= jitterFrames) return true
|
||||
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 300
|
||||
val waitMs = maxOf(GATE_MIN_WAIT_MS, jitterFrames * 20L)
|
||||
return gateStart != 0L && System.currentTimeMillis() - gateStart >= waitMs
|
||||
}
|
||||
|
||||
fun onAudioFlush() {
|
||||
try {
|
||||
// MediaCodec.flush() возвращает входные буферы, но уже выданные индексы
|
||||
// остаются у нас в freeInputs и дублируются колбэками «повторной выдачи» —
|
||||
// queueInputBuffer падает (index not owned by client) и кадры теряются.
|
||||
// Надёжнее пересоздать декодер: колбэки старого отсекаются проверкой
|
||||
// codec !== decoder.
|
||||
player?.flush()
|
||||
rxQueue.clear()
|
||||
receivedAny = false
|
||||
playStarted = false
|
||||
gateStart = 0L
|
||||
onLevel(SRC_SPEAKER, 0f)
|
||||
restartDecoder()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
|
||||
/** Пересоздать только декодер (плеер и джиттер-буфер сохраняем). */
|
||||
private fun restartDecoder() {
|
||||
val dec = decoder
|
||||
decoder = null
|
||||
if (dec != null) {
|
||||
try {
|
||||
dec.stop()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
try {
|
||||
dec.release()
|
||||
} catch (_: Exception) {
|
||||
}
|
||||
}
|
||||
val format = MediaFormat()
|
||||
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
|
||||
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
|
||||
if (startDecoderCodec(format)) {
|
||||
concealLogs = 0
|
||||
dbgOut = 0
|
||||
ptsCounter = 20_000L
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке.
|
||||
* Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM.
|
||||
|
||||
@@ -178,4 +178,14 @@ object Prefs {
|
||||
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()
|
||||
}
|
||||
}
|
||||
|
||||
@@ -85,6 +85,15 @@ class ReflectorClient(
|
||||
@Volatile
|
||||
private var fatal = false
|
||||
|
||||
/**
|
||||
* Была ли хотя бы одна успешная сессия за текущее подключение.
|
||||
* Если да — креды уже проверены, поэтому ошибки на рукопожатии считаем
|
||||
* временными (сервер мог ещё держать старую сессию после обрыва) и
|
||||
* продолжаем автопереподключение вместо показа «ошибки авторизации».
|
||||
*/
|
||||
@Volatile
|
||||
private var everReady = false
|
||||
|
||||
@Volatile
|
||||
private var tcpSocket: Socket? = null
|
||||
private var udpSocket: DatagramSocket? = null
|
||||
@@ -107,6 +116,7 @@ class ReflectorClient(
|
||||
this.settings = settings
|
||||
manualDisconnect.set(false)
|
||||
fatal = false
|
||||
everReady = false
|
||||
if (running.get()) {
|
||||
// Перезапуск с новыми настройками (например, смена профиля)
|
||||
running.set(false)
|
||||
@@ -254,7 +264,10 @@ class ReflectorClient(
|
||||
callbacks.onState(STATE_TCP_CONNECTED)
|
||||
startTcpSenderThread(socket, s)
|
||||
runTcpLoop(socket, s)
|
||||
true
|
||||
// runTcpLoop завершилась: если всё ещё должны работать — значит
|
||||
// соединение разорвано/отклонено, вернём false, чтобы reconnectLoop
|
||||
// выдержал RECONNECT_DELAY_MS и не бомбил сервер переподключениями.
|
||||
!running.get()
|
||||
} catch (e: Exception) {
|
||||
try {
|
||||
socket.close()
|
||||
@@ -399,6 +412,7 @@ class ReflectorClient(
|
||||
serverCodecs = info.codecs
|
||||
callbacks.onServerInfo(info)
|
||||
state = STATE_READY
|
||||
everReady = true
|
||||
callbacks.onState(STATE_READY)
|
||||
// Сначала сообщаем о себе (версия ПО для дашборда), затем
|
||||
// выбираем TG для передачи и подписываемся на прослушивание
|
||||
@@ -441,9 +455,17 @@ class ReflectorClient(
|
||||
|
||||
ReflectorProtocol.MSG_ERROR -> {
|
||||
val err = MsgParser.errorMessage(msg.fields)
|
||||
Log.w(TAG, "MSG_ERROR: $err")
|
||||
Log.w(TAG, "MSG_ERROR: $err (state=$state, everReady=$everReady)")
|
||||
if (state < STATE_READY) {
|
||||
if (everReady) {
|
||||
// Креды проверены ранее: ошибка на рукопожатии почти
|
||||
// всегда временная (сервер ещё держит старую сессию
|
||||
// после короткого обрыва) — переподключаемся заново.
|
||||
callbacks.onState(STATE_ERROR, err.ifEmpty { "Переподключение" })
|
||||
abortSockets()
|
||||
} else {
|
||||
stopFatal(err.ifEmpty { "Access denied" })
|
||||
}
|
||||
return false
|
||||
}
|
||||
callbacks.onError(err)
|
||||
@@ -452,7 +474,13 @@ class ReflectorClient(
|
||||
else -> Unit // неизвестные сообщения игнорируются
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
if (everReady) {
|
||||
// Сбой после успешного подключения — не фатально, переподключаемся.
|
||||
callbacks.onState(STATE_ERROR, "Ошибка протокола: ${e.message}")
|
||||
abortSockets()
|
||||
} else {
|
||||
stopFatal("Ошибка протокола: ${e.message}")
|
||||
}
|
||||
return false
|
||||
}
|
||||
return true
|
||||
@@ -478,7 +506,12 @@ class ReflectorClient(
|
||||
}
|
||||
|
||||
private fun protocolError(detail: String): Boolean {
|
||||
if (everReady) {
|
||||
callbacks.onState(STATE_ERROR, detail)
|
||||
abortSockets()
|
||||
} else {
|
||||
stopFatal(detail)
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
|
||||
@@ -80,6 +80,8 @@
|
||||
<string name="notif_connected">Соединение с рефлектором активно</string>
|
||||
<string name="notif_talking">Говорит: %1$s</string>
|
||||
<string name="notif_last_talker">Последний: %1$s</string>
|
||||
<string name="push_channel_id">svx_push</string>
|
||||
<string name="push_channel_name">Пуш-уведомления</string>
|
||||
|
||||
<string name="btn_connect">Подключиться</string>
|
||||
<string name="btn_disconnect">Отключиться</string>
|
||||
|
||||
@@ -6,3 +6,4 @@ org.gradle.parallel=false
|
||||
org.gradle.caching=false
|
||||
kotlin.compiler.execution.strategy=in-process
|
||||
android.nonTransitiveRClass=true
|
||||
android.useAndroidX=true
|
||||
|
||||
@@ -11,6 +11,7 @@ dependencyResolutionManagement {
|
||||
repositories {
|
||||
google()
|
||||
mavenCentral()
|
||||
maven("https://nexus-external.rustore.ru/repository/maven-rustore-exposed/")
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
Reference in New Issue
Block a user