1 Commits

Author SHA1 Message Date
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
4 changed files with 219 additions and 168 deletions

View File

@@ -13,12 +13,14 @@ 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 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.InstallerService
import uk.silverlabs.silverdroid.update.UpdateCheckerWorker
import uk.silverlabs.silverdroid.vpn.WireGuardManager
import java.util.concurrent.TimeUnit
@@ -53,6 +55,11 @@ class MainActivity : ComponentActivity() {
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
lifecycleScope.launch {
val config = RemoteConfigLoader.loadConfigWithRemote(this@MainActivity)

View File

@@ -1,12 +1,18 @@
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
@@ -36,13 +42,50 @@ class AppStoreJsBridge(
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,
@@ -86,13 +129,29 @@ class AppStoreJsBridge(
/**
* 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 {
kotlinx.coroutines.runBlocking(Dispatchers.IO) {
db.installedAppDao().getBySlug(slug)?.installedVersion ?: ""
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)

View File

@@ -4,10 +4,8 @@ import android.annotation.SuppressLint
import android.graphics.Bitmap
import android.webkit.*
import androidx.activity.compose.BackHandler
import androidx.compose.foundation.background
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.runtime.*
import androidx.compose.ui.Alignment
@@ -16,7 +14,6 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WasmWebView(
url: String,
@@ -28,57 +25,13 @@ fun WasmWebView(
var webView by remember { mutableStateOf<WebView?>(null) }
var isLoading by remember { mutableStateOf(true) }
var loadProgress by remember { mutableIntStateOf(0) }
var pageTitle by remember { mutableStateOf(appName) }
var canGoBack by remember { mutableStateOf(false) }
BackHandler(enabled = canGoBack) {
webView?.goBack()
}
Column(modifier = modifier.fillMaxSize()) {
// Top bar with navigation
Surface(
tonalElevation = 3.dp,
shadowElevation = 3.dp
) {
TopAppBar(
title = {
Column {
Text(
text = pageTitle,
style = MaterialTheme.typography.titleMedium
)
if (isLoading) {
LinearProgressIndicator(
progress = { loadProgress / 100f },
modifier = Modifier
.fillMaxWidth()
.height(2.dp),
)
}
}
},
navigationIcon = {
IconButton(onClick = {
if (canGoBack) {
webView?.goBack()
} else {
onBackPressed()
}
}) {
Icon(Icons.AutoMirrored.Filled.ArrowBack, "Back")
}
},
actions = {
IconButton(onClick = { webView?.reload() }) {
Icon(Icons.Default.Refresh, "Refresh")
}
}
)
}
// WebView
Box(modifier = Modifier.weight(1f)) {
Box(modifier = modifier.fillMaxSize()) {
AndroidView(
factory = { context ->
WebView(context).apply {
@@ -107,9 +60,9 @@ fun WasmWebView(
settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
settings.cacheMode = WebSettings.LOAD_DEFAULT
// Enable zooming
settings.setSupportZoom(true)
settings.builtInZoomControls = true
// Disable zoom — native app feel
settings.setSupportZoom(false)
settings.builtInZoomControls = false
settings.displayZoomControls = false
// User agent (modern)
@@ -119,6 +72,7 @@ fun WasmWebView(
// Wire AppStore JS bridge so Blazor can call native install
jsInterface?.let {
addJavascriptInterface(it, AppStoreJsBridge.BRIDGE_NAME)
it.webView = this
}
// Enable wide viewport
@@ -140,7 +94,6 @@ fun WasmWebView(
isLoading = false
loadProgress = 100
canGoBack = view?.canGoBack() ?: false
pageTitle = view?.title ?: appName
}
override fun shouldOverrideUrlLoading(
@@ -169,10 +122,6 @@ fun WasmWebView(
}
}
override fun onReceivedTitle(view: WebView?, title: String?) {
pageTitle = title ?: appName
}
override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
// Log console messages for debugging
consoleMessage?.let {
@@ -195,10 +144,23 @@ fun WasmWebView(
modifier = Modifier.fillMaxSize()
)
// Loading indicator
// Thin progress bar along top edge (no browser chrome)
if (isLoading) {
LinearProgressIndicator(
progress = { loadProgress / 100f },
modifier = Modifier
.fillMaxWidth()
.height(3.dp)
.align(Alignment.TopStart)
)
}
// Full-screen splash only on cold WASM load
if (isLoading && loadProgress < 20) {
Box(
modifier = Modifier.fillMaxSize(),
modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center
) {
CircularProgressIndicator()
@@ -206,4 +168,3 @@ fun WasmWebView(
}
}
}
}

View File

@@ -181,4 +181,28 @@ class InstallerService(private val context: Context) {
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)
}
}
}
}