36 Commits

Author SHA1 Message Date
ua1zbe
78ac8648a6 Default APRS coords to Saint Petersburg center; bump to 1.0.7 2026-08-18 09:14:19 +03:00
ua1zbe
ac10ffc7fa Remove APRS callsign setting; connect to APRS-IS with SVX callsign, keep only coords 2026-08-18 09:01:30 +03:00
ua1zbe
f5cb74ba25 Show meteo line centered with WX prefix 2026-08-18 08:53:09 +03:00
ua1zbe
4102140fa0 Fix APRS meteo temp: t-field is whole degrees Fahrenheit (was dividing by 10); round to whole Celsius 2026-08-18 08:46:48 +03:00
ua1zbe
985cd9eb1a Convert APRS meteo temperature from Fahrenheit to Celsius 2026-08-18 07:58:17 +03:00
ua1zbe
7564c8d092 Add APRS weather stations (experimental): receive meteo in 250 km radius, show temperature in main screen line 2026-08-17 16:58:00 +03:00
ua1zbe
fac3c7d692 Bump version to 1.0.6 2026-08-17 13:23:15 +03:00
ua1zbe
884678d36d Fix RX audio for subsequent transmissions (recreate decoder on flush) 2026-08-17 13:18:50 +03:00
ua1zbe
9d7b659231 Add last talkers log (15 entries, time/callsign/TG) 2026-08-17 11:28:39 +03:00
ua1zbe
ee61d3ef6f Update monitored TG when switching group live 2026-08-17 11:14:41 +03:00
ua1zbe
3109c52bbd Fix group switching: send selectGroup on background thread 2026-08-17 11:11:30 +03:00
ua1zbe
eeffc14f1a Switch talk group without reconnecting 2026-08-17 10:55:44 +03:00
ua1zbe
9838daea2f Add multiple connection profiles (v1.0.6) 2026-08-17 10:39:15 +03:00
ua1zbe
531ec51fb6 Release v1.0.5 (versionCode 7) 2026-08-17 09:55:24 +03:00
ua1zbe
e523bf756b Monitoring toggle toasts: 10s dev warning on enable, normal mode on disable (v1.0.5) 2026-08-17 09:50:26 +03:00
ua1zbe
ef72a2dc7f Show dev-warning toast on monitoring button press (v1.0.5) 2026-08-17 09:46:51 +03:00
ua1zbe
ef4fa24ba5 Fix node info field name: swVer is shown on reflector dashboard (v1.0.5) 2026-08-17 09:39:46 +03:00
ua1zbe
a1b2bdaa1c Report actual app version to reflector (BuildConfig.VERSION_NAME) (v1.0.5) 2026-08-17 09:34:26 +03:00
ua1zbe
0b51e63262 Fix Opus decoding and RX jitter buffer on Android 16 (v1.0.5) 2026-08-17 09:22:47 +03:00
ua1zbe
268c9d76af Fix flush on PTT release; monitoring priority tweaks (v1.0.5) 2026-08-17 08:22:47 +03:00
ua1zbe
11446a92a5 Add group monitoring mode (v1.0.5)
- Monitor button between callsign and TG selector
- On: reconnects with TG_MONITOR for all switch-list groups,
  status shows 'Мониторинг: TG...', PTT disabled
- Talker display shows callsign and its TG
- Off: back to normal talk mode
2026-08-14 15:01:46 +03:00
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
29 changed files with 2081 additions and 280 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote"
minSdk = 26
targetSdk = 34
versionCode = 2
versionName = "1.0.0"
versionCode = 9
versionName = "1.0.7"
}
buildTypes {
@@ -21,6 +21,10 @@ android {
}
}
buildFeatures {
buildConfig = true
}
lint {
checkReleaseBuilds = false
abortOnError = false

View File

@@ -6,6 +6,7 @@
<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"
@@ -25,14 +26,28 @@
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,38 @@ 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.content.res.ColorStateList
import android.graphics.Color
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.view.Gravity
import android.view.MotionEvent
import android.view.View
import android.view.WindowManager
import android.widget.Button
import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import java.io.File
import ru.ua1zbe.svxremote.aprs.AprsMeteo
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
@@ -32,22 +43,39 @@ class MainActivity : Activity(), SvxService.Listener {
companion object {
private const val TAG = "SVXRemote"
private const val TALK_LOG_MAX = 15
}
private lateinit var statusChip: TextView
private lateinit var lblAppTitle: 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 btnMonitor: ImageButton
private val mainHandler = Handler(Looper.getMainLooper())
private var titleHoldRunnable: Runnable? = null
private data class TalkLogEntry(val time: Long, val callsign: String, val tg: Long)
private val talkLog = ArrayDeque<TalkLogEntry>()
private var connected = false
private var monitoring = false
private var connectedSettings: ConnectionSettings? = null
private var priorityActive = false
private var settings: ConnectionSettings = ConnectionSettings("", 5300, "", "", 11)
private var svc: SvxService? = null
@@ -62,58 +90,108 @@ class MainActivity : Activity(), SvxService.Listener {
}
}
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = null
// ---------- APRS метеостанции (экспериментально) ----------
private var aprsMeteo: AprsMeteo? = null
private var lastMeteo: String? = null
private var lastAprsCoords = ""
private fun startAprsMeteo() {
aprsMeteo?.stop()
aprsMeteo = null
// Пустые координаты — центр Санкт-Петербурга
val coords = Prefs.aprsCoords(this)
.split(',')
.mapNotNull { it.trim().toDoubleOrNull() }
.take(2)
.let { if (it.size == 2) it[0] to it[1] else 59.95 to 30.30 }
aprsMeteo = AprsMeteo(coords) { wx ->
lastMeteo = wx
lblSubtitle.text = wx
}.also { it.start() }
}
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)
lblAppTitle = findViewById(R.id.lbl_app_title)
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)
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)
btnMonitor = findViewById(R.id.btn_monitor)
pttButton.enabled2 = false
btnMonitor.setOnClickListener { toggleMonitoring() }
updateMonitorButton()
val testText = intent?.getStringExtra("node_toast_test")
if (testText != null) {
mainHandler.postDelayed({ showNodeOverlay(testText) }, 1500)
}
btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java))
}
btnConnect.setOnClickListener {
val s = svc
if (connected && s != null) {
s.disconnect()
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() }
lblTalker.setOnClickListener { showTalkLogDialog() }
// Удержание на названии программы (> 2 с) — выбор профиля настроек
lblAppTitle.setOnTouchListener { _, event ->
when (event.actionMasked) {
MotionEvent.ACTION_DOWN -> {
val r = Runnable { showProfileDialog() }
titleHoldRunnable = r
mainHandler.postDelayed(r, 2000)
true
}
MotionEvent.ACTION_UP, MotionEvent.ACTION_CANCEL -> {
titleHoldRunnable?.let { mainHandler.removeCallbacks(it) }
titleHoldRunnable = null
true
}
else -> true
}
}
connectZone.setOnClickListener {
if (connected) {
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
}
if (Build.VERSION.SDK_INT >= 33 &&
checkSelfPermission(Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED
) {
requestPermissions(arrayOf(Manifest.permission.POST_NOTIFICATIONS), REQ_NOTIF)
}
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)
requestPermissionsIfNeeded()
connect()
}
}
@@ -127,40 +205,89 @@ class MainActivity : Activity(), SvxService.Listener {
toast(getString(R.string.perm_mic))
return
}
lblTxState.visibility = TextView.VISIBLE
lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking)
svc?.startTx()
}
override fun onPttUp() {
Log.d("SVXRemote", "PTT up")
svc?.stopTx()
lblTxState.visibility = TextView.GONE
lblPttHint.text = getString(R.string.ptt_hint)
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()
startAprsMeteo()
lastAprsCoords = Prefs.aprsCoords(this).trim()
}
private var autoConnectAttempted = false
override fun onResume() {
super.onResume()
val prev = settings
settings = Prefs.load(this)
if (prev != settings && connectedSettings != null) {
if (settings.host.isBlank() || settings.callsign.isBlank()) {
monitoring = false
priorityActive = false
updateMonitorButton()
updatePttEnabled()
svc?.disconnect()
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
refreshUi()
val aprsCoords = Prefs.aprsCoords(this).trim()
if (aprsCoords != lastAprsCoords) {
lastAprsCoords = aprsCoords
startAprsMeteo()
}
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()
aprsMeteo?.stop()
aprsMeteo = null
mainHandler.removeCallbacksAndMessages(null)
SplashActivity.mainAlive = false
svc?.listener = null
unbindService(svcConn)
}
private fun refreshUi() {
val host = settings.host
lblSubtitle.text = if (host.isBlank()) {
lblSubtitle.text = lastMeteo ?: if (host.isBlank()) {
getString(R.string.settings_not_configured)
} 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 от SvxService (фоновые потоки!) ----------
@@ -173,7 +300,11 @@ class MainActivity : Activity(), SvxService.Listener {
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,
@@ -182,7 +313,9 @@ class MainActivity : Activity(), SvxService.Listener {
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()
}
@@ -190,15 +323,25 @@ class MainActivity : Activity(), SvxService.Listener {
ReflectorClient.STATE_READY -> {
connected = true
updatePttEnabled()
statusChip.text = getString(R.string.status_connected)
btnConnect.text = getString(R.string.btn_disconnect)
statusChip.text = if (monitoring) {
getString(R.string.status_monitoring, monitorTgLabel())
} else {
getString(R.string.status_connected)
}
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 ?: ""))
}
@@ -206,7 +349,10 @@ class MainActivity : Activity(), SvxService.Listener {
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)
}
}
}
@@ -222,13 +368,83 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onTalkerStart(tg: Long, callsign: String) {
mainHandler.post {
lblTalkerTitle.text = getString(R.string.talker_now, callsign)
lblTalker.text = callsign
talkLog.addLast(TalkLogEntry(System.currentTimeMillis(), callsign, tg))
while (talkLog.size > TALK_LOG_MAX) {
talkLog.removeFirst()
}
val priority = monitoring && settings.groups.firstOrNull() == tg
if (priority) {
priorityActive = true
}
if (priority || !priorityActive) {
svc?.selectGroup(tg)
lblTalkerTitle.text = when {
priority -> getString(R.string.talker_mon_priority, callsign, tg)
monitoring -> getString(R.string.talker_mon, callsign, tg)
else -> getString(R.string.talker_now, callsign)
}
lblTalkerTitle.setTextColor(
if (priority) getColor(R.color.danger) else getColor(R.color.text_secondary)
)
lblTalker.text = callsign
}
}
}
override fun onTalkerStop(tg: Long, callsign: String) {
// последний говоривший остаётся на экране
mainHandler.post {
if (monitoring && settings.groups.firstOrNull() == tg) {
priorityActive = false
}
}
}
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)
}
}
private fun hideNodeOverlay() {
nodeOverlay?.let { v ->
try { windowManager.removeView(v) } catch (_: Exception) { }
nodeOverlay = null
}
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
@@ -239,7 +455,18 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onLevel(source: Int, level: Float) {
mainHandler.post {
levelMeter.setLevel(source, level)
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
}
}
}
@@ -250,9 +477,300 @@ class MainActivity : Activity(), SvxService.Listener {
}
private fun updatePttEnabled() {
pttButton.enabled2 = connected
pttButton.enabled2 = connected && !monitoring
}
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)
}
}
/** Запустить сервис и подключиться с текущими настройками. */
private fun connect() {
connectedSettings = settings
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,
monitorTgs = if (monitoring) settings.groups else null
)
)
}, 300)
}
/** Включить/выключить режим мониторинга групп с переподключением. */
private fun toggleMonitoring() {
val enabling = !monitoring
if (enabling) {
showToast(R.string.monitoring_dev_warning, 10000)
} else {
showToast(R.string.monitor_off)
}
monitoring = enabling
priorityActive = false
updateMonitorButton()
updatePttEnabled()
if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
/** Окно с сообщением на заданное время (мс). */
private fun showToast(resId: Int, durationMs: Int = Toast.LENGTH_SHORT) {
val toast = Toast.makeText(this, resId, Toast.LENGTH_SHORT)
toast.show()
if (durationMs > Toast.LENGTH_SHORT) {
mainHandler.postDelayed({ toast.cancel() }, durationMs.toLong())
}
}
/** Список профилей: выбор сразу подключается с новыми настройками. */
private fun showProfileDialog() {
val profiles = Prefs.loadProfiles(this)
val active = Prefs.activeProfileIndex(this)
val items = profiles.mapIndexed { i, p ->
val s = p.settings
val detail = if (s.callsign.isBlank()) {
getString(R.string.profile_empty)
} else {
"${s.callsign} @ ${s.host}:${s.port}"
}
val marker = if (i == active) getString(R.string.profile_current) else ""
"${p.name} — $detail$marker"
}.toTypedArray()
AlertDialog.Builder(this)
.setTitle(getString(R.string.profiles_title))
.setItems(items) { _, which ->
if (which != active) {
Prefs.setActiveProfile(this, which)
settings = Prefs.load(this)
monitoring = false
priorityActive = false
updateMonitorButton()
updatePttEnabled()
refreshUi()
if (settings.host.isBlank() || settings.callsign.isBlank()) {
toast(getString(R.string.toast_need_settings))
} else if (connected) {
svc?.disconnect()
mainHandler.postDelayed({ connect() }, 500)
} else {
connect()
}
}
}
.setNegativeButton(getString(R.string.cancel), null)
.show()
}
private fun updateMonitorButton() {
val active = monitoring
btnMonitor.imageTintList = ColorStateList.valueOf(
if (active) Color.parseColor("#58A6FF") else Color.parseColor("#8B949E")
)
btnMonitor.setBackgroundResource(if (active) R.drawable.bg_monitor_on else 0)
btnMonitor.contentDescription =
getString(if (active) R.string.btn_monitor_on else R.string.btn_monitor)
}
/** Переключиться на другую группу: отправить серверу по живому соединению. */
private fun switchGroup(tg: Long) {
settings = settings.copy(talkGroup = tg)
connectedSettings = settings
Prefs.save(this, settings)
refreshUi()
svc?.selectGroup(tg)
if (!monitoring) {
svc?.setMonitorTgs(listOf(tg))
}
}
private fun monitorTgLabel(): String =
settings.groups.joinToString(", ") { "TG$it" }
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()
}
/** Лог последних выходов на передачу (до 15, время — позывной — группа). */
private fun showTalkLogDialog() {
if (talkLog.isEmpty()) {
toast(getString(R.string.talk_log_empty))
return
}
val fmt = java.text.SimpleDateFormat("HH:mm", java.util.Locale.getDefault())
val items = talkLog.map { e ->
getString(R.string.talk_log_entry, fmt.format(java.util.Date(e.time)), e.callsign, e.tg)
}.toTypedArray()
AlertDialog.Builder(this)
.setTitle(R.string.talk_log_title)
.setItems(items, null)
.setPositiveButton(R.string.close, 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()
}

View File

@@ -2,36 +2,64 @@ package ru.ua1zbe.svxremote
import android.app.Activity
import android.os.Bundle
import android.view.View
import android.widget.AdapterView
import android.widget.ArrayAdapter
import android.widget.Button
import android.widget.CheckBox
import android.widget.EditText
import android.widget.Spinner
import android.widget.Toast
import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.prefs.Profile
class SettingsActivity : Activity() {
private lateinit var spinnerProfile: Spinner
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
private lateinit var inputGroups: EditText
private lateinit var inputAprsCoords: EditText
private var currentProfile = 0
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_settings)
spinnerProfile = findViewById(R.id.spinner_profile)
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)
inputGroups = findViewById(R.id.input_groups)
inputAprsCoords = findViewById(R.id.input_aprs_coords)
val chkNodeToasts = findViewById<CheckBox>(R.id.chk_node_toasts)
chkNodeToasts.isChecked = Prefs.nodeToastsEnabled(this)
inputAprsCoords.setText(Prefs.aprsCoords(this))
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())
val profileNames = (1..Prefs.PROFILE_COUNT)
.map { getString(R.string.profile_name, it) }
spinnerProfile.adapter = ArrayAdapter(
this, android.R.layout.simple_spinner_dropdown_item, profileNames
)
currentProfile = Prefs.activeProfileIndex(this)
spinnerProfile.setSelection(currentProfile)
spinnerProfile.onItemSelectedListener = object : AdapterView.OnItemSelectedListener {
override fun onItemSelected(parent: AdapterView<*>?, view: View?, position: Int, id: Long) {
if (position != currentProfile) {
currentProfile = position
loadFields(Prefs.loadProfile(this@SettingsActivity, position).settings)
}
}
override fun onNothingSelected(parent: AdapterView<*>?) {}
}
loadFields(Prefs.loadProfile(this, currentProfile).settings)
findViewById<Button>(R.id.btn_save).setOnClickListener {
val host = inputHost.text.toString().trim()
@@ -39,28 +67,46 @@ 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))
return@setOnClickListener
}
Prefs.save(
this,
ConnectionSettings(
val profile = Profile(
name = getString(R.string.profile_name, currentProfile + 1),
settings = ConnectionSettings(
host = host,
port = port.coerceIn(1, 65535),
callsign = callsign,
authKey = authKey,
talkGroup = tg.coerceAtLeast(0)
talkGroup = tg.coerceAtLeast(0),
groups = groups
)
)
Prefs.saveProfile(this, currentProfile, profile)
Prefs.setActiveProfile(this, currentProfile)
Prefs.setNodeToastsEnabled(this, chkNodeToasts.isChecked)
Prefs.setAprsCoords(this, inputAprsCoords.text.toString())
toast(getString(R.string.saved))
finish()
}
}
private fun loadFields(s: ConnectionSettings) {
inputHost.setText(s.host)
inputPort.setText(s.port.toString())
inputCallsign.setText(s.callsign)
inputAuthKey.setText(s.authKey)
inputTg.setText(s.talkGroup.toString())
inputGroups.setText(s.groups.joinToString(", "))
}
private fun toast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
}
}
}

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

@@ -3,10 +3,12 @@ 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 android.util.Log
import ru.ua1zbe.svxremote.audio.OpusEngine
import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient
@@ -22,6 +24,8 @@ class SvxService : Service(), ReflectorClient.Callbacks {
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)
@@ -53,11 +57,22 @@ class SvxService : Service(), ReflectorClient.Callbacks {
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(getString(R.string.notif_connected))
.setSmallIcon(android.R.drawable.ic_menu_call)
.setContentText(text)
.setSmallIcon(R.drawable.ic_launcher_fg)
.setOngoing(true)
.setContentIntent(contentIntent)
.build()
startForeground(1, n)
}
@@ -73,20 +88,38 @@ class SvxService : Service(), ReflectorClient.Callbacks {
fun isRunning(): Boolean = client.isRunning()
fun selectGroup(tg: Long) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.selectGroup(tg) }.start()
}
fun setMonitorTgs(tgs: List<Long>) {
// Сеть запрещена в главном потоке (NetworkOnMainThreadException)
Thread { client.setMonitorTgs(tgs) }.start()
}
fun startTx() {
engine.startCapture { packet -> client.sendAudio(packet) }
}
fun stopTx() {
engine.stopCapture()
client.sendAudioFlush()
Log.d("SVXRemote", "stopTx")
// В фоне: сеть запрещена в главном потоке (NetworkOnMainThreadException),
// а join потока захвата блокировал бы UI
Thread {
engine.stopCapture()
client.sendAudioFlush()
}.start()
}
// ---------- Callbacks (фоновые потоки) ----------
override fun onState(state: Int, detail: String?) {
when (state) {
ReflectorClient.STATE_READY -> engine.startDecoder()
ReflectorClient.STATE_READY -> {
updateNotification(getString(R.string.notif_connected))
engine.startDecoder()
}
ReflectorClient.STATE_DISCONNECTED -> {
engine.stopDecoder()
stopForeground(STOP_FOREGROUND_REMOVE)
@@ -96,23 +129,47 @@ class SvxService : Service(), ReflectorClient.Callbacks {
}
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 onNodeJoined(callsign: String) {
}
override fun onNodeLeft(callsign: String) {
}
override fun onAudioPacket(packet: ByteArray, seq: Int) {
engine.onAudioPacket(packet)
}
@@ -124,4 +181,4 @@ class SvxService : Service(), ReflectorClient.Callbacks {
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

@@ -0,0 +1,157 @@
package ru.ua1zbe.svxremote.aprs
import android.os.Handler
import android.os.Looper
import android.util.Log
import ru.ua1zbe.svxremote.BuildConfig
import java.io.BufferedReader
import java.io.InputStreamReader
import java.net.InetSocketAddress
import java.net.Socket
/**
* Экспериментально: приём метеостанций с APRS-IS (aprs2.net) в радиусе 250 км
* от координат (фильтр r/). Весь код — в этом файле, удаляется вместе с
* вызовом в MainActivity и полями в настройках.
*/
class AprsMeteo(
private val coords: Pair<Double, Double>?,
private val onWeather: (String) -> Unit
) {
private val tag = "AprsMeteo"
private val main = Handler(Looper.getMainLooper())
@Volatile private var running = false
private var hostIdx = 0
fun start() {
if (running) return
running = true
Thread({ run() }, "aprs-meteo").start()
}
fun stop() {
running = false
}
private fun run() {
while (running) {
try {
runOnce()
} catch (e: Exception) {
if (running) Log.d(tag, "err: ${e.message}")
}
if (running) {
try {
Thread.sleep(30_000)
} catch (_: InterruptedException) {
break
}
}
}
}
private fun runOnce() {
val sock = Socket()
try {
sock.connect(InetSocketAddress(APRS_HOSTS[hostIdx], APRS_PORT), 10_000)
sock.soTimeout = 90_000
val filter = if (coords != null) {
"r/${coords.first}/${coords.second}/250"
} else {
// Координаты не заданы — функция отключена (MainActivity не запускает)
"b/$APRS_CALL/250"
}
// ВАЖНО: без "vers" — сервер пользователя (T2SPB/T2APATITY)
// не принимает фильтр и молчит; с "vers" — фильтр игнорируется
val login = "user $APRS_CALL pass ${passcode(APRS_CALL)} " +
"filter $filter\r\n"
sock.getOutputStream().write(login.toByteArray())
sock.getOutputStream().flush()
Log.d(tag, "connected to ${APRS_HOSTS[hostIdx]}, filter=$filter")
val reader = BufferedReader(InputStreamReader(sock.getInputStream()))
val start = System.currentTimeMillis()
var sawPacket = false
while (running) {
val line = try {
reader.readLine()
} catch (e: java.net.SocketTimeoutException) {
// Нет строк (даже heartbeat) — канал мёртв, переподключаемся
break
} ?: break
if (line.startsWith("#")) {
if (line.contains("logresp")) {
val server = line.substringAfter("server", "?").trim()
Log.d(tag, "server=$server")
}
// Сервер шлёт heartbeat, но не отдаёт пакеты. На основном
// сервере слушаем долго (станции передают редко), на
// запасном — 3 минуты, и возвращаемся на основной
val wait = if (hostIdx == 0) 900_000L else 180_000L
if (!sawPacket && System.currentTimeMillis() - start > wait) break
continue
}
sawPacket = true
if (BuildConfig.DEBUG) Log.d(tag, "pkt: " + line.substringBefore('>'))
parse(line)?.let { wx ->
Log.d(tag, "wx: $wx RAW=${line.length} $line")
main.post { onWeather(wx) }
}
}
// Сервер принял логин, но не отдал ни одного пакета (аплинк молчит) —
// в следующий раз пробуем следующий сервер. С пакетами — возвращаемся
// к первому (сервер пользователя)
hostIdx = if (sawPacket) 0 else (hostIdx + 1) % APRS_HOSTS.size
} finally {
try {
sock.close()
} catch (_: Exception) {
}
}
}
/** Парсинг: CALL>...:!|@|=<lat/lon>_<метео> -> "<call> Температура <T>C". */
private fun parse(line: String): String? {
if (line.startsWith("#") || line.isEmpty()) return null
// Разделитель заголовка — ПЕРВЫЙ ':' после '>'; в info могут быть свои ':'
// (например "Temp: 16.4°C Uptime: 319h")
val gt = line.indexOf('>')
val idx = line.indexOf(':', gt)
if (idx < 0 || idx + 1 >= line.length) return null
val body = line.substring(idx + 1)
if (body.isEmpty() || body[0] !in "!@=") return null
val temp = parseTemp(body) ?: return null
// В APRS метео-часть температура в градусах Фаренгейта
val c = (temp - 32.0) * 5.0 / 9.0
val t = Math.round(c).toString()
return "WX " + line.substringBefore('>') + " Температура: " + t + " °C"
}
/** Температура из метео-части после символа '_': t057=57°F, t06=6°F, t-08=-8°F.
* В APRS метео-формате температура — целые градусы Фаренгейта. */
private fun parseTemp(body: String): Double? {
val u = body.indexOf('_')
if (u < 0) return null
val meta = body.substring(u + 1).substringBefore(' ') // до комментария
val m = Regex("[tT]([+-]?)(\\d{2,3})").find(meta) ?: return null
val sign = if (m.groupValues[1] == "-") -1 else 1
return sign * m.groupValues[2].toInt().toDouble()
}
/** APRS-IS passcode по позывному (алгоритм aprsc). */
private fun passcode(call: String): Int {
var hash = 0x73E2
for (c in call.uppercase()) {
hash = hash xor (c.code shl 8)
hash = ((hash and 0xFFFF) * 0x89) and 0xFFFF
}
return hash and 0x7FFF
}
companion object {
// Позывной для подключения к APRS-IS (SVX). Сначала сервер пользователя;
// если он не отдаёт трафик (аплинк молчит), переходим на публичный aprs2.net
private const val APRS_CALL = "SVX"
private val APRS_HOSTS = listOf("rotate.radiorubka.org", "rotate.aprs2.net")
private const val APRS_PORT = 14580
}
}

View File

@@ -47,40 +47,36 @@ class OpusEngine(
private var recThread: Thread? = null
private val encodeBufferInfo = MediaCodec.BufferInfo()
private val decodeBufferInfo = MediaCodec.BufferInfo()
// ---------- Воспроизведение (RX) ----------
private var headSent = false
private val rxQueue = java.util.concurrent.ConcurrentLinkedQueue<ByteArray>()
private var drainThread: Thread? = null
private val freeInputs = java.util.concurrent.ConcurrentLinkedQueue<Int>()
private var ptsCounter = 0L
private var receivedAny = false
private var concealLogs = 0
private var dbgOut = 0
private var playStarted = false
private var gateStart = 0L
private val jitterTarget = 6 // ≈ 120 мс накапливаем до первого слива
private val configBuffers = ArrayList<ByteArray>()
private var configIdx = 0
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)
// ВАЖНО: c2.android.opus.decoder (Android 16) выводит только 48 кГц
// (kRate=48000); KEY_SAMPLE_RATE=8000 вызывает конфликт конфига
// («We have a failed config») и фатальную ошибку кодера.
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
format.setInteger(MediaFormat.KEY_MAX_INPUT_SIZE, 8192)
// 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
// Android 16: c2.android.opus.decoder читает конфиг (OpusHead + codec
// delay + seek preroll) ТОЛЬКО из первых входных буферов
// (см. queueDecoderConfig); csd-ключи MediaFormat он игнорирует, и без
// конфига первый же аудио-кадр ломает декодер (C2_CORRUPTED).
if (!startDecoderCodec(format)) return
Log.d(TAG, "decoder started, config=${configBuffers.size}B")
val minBuf = AudioTrack.getMinBufferSize(
OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
@@ -92,89 +88,259 @@ class OpusEngine(
OUT_SAMPLE_RATE,
AudioFormat.CHANNEL_OUT_MONO,
AudioFormat.ENCODING_PCM_16BIT,
maxOf(minBuf, FRAME_SIZE * 2 * 8),
maxOf(minBuf, 20000), // ~208 мс — буфер AudioTrack и есть джиттер-буфер
AudioTrack.MODE_STREAM
).also { it.play() }
) // play() включаем на первом выходном кадре — буфер успевает накопиться
} catch (e: Exception) {
Log.e(TAG, "AudioTrack create failed: ${e.message}")
null
}
headSent = true // csd уходит через формат, не через буферы
drainThread = Thread({ drainLoop() }, "opus-out").also { it.start() }
concealLogs = 0
dbgOut = 0
ptsCounter = 20_000L // первый кадр: pts!=0, иначе декодер повторно сбросит codecDelay
Log.d(TAG, "decoder started")
}
/** Создать и запустить только декодер (плеер не трогаем). */
private fun startDecoderCodec(format: MediaFormat): Boolean {
val dec = try {
MediaCodec.createDecoderByType(MediaFormat.MIMETYPE_AUDIO_OPUS).also {
it.setCallback(decoderCallback)
it.configure(format, null, null, 0)
queueDecoderConfig() // готовим конфиг ДО start: колбэки уйдут после
}
} catch (e: Exception) {
Log.e(TAG, "createDecoder failed: ${e.message}")
null
}
if (dec == null) return false
// decoder назначаем ДО start(): колбэки нового кодека сразу проходят
// проверку codec !== decoder, иначе первый конфиг-колбэк отбрасывается
// и декодер навсегда остаётся без конфига
decoder = dec
freeInputs.clear()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
try {
dec.start()
} catch (e: Exception) {
Log.e(TAG, "decoder start failed: ${e.message}")
decoder = null
try {
dec.release()
} catch (_: Exception) {
}
return false
}
return true
}
/** Асинхронные колбэки кодера: подача входных кадров + слив вывода. */
private val decoderCallback = object : MediaCodec.Callback() {
override fun onError(codec: MediaCodec, e: MediaCodec.CodecException) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.e(TAG, "decoder error: ${e.message} code=${e.errorCode} transient=${e.isTransient}")
}
override fun onInputBufferAvailable(codec: MediaCodec, index: Int) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
// Конфиг (csd) первыми входными буферами — в async-режиме нельзя
// использовать dequeueInputBuffer, только колбэки
if (configIdx < configBuffers.size) {
val data = configBuffers[configIdx++]
try {
val buf = codec.getInputBuffer(index) ?: return
buf.clear()
buf.put(data)
codec.queueInputBuffer(index, 0, data.size, 0, MediaCodec.BUFFER_FLAG_CODEC_CONFIG)
Log.d(TAG, "cfg ${data.size}B")
} catch (e: Exception) {
Log.e(TAG, "cfg error: ${e.message}")
}
return
}
val pkt = rxQueue.poll()
when {
// Джиттер-гейт: пока не накопили jitterTarget кадров (или не
// вышло 80 мс с первого пакета) — не кормим декодер, чтобы
// буфер AudioTrack успел заполниться до старта воспроизведения
pkt != null && gateOpen() -> feedDecoder(codec, index, pkt)
pkt != null -> {
rxQueue.add(pkt)
freeInputs.add(index)
}
receivedAny -> {
if (concealLogs < 3) {
Log.d(TAG, "conceal (no packet)")
concealLogs++
}
feedDecoder(codec, index, silenceFrame()) // скрытие потерь
}
else -> freeInputs.add(index) // ждём первый пакет
}
}
override fun onOutputBufferAvailable(codec: MediaCodec, index: Int, info: MediaCodec.BufferInfo) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
try {
if (info.size > 0) {
val outBuf = ByteBuffer.allocate(info.size)
outBuf.order(ByteOrder.LITTLE_ENDIAN)
codec.getOutputBuffer(index)?.also { src ->
src.position(info.offset)
src.limit(info.offset + info.size)
outBuf.put(src)
}
val pcm = outBuf.array()
if (!playStarted) {
playStarted = true
Log.d(TAG, "play start")
try {
player?.play()
} catch (e: Exception) {
Log.e(TAG, "play error: ${e.message}")
}
}
player?.write(pcm, 0, pcm.size)
onLevel(SRC_SPEAKER, pcmLevel(pcm))
if (dbgOut < 5) {
Log.d(TAG, "out ${pcm.size}B")
dbgOut++
}
}
} catch (e: Exception) {
Log.e(TAG, "out error: ${e.message}")
} finally {
try {
codec.releaseOutputBuffer(index, false)
} catch (_: Exception) {
}
}
}
override fun onOutputFormatChanged(codec: MediaCodec, format: MediaFormat) {
if (codec !== decoder) return // колбэк от уже пересозданного декодера
Log.d(TAG, "dec out format: sr=${format.getInteger(MediaFormat.KEY_SAMPLE_RATE)} " +
"ch=${format.getInteger(MediaFormat.KEY_CHANNEL_COUNT)}")
}
}
private fun feedDecoder(codec: MediaCodec, index: Int, pkt: ByteArray) {
try {
val buf = codec.getInputBuffer(index) ?: return
buf.clear()
buf.put(pkt)
codec.queueInputBuffer(index, 0, pkt.size, ptsCounter, 0)
ptsCounter += 20_000L // 20 мс кадра
if (concealLogs < 3 && rxQueue.isEmpty() && pkt === silenceFrame()) {
Log.d(TAG, "conceal (no packet)")
concealLogs++
}
} catch (e: Exception) {
Log.e(TAG, "feed error: ${e.message}")
}
}
/**
* Android 16: c2.android.opus.decoder читает конфиг из первых входных
* буферов. Единый CSD-блоб (формат AOSP WriteOpusHeaders): OpusHead (19
* байт) + codecDelay (int64 LE нс) + seekPreroll (int64 LE нс) — БЕЗ
* u32-префиксов длины. После него декодер уже сконфигурирован; лишние
* конфиг-буферы игнорируются как повторный CSD, поэтому шлём и их.
*/
private fun queueDecoderConfig() {
configBuffers.clear()
try {
val head = opusHead()
val delay = longBytes(39_000_000L) // прескип 312 сэмплов при 8 кГц = 39 мс
val preroll = longBytes(0L)
val w = java.io.ByteArrayOutputStream()
w.write(head)
w.write(delay)
w.write(preroll)
configBuffers.add(w.toByteArray())
configBuffers.add(delay)
configBuffers.add(preroll)
configIdx = 0
} catch (_: Exception) {
}
}
/** 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
var dbg = 0
while (decoder != null && decoder === dec) {
try {
// Монотонный 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(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, 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)
dec.getOutputBuffer(outIdx)?.also { src ->
src.position(decodeBufferInfo.offset)
src.limit(decodeBufferInfo.offset + decodeBufferInfo.size)
outBuf.put(src)
}
val pcm = outBuf.array()
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 (e: Exception) {
Log.e(TAG, "drain error: ${e.message}")
}
}
}
/** Подача одного Opus-пакета на декодер. */
/** Подача одного Opus-пакета на декодер (с UDP-потока). */
fun onAudioPacket(packet: ByteArray) {
rxQueue.add(packet)
receivedAny = true
if (gateStart == 0L) gateStart = System.currentTimeMillis()
if (!gateOpen()) return
val idx = freeInputs.poll() ?: return // если слот свободен — кормим сразу
decoder?.let { feedDecoder(it, idx, rxQueue.poll() ?: packet) }
}
/** Джиттер-гейт открыт: накопили кадры или вышло 80 мс с первого пакета. */
private fun gateOpen(): Boolean {
if (playStarted) return true
if (rxQueue.size >= jitterTarget) return true
return gateStart != 0L && System.currentTimeMillis() - gateStart >= 80
}
fun onAudioFlush() {
try {
decoder?.flush()
// MediaCodec.flush() возвращает входные буферы, но уже выданные индексы
// остаются у нас в freeInputs и дублируются колбэками «повторной выдачи» —
// queueInputBuffer падает (index not owned by client) и кадры теряются.
// Надёжнее пересоздать декодер: колбэки старого отсекаются проверкой
// codec !== decoder.
player?.flush()
rxQueue.clear()
receivedAny = false
playStarted = false
gateStart = 0L
onLevel(SRC_SPEAKER, 0f)
restartDecoder()
} catch (_: Exception) {
}
}
/** Пересоздать только декодер (плеер и джиттер-буфер сохраняем). */
private fun restartDecoder() {
val dec = decoder
decoder = null
if (dec != null) {
try {
dec.stop()
} catch (_: Exception) {
}
try {
dec.release()
} catch (_: Exception) {
}
}
val format = MediaFormat()
format.setString(MediaFormat.KEY_MIME, MediaFormat.MIMETYPE_AUDIO_OPUS)
format.setInteger(MediaFormat.KEY_SAMPLE_RATE, OUT_SAMPLE_RATE)
format.setInteger(MediaFormat.KEY_CHANNEL_COUNT, CHANNELS)
if (startDecoderCodec(format)) {
concealLogs = 0
dbgOut = 0
ptsCounter = 20_000L
}
}
/**
* Один кадр тишины (20 мс) для скрытия потерь вместо паузы в звуке.
* Сгенерирован opuslib: 8 кГц, моно, 20 кбит/с, нули PCM.
*/
private fun silenceFrame(): ByteArray = byteArrayOf(
0x08.toByte(), 0x0B.toByte(), 0xE4.toByte(), 0xB9.toByte(),
0xA0.toByte(), 0xBC.toByte(), 0x84.toByte()
)
/** Заголовок OpusHead (csd-0) для декодера: Opus, 1 канал, 8 кГц, прескип 312. */
private fun opusHead(): ByteArray {
val head = "OpusHead".toByteArray(Charsets.US_ASCII)
@@ -197,8 +363,12 @@ class OpusEngine(
/** Остановить декодер и плеер (при отключении). */
fun stopDecoder() {
val dec = decoder
decoder = null // поток разбора выйдет из цикла до release
decoder = null
rxQueue.clear()
freeInputs.clear()
receivedAny = false
playStarted = false
gateStart = 0L
if (dec != null) {
try {
dec.stop()
@@ -308,14 +478,43 @@ class OpusEngine(
} catch (_: Exception) {
}
}
// Дренаж: отдаём пакеты, оставшиеся в кодере после остановки записи
try {
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() {
if (!encoding && recThread == null) return
encoding = false
try {
recorder?.stop()
} catch (_: Exception) {
}
// Ждём завершения потока захвата: он должен отправить последние пакеты,
// чтобы flush (окончание передачи) ушёл после всего аудио
val t = recThread
recThread = null
if (t != null && t.isAlive) {
try {
t.join(1000)
} catch (_: InterruptedException) {
}
}
try {
recorder?.release()
} catch (_: Exception) {
@@ -330,8 +529,6 @@ class OpusEngine(
} catch (_: Exception) {
}
encoder = null
recThread?.interrupt()
recThread = null
onLevel(SRC_MIC, 0f)
}

View File

@@ -8,31 +8,132 @@ data class ConnectionSettings(
val port: Int,
val callsign: String,
val authKey: String,
val talkGroup: Long
val talkGroup: Long,
val groups: List<Long> = emptyList()
)
/** Профиль настроек: имя + параметры подключения. */
data class Profile(
val name: String,
val settings: ConnectionSettings
)
object Prefs {
private const val PREFS_NAME = "svxremote"
const val PROFILE_COUNT = 5
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)
private fun sp(context: Context): SharedPreferences =
context.getSharedPreferences(PREFS_NAME, Context.MODE_PRIVATE)
/** Индекс активного профиля (0..PROFILE_COUNT-1). */
fun activeProfileIndex(context: Context): Int =
sp(context).getInt("active_profile", 0).coerceIn(0, PROFILE_COUNT - 1)
fun setActiveProfile(context: Context, index: Int) {
sp(context).edit().putInt("active_profile", index.coerceIn(0, PROFILE_COUNT - 1)).apply()
}
/** Настройки активного профиля. */
fun load(context: Context): ConnectionSettings =
loadProfile(context, activeProfileIndex(context)).settings
/** Сохранить в активный профиль. */
fun save(context: Context, s: ConnectionSettings) {
val index = activeProfileIndex(context)
val profile = loadProfile(context, index)
saveProfile(context, index, Profile(profile.name, s))
}
fun loadProfile(context: Context, index: Int): Profile =
loadProfile(sp(context), index.coerceIn(0, PROFILE_COUNT - 1))
fun saveProfile(context: Context, index: Int, profile: Profile) {
val profiles = loadProfiles(context)
profiles[index.coerceIn(0, PROFILE_COUNT - 1)] = profile
saveProfiles(context, profiles)
}
/** Все профили (5 слотов). */
fun loadProfiles(context: Context): MutableList<Profile> {
val prefs = sp(context)
migrateIfNeeded(context, prefs)
return (0 until PROFILE_COUNT).map { loadProfile(prefs, it) }.toMutableList()
}
private fun loadProfile(prefs: SharedPreferences, i: Int): Profile {
val host = prefs.getString("p${i}_host", null)
if (host == null) {
return Profile("Профиль ${i + 1}", ConnectionSettings("", 5300, "", "", 11))
}
return Profile(
name = prefs.getString("p${i}_name", null) ?: "Профиль ${i + 1}",
settings = ConnectionSettings(
host = host,
port = prefs.getInt("p${i}_port", 5300),
callsign = prefs.getString("p${i}_callsign", "") ?: "",
authKey = prefs.getString("p${i}_auth", "") ?: "",
talkGroup = prefs.getLong("p${i}_tg", 11),
groups = (prefs.getString("p${i}_groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
)
)
}
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()
private fun saveProfiles(context: Context, profiles: List<Profile>) {
val e = sp(context).edit()
profiles.forEachIndexed { i, p ->
e.putString("p${i}_name", p.name)
.putString("p${i}_host", p.settings.host.trim())
.putInt("p${i}_port", p.settings.port)
.putString("p${i}_callsign", p.settings.callsign.trim())
.putString("p${i}_auth", p.settings.authKey)
.putLong("p${i}_tg", p.settings.talkGroup)
.putString("p${i}_groups", p.settings.groups.distinct().joinToString(","))
}
e.apply()
}
}
/** Перенос старых настроек (до v1.0.6) в профиль 1 — один раз. */
private fun migrateIfNeeded(context: Context, prefs: SharedPreferences) {
if (prefs.getBoolean("migrated_v106", false)) return
val host = prefs.getString("host", null)
if (host != null) {
val legacy = ConnectionSettings(
host = host,
port = prefs.getInt("port", 5300),
callsign = prefs.getString("callsign", "") ?: "",
authKey = prefs.getString("auth_key", "") ?: "",
talkGroup = prefs.getLong("tg", 11),
groups = (prefs.getString("groups", "") ?: "")
.split(',')
.mapNotNull { it.trim().toLongOrNull() }
.distinct()
.ifEmpty { listOf(9L, 11L, 12L, 13L) }
)
val profiles = (0 until PROFILE_COUNT).map { loadProfile(prefs, it) }.toMutableList()
profiles[0] = Profile("Профиль 1", legacy)
saveProfiles(context, profiles)
}
prefs.edit().putBoolean("migrated_v106", true).apply()
}
fun nodeToastsEnabled(context: Context): Boolean {
return sp(context).getBoolean("node_toasts", true)
}
fun setNodeToastsEnabled(context: Context, enabled: Boolean) {
sp(context).edit().putBoolean("node_toasts", enabled).apply()
}
// ---------- APRS метеостанции (экспериментально) ----------
/** Координаты "lat,lon" (пусто = функция выключена). */
fun aprsCoords(context: Context): String =
sp(context).getString("aprs_coords", "") ?: ""
fun setAprsCoords(context: Context, coords: String) {
sp(context).edit().putString("aprs_coords", coords.trim()).apply()
}
}

View File

@@ -1,5 +1,6 @@
package ru.ua1zbe.svxremote.proto
import android.util.Log
import java.net.DatagramPacket
import java.net.DatagramSocket
import java.net.InetAddress
@@ -7,7 +8,7 @@ import java.net.InetSocketAddress
import java.net.Socket
import java.net.SocketTimeoutException
import java.util.concurrent.atomic.AtomicBoolean
import android.util.Log
import ru.ua1zbe.svxremote.BuildConfig
/**
* Клиент рефлектора SVXLink (протокол v2, авторизация AUTH_KEY, аудио Opus по UDP).
@@ -23,8 +24,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()
@@ -50,7 +51,9 @@ class ReflectorClient(
const val RECONNECT_DELAY_MS = 5000L
const val CLIENT_SW_INFO = "SVXHomyak"
const val CLIENT_SW_VERSION = "1.0.0"
// Версия ПО передаётся на сервер для дашборда; берём из BuildConfig,
// чтобы не расходилась с versionName в build.gradle.kts
val CLIENT_SW_VERSION = BuildConfig.VERSION_NAME
}
data class Settings(
@@ -58,7 +61,9 @@ class ReflectorClient(
val port: Int,
val callsign: String,
val authKey: String,
val talkGroup: Long
val talkGroup: Long,
/** Список групп для мониторинга; null = обычный режим (своя группа). */
val monitorTgs: List<Long>? = null
)
@Volatile
@@ -99,10 +104,15 @@ class ReflectorClient(
@Synchronized
fun connect(settings: Settings) {
if (running.get()) return
this.settings = settings
manualDisconnect.set(false)
fatal = false
if (running.get()) {
// Перезапуск с новыми настройками (например, смена профиля)
running.set(false)
synchronized(stopEvent) { stopEvent.notifyAll() }
abortSockets()
}
running.set(true)
state = STATE_DISCONNECTED
seqCounter = 0
@@ -151,12 +161,46 @@ class ReflectorClient(
udpSend(sock, s, data)
}
/** Переключить принимаемую группу на живом соединении. */
fun selectGroup(tg: Long) {
val sock = tcpSocket
if (sock == null || !running.get()) {
Log.d(TAG, "selectGroup tg=$tg skipped (sock=${sock != null} running=${running.get()})")
return
}
try {
Log.d(TAG, "selectGroup tg=$tg")
sendTcp(sock, MsgBuilder.selectTg(tg))
} catch (e: Exception) {
Log.w(TAG, "selectGroup send failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
/** Переключить группы мониторинга на живом соединении. */
fun setMonitorTgs(tgs: List<Long>) {
val sock = tcpSocket
if (sock == null || !running.get()) {
Log.d(TAG, "setMonitorTgs $tgs skipped (sock=${sock != null} running=${running.get()})")
return
}
try {
Log.d(TAG, "setMonitorTgs $tgs")
sendTcp(sock, MsgBuilder.tgMonitor(tgs))
} catch (e: Exception) {
Log.w(TAG, "setMonitorTgs failed: ${e.javaClass.simpleName}: ${e.message}")
}
}
/** Конец передачи. */
fun sendAudioFlush() {
val sock = udpSocket
val s = settings
val id = clientId
if (sock == null || s == null || id == 0) return
if (sock == null || s == null || id == 0) {
Log.w(TAG, "sendAudioFlush skipped (sock=${sock != null} s=${s != null} id=$id)")
return
}
Log.d(TAG, "sendAudioFlush")
udpSend(sock, s, MsgBuilder.udpFlush(id, nextSeq()))
}
@@ -361,19 +405,25 @@ class ReflectorClient(
// (иначе сервер не шлёт аудио группы и не показывает
// подписку в статусе: monitoredTGs остаётся пустым).
sendTcp(socket, MsgBuilder.nodeInfo(CLIENT_SW_INFO, CLIENT_SW_VERSION))
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
val monitor = s.monitorTgs
if (monitor.isNullOrEmpty()) {
sendTcp(socket, MsgBuilder.selectTg(s.talkGroup))
sendTcp(socket, MsgBuilder.tgMonitor(listOf(s.talkGroup)))
} else {
sendTcp(socket, MsgBuilder.selectTg(monitor.first()))
sendTcp(socket, MsgBuilder.tgMonitor(monitor))
}
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 -> {
@@ -388,6 +438,7 @@ class ReflectorClient(
ReflectorProtocol.MSG_ERROR -> {
val err = MsgParser.errorMessage(msg.fields)
Log.w(TAG, "MSG_ERROR: $err")
if (state < STATE_READY) {
stopFatal(err.ifEmpty { "Access denied" })
return false
@@ -404,6 +455,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 { "?" }
@@ -436,6 +498,7 @@ class ReflectorClient(
stopUdp()
val ds = DatagramSocket()
udpSocket = ds
Log.d(TAG, "UDP bound port=${ds.localPort}")
// Первый пакет регистрирует наш UDP-адрес у сервера
val addr = resolvedAddr ?: InetAddress.getByName(s.host)
sendRawUdp(ds, addr, s.port, MsgBuilder.udpHeartbeat(clientId, nextSeq()))
@@ -479,7 +542,7 @@ class ReflectorClient(
val seq = r.u16()
val payload = data.copyOfRange(6, data.size)
when (type) {
ReflectorProtocol.MSG_UDP_HEARTBEAT -> Unit
ReflectorProtocol.MSG_UDP_HEARTBEAT -> Log.d(TAG, "UDP RX heartbeat")
ReflectorProtocol.MSG_UDP_AUDIO -> {
val audio = MsgParser.udpAudio(payload)
if (audio != null && audio.isNotEmpty()) {
@@ -493,7 +556,7 @@ class ReflectorClient(
Log.d(TAG, "UDP RX flush")
callbacks.onAudioFlush()
}
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Unit
ReflectorProtocol.MSG_UDP_ALL_SAMPLES_FLUSHED -> Log.d(TAG, "UDP RX all-flushed seq=$seq")
else -> Log.d(TAG, "UDP RX type=$type len=${data.size}")
}
}
@@ -517,10 +580,16 @@ class ReflectorClient(
}
private fun sendRawUdp(sock: DatagramSocket, addr: InetAddress, port: Int, data: ByteArray) {
if (sock.isClosed) return
if (sock.isClosed) {
Log.w(TAG, "UDP TX skipped: socket closed")
return
}
try {
sock.send(DatagramPacket(data, data.size, addr, port))
val typ = if (data.size >= 2) ((data[0].toInt() shl 8) or (data[1].toInt() and 0xFF)) else -1
Log.d(TAG, "UDP TX type=$typ len=${data.size}")
} catch (e: Exception) {
Log.e(TAG, "UDP send error: ${e.message}", e)
callbacks.onError("UDP send: ${e.message}")
}
}

View File

@@ -195,9 +195,11 @@ object MsgBuilder {
return RcMessage(ReflectorProtocol.MSG_TG_MONITOR, w.toByteArray())
}
/** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.). */
/** MsgNodeInfo v2: JSON-строка с данными клиента (версия ПО и пр.).
* Рефлектор вливает JSON в статус узла, дашборд показывает поле
* «swVer» (как у svxlink-узлов). */
fun nodeInfo(swInfo: String, swVersion: String): RcMessage {
val json = "{\"swInfo\":\"$swInfo\",\"swVersion\":\"$swVersion\"}"
val json = "{\"swInfo\":\"$swInfo\",\"swVer\":\"$swVersion\"}"
return RcMessage(ReflectorProtocol.MSG_NODE_INFO, BinWriter().str(json).toByteArray())
}

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,5 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="oval">
<solid android:color="#3358A6FF" />
</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

@@ -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="M12,1c-4.97,0 -9,4.03 -9,9v7c0,1.66 1.34,3 3,3h3v-8L5,12v-2c0,-3.87 3.13,-7 7,-7s7,3.13 7,7v2h-4v8h3c1.66,0 3,-1.34 3,-3v-7c0,-4.97 -4.03,-9 -9,-9z" />
</vector>

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,38 @@
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:id="@+id/lbl_app_title"
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,83 +52,251 @@
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:singleLine="true"
android:ellipsize="end"
android:text="@string/settings_not_configured" />
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" />
<ImageButton
android:id="@+id/btn_monitor"
android:layout_width="44dp"
android:layout_height="44dp"
android:src="@drawable/ic_headset"
android:background="?android:attr/selectableItemBackgroundBorderless"
android:contentDescription="@string/btn_monitor"
android:scaleType="centerInside" />
<TextView
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="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:gravity="center"
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="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="28dp"
android:layout_marginTop="24dp"
android:gravity="center"
android:textColor="@color/text_secondary"
android:textSize="14sp"
android:text="@string/talker_none" />
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" />
<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" />
android:text="@string/hold_to_talk" />
<Button
android:id="@+id/btn_connect"
<Space
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="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"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:gravity="center"
android:textColor="@color/danger"
android:textSize="14sp"
android:textStyle="bold"
android:text="@string/status_big_disconnected" />
<TextView
android:id="@+id/lbl_connect_hint"
android:layout_width="match_parent"
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

@@ -11,6 +11,23 @@
android:orientation="vertical"
android:padding="20dp">
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/profiles_label"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<Spinner
android:id="@+id/spinner_profile"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp"
android:background="@drawable/bg_input"
android:padding="12dp"
android:spinnerMode="dropdown"
android:textColor="@color/text_primary" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
@@ -110,7 +127,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 +136,72 @@
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" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_label"
android:textColor="@color/accent"
android:textSize="15sp"
android:textStyle="bold"
android:layout_marginBottom="4dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_hint"
android:textColor="@color/text_secondary"
android:textSize="13sp"
android:layout_marginBottom="12dp" />
<TextView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:text="@string/pref_aprs_coords"
android:textColor="@color/text_primary"
android:textSize="15sp" />
<EditText
android:id="@+id/input_aprs_coords"
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="@string/pref_aprs_coords_hint" />
<Button
android:id="@+id/btn_save"
android:layout_width="match_parent"
@@ -128,5 +211,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,17 +9,55 @@
<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="pref_aprs_label">Метеостанции (APRS)</string>
<string name="pref_aprs_hint">Экспериментально: принимает метеостанции в радиусе 250 км. Последняя станция показывается вместо строки сервера на главном экране.</string>
<string name="pref_aprs_coords">Координаты (широта, долгота)</string>
<string name="pref_aprs_coords_hint">пусто = центр Санкт-Петербурга, например 59.95, 30.30</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.5</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="btn_monitor">Мониторинг групп (нажмите, чтобы включить)</string>
<string name="monitoring_dev_warning">Функция находится в разработке. Может работать некорректно!</string>
<string name="monitor_off">Обычный режим</string>
<string name="btn_monitor_on">Мониторинг групп включен (нажмите, чтобы выключить)</string>
<string name="status_monitoring">Мониторинг: %1$s</string>
<string name="talker_mon">Говорит: %1$s · TG%2$d</string>
<string name="talker_mon_priority">ПРИОРИТЕТ: %1$s · TG%2$d</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>
@@ -41,7 +79,24 @@
<string name="toast_codec_unsupported">Рефлектор требует кодек %1$s — приложение поддерживает только OPUS</string>
<string name="toast_settings_empty">Заполните настройки подключения</string>
<string name="toast_need_settings">Откройте настройки и укажите сервер и позывной</string>
<string name="profiles_label">Профиль настроек</string>
<string name="profile_name">Профиль %1$d</string>
<string name="profiles_title">Выбор профиля</string>
<string name="profile_current"> (текущий)</string>
<string name="profile_empty">не настроен</string>
<string name="cancel">Отмена</string>
<string name="talk_log_title">Последние передачи</string>
<string name="talk_log_empty">Журнала передач пока нет</string>
<string name="talk_log_entry">%1$s %2$s TG%3$d</string>
<string name="close">Закрыть</string>
<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>