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.ExistingPeriodicWorkPolicy
import androidx.work.PeriodicWorkRequestBuilder import androidx.work.PeriodicWorkRequestBuilder
import androidx.work.WorkManager 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.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.update.UpdateCheckerWorker
import uk.silverlabs.silverdroid.vpn.WireGuardManager import uk.silverlabs.silverdroid.vpn.WireGuardManager
import java.util.concurrent.TimeUnit import java.util.concurrent.TimeUnit
@@ -53,6 +55,11 @@ class MainActivity : ComponentActivity() {
updateWorkRequest 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)

View File

@@ -1,12 +1,18 @@
package uk.silverlabs.silverdroid.ui.webview package uk.silverlabs.silverdroid.ui.webview
import android.content.Context import android.content.Context
import android.content.Intent
import android.content.pm.PackageManager import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log import android.util.Log
import android.webkit.JavascriptInterface import android.webkit.JavascriptInterface
import android.webkit.WebView
import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.withContext import kotlinx.coroutines.withContext
import uk.silverlabs.silverdroid.data.PwaDatabase import uk.silverlabs.silverdroid.data.PwaDatabase
import uk.silverlabs.silverdroid.data.model.InstalledApp import uk.silverlabs.silverdroid.data.model.InstalledApp
@@ -36,13 +42,50 @@ class AppStoreJsBridge(
private val installer = InstallerService(context) private val installer = InstallerService(context)
private val db get() = PwaDatabase.getInstance(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. * 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. * Runs download + verify + PackageInstaller in a coroutine; registers the app in DB on success.
*/ */
@JavascriptInterface @JavascriptInterface
fun installApp(slug: String, downloadUrl: String, sha256: String, appName: String, version: String) { fun installApp(slug: String, downloadUrl: String, sha256: String, appName: String, version: String) {
Log.i(TAG, "installApp: slug=$slug version=$version") 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) { scope.launch(Dispatchers.IO) {
val success = installer.downloadAndInstall( val success = installer.downloadAndInstall(
slug = slug, slug = slug,
@@ -86,13 +129,29 @@ class AppStoreJsBridge(
/** /**
* Returns the installed version string for a slug, or "" if not tracked. * 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). * Called synchronously from JS (runs on background thread — safe per Android docs).
*/ */
@JavascriptInterface @JavascriptInterface
fun getInstalledVersion(slug: String): String { fun getInstalledVersion(slug: String): String {
return try { return try {
kotlinx.coroutines.runBlocking(Dispatchers.IO) { runBlocking(Dispatchers.IO) {
db.installedAppDao().getBySlug(slug)?.installedVersion ?: "" 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) { } catch (e: Exception) {
Log.w(TAG, "getInstalledVersion failed for $slug", e) Log.w(TAG, "getInstalledVersion failed for $slug", e)

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,57 +25,13 @@ 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
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)) {
AndroidView( AndroidView(
factory = { context -> factory = { context ->
WebView(context).apply { WebView(context).apply {
@@ -107,9 +60,9 @@ fun WasmWebView(
settings.setRenderPriority(WebSettings.RenderPriority.HIGH) settings.setRenderPriority(WebSettings.RenderPriority.HIGH)
settings.cacheMode = WebSettings.LOAD_DEFAULT settings.cacheMode = WebSettings.LOAD_DEFAULT
// Enable zooming // Disable zoom — native app feel
settings.setSupportZoom(true) settings.setSupportZoom(false)
settings.builtInZoomControls = true settings.builtInZoomControls = false
settings.displayZoomControls = false settings.displayZoomControls = false
// User agent (modern) // User agent (modern)
@@ -119,6 +72,7 @@ fun WasmWebView(
// Wire AppStore JS bridge so Blazor can call native install // Wire AppStore JS bridge so Blazor can call native install
jsInterface?.let { jsInterface?.let {
addJavascriptInterface(it, AppStoreJsBridge.BRIDGE_NAME) addJavascriptInterface(it, AppStoreJsBridge.BRIDGE_NAME)
it.webView = this
} }
// Enable wide viewport // Enable wide viewport
@@ -140,7 +94,6 @@ fun WasmWebView(
isLoading = false isLoading = false
loadProgress = 100 loadProgress = 100
canGoBack = view?.canGoBack() ?: false canGoBack = view?.canGoBack() ?: false
pageTitle = view?.title ?: appName
} }
override fun shouldOverrideUrlLoading( 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 { override fun onConsoleMessage(consoleMessage: ConsoleMessage?): Boolean {
// Log console messages for debugging // Log console messages for debugging
consoleMessage?.let { consoleMessage?.let {
@@ -195,10 +144,23 @@ fun WasmWebView(
modifier = Modifier.fillMaxSize() 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) { if (isLoading && loadProgress < 20) {
Box( Box(
modifier = Modifier.fillMaxSize(), modifier = Modifier
.fillMaxSize()
.background(MaterialTheme.colorScheme.background),
contentAlignment = Alignment.Center contentAlignment = Alignment.Center
) { ) {
CircularProgressIndicator() CircularProgressIndicator()
@@ -206,4 +168,3 @@ fun WasmWebView(
} }
} }
} }
}

View File

@@ -181,4 +181,28 @@ class InstallerService(private val context: Context) {
dao.markUpdateInstalled(app.slug, newVersion) dao.markUpdateInstalled(app.slug, newVersion)
Log.i(TAG, "Updated DB record for ${app.slug} to $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)
}
}
}
} }