From 1a9dd9a5a498e30a540e7974342418a1ee6f5190 Mon Sep 17 00:00:00 2001 From: ua1zbe Date: Fri, 14 Aug 2026 08:54:17 +0300 Subject: [PATCH] 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 --- app/build.gradle.kts | 4 +- app/src/main/AndroidManifest.xml | 7 + .../java/ru/ua1zbe/svxremote/MainActivity.kt | 201 ++++++++++++-- .../java/ru/ua1zbe/svxremote/UpdateChecker.kt | 77 ++++++ .../ru/ua1zbe/svxremote/UpdateFileProvider.kt | 34 +++ .../java/ru/ua1zbe/svxremote/ui/PttButton.kt | 52 ++-- app/src/main/res/drawable/bg_meter.xml | 6 + .../main/res/drawable/bg_meter_fill_mic.xml | 5 + .../main/res/drawable/bg_meter_fill_rx.xml | 5 + app/src/main/res/layout/activity_main.xml | 256 ++++++++++++++---- app/src/main/res/values/strings.xml | 22 +- 11 files changed, 582 insertions(+), 87 deletions(-) create mode 100644 app/src/main/java/ru/ua1zbe/svxremote/UpdateChecker.kt create mode 100644 app/src/main/java/ru/ua1zbe/svxremote/UpdateFileProvider.kt create mode 100644 app/src/main/res/drawable/bg_meter.xml create mode 100644 app/src/main/res/drawable/bg_meter_fill_mic.xml create mode 100644 app/src/main/res/drawable/bg_meter_fill_rx.xml diff --git a/app/build.gradle.kts b/app/build.gradle.kts index ca22965..b4d1da0 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -11,8 +11,8 @@ android { applicationId = "ru.ua1zbe.svxremote" minSdk = 26 targetSdk = 34 - versionCode = 2 - versionName = "1.0.0" + versionCode = 4 + versionName = "1.0.2" } buildTypes { diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index c8da151..817c3de 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -6,6 +6,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt index 469dfac..cf70053 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/MainActivity.kt @@ -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,14 @@ class MainActivity : Activity(), SvxService.Listener { } } + private var pendingUpdate: UpdateChecker.UpdateInfo? = null + private var updateDialog: AlertDialog? = null + override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) 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,16 +88,23 @@ 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 { + connectZone.setOnClickListener { val s = svc if (connected && s != null) { s.disconnect() @@ -127,15 +149,17 @@ 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) } }) @@ -161,6 +185,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 +203,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 +216,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 +227,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 +248,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 +284,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 +309,119 @@ class MainActivity : Activity(), SvxService.Listener { 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) { Toast.makeText(this, msg, Toast.LENGTH_SHORT).show() } diff --git a/app/src/main/java/ru/ua1zbe/svxremote/UpdateChecker.kt b/app/src/main/java/ru/ua1zbe/svxremote/UpdateChecker.kt new file mode 100644 index 0000000..fa0f8a3 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/UpdateChecker.kt @@ -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 + } +} \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/svxremote/UpdateFileProvider.kt b/app/src/main/java/ru/ua1zbe/svxremote/UpdateFileProvider.kt new file mode 100644 index 0000000..0a17316 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/svxremote/UpdateFileProvider.kt @@ -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?): Int = 0 + override fun query( + uri: Uri, projection: Array?, selection: String?, + selectionArgs: Array?, sortOrder: String? + ): Cursor? = null + override fun update(uri: Uri, values: ContentValues?, selection: String?, selectionArgs: Array?): Int = 0 +} \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/svxremote/ui/PttButton.kt b/app/src/main/java/ru/ua1zbe/svxremote/ui/PttButton.kt index e3be73a..5ef3226 100644 --- a/app/src/main/java/ru/ua1zbe/svxremote/ui/PttButton.kt +++ b/app/src/main/java/ru/ua1zbe/svxremote/ui/PttButton.kt @@ -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 ) } diff --git a/app/src/main/res/drawable/bg_meter.xml b/app/src/main/res/drawable/bg_meter.xml new file mode 100644 index 0000000..52ebba1 --- /dev/null +++ b/app/src/main/res/drawable/bg_meter.xml @@ -0,0 +1,6 @@ + + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_meter_fill_mic.xml b/app/src/main/res/drawable/bg_meter_fill_mic.xml new file mode 100644 index 0000000..ecdd77d --- /dev/null +++ b/app/src/main/res/drawable/bg_meter_fill_mic.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/drawable/bg_meter_fill_rx.xml b/app/src/main/res/drawable/bg_meter_fill_rx.xml new file mode 100644 index 0000000..992dc31 --- /dev/null +++ b/app/src/main/res/drawable/bg_meter_fill_rx.xml @@ -0,0 +1,5 @@ + + + + + \ No newline at end of file diff --git a/app/src/main/res/layout/activity_main.xml b/app/src/main/res/layout/activity_main.xml index 1377df4..9fc228e 100644 --- a/app/src/main/res/layout/activity_main.xml +++ b/app/src/main/res/layout/activity_main.xml @@ -4,24 +4,28 @@ android:layout_height="match_parent" android:orientation="vertical" android:background="@color/bg" + android:gravity="center_horizontal" android:padding="16dp"> + + + + android:textSize="20sp" + android:textStyle="bold" /> - + + android:layout_marginTop="36dp" + android:orientation="horizontal" + android:gravity="center_vertical"> + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + android:textSize="13sp" + android:text="@string/talker_last_none" /> + android:ellipsize="end" /> - - + + android:layout_width="130dp" + android:layout_height="130dp" /> - - + android:text="@string/hold_to_talk" /> -