feat: add auto-update mechanism with in-app APK download

This commit is contained in:
2026-06-09 16:59:08 -04:00
parent a424120770
commit 3a4e8eb52c
11 changed files with 243 additions and 0 deletions

View File

@@ -3,6 +3,8 @@
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<application
android:name=".ServerManagerApp"
@@ -22,5 +24,15 @@
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="${applicationId}.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
</application>
</manifest>

View File

@@ -1,6 +1,11 @@
package com.devlab.app
import android.app.AlertDialog
import android.os.Bundle
import androidx.lifecycle.lifecycleScope
import com.devlab.app.util.AppUpdater
import com.devlab.app.util.UpdateChecker
import kotlinx.coroutines.launch
import androidx.activity.ComponentActivity
import androidx.activity.compose.setContent
import androidx.activity.enableEdgeToEdge
@@ -40,12 +45,43 @@ class MainActivity : ComponentActivity() {
installSplashScreen()
super.onCreate(savedInstanceState)
enableEdgeToEdge()
checkForUpdates()
setContent {
ServerManagerTheme {
AppRoot()
}
}
}
private fun checkForUpdates() {
val versionName = try {
packageManager.getPackageInfo(packageName, 0).versionName ?: "1.0.0"
} catch (_: Exception) { "1.0.0" }
lifecycleScope.launch {
val updateInfo = UpdateChecker(versionName).check()
if (updateInfo.hasUpdate) {
if (updateInfo.forceUpdate) {
AlertDialog.Builder(this@MainActivity)
.setTitle("Update Required")
.setMessage("A new version (${updateInfo.version}) is required to continue.\n\n${updateInfo.releaseNotes}")
.setCancelable(false)
.setPositiveButton("Update Now") { _, _ ->
AppUpdater(this@MainActivity).downloadAndInstall(updateInfo.apkUrl)
}
.show()
} else {
AlertDialog.Builder(this@MainActivity)
.setTitle("Update Available")
.setMessage("Version ${updateInfo.version} is now available.\n\n${updateInfo.releaseNotes}")
.setPositiveButton("Update Now") { _, _ ->
AppUpdater(this@MainActivity).downloadAndInstall(updateInfo.apkUrl)
}
.setNegativeButton("Later", null)
.show()
}
}
}
}
}
@Composable

View File

@@ -46,4 +46,7 @@ interface ApiService {
@GET("api/refresh-metrics")
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>
@GET("api/app-version")
suspend fun checkVersion(): ApiResponse<AppVersionResponse>
}

View File

@@ -17,3 +17,11 @@ data class ApiResponse<T>(
val error: String? = null,
val pagination: Pagination? = null
)
@Serializable
data class AppVersionResponse(
val version: String = "",
val apk_url: String = "",
val force_update: Boolean = false,
val release_notes: String = ""
)

View File

@@ -0,0 +1,103 @@
package com.devlab.app.util
import android.app.DownloadManager
import android.app.NotificationChannel
import android.app.NotificationManager
import android.content.BroadcastReceiver
import android.content.Context
import android.content.Intent
import android.content.IntentFilter
import android.database.Cursor
import android.net.Uri
import android.os.Build
import android.os.Environment
import android.widget.Toast
import androidx.core.app.NotificationCompat
import androidx.core.content.FileProvider
import androidx.lifecycle.LifecycleOwner
import com.devlab.app.R
import java.io.File
class AppUpdater(private val context: Context) {
private val downloadManager = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
private var downloadId: Long = -1
companion object {
private const val CHANNEL_ID = "app_update"
private const val NOTIFICATION_ID = 1001
private const val APK_FILE_NAME = "servermanager-update.apk"
}
fun downloadAndInstall(apkUrl: String) {
createNotificationChannel()
val apkFile = File(
context.getExternalFilesDir(Environment.DIRECTORY_DOWNLOADS),
APK_FILE_NAME
)
apkFile.delete()
val baseUrl = com.devlab.app.data.api.RetrofitClient.BASE_URL
val fullUrl = if (apkUrl.startsWith("http")) apkUrl else baseUrl.trimEnd('/') + "/" + apkUrl.trimStart('/')
val request = DownloadManager.Request(Uri.parse(fullUrl))
.setTitle("ServerManager Update")
.setDescription("Downloading update...")
.setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE_NOTIFY_COMPLETED)
.setDestinationUri(Uri.fromFile(apkFile))
.setAllowedOverMetered(true)
.setAllowedOverRoaming(true)
downloadId = downloadManager.enqueue(request)
context.registerReceiver(
object : BroadcastReceiver() {
override fun onReceive(ctx: Context?, intent: Intent?) {
val id = intent?.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) ?: return
if (id == downloadId) {
val query = DownloadManager.Query().setFilterById(downloadId)
val cursor: Cursor = downloadManager.query(query)
if (cursor.moveToFirst()) {
val status = cursor.getInt(cursor.getColumnIndex(DownloadManager.COLUMN_STATUS))
if (status == DownloadManager.STATUS_SUCCESSFUL) {
installApk(apkFile)
} else {
Toast.makeText(context, "Download failed", Toast.LENGTH_SHORT).show()
}
}
cursor.close()
try {
context.unregisterReceiver(this)
} catch (_: Exception) { }
}
}
},
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE)
)
}
private fun installApk(file: File) {
val uri = FileProvider.getUriForFile(
context,
"${context.packageName}.fileprovider",
file
)
val intent = Intent(Intent.ACTION_VIEW).apply {
setDataAndType(uri, "application/vnd.android.package-archive")
flags = Intent.FLAG_GRANT_READ_URI_PERMISSION or Intent.FLAG_ACTIVITY_NEW_TASK
}
context.startActivity(intent)
}
private fun createNotificationChannel() {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
val channel = NotificationChannel(
CHANNEL_ID,
"App Updates",
NotificationManager.IMPORTANCE_LOW
)
val manager = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
manager.createNotificationChannel(channel)
}
}
}

View File

@@ -0,0 +1,58 @@
package com.devlab.app.util
import com.devlab.app.data.api.RetrofitClient
import com.devlab.app.data.model.AppVersionResponse
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
data class UpdateInfo(
val hasUpdate: Boolean,
val version: String,
val apkUrl: String,
val forceUpdate: Boolean,
val releaseNotes: String
)
class UpdateChecker(private val currentVersion: String) {
suspend fun check(): UpdateInfo = withContext(Dispatchers.IO) {
try {
val service = RetrofitClient.getUnauthenticatedService()
val response = service.checkVersion()
if (response.success && response.data != null) {
val remoteVersion = parseVersion(response.data.version)
val localVersion = parseVersion(currentVersion)
val hasUpdate = remoteVersion > localVersion
UpdateInfo(
hasUpdate = hasUpdate,
version = response.data.version,
apkUrl = response.data.apk_url,
forceUpdate = response.data.force_update,
releaseNotes = response.data.release_notes
)
} else {
UpdateInfo(false, "", "", false, "")
}
} catch (_: Exception) {
UpdateInfo(false, "", "", false, "")
}
}
private fun parseVersion(version: String): Triple<Int, Int, Int> {
val parts = version.split(".").mapNotNull { it.toIntOrNull() }
return Triple(
parts.getOrElse(0) { 0 },
parts.getOrElse(1) { 0 },
parts.getOrElse(2) { 0 }
)
}
private operator fun Triple<Int, Int, Int>.compareTo(other: Triple<Int, Int, Int>): Int {
return when {
first != other.first -> first.compareTo(other.first)
second != other.second -> second.compareTo(other.second)
else -> third.compareTo(other.third)
}
}
}

View File

@@ -2,4 +2,9 @@
<resources>
<string name="app_name">ServerManager</string>
<string name="splash_subtitle">Server Management Platform</string>
<string name="update_available">Update Available</string>
<string name="update_required">Update Required</string>
<string name="update_now">Update Now</string>
<string name="later">Later</string>
<string name="downloading_update">Downloading update...</string>
</resources>

View File

@@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<external-files-path name="downloads" path="Download/" />
</paths>