From df58e3b0df3aecb5294d61f58e98edb8573e5dc4 Mon Sep 17 00:00:00 2001 From: SysAdmin Date: Thu, 5 Mar 2026 18:21:15 +0000 Subject: [PATCH] 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 --- .../uk/silverlabs/silverdroid/MainActivity.kt | 7 + .../ui/webview/AppStoreJsBridge.kt | 63 +++- .../silverdroid/ui/webview/WasmWebView.kt | 293 ++++++++---------- .../silverdroid/update/InstallerService.kt | 24 ++ 4 files changed, 219 insertions(+), 168 deletions(-) diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt index 9b33621..c4ccdef 100644 --- a/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/MainActivity.kt @@ -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) 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 index 8d7bfe3..5410841 100644 --- a/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/AppStoreJsBridge.kt +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/AppStoreJsBridge.kt @@ -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) diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/WasmWebView.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/WasmWebView.kt index 0d98513..15f84b2 100644 --- a/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/WasmWebView.kt +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/ui/webview/WasmWebView.kt @@ -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,182 +25,146 @@ fun WasmWebView( var webView by remember { mutableStateOf(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), - ) + Box(modifier = modifier.fillMaxSize()) { + AndroidView( + factory = { context -> + WebView(context).apply { + webView = this + + // Enable JavaScript + settings.javaScriptEnabled = true + + // 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 + + // 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 = { - IconButton(onClick = { - if (canGoBack) { - webView?.goBack() - } else { - onBackPressed() + + // Chrome client for progress + webChromeClient = object : WebChromeClient() { + override fun onProgressChanged(view: WebView?, newProgress: Int) { + loadProgress = newProgress + if (newProgress == 100) { + 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 - Box(modifier = Modifier.weight(1f)) { - AndroidView( - factory = { context -> - WebView(context).apply { - webView = this - - // Enable JavaScript - settings.javaScriptEnabled = true - - // 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() - } + // Full-screen splash only on cold WASM load + if (isLoading && loadProgress < 20) { + Box( + modifier = Modifier + .fillMaxSize() + .background(MaterialTheme.colorScheme.background), + contentAlignment = Alignment.Center + ) { + CircularProgressIndicator() } } } -} \ No newline at end of file +} diff --git a/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt index 5b7a51f..df938ad 100644 --- a/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt +++ b/app/src/main/kotlin/uk/silverlabs/silverdroid/update/InstallerService.kt @@ -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) + } + } + } }