Initial commit: SVXHomyak - Android client for SVXLink reflectors

This commit is contained in:
ua1zbe
2026-08-13 13:33:17 +03:00
commit f924c4cc4a
29 changed files with 2528 additions and 0 deletions

46
app/build.gradle.kts Normal file
View 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"
}
}
}

View 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>

View 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))
}
}
}

View 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()
}
}

View 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)
}
}

View 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()
}
}

View 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) {
}
}
}
}

View 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)
}
}

View 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)
}
}
}

View 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
}
}
}

View File

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

View File

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

View File

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

View 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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="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>

View File

@@ -0,0 +1,10 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8B949E"
android:pathData="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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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>

View 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()
}
}

View 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)
}
}