Initial commit: SVXHomyak - Android client for SVXLink reflectors
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
*.iml
|
||||||
|
.gradle/
|
||||||
|
local.properties
|
||||||
|
.idea/
|
||||||
|
.DS_Store
|
||||||
|
build/
|
||||||
|
/captures
|
||||||
|
.externalNativeBuild
|
||||||
|
.cxx
|
||||||
66
README.md
Normal file
66
README.md
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
# SVXHomyak
|
||||||
|
|
||||||
|
Android-клиент для подключения к рефлекторам **SVXLink** (svxreflector) — слушайте и участвуйте в групповых переговорах (TG) прямо со смартфона.
|
||||||
|
|
||||||
|
## Возможности
|
||||||
|
|
||||||
|
- Подключение к рефлектору SVXLink по протоколу v2 с авторизацией по AUTH_KEY
|
||||||
|
- Выбор группы вещания (TG) — по умолчанию 11
|
||||||
|
- Голосовая связь в формате **Opus** (8 кГц, моно) через UDP
|
||||||
|
- Кнопка PTT (Push-To-Talk) — удерживайте, чтобы говорить
|
||||||
|
- Индикаторы уровня передачи и приёма
|
||||||
|
- Отображение текущего говорящего
|
||||||
|
- Автоматическое переподключение при обрыве связи
|
||||||
|
- Позывной регистронезависимый: `ua1zbe-m` автоматически преобразуется в `UA1ZBE-M` для отправки на сервер
|
||||||
|
|
||||||
|
## Требования
|
||||||
|
|
||||||
|
- Android 8.0 (API 26) и выше
|
||||||
|
- Доступ к сети и микрофону
|
||||||
|
- Рефлектор SVXLink с включённой поддержкой протокола v2 (AUTH_KEY)
|
||||||
|
|
||||||
|
## Установка
|
||||||
|
|
||||||
|
Скачайте последний APK со страницы [Releases](https://git2.ua1zbe.ru/ua1zbe/svxhomyak/releases) и установите его на устройство (разрешите установку из неизвестных источников).
|
||||||
|
|
||||||
|
## Использование
|
||||||
|
|
||||||
|
1. Откройте **Настройки** (иконка шестерёнки).
|
||||||
|
2. Укажите:
|
||||||
|
- **Сервер** — адрес рефлектора (хост или IP)
|
||||||
|
- **Порт** — по умолчанию `5300`
|
||||||
|
- **Позывной** — ваш позывной, например `UA1ZBE-M`
|
||||||
|
- **Пароль (AUTH_KEY)** — ключ доступа к рефлектору
|
||||||
|
- **Группа вещания (TG)** — номер группы
|
||||||
|
3. Нажмите **Сохранить**, затем **Подключиться**.
|
||||||
|
4. Для передачи удерживайте кнопку PTT.
|
||||||
|
|
||||||
|
## Сборка из исходников
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :app:assembleDebug
|
||||||
|
```
|
||||||
|
|
||||||
|
APK появится в `app/build/outputs/apk/debug/app-debug.apk`.
|
||||||
|
|
||||||
|
Требования: JDK 17, Android SDK (compileSdk 34).
|
||||||
|
|
||||||
|
## Структура проекта
|
||||||
|
|
||||||
|
- `app/src/main/java/ru/ua1zbe/svxremote/MainActivity.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/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/test/java/ru/ua1zbe/svxremote/` — юнит-тесты протокола и рукопожатия
|
||||||
|
|
||||||
|
## Тесты
|
||||||
|
|
||||||
|
```bash
|
||||||
|
./gradlew :app:testDebugUnitTest
|
||||||
|
```
|
||||||
|
|
||||||
|
## Лицензия
|
||||||
|
|
||||||
|
Проект распространяется без ограничений (для радиолюбительских целей).
|
||||||
46
app/build.gradle.kts
Normal file
46
app/build.gradle.kts
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application")
|
||||||
|
id("org.jetbrains.kotlin.android")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
namespace = "ru.ua1zbe.svxremote"
|
||||||
|
compileSdk = 34
|
||||||
|
|
||||||
|
defaultConfig {
|
||||||
|
applicationId = "ru.ua1zbe.svxremote"
|
||||||
|
minSdk = 26
|
||||||
|
targetSdk = 34
|
||||||
|
versionCode = 1
|
||||||
|
versionName = "1.0"
|
||||||
|
}
|
||||||
|
|
||||||
|
buildTypes {
|
||||||
|
release {
|
||||||
|
isMinifyEnabled = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
compileOptions {
|
||||||
|
sourceCompatibility = JavaVersion.VERSION_17
|
||||||
|
targetCompatibility = JavaVersion.VERSION_17
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
kotlin {
|
||||||
|
compilerOptions {
|
||||||
|
jvmTarget.set(org.jetbrains.kotlin.gradle.dsl.JvmTarget.JVM_17)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencies {
|
||||||
|
testImplementation("junit:junit:4.13.2")
|
||||||
|
}
|
||||||
|
|
||||||
|
android {
|
||||||
|
testOptions {
|
||||||
|
unitTests.all {
|
||||||
|
it.maxHeapSize = "256m"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
31
app/src/main/AndroidManifest.xml
Normal file
31
app/src/main/AndroidManifest.xml
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
|
||||||
|
<uses-permission android:name="android.permission.INTERNET" />
|
||||||
|
<uses-permission android:name="android.permission.RECORD_AUDIO" />
|
||||||
|
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||||
|
|
||||||
|
<application
|
||||||
|
android:label="@string/app_name"
|
||||||
|
android:icon="@mipmap/ic_launcher"
|
||||||
|
android:usesCleartextTraffic="true"
|
||||||
|
android:supportsRtl="true"
|
||||||
|
android:theme="@style/Theme.SvxRemote">
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".SettingsActivity"
|
||||||
|
android:exported="false"
|
||||||
|
android:label="@string/settings_title" />
|
||||||
|
|
||||||
|
<activity
|
||||||
|
android:name=".MainActivity"
|
||||||
|
android:exported="true"
|
||||||
|
android:screenOrientation="portrait"
|
||||||
|
android:configChanges="orientation|screenSize|keyboardHidden">
|
||||||
|
<intent-filter>
|
||||||
|
<action android:name="android.intent.action.MAIN" />
|
||||||
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
|
</intent-filter>
|
||||||
|
</activity>
|
||||||
|
</application>
|
||||||
|
</manifest>
|
||||||
272
app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt
Normal file
272
app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package ru.ua1zbe.svxremote
|
||||||
|
|
||||||
|
import android.Manifest
|
||||||
|
import android.app.Activity
|
||||||
|
import android.content.Intent
|
||||||
|
import android.content.pm.PackageManager
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.os.Handler
|
||||||
|
import android.os.Looper
|
||||||
|
import android.util.Log
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.ImageButton
|
||||||
|
import android.widget.TextView
|
||||||
|
import android.widget.Toast
|
||||||
|
import ru.ua1zbe.svxremote.audio.OpusEngine
|
||||||
|
import ru.ua1zbe.svxremote.prefs.ConnectionSettings
|
||||||
|
import ru.ua1zbe.svxremote.prefs.Prefs
|
||||||
|
import ru.ua1zbe.svxremote.proto.MsgParser
|
||||||
|
import ru.ua1zbe.svxremote.proto.ReflectorClient
|
||||||
|
import ru.ua1zbe.svxremote.ui.LevelMeter
|
||||||
|
import ru.ua1zbe.svxremote.ui.PttButton
|
||||||
|
|
||||||
|
private const val REQ_MIC = 1001
|
||||||
|
|
||||||
|
class MainActivity : Activity(), ReflectorClient.Callbacks {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
private const val TAG = "SVXRemote"
|
||||||
|
}
|
||||||
|
|
||||||
|
private lateinit var statusChip: TextView
|
||||||
|
private lateinit var lblSubtitle: TextView
|
||||||
|
private lateinit var lblTalkerTitle: TextView
|
||||||
|
private lateinit var lblTalker: TextView
|
||||||
|
private lateinit var lblPttHint: TextView
|
||||||
|
private lateinit var lblTxState: TextView
|
||||||
|
private lateinit var btnConnect: Button
|
||||||
|
private lateinit var pttButton: PttButton
|
||||||
|
private lateinit var levelMeter: LevelMeter
|
||||||
|
private lateinit var btnSettings: ImageButton
|
||||||
|
|
||||||
|
private lateinit var engine: OpusEngine
|
||||||
|
private lateinit var client: ReflectorClient
|
||||||
|
private val mainHandler = Handler(Looper.getMainLooper())
|
||||||
|
|
||||||
|
private var connected = false
|
||||||
|
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
|
||||||
|
|
||||||
|
private val talkerTimeout = object : Runnable {
|
||||||
|
override fun run() {
|
||||||
|
clearTalker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_main)
|
||||||
|
|
||||||
|
statusChip = findViewById(R.id.status_chip)
|
||||||
|
lblSubtitle = findViewById(R.id.lbl_subtitle)
|
||||||
|
lblTalkerTitle = findViewById(R.id.lbl_talker_title)
|
||||||
|
lblTalker = findViewById(R.id.lbl_talker)
|
||||||
|
lblPttHint = findViewById(R.id.lbl_ptt_hint)
|
||||||
|
lblTxState = findViewById(R.id.lbl_tx_state)
|
||||||
|
btnConnect = findViewById(R.id.btn_connect)
|
||||||
|
pttButton = findViewById(R.id.ptt_button)
|
||||||
|
levelMeter = findViewById(R.id.level_meter)
|
||||||
|
btnSettings = findViewById(R.id.btn_settings)
|
||||||
|
|
||||||
|
engine = OpusEngine { source, level ->
|
||||||
|
levelMeter.setLevel(source, level)
|
||||||
|
}
|
||||||
|
client = ReflectorClient(this)
|
||||||
|
|
||||||
|
btnSettings.setOnClickListener {
|
||||||
|
startActivity(Intent(this, SettingsActivity::class.java))
|
||||||
|
}
|
||||||
|
|
||||||
|
btnConnect.setOnClickListener {
|
||||||
|
if (connected) {
|
||||||
|
client.disconnect()
|
||||||
|
} else {
|
||||||
|
settings = Prefs.load(this)
|
||||||
|
if (settings.host.isBlank() || settings.callsign.isBlank()) {
|
||||||
|
toast(getString(R.string.toast_need_settings))
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC)
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
client.connect(
|
||||||
|
ReflectorClient.Settings(
|
||||||
|
host = settings.host,
|
||||||
|
port = settings.port,
|
||||||
|
callsign = settings.callsign,
|
||||||
|
authKey = settings.authKey,
|
||||||
|
talkGroup = settings.talkGroup
|
||||||
|
)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pttButton.setListener(object : PttButton.Listener {
|
||||||
|
override fun onPttDown() {
|
||||||
|
if (!connected) {
|
||||||
|
toast(getString(R.string.ptt_disabled))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
|
||||||
|
toast(getString(R.string.perm_mic))
|
||||||
|
return
|
||||||
|
}
|
||||||
|
lblTxState.visibility = TextView.VISIBLE
|
||||||
|
lblPttHint.text = getString(R.string.ptt_talking)
|
||||||
|
engine.startCapture { packet ->
|
||||||
|
client.sendAudio(packet)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onPttUp() {
|
||||||
|
engine.stopCapture()
|
||||||
|
client.sendAudioFlush()
|
||||||
|
lblTxState.visibility = TextView.GONE
|
||||||
|
lblPttHint.text = getString(R.string.ptt_hint)
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
refreshUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onResume() {
|
||||||
|
super.onResume()
|
||||||
|
settings = Prefs.load(this)
|
||||||
|
refreshUi()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshUi() {
|
||||||
|
val host = settings.host
|
||||||
|
lblSubtitle.text = if (host.isBlank()) {
|
||||||
|
getString(R.string.settings_not_configured)
|
||||||
|
} else {
|
||||||
|
getString(R.string.server_subtitle, host, settings.port, settings.talkGroup)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Callbacks от ReflectorClient (фоновые потоки!) ----------
|
||||||
|
|
||||||
|
override fun onState(state: Int, detail: String?) {
|
||||||
|
Log.d(TAG, "state=$state detail=$detail")
|
||||||
|
mainHandler.post {
|
||||||
|
when (state) {
|
||||||
|
ReflectorClient.STATE_DISCONNECTED -> {
|
||||||
|
connected = false
|
||||||
|
updatePttEnabled()
|
||||||
|
statusChip.text = getString(R.string.status_disconnected)
|
||||||
|
btnConnect.text = getString(R.string.btn_connect)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorClient.STATE_CONNECTING,
|
||||||
|
ReflectorClient.STATE_TCP_CONNECTED,
|
||||||
|
ReflectorClient.STATE_WAIT_CHALLENGE,
|
||||||
|
ReflectorClient.STATE_WAIT_AUTH_OK,
|
||||||
|
ReflectorClient.STATE_WAIT_SERVER_INFO -> {
|
||||||
|
statusChip.text = getString(R.string.status_connecting)
|
||||||
|
btnConnect.text = getString(R.string.btn_disconnect)
|
||||||
|
connected = false
|
||||||
|
updatePttEnabled()
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorClient.STATE_READY -> {
|
||||||
|
connected = true
|
||||||
|
updatePttEnabled()
|
||||||
|
statusChip.text = getString(R.string.status_connected)
|
||||||
|
btnConnect.text = getString(R.string.btn_disconnect)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorClient.STATE_AUTH_FAILED -> {
|
||||||
|
connected = false
|
||||||
|
updatePttEnabled()
|
||||||
|
statusChip.text = getString(R.string.status_auth_failed)
|
||||||
|
btnConnect.text = getString(R.string.btn_connect)
|
||||||
|
toast(getString(R.string.status_error, detail ?: ""))
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorClient.STATE_ERROR -> {
|
||||||
|
connected = false
|
||||||
|
updatePttEnabled()
|
||||||
|
statusChip.text = getString(R.string.status_error, detail ?: "")
|
||||||
|
btnConnect.text = getString(R.string.btn_connect)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServerInfo(info: MsgParser.ServerInfo) {
|
||||||
|
mainHandler.post {
|
||||||
|
if (!info.codecs.contains("OPUS")) {
|
||||||
|
toast(getString(R.string.toast_codec_unsupported, info.codecs.joinToString(", ")))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTalkerStart(tg: Long, callsign: String) {
|
||||||
|
mainHandler.post {
|
||||||
|
lblTalkerTitle.text = getString(R.string.talker_now, callsign)
|
||||||
|
lblTalker.text = callsign
|
||||||
|
mainHandler.removeCallbacks(talkerTimeout)
|
||||||
|
mainHandler.postDelayed(talkerTimeout, 4000)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTalkerStop(tg: Long, callsign: String) {
|
||||||
|
mainHandler.post {
|
||||||
|
clearTalker()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNodeJoined(callsign: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onNodeLeft(callsign: String) {
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioPacket(packet: ByteArray, seq: Int) {
|
||||||
|
engine.onAudioPacket(packet)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onAudioFlush() {
|
||||||
|
engine.onAudioFlush()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onError(detail: String) {
|
||||||
|
mainHandler.post {
|
||||||
|
toast(detail)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun clearTalker() {
|
||||||
|
lblTalkerTitle.text = getString(R.string.talker_none)
|
||||||
|
lblTalker.text = getString(R.string.talker_none)
|
||||||
|
mainHandler.removeCallbacks(talkerTimeout)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun updatePttEnabled() {
|
||||||
|
pttButton.enabled2 = connected
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) {
|
||||||
|
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDestroy() {
|
||||||
|
super.onDestroy()
|
||||||
|
mainHandler.removeCallbacks(talkerTimeout)
|
||||||
|
client.disconnect()
|
||||||
|
engine.releaseAll()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onRequestPermissionsResult(
|
||||||
|
requestCode: Int,
|
||||||
|
permissions: Array<out String>,
|
||||||
|
grantResults: IntArray
|
||||||
|
) {
|
||||||
|
super.onRequestPermissionsResult(requestCode, permissions, grantResults)
|
||||||
|
if (requestCode == REQ_MIC &&
|
||||||
|
(grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED)
|
||||||
|
) {
|
||||||
|
toast(getString(R.string.perm_mic))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
66
app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt
Normal file
66
app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
package ru.ua1zbe.svxremote
|
||||||
|
|
||||||
|
import android.app.Activity
|
||||||
|
import android.os.Bundle
|
||||||
|
import android.widget.Button
|
||||||
|
import android.widget.EditText
|
||||||
|
import android.widget.Toast
|
||||||
|
import ru.ua1zbe.svxremote.prefs.ConnectionSettings
|
||||||
|
import ru.ua1zbe.svxremote.prefs.Prefs
|
||||||
|
|
||||||
|
class SettingsActivity : Activity() {
|
||||||
|
|
||||||
|
private lateinit var inputHost: EditText
|
||||||
|
private lateinit var inputPort: EditText
|
||||||
|
private lateinit var inputCallsign: EditText
|
||||||
|
private lateinit var inputAuthKey: EditText
|
||||||
|
private lateinit var inputTg: EditText
|
||||||
|
|
||||||
|
override fun onCreate(savedInstanceState: Bundle?) {
|
||||||
|
super.onCreate(savedInstanceState)
|
||||||
|
setContentView(R.layout.activity_settings)
|
||||||
|
|
||||||
|
inputHost = findViewById(R.id.input_host)
|
||||||
|
inputPort = findViewById(R.id.input_port)
|
||||||
|
inputCallsign = findViewById(R.id.input_callsign)
|
||||||
|
inputAuthKey = findViewById(R.id.input_auth_key)
|
||||||
|
inputTg = findViewById(R.id.input_tg)
|
||||||
|
|
||||||
|
val s = Prefs.load(this)
|
||||||
|
inputHost.setText(s.host)
|
||||||
|
inputPort.setText(s.port.toString())
|
||||||
|
inputCallsign.setText(s.callsign)
|
||||||
|
inputAuthKey.setText(s.authKey)
|
||||||
|
inputTg.setText(s.talkGroup.toString())
|
||||||
|
|
||||||
|
findViewById<Button>(R.id.btn_save).setOnClickListener {
|
||||||
|
val host = inputHost.text.toString().trim()
|
||||||
|
val port = inputPort.text.toString().trim().toIntOrNull() ?: 5300
|
||||||
|
val callsign = inputCallsign.text.toString().trim()
|
||||||
|
val authKey = inputAuthKey.text.toString()
|
||||||
|
val tg = inputTg.text.toString().trim().toLongOrNull() ?: 11
|
||||||
|
|
||||||
|
if (host.isBlank() || callsign.isBlank()) {
|
||||||
|
toast(getString(R.string.toast_settings_empty))
|
||||||
|
return@setOnClickListener
|
||||||
|
}
|
||||||
|
|
||||||
|
Prefs.save(
|
||||||
|
this,
|
||||||
|
ConnectionSettings(
|
||||||
|
host = host,
|
||||||
|
port = port.coerceIn(1, 65535),
|
||||||
|
callsign = callsign,
|
||||||
|
authKey = authKey,
|
||||||
|
talkGroup = tg.coerceAtLeast(0)
|
||||||
|
)
|
||||||
|
)
|
||||||
|
toast(getString(R.string.saved))
|
||||||
|
finish()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun toast(msg: String) {
|
||||||
|
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
|
||||||
|
}
|
||||||
|
}
|
||||||
277
app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt
Normal file
277
app/src/main/java/ru/ua1zbe/svxremote/audio/OpusEngine.kt
Normal file
@@ -0,0 +1,277 @@
|
|||||||
|
package ru.ua1zbe.svxremote.audio
|
||||||
|
|
||||||
|
import android.media.AudioFormat
|
||||||
|
import android.media.AudioManager
|
||||||
|
import android.media.AudioRecord
|
||||||
|
import android.media.AudioTrack
|
||||||
|
import android.media.MediaCodec
|
||||||
|
import android.media.MediaCodecInfo
|
||||||
|
import android.media.MediaFormat
|
||||||
|
import android.media.MediaRecorder
|
||||||
|
import android.os.Build
|
||||||
|
import java.nio.ByteBuffer
|
||||||
|
import java.nio.ByteOrder
|
||||||
|
import kotlin.math.abs
|
||||||
|
import kotlin.math.log10
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Opus-кодек через MediaCodec + захват микрофона + воспроизведение.
|
||||||
|
* SVXLink: моно, 8 кГц, 16 бит PCM, кадры 20 мс. Поток уровня сигнала 0..1.
|
||||||
|
*/
|
||||||
|
class OpusEngine(
|
||||||
|
private val onLevel: (source: Int, level: Float) -> Unit
|
||||||
|
) {
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val SAMPLE_RATE = 8000
|
||||||
|
const val CHANNELS = 1
|
||||||
|
const val FRAME_SIZE = 160 // 20 мс в 8 кГц
|
||||||
|
const val BIT_RATE = 20000
|
||||||
|
|
||||||
|
const val SRC_MIC = 0
|
||||||
|
const val SRC_SPEAKER = 1
|
||||||
|
}
|
||||||
|
|
||||||
|
private var encoder: MediaCodec? = null
|
||||||
|
private var decoder: MediaCodec? = null
|
||||||
|
private var recorder: AudioRecord? = null
|
||||||
|
private var player: AudioTrack? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var encoding = false
|
||||||
|
private var recThread: Thread? = null
|
||||||
|
|
||||||
|
private val encodeBufferInfo = MediaCodec.BufferInfo()
|
||||||
|
private val decodeBufferInfo = MediaCodec.BufferInfo()
|
||||||
|
|
||||||
|
// ---------- Воспроизведение (RX) ----------
|
||||||
|
|
||||||
|
fun startDecoder() {
|
||||||
|
if (decoder != null) return
|
||||||
|
val format = MediaFormat()
|
||||||
|
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||||
|
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, SAMPLE_RATE)
|
||||||
|
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
|
||||||
|
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
|
||||||
|
if (Build.VERSION.SDK_INT >= 29) {
|
||||||
|
// Не звонить GSM — приоритет медиа
|
||||||
|
}
|
||||||
|
decoder = try {
|
||||||
|
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
|
||||||
|
it.configure(format, null, null, 0)
|
||||||
|
it.start()
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (decoder == null) return
|
||||||
|
val minBuf = AudioTrack.getMinBufferSize(
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_OUT_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT
|
||||||
|
)
|
||||||
|
player = AudioTrack(
|
||||||
|
AudioManager.STREAM_MUSIC,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_OUT_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
maxOf(minBuf, FRAME_SIZE * 2 * 8),
|
||||||
|
AudioTrack.MODE_STREAM
|
||||||
|
).also { it.play() }
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Подача одного Opus-пакета на декодер. */
|
||||||
|
fun onAudioPacket(packet: ByteArray) {
|
||||||
|
val dec = decoder ?: return
|
||||||
|
val track = player ?: return
|
||||||
|
try {
|
||||||
|
val inIdx = dec.dequeueInputBuffer(10000)
|
||||||
|
if (inIdx >= 0) {
|
||||||
|
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)
|
||||||
|
while (outIdx >= 0) {
|
||||||
|
if (decodeBufferInfo.size > 0) {
|
||||||
|
val outBuf = ByteBuffer.allocate(decodeBufferInfo.size)
|
||||||
|
outBuf.order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
dec.getOutputBuffer(outIdx)?.also { src ->
|
||||||
|
src.position(decodeBufferInfo.offset)
|
||||||
|
src.limit(decodeBufferInfo.offset + decodeBufferInfo.size)
|
||||||
|
outBuf.put(src)
|
||||||
|
}
|
||||||
|
val pcm = outBuf.array()
|
||||||
|
track.write(pcm, 0, pcm.size)
|
||||||
|
onLevel(SRC_SPEAKER, pcmLevel(pcm))
|
||||||
|
}
|
||||||
|
dec.releaseOutputBuffer(outIdx, false)
|
||||||
|
outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
|
||||||
|
}
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun onAudioFlush() {
|
||||||
|
try {
|
||||||
|
decoder?.flush()
|
||||||
|
player?.flush()
|
||||||
|
onLevel(SRC_SPEAKER, 0f)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Захват (TX) ----------
|
||||||
|
|
||||||
|
/** Начать PTT: запустить запись и кодирование. */
|
||||||
|
fun startCapture(onPacket: (ByteArray) -> Unit) {
|
||||||
|
if (encoding) return
|
||||||
|
val enc = createEncoder()
|
||||||
|
encoder = enc
|
||||||
|
val rec = try {
|
||||||
|
AudioRecord(
|
||||||
|
MediaRecorder.AudioSource.MIC,
|
||||||
|
SAMPLE_RATE,
|
||||||
|
AudioFormat.CHANNEL_IN_MONO,
|
||||||
|
AudioFormat.ENCODING_PCM_16BIT,
|
||||||
|
FRAME_SIZE * 2 * 10
|
||||||
|
)
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
if (enc == null || rec == null) {
|
||||||
|
stopCapture()
|
||||||
|
return
|
||||||
|
}
|
||||||
|
recorder = rec
|
||||||
|
rec.startRecording()
|
||||||
|
encoding = true
|
||||||
|
val t = Thread({
|
||||||
|
captureLoop(enc, rec, onPacket)
|
||||||
|
}, "svx-capture")
|
||||||
|
t.isDaemon = true
|
||||||
|
recThread = t
|
||||||
|
t.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun createEncoder(): MediaCodec? {
|
||||||
|
return try {
|
||||||
|
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, BIT_RATE)
|
||||||
|
fmt.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, FRAME_SIZE * 2)
|
||||||
|
val codec = MediaCodec.createEncoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS)
|
||||||
|
codec.configure(fmt, null, null, MediaCodec.CONFIGURE_FLAG_ENCODE)
|
||||||
|
codec.start()
|
||||||
|
codec
|
||||||
|
} catch (e: Exception) {
|
||||||
|
null
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun captureLoop(enc: MediaCodec, rec: AudioRecord, onPacket: (ByteArray) -> Unit) {
|
||||||
|
val pcmBuf = ByteBuffer.allocateDirect(FRAME_SIZE * 2).order(ByteOrder.LITTLE_ENDIAN)
|
||||||
|
val micLevel = FloatArray(1)
|
||||||
|
while (encoding) {
|
||||||
|
val n = rec.read(pcmBuf, FRAME_SIZE * 2) // 160 сэмплов = 20 мс
|
||||||
|
if (n != FRAME_SIZE * 2) continue
|
||||||
|
pcmBuf.position(0)
|
||||||
|
pcmBuf.limit(n)
|
||||||
|
val pcm = ByteArray(n)
|
||||||
|
pcmBuf.get(pcm)
|
||||||
|
micLevel[0] = pcmLevel(pcm)
|
||||||
|
onLevel(SRC_MIC, micLevel[0])
|
||||||
|
try {
|
||||||
|
val inIdx = enc.dequeueInputBuffer(10000)
|
||||||
|
if (inIdx >= 0) {
|
||||||
|
val buf = enc.getInputBuffer(inIdx) ?: continue
|
||||||
|
buf.clear()
|
||||||
|
buf.put(pcm)
|
||||||
|
enc.queueInputBuffer(inIdx, 0, n, 0, 0)
|
||||||
|
}
|
||||||
|
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() {
|
||||||
|
encoding = false
|
||||||
|
try {
|
||||||
|
recorder?.stop()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
recorder?.release()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
recorder = null
|
||||||
|
try {
|
||||||
|
encoder?.stop()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
encoder?.release()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
encoder = null
|
||||||
|
recThread?.interrupt()
|
||||||
|
recThread = null
|
||||||
|
onLevel(SRC_MIC, 0f)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun releaseAll() {
|
||||||
|
stopCapture()
|
||||||
|
try {
|
||||||
|
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)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** RMS-уровень PCM16 → 0..1 (полное отклонение ≈ 0 dBFS). */
|
||||||
|
private fun pcmLevel(pcm: ByteArray): Float {
|
||||||
|
var sum = 0.0
|
||||||
|
for (i in 0 until pcm.size - 1 step 2) {
|
||||||
|
val s = ((pcm[i + 1].toInt() shl 8) or (pcm[i].toInt() and 0xFF)).toShort()
|
||||||
|
sum += abs(s.toDouble())
|
||||||
|
}
|
||||||
|
val avg = sum / (pcm.size / 2)
|
||||||
|
if (avg <= 0.0) return 0f
|
||||||
|
val rms = avg
|
||||||
|
// среднее |x| ≈ 0.79*RMS; нормируем к 1.0 на уровне ~ -12 dBFS, чтобы шкала не была пустой
|
||||||
|
val db = 20.0 * log10(rms / 32768.0)
|
||||||
|
val norm = (db + 60.0) / 48.0
|
||||||
|
return norm.toFloat().coerceIn(0f, 1f)
|
||||||
|
}
|
||||||
|
}
|
||||||
38
app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt
Normal file
38
app/src/main/java/ru/ua1zbe/svxremote/prefs/Prefs.kt
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
package ru.ua1zbe.svxremote.prefs
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.content.SharedPreferences
|
||||||
|
|
||||||
|
data class ConnectionSettings(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val callsign: String,
|
||||||
|
val authKey: String,
|
||||||
|
val talkGroup: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
object Prefs {
|
||||||
|
private const val PREFS_NAME = "svxremote"
|
||||||
|
|
||||||
|
fun load(context: Context): ConnectionSettings {
|
||||||
|
val sp = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
return ConnectionSettings(
|
||||||
|
host = sp.getString("host", "") ?: "",
|
||||||
|
port = sp.getInt("port", 5300),
|
||||||
|
callsign = sp.getString("callsign", "") ?: "",
|
||||||
|
authKey = sp.getString("auth_key", "") ?: "",
|
||||||
|
talkGroup = sp.getLong("tg", 11)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun save(context: Context, s: ConnectionSettings) {
|
||||||
|
val sp: SharedPreferences = context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
|
||||||
|
sp.edit()
|
||||||
|
.putString("host", s.host.trim())
|
||||||
|
.putInt("port", s.port)
|
||||||
|
.putString("callsign", s.callsign.trim())
|
||||||
|
.putString("auth_key", s.authKey)
|
||||||
|
.putLong("tg", s.talkGroup)
|
||||||
|
.apply()
|
||||||
|
}
|
||||||
|
}
|
||||||
517
app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt
Normal file
517
app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorClient.kt
Normal file
@@ -0,0 +1,517 @@
|
|||||||
|
package ru.ua1zbe.svxremote.proto
|
||||||
|
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.InetAddress
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.Socket
|
||||||
|
import java.net.SocketTimeoutException
|
||||||
|
import java.util.concurrent.atomic.AtomicBoolean
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP).
|
||||||
|
*
|
||||||
|
* TCP-поток: handshake + управление. UDP-сокет: аудио (порт совпадает с TCP-портом сервера).
|
||||||
|
*/
|
||||||
|
class ReflectorClient(
|
||||||
|
private val callbacks: Callbacks
|
||||||
|
) {
|
||||||
|
|
||||||
|
interface Callbacks {
|
||||||
|
fun onState(state: Int, detail: String? = null)
|
||||||
|
fun onServerInfo(info: MsgParser.ServerInfo)
|
||||||
|
fun onTalkerStart(tg: Long, callsign: String)
|
||||||
|
fun onTalkerStop(tg: Long, callsign: String)
|
||||||
|
fun onNodeJoined(callsign: String)
|
||||||
|
fun onNodeLeft(callsign: String)
|
||||||
|
/** Входящий аудиопакет Opus. */
|
||||||
|
fun onAudioPacket(packet: ByteArray, seq: Int)
|
||||||
|
fun onAudioFlush()
|
||||||
|
fun onError(detail: String)
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
const val STATE_DISCONNECTED = 0
|
||||||
|
const val STATE_CONNECTING = 1
|
||||||
|
const val STATE_TCP_CONNECTED = 2
|
||||||
|
const val STATE_WAIT_CHALLENGE = 3
|
||||||
|
const val STATE_WAIT_AUTH_OK = 4
|
||||||
|
const val STATE_WAIT_SERVER_INFO = 5
|
||||||
|
const val STATE_READY = 6
|
||||||
|
const val STATE_AUTH_FAILED = 7
|
||||||
|
const val STATE_ERROR = 8
|
||||||
|
|
||||||
|
const val TCP_CONNECT_TIMEOUT_MS = 8000
|
||||||
|
const val TCP_HEARTBEAT_INTERVAL_MS = 2000L
|
||||||
|
const val UDP_HEARTBEAT_INTERVAL_MS = 4000L
|
||||||
|
const val RECONNECT_DELAY_MS = 5000L
|
||||||
|
}
|
||||||
|
|
||||||
|
data class Settings(
|
||||||
|
val host: String,
|
||||||
|
val port: Int,
|
||||||
|
val callsign: String,
|
||||||
|
val authKey: String,
|
||||||
|
val talkGroup: Long
|
||||||
|
)
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var settings: Settings? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var state = STATE_DISCONNECTED
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var clientId: Int = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var serverCodecs: List<String> = emptyList()
|
||||||
|
|
||||||
|
private val running = AtomicBoolean(false)
|
||||||
|
private val manualDisconnect = AtomicBoolean(false)
|
||||||
|
|
||||||
|
/** Фатальная ошибка (например, отказ в авторизации) — автопереподключение не выполняется. */
|
||||||
|
@Volatile
|
||||||
|
private var fatal = false
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var tcpSocket: Socket? = null
|
||||||
|
private var udpSocket: DatagramSocket? = null
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var seqCounter = 0
|
||||||
|
|
||||||
|
private val stopEvent = Object()
|
||||||
|
private val tcpLock = Object()
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
private var resolvedAddr: InetAddress? = null
|
||||||
|
|
||||||
|
fun isRunning(): Boolean = running.get()
|
||||||
|
|
||||||
|
fun serverCodecs(): List<String> = serverCodecs
|
||||||
|
|
||||||
|
@Synchronized
|
||||||
|
fun connect(settings: Settings) {
|
||||||
|
if (running.get()) return
|
||||||
|
this.settings = settings
|
||||||
|
manualDisconnect.set(false)
|
||||||
|
fatal = false
|
||||||
|
running.set(true)
|
||||||
|
state = STATE_DISCONNECTED
|
||||||
|
seqCounter = 0
|
||||||
|
callbacks.onState(STATE_DISCONNECTED)
|
||||||
|
val t = Thread({
|
||||||
|
try {
|
||||||
|
reconnectLoop()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
running.set(false)
|
||||||
|
callbacks.onState(STATE_ERROR, e.message ?: "internal error")
|
||||||
|
}
|
||||||
|
}, "svx-reconnect")
|
||||||
|
t.isDaemon = true
|
||||||
|
t.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Полный останов (ручное отключение). */
|
||||||
|
@Synchronized
|
||||||
|
fun disconnect() {
|
||||||
|
manualDisconnect.set(true)
|
||||||
|
running.set(false)
|
||||||
|
fatal = false
|
||||||
|
synchronized(stopEvent) { stopEvent.notifyAll() }
|
||||||
|
abortSockets()
|
||||||
|
callbacks.onState(STATE_DISCONNECTED)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Закрыть сокеты, но оставить поток переподключения живым. */
|
||||||
|
private fun abortSockets() {
|
||||||
|
try {
|
||||||
|
tcpSocket?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
udpSocket?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fun sendAudio(packet: ByteArray) {
|
||||||
|
val sock = udpSocket
|
||||||
|
val s = settings
|
||||||
|
val id = clientId
|
||||||
|
if (sock == null || s == null || id == 0) return
|
||||||
|
val data = MsgBuilder.udpAudio(id, nextSeq(), packet)
|
||||||
|
udpSend(sock, s, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Конец передачи. */
|
||||||
|
fun sendAudioFlush() {
|
||||||
|
val sock = udpSocket
|
||||||
|
val s = settings
|
||||||
|
val id = clientId
|
||||||
|
if (sock == null || s == null || id == 0) return
|
||||||
|
udpSend(sock, s, MsgBuilder.udpFlush(id, nextSeq()))
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun nextSeq(): Int {
|
||||||
|
val v = seqCounter
|
||||||
|
seqCounter = (seqCounter + 1) and 0xFFFF
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun reconnectLoop() {
|
||||||
|
while (running.get()) {
|
||||||
|
val ok = tryConnectOnce()
|
||||||
|
if (!ok) {
|
||||||
|
if (!running.get()) return
|
||||||
|
waitForReconnect()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForReconnect() {
|
||||||
|
val deadline = System.currentTimeMillis() + RECONNECT_DELAY_MS
|
||||||
|
synchronized(stopEvent) {
|
||||||
|
while (running.get() && System.currentTimeMillis() < deadline) {
|
||||||
|
try {
|
||||||
|
val ms = deadline - System.currentTimeMillis()
|
||||||
|
if (ms <= 0) break
|
||||||
|
stopEvent.wait(ms)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun tryConnectOnce(): Boolean {
|
||||||
|
if (fatal) return false
|
||||||
|
val s = settings ?: return false
|
||||||
|
callbacks.onState(STATE_CONNECTING)
|
||||||
|
val socket = Socket()
|
||||||
|
return try {
|
||||||
|
socket.tcpNoDelay = true
|
||||||
|
socket.connect(InetSocketAddress(s.host, s.port), TCP_CONNECT_TIMEOUT_MS)
|
||||||
|
if (!running.get()) {
|
||||||
|
// Отключились, пока шло соединение
|
||||||
|
socket.close()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
resolvedAddr = InetAddress.getByName(s.host)
|
||||||
|
tcpSocket = socket
|
||||||
|
state = STATE_TCP_CONNECTED
|
||||||
|
callbacks.onState(STATE_TCP_CONNECTED)
|
||||||
|
startTcpSenderThread(socket, s)
|
||||||
|
runTcpLoop(socket, s)
|
||||||
|
true
|
||||||
|
} catch (e: Exception) {
|
||||||
|
try {
|
||||||
|
socket.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
callbacks.onState(STATE_ERROR, e.message ?: "connect failed")
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun startTcpSenderThread(socket: Socket, s: Settings) {
|
||||||
|
val t = Thread({
|
||||||
|
try {
|
||||||
|
// Версия протокола — первым сообщением
|
||||||
|
sendTcp(socket, MsgBuilder.protoVer())
|
||||||
|
runHeartbeats(socket)
|
||||||
|
} catch (_: Exception) {
|
||||||
|
// основной поток обработает закрытие
|
||||||
|
}
|
||||||
|
}, "svx-tcp-send")
|
||||||
|
t.isDaemon = true
|
||||||
|
t.start()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runHeartbeats(socket: Socket) {
|
||||||
|
// Heartbeat только после завершения handshake (иначе сервер спутает
|
||||||
|
// его с ожидаемым кадром, например с AuthResponse)
|
||||||
|
while (running.get() && !socket.isClosed && state < STATE_READY) {
|
||||||
|
waitForStop(50)
|
||||||
|
}
|
||||||
|
var lastUdpHeartbeat = 0L
|
||||||
|
while (running.get() && !socket.isClosed) {
|
||||||
|
sendTcp(socket, MsgBuilder.heartbeat())
|
||||||
|
val now = System.currentTimeMillis()
|
||||||
|
if (now - lastUdpHeartbeat > UDP_HEARTBEAT_INTERVAL_MS) {
|
||||||
|
lastUdpHeartbeat = now
|
||||||
|
val udp = udpSocket
|
||||||
|
val s = settings
|
||||||
|
val id = clientId
|
||||||
|
if (udp != null && s != null && id != 0) {
|
||||||
|
udpSend(udp, s, MsgBuilder.udpHeartbeat(id, nextSeq()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
waitForStop(TCP_HEARTBEAT_INTERVAL_MS)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runTcpLoop(socket: Socket, s: Settings): Boolean {
|
||||||
|
state = STATE_WAIT_CHALLENGE
|
||||||
|
callbacks.onState(STATE_WAIT_CHALLENGE)
|
||||||
|
val input = socket.getInputStream()
|
||||||
|
val readBuf = ByteArray(8192)
|
||||||
|
val frameBuf = ByteArray(1 shl 20)
|
||||||
|
var frameLen = 0
|
||||||
|
|
||||||
|
while (running.get() && !socket.isClosed) {
|
||||||
|
val n = try {
|
||||||
|
input.read(readBuf)
|
||||||
|
} catch (e: SocketTimeoutException) {
|
||||||
|
continue
|
||||||
|
} catch (e: Exception) {
|
||||||
|
if (running.get()) onTcpBreak()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (n <= 0) {
|
||||||
|
if (running.get()) onTcpBreak()
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
if (frameLen + n > frameBuf.size) {
|
||||||
|
return protocolError("Кадр протокола слишком большой")
|
||||||
|
}
|
||||||
|
System.arraycopy(readBuf, 0, frameBuf, frameLen, n)
|
||||||
|
frameLen += n
|
||||||
|
|
||||||
|
var parsed: Int
|
||||||
|
do {
|
||||||
|
parsed = parseFrames(frameBuf, frameLen, socket, s)
|
||||||
|
if (parsed > 0) {
|
||||||
|
val rest = frameLen - parsed
|
||||||
|
System.arraycopy(frameBuf, parsed, frameBuf, 0, rest)
|
||||||
|
frameLen = rest
|
||||||
|
}
|
||||||
|
} while (parsed > 0 && frameLen > 0)
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Разбирает все полные кадры из буфера; возвращает число потреблённых байт
|
||||||
|
* (0 — нужны ещё данные). Внутри обрабатывает сообщения.
|
||||||
|
*/
|
||||||
|
private fun parseFrames(buf: ByteArray, len: Int, socket: Socket, s: Settings): Int {
|
||||||
|
var pos = 0
|
||||||
|
while (true) {
|
||||||
|
if (len - pos < 4) return pos
|
||||||
|
var payloadLen = 0L
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
payloadLen = (payloadLen shl 8) or (buf[pos + i].toLong() and 0xFF)
|
||||||
|
}
|
||||||
|
if (payloadLen > (1 shl 20)) throw IllegalArgumentException("frame too large")
|
||||||
|
if (len - pos - 4 < payloadLen) return pos // ждём остаток кадра
|
||||||
|
pos += 4
|
||||||
|
val payload = buf.copyOfRange(pos, pos + payloadLen.toInt())
|
||||||
|
pos += payloadLen.toInt()
|
||||||
|
val msg = RcMessage.fromPayload(payload)
|
||||||
|
if (!handleMessage(msg, socket, s)) return -1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleMessage(msg: RcMessage, socket: Socket, s: Settings): Boolean {
|
||||||
|
try {
|
||||||
|
when (msg.type) {
|
||||||
|
ReflectorProtocol.MSG_HEARTBEAT -> Unit
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_PROTO_VER_DOWNGRADE -> {
|
||||||
|
stopFatal("Сервер требует протокол ниже v2")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_AUTH_CHALLENGE -> {
|
||||||
|
// Challenge — это vector<uint8_t>: [u16 длина][20 байт]
|
||||||
|
val r = BinReader(msg.fields)
|
||||||
|
val challenge = r.bytes(r.u16())
|
||||||
|
if (challenge.size != ReflectorProtocol.AUTH_CHALLENGE_LENGTH) {
|
||||||
|
stopFatal("Неверный challenge от сервера")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
state = STATE_WAIT_AUTH_OK
|
||||||
|
callbacks.onState(STATE_WAIT_AUTH_OK)
|
||||||
|
// Рефлектор принимает позывной только в верхнем регистре
|
||||||
|
sendTcp(socket, MsgBuilder.authResponse(s.callsign.uppercase(), s.authKey, challenge))
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_AUTH_OK -> {
|
||||||
|
state = STATE_WAIT_SERVER_INFO
|
||||||
|
callbacks.onState(STATE_WAIT_SERVER_INFO)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_SERVER_INFO -> {
|
||||||
|
val info = MsgParser.serverInfo(msg.fields)
|
||||||
|
clientId = info.clientId
|
||||||
|
serverCodecs = info.codecs
|
||||||
|
callbacks.onServerInfo(info)
|
||||||
|
state = STATE_READY
|
||||||
|
callbacks.onState(STATE_READY)
|
||||||
|
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
|
||||||
|
openUdp(s)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_NODE_JOINED -> {
|
||||||
|
val cs = parseSimpleString(msg.fields)
|
||||||
|
callbacks.onNodeJoined(cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_NODE_LEFT -> {
|
||||||
|
val cs = parseSimpleString(msg.fields)
|
||||||
|
callbacks.onNodeLeft(cs)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_TALKER_START -> {
|
||||||
|
val t = MsgParser.talker(msg.fields)
|
||||||
|
callbacks.onTalkerStart(t.tg, t.callsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_TALKER_STOP -> {
|
||||||
|
val t = MsgParser.talker(msg.fields)
|
||||||
|
callbacks.onTalkerStop(t.tg, t.callsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
ReflectorProtocol.MSG_ERROR -> {
|
||||||
|
val err = MsgParser.errorMessage(msg.fields)
|
||||||
|
if (state < STATE_READY) {
|
||||||
|
stopFatal(err.ifEmpty { "Access denied" })
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
callbacks.onError(err)
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> Unit // неизвестные сообщения игнорируются
|
||||||
|
}
|
||||||
|
} catch (e: Exception) {
|
||||||
|
stopFatal("Ошибка протокола: ${e.message}")
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
return true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun parseSimpleString(fields: ByteArray): String {
|
||||||
|
return try {
|
||||||
|
BinReader(fields).str().ifEmpty { "?" }
|
||||||
|
} catch (_: Exception) {
|
||||||
|
"?"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun protocolError(detail: String): Boolean {
|
||||||
|
stopFatal(detail)
|
||||||
|
return false
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun onTcpBreak() {
|
||||||
|
if (!manualDisconnect.get()) {
|
||||||
|
callbacks.onState(STATE_ERROR, "Соединение потеряно")
|
||||||
|
}
|
||||||
|
abortSockets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopFatal(detail: String) {
|
||||||
|
fatal = true
|
||||||
|
running.set(false)
|
||||||
|
callbacks.onState(STATE_AUTH_FAILED, detail)
|
||||||
|
abortSockets()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun openUdp(s: Settings) {
|
||||||
|
try {
|
||||||
|
stopUdp()
|
||||||
|
val ds = DatagramSocket()
|
||||||
|
udpSocket = ds
|
||||||
|
// Первый пакет регистрирует наш UDP-адрес у сервера
|
||||||
|
val addr = resolvedAddr ?: InetAddress.getByName(s.host)
|
||||||
|
sendRawUdp(ds, addr, s.port, MsgBuilder.udpHeartbeat(clientId, nextSeq()))
|
||||||
|
val t = Thread({ runUdpLoop(ds) }, "svx-udp")
|
||||||
|
t.isDaemon = true
|
||||||
|
t.start()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callbacks.onError("UDP: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun stopUdp() {
|
||||||
|
try {
|
||||||
|
udpSocket?.close()
|
||||||
|
} catch (_: Exception) {
|
||||||
|
}
|
||||||
|
udpSocket = null
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun runUdpLoop(ds: DatagramSocket) {
|
||||||
|
val buf = ByteArray(8192)
|
||||||
|
while (running.get() && !ds.isClosed) {
|
||||||
|
try {
|
||||||
|
val p = DatagramPacket(buf, buf.size)
|
||||||
|
ds.receive(p)
|
||||||
|
val data = buf.copyOfRange(0, p.length)
|
||||||
|
handleUdp(data)
|
||||||
|
} catch (e: SocketTimeoutException) {
|
||||||
|
// ждём дальше
|
||||||
|
} catch (_: Exception) {
|
||||||
|
if (!running.get() || ds.isClosed) return
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun handleUdp(data: ByteArray) {
|
||||||
|
if (data.size < 6) return
|
||||||
|
val r = BinReader(data)
|
||||||
|
val type = r.u16()
|
||||||
|
r.u16() // client id
|
||||||
|
val seq = r.u16()
|
||||||
|
val payload = data.copyOfRange(6, data.size)
|
||||||
|
when (type) {
|
||||||
|
ReflectorProtocol.MSG_UDP_HEARTBEAT -> Unit
|
||||||
|
ReflectorProtocol.MSG_UDP_AUDIO -> {
|
||||||
|
val audio = MsgParser.udpAudio(payload)
|
||||||
|
if (audio != null && audio.isNotEmpty()) {
|
||||||
|
callbacks.onAudioPacket(audio, seq)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> callbacks.onAudioFlush()
|
||||||
|
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendTcp(socket: Socket, msg: RcMessage) {
|
||||||
|
synchronized(tcpLock) {
|
||||||
|
if (socket.isClosed) return
|
||||||
|
val frame = msg.toFrame()
|
||||||
|
socket.getOutputStream().write(frame, 0, frame.size)
|
||||||
|
socket.getOutputStream().flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun udpSend(sock: DatagramSocket, s: Settings, data: ByteArray) {
|
||||||
|
val addr = resolvedAddr ?: run {
|
||||||
|
val a = InetAddress.getByName(s.host)
|
||||||
|
resolvedAddr = a
|
||||||
|
a
|
||||||
|
}
|
||||||
|
sendRawUdp(sock, addr, s.port, data)
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun sendRawUdp(sock: DatagramSocket, addr: InetAddress, port: Int, data: ByteArray) {
|
||||||
|
if (sock.isClosed) return
|
||||||
|
try {
|
||||||
|
sock.send(DatagramPacket(data, data.size, addr, port))
|
||||||
|
} catch (e: Exception) {
|
||||||
|
callbacks.onError("UDP send: ${e.message}")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun waitForStop(ms: Long) {
|
||||||
|
synchronized(stopEvent) {
|
||||||
|
try {
|
||||||
|
stopEvent.wait(ms)
|
||||||
|
} catch (_: InterruptedException) {
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
272
app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt
Normal file
272
app/src/main/java/ru/ua1zbe/svxremote/proto/ReflectorProtocol.kt
Normal file
@@ -0,0 +1,272 @@
|
|||||||
|
package ru.ua1zbe.svxremote.proto
|
||||||
|
|
||||||
|
import java.io.ByteArrayOutputStream
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Wire format SVXReflector (svxreflector, TCP-порт 5300):
|
||||||
|
* - TCP-кадр: [u32 BE длина][payload]
|
||||||
|
* - Сообщение: [u16 BE тип] [поля]
|
||||||
|
* - Все числа — big-endian, строки = [u16 BE длина][UTF-8],
|
||||||
|
* коллекции = [u16 BE количество][элементы]
|
||||||
|
* - UDP-датаграмма: [u16 BE тип][u16 BE client_id][u16 BE seq][payload]
|
||||||
|
*
|
||||||
|
* Спецификация из исходников svxlink (src/svxlink/reflector).
|
||||||
|
*/
|
||||||
|
object ReflectorProtocol {
|
||||||
|
|
||||||
|
const val TCP_DEFAULT_PORT = 5300
|
||||||
|
|
||||||
|
// Типы TCP-сообщений
|
||||||
|
const val MSG_HEARTBEAT = 1
|
||||||
|
const val MSG_PROTO_VER = 5
|
||||||
|
const val MSG_PROTO_VER_DOWNGRADE = 6
|
||||||
|
const val MSG_AUTH_CHALLENGE = 10
|
||||||
|
const val MSG_AUTH_RESPONSE = 11
|
||||||
|
const val MSG_AUTH_OK = 12
|
||||||
|
const val MSG_ERROR = 13
|
||||||
|
const val MSG_SERVER_INFO = 100
|
||||||
|
const val MSG_NODE_LIST = 101
|
||||||
|
const val MSG_NODE_JOINED = 102
|
||||||
|
const val MSG_NODE_LEFT = 103
|
||||||
|
const val MSG_TALKER_START = 104
|
||||||
|
const val MSG_TALKER_STOP = 105
|
||||||
|
const val MSG_SELECT_TG = 106
|
||||||
|
const val MSG_TG_MONITOR = 107
|
||||||
|
const val MSG_REQUEST_QSY = 109
|
||||||
|
const val MSG_STATE_EVENT = 110
|
||||||
|
const val MSG_NODE_INFO = 111
|
||||||
|
|
||||||
|
// Типы UDP-сообщений
|
||||||
|
const val MSG_UDP_HEARTBEAT = 1
|
||||||
|
const val MSG_UDP_AUDIO = 101
|
||||||
|
const val MSG_UDP_FLUSH_SAMPLES = 102
|
||||||
|
const val MSG_UDP_ALL_SAMPLES_FLUSHED = 103
|
||||||
|
|
||||||
|
const val AUTH_CHALLENGE_LENGTH = 20
|
||||||
|
|
||||||
|
/** Протокольная версия клиента (v2 — авторизация по ключу, без TLS). */
|
||||||
|
const val MAJOR = 2
|
||||||
|
const val MINOR = 0
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Компактный бинарный reader (big-endian). */
|
||||||
|
class BinReader(private val data: ByteArray, private var pos: Int = 0) {
|
||||||
|
|
||||||
|
fun remaining(): Int = data.size - pos
|
||||||
|
|
||||||
|
fun u8(): Int {
|
||||||
|
require(remaining() >= 1) { "BinReader: EOF (u8)" }
|
||||||
|
return data[pos++].toInt() and 0xFF
|
||||||
|
}
|
||||||
|
|
||||||
|
fun u16(): Int {
|
||||||
|
require(remaining() >= 2) { "BinReader: EOF (u16)" }
|
||||||
|
val v = ((data[pos].toInt() and 0xFF) shl 8) or (data[pos + 1].toInt() and 0xFF)
|
||||||
|
pos += 2
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
fun u32(): Long {
|
||||||
|
require(remaining() >= 4) { "BinReader: EOF (u32)" }
|
||||||
|
var v = 0L
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
v = (v shl 8) or (data[pos + i].toLong() and 0xFF)
|
||||||
|
}
|
||||||
|
pos += 4
|
||||||
|
return v
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bytes(n: Int): ByteArray {
|
||||||
|
require(remaining() >= n) { "BinReader: EOF (bytes $n)" }
|
||||||
|
val out = data.copyOfRange(pos, pos + n)
|
||||||
|
pos += n
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
fun str(): String {
|
||||||
|
return String(bytes(u16()), Charsets.UTF_8)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Writer ints/строк в big-endian. */
|
||||||
|
class BinWriter {
|
||||||
|
private val out = ByteArrayOutputStream()
|
||||||
|
|
||||||
|
fun u8(v: Int): BinWriter {
|
||||||
|
out.write(v and 0xFF)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun u16(v: Int): BinWriter {
|
||||||
|
out.write((v ushr 8) and 0xFF)
|
||||||
|
out.write(v and 0xFF)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun u32(v: Long): BinWriter {
|
||||||
|
for (i in 3 downTo 0) {
|
||||||
|
out.write(((v ushr (8 * i)) and 0xFF).toInt())
|
||||||
|
}
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun bytes(b: ByteArray): BinWriter {
|
||||||
|
out.write(b, 0, b.size)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun str(s: String): BinWriter {
|
||||||
|
val b = s.toByteArray(Charsets.UTF_8)
|
||||||
|
require(b.size <= 0xFFFF) { "String too long" }
|
||||||
|
u16(b.size)
|
||||||
|
out.write(b, 0, b.size)
|
||||||
|
return this
|
||||||
|
}
|
||||||
|
|
||||||
|
fun toByteArray(): ByteArray = out.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Базовое TCP-сообщение рефлектора.
|
||||||
|
* Поля — в порядке сериализации (зависит от типа сообщения, см. объекты-компаньоны).
|
||||||
|
*/
|
||||||
|
class RcMessage(val type: Int, val fields: ByteArray = ByteArray(0)) {
|
||||||
|
|
||||||
|
/** Сериализация в TCP-кадр: [u32 BE длина payload][payload], payload = [u16 тип][поля]. */
|
||||||
|
fun toFrame(): ByteArray {
|
||||||
|
val payload = ByteArray(2 + fields.size)
|
||||||
|
payload[0] = ((type ushr 8) and 0xFF).toByte()
|
||||||
|
payload[1] = (type and 0xFF).toByte()
|
||||||
|
System.arraycopy(fields, 0, payload, 2, fields.size)
|
||||||
|
val w = BinWriter()
|
||||||
|
w.u32(payload.size.toLong())
|
||||||
|
return w.bytes(payload).toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
companion object {
|
||||||
|
/** Разбор TCP-кадра (вход — payload кадра целиком). */
|
||||||
|
fun fromPayload(payload: ByteArray): RcMessage {
|
||||||
|
val r = BinReader(payload)
|
||||||
|
val type = r.u16()
|
||||||
|
val rest = payload.copyOfRange(2, payload.size)
|
||||||
|
return RcMessage(type, rest)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** Разбор кадра из потока, возвращает null пока кадр неполный. */
|
||||||
|
fun tryParseFrame(buffer: ByteArray): RcMessage? {
|
||||||
|
val r = BinReader(buffer)
|
||||||
|
if (r.remaining() < 4) return null
|
||||||
|
val len = r.u32().toInt()
|
||||||
|
if (len > 0x100000) throw IllegalArgumentException("Frame too large: $len")
|
||||||
|
if (r.remaining() < len) return null
|
||||||
|
val payload = r.bytes(len)
|
||||||
|
return fromPayload(payload)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Построение сообщений ----------
|
||||||
|
|
||||||
|
object MsgBuilder {
|
||||||
|
|
||||||
|
fun protoVer(major: Int = ReflectorProtocol.MAJOR, minor: Int = ReflectorProtocol.MINOR): RcMessage {
|
||||||
|
val w = BinWriter().u16(major).u16(minor)
|
||||||
|
return RcMessage(ReflectorProtocol.MSG_PROTO_VER, w.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun authResponse(callsign: String, key: String, challenge: ByteArray): RcMessage {
|
||||||
|
val digest = HmacSha1.apply(key.toByteArray(Charsets.UTF_8), challenge)
|
||||||
|
// digest — vector<uint8_t>: обязательно префикс длины u16
|
||||||
|
val w = BinWriter().str(callsign).u16(digest.size).bytes(digest)
|
||||||
|
return RcMessage(ReflectorProtocol.MSG_AUTH_RESPONSE, w.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun heartbeat(): RcMessage = RcMessage(ReflectorProtocol.MSG_HEARTBEAT)
|
||||||
|
|
||||||
|
fun selectTg(tg: Long): RcMessage {
|
||||||
|
val w = BinWriter().u32(tg)
|
||||||
|
return RcMessage(ReflectorProtocol.MSG_SELECT_TG, w.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
fun tgMonitor(tgs: Collection<Long>): RcMessage {
|
||||||
|
val w = BinWriter()
|
||||||
|
w.u16(tgs.size)
|
||||||
|
for (tg in tgs) w.u32(tg)
|
||||||
|
return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray())
|
||||||
|
}
|
||||||
|
|
||||||
|
/** UDP-датаграмма V2: [u16 тип][u16 client_id][u16 seq][payload]. */
|
||||||
|
fun udpDatagram(type: Int, clientId: Int, seq: Int, payload: ByteArray = ByteArray(0)): ByteArray {
|
||||||
|
val w = BinWriter().u16(type).u16(clientId).u16(seq).bytes(payload)
|
||||||
|
return w.toByteArray()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun udpAudio(clientId: Int, seq: Int, audio: ByteArray): ByteArray {
|
||||||
|
// MsgUdpAudio: payload = vector<uint8_t> = [u16 len][данные]
|
||||||
|
val payload = BinWriter().u16(audio.size).bytes(audio).toByteArray()
|
||||||
|
return udpDatagram(ReflectorProtocol.MSG_UDP_AUDIO, clientId, seq, payload)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun udpHeartbeat(clientId: Int, seq: Int): ByteArray =
|
||||||
|
udpDatagram(ReflectorProtocol.MSG_UDP_HEARTBEAT, clientId, seq)
|
||||||
|
|
||||||
|
fun udpFlush(clientId: Int, seq: Int): ByteArray =
|
||||||
|
udpDatagram(ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES, clientId, seq)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------- Разбор сообщений ----------
|
||||||
|
|
||||||
|
data class TalkerInfo(val tg: Long, val callsign: String)
|
||||||
|
|
||||||
|
object MsgParser {
|
||||||
|
|
||||||
|
/** u16 len + bytes → audio data (для MsgUdpAudio по UDP). */
|
||||||
|
fun udpAudio(data: ByteArray): ByteArray? {
|
||||||
|
val r = BinReader(data)
|
||||||
|
if (r.remaining() < 2) return null
|
||||||
|
val len = r.u16()
|
||||||
|
if (r.remaining() < len) return null
|
||||||
|
return r.bytes(len)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MsgServerInfo: reserved u16, client_id u16, nodes[], codecs[]. */
|
||||||
|
fun serverInfo(fields: ByteArray): ServerInfo {
|
||||||
|
val r = BinReader(fields)
|
||||||
|
r.u16() // reserved
|
||||||
|
val clientId = r.u16()
|
||||||
|
val nodeCount = r.u16()
|
||||||
|
val nodes = ArrayList<String>(nodeCount)
|
||||||
|
repeat(nodeCount) { nodes.add(r.str()) }
|
||||||
|
val codecCount = r.u16()
|
||||||
|
val codecs = ArrayList<String>(codecCount)
|
||||||
|
repeat(codecCount) { codecs.add(r.str()) }
|
||||||
|
return ServerInfo(clientId, nodes, codecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** MsgTalkerStart/Stop v2: u32 tg + string. */
|
||||||
|
fun talker(fields: ByteArray): TalkerInfo {
|
||||||
|
val r = BinReader(fields)
|
||||||
|
val tg = r.u32()
|
||||||
|
val callsign = if (r.remaining() > 0) r.str() else ""
|
||||||
|
return TalkerInfo(tg, callsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
fun errorMessage(fields: ByteArray): String {
|
||||||
|
return try {
|
||||||
|
BinReader(fields).str() ?: ""
|
||||||
|
} catch (e: Exception) {
|
||||||
|
""
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
data class ServerInfo(val clientId: Int, val nodes: List<String>, val codecs: List<String>)
|
||||||
|
}
|
||||||
|
|
||||||
|
/** HMAC-SHA1 — стандартная библиотека (javax.crypto доступна и в Android, и в JVM). */
|
||||||
|
object HmacSha1 {
|
||||||
|
fun apply(key: ByteArray, data: ByteArray): ByteArray {
|
||||||
|
val mac = javax.crypto.Mac.getInstance("HmacSHA1")
|
||||||
|
mac.init(javax.crypto.spec.SecretKeySpec(key, "HmacSHA1"))
|
||||||
|
return mac.doFinal(data)
|
||||||
|
}
|
||||||
|
}
|
||||||
68
app/src/main/java/ru/ua1zbe/svxremote/ui/LevelMeter.kt
Normal file
68
app/src/main/java/ru/ua1zbe/svxremote/ui/LevelMeter.kt
Normal file
@@ -0,0 +1,68 @@
|
|||||||
|
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.util.AttributeSet
|
||||||
|
import android.view.View
|
||||||
|
import ru.ua1zbe.svxremote.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Индикатор уровня сигнала: горизонтальная полоса.
|
||||||
|
* Нижняя половина — приём (синяя), верхняя — передача (красная), 0..1.
|
||||||
|
*/
|
||||||
|
class LevelMeter @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : View(context, attrs) {
|
||||||
|
|
||||||
|
private val trackPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val fillPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val linePaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
private val rounding = RectF()
|
||||||
|
|
||||||
|
/** Активный источник: 0 = mic (TX), 1 = speaker (RX). */
|
||||||
|
@Volatile
|
||||||
|
var activeSource: Int = 0
|
||||||
|
|
||||||
|
@Volatile
|
||||||
|
var level: Float = 0f
|
||||||
|
|
||||||
|
init {
|
||||||
|
trackPaint.color = Color.parseColor("#21262D")
|
||||||
|
linePaint.color = Color.parseColor("#484F58")
|
||||||
|
linePaint.strokeWidth = 1f
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setLevel(source: Int, value: Float) {
|
||||||
|
activeSource = source
|
||||||
|
level = value.coerceIn(0f, 1f)
|
||||||
|
postInvalidateOnAnimation()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
val w = width.toFloat()
|
||||||
|
val h = height.toFloat()
|
||||||
|
val r = h / 2f
|
||||||
|
rounding.set(0f, 0f, w, h)
|
||||||
|
canvas.drawRoundRect(rounding, r, r, trackPaint)
|
||||||
|
|
||||||
|
// разделитель
|
||||||
|
canvas.drawLine(0f, h / 2f, w, h / 2f, linePaint)
|
||||||
|
|
||||||
|
val fillW = w * level.coerceIn(0f, 1f)
|
||||||
|
if (fillW > 0.5f) {
|
||||||
|
fillPaint.color = if (activeSource == 0) {
|
||||||
|
Color.parseColor("#F85149")
|
||||||
|
} else {
|
||||||
|
Color.parseColor("#58A6FF")
|
||||||
|
}
|
||||||
|
rounding.set(0f, 0f, fillW, h)
|
||||||
|
canvas.drawRoundRect(rounding, r, r, fillPaint)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
132
app/src/main/java/ru/ua1zbe/svxremote/ui/PttButton.kt
Normal file
132
app/src/main/java/ru/ua1zbe/svxremote/ui/PttButton.kt
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
package ru.ua1zbe.svxremote.ui
|
||||||
|
|
||||||
|
import android.content.Context
|
||||||
|
import android.graphics.Canvas
|
||||||
|
import android.graphics.Color
|
||||||
|
import android.graphics.Paint
|
||||||
|
import android.graphics.Path
|
||||||
|
import android.util.AttributeSet
|
||||||
|
import android.view.MotionEvent
|
||||||
|
import android.view.View
|
||||||
|
import ru.ua1zbe.svxremote.R
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Большая круглая кнопка PTT. Удерживание — передача.
|
||||||
|
* Состояния: idle (синяя), нажата (красная), отключена (серая).
|
||||||
|
*/
|
||||||
|
class PttButton @JvmOverloads constructor(
|
||||||
|
context: Context,
|
||||||
|
attrs: AttributeSet? = null
|
||||||
|
) : View(context, attrs) {
|
||||||
|
|
||||||
|
private val paint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
private val ringPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
|
||||||
|
private var radius = 0f
|
||||||
|
private var centerX = 0f
|
||||||
|
private var centerY = 0f
|
||||||
|
|
||||||
|
/** Флаг: включено ли PTT (соединение установлено). */
|
||||||
|
var enabled2: Boolean = true
|
||||||
|
set(value) {
|
||||||
|
field = value
|
||||||
|
if (!value) setPressed(false)
|
||||||
|
refreshColors()
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
val isPressed2: Boolean
|
||||||
|
get() = isPressed
|
||||||
|
|
||||||
|
private var listener: Listener? = null
|
||||||
|
|
||||||
|
interface Listener {
|
||||||
|
fun onPttDown()
|
||||||
|
fun onPttUp()
|
||||||
|
}
|
||||||
|
|
||||||
|
fun setListener(l: Listener?) {
|
||||||
|
listener = l
|
||||||
|
}
|
||||||
|
|
||||||
|
init {
|
||||||
|
ringPaint.style = Paint.Style.STROKE
|
||||||
|
ringPaint.strokeWidth = dp(3).toFloat()
|
||||||
|
ringPaint.color = Color.parseColor("#3D444D")
|
||||||
|
paint.style = Paint.Style.FILL
|
||||||
|
refreshColors()
|
||||||
|
isClickable = true
|
||||||
|
isFocusable = true
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun refreshColors() {
|
||||||
|
paint.color = when {
|
||||||
|
!enabled2 -> Color.parseColor("#484F58")
|
||||||
|
isPressed -> Color.parseColor("#F85149")
|
||||||
|
else -> Color.parseColor("#1F6FEB")
|
||||||
|
}
|
||||||
|
invalidate()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
|
||||||
|
|
||||||
|
override fun onSizeChanged(w: Int, h: Int, oldw: Int, oldh: Int) {
|
||||||
|
super.onSizeChanged(w, h, oldw, oldh)
|
||||||
|
centerX = w / 2f
|
||||||
|
centerY = h / 2f
|
||||||
|
radius = minOf(w, h) / 2f - dp(16)
|
||||||
|
refreshColors()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onDraw(canvas: Canvas) {
|
||||||
|
super.onDraw(canvas)
|
||||||
|
val r = radius / (1f + if (isPressed) 0.06f else 0f)
|
||||||
|
canvas.drawCircle(centerX, centerY, r, paint)
|
||||||
|
canvas.drawCircle(centerX, centerY, r, ringPaint)
|
||||||
|
|
||||||
|
// Иконка микрофона
|
||||||
|
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
|
||||||
|
micPaint.color = Color.WHITE
|
||||||
|
micPaint.style = Paint.Style.FILL
|
||||||
|
val s = r * 0.55f
|
||||||
|
val path = Path()
|
||||||
|
// Простой микрофон
|
||||||
|
val x = centerX
|
||||||
|
val y = centerY
|
||||||
|
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)
|
||||||
|
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)
|
||||||
|
path.close()
|
||||||
|
canvas.drawPath(path, micPaint)
|
||||||
|
canvas.drawRect(x - s * 0.06f, y - s * 1.15f, x + s * 0.06f, y - s * 0.28f, micPaint)
|
||||||
|
// ножка и дуга
|
||||||
|
canvas.drawRect(x - s * 0.05f, y + s * 0.28f, x + s * 0.05f, y + s * 0.55f, micPaint)
|
||||||
|
canvas.drawArc(
|
||||||
|
x - s * 0.55f, y + s * 0.30f,
|
||||||
|
x + s * 0.55f, y + s * 1.20f,
|
||||||
|
0f, 180f, false, micPaint
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTouchEvent(event: MotionEvent): Boolean {
|
||||||
|
if (!enabled2) return false
|
||||||
|
return when (event.actionMasked) {
|
||||||
|
MotionEvent.ACTION_DOWN -> {
|
||||||
|
setPressed(true)
|
||||||
|
listener?.onPttDown()
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
|
||||||
|
if (isPressed) {
|
||||||
|
setPressed(false)
|
||||||
|
listener?.onPttUp()
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
else -> true
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
6
app/src/main/res/drawable/bg_chip.xml
Normal file
6
app/src/main/res/drawable/bg_chip.xml
Normal 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_light" />
|
||||||
|
<corners android:radius="18dp" />
|
||||||
|
</shape>
|
||||||
7
app/src/main/res/drawable/bg_input.xml
Normal file
7
app/src/main/res/drawable/bg_input.xml
Normal 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="@color/surface" />
|
||||||
|
<stroke android:width="1dp" android:color="@color/surface_light" />
|
||||||
|
<corners android:radius="10dp" />
|
||||||
|
</shape>
|
||||||
6
app/src/main/res/drawable/bg_primary_btn.xml
Normal file
6
app/src/main/res/drawable/bg_primary_btn.xml
Normal 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/accent" />
|
||||||
|
<corners android:radius="14dp" />
|
||||||
|
</shape>
|
||||||
16
app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
16
app/src/main/res/drawable/ic_launcher_fg.xml
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
<?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>
|
||||||
10
app/src/main/res/drawable/ic_mic.xml
Normal file
10
app/src/main/res/drawable/ic_mic.xml
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<vector xmlns:android="http://schemas.android.com/apk/res/android"
|
||||||
|
android:width="64dp"
|
||||||
|
android:height="64dp"
|
||||||
|
android:viewportWidth="24"
|
||||||
|
android:viewportHeight="24">
|
||||||
|
<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" />
|
||||||
|
</vector>
|
||||||
10
app/src/main/res/drawable/ic_settings.xml
Normal file
10
app/src/main/res/drawable/ic_settings.xml
Normal 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="M19.14,12.94c0.04,-0.3 0.06,-0.61 0.06,-0.94c0,-0.32 -0.02,-0.64 -0.07,-0.94l2.03,-1.58c0.18,-0.14 0.23,-0.41 0.12,-0.61l-1.92,-3.32c-0.12,-0.22 -0.37,-0.29 -0.59,-0.22l-2.39,0.96c-0.5,-0.38 -1.03,-0.7 -1.62,-0.94L14.4,2.81c-0.04,-0.24 -0.24,-0.41 -0.48,-0.41h-3.84c-0.24,0 -0.43,0.17 -0.47,0.41L9.25,5.35C8.66,5.59 8.12,5.92 7.63,6.29L5.24,5.33c-0.22,-0.08 -0.47,0 -0.59,0.22L2.74,8.87C2.62,9.08 2.66,9.34 2.86,9.48l2.03,1.58C4.84,11.36 4.8,11.69 4.8,12s0.02,0.64 0.07,0.94l-2.03,1.58c-0.18,0.14 -0.23,0.41 -0.12,0.61l1.92,3.32c0.12,0.22 0.37,0.29 0.59,0.22l2.39,-0.96c0.5,0.38 1.03,0.7 1.62,0.94l0.36,2.54c0.05,0.24 0.24,0.41 0.48,0.41h3.84c0.24,0 0.44,-0.17 0.47,-0.41l0.36,-2.54c0.59,-0.24 1.13,-0.56 1.62,-0.94l2.39,0.96c0.22,0.08 0.47,0 0.59,-0.22l1.92,-3.32c0.12,-0.22 0.07,-0.47 -0.12,-0.61L19.14,12.94zM12,15.6c-1.98,0 -3.6,-1.62 -3.6,-3.6s1.62,-3.6 3.6,-3.6s3.6,1.62 3.6,3.6S13.98,15.6 12,15.6z" />
|
||||||
|
</vector>
|
||||||
120
app/src/main/res/layout/activity_main.xml
Normal file
120
app/src/main/res/layout/activity_main.xml
Normal file
@@ -0,0 +1,120 @@
|
|||||||
|
<?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="match_parent"
|
||||||
|
android:orientation="vertical"
|
||||||
|
android:background="@color/bg"
|
||||||
|
android:padding="16dp">
|
||||||
|
|
||||||
|
<LinearLayout
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:orientation="horizontal"
|
||||||
|
android:gravity="center_vertical">
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/status_chip"
|
||||||
|
android:layout_width="wrap_content"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:paddingHorizontal="14dp"
|
||||||
|
android:paddingVertical="6dp"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:background="@drawable/bg_chip"
|
||||||
|
android:text="@string/status_disconnected" />
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="0dp"
|
||||||
|
android:layout_height="1dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<ImageButton
|
||||||
|
android:id="@+id/btn_settings"
|
||||||
|
android:layout_width="48dp"
|
||||||
|
android:layout_height="48dp"
|
||||||
|
android:src="@drawable/ic_settings"
|
||||||
|
android:background="?android:attr/selectableItemBackgroundBorderless"
|
||||||
|
android:contentDescription="@string/settings_title"
|
||||||
|
android:scaleType="centerInside" />
|
||||||
|
</LinearLayout>
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/lbl_subtitle"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="8dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:text="@string/settings_not_configured" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/lbl_talker_title"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="28dp"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:text="@string/talker_none" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/lbl_talker"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginTop="4dp"
|
||||||
|
android:textColor="@color/accent"
|
||||||
|
android:textSize="28sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:singleLine="true"
|
||||||
|
android:ellipsize="end"
|
||||||
|
android:text="@string/talker_none" />
|
||||||
|
|
||||||
|
<Space
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="0dp"
|
||||||
|
android:layout_weight="1" />
|
||||||
|
|
||||||
|
<ru.ua1zbe.svxremote.ui.LevelMeter
|
||||||
|
android:id="@+id/level_meter"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="30dp" />
|
||||||
|
|
||||||
|
<ru.ua1zbe.svxremote.ui.PttButton
|
||||||
|
android:id="@+id/ptt_button"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="220dp"
|
||||||
|
android:layout_marginTop="24dp"
|
||||||
|
android:layout_marginBottom="12dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/lbl_ptt_hint"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="13sp"
|
||||||
|
android:text="@string/ptt_hint" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:id="@+id/lbl_tx_state"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:gravity="center"
|
||||||
|
android:textColor="@color/danger"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:textStyle="bold"
|
||||||
|
android:visibility="gone"
|
||||||
|
android:text="@string/ptt_talking" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_connect"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="52dp"
|
||||||
|
android:layout_marginTop="12dp"
|
||||||
|
android:text="@string/btn_connect"
|
||||||
|
android:textColor="#0D1117"
|
||||||
|
android:background="@drawable/bg_primary_btn"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:textStyle="bold" />
|
||||||
|
</LinearLayout>
|
||||||
132
app/src/main/res/layout/activity_settings.xml
Normal file
132
app/src/main/res/layout/activity_settings.xml
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
<?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"
|
||||||
|
android:fillViewport="true">
|
||||||
|
|
||||||
|
<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/settings_hint"
|
||||||
|
android:textColor="@color/text_secondary"
|
||||||
|
android:textSize="14sp"
|
||||||
|
android:layout_marginBottom="20dp" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/pref_server_host"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/input_host"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:inputType="textUri"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textColorHint="@color/text_secondary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:background="@drawable/bg_input"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:hint="reflector.example.org" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/pref_server_port"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/input_port"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:inputType="number"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textColorHint="@color/text_secondary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:background="@drawable/bg_input"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:hint="5300" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/pref_callsign"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/input_callsign"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
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_callsign_hint" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/pref_auth_key"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/input_auth_key"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="16dp"
|
||||||
|
android:inputType="textPassword"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textColorHint="@color/text_secondary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:background="@drawable/bg_input"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:hint="••••••••" />
|
||||||
|
|
||||||
|
<TextView
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:text="@string/pref_tg"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textSize="15sp" />
|
||||||
|
|
||||||
|
<EditText
|
||||||
|
android:id="@+id/input_tg"
|
||||||
|
android:layout_width="match_parent"
|
||||||
|
android:layout_height="wrap_content"
|
||||||
|
android:layout_marginBottom="24dp"
|
||||||
|
android:inputType="number"
|
||||||
|
android:textColor="@color/text_primary"
|
||||||
|
android:textColorHint="@color/text_secondary"
|
||||||
|
android:textSize="16sp"
|
||||||
|
android:background="@drawable/bg_input"
|
||||||
|
android:padding="12dp"
|
||||||
|
android:hint="11" />
|
||||||
|
|
||||||
|
<Button
|
||||||
|
android:id="@+id/btn_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>
|
||||||
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
5
app/src/main/res/mipmap-anydpi-v26/ic_launcher.xml
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
|
||||||
|
<foreground android:drawable="@drawable/ic_launcher_fg" />
|
||||||
|
<background android:drawable="@color/bg" />
|
||||||
|
</adaptive-icon>
|
||||||
19
app/src/main/res/values/colors.xml
Normal file
19
app/src/main/res/values/colors.xml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<color name="bg">#0D1117</color>
|
||||||
|
<color name="surface">#161B22</color>
|
||||||
|
<color name="surface_light">#21262D</color>
|
||||||
|
<color name="text_primary">#E6EDF3</color>
|
||||||
|
<color name="text_secondary">#8B949E</color>
|
||||||
|
<color name="accent">#3FB950</color>
|
||||||
|
<color name="accent_pressed">#2EA043</color>
|
||||||
|
<color name="danger">#F85149</color>
|
||||||
|
<color name="warn">#D29922</color>
|
||||||
|
<color name="info">#58A6FF</color>
|
||||||
|
<color name="transparent">#00000000</color>
|
||||||
|
<color name="ptt_idle">#1F6FEB</color>
|
||||||
|
<color name="ptt_pressed">#F85149</color>
|
||||||
|
<color name="meter_background">#21262D</color>
|
||||||
|
<color name="meter_fill">#58A6FF</color>
|
||||||
|
<color name="meter_fill_tx">#F85149</color>
|
||||||
|
</resources>
|
||||||
46
app/src/main/res/values/strings.xml
Normal file
46
app/src/main/res/values/strings.xml
Normal file
@@ -0,0 +1,46 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<string name="app_name">SVXHomyak</string>
|
||||||
|
|
||||||
|
<string name="settings_title">Настройки</string>
|
||||||
|
<string name="settings_hint">Параметры подключения к рефлектору SVXLink</string>
|
||||||
|
<string name="pref_server_host">Сервер (хост или IP)</string>
|
||||||
|
<string name="pref_server_port">Порт (по умолчанию 5300)</string>
|
||||||
|
<string name="pref_callsign">Позывной</string>
|
||||||
|
<string name="pref_auth_key">Пароль (AUTH_KEY)</string>
|
||||||
|
<string name="pref_tg">Группа вещания (TG)</string>
|
||||||
|
<string name="btn_save">Сохранить</string>
|
||||||
|
<string name="saved">Сохранено</string>
|
||||||
|
<string name="pref_callsign_hint">например UA1ZBE</string>
|
||||||
|
|
||||||
|
<string name="status_disconnected">Отключено</string>
|
||||||
|
<string name="status_connecting">Подключение…</string>
|
||||||
|
<string name="status_connecting_ssl">Проверка ключа…</string>
|
||||||
|
<string name="status_connected">На связи</string>
|
||||||
|
<string name="status_auth_failed">Ошибка авторизации</string>
|
||||||
|
<string name="status_error">Ошибка: %1$s</string>
|
||||||
|
|
||||||
|
<string name="btn_connect">Подключиться</string>
|
||||||
|
<string name="btn_disconnect">Отключиться</string>
|
||||||
|
|
||||||
|
<string name="talker_none">—</string>
|
||||||
|
<string name="talker_now">Сейчас говорит: %1$s</string>
|
||||||
|
<string name="talker_on_tg">группа %1$d</string>
|
||||||
|
|
||||||
|
<string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string>
|
||||||
|
<string name="ptt_talking">Передача…</string>
|
||||||
|
|
||||||
|
<string name="rx_level">Приём</string>
|
||||||
|
<string name="tx_level">Передача</string>
|
||||||
|
|
||||||
|
<string name="server_subtitle">%1$s:%2$d · TG %3$d</string>
|
||||||
|
|
||||||
|
<string name="perm_mic">Для работы PTT нужен доступ к микрофону</string>
|
||||||
|
<string name="toast_connect_failed">Не удалось подключиться: %1$s</string>
|
||||||
|
<string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string>
|
||||||
|
<string name="toast_settings_empty">Заполните настройки подключения</string>
|
||||||
|
<string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string>
|
||||||
|
|
||||||
|
<string name="settings_not_configured">Настройки не заданы</string>
|
||||||
|
<string name="ptt_disabled">Нет связи</string>
|
||||||
|
</resources>
|
||||||
9
app/src/main/res/values/themes.xml
Normal file
9
app/src/main/res/values/themes.xml
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
<?xml version="1.0" encoding="utf-8"?>
|
||||||
|
<resources>
|
||||||
|
<style name="Theme.SvxRemote" parent="android:Theme.Material.NoActionBar">
|
||||||
|
<item name="android:windowBackground">@color/bg</item>
|
||||||
|
<item name="android:statusBarColor">@color/bg</item>
|
||||||
|
<item name="android:navigationBarColor">@color/bg</item>
|
||||||
|
<item name="android:colorAccent">@color/info</item>
|
||||||
|
</style>
|
||||||
|
</resources>
|
||||||
208
app/src/test/java/ru/ua1zbe/svxremote/HandshakeTest.kt
Normal file
208
app/src/test/java/ru/ua1zbe/svxremote/HandshakeTest.kt
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
package ru.ua1zbe.svxremote
|
||||||
|
|
||||||
|
import org.junit.After
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertNull
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Before
|
||||||
|
import org.junit.Test
|
||||||
|
import ru.ua1zbe.svxremote.proto.BinReader
|
||||||
|
import ru.ua1zbe.svxremote.proto.BinWriter
|
||||||
|
import ru.ua1zbe.svxremote.proto.HmacSha1
|
||||||
|
import ru.ua1zbe.svxremote.proto.MsgBuilder
|
||||||
|
import ru.ua1zbe.svxremote.proto.MsgParser
|
||||||
|
import ru.ua1zbe.svxremote.proto.RcMessage
|
||||||
|
import ru.ua1zbe.svxremote.proto.ReflectorClient
|
||||||
|
import ru.ua1zbe.svxremote.proto.ReflectorProtocol
|
||||||
|
import java.net.DatagramPacket
|
||||||
|
import java.net.DatagramSocket
|
||||||
|
import java.net.InetSocketAddress
|
||||||
|
import java.net.ServerSocket
|
||||||
|
import java.net.Socket
|
||||||
|
import java.util.concurrent.CountDownLatch
|
||||||
|
import java.util.concurrent.TimeUnit
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Полный TCP-handshake против фейкового svxreflector-сервера,
|
||||||
|
* реализованного по спецификации (v2: protover → challenge → auth response → ok → server info).
|
||||||
|
*/
|
||||||
|
class HandshakeTest {
|
||||||
|
|
||||||
|
private lateinit var server: ServerSocket
|
||||||
|
private var serverThread: Thread? = null
|
||||||
|
private var sawAuthResponse: Boolean = false
|
||||||
|
private var sawSelectTg: Boolean = false
|
||||||
|
private var sentCodecs: String = "OPUS"
|
||||||
|
@Volatile
|
||||||
|
private var serverError: Throwable? = null
|
||||||
|
private val selectTgLatch = CountDownLatch(1)
|
||||||
|
private val errors = mutableListOf<String>()
|
||||||
|
|
||||||
|
@Before
|
||||||
|
fun setUp() {
|
||||||
|
server = ServerSocket(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
@After
|
||||||
|
fun tearDown() {
|
||||||
|
server.close()
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `полный handshake с фейковым рефлектором`() {
|
||||||
|
serverThread = Thread {
|
||||||
|
try {
|
||||||
|
serverBody()
|
||||||
|
} catch (e: Throwable) {
|
||||||
|
serverError = e
|
||||||
|
}
|
||||||
|
}
|
||||||
|
serverThread!!.start()
|
||||||
|
|
||||||
|
val stateEvents = mutableListOf<Int>()
|
||||||
|
val latch = CountDownLatch(1)
|
||||||
|
val c = ReflectorClient(object : ReflectorClient.Callbacks {
|
||||||
|
override fun onState(state: Int, detail: String?) {
|
||||||
|
synchronized(stateEvents) { stateEvents.add(state) }
|
||||||
|
if (state == ReflectorClient.STATE_READY) latch.countDown()
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onServerInfo(info: MsgParser.ServerInfo) {
|
||||||
|
assertEquals(4242, info.clientId)
|
||||||
|
assertEquals(listOf("OPUS"), info.codecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
override fun onTalkerStart(tg: Long, callsign: String) {}
|
||||||
|
override fun onTalkerStop(tg: Long, callsign: String) {}
|
||||||
|
override fun onNodeJoined(callsign: String) {}
|
||||||
|
override fun onNodeLeft(callsign: String) {}
|
||||||
|
override fun onAudioPacket(packet: ByteArray, seq: Int) {}
|
||||||
|
override fun onAudioFlush() {}
|
||||||
|
override fun onError(detail: String) {
|
||||||
|
synchronized(errors) { errors.add(detail) }
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
c.connect(
|
||||||
|
ReflectorClient.Settings(
|
||||||
|
host = "127.0.0.1",
|
||||||
|
port = server.localPort,
|
||||||
|
callsign = "ua1zbe-m",
|
||||||
|
authKey = "secret",
|
||||||
|
talkGroup = 11
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
assertTrue(
|
||||||
|
"latch timeout; states=$stateEvents serverError=$serverError errs=$errors",
|
||||||
|
latch.await(60, TimeUnit.SECONDS)
|
||||||
|
)
|
||||||
|
assertNull(serverError)
|
||||||
|
assertTrue(sawAuthResponse)
|
||||||
|
assertTrue("SelectTG не получен за 10s", selectTgLatch.await(10, TimeUnit.SECONDS))
|
||||||
|
assertTrue(stateEvents.contains(ReflectorClient.STATE_READY))
|
||||||
|
c.disconnect()
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun serverBody() {
|
||||||
|
val sock = server.accept()
|
||||||
|
try {
|
||||||
|
sock.soTimeout = 60000
|
||||||
|
val input = sock.getInputStream()
|
||||||
|
val out = sock.getOutputStream()
|
||||||
|
val buf = ByteArray(4096)
|
||||||
|
// Client -> MsgProtoVer
|
||||||
|
val frame1 = readFrame(input, buf)
|
||||||
|
val msg1 = RcMessage.fromPayload(frame1)
|
||||||
|
assertEquals(ReflectorProtocol.MSG_PROTO_VER, msg1.type)
|
||||||
|
// Server -> MsgAuthChallenge: vector<uint8_t> = [u16 len][20 байт]
|
||||||
|
val challenge = ByteArray(20) { (it + 1).toByte() }
|
||||||
|
writeFrame(
|
||||||
|
out,
|
||||||
|
RcMessage(
|
||||||
|
ReflectorProtocol.MSG_AUTH_CHALLENGE,
|
||||||
|
BinWriter().u16(challenge.size).bytes(challenge).toByteArray()
|
||||||
|
)
|
||||||
|
)
|
||||||
|
// Client -> MsgAuthResponse
|
||||||
|
val frame2 = readFrame(input, buf)
|
||||||
|
val msg2 = RcMessage.fromPayload(frame2)
|
||||||
|
assertEquals(ReflectorProtocol.MSG_AUTH_RESPONSE, msg2.type)
|
||||||
|
val r = BinReader(msg2.fields)
|
||||||
|
val callsign = r.str()
|
||||||
|
// digest — vector<uint8_t> с префиксом длины
|
||||||
|
assertEquals(20, r.u16())
|
||||||
|
val digest = r.bytes(20)
|
||||||
|
// Сервер принимает позывной только в верхнем регистре —
|
||||||
|
// клиент обязан нормализовать его на проводе
|
||||||
|
assertEquals("UA1ZBE-M", callsign)
|
||||||
|
// Server проверяет HMAC
|
||||||
|
assertTrue(digest.contentEquals(
|
||||||
|
HmacSha1.apply("secret".toByteArray(), challenge)
|
||||||
|
))
|
||||||
|
sawAuthResponse = true
|
||||||
|
// Server -> MsgAuthOk + MsgServerInfo(client_id, codecs)
|
||||||
|
writeFrame(out, RcMessage(ReflectorProtocol.MSG_AUTH_OK))
|
||||||
|
val info = BinWriter()
|
||||||
|
.u16(0)
|
||||||
|
.u16(4242)
|
||||||
|
.u16(1).str("UA1ZBE")
|
||||||
|
.u16(1).str(sentCodecs)
|
||||||
|
.toByteArray()
|
||||||
|
writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info))
|
||||||
|
// Client должен прислать MsgSelectTG(11) и UDP-датаграмму
|
||||||
|
var gotTg = false
|
||||||
|
while (!gotTg) {
|
||||||
|
val frame3 = readFrame(input, buf)
|
||||||
|
val msg3 = RcMessage.fromPayload(frame3)
|
||||||
|
when (msg3.type) {
|
||||||
|
ReflectorProtocol.MSG_SELECT_TG -> {
|
||||||
|
val rr = BinReader(msg3.fields)
|
||||||
|
assertEquals(11L, rr.u32())
|
||||||
|
gotTg = true
|
||||||
|
sawSelectTg = true
|
||||||
|
selectTgLatch.countDown()
|
||||||
|
}
|
||||||
|
ReflectorProtocol.MSG_HEARTBEAT -> Unit
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Ждём UDP-датаграмму (регистрация UDP)
|
||||||
|
try {
|
||||||
|
val ds = DatagramSocket(server.localPort)
|
||||||
|
val dp = DatagramPacket(ByteArray(64), 64)
|
||||||
|
ds.soTimeout = 60000
|
||||||
|
ds.receive(dp)
|
||||||
|
val rr = BinReader(dp.data.copyOfRange(0, dp.length))
|
||||||
|
assertEquals(ReflectorProtocol.MSG_UDP_HEARTBEAT, rr.u16())
|
||||||
|
ds.close()
|
||||||
|
} catch (e: Exception) {
|
||||||
|
// UDP не критичен для этого теста
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
sock.close()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun readFrame(input: java.io.InputStream, buf: ByteArray): ByteArray {
|
||||||
|
var size = 0
|
||||||
|
for (i in 0 until 4) {
|
||||||
|
val b = input.read()
|
||||||
|
assertTrue(b >= 0)
|
||||||
|
size = (size shl 8) or b
|
||||||
|
}
|
||||||
|
val out = ByteArray(size)
|
||||||
|
var off = 0
|
||||||
|
while (off < size) {
|
||||||
|
val n = input.read(out, off, size - off)
|
||||||
|
assertTrue(n > 0)
|
||||||
|
off += n
|
||||||
|
}
|
||||||
|
return out
|
||||||
|
}
|
||||||
|
|
||||||
|
private fun writeFrame(out: java.io.OutputStream, msg: RcMessage) {
|
||||||
|
val frame = msg.toFrame()
|
||||||
|
out.write(frame, 0, frame.size)
|
||||||
|
out.flush()
|
||||||
|
}
|
||||||
|
}
|
||||||
110
app/src/test/java/ru/ua1zbe/svxremote/ProtocolTest.kt
Normal file
110
app/src/test/java/ru/ua1zbe/svxremote/ProtocolTest.kt
Normal file
@@ -0,0 +1,110 @@
|
|||||||
|
package ru.ua1zbe.svxremote
|
||||||
|
|
||||||
|
import org.junit.Assert.assertArrayEquals
|
||||||
|
import org.junit.Assert.assertEquals
|
||||||
|
import org.junit.Assert.assertTrue
|
||||||
|
import org.junit.Test
|
||||||
|
import ru.ua1zbe.svxremote.proto.BinReader
|
||||||
|
import ru.ua1zbe.svxremote.proto.BinWriter
|
||||||
|
import ru.ua1zbe.svxremote.proto.HmacSha1
|
||||||
|
import ru.ua1zbe.svxremote.proto.MsgBuilder
|
||||||
|
import ru.ua1zbe.svxremote.proto.MsgParser
|
||||||
|
import ru.ua1zbe.svxremote.proto.RcMessage
|
||||||
|
import ru.ua1zbe.svxremote.proto.ReflectorProtocol
|
||||||
|
|
||||||
|
class ProtocolTest {
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `hmac sha1 известный вектор RFC 2202`() {
|
||||||
|
// Test Case 1: key = 0x0b*20, data = "Hi There"
|
||||||
|
val key = ByteArray(20) { 0x0b }
|
||||||
|
val digest = HmacSha1.apply(key, "Hi There".toByteArray(Charsets.UTF_8))
|
||||||
|
val expected = byteArrayOf(
|
||||||
|
0xb6.toByte(), 0x17.toByte(), 0x31.toByte(), 0x86.toByte(), 0x55.toByte(),
|
||||||
|
0x05.toByte(), 0x72.toByte(), 0x64.toByte(), 0xe2.toByte(), 0x8b.toByte(),
|
||||||
|
0xc0.toByte(), 0xb6.toByte(), 0xfb.toByte(), 0x37.toByte(), 0x8c.toByte(),
|
||||||
|
0x8e.toByte(), 0xf1.toByte(), 0x46.toByte(), 0xbe.toByte(), 0x00.toByte()
|
||||||
|
)
|
||||||
|
assertArrayEquals(expected, digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `protoVer roundtrip`() {
|
||||||
|
val msg = MsgBuilder.protoVer(2, 0)
|
||||||
|
val frame = msg.toFrame()
|
||||||
|
val parsed = RcMessage.tryParseFrame(frame)
|
||||||
|
assertEquals(ReflectorProtocol.MSG_PROTO_VER, parsed?.type)
|
||||||
|
val r = BinReader(parsed!!.fields)
|
||||||
|
assertEquals(2, r.u16())
|
||||||
|
assertEquals(0, r.u16())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `authResponse roundtrip и проверка`() {
|
||||||
|
val callsign = "UA1ZBE"
|
||||||
|
val key = "secret"
|
||||||
|
val challenge = ByteArray(20) { (it * 7 + 3).toByte() }
|
||||||
|
val msg = MsgBuilder.authResponse(callsign, key, challenge)
|
||||||
|
val parsed = RcMessage.tryParseFrame(msg.toFrame())
|
||||||
|
assertEquals(ReflectorProtocol.MSG_AUTH_RESPONSE, parsed?.type)
|
||||||
|
val r = BinReader(parsed!!.fields)
|
||||||
|
assertEquals(callsign, r.str())
|
||||||
|
assertEquals(20, r.u16())
|
||||||
|
val digest = r.bytes(20)
|
||||||
|
assertArrayEquals(HmacSha1.apply(key.toByteArray(), challenge), digest)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `serverInfo roundtrip`() {
|
||||||
|
val bytes = BinWriter()
|
||||||
|
.u16(0) // reserved
|
||||||
|
.u16(1234) // client id
|
||||||
|
.u16(2)
|
||||||
|
.str("UA1ZBE")
|
||||||
|
.str("R2CBW")
|
||||||
|
.u16(1)
|
||||||
|
.str("OPUS")
|
||||||
|
.toByteArray()
|
||||||
|
val info = MsgParser.serverInfo(bytes)
|
||||||
|
assertEquals(1234, info.clientId)
|
||||||
|
assertEquals(listOf("UA1ZBE", "R2CBW"), info.nodes)
|
||||||
|
assertEquals(listOf("OPUS"), info.codecs)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `talker roundtrip`() {
|
||||||
|
val bytes = BinWriter().u32(11).str("R2CBW").toByteArray()
|
||||||
|
val t = MsgParser.talker(bytes)
|
||||||
|
assertEquals(11L, t.tg)
|
||||||
|
assertEquals("R2CBW", t.callsign)
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `selectTg roundtrip`() {
|
||||||
|
val parsed = RcMessage.tryParseFrame(MsgBuilder.selectTg(11).toFrame())
|
||||||
|
assertEquals(ReflectorProtocol.MSG_SELECT_TG, parsed?.type)
|
||||||
|
val r = BinReader(parsed!!.fields)
|
||||||
|
assertEquals(11L, r.u32())
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `udp audio roundtrip`() {
|
||||||
|
val audio = ByteArray(30) { (it and 0xFF).toByte() }
|
||||||
|
val datagram = MsgBuilder.udpAudio(99, 7, audio)
|
||||||
|
val r = BinReader(datagram)
|
||||||
|
assertEquals(ReflectorProtocol.MSG_UDP_AUDIO, r.u16())
|
||||||
|
assertEquals(99, r.u16())
|
||||||
|
assertEquals(7, r.u16())
|
||||||
|
val payload = datagram.copyOfRange(6, datagram.size)
|
||||||
|
assertArrayEquals(audio, MsgParser.udpAudio(payload))
|
||||||
|
}
|
||||||
|
|
||||||
|
@Test
|
||||||
|
fun `частичный кадр возвращает null`() {
|
||||||
|
val frame = MsgBuilder.protoVer().toFrame()
|
||||||
|
assertTrue(RcMessage.tryParseFrame(frame.copyOfRange(0, 2)) == null)
|
||||||
|
assertTrue(RcMessage.tryParseFrame(frame.copyOfRange(0, 3)) == null)
|
||||||
|
// 4 (длина) + 2 (тип) + 4 (major/minor)
|
||||||
|
assertEquals(10, frame.size)
|
||||||
|
}
|
||||||
|
}
|
||||||
4
build.gradle.kts
Normal file
4
build.gradle.kts
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
plugins {
|
||||||
|
id("com.android.application") version "8.13.2" apply false
|
||||||
|
id("org.jetbrains.kotlin.android") version "2.2.21" apply false
|
||||||
|
}
|
||||||
8
gradle.properties
Normal file
8
gradle.properties
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
org.gradle.jvmargs=-Xmx768m -XX:MaxMetaspaceSize=384m -XX:+UseSerialGC -Dfile.encoding=UTF-8
|
||||||
|
kotlin.daemon.jvmargs=-Xmx512m -XX:+UseSerialGC
|
||||||
|
org.gradle.workers.max=1
|
||||||
|
org.gradle.daemon=false
|
||||||
|
org.gradle.parallel=false
|
||||||
|
org.gradle.caching=false
|
||||||
|
kotlin.compiler.execution.strategy=in-process
|
||||||
|
android.nonTransitiveRClass=true
|
||||||
18
settings.gradle.kts
Normal file
18
settings.gradle.kts
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
pluginManagement {
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
gradlePluginPortal()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
dependencyResolutionManagement {
|
||||||
|
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
|
||||||
|
repositories {
|
||||||
|
google()
|
||||||
|
mavenCentral()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
rootProject.name = "SvxRemote"
|
||||||
|
include(":app")
|
||||||
Reference in New Issue
Block a user