From f4440ea6b40fbb01963284ceb8e59c6ad2101521 Mon Sep 17 00:00:00 2001 From: ua1zbe Date: Wed, 23 Sep 2026 08:56:09 +0300 Subject: [PATCH] Auto-update via Gitea releases (as in svxhomyak) --- app/src/main/AndroidManifest.xml | 7 + .../java/ru/ua1zbe/aprsmap/MainActivity.kt | 138 ++++++++++++++++++ .../java/ru/ua1zbe/aprsmap/UpdateChecker.kt | 77 ++++++++++ .../ru/ua1zbe/aprsmap/UpdateFileProvider.kt | 34 +++++ app/src/main/res/values/strings.xml | 6 + 5 files changed, 262 insertions(+) create mode 100644 app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt create mode 100644 app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index bc67852..9de5042 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -2,6 +2,7 @@ + + + \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/aprsmap/MainActivity.kt b/app/src/main/java/ru/ua1zbe/aprsmap/MainActivity.kt index 225fd0b..3cfd07c 100644 --- a/app/src/main/java/ru/ua1zbe/aprsmap/MainActivity.kt +++ b/app/src/main/java/ru/ua1zbe/aprsmap/MainActivity.kt @@ -2,10 +2,18 @@ package ru.ua1zbe.aprsmap import android.annotation.SuppressLint import android.app.Activity +import android.app.AlertDialog +import android.content.Intent import android.graphics.Color import android.graphics.PorterDuff import android.graphics.PorterDuffColorFilter +import android.net.Uri +import android.os.Build import android.os.Bundle +import android.os.Handler +import android.os.Looper +import android.provider.Settings +import android.util.Log import android.view.Gravity import android.view.ViewGroup import android.webkit.WebChromeClient @@ -13,13 +21,21 @@ import android.webkit.WebSettings import android.webkit.WebView import android.webkit.WebViewClient import android.widget.FrameLayout +import android.widget.LinearLayout import android.widget.ProgressBar +import android.widget.TextView +import android.widget.Toast +import java.io.File class MainActivity : Activity() { private lateinit var webView: WebView private lateinit var progress: ProgressBar + private var pendingUpdate: UpdateChecker.UpdateInfo? = null + private var updateDialog: AlertDialog? = null + private val mainHandler = Handler(Looper.getMainLooper()) + @SuppressLint("SetJavaScriptEnabled") override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) @@ -87,8 +103,126 @@ class MainActivity : Activity() { setContentView(root) webView.loadUrl(getString(R.string.map_url)) + checkForUpdates() } + 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 bar = ProgressBar(this, null, android.R.attr.progressBarStyleHorizontal) + bar.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( + bar, + 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/aprsmap-${info.version}.apk") + file.parentFile?.mkdirs() + Thread { + try { + UpdateChecker.download(info.apkUrl, file) { p -> + mainHandler.post { bar.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 toast(msg: String) { + runOnUiThread { Toast.makeText(this, msg, Toast.LENGTH_LONG).show() } + } + + private fun dp(v: Int): Int = (v * resources.displayMetrics.density).toInt() + @Deprecated("Deprecated in Java") override fun onBackPressed() { finish() @@ -98,4 +232,8 @@ class MainActivity : Activity() { webView.destroy() super.onDestroy() } + + companion object { + private const val TAG = "APRSmap" + } } \ No newline at end of file diff --git a/app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt b/app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt new file mode 100644 index 0000000..dd81da2 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt @@ -0,0 +1,77 @@ +package ru.ua1zbe.aprsmap + +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/aprsmap/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/aprsmap/UpdateFileProvider.kt b/app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt new file mode 100644 index 0000000..cd4ec72 --- /dev/null +++ b/app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt @@ -0,0 +1,34 @@ +package ru.ua1zbe.aprsmap + +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/res/values/strings.xml b/app/src/main/res/values/strings.xml index 08c4501..81bfca5 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -2,4 +2,10 @@ APRS RadioRubka https://aprs.radiorubka.org/?center=67.5096,32.8271&zoom=8 + Доступно обновление + Версия %1$s уже вышла. Обновить приложение? + Обновить + Позже + Загрузка обновления… + Не удалось загрузить обновление: %1$s \ No newline at end of file