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>
This commit is contained in:
@@ -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)
|
||||||
|
|||||||
@@ -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)
|
||||||
|
|||||||
@@ -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,15 +144,27 @@ 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()
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
|
||||||
}
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user