Auto-update via Gitea releases (as in svxhomyak)

This commit is contained in:
ua1zbe
2026-09-23 08:56:09 +03:00
parent eaa2472a8e
commit f4440ea6b4
5 changed files with 262 additions and 0 deletions

View File

@@ -2,6 +2,7 @@
<manifest xmlns:android="http://schemas.android.com/apk/res/android"> <manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" /> <uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application <application
android:allowBackup="true" android:allowBackup="true"
@@ -27,6 +28,12 @@
android:exported="false" android:exported="false"
android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode" android:configChanges="orientation|screenSize|screenLayout|keyboardHidden|smallestScreenSize|uiMode"
android:theme="@style/Theme.APRS.Main" /> android:theme="@style/Theme.APRS.Main" />
<provider
android:name=".UpdateFileProvider"
android:authorities="ru.ua1zbe.aprsmap.fileprovider"
android:exported="false"
android:grantUriPermissions="true" />
</application> </application>
</manifest> </manifest>

View File

@@ -2,10 +2,18 @@ package ru.ua1zbe.aprsmap
import android.annotation.SuppressLint import android.annotation.SuppressLint
import android.app.Activity import android.app.Activity
import android.app.AlertDialog
import android.content.Intent
import android.graphics.Color import android.graphics.Color
import android.graphics.PorterDuff import android.graphics.PorterDuff
import android.graphics.PorterDuffColorFilter import android.graphics.PorterDuffColorFilter
import android.net.Uri
import android.os.Build
import android.os.Bundle 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.Gravity
import android.view.ViewGroup import android.view.ViewGroup
import android.webkit.WebChromeClient import android.webkit.WebChromeClient
@@ -13,13 +21,21 @@ import android.webkit.WebSettings
import android.webkit.WebView import android.webkit.WebView
import android.webkit.WebViewClient import android.webkit.WebViewClient
import android.widget.FrameLayout import android.widget.FrameLayout
import android.widget.LinearLayout
import android.widget.ProgressBar import android.widget.ProgressBar
import android.widget.TextView
import android.widget.Toast
import java.io.File
class MainActivity : Activity() { class MainActivity : Activity() {
private lateinit var webView: WebView private lateinit var webView: WebView
private lateinit var progress: ProgressBar private lateinit var progress: ProgressBar
private var pendingUpdate: UpdateChecker.UpdateInfo? = null
private var updateDialog: AlertDialog? = null
private val mainHandler = Handler(Looper.getMainLooper())
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
override fun onCreate(savedInstanceState: Bundle?) { override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
@@ -87,8 +103,126 @@ class MainActivity : Activity() {
setContentView(root) setContentView(root)
webView.loadUrl(getString(R.string.map_url)) 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") @Deprecated("Deprecated in Java")
override fun onBackPressed() { override fun onBackPressed() {
finish() finish()
@@ -98,4 +232,8 @@ class MainActivity : Activity() {
webView.destroy() webView.destroy()
super.onDestroy() super.onDestroy()
} }
companion object {
private const val TAG = "APRSmap"
}
} }

View File

@@ -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
}
}

View File

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

@@ -2,4 +2,10 @@
<resources> <resources>
<string name="app_name">APRS RadioRubka</string> <string name="app_name">APRS RadioRubka</string>
<string name="map_url">https://aprs.radiorubka.org/?center=67.5096,32.8271&amp;zoom=8</string> <string name="map_url">https://aprs.radiorubka.org/?center=67.5096,32.8271&amp;zoom=8</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>