feat: add auto-update mechanism with in-app APK download
This commit is contained in:
@@ -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>
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
}
|
||||
|
||||
@@ -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 = ""
|
||||
)
|
||||
|
||||
103
android/app/src/main/java/com/devlab/app/util/AppUpdater.kt
Normal file
103
android/app/src/main/java/com/devlab/app/util/AppUpdater.kt
Normal 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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
|
||||
4
android/app/src/main/res/xml/file_paths.xml
Normal file
4
android/app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,4 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<external-files-path name="downloads" path="Download/" />
|
||||
</paths>
|
||||
Binary file not shown.
@@ -103,6 +103,7 @@ $router->get('/api/servers/:id/metrics', [ApiController::class, 'serverMetrics']
|
||||
$router->post('/api/servers/:id/execute', [ApiController::class, 'executeCommand']);
|
||||
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
||||
$router->post('/api/auth/register', [ApiController::class, 'register']);
|
||||
$router->get('/api/app-version', [ApiController::class, 'appVersion']);
|
||||
$router->get('/api/users', [ApiController::class, 'users']);
|
||||
$router->get('/api/profile', [ApiController::class, 'profile']);
|
||||
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
|
||||
|
||||
@@ -466,6 +466,19 @@ class ApiController
|
||||
]);
|
||||
}
|
||||
|
||||
public function appVersion(): void
|
||||
{
|
||||
$this->view->json([
|
||||
'success' => true,
|
||||
'data' => [
|
||||
'version' => '1.0.1',
|
||||
'apk_url' => '/sysadmin.apk',
|
||||
'force_update' => false,
|
||||
'release_notes' => '• Gráficos de métricas en tiempo real\n• Nuevo diseño con animaciones\n• Modo oscuro\n• Perfil de usuario\n• Mejoras de rendimiento',
|
||||
],
|
||||
]);
|
||||
}
|
||||
|
||||
public function login(): void
|
||||
{
|
||||
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||
|
||||
Reference in New Issue
Block a user