commit f924c4cc4a492bd73103f0aef4d14eefc91f67d2 Author: ua1zbe Date: Thu Aug 13 13:33:17 2026 +0300 Initial commit: SVXHomyak - Android client for SVXLink reflectors diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..2acba49 --- /dev/null +++ b/.gitignore @@ -0,0 +1,9 @@ +*.iml +.gradle/ +local.properties +.idea/ +.DS_Store +build/ +/captures +.externalNativeBuild +.cxx diff --git a/README.md b/README.md new file mode 100644 index 0000000..22aeb59 --- /dev/null +++ b/README.md @@ -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 +``` + +## Лицензия + +Проект распространяется без ограничений (для радиолюбительских целей). diff --git a/app/build.gradle.kts b/app/build.gradle.kts new file mode 100644 index 0000000..528f9a0 --- /dev/null +++ b/app/build.gradle.kts @@ -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" + } + } +} \ No newline at end of file diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml new file mode 100644 index 0000000..f5b7e93 --- /dev/null +++ b/app/src/main/AndroidManifest.xml @@ -0,0 +1,31 @@ + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt new file mode 100644 index 0000000..bb15874 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -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, + grantResults: IntArray + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQ_MIC && + (grantResults.isEmpty() || grantResults[0] != PackageManager.PERMISSION_GRANTED) + ) { + toast(getString(R.string.perm_mic)) + } + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt new file mode 100644 index 0000000..69d28f0 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/SettingsActivity.kt @@ -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