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