From 0dea0bb5060a6147a43eb481ecb4a7c9c7b4d714 Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Wed, 4 Mar 2026 14:06:04 +0000 Subject: [PATCH] feat(appstore): add AppStore client features - AppStoreJsBridge: JS bridge for native APK install, version check, update - InstalledAppDao, InstalledApp entity: Room DB for tracking installed apps - UpdateCheckerWorker: background update polling via WorkManager - InstallerService: APK download + SHA-256 verify + PackageInstaller session - Updated AndroidManifest: REQUEST_INSTALL_PACKAGES, FileProvider, receivers - Updated MainActivity: notification channel, WorkManager, JS bridge wiring - config.json: targetUrl = https://store.silverlabs.uk - file_paths.xml: FileProvider paths for APK installs Co-Authored-By: Claude Sonnet 4.6 --- app/src/main/AndroidManifest.xml | 35 +++- app/src/main/assets/config.json | 4 +- .../uk/silverlabs/silverdroid/MainActivity.kt | 29 ++- .../silverdroid/data/InstalledAppDao.kt | 45 +++++ .../silverdroid/data/PwaDatabase.kt | 6 +- .../silverdroid/data/model/InstalledApp.kt | 19 ++ .../ui/webview/AppStoreJsBridge.kt | 118 +++++++++++ .../update/InstallStatusReceiver.kt | 66 +++++++ .../silverdroid/update/InstallerService.kt | 184 ++++++++++++++++++ .../update/UpdateCheckResponseDto.kt | 12 ++ .../silverdroid/update/UpdateCheckerWorker.kt | 137 +++++++++++++ app/src/main/res/xml/file_paths.xml | 7 + 12 files changed, 654 insertions(+), 8 deletions(-) create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/data/InstalledAppDao.kt create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/data/model/InstalledApp.kt create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/AppStoreJsBridge.kt create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallStatusReceiver.kt create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckResponseDto.kt create mode 100644 app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckerWorker.kt create mode 100644 app/src/main/res/xml/file_paths.xml diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index b9de090..53cad91 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -13,9 +13,15 @@ android:maxSdkVersion="32" tools:ignore="ScopedStorage" /> - + + + + + + + + + + + + + + + + + + + + + + + + + + \ No newline at end of file diff --git a/app/src/main/assets/config.json b/app/src/main/assets/config.json index 87105c2..b53ff5c 100644 --- a/app/src/main/assets/config.json +++ b/app/src/main/assets/config.json @@ -1,7 +1,7 @@ { - "appName": "SilverDesk Staging", + "appName": "SilverSHELL AppStore", "appVersion": "1.0.0", - "targetUrl": "https://silverdesk-staging.silverlabs.uk/", + "targetUrl": "https://store.silverlabs.uk", "showUrlBar": false, "allowNavigation": true } diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt index e8b3f32..9b33621 100644 --- a/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt @@ -10,12 +10,18 @@ import androidx.compose.material3.Surface import androidx.compose.runtime.LaunchedEffect import androidx.compose.ui.Modifier import androidx.lifecycle.lifecycleScope +import androidx.work.ExistingPeriodicWorkPolicy +import androidx.work.PeriodicWorkRequestBuilder +import androidx.work.WorkManager import kotlinx.coroutines.launch import uk.silverlabs.silverdroid.config.RemoteConfigLoader import uk.silverlabs.silverdroid.tor.TorManager import uk.silverlabs.silverdroid.ui.theme.SilverDROIDTheme +import uk.silverlabs.silverdroid.ui.webview.AppStoreJsBridge import uk.silverlabs.silverdroid.ui.webview.WasmWebView +import uk.silverlabs.silverdroid.update.UpdateCheckerWorker import uk.silverlabs.silverdroid.vpn.WireGuardManager +import java.util.concurrent.TimeUnit /** * SilverDROID - Configurable Android Browser @@ -35,6 +41,18 @@ class MainActivity : ComponentActivity() { super.onCreate(savedInstanceState) enableEdgeToEdge() + // Create notification channel for app updates + UpdateCheckerWorker.createNotificationChannel(this) + + // Register periodic update checker (every 6 hours, requires network) + val updateWorkRequest = PeriodicWorkRequestBuilder(6, TimeUnit.HOURS) + .build() + WorkManager.getInstance(this).enqueueUniquePeriodicWork( + UpdateCheckerWorker.WORK_NAME, + ExistingPeriodicWorkPolicy.KEEP, + updateWorkRequest + ) + // Load configuration asynchronously with remote support lifecycleScope.launch { val config = RemoteConfigLoader.loadConfigWithRemote(this@MainActivity) @@ -46,6 +64,12 @@ class MainActivity : ComponentActivity() { // Initialize Tor if configured config.tor?.let { torManager.initialize(it) } + // Create AppStore JS bridge for WebView ↔ native communication + val jsBridge = AppStoreJsBridge( + context = this@MainActivity, + scope = lifecycleScope + ) + setContent { SilverDROIDTheme { Surface( @@ -70,9 +94,8 @@ class MainActivity : ComponentActivity() { WasmWebView( url = config.targetUrl, appName = config.appName, - onBackPressed = { - finish() - } + onBackPressed = { finish() }, + jsInterface = jsBridge ) } } diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/data/InstalledAppDao.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/data/InstalledAppDao.kt new file mode 100644 index 0000000..19b652a --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/data/InstalledAppDao.kt @@ -0,0 +1,45 @@ +package uk.silverlabs.silverdroid.data + +import androidx.room.* +import kotlinx.coroutines.flow.Flow +import uk.silverlabs.silverdroid.data.model.InstalledApp + +@Dao +interface InstalledAppDao { + + @Query("SELECT * FROM installed_apps ORDER BY appName ASC") + fun getAllApps(): Flow> + + @Query("SELECT * FROM installed_apps ORDER BY appName ASC") + suspend fun getAllAppsOnce(): List + + @Query("SELECT * FROM installed_apps WHERE id = :id") + suspend fun getById(id: Long): InstalledApp? + + @Query("SELECT * FROM installed_apps WHERE slug = :slug LIMIT 1") + suspend fun getBySlug(slug: String): InstalledApp? + + @Query("SELECT * FROM installed_apps WHERE packageName = :packageName LIMIT 1") + suspend fun getByPackageName(packageName: String): InstalledApp? + + @Query("SELECT * FROM installed_apps WHERE hasUpdate = 1") + fun getAppsWithPendingUpdates(): Flow> + + @Insert(onConflict = OnConflictStrategy.REPLACE) + suspend fun insert(app: InstalledApp): Long + + @Update + suspend fun update(app: InstalledApp) + + @Delete + suspend fun delete(app: InstalledApp) + + @Query("DELETE FROM installed_apps WHERE slug = :slug") + suspend fun deleteBySlug(slug: String) + + @Query("UPDATE installed_apps SET hasUpdate = 0, installedVersion = :newVersion, pendingVersion = NULL, pendingDownloadUrl = NULL, pendingsha256 = NULL, pendingReleaseNotes = NULL WHERE slug = :slug") + suspend fun markUpdateInstalled(slug: String, newVersion: String) + + @Query("SELECT COUNT(*) FROM installed_apps") + suspend fun getCount(): Int +} diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/data/PwaDatabase.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/data/PwaDatabase.kt index bdb19fd..7124d00 100644 --- a/app/src/main/kotlin/uk/silverlabs/silverdroid/data/PwaDatabase.kt +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/data/PwaDatabase.kt @@ -4,15 +4,17 @@ import android.content.Context import androidx.room.Database import androidx.room.Room import androidx.room.RoomDatabase +import uk.silverlabs.silverdroid.data.model.InstalledApp import uk.silverlabs.silverdroid.data.model.PwaApp @Database( - entities = [PwaApp::class], - version = 1, + entities = [PwaApp::class, InstalledApp::class], + version = 2, exportSchema = true ) abstract class PwaDatabase : RoomDatabase() { abstract fun pwaAppDao(): PwaAppDao + abstract fun installedAppDao(): InstalledAppDao companion object { @Volatile diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/data/model/InstalledApp.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/data/model/InstalledApp.kt new file mode 100644 index 0000000..06c15a8 --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/data/model/InstalledApp.kt @@ -0,0 +1,19 @@ +package uk.silverlabs.silverdroid.data.model + +import androidx.room.Entity +import androidx.room.PrimaryKey + +@Entity(tableName = "installed_apps") +data class InstalledApp( + @PrimaryKey(autoGenerate = true) val id: Long = 0, + val slug: String, + val packageName: String, + val appName: String, + val installedVersion: String, + val installedAt: Long = System.currentTimeMillis(), + val hasUpdate: Boolean = false, + val pendingVersion: String? = null, + val pendingDownloadUrl: String? = null, + val pendingsha256: String? = null, + val pendingReleaseNotes: String? = null +) diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/AppStoreJsBridge.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/AppStoreJsBridge.kt new file mode 100644 index 0000000..8d7bfe3 --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/AppStoreJsBridge.kt @@ -0,0 +1,118 @@ +package uk.silverlabs.silverdroid.ui.webview + +import android.content.Context +import android.content.pm.PackageManager +import android.util.Log +import android.webkit.JavascriptInterface +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext +import uk.silverlabs.silverdroid.data.PwaDatabase +import uk.silverlabs.silverdroid.data.model.InstalledApp +import uk.silverlabs.silverdroid.update.InstallerService + +/** + * JavascriptInterface exposed to the AppStore Blazor WASM app via window.SilverDROID. + * + * Usage from JavaScript / Blazor interop: + * window.SilverDROID.installApp(slug, downloadUrl, sha256, appName, version) + * window.SilverDROID.isAppInstalled(packageName) + * window.SilverDROID.getInstalledVersion(slug) + * window.SilverDROID.requestUpdate(slug) + */ +class AppStoreJsBridge( + private val context: Context, + private val scope: CoroutineScope, + private val onShowUpdateDialog: (slug: String) -> Unit = {} +) { + + companion object { + private const val TAG = "AppStoreJsBridge" + /** Name registered with addJavascriptInterface — window.SilverDROID in the web app */ + const val BRIDGE_NAME = "SilverDROID" + } + + private val installer = InstallerService(context) + private val db get() = PwaDatabase.getInstance(context) + + /** + * Called by the Blazor WASM app to install an APK natively. + * Runs download + verify + PackageInstaller in a coroutine; registers the app in DB on success. + */ + @JavascriptInterface + fun installApp(slug: String, downloadUrl: String, sha256: String, appName: String, version: String) { + Log.i(TAG, "installApp: slug=$slug version=$version") + scope.launch(Dispatchers.IO) { + val success = installer.downloadAndInstall( + slug = slug, + downloadUrl = downloadUrl, + sha256 = sha256.ifBlank { null }, + appName = appName, + version = version + ) + if (success) { + val existing = db.installedAppDao().getBySlug(slug) + if (existing == null) { + db.installedAppDao().insert( + InstalledApp( + slug = slug, + packageName = slugToPackageName(slug), + appName = appName, + installedVersion = version + ) + ) + } else { + db.installedAppDao().markUpdateInstalled(slug, version) + } + Log.i(TAG, "installApp: DB record saved for $slug") + } + } + } + + /** + * Returns true if the given package is installed on this device. + * Called synchronously from JS. + */ + @JavascriptInterface + fun isAppInstalled(packageName: String): Boolean { + return try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } + } + + /** + * Returns the installed version string for a slug, or "" if not tracked. + * Called synchronously from JS (runs on background thread — safe per Android docs). + */ + @JavascriptInterface + fun getInstalledVersion(slug: String): String { + return try { + kotlinx.coroutines.runBlocking(Dispatchers.IO) { + db.installedAppDao().getBySlug(slug)?.installedVersion ?: "" + } + } catch (e: Exception) { + Log.w(TAG, "getInstalledVersion failed for $slug", e) + "" + } + } + + /** + * Called by the web app to trigger the native update bottom sheet. + * Delegates to the host composable via callback. + */ + @JavascriptInterface + fun requestUpdate(slug: String) { + Log.i(TAG, "requestUpdate: slug=$slug") + scope.launch(Dispatchers.Main) { + onShowUpdateDialog(slug) + } + } + + // Derive a likely package name from a slug; actual name stored by installApp + private fun slugToPackageName(slug: String): String = + "uk.silverlabs.${slug.replace("-", ".")}" +} diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallStatusReceiver.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallStatusReceiver.kt new file mode 100644 index 0000000..8dfdd89 --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallStatusReceiver.kt @@ -0,0 +1,66 @@ +package uk.silverlabs.silverdroid.update + +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.pm.PackageInstaller +import android.util.Log +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch + +/** + * Receives install status callbacks from PackageInstaller session commits. + * Also listens for PACKAGE_REPLACED to update InstalledApp records. + */ +class InstallStatusReceiver : BroadcastReceiver() { + + companion object { + private const val TAG = "InstallStatusReceiver" + } + + override fun onReceive(context: Context, intent: Intent) { + when (intent.action) { + "uk.silverlabs.silverdroid.INSTALL_STATUS" -> { + val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1) + val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE) + when (status) { + PackageInstaller.STATUS_SUCCESS -> + Log.i(TAG, "Package install succeeded") + PackageInstaller.STATUS_FAILURE, + PackageInstaller.STATUS_FAILURE_ABORTED, + PackageInstaller.STATUS_FAILURE_BLOCKED, + PackageInstaller.STATUS_FAILURE_CONFLICT, + PackageInstaller.STATUS_FAILURE_INCOMPATIBLE, + PackageInstaller.STATUS_FAILURE_INVALID, + PackageInstaller.STATUS_FAILURE_STORAGE -> + Log.e(TAG, "Package install failed: status=$status msg=$msg") + PackageInstaller.STATUS_PENDING_USER_ACTION -> { + // Need to prompt user — launch the confirmation intent + val confirmIntent = intent.getParcelableExtra(Intent.EXTRA_INTENT) + confirmIntent?.let { + it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + context.startActivity(it) + } + } + } + } + + Intent.ACTION_PACKAGE_REPLACED, + Intent.ACTION_PACKAGE_ADDED -> { + val pkg = intent.data?.schemeSpecificPart ?: return + Log.i(TAG, "Package installed/replaced: $pkg") + // Update DB — we don't know the new version here, the bridge handles that + CoroutineScope(Dispatchers.IO).launch { + val dao = uk.silverlabs.silverdroid.data.PwaDatabase + .getInstance(context).installedAppDao() + val app = dao.getByPackageName(pkg) ?: return@launch + // Clear the pending update flag; version is set by AppStoreJsBridge.installApp + if (app.pendingVersion != null) { + dao.markUpdateInstalled(app.slug, app.pendingVersion) + } + } + } + } + } +} diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt new file mode 100644 index 0000000..5b7a51f --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt @@ -0,0 +1,184 @@ +package uk.silverlabs.silverdroid.update + +import android.app.DownloadManager +import android.content.BroadcastReceiver +import android.content.Context +import android.content.Intent +import android.content.IntentFilter +import android.content.pm.PackageInstaller +import android.net.Uri +import android.os.Build +import android.util.Log +import androidx.core.content.FileProvider +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.suspendCancellableCoroutine +import kotlinx.coroutines.withContext +import uk.silverlabs.silverdroid.data.PwaDatabase +import java.io.File +import java.io.FileInputStream +import java.security.MessageDigest +import kotlin.coroutines.resume + +class InstallerService(private val context: Context) { + + companion object { + private const val TAG = "InstallerService" + const val FILE_PROVIDER_AUTHORITY = "uk.silverlabs.silverdroid.fileprovider" + } + + /** + * Download APK, verify SHA-256, then install via PackageInstaller session API. + * Returns true if install session was created successfully (actual install is user-confirmed). + */ + suspend fun downloadAndInstall( + slug: String, + downloadUrl: String, + sha256: String?, + appName: String, + version: String, + onProgress: (Int) -> Unit = {} + ): Boolean = withContext(Dispatchers.IO) { + try { + val apkFile = downloadApk(downloadUrl, slug, appName, onProgress) + ?: return@withContext false + + if (sha256 != null && !verifySha256(apkFile, sha256)) { + Log.e(TAG, "SHA-256 verification failed for $slug") + apkFile.delete() + return@withContext false + } + + installApk(apkFile) + true + } catch (e: Exception) { + Log.e(TAG, "Failed to download/install $slug", e) + false + } + } + + private suspend fun downloadApk( + url: String, + slug: String, + appName: String, + onProgress: (Int) -> Unit + ): File? = suspendCancellableCoroutine { cont -> + val cacheDir = context.externalCacheDir ?: context.cacheDir + val apkFile = File(cacheDir, "$slug.apk") + + val request = DownloadManager.Request(Uri.parse(url)).apply { + setTitle("Downloading $appName") + setDescription("Preparing update...") + setDestinationUri(Uri.fromFile(apkFile)) + setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE) + setAllowedOverMetered(true) + setAllowedOverRoaming(false) + } + + val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager + val downloadId = dm.enqueue(request) + + // Poll for completion + val receiver = object : BroadcastReceiver() { + override fun onReceive(ctx: Context, intent: Intent) { + val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1) + if (id != downloadId) return + + context.unregisterReceiver(this) + + val query = DownloadManager.Query().setFilterById(downloadId) + val cursor = dm.query(query) + if (cursor.moveToFirst()) { + val statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS) + val status = cursor.getInt(statusCol) + cursor.close() + if (status == DownloadManager.STATUS_SUCCESSFUL) { + onProgress(100) + cont.resume(apkFile) + } else { + cont.resume(null) + } + } else { + cursor.close() + cont.resume(null) + } + } + } + + context.registerReceiver( + receiver, + IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE), + Context.RECEIVER_NOT_EXPORTED + ) + + cont.invokeOnCancellation { + dm.remove(downloadId) + try { context.unregisterReceiver(receiver) } catch (_: Exception) {} + } + } + + private fun verifySha256(file: File, expectedHex: String): Boolean { + val digest = MessageDigest.getInstance("SHA-256") + FileInputStream(file).use { fis -> + val buf = ByteArray(8192) + var read: Int + while (fis.read(buf).also { read = it } != -1) { + digest.update(buf, 0, read) + } + } + val actualHex = digest.digest().joinToString("") { "%02x".format(it) } + return actualHex.equals(expectedHex, ignoreCase = true) + } + + private fun installApk(apkFile: File) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + installViaPackageInstaller(apkFile) + } else { + installViaIntent(apkFile) + } + } + + private fun installViaPackageInstaller(apkFile: File) { + val pi = context.packageManager.packageInstaller + val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL) + params.setSize(apkFile.length()) + + val sessionId = pi.createSession(params) + val session = pi.openSession(sessionId) + + FileInputStream(apkFile).use { fis -> + session.openWrite("package", 0, apkFile.length()).use { os -> + fis.copyTo(os) + session.fsync(os) + } + } + + val intent = Intent(context, InstallStatusReceiver::class.java).apply { + action = "uk.silverlabs.silverdroid.INSTALL_STATUS" + } + val pi2 = android.app.PendingIntent.getBroadcast( + context, sessionId, intent, + android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE + ) + session.commit(pi2.intentSender) + session.close() + } + + private fun installViaIntent(apkFile: File) { + val apkUri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, apkFile) + val intent = Intent(Intent.ACTION_VIEW).apply { + setDataAndType(apkUri, "application/vnd.android.package-archive") + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION + } + context.startActivity(intent) + } + + /** + * Mark app as successfully updated in the DB after PACKAGE_REPLACED broadcast. + */ + suspend fun onPackageInstalled(packageName: String, newVersion: String) { + val dao = PwaDatabase.getInstance(context).installedAppDao() + val app = dao.getByPackageName(packageName) ?: return + dao.markUpdateInstalled(app.slug, newVersion) + Log.i(TAG, "Updated DB record for ${app.slug} to $newVersion") + } +} diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckResponseDto.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckResponseDto.kt new file mode 100644 index 0000000..e9641ad --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckResponseDto.kt @@ -0,0 +1,12 @@ +package uk.silverlabs.silverdroid.update + +import kotlinx.serialization.Serializable + +@Serializable +data class UpdateCheckResponseDto( + val hasUpdate: Boolean, + val latestVersion: String, + val downloadUrl: String? = null, + val sha256: String? = null, + val releaseNotes: String? = null +) diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckerWorker.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckerWorker.kt new file mode 100644 index 0000000..231ab66 --- /dev/null +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/UpdateCheckerWorker.kt @@ -0,0 +1,137 @@ +package uk.silverlabs.silverdroid.update + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.PendingIntent +import android.content.Context +import android.content.Intent +import android.os.Build +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.work.CoroutineWorker +import androidx.work.WorkerParameters +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.withContext +import kotlinx.serialization.json.Json +import uk.silverlabs.silverdroid.MainActivity +import uk.silverlabs.silverdroid.R +import uk.silverlabs.silverdroid.data.PwaDatabase +import uk.silverlabs.silverdroid.data.model.InstalledApp +import java.net.HttpURLConnection +import java.net.URL + +class UpdateCheckerWorker( + private val context: Context, + params: WorkerParameters +) : CoroutineWorker(context, params) { + + companion object { + const val WORK_NAME = "silverdroid_update_checker" + const val NOTIFICATION_CHANNEL_ID = "app-updates" + private const val TAG = "UpdateCheckerWorker" + private const val NOTIFICATION_ID = 1001 + + fun createNotificationChannel(context: Context) { + val channel = NotificationChannel( + NOTIFICATION_CHANNEL_ID, + "App Updates", + NotificationManager.IMPORTANCE_DEFAULT + ).apply { + description = "Notifications for available app updates from SilverSHELL AppStore" + } + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + nm.createNotificationChannel(channel) + } + } + + private val json = Json { ignoreUnknownKeys = true } + + override suspend fun doWork(): Result = withContext(Dispatchers.IO) { + try { + val dao = PwaDatabase.getInstance(context).installedAppDao() + val installedApps = dao.getAllAppsOnce() + + if (installedApps.isEmpty()) return@withContext Result.success() + + val updatesFound = mutableListOf() + + for (app in installedApps) { + try { + val response = checkUpdate(app.slug, app.installedVersion) + if (response?.hasUpdate == true) { + val updated = app.copy( + hasUpdate = true, + pendingVersion = response.latestVersion, + pendingDownloadUrl = response.downloadUrl, + pendingsha256 = response.sha256, + pendingReleaseNotes = response.releaseNotes + ) + dao.update(updated) + updatesFound.add(updated) + } + } catch (e: Exception) { + Log.w(TAG, "Failed to check update for ${app.slug}", e) + } + } + + if (updatesFound.isNotEmpty()) { + fireUpdateNotification(updatesFound) + } + + Result.success() + } catch (e: Exception) { + Log.e(TAG, "UpdateCheckerWorker failed", e) + Result.retry() + } + } + + private fun checkUpdate(slug: String, currentVersion: String): UpdateCheckResponseDto? { + val url = URL("https://store.silverlabs.uk/api/apps/$slug/check-update?platform=Android&version=$currentVersion") + val conn = url.openConnection() as HttpURLConnection + return try { + conn.connectTimeout = 15_000 + conn.readTimeout = 15_000 + conn.requestMethod = "GET" + if (conn.responseCode == 200) { + val body = conn.inputStream.bufferedReader().readText() + json.decodeFromString(body) + } else null + } finally { + conn.disconnect() + } + } + + private fun fireUpdateNotification(updates: List) { + val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + + val intent = Intent(context, MainActivity::class.java).apply { + flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK + } + val pendingIntent = PendingIntent.getActivity( + context, 0, intent, + PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE + ) + + val title = if (updates.size == 1) { + "${updates[0].appName} update available" + } else { + "${updates.size} app updates available" + } + val body = if (updates.size == 1) { + "Version ${updates[0].pendingVersion} is ready to install" + } else { + updates.joinToString(", ") { it.appName } + } + + val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID) + .setSmallIcon(android.R.drawable.stat_sys_download_done) + .setContentTitle(title) + .setContentText(body) + .setPriority(NotificationCompat.PRIORITY_DEFAULT) + .setContentIntent(pendingIntent) + .setAutoCancel(true) + .build() + + nm.notify(NOTIFICATION_ID, notification) + } +} diff --git a/app/src/main/res/xml/file_paths.xml b/app/src/main/res/xml/file_paths.xml new file mode 100644 index 0000000..6d3ebb8 --- /dev/null +++ b/app/src/main/res/xml/file_paths.xml @@ -0,0 +1,7 @@ + + + + + + +