Initial commit: SVXHomyak - Android client for SVXLink reflectors
This commit is contained in:
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)
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user