16 Commits
v1.0 ... v1.0.4

Author SHA1 Message Date
ua1zbe
b988e7cea0 Match callsign size to TG selector (22sp) (v1.0.4) 2026-08-14 13:50:20 +03:00
ua1zbe
f4996bef8e Enlarge TG selector to 22sp and push to right edge (v1.0.4) 2026-08-14 13:48:38 +03:00
ua1zbe
27169e9d0f Move TG selector inline with callsign, enlarge to 18sp (v1.0.4) 2026-08-14 13:46:16 +03:00
ua1zbe
a083660e03 Center app title between power and settings buttons (v1.0.4) 2026-08-14 13:43:13 +03:00
ua1zbe
3de3d7e893 Align power button to left edge (v1.0.4) 2026-08-14 13:40:10 +03:00
ua1zbe
efcec098bd Add power button next to app title (v1.0.4)
- Tap: disconnect from server, stop service and close app
2026-08-14 13:38:04 +03:00
ua1zbe
d0c055fec9 Show talk group next to nodes in list (v1.0.4)
- SvxService tracks node->TG mapping learned from talker events
- Own callsign always shown with its current TG
- Protocol carries no TG in join/leave, so others appear once they talk
2026-08-14 13:32:41 +03:00
ua1zbe
9790f9d69c Show centered node join/leave overlay for 5s (v1.0.4)
- Replaces bottom toast: dark rounded card centered on screen,
  auto-hides after 5 seconds
- Optional test trigger via node_toast_test intent extra
2026-08-14 13:28:55 +03:00
ua1zbe
ea36319fe3 Add connected nodes list dialog (v1.0.4)
- Tap callsign on main screen shows dialog with connected nodes
- SvxService tracks node list: ServerInfo + NODE_JOINED/NODE_LEFT events
- Bump to 1.0.4
2026-08-14 13:05:05 +03:00
ua1zbe
fc224e3e09 Replace app icon with walkie-talkie (icons8 style)
- Adaptive icon foreground is now the purple walkie-talkie PNG on the
  dark background, replaces the white mic vector
- Notification small icon now uses the walkie silhouette
2026-08-14 11:15:35 +03:00
ua1zbe
583ec62e39 Bump version to 1.0.3 2026-08-14 09:37:33 +03:00
ua1zbe
bff0d29ee8 Talk group switcher on main screen, auto-connect on launch
- Tapping the TG badge opens a group list; picking a group saves it and
  reconnects to the server with the new group
- Groups configured in settings (comma-separated), defaults 9,11,12,13
- Auto-connect at app launch to the default group from settings
2026-08-14 09:34:26 +03:00
ua1zbe
abb6f254bc Single-instance app, talker info in notification
- MainActivity singleTask, SplashActivity singleTop: re-launching from
  launcher or notification never creates a second instance
- Splash skips the 4s animation when the main screen is already open
- Foreground notification shows 'Говорит: CALLSIGN' / 'Последний:
  CALLSIGN'; tapping it brings the app back to the front
2026-08-14 09:19:23 +03:00
ua1zbe
1a9dd9a5a4 Add in-app update checker via Gitea API (v1.0.2)
- Check latest release on startup, show 'update available' dialog with
  Update/ Later buttons
- Download APK with progress and install via ACTION_VIEW + custom
  UpdateFileProvider (no androidx)
- Request unknown-app-sources permission if needed, resume download on
  return to the app
2026-08-14 08:54:17 +03:00
ua1zbe
68e149638e Add animated splash screen (4s), show app version on settings page 2026-08-13 16:33:22 +03:00
ua1zbe
805aaa2411 Fix Opus RX decoding, add foreground service, last-talker display
- Decode received Opus audio: handle svxlink self-delimited framing and
  provide monotonic PTS (c2.opus.decoder discards output when PTS == 0)
- Keep connection and playback alive in background via SvxService
  (foreground service, media playback type)
- Keep screen on while app is open
- Show last talker callsign persistently instead of clearing after 4s
- Bump versionCode to 2, disable lint on release builds
2026-08-13 16:23:41 +03:00
28 changed files with 1458 additions and 235 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote"
minSdk = 26
targetSdk = 34
versionCode = 1
versionName = "1.0"
versionCode = 6
versionName = "1.0.4"
}
buildTypes {
@@ -21,6 +21,11 @@ android {
}
}
lint {
checkReleaseBuilds = false
abortOnError = false
}
compileOptions {
sourceCompatibility = JavaVersion.VERSION_17
targetCompatibility = JavaVersion.VERSION_17
@@ -39,8 +44,11 @@ dependencies {
android {
testOptions {
unitTests.all {
unitTests {
isReturnDefaultValues = true
all {
it.maxHeapSize = "256m"
}
}
}
}

View File

@@ -4,6 +4,9 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.RECORD_AUDIO" />
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:label="@string/app_name"
@@ -12,20 +15,39 @@
android:supportsRtl="true"
android:theme="@style/Theme.SvxRemote">
<service
android:name=".SvxService"
android:exported="false"
android:foregroundServiceType="mediaPlayback" />
<activity
android:name=".SettingsActivity"
android:exported="false"
android:label="@string/settings_title" />
<activity
android:name=".MainActivity"
android:name=".SplashActivity"
android:exported="true"
android:launchMode="singleTop"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden">
android:theme="@style/Theme.SvxRemote">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<activity
android:name=".MainActivity"
android:exported="false"
android:launchMode="singleTask"
android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden" />
<provider
android:name=".UpdateFileProvider"
android:authorities="ru.ua1zbe.svxremote.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
</application>
</manifest>

View File

@@ -2,27 +2,40 @@ package ru.ua1zbe.svxremote
import android.Manifest
import android.app.Activity
import android.app.AlertDialog
import android.content.ComponentName
import android.content.Context
import android.content.Intent
import android.content.ServiceConnection
import android.content.pm.PackageManager
import android.net.Uri
import android.graphics.PixelFormat
import android.os.Build
import android.os.Bundle
import android.os.Handler
import android.os.IBinder
import android.os.Looper
import android.provider.Settings
import android.util.Log
import android.widget.Button
import android.view.Gravity
import android.view.View
import android.view.WindowManager
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import ru.ua1zbe.svxremote.audio.OpusEngine
import java.io.File
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
private const val REQ_NOTIF = 1002
class MainActivity : Activity(), ReflectorClient.Callbacks {
class MainActivity : Activity(), SvxService.Listener {
companion object {
private const val TAG = "SVXRemote"
@@ -30,31 +43,47 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
private lateinit var statusChip: TextView
private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView
private lateinit var lblTg: 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 lblConnectHint: TextView
private lateinit var connectZone: View
private lateinit var rxBarFill: View
private lateinit var rxDb: TextView
private lateinit var micBarFill: View
private lateinit var micDb: TextView
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()
private var svc: SvxService? = null
private val svcConn = object : ServiceConnection {
override fun onServiceConnected(name: ComponentName, binder: IBinder) {
svc = (binder as SvxService.LocalBinder).service()
svc?.listener = this@MainActivity
}
override fun onServiceDisconnected(name: ComponentName) {
svc = null
}
}
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = null
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
SplashActivity.mainAlive = true
setContentView(R.layout.activity_main)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
checkForUpdates()
statusChip = findViewById(R.id.status_chip)
lblSubtitle = findViewById(R.id.lbl_subtitle)
@@ -62,42 +91,48 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
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)
lblConnectHint = findViewById(R.id.lbl_connect_hint)
connectZone = findViewById(R.id.connect_zone)
lblCallsign = findViewById(R.id.lbl_callsign)
lblTg = findViewById(R.id.lbl_tg)
rxBarFill = findViewById(R.id.rx_bar_fill)
rxDb = findViewById(R.id.rx_db)
micBarFill = findViewById(R.id.mic_bar_fill)
micDb = findViewById(R.id.mic_db)
pttButton = findViewById(R.id.ptt_button)
levelMeter = findViewById(R.id.level_meter)
btnSettings = findViewById(R.id.btn_settings)
pttButton.enabled2 = false
engine = OpusEngine { source, level ->
levelMeter.setLevel(source, level)
val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) {
mainHandler.postDelayed({ showNodeOverlay(testText) }, 1500)
}
client = ReflectorClient(this)
btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
btnConnect.setOnClickListener {
findViewById<ImageButton>(R.id.btn_power).setOnClickListener {
svc?.disconnect()
stopService(Intent(this, SvxService::class.java))
SplashActivity.mainAlive = false
finishAffinity()
}
lblTg.setOnClickListener { showGroupsDialog() }
lblCallsign.setOnClickListener { showNodesDialog() }
connectZone.setOnClickListener {
if (connected) {
client.disconnect()
svc?.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
)
)
requestPermissionsIfNeeded()
connect()
}
}
@@ -111,28 +146,49 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
toast(getString(R.string.perm_mic))
return
}
lblTxState.visibility = TextView.VISIBLE
lblPttHint.text = getString(R.string.ptt_talking)
engine.startCapture { packet ->
client.sendAudio(packet)
}
lblTxState.text = getString(R.string.status_big_talking)
svc?.startTx()
}
override fun onPttUp() {
engine.stopCapture()
client.sendAudioFlush()
lblTxState.visibility = TextView.GONE
lblPttHint.text = getString(R.string.ptt_hint)
svc?.stopTx()
lblPttHint.text = getString(R.string.hold_to_talk)
lblTxState.text =
if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected)
}
})
refreshUi()
}
private var autoConnectAttempted = false
override fun onResume() {
super.onResume()
settings = Prefs.load(this)
refreshUi()
if (!autoConnectAttempted &&
settings.host.isNotBlank() &&
settings.callsign.isNotBlank()
) {
autoConnectAttempted = true
requestPermissionsIfNeeded()
connect()
}
if (pendingUpdate != null && updateDialog == null && canInstall()) {
startUpdate()
}
}
override fun onDestroy() {
super.onDestroy()
hideNodeOverlay()
mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false
svc?.listener = null
unbindService(svcConn)
}
private fun refreshUi() {
@@ -142,9 +198,15 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
} else {
getString(R.string.server_subtitle, host, settings.port, settings.talkGroup)
}
lblCallsign.text = if (settings.callsign.isBlank()) {
getString(R.string.settings_not_configured)
} else {
settings.callsign
}
lblTg.text = getString(R.string.tg_badge, settings.talkGroup)
}
// ---------- Callbacks от ReflectorClient (фоновые потоки!) ----------
// ---------- Callbacks от SvxService (фоновые потоки!) ----------
override fun onState(state: Int, detail: String?) {
Log.d(TAG, "state=$state detail=$detail")
@@ -154,7 +216,11 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
connected = false
updatePttEnabled()
statusChip.text = getString(R.string.status_disconnected)
btnConnect.text = getString(R.string.btn_connect)
statusChip.setTextColor(getColor(R.color.danger))
lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect)
lblPttHint.text = getString(R.string.hold_to_talk)
}
ReflectorClient.STATE_CONNECTING,
@@ -163,7 +229,9 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
ReflectorClient.STATE_WAIT_AUTH_OK,
ReflectorClient.STATE_WAIT_SERVER_INFO -> {
statusChip.text = getString(R.string.status_connecting)
btnConnect.text = getString(R.string.btn_disconnect)
statusChip.setTextColor(getColor(R.color.warn))
lblTxState.text = getString(R.string.status_connecting).uppercase()
lblTxState.setTextColor(getColor(R.color.warn))
connected = false
updatePttEnabled()
}
@@ -172,14 +240,20 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
connected = true
updatePttEnabled()
statusChip.text = getString(R.string.status_connected)
btnConnect.text = getString(R.string.btn_disconnect)
statusChip.setTextColor(getColor(R.color.accent))
lblTxState.text = getString(R.string.status_big_connected)
lblTxState.setTextColor(getColor(R.color.accent))
lblConnectHint.text = getString(R.string.hint_disconnect)
}
ReflectorClient.STATE_AUTH_FAILED -> {
connected = false
updatePttEnabled()
statusChip.text = getString(R.string.status_auth_failed)
btnConnect.text = getString(R.string.btn_connect)
statusChip.setTextColor(getColor(R.color.danger))
lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect)
toast(getString(R.string.status_error, detail ?: ""))
}
@@ -187,7 +261,10 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
connected = false
updatePttEnabled()
statusChip.text = getString(R.string.status_error, detail ?: "")
btnConnect.text = getString(R.string.btn_connect)
statusChip.setTextColor(getColor(R.color.danger))
lblTxState.text = getString(R.string.status_big_disconnected)
lblTxState.setTextColor(getColor(R.color.danger))
lblConnectHint.text = getString(R.string.hint_connect)
}
}
}
@@ -205,29 +282,81 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
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, tg: Long?) {
showNodeToast(R.string.toast_node_joined, callsign, tg)
}
override fun onNodeLeft(callsign: String, tg: Long?) {
showNodeToast(R.string.toast_node_left, callsign, tg)
}
private fun showNodeToast(tpl: Int, callsign: String, tg: Long?) {
if (!Prefs.nodeToastsEnabled(this)) return
if (callsign.equals(settings.callsign, ignoreCase = true)) return
val suffix = if (tg != null) getString(R.string.node_tg_suffix, tg) else ""
mainHandler.post { showNodeOverlay(getString(tpl, callsign, suffix)) }
}
private var nodeOverlay: View? = null
private fun showNodeOverlay(text: String) {
hideNodeOverlay()
val view = layoutInflater.inflate(R.layout.view_node_toast, null, false)
view.findViewById<TextView>(R.id.lbl_node_toast).text = text
try {
windowManager.addView(
view,
WindowManager.LayoutParams(
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.WRAP_CONTENT,
WindowManager.LayoutParams.TYPE_APPLICATION,
WindowManager.LayoutParams.FLAG_NOT_FOCUSABLE or
WindowManager.LayoutParams.FLAG_NOT_TOUCHABLE,
PixelFormat.TRANSLUCENT
).apply { gravity = Gravity.CENTER }
)
nodeOverlay = view
mainHandler.postDelayed({ hideNodeOverlay() }, 5000)
} catch (_: Exception) {
toast(text)
}
}
override fun onNodeJoined(callsign: String) {
private fun hideNodeOverlay() {
nodeOverlay?.let { v ->
try { windowManager.removeView(v) } catch (_: Exception) { }
nodeOverlay = null
}
override fun onNodeLeft(callsign: String) {
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet)
}
override fun onAudioFlush() {
engine.onAudioFlush()
}
override fun onLevel(source: Int, level: Float) {
mainHandler.post {
val db = if (level <= 0.001f) {
getString(R.string.meter_idle)
} else {
getString(R.string.meter_db, (level * 60).toInt().coerceIn(1, 60))
}
if (source == 0) { // SRC_MIC
micBarFill.scaleX = level.coerceIn(0f, 1f)
micDb.text = db
} else { // SRC_SPEAKER
rxBarFill.scaleX = level.coerceIn(0f, 1f)
rxDb.text = db
}
}
}
override fun onError(detail: String) {
@@ -236,25 +365,200 @@ class MainActivity : Activity(), ReflectorClient.Callbacks {
}
}
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()
private fun requestPermissionsIfNeeded() {
if (checkSelfPermission(Manifest.permission.RECORD_AUDIO) != PackageManager.PERMISSION_GRANTED) {
requestPermissions(arrayOf(Manifest.permission.RECORD_AUDIO), REQ_MIC)
}
if (Build.VERSION.SDK_INT >= 33 &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIF)
}
}
override fun onDestroy() {
super.onDestroy()
mainHandler.removeCallbacks(talkerTimeout)
client.disconnect()
engine.releaseAll()
/** Запустить сервис и подключиться с текущими настройками. */
private fun connect() {
startForegroundService(Intent(this, SvxService::class.java))
bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE)
mainHandler.postDelayed({
svc?.connect(
ReflectorClient.Settings(
host = settings.host,
port = settings.port,
callsign = settings.callsign,
authKey = settings.authKey,
talkGroup = settings.talkGroup
)
)
}, 300)
}
/** Переключиться на другую группу: пересохранить и переподключиться. */
private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg)
Prefs.save(this, settings)
refreshUi()
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
}
private fun showNodesDialog() {
val nodes = svc?.nodeList().orEmpty()
if (nodes.isEmpty()) {
toast(getString(R.string.toast_nodes_empty))
return
}
val myCs = settings.callsign.trim()
val items = nodes
.map { (cs, tg) ->
val t = tg ?: if (cs.equals(myCs, ignoreCase = true)) settings.talkGroup else null
if (t != null) getString(R.string.node_with_tg, cs, t) else cs
}
.sorted()
AlertDialog.Builder(this)
.setTitle(getString(R.string.nodes_title, items.size))
.setItems(items.toTypedArray(), null)
.show()
}
private fun showGroupsDialog() {
val groups = (settings.groups + settings.talkGroup).distinct()
if (groups.isEmpty()) {
toast(getString(R.string.toast_groups_empty))
return
}
val current = settings.talkGroup
AlertDialog.Builder(this)
.setTitle(R.string.select_tg)
.setItems(groups.map { tg ->
val label = getString(R.string.tg_badge, tg)
if (tg == current) "$label" else label
}.toTypedArray()) { _, which ->
val tg = groups[which]
if (tg != current) switchGroup(tg)
}
.show()
}
private fun checkForUpdates() {
Thread {
val version = try {
packageManager.getPackageInfo(packageName, 0).versionName
} catch (_: Exception) {
return@Thread
}
val info = try {
UpdateChecker.check(version)
} catch (e: Exception) {
Log.w(TAG, "update check failed: ${e.message}")
null
}
if (info != null) {
mainHandler.post { showUpdateDialog(info) }
} else {
Log.d(TAG, "update: no newer version")
}
}.start()
}
private fun showUpdateDialog(info: UpdateChecker.UpdateInfo) {
pendingUpdate = info
updateDialog = AlertDialog.Builder(this)
.setTitle(R.string.update_available_title)
.setMessage(getString(R.string.update_available_msg, info.version))
.setPositiveButton(R.string.update_btn) { _, _ -> startUpdate() }
.setNegativeButton(R.string.update_later) { _, _ ->
pendingUpdate = null
updateDialog = null
}
.setOnCancelListener {
pendingUpdate = null
updateDialog = null
}
.show()
}
private fun canInstall(): Boolean =
Build.VERSION.SDK_INT < Build.VERSION_CODES.O || packageManager.canRequestPackageInstalls()
private fun startUpdate() {
val info = pendingUpdate ?: return
if (!canInstall()) {
startActivity(
Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:$packageName")
)
)
return
}
val progress = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal)
progress.max = 100
val label = TextView(this)
label.setText(R.string.update_downloading)
label.setPadding(dp(20), dp(16), dp(20), 0)
val content = LinearLayout(this).apply {
orientation = LinearLayout.VERTICAL
addView(label)
addView(
progress,
LinearLayout.LayoutParams(
LinearLayout.LayoutParams.MATCH_PARENT,
LinearLayout.LayoutParams.WRAP_CONTENT
).apply { topMargin = dp(12) }
)
}
updateDialog = AlertDialog.Builder(this)
.setTitle(R.string.update_available_title)
.setView(content)
.setCancelable(false)
.show()
val file = File(cacheDir, "updates/svxhomyak-${info.version}.apk")
file.parentFile?.mkdirs()
Thread {
try {
UpdateChecker.download(info.apkUrl, file) { p ->
mainHandler.post { progress.progress = (p * 100).toInt() }
}
mainHandler.post {
updateDialog?.dismiss()
updateDialog = null
pendingUpdate = null
installApk(file)
}
} catch (e: Exception) {
mainHandler.post {
updateDialog?.dismiss()
updateDialog = null
pendingUpdate = null
toast(getString(R.string.update_failed, e.message))
}
}
}.start()
}
private fun installApk(file: File) {
val uri = Uri.Builder()
.scheme("content")
.authority("$packageName.fileprovider")
.path("updates/${file.name}")
.build()
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
}
startActivity(intent)
}
private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt()
private fun toast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
override fun onRequestPermissionsResult(

View File

@@ -3,6 +3,7 @@ package ru.ua1zbe.svxremote
import android.app.Activity
import android.os.Bundle
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings
@@ -15,6 +16,7 @@ class SettingsActivity : Activity() {
private lateinit var inputCallsign: EditText
private lateinit var inputAuthKey: EditText
private lateinit var inputTg: EditText
private lateinit var inputGroups: EditText
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
@@ -25,6 +27,9 @@ class SettingsActivity : Activity() {
inputCallsign = findViewById(R.id.input_callsign)
inputAuthKey = findViewById(R.id.input_auth_key)
inputTg = findViewById(R.id.input_tg)
inputGroups = findViewById(R.id.input_groups)
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
val s = Prefs.load(this)
inputHost.setText(s.host)
@@ -32,6 +37,7 @@ class SettingsActivity : Activity() {
inputCallsign.setText(s.callsign)
inputAuthKey.setText(s.authKey)
inputTg.setText(s.talkGroup.toString())
inputGroups.setText(s.groups.joinToString(", "))
findViewById<Button>(R.id.btn_save).setOnClickListener {
val host = inputHost.text.toString().trim()
@@ -39,6 +45,10 @@ class SettingsActivity : Activity() {
val callsign = inputCallsign.text.toString().trim()
val authKey = inputAuthKey.text.toString()
val tg = inputTg.text.toString().trim().toLongOrNull() ?: 11
val groups = inputGroups.text.toString()
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
if (host.isBlank() || callsign.isBlank()) {
toast(getString(R.string.toast_settings_empty))
@@ -52,9 +62,11 @@ class SettingsActivity : Activity() {
port = port.coerceIn(1, 65535),
callsign = callsign,
authKey = authKey,
talkGroup = tg.coerceAtLeast(0)
talkGroup = tg.coerceAtLeast(0),
groups = groups
)
)
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
toast(getString(R.string.saved))
finish()
}

View File

@@ -0,0 +1,72 @@
package ru.ua1zbe.svxremote
import android.app.Activity
import android.content.Intent
import android.os.Bundle
import android.view.View
import android.view.animation.AccelerateDecelerateInterpolator
/** Красивая заставка с плавным появлением логотипа. */
class SplashActivity : Activity() {
companion object {
@Volatile
var mainAlive = false
}
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
if (mainAlive) {
openMain()
return
}
setContentView(R.layout.activity_splash)
val title = findViewById<View>(R.id.splash_title)
val subtitle = findViewById<View>(R.id.splash_subtitle)
val line = findViewById<View>(R.id.splash_line)
title.alpha = 0f
subtitle.alpha = 0f
line.alpha = 0f
title.animate()
.alpha(1f)
.scaleX(1.0f)
.scaleY(1.0f)
.setStartDelay(150)
.setDuration(700)
.setInterpolator(AccelerateDecelerateInterpolator())
.start()
subtitle.animate()
.alpha(1f)
.setStartDelay(600)
.setDuration(600)
.start()
line.animate()
.alpha(1f)
.setStartDelay(900)
.setDuration(500)
.start()
window.decorView.postDelayed({
openMain()
}, 4000)
}
private fun openMain() {
val intent = Intent(this, MainActivity::class.java)
.addFlags(
Intent.FLAG_ACTIVITY_NEW_TASK or
Intent.FLAG_ACTIVITY_CLEAR_TOP or
Intent.FLAG_ACTIVITY_SINGLE_TOP
)
val testText = this.intent?.getStringExtra("node_toast_test")
if (testText != null) intent.putExtra("node_toast_test", testText)
startActivity(intent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
finish()
}
}

View File

@@ -0,0 +1,168 @@
package ru.ua1zbe.svxremote
import android.app.Notification
import android.app.NotificationChannel
import android.app.NotificationManager
import android.app.PendingIntent
import android.app.Service
import android.content.Intent
import android.os.Binder
import android.os.IBinder
import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient
/**
* Foreground-сервис: держит соединение с рефлектором и декодер
* живыми в фоне и при выключенном экране.
*/
class SvxService : Service(), ReflectorClient.Callbacks {
interface Listener {
fun onState(state: Int, detail: String?)
fun onServerInfo(info: MsgParser.ServerInfo)
fun onTalkerStart(tg: Long, callsign: String)
fun onTalkerStop(tg: Long, callsign: String)
fun onNodeJoined(callsign: String, tg: Long?)
fun onNodeLeft(callsign: String, tg: Long?)
fun onAudioPacket(packet: ByteArray, seq: Int)
fun onAudioFlush()
fun onError(detail: String)
fun onLevel(source: Int, level: Float)
}
var listener: Listener? = null
private val engine by lazy {
OpusEngine { source, level -> listener?.onLevel(source, level) }
}
val client: ReflectorClient by lazy { ReflectorClient(this) }
private val binder = LocalBinder()
inner class LocalBinder : Binder() {
fun service(): SvxService = this@SvxService
}
override fun onBind(intent: Intent): IBinder = binder
override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int {
startForegroundCompat()
return START_STICKY
}
private fun startForegroundCompat() {
val nm = getSystemService(NotificationManager::class.java)
nm.createNotificationChannel(
NotificationChannel("svx", "SVXRemote", NotificationManager.IMPORTANCE_LOW)
)
updateNotification(getString(R.string.notif_connected))
}
private fun updateNotification(text: String) {
val contentIntent = PendingIntent.getActivity(
this, 0,
Intent(this, MainActivity::class.java)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_SINGLE_TOP),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
)
val n = Notification.Builder(this, "svx")
.setContentTitle("SVXRemote")
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher_fg)
.setOngoing(true)
.setContentIntent(contentIntent)
.build()
startForeground(1, n)
}
fun connect(settings: ReflectorClient.Settings) {
startForegroundCompat()
client.connect(settings)
}
fun disconnect() {
client.disconnect()
}
fun isRunning(): Boolean = client.isRunning()
fun startTx() {
engine.startCapture { packet -> client.sendAudio(packet) }
}
fun stopTx() {
engine.stopCapture()
client.sendAudioFlush()
}
// ---------- Callbacks (фоновые потоки) ----------
override fun onState(state: Int, detail: String?) {
when (state) {
ReflectorClient.STATE_READY -> {
updateNotification(getString(R.string.notif_connected))
engine.startDecoder()
}
ReflectorClient.STATE_DISCONNECTED -> {
engine.stopDecoder()
stopForeground(STOP_FOREGROUND_REMOVE)
}
}
listener?.onState(state, detail)
}
override fun onServerInfo(info: MsgParser.ServerInfo) {
synchronized(nodeSet) {
nodeSet.clear()
nodeSet.addAll(info.nodes)
}
listener?.onServerInfo(info)
}
private val nodeSet = LinkedHashSet<String>()
private val nodeTgs = HashMap<String, Long>()
/** Текущий список узлов и известные группы (копия для UI). */
fun nodeList(): List<Pair<String, Long?>> = synchronized(nodeSet) {
nodeSet.map { it to nodeTgs[it] }
}
override fun onNodeJoined(callsign: String, tg: Long?) {
synchronized(nodeSet) { nodeSet.add(callsign) }
listener?.onNodeJoined(callsign, tg)
}
override fun onNodeLeft(callsign: String, tg: Long?) {
synchronized(nodeSet) {
nodeSet.remove(callsign)
nodeTgs.remove(callsign)
}
listener?.onNodeLeft(callsign, tg)
}
override fun onTalkerStart(tg: Long, callsign: String) {
synchronized(nodeSet) {
if (callsign in nodeSet) nodeTgs[callsign] = tg
}
updateNotification(getString(R.string.notif_talking, callsign))
listener?.onTalkerStart(tg, callsign)
}
override fun onTalkerStop(tg: Long, callsign: String) {
updateNotification(getString(R.string.notif_last_talker, callsign))
listener?.onTalkerStop(tg, callsign)
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet)
}
override fun onAudioFlush() {
engine.onAudioFlush()
}
override fun onError(detail: String) {
listener?.onError(detail)
}
}

View File

@@ -0,0 +1,77 @@
package ru.ua1zbe.svxremote
import org.json.JSONArray
import org.json.JSONObject
import java.io.File
import java.net.HttpURLConnection
import java.net.URL
/** Проверка и загрузка обновлений через API Gitea. */
object UpdateChecker {
data class UpdateInfo(val version: String, val apkUrl: String)
private const val API_URL =
"https://git2.ua1zbe.ru/api/v1/repos/ua1zbe/svxhomyak/releases/latest"
/** Вернуть информацию об обновлении, если доступна более новая версия. */
fun check(currentVersion: String): UpdateInfo? {
val conn = URL(API_URL).openConnection() as HttpURLConnection
conn.connectTimeout = 10_000
conn.readTimeout = 10_000
return try {
if (conn.responseCode != 200) return null
val json = JSONObject(conn.inputStream.bufferedReader().readText())
val version = json.optString("tag_name", "").removePrefix("v")
if (!isNewer(version, currentVersion)) return null
val assets = json.optJSONArray("assets") ?: JSONArray()
var url: String? = null
for (i in 0 until assets.length()) {
val a = assets.getJSONObject(i)
if (a.optString("name").endsWith(".apk")) {
url = a.optString("browser_download_url")
break
}
}
url?.let { UpdateInfo(version, it) }
} finally {
conn.disconnect()
}
}
/** Скачать APK в файл, сообщая прогресс 0..1. */
fun download(url: String, target: File, onProgress: (Float) -> Unit) {
val conn = URL(url).openConnection() as HttpURLConnection
conn.connectTimeout = 15_000
conn.readTimeout = 15_000
try {
val total = conn.contentLengthLong
var done = 0L
conn.inputStream.use { input ->
target.outputStream().use { out ->
val buf = ByteArray(64 * 1024)
while (true) {
val n = input.read(buf)
if (n < 0) break
out.write(buf, 0, n)
done += n
if (total > 0) onProgress(done.toFloat() / total)
}
}
}
} finally {
conn.disconnect()
}
}
private fun isNewer(remote: String, current: String): Boolean {
val r = remote.split('.').mapNotNull { it.toIntOrNull() }
val c = current.split('.').mapNotNull { it.toIntOrNull() }
for (i in 0 until maxOf(r.size, c.size)) {
val a = r.getOrElse(i) { 0 }
val b = c.getOrElse(i) { 0 }
if (a != b) return a > b
}
return false
}
}

View File

@@ -0,0 +1,34 @@
package ru.ua1zbe.svxremote
import android.content.ContentProvider
import android.content.ContentValues
import android.database.Cursor
import android.net.Uri
import android.os.ParcelFileDescriptor
import java.io.File
/** Минимальный FileProvider для установки APK (без androidx). */
class UpdateFileProvider : ContentProvider() {
override fun onCreate(): Boolean = true
override fun openFile(uri: Uri, mode: String): ParcelFileDescriptor {
val name = uri.lastPathSegment
?: throw IllegalArgumentException("bad uri")
if (name != name.substringAfterLast('/')) {
throw IllegalArgumentException("bad uri")
}
val path = File(context?.cacheDir, "updates/$name")
return ParcelFileDescriptor.open(path, ParcelFileDescriptor.MODE_READ_ONLY)
}
override fun getType(uri: Uri): String = "application/vnd.android.package-archive"
override fun insert(uri: Uri, values: ContentValues?): Uri? = null
override fun delete(uri: Uri, selection: String?, selectionArgs: Array<out String>?): Int = 0
override fun query(
uri: Uri, projection: Array<out String>?, selection: String?,
selectionArgs: Array<out String>?, sortOrder: String?
): Cursor? = null
override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array<out String>?): Int = 0
}

View File

@@ -9,6 +9,7 @@ import android.media.MediaCodecInfo
import android.media.MediaFormat
import android.media.MediaRecorder
import android.os.Build
import android.util.Log
import java.nio.ByteBuffer
import java.nio.ByteOrder
import kotlin.math.abs
@@ -23,7 +24,9 @@ class OpusEngine(
) {
companion object {
const val TAG = "SVXRemote"
const val SAMPLE_RATE = 8000
const val OUT_SAMPLE_RATE = 48000 // декодер opus всегда выдаёт 48 кГц
const val CHANNELS = 1
const val FRAME_SIZE = 160 // 20 мс в 8 кГц
const val BIT_RATE = 20000
@@ -32,7 +35,9 @@ class OpusEngine(
const val SRC_SPEAKER = 1
}
@Volatile
private var encoder: MediaCodec? = null
@Volatile
private var decoder: MediaCodec? = null
private var recorder: AudioRecord? = null
private var player: AudioTrack? = null
@@ -46,6 +51,11 @@ class OpusEngine(
// ---------- Воспроизведение (RX) ----------
private var headSent = false
private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue<ByteArray>()
private var drainThread: Thread? = null
private var ptsCounter = 0L
fun startDecoder() {
if (decoder != null) return
val format = MediaFormat()
@@ -53,47 +63,80 @@ class OpusEngine(
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 — приоритет медиа
}
// Android 16: c2.android.opus.decoder ждёт 3 конфиг-буфера:
// csd-0 OpusHead, csd-1 CodecDelay (int64 LE нс), csd-2 SeekPreRoll (int64 LE нс).
// Без csd-1/2 декодер берёт мусорные значения из пакетов и молча
// выкидывает весь вывод (mSamplesToDiscard).
format.setByteBuffer("csd-0", ByteBuffer.wrap(opusHead()))
// Прескип 312 сэмплов при 8 кГц = 39 мс = 39_000_000 нс (перекодируется в 48 кГц внутри)
format.setByteBuffer("csd-1", ByteBuffer.wrap(longBytes(39_000_000L)))
format.setByteBuffer("csd-2", ByteBuffer.wrap(longBytes(0L)))
decoder = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.configure(format, null, null, 0)
it.start()
}
} catch (e: Exception) {
Log.e(TAG, "createDecoder failed: ${e.message}")
null
}
if (decoder == null) return
val minBuf = AudioTrack.getMinBufferSize(
SAMPLE_RATE,
OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT
)
player = AudioTrack(
player = try {
AudioTrack(
AudioManager.STREAM_MUSIC,
SAMPLE_RATE,
OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, FRAME_SIZE * 2 * 8),
AudioTrack.MODE_STREAM
).also { it.play() }
} catch (e: Exception) {
Log.e(TAG, "AudioTrack create failed: ${e.message}")
null
}
headSent = true // csd уходит через формат, не через буферы
drainThread = Thread({ drainLoop() }, "opus-out").also { it.start() }
Log.d(TAG, "decoder started")
}
/** Подача одного Opus-пакета на декодер. */
fun onAudioPacket(packet: ByteArray) {
/** int64 в little-endian. */
private fun longBytes(v: Long): ByteArray =
ByteBuffer.allocate(8).order(ByteOrder.LITTLE_ENDIAN).putLong(v).array()
/** Отдельный поток: голова + входная очередь + слив вывода. */
private fun drainLoop() {
val dec = decoder ?: return
val track = player ?: return
var dbg = 0
while (decoder != null && decoder === dec) {
try {
val inIdx = dec.dequeueInputBuffer(10000)
if (inIdx >= 0) {
val buf = dec.getInputBuffer(inIdx) ?: return
// Монотонный pts обязателен: при pts==0 c2.opus.decoder
// считает каждый кадр «перемоткой в ноль» и выбрасывает
// mCodecDelay сэмплов (C2SoftOpusDec.cpp, "When seeking to zero").
var pts = ptsCounter
while (true) {
val pkt = rxQueue.poll() ?: break
val inIdx = dec.dequeueInputBuffer(5000)
if (inIdx < 0) { rxQueue.add(pkt); break }
val buf = dec.getInputBuffer(inIdx) ?: break
buf.clear()
buf.put(packet)
dec.queueInputBuffer(inIdx, 0, packet.size, 0, 0)
buf.put(pkt)
dec.queueInputBuffer(inIdx, 0, pkt.size, pts, 0)
pts += 20_000L // 20 мс кадра
ptsCounter = pts
if (dbg < 5) { Log.d(TAG, "queued ${pkt.size}B"); dbg++ }
}
var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
var outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 1000)
while (outIdx >= 0) {
if (outIdx == MediaCodec.INFO_OUTPUT_FORMAT_CHANGED) {
val f = dec.outputFormat
Log.d(TAG, "dec out format: sr=${f.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " +
"ch=${f.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}")
} else {
if (decodeBufferInfo.size > 0) {
val outBuf = ByteBuffer.allocate(decodeBufferInfo.size)
outBuf.order(ByteOrder.LITTLE_ENDIAN)
@@ -103,25 +146,81 @@ class OpusEngine(
outBuf.put(src)
}
val pcm = outBuf.array()
track.write(pcm, 0, pcm.size)
player?.write(pcm, 0, pcm.size)
onLevel(SRC_SPEAKER, pcmLevel(pcm))
if (dbg < 5) { Log.d(TAG, "out ${pcm.size}B"); dbg++ }
}
dec.releaseOutputBuffer(outIdx, false)
}
outIdx = dec.dequeueOutputBuffer(decodeBufferInfo, 0)
}
} catch (_: Exception) {
} catch (e: Exception) {
Log.e(TAG, "drain error: ${e.message}")
}
}
}
/** Подача одного Opus-пакета на декодер. */
fun onAudioPacket(packet: ByteArray) {
rxQueue.add(packet)
}
fun onAudioFlush() {
try {
decoder?.flush()
player?.flush()
rxQueue.clear()
onLevel(SRC_SPEAKER, 0f)
} catch (_: Exception) {
}
}
/** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */
private fun opusHead(): ByteArray {
val head = "OpusHead".toByteArray(Charsets.US_ASCII)
val out = ByteArray(19)
System.arraycopy(head, 0, out, 0, head.size)
out[8] = 1 // версия Opus
out[9] = 1 // каналов (моно)
out[10] = 0x38 // pre-skip LE = 312
out[11] = 0x01
out[12] = 0x40 // input sample rate LE = 8000
out[13] = 0x1F
out[14] = 0x00
out[15] = 0x00
out[16] = 0x00 // output gain
out[17] = 0x00
out[18] = 0x00 // channel mapping family = 0
return out
}
/** Остановить декодер и плеер (при отключении). */
fun stopDecoder() {
val dec = decoder
decoder = null // поток разбора выйдет из цикла до release
rxQueue.clear()
if (dec != null) {
try {
dec.stop()
} catch (_: Exception) {
}
try {
dec.release()
} catch (_: Exception) {
}
}
try {
player?.stop()
} catch (_: Exception) {
}
try {
player?.release()
} catch (_: Exception) {
}
player = null
onLevel(SRC_SPEAKER, 0f)
}
// ---------- Захват (TX) ----------
/** Начать PTT: запустить запись и кодирование. */
@@ -238,24 +337,7 @@ class OpusEngine(
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
stopDecoder()
onLevel(SRC_SPEAKER, 0f)
}

View File

@@ -8,7 +8,8 @@ data class ConnectionSettings(
val port: Int,
val callsign: String,
val authKey: String,
val talkGroup: Long
val talkGroup: Long,
val groups: List<Long> = emptyList()
)
object Prefs {
@@ -21,7 +22,12 @@ object Prefs {
port = sp.getInt("port", 5300),
callsign = sp.getString("callsign", "") ?: "",
authKey = sp.getString("auth_key", "") ?: "",
talkGroup = sp.getLong("tg", 11)
talkGroup = sp.getLong("tg", 11),
groups = (sp.getString("groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.ifEmpty { listOf(9L, 11L, 12L, 13L) }
)
}
@@ -33,6 +39,17 @@ object Prefs {
.putString("callsign", s.callsign.trim())
.putString("auth_key", s.authKey)
.putLong("tg", s.talkGroup)
.putString("groups", s.groups.distinct().joinToString(","))
.apply()
}
fun nodeToastsEnabled(context: Context): Boolean {
return context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.getBoolean("node_toasts", true)
}
fun setNodeToastsEnabled(context: Context, enabled: Boolean) {
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
.edit().putBoolean("node_toasts", enabled).apply()
}
}

View File

@@ -7,6 +7,7 @@ import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
/**
* Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP).
@@ -22,8 +23,8 @@ class ReflectorClient(
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)
fun onNodeJoined(callsign: String, tg: Long?)
fun onNodeLeft(callsign: String, tg: Long?)
/** Входящий аудиопакет Opus. */
fun onAudioPacket(packet: ByteArray, seq: Int)
fun onAudioFlush()
@@ -31,6 +32,8 @@ class ReflectorClient(
}
companion object {
const val TAG = "SVXRemote"
const val STATE_DISCONNECTED = 0
const val STATE_CONNECTING = 1
const val STATE_TCP_CONNECTED = 2
@@ -45,6 +48,9 @@ class ReflectorClient(
const val TCP_HEARTBEAT_INTERVAL_MS = 2000L
const val UDP_HEARTBEAT_INTERVAL_MS = 4000L
const val RECONNECT_DELAY_MS = 5000L
const val CLIENT_SW_INFO = "SVXHomyak"
const val CLIENT_SW_VERSION = "1.0.0"
}
data class Settings(
@@ -350,18 +356,24 @@ class ReflectorClient(
callbacks.onServerInfo(info)
state = STATE_READY
callbacks.onState(STATE_READY)
// Сначала сообщаем о себе (версия ПО для дашборда), затем
// выбираем TG для передачи и подписываемся на прослушивание
// (иначе сервер не шлёт аудио группы и не показывает
// подписку в статусе: monitoredTGs остаётся пустым).
sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION))
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
openUdp(s)
}
ReflectorProtocol.MSG_NODE_JOINED -> {
val cs = parseSimpleString(msg.fields)
callbacks.onNodeJoined(cs)
val (cs, tg) = parseNodeEvent(msg.fields)
callbacks.onNodeJoined(cs, tg)
}
ReflectorProtocol.MSG_NODE_LEFT -> {
val cs = parseSimpleString(msg.fields)
callbacks.onNodeLeft(cs)
val (cs, tg) = parseNodeEvent(msg.fields)
callbacks.onNodeLeft(cs, tg)
}
ReflectorProtocol.MSG_TALKER_START -> {
@@ -392,6 +404,17 @@ class ReflectorClient(
return true
}
private fun parseNodeEvent(fields: ByteArray): Pair<String, Long?> {
return try {
val r = BinReader(fields)
val cs = r.str().ifEmpty { "?" }
val tg = try { r.str().toLongOrNull() } catch (_: Exception) { null }
cs to tg
} catch (_: Exception) {
"?" to null
}
}
private fun parseSimpleString(fields: ByteArray): String {
return try {
BinReader(fields).str().ifEmpty { "?" }
@@ -471,11 +494,18 @@ class ReflectorClient(
ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) {
Log.d(TAG, "UDP RX audio seq=$seq len=${audio.size}")
callbacks.onAudioPacket(audio, seq)
} else {
Log.w(TAG, "UDP RX audio empty/null len=${payload.size}")
}
}
ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> callbacks.onAudioFlush()
ReflectorProtocol.MSG_UDP_FLUSH_SAMPLES -> {
Log.d(TAG, "UDP RX flush")
callbacks.onAudioFlush()
}
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit
else -> Log.d(TAG, "UDP RX type=$type len=${data.size}")
}
}

View File

@@ -195,6 +195,12 @@ object MsgBuilder {
return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray())
}
/** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.). */
fun nodeInfo(swInfo: String, swVersion: String): RcMessage {
val json = "{\"swInfo\":\"$swInfo\",\"swVersion\":\"$swVersion\"}"
return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).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)

View File

@@ -84,28 +84,46 @@ class PttButton @JvmOverloads constructor(
canvas.drawCircle(centerX, centerY, r, paint)
canvas.drawCircle(centerX, centerY, r, ringPaint)
// Иконка микрофона
// Иконка микрофона (как icons8: капсула + чашка + ножка + основание)
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
val s = r * 0.5f
// корпус-капсула
val halfW = s * 0.34f
canvas.drawRoundRect(
x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
halfW, halfW, micPaint
)
// чашка-держатель (U-образная, заполненная)
val cup = Path()
cup.fillType = Path.FillType.EVEN_ODD
cup.moveTo(x - s * 0.52f, y + s * 0.06f)
cup.lineTo(x - s * 0.52f, y + s * 0.48f)
cup.quadTo(x - s * 0.52f, y + s * 0.70f, x, y + s * 0.70f)
cup.quadTo(x + s * 0.52f, y + s * 0.70f, x + s * 0.52f, y + s * 0.48f)
cup.lineTo(x + s * 0.52f, y + s * 0.06f)
cup.close()
cup.moveTo(x - s * 0.38f, y + s * 0.06f)
cup.lineTo(x - s * 0.38f, y + s * 0.44f)
cup.quadTo(x - s * 0.38f, y + s * 0.56f, x, y + s * 0.56f)
cup.quadTo(x + s * 0.38f, y + s * 0.56f, x + s * 0.38f, y + s * 0.44f)
cup.lineTo(x + s * 0.38f, y + s * 0.06f)
cup.close()
canvas.drawPath(cup, micPaint)
// ножка
canvas.drawRoundRect(
x - s * 0.08f, y + s * 0.46f, x + s * 0.08f, y + s * 0.72f,
s * 0.08f, s * 0.08f, micPaint
)
// основание
canvas.drawRoundRect(
x - s * 0.30f, y + s * 0.66f, x + s * 0.30f, y + s * 0.90f,
s * 0.12f, s * 0.12f, micPaint
)
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

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" />
<stroke android:width="1dp" android:color="@color/surface_light" />
<corners android:radius="8dp" />
</shape>

View File

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

View File

@@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<solid android:color="@color/info" />
<corners android:radius="8dp" />
</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="#E61B222A" />
<corners android:radius="24dp" />
<stroke android:width="1dp" android:color="#40FFFFFF" />
</shape>

View File

@@ -0,0 +1,8 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
<gradient
android:angle="135"
android:startColor="#0D1117"
android:centerColor="#101826"
android:endColor="#0D1117" />
</shape>

View File

@@ -1,16 +0,0 @@
<?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="24dp"
android:height="24dp"
android:viewportWidth="24"
android:viewportHeight="24">
<path
android:fillColor="#8B949E"
android:pathData="M13,3h-2v10h2L13,3zM17.83,5.17l-1.42,1.42C17.99,7.86 19,9.81 19,12c0,3.87 -3.13,7 -7,7s-7,-3.13 -7,-7c0,-2.19 1.01,-4.14 2.58,-5.41L6.17,5.17C4.23,6.82 3,9.26 3,12c0,4.97 4.03,9 9,9s9,-4.03 9,-9C21,9.26 19.77,6.82 17.83,5.17z" />
</vector>

View File

@@ -4,24 +4,37 @@
android:layout_height="match_parent"
android:orientation="vertical"
android:background="@color/bg"
android:gravity="center_horizontal"
android:padding="16dp">
<!-- Верхняя панель -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<ImageButton
android:id="@+id/btn_power"
android:layout_width="48dp"
android:layout_height="48dp"
android:src="@drawable/ic_power"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_power"
android:scaleType="centerInside" />
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView
android:id="@+id/status_chip"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:paddingHorizontal="14dp"
android:paddingVertical="6dp"
android:text="@string/app_title"
android:textColor="@color/text_primary"
android:textSize="14sp"
android:background="@drawable/bg_chip"
android:text="@string/status_disconnected" />
android:textSize="20sp"
android:textStyle="bold" />
<Space
android:layout_width="0dp"
@@ -38,63 +51,221 @@
android:scaleType="centerInside" />
</LinearLayout>
<TextView
android:id="@+id/lbl_subtitle"
<!-- Позывной + комната + группа -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginTop="36dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:id="@+id/lbl_callsign"
android:layout_width="0dp"
android:layout_weight="1"
android:layout_height="wrap_content"
android:fontFamily="monospace"
android:textColor="@color/text_primary"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/settings_not_configured" />
<TextView
android:id="@+id/lbl_talker_title"
android:id="@+id/lbl_tg"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="14dp"
android:textColor="@color/warn"
android:textSize="22sp"
android:textStyle="bold"
android:fontFamily="monospace" />
</LinearLayout>
<TextView
android:id="@+id/lbl_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:singleLine="true"
android:ellipsize="end" />
</LinearLayout>
</LinearLayout>
<!-- Статус соединения -->
<TextView
android:id="@+id/status_chip"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="20dp"
android:gravity="center"
android:textColor="@color/danger"
android:textSize="18sp"
android:textStyle="bold"
android:text="@string/status_disconnected" />
<!-- S-метры -->
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="28dp"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="34dp"
android:layout_height="wrap_content"
android:text="@string/meter_rx"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:text="@string/talker_none" />
android:textSize="13sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="16dp"
android:layout_weight="1"
android:background="@drawable/bg_meter">
<View
android:id="@+id/rx_bar_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_meter_fill_rx"
android:pivotX="0"
android:scaleX="0" />
</FrameLayout>
<TextView
android:id="@+id/rx_db"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:gravity="end"
android:fontFamily="monospace"
android:text="@string/meter_idle"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:orientation="horizontal"
android:gravity="center_vertical">
<TextView
android:layout_width="34dp"
android:layout_height="wrap_content"
android:text="@string/meter_mic"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:textStyle="bold" />
<FrameLayout
android:layout_width="0dp"
android:layout_height="16dp"
android:layout_weight="1"
android:background="@drawable/bg_meter">
<View
android:id="@+id/mic_bar_fill"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_meter_fill_mic"
android:pivotX="0"
android:scaleX="0" />
</FrameLayout>
<TextView
android:id="@+id/mic_db"
android:layout_width="64dp"
android:layout_height="wrap_content"
android:gravity="end"
android:fontFamily="monospace"
android:text="@string/meter_idle"
android:textColor="@color/text_primary"
android:textSize="13sp" />
</LinearLayout>
</LinearLayout>
<!-- Последний говоривший -->
<TextView
android:id="@+id/lbl_talker_title"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:text="@string/talker_last_none" />
<TextView
android:id="@+id/lbl_talker"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/accent"
android:textSize="28sp"
android:textSize="22sp"
android:textStyle="bold"
android:singleLine="true"
android:ellipsize="end"
android:text="@string/talker_none" />
android:ellipsize="end" />
<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" />
<!-- Кнопка PTT -->
<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" />
android:layout_width="130dp"
android:layout_height="130dp" />
<TextView
android:id="@+id/lbl_ptt_hint"
android:layout_width="match_parent"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:text="@string/ptt_hint" />
android:textSize="14sp"
android:textStyle="bold"
android:text="@string/hold_to_talk" />
<Space
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<!-- Нижняя зона статуса (кликабельна) -->
<LinearLayout
android:id="@+id/connect_zone"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="8dp"
android:clickable="true"
android:focusable="true">
<TextView
android:id="@+id/lbl_tx_state"
@@ -104,17 +275,17 @@
android:textColor="@color/danger"
android:textSize="14sp"
android:textStyle="bold"
android:visibility="gone"
android:text="@string/ptt_talking" />
android:text="@string/status_big_disconnected" />
<Button
android:id="@+id/btn_connect"
<TextView
android:id="@+id/lbl_connect_hint"
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" />
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:textStyle="italic"
android:text="@string/hint_connect" />
</LinearLayout>
</LinearLayout>

View File

@@ -110,7 +110,7 @@
android:id="@+id/input_tg"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:layout_marginBottom="16dp"
android:inputType="number"
android:textColor="@color/text_primary"
android:textColorHint="@color/text_secondary"
@@ -119,6 +119,35 @@
android:padding="12dp"
android:hint="11" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_groups"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<EditText
android:id="@+id/input_groups"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
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="9, 11, 12, 13" />
<CheckBox
android:id="@+id/chk_node_toasts"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="24dp"
android:text="@string/pref_node_toasts"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Button
android:id="@+id/btn_save"
android:layout_width="match_parent"
@@ -128,5 +157,15 @@
android:background="@drawable/bg_primary_btn"
android:textSize="16sp"
android:textStyle="bold" />
<TextView
android:id="@+id/lbl_version"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:gravity="center"
android:text="@string/app_version"
android:textColor="@color/text_secondary"
android:textSize="12sp" />
</LinearLayout>
</ScrollView>

View File

@@ -0,0 +1,44 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:background="@drawable/bg_splash">
<LinearLayout
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:orientation="vertical"
android:gravity="center">
<TextView
android:id="@+id/splash_title"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="SVXhomyak"
android:textColor="@color/text_primary"
android:textSize="34sp"
android:textStyle="bold"
android:letterSpacing="0.08"
android:alpha="0" />
<TextView
android:id="@+id/splash_subtitle"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="10dp"
android:text="СВЯЗЬ ЧЕРЕЗ СЕРВЕР"
android:textColor="@color/text_secondary"
android:textSize="12sp"
android:letterSpacing="0.3"
android:alpha="0" />
<View
android:id="@+id/splash_line"
android:layout_width="48dp"
android:layout_height="2dp"
android:layout_marginTop="16dp"
android:background="@color/info"
android:alpha="0" />
</LinearLayout>
</FrameLayout>

View File

@@ -0,0 +1,20 @@
<?xml version="1.0" encoding="utf-8"?>
<FrameLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:id="@+id/node_toast_root"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/bg_node_toast"
android:paddingLeft="24dp"
android:paddingRight="24dp"
android:paddingTop="14dp"
android:paddingBottom="14dp">
<TextView
android:id="@+id/lbl_node_toast"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="#F0F0F0"
android:textSize="16sp"
android:textStyle="bold" />
</FrameLayout>

View File

@@ -9,22 +9,50 @@
<string name="pref_callsign">Позывной</string>
<string name="pref_auth_key">Пароль (AUTH_KEY)</string>
<string name="pref_tg">Группа вещания (TG)</string>
<string name="pref_groups">Группы для переключения (через запятую)</string>
<string name="select_tg">Выбор группы</string>
<string name="toast_groups_empty">Добавьте группы в настройках</string>
<string name="nodes_title">Узлы в сети (%1$d)</string>
<string name="toast_nodes_empty">Нет подключенных узлов</string>
<string name="pref_node_toasts">Всплывающее уведомление о подключении/отключении узлов</string>
<string name="toast_node_joined">Подключился: %1$s%2$s</string>
<string name="toast_node_left">Отключился: %1$s%2$s</string>
<string name="node_tg_suffix"> (группа %1$d)</string>
<string name="node_with_tg">%1$s — TG%2$d</string>
<string name="btn_save">Сохранить</string>
<string name="app_version">SVXhomyak — v1.0.4</string>
<string name="saved">Сохранено</string>
<string name="pref_callsign_hint">например UA1ZBE</string>
<string name="status_disconnected">Отключено</string>
<string name="app_title">SVXHomyak</string>
<string name="btn_power">Выключить: отключиться и закрыть</string>
<string name="status_big_disconnected">DISCONNECTED</string>
<string name="status_big_connected">CONNECTED</string>
<string name="status_big_talking">ПЕРЕДАЧА</string>
<string name="hint_connect">Нажмите, чтобы подключиться</string>
<string name="hint_disconnect">Нажмите, чтобы отключиться</string>
<string name="hold_to_talk">HOLD TO TALK</string>
<string name="meter_rx">RX</string>
<string name="meter_mic">MIC</string>
<string name="meter_idle">---</string>
<string name="meter_db">-%1$d dB</string>
<string name="tg_badge">TG%1$d ▾</string>
<string name="talker_last_none">Последний: —</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="notif_connected">Соединение с рефлектором активно</string>
<string name="notif_talking">Говорит: %1$s</string>
<string name="notif_last_talker">Последний: %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_now">Последний: %1$s</string>
<string name="talker_on_tg">группа %1$d</string>
<string name="ptt_hint">Удерживайте кнопку, чтобы говорить</string>
@@ -43,4 +71,11 @@
<string name="settings_not_configured">Настройки не заданы</string>
<string name="ptt_disabled">Нет связи</string>
<string name="update_available_title">Доступно обновление</string>
<string name="update_available_msg">Версия %1$s уже вышла. Обновить приложение?</string>
<string name="update_btn">Обновить</string>
<string name="update_later">Позже</string>
<string name="update_downloading">Загрузка обновления…</string>
<string name="update_failed">Не удалось загрузить обновление: %1$s</string>
</resources>

View File

@@ -150,9 +150,13 @@ class HandshakeTest {
.u16(1).str(sentCodecs)
.toByteArray()
writeFrame(out, RcMessage(ReflectorProtocol.MSG_SERVER_INFO, info))
// Client должен прислать MsgSelectTG(11) и UDP-датаграмму
// Client должен прислать MsgSelectTG(11), MsgTgMonitor(11) и
// MsgNodeInfo с версией ПО, плюс UDP-датаграмму
var gotTg = false
while (!gotTg) {
var gotMonitor = false
var gotNodeInfo = false
var tries = 0
while ((!gotTg || !gotMonitor || !gotNodeInfo) && tries++ < 100) {
val frame3 = readFrame(input, buf)
val msg3 = RcMessage.fromPayload(frame3)
when (msg3.type) {
@@ -163,9 +167,23 @@ gotTg = true
sawSelectTg = true
selectTgLatch.countDown()
}
ReflectorProtocol.MSG_TG_MONITOR -> {
val rr = BinReader(msg3.fields)
assertEquals(1, rr.u16())
assertEquals(11L, rr.u32())
gotMonitor = true
}
ReflectorProtocol.MSG_NODE_INFO -> {
val json = BinReader(msg3.fields).str()
assertTrue("JSON: $json", json.contains("swVersion"))
assertTrue("JSON: $json", json.contains("1.0.0"))
gotNodeInfo = true
}
ReflectorProtocol.MSG_HEARTBEAT -> Unit
}
}
assertTrue("TgMonitor не получен", gotMonitor)
assertTrue("NodeInfo не получен", gotNodeInfo)
// Ждём UDP-датаграмму (регистрация UDP)
try {
val ds = DatagramSocket(server.localPort)

View File

@@ -87,6 +87,27 @@ class ProtocolTest {
assertEquals(11L, r.u32())
}
@Test
fun `tgMonitor roundtrip`() {
val parsed = RcMessage.tryParseFrame(MsgBuilder.tgMonitor(listOf(11, 22)).toFrame())
assertEquals(ReflectorProtocol.MSG_TG_MONITOR, parsed?.type)
val r = BinReader(parsed!!.fields)
assertEquals(2, r.u16())
assertEquals(11L, r.u32())
assertEquals(22L, r.u32())
}
@Test
fun `nodeInfo roundtrip и версия ПО`() {
val parsed = RcMessage.tryParseFrame(
MsgBuilder.nodeInfo("SVXHomyak", "1.0.0").toFrame()
)
assertEquals(ReflectorProtocol.MSG_NODE_INFO, parsed?.type)
val json = BinReader(parsed!!.fields).str()
assertTrue("JSON: $json", json.contains("\"swInfo\":\"SVXHomyak\""))
assertTrue("JSON: $json", json.contains("\"swVersion\":\"1.0.0\""))
}
@Test
fun `udp audio roundtrip`() {
val audio = ByteArray(30) { (it and 0xFF).toByte() }