Compare commits
6 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
fc224e3e09 | ||
|
|
583ec62e39 | ||
|
|
bff0d29ee8 | ||
|
|
abb6f254bc | ||
|
|
1a9dd9a5a4 | ||
|
|
68e149638e |
@@ -11,8 +11,8 @@ android {
|
||||
applicationId = "ru.ua1zbe.svxremote"
|
||||
minSdk = 26
|
||||
targetSdk = 34
|
||||
versionCode = 2
|
||||
versionName = "1.0.0"
|
||||
versionCode = 5
|
||||
versionName = "1.0.3"
|
||||
}
|
||||
|
||||
buildTypes {
|
||||
|
||||
@@ -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>
|
||||
@@ -2,27 +2,32 @@ 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.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.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.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
|
||||
@@ -36,13 +41,19 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
|
||||
private lateinit var statusChip: TextView
|
||||
private lateinit var lblSubtitle: TextView
|
||||
private lateinit var lblCallsign: TextView
|
||||
private lateinit var lblTg: TextView
|
||||
private lateinit var lblTalkerTitle: TextView
|
||||
private lateinit var lblTalker: TextView
|
||||
private lateinit var lblPttHint: TextView
|
||||
private lateinit var lblTxState: TextView
|
||||
private lateinit var btnConnect: Button
|
||||
private lateinit var lblConnectHint: TextView
|
||||
private lateinit var connectZone: View
|
||||
private lateinit var rxBarFill: View
|
||||
private lateinit var rxDb: TextView
|
||||
private lateinit var micBarFill: View
|
||||
private lateinit var micDb: TextView
|
||||
private lateinit var pttButton: PttButton
|
||||
private lateinit var levelMeter: LevelMeter
|
||||
private lateinit var btnSettings: ImageButton
|
||||
|
||||
private val mainHandler = Handler(Looper.getMainLooper())
|
||||
@@ -62,10 +73,15 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
}
|
||||
}
|
||||
|
||||
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
|
||||
private var updateDialog: AlertDialog? = null
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
SplashActivity.mainAlive = true
|
||||
setContentView(R.layout.activity_main)
|
||||
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
|
||||
checkForUpdates()
|
||||
|
||||
statusChip = findViewById(R.id.status_chip)
|
||||
lblSubtitle = findViewById(R.id.lbl_subtitle)
|
||||
@@ -73,47 +89,34 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
lblTalker = findViewById(R.id.lbl_talker)
|
||||
lblPttHint = findViewById(R.id.lbl_ptt_hint)
|
||||
lblTxState = findViewById(R.id.lbl_tx_state)
|
||||
btnConnect = findViewById(R.id.btn_connect)
|
||||
lblConnectHint = findViewById(R.id.lbl_connect_hint)
|
||||
connectZone = findViewById(R.id.connect_zone)
|
||||
lblCallsign = findViewById(R.id.lbl_callsign)
|
||||
lblTg = findViewById(R.id.lbl_tg)
|
||||
rxBarFill = findViewById(R.id.rx_bar_fill)
|
||||
rxDb = findViewById(R.id.rx_db)
|
||||
micBarFill = findViewById(R.id.mic_bar_fill)
|
||||
micDb = findViewById(R.id.mic_db)
|
||||
pttButton = findViewById(R.id.ptt_button)
|
||||
levelMeter = findViewById(R.id.level_meter)
|
||||
btnSettings = findViewById(R.id.btn_settings)
|
||||
pttButton.enabled2 = false
|
||||
|
||||
btnSettings.setOnClickListener {
|
||||
startActivity(Intent(this, SettingsActivity::class.java))
|
||||
}
|
||||
|
||||
btnConnect.setOnClickListener {
|
||||
val s = svc
|
||||
if (connected && s != null) {
|
||||
s.disconnect()
|
||||
lblTg.setOnClickListener { showGroupsDialog() }
|
||||
|
||||
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,29 +130,45 @@ 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() {
|
||||
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()
|
||||
}
|
||||
|
||||
private var autoConnectAttempted = false
|
||||
|
||||
override fun onResume() {
|
||||
super.onResume()
|
||||
settings = Prefs.load(this)
|
||||
refreshUi()
|
||||
if (!autoConnectAttempted &&
|
||||
settings.host.isNotBlank() &&
|
||||
settings.callsign.isNotBlank()
|
||||
) {
|
||||
autoConnectAttempted = true
|
||||
requestPermissionsIfNeeded()
|
||||
connect()
|
||||
}
|
||||
if (pendingUpdate != null && updateDialog == null && canInstall()) {
|
||||
startUpdate()
|
||||
}
|
||||
}
|
||||
|
||||
override fun onDestroy() {
|
||||
super.onDestroy()
|
||||
SplashActivity.mainAlive = false
|
||||
svc?.listener = null
|
||||
unbindService(svcConn)
|
||||
}
|
||||
@@ -161,6 +180,12 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
} 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 +198,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 +211,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()
|
||||
}
|
||||
@@ -191,14 +222,20 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
connected = true
|
||||
updatePttEnabled()
|
||||
statusChip.text = getString(R.string.status_connected)
|
||||
btnConnect.text = getString(R.string.btn_disconnect)
|
||||
statusChip.setTextColor(getColor(R.color.accent))
|
||||
lblTxState.text = getString(R.string.status_big_connected)
|
||||
lblTxState.setTextColor(getColor(R.color.accent))
|
||||
lblConnectHint.text = getString(R.string.hint_disconnect)
|
||||
}
|
||||
|
||||
ReflectorClient.STATE_AUTH_FAILED -> {
|
||||
connected = false
|
||||
updatePttEnabled()
|
||||
statusChip.text = getString(R.string.status_auth_failed)
|
||||
btnConnect.text = getString(R.string.btn_connect)
|
||||
statusChip.setTextColor(getColor(R.color.danger))
|
||||
lblTxState.text = getString(R.string.status_big_disconnected)
|
||||
lblTxState.setTextColor(getColor(R.color.danger))
|
||||
lblConnectHint.text = getString(R.string.hint_connect)
|
||||
toast(getString(R.string.status_error, detail ?: ""))
|
||||
}
|
||||
|
||||
@@ -206,7 +243,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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -239,7 +279,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
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -253,6 +304,175 @@ class MainActivity : Activity(), SvxService.Listener {
|
||||
pttButton.enabled2 = connected
|
||||
}
|
||||
|
||||
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() {
|
||||
startForegroundService(Intent(this, SvxService::class.java))
|
||||
bindService(Intent(this, SvxService::class.java), svcConn, Context.BIND_AUTO_CREATE)
|
||||
mainHandler.postDelayed({
|
||||
svc?.connect(
|
||||
ReflectorClient.Settings(
|
||||
host = settings.host,
|
||||
port = settings.port,
|
||||
callsign = settings.callsign,
|
||||
authKey = settings.authKey,
|
||||
talkGroup = settings.talkGroup
|
||||
)
|
||||
)
|
||||
}, 300)
|
||||
}
|
||||
|
||||
/** Переключиться на другую группу: пересохранить и переподключиться. */
|
||||
private fun switchGroup(tg: Long) {
|
||||
settings = settings.copy(talkGroup = tg)
|
||||
Prefs.save(this, settings)
|
||||
refreshUi()
|
||||
svc?.disconnect()
|
||||
mainHandler.postDelayed({ connect() }, 500)
|
||||
}
|
||||
|
||||
private fun 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()
|
||||
}
|
||||
|
||||
@@ -15,6 +15,7 @@ class SettingsActivity : Activity() {
|
||||
private lateinit var inputCallsign: EditText
|
||||
private lateinit var inputAuthKey: EditText
|
||||
private lateinit var inputTg: EditText
|
||||
private lateinit var inputGroups: EditText
|
||||
|
||||
override fun onCreate(savedInstanceState: Bundle?) {
|
||||
super.onCreate(savedInstanceState)
|
||||
@@ -25,6 +26,7 @@ class SettingsActivity : Activity() {
|
||||
inputCallsign = findViewById(R.id.input_callsign)
|
||||
inputAuthKey = findViewById(R.id.input_auth_key)
|
||||
inputTg = findViewById(R.id.input_tg)
|
||||
inputGroups = findViewById(R.id.input_groups)
|
||||
|
||||
val s = Prefs.load(this)
|
||||
inputHost.setText(s.host)
|
||||
@@ -32,6 +34,7 @@ class SettingsActivity : Activity() {
|
||||
inputCallsign.setText(s.callsign)
|
||||
inputAuthKey.setText(s.authKey)
|
||||
inputTg.setText(s.talkGroup.toString())
|
||||
inputGroups.setText(s.groups.joinToString(", "))
|
||||
|
||||
findViewById<Button>(R.id.btn_save).setOnClickListener {
|
||||
val host = inputHost.text.toString().trim()
|
||||
@@ -39,6 +42,10 @@ class SettingsActivity : Activity() {
|
||||
val callsign = inputCallsign.text.toString().trim()
|
||||
val authKey = inputAuthKey.text.toString()
|
||||
val tg = inputTg.text.toString().trim().toLongOrNull() ?: 11
|
||||
val groups = inputGroups.text.toString()
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.distinct()
|
||||
|
||||
if (host.isBlank() || callsign.isBlank()) {
|
||||
toast(getString(R.string.toast_settings_empty))
|
||||
@@ -52,7 +59,8 @@ class SettingsActivity : Activity() {
|
||||
port = port.coerceIn(1, 65535),
|
||||
callsign = callsign,
|
||||
authKey = authKey,
|
||||
talkGroup = tg.coerceAtLeast(0)
|
||||
talkGroup = tg.coerceAtLeast(0),
|
||||
groups = groups
|
||||
)
|
||||
)
|
||||
toast(getString(R.string.saved))
|
||||
|
||||
70
app/src/main/java/ru/ua1zbe/svxremote/SplashActivity.kt
Normal file
70
app/src/main/java/ru/ua1zbe/svxremote/SplashActivity.kt
Normal file
@@ -0,0 +1,70 @@
|
||||
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
|
||||
)
|
||||
startActivity(intent)
|
||||
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
|
||||
finish()
|
||||
}
|
||||
}
|
||||
@@ -3,6 +3,7 @@ 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
|
||||
@@ -53,11 +54,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)
|
||||
}
|
||||
@@ -86,7 +98,10 @@ class SvxService : Service(), ReflectorClient.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)
|
||||
@@ -100,10 +115,12 @@ class SvxService : Service(), ReflectorClient.Callbacks {
|
||||
}
|
||||
|
||||
override fun onTalkerStart(tg: Long, callsign: String) {
|
||||
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)
|
||||
}
|
||||
|
||||
|
||||
77
app/src/main/java/ru/ua1zbe/svxremote/UpdateChecker.kt
Normal file
77
app/src/main/java/ru/ua1zbe/svxremote/UpdateChecker.kt
Normal 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
|
||||
}
|
||||
}
|
||||
34
app/src/main/java/ru/ua1zbe/svxremote/UpdateFileProvider.kt
Normal file
34
app/src/main/java/ru/ua1zbe/svxremote/UpdateFileProvider.kt
Normal 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
|
||||
}
|
||||
@@ -8,7 +8,8 @@ data class ConnectionSettings(
|
||||
val port: Int,
|
||||
val callsign: String,
|
||||
val authKey: String,
|
||||
val talkGroup: Long
|
||||
val talkGroup: Long,
|
||||
val groups: List<Long> = emptyList()
|
||||
)
|
||||
|
||||
object Prefs {
|
||||
@@ -21,7 +22,12 @@ object Prefs {
|
||||
port = sp.getInt("port", 5300),
|
||||
callsign = sp.getString("callsign", "") ?: "",
|
||||
authKey = sp.getString("auth_key", "") ?: "",
|
||||
talkGroup = sp.getLong("tg", 11)
|
||||
talkGroup = sp.getLong("tg", 11),
|
||||
groups = (sp.getString("groups", "") ?: "")
|
||||
.split(',')
|
||||
.mapNotNull { it.trim().toLongOrNull() }
|
||||
.distinct()
|
||||
.ifEmpty { listOf(9L, 11L, 12L, 13L) }
|
||||
)
|
||||
}
|
||||
|
||||
@@ -33,6 +39,7 @@ object Prefs {
|
||||
.putString("callsign", s.callsign.trim())
|
||||
.putString("auth_key", s.authKey)
|
||||
.putLong("tg", s.talkGroup)
|
||||
.putString("groups", s.groups.distinct().joinToString(","))
|
||||
.apply()
|
||||
}
|
||||
}
|
||||
@@ -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
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
BIN
app/src/main/res/drawable-nodpi/ic_launcher_fg.png
Normal file
BIN
app/src/main/res/drawable-nodpi/ic_launcher_fg.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 43 KiB |
6
app/src/main/res/drawable/bg_meter.xml
Normal file
6
app/src/main/res/drawable/bg_meter.xml
Normal file
@@ -0,0 +1,6 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<shape xmlns:android="http://schemas.android.com/apk/res/android" android:shape="rectangle">
|
||||
<solid android:color="@color/surface" />
|
||||
<stroke android:width="1dp" android:color="@color/surface_light" />
|
||||
<corners android:radius="8dp" />
|
||||
</shape>
|
||||
5
app/src/main/res/drawable/bg_meter_fill_mic.xml
Normal file
5
app/src/main/res/drawable/bg_meter_fill_mic.xml
Normal 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>
|
||||
5
app/src/main/res/drawable/bg_meter_fill_rx.xml
Normal file
5
app/src/main/res/drawable/bg_meter_fill_rx.xml
Normal 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>
|
||||
8
app/src/main/res/drawable/bg_splash.xml
Normal file
8
app/src/main/res/drawable/bg_splash.xml
Normal 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>
|
||||
@@ -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>
|
||||
@@ -4,24 +4,28 @@
|
||||
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">
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="1dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/status_chip"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:paddingHorizontal="14dp"
|
||||
android:paddingVertical="6dp"
|
||||
android:text="@string/app_title"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="14sp"
|
||||
android:background="@drawable/bg_chip"
|
||||
android:text="@string/status_disconnected" />
|
||||
android:textSize="20sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<Space
|
||||
android:layout_width="0dp"
|
||||
@@ -38,63 +42,213 @@
|
||||
android:scaleType="centerInside" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_subtitle"
|
||||
<!-- Позывной + комната + группа -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="8dp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:layout_marginTop="36dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_weight="1"
|
||||
android:orientation="vertical">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_callsign"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:fontFamily="monospace"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="28sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
android:text="@string/settings_not_configured" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_talker_title"
|
||||
android:id="@+id/lbl_subtitle"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end" />
|
||||
</LinearLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_tg"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginStart="12dp"
|
||||
android:textColor="@color/warn"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold"
|
||||
android:fontFamily="monospace" />
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Статус соединения -->
|
||||
<TextView
|
||||
android:id="@+id/status_chip"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="20dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/danger"
|
||||
android:textSize="18sp"
|
||||
android:textStyle="bold"
|
||||
android:text="@string/status_disconnected" />
|
||||
|
||||
<!-- S-метры -->
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="28dp"
|
||||
android:orientation="vertical">
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/meter_rx"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="14sp"
|
||||
android:text="@string/talker_none" />
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_meter">
|
||||
|
||||
<View
|
||||
android:id="@+id/rx_bar_fill"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_meter_fill_rx"
|
||||
android:pivotX="0"
|
||||
android:scaleX="0" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/rx_db"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:fontFamily="monospace"
|
||||
android:text="@string/meter_idle"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
|
||||
<LinearLayout
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="10dp"
|
||||
android:orientation="horizontal"
|
||||
android:gravity="center_vertical">
|
||||
|
||||
<TextView
|
||||
android:layout_width="34dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:text="@string/meter_mic"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:textStyle="bold" />
|
||||
|
||||
<FrameLayout
|
||||
android:layout_width="0dp"
|
||||
android:layout_height="16dp"
|
||||
android:layout_weight="1"
|
||||
android:background="@drawable/bg_meter">
|
||||
|
||||
<View
|
||||
android:id="@+id/mic_bar_fill"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="match_parent"
|
||||
android:background="@drawable/bg_meter_fill_mic"
|
||||
android:pivotX="0"
|
||||
android:scaleX="0" />
|
||||
</FrameLayout>
|
||||
|
||||
<TextView
|
||||
android:id="@+id/mic_db"
|
||||
android:layout_width="64dp"
|
||||
android:layout_height="wrap_content"
|
||||
android:gravity="end"
|
||||
android:fontFamily="monospace"
|
||||
android:text="@string/meter_idle"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textSize="13sp" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
|
||||
<!-- Последний говоривший -->
|
||||
<TextView
|
||||
android:id="@+id/lbl_talker_title"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="24dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:text="@string/talker_last_none" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_talker"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/accent"
|
||||
android:textSize="28sp"
|
||||
android:textSize="22sp"
|
||||
android:textStyle="bold"
|
||||
android:singleLine="true"
|
||||
android:ellipsize="end"
|
||||
android:text="@string/talker_none" />
|
||||
android:ellipsize="end" />
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<ru.ua1zbe.svxremote.ui.LevelMeter
|
||||
android:id="@+id/level_meter"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="30dp" />
|
||||
|
||||
<!-- Кнопка PTT -->
|
||||
<ru.ua1zbe.svxremote.ui.PttButton
|
||||
android:id="@+id/ptt_button"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="220dp"
|
||||
android:layout_marginTop="24dp"
|
||||
android:layout_marginBottom="12dp" />
|
||||
android:layout_width="130dp"
|
||||
android:layout_height="130dp" />
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_ptt_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_width="wrap_content"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="14dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="13sp"
|
||||
android:text="@string/ptt_hint" />
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:text="@string/hold_to_talk" />
|
||||
|
||||
<Space
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="0dp"
|
||||
android:layout_weight="1" />
|
||||
|
||||
<!-- Нижняя зона статуса (кликабельна) -->
|
||||
<LinearLayout
|
||||
android:id="@+id/connect_zone"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:orientation="vertical"
|
||||
android:padding="8dp"
|
||||
android:clickable="true"
|
||||
android:focusable="true">
|
||||
|
||||
<TextView
|
||||
android:id="@+id/lbl_tx_state"
|
||||
@@ -104,17 +258,17 @@
|
||||
android:textColor="@color/danger"
|
||||
android:textSize="14sp"
|
||||
android:textStyle="bold"
|
||||
android:visibility="gone"
|
||||
android:text="@string/ptt_talking" />
|
||||
android:text="@string/status_big_disconnected" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_connect"
|
||||
<TextView
|
||||
android:id="@+id/lbl_connect_hint"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="52dp"
|
||||
android:layout_marginTop="12dp"
|
||||
android:text="@string/btn_connect"
|
||||
android:textColor="#0D1117"
|
||||
android:background="@drawable/bg_primary_btn"
|
||||
android:textSize="16sp"
|
||||
android:textStyle="bold" />
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginTop="4dp"
|
||||
android:gravity="center"
|
||||
android:textColor="@color/text_secondary"
|
||||
android:textSize="12sp"
|
||||
android:textStyle="italic"
|
||||
android:text="@string/hint_connect" />
|
||||
</LinearLayout>
|
||||
</LinearLayout>
|
||||
@@ -110,7 +110,7 @@
|
||||
android:id="@+id/input_tg"
|
||||
android:layout_width="match_parent"
|
||||
android:layout_height="wrap_content"
|
||||
android:layout_marginBottom="24dp"
|
||||
android:layout_marginBottom="16dp"
|
||||
android:inputType="number"
|
||||
android:textColor="@color/text_primary"
|
||||
android:textColorHint="@color/text_secondary"
|
||||
@@ -119,6 +119,26 @@
|
||||
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" />
|
||||
|
||||
<Button
|
||||
android:id="@+id/btn_save"
|
||||
android:layout_width="match_parent"
|
||||
@@ -128,5 +148,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>
|
||||
44
app/src/main/res/layout/activity_splash.xml
Normal file
44
app/src/main/res/layout/activity_splash.xml
Normal 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>
|
||||
@@ -9,17 +9,36 @@
|
||||
<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="btn_save">Сохранить</string>
|
||||
<string name="app_version">SVXhomyak — v1.0.3</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="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>
|
||||
@@ -44,4 +63,11 @@
|
||||
|
||||
<string name="settings_not_configured">Настройки не заданы</string>
|
||||
<string name="ptt_disabled">Нет связи</string>
|
||||
|
||||
<string name="update_available_title">Доступно обновление</string>
|
||||
<string name="update_available_msg">Версия %1$s уже вышла. Обновить приложение?</string>
|
||||
<string name="update_btn">Обновить</string>
|
||||
<string name="update_later">Позже</string>
|
||||
<string name="update_downloading">Загрузка обновления…</string>
|
||||
<string name="update_failed">Не удалось загрузить обновление: %1$s</string>
|
||||
</resources>
|
||||
Reference in New Issue
Block a user