Auto-update via Gitea releases (as in svxhomyak)
This commit is contained in:
@@ -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>
|
||||||
@@ -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"
|
||||||
|
}
|
||||||
}
|
}
|
||||||
77
app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt
Normal file
77
app/src/main/java/ru/ua1zbe/aprsmap/UpdateChecker.kt
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
34
app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt
Normal file
34
app/src/main/java/ru/ua1zbe/aprsmap/UpdateFileProvider.kt
Normal 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
|
||||||
|
}
|
||||||
@@ -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&zoom=8</string>
|
<string name="map_url">https://aprs.radiorubka.org/?center=67.5096,32.8271&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>
|
||||||
Reference in New Issue
Block a user