diff --git a/android/app/src/main/AndroidManifest.xml b/android/app/src/main/AndroidManifest.xml index 0f6f5a9..68623b2 100644 --- a/android/app/src/main/AndroidManifest.xml +++ b/android/app/src/main/AndroidManifest.xml @@ -3,6 +3,8 @@ + + + + + + diff --git a/android/app/src/main/java/com/devlab/app/MainActivity.kt b/android/app/src/main/java/com/devlab/app/MainActivity.kt index 492b47e..3353d9b 100644 --- a/android/app/src/main/java/com/devlab/app/MainActivity.kt +++ b/android/app/src/main/java/com/devlab/app/MainActivity.kt @@ -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 diff --git a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt index 2a96e41..0857638 100644 --- a/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt +++ b/android/app/src/main/java/com/devlab/app/data/api/ApiService.kt @@ -46,4 +46,7 @@ interface ApiService { @GET("api/refresh-metrics") suspend fun refreshMetrics(): ApiResponse> + + @GET("api/app-version") + suspend fun checkVersion(): ApiResponse } diff --git a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt index e4e5c2c..601c2bb 100644 --- a/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt +++ b/android/app/src/main/java/com/devlab/app/data/model/ApiResponse.kt @@ -17,3 +17,11 @@ data class ApiResponse( 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 = "" +) diff --git a/android/app/src/main/java/com/devlab/app/util/AppUpdater.kt b/android/app/src/main/java/com/devlab/app/util/AppUpdater.kt new file mode 100644 index 0000000..92fe59c --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/util/AppUpdater.kt @@ -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) + } + } +} diff --git a/android/app/src/main/java/com/devlab/app/util/UpdateChecker.kt b/android/app/src/main/java/com/devlab/app/util/UpdateChecker.kt new file mode 100644 index 0000000..d8f340d --- /dev/null +++ b/android/app/src/main/java/com/devlab/app/util/UpdateChecker.kt @@ -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 { + 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.compareTo(other: Triple): Int { + return when { + first != other.first -> first.compareTo(other.first) + second != other.second -> second.compareTo(other.second) + else -> third.compareTo(other.third) + } + } +} diff --git a/android/app/src/main/res/values/strings.xml b/android/app/src/main/res/values/strings.xml index 856d322..dba1be7 100644 --- a/android/app/src/main/res/values/strings.xml +++ b/android/app/src/main/res/values/strings.xml @@ -2,4 +2,9 @@ ServerManager Server Management Platform + Update Available + Update Required + Update Now + Later + Downloading update... diff --git a/android/app/src/main/res/xml/file_paths.xml b/android/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..a9d2666 --- /dev/null +++ b/android/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,4 @@ + + + + diff --git a/public/sysadmin.apk b/public/sysadmin.apk index 09c8658..67265ab 100644 Binary files a/public/sysadmin.apk and b/public/sysadmin.apk differ diff --git a/routes/web.php b/routes/web.php index dc7785f..70ab3ad 100755 --- a/routes/web.php +++ b/routes/web.php @@ -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']); diff --git a/src/Controllers/ApiController.php b/src/Controllers/ApiController.php index 590f3bd..94aaf3c 100755 --- a/src/Controllers/ApiController.php +++ b/src/Controllers/ApiController.php @@ -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;