Compare commits
2 Commits
appstore-v
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b756788c2 | |||
| df58e3b0df |
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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<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),
|
||||
)
|
||||
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()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -49,6 +49,21 @@ class UpdateCheckerWorker(
|
||||
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()
|
||||
|
||||
Reference in New Issue
Block a user