3 Commits

Author SHA1 Message Date
0b756788c2 fix(updater): self-register SilverDROID in installed_apps before update check
The worker exited early when the DB was empty (fresh installs via browser).
Now upserts a self-record for slug='silverdroid' before the isEmpty guard
so update notifications fire even when no other apps have been installed
via the AppStore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:43:29 +00:00
df58e3b0df fix(appstore): runtime install permission gate and DB reconciliation
- AppStoreJsBridge: add canInstallApps() JS interface for UI gating
- AppStoreJsBridge: gate installApp() on canRequestPackageInstalls() at
  runtime; redirect to Settings and fire onInstallError callback on deny
- AppStoreJsBridge: fix getInstalledVersion() to cross-check PackageManager,
  correcting stale version strings and removing uninstalled app records
- InstallerService: add reconcileWithPackageManager() to sync Room DB with
  actual installed packages on every app start
- MainActivity: launch reconciliation on start before WebView loads
- WasmWebView: set webView ref on bridge for evaluateJavascript callbacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:21:15 +00:00
0dea0bb506 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>
2026-03-04 14:06:04 +00:00
13 changed files with 886 additions and 174 deletions

View File

@@ -13,9 +13,15 @@
android:maxSdkVersion="32" android:maxSdkVersion="32"
tools:ignore="ScopedStorage" /> 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" /> <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 <application
android:allowBackup="true" android:allowBackup="true"
android:dataExtractionRules="@xml/data_extraction_rules" android:dataExtractionRules="@xml/data_extraction_rules"
@@ -49,6 +55,33 @@
android:theme="@style/Theme.SilverDROID.Fullscreen" android:theme="@style/Theme.SilverDROID.Fullscreen"
android:windowSoftInputMode="adjustResize" /> 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> </application>
</manifest> </manifest>

View File

@@ -1,7 +1,7 @@
{ {
"appName": "SilverDesk Staging", "appName": "SilverSHELL AppStore",
"appVersion": "1.0.0", "appVersion": "1.0.0",
"targetUrl": "https://silverdesk-staging.silverlabs.uk/", "targetUrl": "https://store.silverlabs.uk",
"showUrlBar": false, "showUrlBar": false,
"allowNavigation": true "allowNavigation": true
} }

View File

@@ -10,12 +10,20 @@ import androidx.compose.material3.Surface
import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.LaunchedEffect
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import androidx.lifecycle.lifecycleScope import androidx.lifecycle.lifecycleScope
import androidx.work.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import uk.silverlabs.silverdroid.config.RemoteConfigLoader import uk.silverlabs.silverdroid.config.RemoteConfigLoader
import uk.silverlabs.silverdroid.tor.TorManager import uk.silverlabs.silverdroid.tor.TorManager
import uk.silverlabs.silverdroid.ui.theme.SilverDROIDTheme 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.ui.webview.WasmWebView
import uk.silverlabs.silverdroid.update.InstallerService
import uk.silverlabs.silverdroid.update.UpdateCheckerWorker
import uk.silverlabs.silverdroid.vpn.WireGuardManager import uk.silverlabs.silverdroid.vpn.WireGuardManager
import java.util.concurrent.TimeUnit
/** /**
* SilverDROID - Configurable Android Browser * SilverDROID - Configurable Android Browser
@@ -35,6 +43,23 @@ class MainActivity : ComponentActivity() {
super.onCreate(savedInstanceState) super.onCreate(savedInstanceState)
enableEdgeToEdge() 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
)
// Reconcile installed-apps DB with PackageManager (cleans up apps removed outside the AppStore)
lifecycleScope.launch(Dispatchers.IO) {
InstallerService(applicationContext).reconcileWithPackageManager()
}
// Load configuration asynchronously with remote support // Load configuration asynchronously with remote support
lifecycleScope.launch { lifecycleScope.launch {
val config = RemoteConfigLoader.loadConfigWithRemote(this@MainActivity) val config = RemoteConfigLoader.loadConfigWithRemote(this@MainActivity)
@@ -46,6 +71,12 @@ class MainActivity : ComponentActivity() {
// Initialize Tor if configured // Initialize Tor if configured
config.tor?.let { torManager.initialize(it) } config.tor?.let { torManager.initialize(it) }
// Create AppStore JS bridge for WebView ↔ native communication
val jsBridge = AppStoreJsBridge(
context = this@MainActivity,
scope = lifecycleScope
)
setContent { setContent {
SilverDROIDTheme { SilverDROIDTheme {
Surface( Surface(
@@ -70,9 +101,8 @@ class MainActivity : ComponentActivity() {
WasmWebView( WasmWebView(
url = config.targetUrl, url = config.targetUrl,
appName = config.appName, appName = config.appName,
onBackPressed = { onBackPressed = { finish() },
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.Database
import androidx.room.Room import androidx.room.Room
import androidx.room.RoomDatabase import androidx.room.RoomDatabase
import uk.silverlabs.silverdroid.data.model.InstalledApp
import uk.silverlabs.silverdroid.data.model.PwaApp import uk.silverlabs.silverdroid.data.model.PwaApp
@Database( @Database(
entities = [PwaApp::class], entities = [PwaApp::class, InstalledApp::class],
version = 1, version = 2,
exportSchema = true exportSchema = true
) )
abstract class PwaDatabase : RoomDatabase() { abstract class PwaDatabase : RoomDatabase() {
abstract fun pwaAppDao(): PwaAppDao abstract fun pwaAppDao(): PwaAppDao
abstract fun installedAppDao(): InstalledAppDao
companion object { companion object {
@Volatile @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,177 @@
package uk.silverlabs.silverdroid.ui.webview
import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import android.webkit.JavascriptInterface
import android.webkit.WebView
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
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)
/** Set by WasmWebView after the WebView is created, used to invoke JS callbacks. */
var webView: WebView? = null
/**
* Returns true if the app is allowed to install packages from unknown sources.
* Pre-Oreo devices are always allowed via manifest permission.
*/
@JavascriptInterface
fun canInstallApps(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
context.packageManager.canRequestPackageInstalls()
} else {
true
}
}
/**
* Called by the Blazor WASM app to install an APK natively.
* On Android 8+ checks REQUEST_INSTALL_PACKAGES at runtime and redirects to Settings if missing.
* 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")
// Gate on runtime install permission (Android 8+)
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O &&
!context.packageManager.canRequestPackageInstalls()
) {
Log.w(TAG, "installApp: REQUEST_INSTALL_PACKAGES not granted — opening Settings for $slug")
val intent = Intent(
Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES,
Uri.parse("package:${context.packageName}")
).addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
context.startActivity(intent)
webView?.post {
webView?.evaluateJavascript(
"window.onInstallError && window.onInstallError('permission_required', '${slug.replace("'", "\\'")}')",
null
)
}
return
}
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.
* Cross-checks PackageManager: cleans up stale DB records for uninstalled apps,
* and corrects the stored version if it diverges from the actual installed version.
* Called synchronously from JS (runs on background thread — safe per Android docs).
*/
@JavascriptInterface
fun getInstalledVersion(slug: String): String {
return try {
runBlocking(Dispatchers.IO) {
val record = db.installedAppDao().getBySlug(slug) ?: return@runBlocking ""
try {
val info = context.packageManager.getPackageInfo(record.packageName, 0)
val actualVersion = info.versionName ?: record.installedVersion
if (actualVersion != record.installedVersion) {
Log.i(TAG, "getInstalledVersion: correcting DB for $slug: ${record.installedVersion}$actualVersion")
db.installedAppDao().markUpdateInstalled(slug, actualVersion)
}
actualVersion
} catch (_: PackageManager.NameNotFoundException) {
// Package was uninstalled outside the AppStore — remove stale record
Log.i(TAG, "getInstalledVersion: package ${record.packageName} not found, removing DB record for $slug")
db.installedAppDao().deleteBySlug(slug)
""
}
}
} 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

@@ -4,10 +4,8 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap import android.graphics.Bitmap
import android.webkit.* import android.webkit.*
import androidx.activity.compose.BackHandler import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
import androidx.compose.foundation.layout.* import androidx.compose.foundation.layout.*
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material3.* import androidx.compose.material3.*
import androidx.compose.runtime.* import androidx.compose.runtime.*
import androidx.compose.ui.Alignment import androidx.compose.ui.Alignment
@@ -16,7 +14,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp import androidx.compose.ui.unit.dp
@SuppressLint("SetJavaScriptEnabled") @SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable @Composable
fun WasmWebView( fun WasmWebView(
url: String, url: String,
@@ -28,181 +25,145 @@ fun WasmWebView(
var webView by remember { mutableStateOf<WebView?>(null) } var webView by remember { mutableStateOf<WebView?>(null) }
var isLoading by remember { mutableStateOf(true) } var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) } var loadProgress by remember { mutableIntStateOf(0) }
var pageTitle by remember { mutableStateOf(appName) }
var canGoBack by remember { mutableStateOf(false) } var canGoBack by remember { mutableStateOf(false) }
BackHandler(enabled = canGoBack) { BackHandler(enabled = canGoBack) {
webView?.goBack() webView?.goBack()
} }
Column(modifier = modifier.fillMaxSize()) { Box(modifier = modifier.fillMaxSize()) {
// Top bar with navigation AndroidView(
Surface( factory = { context ->
tonalElevation = 3.dp, WebView(context).apply {
shadowElevation = 3.dp webView = this
) {
TopAppBar( // Enable JavaScript
title = { settings.javaScriptEnabled = true
Column {
Text( // Enable WASM support
text = pageTitle, settings.allowFileAccess = true
style = MaterialTheme.typography.titleMedium settings.allowContentAccess = true
)
if (isLoading) { // Enable DOM storage (required for PWAs)
LinearProgressIndicator( settings.domStorageEnabled = true
progress = { loadProgress / 100f },
modifier = Modifier // Enable database storage
.fillMaxWidth() settings.databaseEnabled = true
.height(2.dp),
) // Enable caching for offline support
settings.cacheMode = WebSettings.LOAD_DEFAULT
// Enable mixed content (for development)
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// Performance optimizations
settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
settings.cacheMode = WebSettings.LOAD_DEFAULT
// Disable zoom — native app feel
settings.setSupportZoom(false)
settings.builtInZoomControls = false
settings.displayZoomControls = false
// User agent (modern)
settings.userAgentString = settings.userAgentString +
" SilverAppStore/1.0 (AppStore Client)"
// Wire AppStore JS bridge so Blazor can call native install
jsInterface?.let {
addJavascriptInterface(it, AppStoreJsBridge.BRIDGE_NAME)
it.webView = this
}
// Enable wide viewport
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
// WebView client
webViewClient = object : WebViewClient() {
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?
) {
isLoading = true
loadProgress = 0
}
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false
loadProgress = 100
canGoBack = view?.canGoBack() ?: false
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// Allow all navigation within WebView
return false
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
isLoading = false
} }
} }
},
navigationIcon = { // Chrome client for progress
IconButton(onClick = { webChromeClient = object : WebChromeClient() {
if (canGoBack) { override fun onProgressChanged(view: WebView?, newProgress: Int) {
webView?.goBack() loadProgress = newProgress
} else { if (newProgress == 100) {
onBackPressed() isLoading = false
}
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
// Log console messages for debugging
consoleMessage?.let {
android.util.Log.d(
"WebView",
"${it.message()} -- From line ${it.lineNumber()} of ${it.sourceId()}"
)
}
return true
} }
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { webView?.reload() }) {
Icon(Icons.Default.Refresh, "Refresh")
} }
// Load the URL
loadUrl(url)
} }
},
update = { view ->
canGoBack = view.canGoBack()
},
modifier = Modifier.fillMaxSize()
)
// Thin progress bar along top edge (no browser chrome)
if (isLoading) {
LinearProgressIndicator(
progress = { loadProgress / 100f },
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.align(Alignment.TopStart)
) )
} }
// WebView // Full-screen splash only on cold WASM load
Box(modifier = Modifier.weight(1f)) { if (isLoading && loadProgress < 20) {
AndroidView( Box(
factory = { context -> modifier = Modifier
WebView(context).apply { .fillMaxSize()
webView = this .background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
// Enable JavaScript ) {
settings.javaScriptEnabled = true CircularProgressIndicator()
// Enable WASM support
settings.allowFileAccess = true
settings.allowContentAccess = true
// Enable DOM storage (required for PWAs)
settings.domStorageEnabled = true
// Enable database storage
settings.databaseEnabled = true
// Enable caching for offline support
settings.cacheMode = WebSettings.LOAD_DEFAULT
// Enable mixed content (for development)
settings.mixedContentMode = WebSettings.MIXED_CONTENT_ALWAYS_ALLOW
// Performance optimizations
settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
settings.cacheMode = WebSettings.LOAD_DEFAULT
// Enable zooming
settings.setSupportZoom(true)
settings.builtInZoomControls = true
settings.displayZoomControls = false
// User agent (modern)
settings.userAgentString = settings.userAgentString +
" SilverAppStore/1.0 (AppStore Client)"
// Wire AppStore JS bridge so Blazor can call native install
jsInterface?.let {
addJavascriptInterface(it, AppStoreJsBridge.BRIDGE_NAME)
}
// Enable wide viewport
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
// WebView client
webViewClient = object : WebViewClient() {
override fun onPageStarted(
view: WebView?,
url: String?,
favicon: Bitmap?
) {
isLoading = true
loadProgress = 0
}
override fun onPageFinished(view: WebView?, url: String?) {
isLoading = false
loadProgress = 100
canGoBack = view?.canGoBack() ?: false
pageTitle = view?.title ?: appName
}
override fun shouldOverrideUrlLoading(
view: WebView?,
request: WebResourceRequest?
): Boolean {
// Allow all navigation within WebView
return false
}
override fun onReceivedError(
view: WebView?,
request: WebResourceRequest?,
error: WebResourceError?
) {
isLoading = false
}
}
// Chrome client for progress
webChromeClient = object : WebChromeClient() {
override fun onProgressChanged(view: WebView?, newProgress: Int) {
loadProgress = newProgress
if (newProgress == 100) {
isLoading = false
}
}
override fun onReceivedTitle(view: WebView?, title: String?) {
pageTitle = title ?: appName
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
// Log console messages for debugging
consoleMessage?.let {
android.util.Log.d(
"WebView",
"${it.message()} -- From line ${it.lineNumber()} of ${it.sourceId()}"
)
}
return true
}
}
// Load the URL
loadUrl(url)
}
},
update = { view ->
canGoBack = view.canGoBack()
},
modifier = Modifier.fillMaxSize()
)
// Loading indicator
if (isLoading && loadProgress < 20) {
Box(
modifier = Modifier.fillMaxSize(),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
}
} }
} }
} }

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,208 @@
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")
}
/**
* Reconcile Room DB against PackageManager on app start.
* - Removes stale records for apps that were uninstalled outside the AppStore.
* - Corrects version strings that diverge from what PackageManager reports.
*/
suspend fun reconcileWithPackageManager() = withContext(Dispatchers.IO) {
val dao = PwaDatabase.getInstance(context).installedAppDao()
val allRecords = dao.getAllAppsOnce()
val pm = context.packageManager
for (record in allRecords) {
try {
val info = pm.getPackageInfo(record.packageName, 0)
val actualVersion = info.versionName ?: continue
if (actualVersion != record.installedVersion) {
Log.i(TAG, "Reconcile: updating ${record.slug} ${record.installedVersion}$actualVersion")
dao.markUpdateInstalled(record.slug, actualVersion)
}
} catch (_: android.content.pm.PackageManager.NameNotFoundException) {
Log.i(TAG, "Reconcile: removing stale record for ${record.slug} (package not installed)")
dao.deleteBySlug(record.slug)
}
}
}
}

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,152 @@
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()
// Ensure SilverDROID itself is always tracked so it can detect its own updates
val selfSlug = "silverdroid"
val selfVersion = try {
context.packageManager.getPackageInfo(context.packageName, 0).versionName ?: "1.0.0"
} catch (e: Exception) { "1.0.0" }
if (dao.getBySlug(selfSlug) == null) {
dao.insert(InstalledApp(
slug = selfSlug,
packageName = context.packageName,
appName = "SilverDROID",
installedVersion = selfVersion
))
}
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>