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.INTERNET" />
|
||||||
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
|
<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
|
<application
|
||||||
android:name=".ServerManagerApp"
|
android:name=".ServerManagerApp"
|
||||||
@@ -22,5 +24,15 @@
|
|||||||
<category android:name="android.intent.category.LAUNCHER" />
|
<category android:name="android.intent.category.LAUNCHER" />
|
||||||
</intent-filter>
|
</intent-filter>
|
||||||
</activity>
|
</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>
|
</application>
|
||||||
</manifest>
|
</manifest>
|
||||||
|
|||||||
@@ -1,6 +1,11 @@
|
|||||||
package com.devlab.app
|
package com.devlab.app
|
||||||
|
|
||||||
|
import android.app.AlertDialog
|
||||||
import android.os.Bundle
|
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.ComponentActivity
|
||||||
import androidx.activity.compose.setContent
|
import androidx.activity.compose.setContent
|
||||||
import androidx.activity.enableEdgeToEdge
|
import androidx.activity.enableEdgeToEdge
|
||||||
@@ -40,12 +45,43 @@ class MainActivity : ComponentActivity() {
|
|||||||
installSplashScreen()
|
installSplashScreen()
|
||||||
super.onCreate(savedInstanceState)
|
super.onCreate(savedInstanceState)
|
||||||
enableEdgeToEdge()
|
enableEdgeToEdge()
|
||||||
|
checkForUpdates()
|
||||||
setContent {
|
setContent {
|
||||||
ServerManagerTheme {
|
ServerManagerTheme {
|
||||||
AppRoot()
|
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
|
@Composable
|
||||||
|
|||||||
@@ -46,4 +46,7 @@ interface ApiService {
|
|||||||
|
|
||||||
@GET("api/refresh-metrics")
|
@GET("api/refresh-metrics")
|
||||||
suspend fun refreshMetrics(): ApiResponse<Map<String, Any?>>
|
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 error: String? = null,
|
||||||
val pagination: Pagination? = 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>
|
<resources>
|
||||||
<string name="app_name">ServerManager</string>
|
<string name="app_name">ServerManager</string>
|
||||||
<string name="splash_subtitle">Server Management Platform</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>
|
</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/servers/:id/execute', [ApiController::class, 'executeCommand']);
|
||||||
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
$router->post('/api/auth/login', [ApiController::class, 'login']);
|
||||||
$router->post('/api/auth/register', [ApiController::class, 'register']);
|
$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/users', [ApiController::class, 'users']);
|
||||||
$router->get('/api/profile', [ApiController::class, 'profile']);
|
$router->get('/api/profile', [ApiController::class, 'profile']);
|
||||||
$router->put('/api/profile', [ApiController::class, 'updateProfile']);
|
$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
|
public function login(): void
|
||||||
{
|
{
|
||||||
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
$input = json_decode(file_get_contents('php://input'), true) ?? $_POST;
|
||||||
|
|||||||
Reference in New Issue
Block a user