2 Commits

Author SHA1 Message Date
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
15 changed files with 705 additions and 88 deletions

View File

@@ -11,8 +11,8 @@ android {
applicationId = "ru.ua1zbe.svxremote" applicationId = "ru.ua1zbe.svxremote"
minSdk = 26 minSdk = 26
targetSdk = 34 targetSdk = 34
versionCode = 2 versionCode = 4
versionName = "1.0.0" versionName = "1.0.2"
} }
buildTypes { buildTypes {

View File

@@ -6,6 +6,7 @@
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" /> <uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE" />
<uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" /> <uses-permission android:name="android.permission.FOREGROUND_SERVICE_MEDIA_PLAYBACK" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:label="@string/app_name" android:label="@string/app_name"
@@ -25,14 +26,26 @@
android:label="@string/settings_title" /> android:label="@string/settings_title" />
<activity <activity
android:name=".MainActivity" android:name=".SplashActivity"
android:exported="true" android:exported="true"
android:screenOrientation="portrait" android:screenOrientation="portrait"
android:configChanges="orientation|screenSize|keyboardHidden"> android:theme="@style/Theme.SvxRemote">
<intent-filter> <intent-filter>
<action android:name="android.intent.action.MAIN" /> <action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" /> <category android:name="android.intent.category.LAUNCHER" />
</intent-filter> </intent-filter>
</activity> </activity>
<activity
android:name=".MainActivity"
android:exported="false"
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> </application>
</manifest> </manifest>

View File

@@ -2,27 +2,32 @@ package ru.ua1zbe.svxremote
import android.Manifest import android.Manifest
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.ComponentName import android.content.ComponentName
import android.content.Context import android.content.Context
import android.content.Intent import android.content.Intent
import android.content.ServiceConnection import android.content.ServiceConnection
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build import android.os.Build
import android.os.Bundle import android.os.Bundle
import android.os.Handler import android.os.Handler
import android.os.IBinder import android.os.IBinder
import android.os.Looper import android.os.Looper
import android.provider.Settings
import android.util.Log import android.util.Log
import android.view.View
import android.view.WindowManager import android.view.WindowManager
import android.widget.Button
import android.widget.ImageButton import android.widget.ImageButton
import android.widget.LinearLayout
import android.widget.ProgressBar
import android.widget.TextView import android.widget.TextView
import android.widget.Toast import android.widget.Toast
import java.io.File
import ru.ua1zbe.svxremote.prefs.ConnectionSettings import ru.ua1zbe.svxremote.prefs.ConnectionSettings
import ru.ua1zbe.svxremote.prefs.Prefs import ru.ua1zbe.svxremote.prefs.Prefs
import ru.ua1zbe.svxremote.proto.MsgParser import ru.ua1zbe.svxremote.proto.MsgParser
import ru.ua1zbe.svxremote.proto.ReflectorClient import ru.ua1zbe.svxremote.proto.ReflectorClient
import ru.ua1zbe.svxremote.ui.LevelMeter
import ru.ua1zbe.svxremote.ui.PttButton import ru.ua1zbe.svxremote.ui.PttButton
private const val REQ_MIC = 1001 private const val REQ_MIC = 1001
@@ -36,13 +41,19 @@ class MainActivity : Activity(), SvxService.Listener {
private lateinit var statusChip: TextView private lateinit var statusChip: TextView
private lateinit var lblSubtitle: TextView private lateinit var lblSubtitle: TextView
private lateinit var lblCallsign: TextView
private lateinit var lblTg: TextView
private lateinit var lblTalkerTitle: TextView private lateinit var lblTalkerTitle: TextView
private lateinit var lblTalker: TextView private lateinit var lblTalker: TextView
private lateinit var lblPttHint: TextView private lateinit var lblPttHint: TextView
private lateinit var lblTxState: 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 pttButton: PttButton
private lateinit var levelMeter: LevelMeter
private lateinit var btnSettings: ImageButton private lateinit var btnSettings: ImageButton
private val mainHandler = Handler(Looper.getMainLooper()) private val mainHandler = Handler(Looper.getMainLooper())
@@ -62,10 +73,14 @@ class MainActivity : Activity(), SvxService.Listener {
} }
} }
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = null
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main) setContentView(R.layout.activity_main)
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON) window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON)
checkForUpdates()
statusChip = findViewById(R.id.status_chip) statusChip = findViewById(R.id.status_chip)
lblSubtitle = findViewById(R.id.lbl_subtitle) lblSubtitle = findViewById(R.id.lbl_subtitle)
@@ -73,16 +88,23 @@ class MainActivity : Activity(), SvxService.Listener {
lblTalker = findViewById(R.id.lbl_talker) lblTalker = findViewById(R.id.lbl_talker)
lblPttHint = findViewById(R.id.lbl_ptt_hint) lblPttHint = findViewById(R.id.lbl_ptt_hint)
lblTxState = findViewById(R.id.lbl_tx_state) 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) pttButton = findViewById(R.id.ptt_button)
levelMeter = findViewById(R.id.level_meter)
btnSettings = findViewById(R.id.btn_settings) btnSettings = findViewById(R.id.btn_settings)
pttButton.enabled2 = false
btnSettings.setOnClickListener { btnSettings.setOnClickListener {
startActivity(Intent(this, SettingsActivity::class.java)) startActivity(Intent(this, SettingsActivity::class.java))
} }
btnConnect.setOnClickListener { connectZone.setOnClickListener {
val s = svc val s = svc
if (connected && s != null) { if (connected && s != null) {
s.disconnect() s.disconnect()
@@ -127,15 +149,17 @@ class MainActivity : Activity(), SvxService.Listener {
toast(getString(R.string.perm_mic)) toast(getString(R.string.perm_mic))
return return
} }
lblTxState.visibility = TextView.VISIBLE
lblPttHint.text = getString(R.string.ptt_talking) lblPttHint.text = getString(R.string.ptt_talking)
lblTxState.text = getString(R.string.status_big_talking)
svc?.startTx() svc?.startTx()
} }
override fun onPttUp() { override fun onPttUp() {
svc?.stopTx() svc?.stopTx()
lblTxState.visibility = TextView.GONE lblPttHint.text = getString(R.string.hold_to_talk)
lblPttHint.text = getString(R.string.ptt_hint) lblTxState.text =
if (connected) getString(R.string.status_big_connected)
else getString(R.string.status_big_disconnected)
} }
}) })
@@ -161,6 +185,12 @@ class MainActivity : Activity(), SvxService.Listener {
} else { } else {
getString(R.string.server_subtitle, host, settings.port, settings.talkGroup) 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 (фоновые потоки!) ---------- // ---------- Callbacks от SvxService (фоновые потоки!) ----------
@@ -173,7 +203,11 @@ class MainActivity : Activity(), SvxService.Listener {
connected = false connected = false
updatePttEnabled() updatePttEnabled()
statusChip.text = getString(R.string.status_disconnected) 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, ReflectorClient.STATE_CONNECTING,
@@ -182,7 +216,9 @@ class MainActivity : Activity(), SvxService.Listener {
ReflectorClient.STATE_WAIT_AUTH_OK, ReflectorClient.STATE_WAIT_AUTH_OK,
ReflectorClient.STATE_WAIT_SERVER_INFO -> { ReflectorClient.STATE_WAIT_SERVER_INFO -> {
statusChip.text = getString(R.string.status_connecting) 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 connected = false
updatePttEnabled() updatePttEnabled()
} }
@@ -191,14 +227,20 @@ class MainActivity : Activity(), SvxService.Listener {
connected = true connected = true
updatePttEnabled() updatePttEnabled()
statusChip.text = getString(R.string.status_connected) 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 -> { ReflectorClient.STATE_AUTH_FAILED -> {
connected = false connected = false
updatePttEnabled() updatePttEnabled()
statusChip.text = getString(R.string.status_auth_failed) 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 ?: "")) toast(getString(R.string.status_error, detail ?: ""))
} }
@@ -206,7 +248,10 @@ class MainActivity : Activity(), SvxService.Listener {
connected = false connected = false
updatePttEnabled() updatePttEnabled()
statusChip.text = getString(R.string.status_error, detail ?: "") 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 +284,18 @@ class MainActivity : Activity(), SvxService.Listener {
override fun onLevel(source: Int, level: Float) { override fun onLevel(source: Int, level: Float) {
mainHandler.post { 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 +309,119 @@ class MainActivity : Activity(), SvxService.Listener {
pttButton.enabled2 = connected pttButton.enabled2 = connected
} }
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) { private fun toast(msg: String) {
Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() Toast.makeText(this, msg, Toast.LENGTH_SHORT).show()
} }

View File

@@ -0,0 +1,53 @@
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() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
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)
val baseAlpha = title.alpha
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({
val intent = Intent(this, MainActivity::class.java)
startActivity(intent)
overridePendingTransition(android.R.anim.fade_in, android.R.anim.fade_out)
finish()
}, 4000)
}
}

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

@@ -84,28 +84,46 @@ class PttButton @JvmOverloads constructor(
canvas.drawCircle(centerX, centerY, r, paint) canvas.drawCircle(centerX, centerY, r, paint)
canvas.drawCircle(centerX, centerY, r, ringPaint) canvas.drawCircle(centerX, centerY, r, ringPaint)
// Иконка микрофона // Иконка микрофона (как icons8: капсула + чашка + ножка + основание)
val micPaint = Paint(Paint.ANTI_ALIAS_FLAG) val micPaint = Paint(Paint.ANTI_ALIAS_FLAG)
micPaint.color = Color.WHITE micPaint.color = Color.WHITE
micPaint.style = Paint.Style.FILL micPaint.style = Paint.Style.FILL
val s = r * 0.55f
val path = Path()
// Простой микрофон
val x = centerX val x = centerX
val y = centerY val y = centerY
path.moveTo(x - s * 0.30f, y - s * 1.05f) val s = r * 0.5f
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) val halfW = s * 0.34f
path.close() canvas.drawRoundRect(
canvas.drawPath(path, micPaint) x - halfW, y - s * 0.85f, x + halfW, y + s * 0.10f,
canvas.drawRect(x - s * 0.06f, y - s * 1.15f, x + s * 0.06f, y - s * 0.28f, micPaint) halfW, halfW, micPaint
// ножка и дуга )
canvas.drawRect(x - s * 0.05f, y + s * 0.28f, x + s * 0.05f, y + s * 0.55f, micPaint) // чашка-держатель (U-образная, заполненная)
canvas.drawArc( val cup = Path()
x - s * 0.55f, y + s * 0.30f, cup.fillType = Path.FillType.EVEN_ODD
x + s * 0.55f, y + s * 1.20f, cup.moveTo(x - s * 0.52f, y + s * 0.06f)
0f, 180f, false, micPaint 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
) )
} }

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

@@ -4,24 +4,28 @@
android:layout_height="match_parent" android:layout_height="match_parent"
android:orientation="vertical" android:orientation="vertical"
android:background="@color/bg" android:background="@color/bg"
android:gravity="center_horizontal"
android:padding="16dp"> android:padding="16dp">
<!-- Верхняя панель -->
<LinearLayout <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:orientation="horizontal" android:orientation="horizontal"
android:gravity="center_vertical"> android:gravity="center_vertical">
<Space
android:layout_width="0dp"
android:layout_height="1dp"
android:layout_weight="1" />
<TextView <TextView
android:id="@+id/status_chip"
android:layout_width="wrap_content" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:paddingHorizontal="14dp" android:text="@string/app_title"
android:paddingVertical="6dp"
android:textColor="@color/text_primary" android:textColor="@color/text_primary"
android:textSize="14sp" android:textSize="20sp"
android:background="@drawable/bg_chip" android:textStyle="bold" />
android:text="@string/status_disconnected" />
<Space <Space
android:layout_width="0dp" android:layout_width="0dp"
@@ -38,63 +42,213 @@
android:scaleType="centerInside" /> android:scaleType="centerInside" />
</LinearLayout> </LinearLayout>
<TextView <!-- Позывной + комната + группа -->
android:id="@+id/lbl_subtitle" <LinearLayout
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="8dp" android:layout_marginTop="36dp"
android:textColor="@color/text_secondary" android:orientation="horizontal"
android:textSize="13sp" 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:singleLine="true"
android:ellipsize="end" android:ellipsize="end"
android:text="@string/settings_not_configured" /> android:text="@string/settings_not_configured" />
<TextView <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_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="28dp" 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:textColor="@color/text_secondary"
android:textSize="14sp" android:textSize="13sp"
android:text="@string/talker_none" /> 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 <TextView
android:id="@+id/lbl_talker" android:id="@+id/lbl_talker"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="4dp" android:gravity="center"
android:textColor="@color/accent" android:textColor="@color/accent"
android:textSize="28sp" android:textSize="22sp"
android:textStyle="bold" android:textStyle="bold"
android:singleLine="true" android:singleLine="true"
android:ellipsize="end" android:ellipsize="end" />
android:text="@string/talker_none" />
<Space <Space
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="0dp" android:layout_height="0dp"
android:layout_weight="1" /> android:layout_weight="1" />
<ru.ua1zbe.svxremote.ui.LevelMeter <!-- Кнопка PTT -->
android:id="@+id/level_meter"
android:layout_width="match_parent"
android:layout_height="30dp" />
<ru.ua1zbe.svxremote.ui.PttButton <ru.ua1zbe.svxremote.ui.PttButton
android:id="@+id/ptt_button" android:id="@+id/ptt_button"
android:layout_width="match_parent" android:layout_width="130dp"
android:layout_height="220dp" android:layout_height="130dp" />
android:layout_marginTop="24dp"
android:layout_marginBottom="12dp" />
<TextView <TextView
android:id="@+id/lbl_ptt_hint" android:id="@+id/lbl_ptt_hint"
android:layout_width="match_parent" android:layout_width="wrap_content"
android:layout_height="wrap_content" android:layout_height="wrap_content"
android:layout_marginTop="14dp"
android:gravity="center" android:gravity="center"
android:textColor="@color/text_secondary" android:textColor="@color/text_secondary"
android:textSize="13sp" android:textSize="14sp"
android:text="@string/ptt_hint" /> 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 <TextView
android:id="@+id/lbl_tx_state" android:id="@+id/lbl_tx_state"
@@ -104,17 +258,17 @@
android:textColor="@color/danger" android:textColor="@color/danger"
android:textSize="14sp" android:textSize="14sp"
android:textStyle="bold" android:textStyle="bold"
android:visibility="gone" android:text="@string/status_big_disconnected" />
android:text="@string/ptt_talking" />
<Button <TextView
android:id="@+id/btn_connect" android:id="@+id/lbl_connect_hint"
android:layout_width="match_parent" android:layout_width="match_parent"
android:layout_height="52dp" android:layout_height="wrap_content"
android:layout_marginTop="12dp" android:layout_marginTop="4dp"
android:text="@string/btn_connect" android:gravity="center"
android:textColor="#0D1117" android:textColor="@color/text_secondary"
android:background="@drawable/bg_primary_btn" android:textSize="12sp"
android:textSize="16sp" android:textStyle="italic"
android:textStyle="bold" /> android:text="@string/hint_connect" />
</LinearLayout>
</LinearLayout> </LinearLayout>

View File

@@ -128,5 +128,15 @@
android:background="@drawable/bg_primary_btn" android:background="@drawable/bg_primary_btn"
android:textSize="16sp" android:textSize="16sp"
android:textStyle="bold" /> 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> </LinearLayout>
</ScrollView> </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

@@ -10,10 +10,24 @@
<string name="pref_auth_key">Пароль (AUTH_KEY)</string> <string name="pref_auth_key">Пароль (AUTH_KEY)</string>
<string name="pref_tg">Группа вещания (TG)</string> <string name="pref_tg">Группа вещания (TG)</string>
<string name="btn_save">Сохранить</string> <string name="btn_save">Сохранить</string>
<string name="app_version">SVXhomyak — v1.0.2</string>
<string name="saved">Сохранено</string> <string name="saved">Сохранено</string>
<string name="pref_callsign_hint">например UA1ZBE</string> <string name="pref_callsign_hint">например UA1ZBE</string>
<string name="status_disconnected">Отключено</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">Подключение…</string>
<string name="status_connecting_ssl">Проверка ключа…</string> <string name="status_connecting_ssl">Проверка ключа…</string>
<string name="status_connected">На связи</string> <string name="status_connected">На связи</string>
@@ -44,4 +58,11 @@
<string name="settings_not_configured">Настройки не заданы</string> <string name="settings_not_configured">Настройки не заданы</string>
<string name="ptt_disabled">Нет связи</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> </resources>