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 <noreply@anthropic.com>
This commit is contained in:
2026-03-04 14:06:04 +00:00
parent 0f1b6a6157
commit 0dea0bb506
12 changed files with 654 additions and 8 deletions

View File

@@ -13,9 +13,15 @@
android:maxSdkVersion="32"
tools:ignore="ScopedStorage" />
<!-- Notifications for PWA push notifications -->
<!-- Notifications for PWA push notifications and app update alerts -->
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
<!-- APK installation for AppStore update flow -->
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<!-- DownloadManager for APK downloads -->
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
<application
android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules"
@@ -49,6 +55,33 @@
android:theme="@style/Theme.SilverDROID.Fullscreen"
android:windowSoftInputMode="adjustResize" />
<!-- FileProvider for sharing APK files to PackageInstaller on older APIs -->
<provider
android:name="androidx.core.content.FileProvider"
android:authorities="uk.silverlabs.silverdroid.fileprovider"
android:exported="false"
android:grantUriPermissions="true">
<meta-data
android:name="android.support.FILE_PROVIDER_PATHS"
android:resource="@xml/file_paths" />
</provider>
<!-- BroadcastReceiver for PackageInstaller session callbacks -->
<receiver
android:name=".update.InstallStatusReceiver"
android:exported="false">
<!-- PackageInstaller session status (no data URI) -->
<intent-filter>
<action android:name="uk.silverlabs.silverdroid.INSTALL_STATUS" />
</intent-filter>
<!-- System package events (require package: data URI) -->
<intent-filter>
<action android:name="android.intent.action.PACKAGE_REPLACED" />
<action android:name="android.intent.action.PACKAGE_ADDED" />
<data android:scheme="package" />
</intent-filter>
</receiver>
</application>
</manifest>

View File

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

View File

@@ -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<UpdateCheckerWorker>(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
)
}
}

View File

@@ -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<List<InstalledApp>>
@Query("SELECT * FROM installed_apps ORDER BY appName ASC")
suspend fun getAllAppsOnce(): List<InstalledApp>
@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<List<InstalledApp>>
@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
}

View File

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

View File

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

View File

@@ -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("-", ".")}"
}

View File

@@ -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>(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)
}
}
}
}
}
}

View File

@@ -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")
}
}

View File

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

View File

@@ -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<InstalledApp>()
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<UpdateCheckResponseDto>(body)
} else null
} finally {
conn.disconnect()
}
}
private fun fireUpdateNotification(updates: List<InstalledApp>) {
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)
}
}

View File

@@ -0,0 +1,7 @@
<?xml version="1.0" encoding="utf-8"?>
<paths>
<!-- External cache dir — APKs downloaded by InstallerService -->
<external-cache-path name="apk_downloads" path="." />
<!-- Fallback to internal cache if external storage unavailable -->
<cache-path name="apk_downloads_internal" path="." />
</paths>