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 @@
+
+
+
+
+
+
+