Compare commits
3 Commits
0f1b6a6157
...
v1.1.1
| Author | SHA1 | Date | |
|---|---|---|---|
| 0b756788c2 | |||
| df58e3b0df | |||
| 0dea0bb506 |
@@ -13,9 +13,15 @@
|
||||
android:maxSdkVersion="32"
|
||||
tools:ignore="ScopedStorage" />
|
||||
|
||||
<!-- Notifications for PWA push notifications -->
|
||||
<!-- Notifications for PWA push notifications and app update alerts -->
|
||||
<uses-permission android:name="android.permission.POST_NOTIFICATIONS" />
|
||||
|
||||
<!-- APK installation for AppStore update flow -->
|
||||
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
|
||||
|
||||
<!-- DownloadManager for APK downloads -->
|
||||
<uses-permission android:name="android.permission.DOWNLOAD_WITHOUT_NOTIFICATION" />
|
||||
|
||||
<application
|
||||
android:allowBackup="true"
|
||||
android:dataExtractionRules="@xml/data_extraction_rules"
|
||||
@@ -49,6 +55,33 @@
|
||||
android:theme="@style/Theme.SilverDROID.Fullscreen"
|
||||
android:windowSoftInputMode="adjustResize" />
|
||||
|
||||
<!-- FileProvider for sharing APK files to PackageInstaller on older APIs -->
|
||||
<provider
|
||||
android:name="androidx.core.content.FileProvider"
|
||||
android:authorities="uk.silverlabs.silverdroid.fileprovider"
|
||||
android:exported="false"
|
||||
android:grantUriPermissions="true">
|
||||
<meta-data
|
||||
android:name="android.support.FILE_PROVIDER_PATHS"
|
||||
android:resource="@xml/file_paths" />
|
||||
</provider>
|
||||
|
||||
<!-- BroadcastReceiver for PackageInstaller session callbacks -->
|
||||
<receiver
|
||||
android:name=".update.InstallStatusReceiver"
|
||||
android:exported="false">
|
||||
<!-- PackageInstaller session status (no data URI) -->
|
||||
<intent-filter>
|
||||
<action android:name="uk.silverlabs.silverdroid.INSTALL_STATUS" />
|
||||
</intent-filter>
|
||||
<!-- System package events (require package: data URI) -->
|
||||
<intent-filter>
|
||||
<action android:name="android.intent.action.PACKAGE_REPLACED" />
|
||||
<action android:name="android.intent.action.PACKAGE_ADDED" />
|
||||
<data android:scheme="package" />
|
||||
</intent-filter>
|
||||
</receiver>
|
||||
|
||||
</application>
|
||||
|
||||
</manifest>
|
||||
@@ -1,7 +1,7 @@
|
||||
{
|
||||
"appName": "SilverDesk Staging",
|
||||
"appName": "SilverSHELL AppStore",
|
||||
"appVersion": "1.0.0",
|
||||
"targetUrl": "https://silverdesk-staging.silverlabs.uk/",
|
||||
"targetUrl": "https://store.silverlabs.uk",
|
||||
"showUrlBar": false,
|
||||
"allowNavigation": true
|
||||
}
|
||||
|
||||
@@ -10,12 +10,20 @@ import androidx.compose.material3.Surface
|
||||
import androidx.compose.runtime.LaunchedEffect
|
||||
import androidx.compose.ui.Modifier
|
||||
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
|
||||
|
||||
/**
|
||||
* SilverDROID - Configurable Android Browser
|
||||
@@ -35,6 +43,23 @@ class MainActivity : ComponentActivity() {
|
||||
super.onCreate(savedInstanceState)
|
||||
enableEdgeToEdge()
|
||||
|
||||
// Create notification channel for app updates
|
||||
UpdateCheckerWorker.createNotificationChannel(this)
|
||||
|
||||
// Register periodic update checker (every 6 hours, requires network)
|
||||
val updateWorkRequest = PeriodicWorkRequestBuilder<UpdateCheckerWorker>(6, TimeUnit.HOURS)
|
||||
.build()
|
||||
WorkManager.getInstance(this).enqueueUniquePeriodicWork(
|
||||
UpdateCheckerWorker.WORK_NAME,
|
||||
ExistingPeriodicWorkPolicy.KEEP,
|
||||
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)
|
||||
@@ -46,6 +71,12 @@ class MainActivity : ComponentActivity() {
|
||||
// Initialize Tor if configured
|
||||
config.tor?.let { torManager.initialize(it) }
|
||||
|
||||
// Create AppStore JS bridge for WebView ↔ native communication
|
||||
val jsBridge = AppStoreJsBridge(
|
||||
context = this@MainActivity,
|
||||
scope = lifecycleScope
|
||||
)
|
||||
|
||||
setContent {
|
||||
SilverDROIDTheme {
|
||||
Surface(
|
||||
@@ -70,9 +101,8 @@ class MainActivity : ComponentActivity() {
|
||||
WasmWebView(
|
||||
url = config.targetUrl,
|
||||
appName = config.appName,
|
||||
onBackPressed = {
|
||||
finish()
|
||||
}
|
||||
onBackPressed = { finish() },
|
||||
jsInterface = jsBridge
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
package uk.silverlabs.silverdroid.data
|
||||
|
||||
import androidx.room.*
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import uk.silverlabs.silverdroid.data.model.InstalledApp
|
||||
|
||||
@Dao
|
||||
interface InstalledAppDao {
|
||||
|
||||
@Query("SELECT * FROM installed_apps ORDER BY appName ASC")
|
||||
fun getAllApps(): Flow<List<InstalledApp>>
|
||||
|
||||
@Query("SELECT * FROM installed_apps ORDER BY appName ASC")
|
||||
suspend fun getAllAppsOnce(): List<InstalledApp>
|
||||
|
||||
@Query("SELECT * FROM installed_apps WHERE id = :id")
|
||||
suspend fun getById(id: Long): InstalledApp?
|
||||
|
||||
@Query("SELECT * FROM installed_apps WHERE slug = :slug LIMIT 1")
|
||||
suspend fun getBySlug(slug: String): InstalledApp?
|
||||
|
||||
@Query("SELECT * FROM installed_apps WHERE packageName = :packageName LIMIT 1")
|
||||
suspend fun getByPackageName(packageName: String): InstalledApp?
|
||||
|
||||
@Query("SELECT * FROM installed_apps WHERE hasUpdate = 1")
|
||||
fun getAppsWithPendingUpdates(): Flow<List<InstalledApp>>
|
||||
|
||||
@Insert(onConflict = OnConflictStrategy.REPLACE)
|
||||
suspend fun insert(app: InstalledApp): Long
|
||||
|
||||
@Update
|
||||
suspend fun update(app: InstalledApp)
|
||||
|
||||
@Delete
|
||||
suspend fun delete(app: InstalledApp)
|
||||
|
||||
@Query("DELETE FROM installed_apps WHERE slug = :slug")
|
||||
suspend fun deleteBySlug(slug: String)
|
||||
|
||||
@Query("UPDATE installed_apps SET hasUpdate = 0, installedVersion = :newVersion, pendingVersion = NULL, pendingDownloadUrl = NULL, pendingsha256 = NULL, pendingReleaseNotes = NULL WHERE slug = :slug")
|
||||
suspend fun markUpdateInstalled(slug: String, newVersion: String)
|
||||
|
||||
@Query("SELECT COUNT(*) FROM installed_apps")
|
||||
suspend fun getCount(): Int
|
||||
}
|
||||
@@ -4,15 +4,17 @@ import android.content.Context
|
||||
import androidx.room.Database
|
||||
import androidx.room.Room
|
||||
import androidx.room.RoomDatabase
|
||||
import uk.silverlabs.silverdroid.data.model.InstalledApp
|
||||
import uk.silverlabs.silverdroid.data.model.PwaApp
|
||||
|
||||
@Database(
|
||||
entities = [PwaApp::class],
|
||||
version = 1,
|
||||
entities = [PwaApp::class, InstalledApp::class],
|
||||
version = 2,
|
||||
exportSchema = true
|
||||
)
|
||||
abstract class PwaDatabase : RoomDatabase() {
|
||||
abstract fun pwaAppDao(): PwaAppDao
|
||||
abstract fun installedAppDao(): InstalledAppDao
|
||||
|
||||
companion object {
|
||||
@Volatile
|
||||
|
||||
@@ -0,0 +1,19 @@
|
||||
package uk.silverlabs.silverdroid.data.model
|
||||
|
||||
import androidx.room.Entity
|
||||
import androidx.room.PrimaryKey
|
||||
|
||||
@Entity(tableName = "installed_apps")
|
||||
data class InstalledApp(
|
||||
@PrimaryKey(autoGenerate = true) val id: Long = 0,
|
||||
val slug: String,
|
||||
val packageName: String,
|
||||
val appName: String,
|
||||
val installedVersion: String,
|
||||
val installedAt: Long = System.currentTimeMillis(),
|
||||
val hasUpdate: Boolean = false,
|
||||
val pendingVersion: String? = null,
|
||||
val pendingDownloadUrl: String? = null,
|
||||
val pendingsha256: String? = null,
|
||||
val pendingReleaseNotes: String? = null
|
||||
)
|
||||
@@ -0,0 +1,177 @@
|
||||
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
|
||||
import uk.silverlabs.silverdroid.update.InstallerService
|
||||
|
||||
/**
|
||||
* JavascriptInterface exposed to the AppStore Blazor WASM app via window.SilverDROID.
|
||||
*
|
||||
* Usage from JavaScript / Blazor interop:
|
||||
* window.SilverDROID.installApp(slug, downloadUrl, sha256, appName, version)
|
||||
* window.SilverDROID.isAppInstalled(packageName)
|
||||
* window.SilverDROID.getInstalledVersion(slug)
|
||||
* window.SilverDROID.requestUpdate(slug)
|
||||
*/
|
||||
class AppStoreJsBridge(
|
||||
private val context: Context,
|
||||
private val scope: CoroutineScope,
|
||||
private val onShowUpdateDialog: (slug: String) -> Unit = {}
|
||||
) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "AppStoreJsBridge"
|
||||
/** Name registered with addJavascriptInterface — window.SilverDROID in the web app */
|
||||
const val BRIDGE_NAME = "SilverDROID"
|
||||
}
|
||||
|
||||
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,
|
||||
downloadUrl = downloadUrl,
|
||||
sha256 = sha256.ifBlank { null },
|
||||
appName = appName,
|
||||
version = version
|
||||
)
|
||||
if (success) {
|
||||
val existing = db.installedAppDao().getBySlug(slug)
|
||||
if (existing == null) {
|
||||
db.installedAppDao().insert(
|
||||
InstalledApp(
|
||||
slug = slug,
|
||||
packageName = slugToPackageName(slug),
|
||||
appName = appName,
|
||||
installedVersion = version
|
||||
)
|
||||
)
|
||||
} else {
|
||||
db.installedAppDao().markUpdateInstalled(slug, version)
|
||||
}
|
||||
Log.i(TAG, "installApp: DB record saved for $slug")
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Returns true if the given package is installed on this device.
|
||||
* Called synchronously from JS.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun isAppInstalled(packageName: String): Boolean {
|
||||
return try {
|
||||
context.packageManager.getPackageInfo(packageName, 0)
|
||||
true
|
||||
} catch (_: PackageManager.NameNotFoundException) {
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* 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 {
|
||||
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)
|
||||
""
|
||||
}
|
||||
}
|
||||
|
||||
/**
|
||||
* Called by the web app to trigger the native update bottom sheet.
|
||||
* Delegates to the host composable via callback.
|
||||
*/
|
||||
@JavascriptInterface
|
||||
fun requestUpdate(slug: String) {
|
||||
Log.i(TAG, "requestUpdate: slug=$slug")
|
||||
scope.launch(Dispatchers.Main) {
|
||||
onShowUpdateDialog(slug)
|
||||
}
|
||||
}
|
||||
|
||||
// Derive a likely package name from a slug; actual name stored by installApp
|
||||
private fun slugToPackageName(slug: String): String =
|
||||
"uk.silverlabs.${slug.replace("-", ".")}"
|
||||
}
|
||||
@@ -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,181 +25,145 @@ 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()
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,66 @@
|
||||
package uk.silverlabs.silverdroid.update
|
||||
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.util.Log
|
||||
import kotlinx.coroutines.CoroutineScope
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.launch
|
||||
|
||||
/**
|
||||
* Receives install status callbacks from PackageInstaller session commits.
|
||||
* Also listens for PACKAGE_REPLACED to update InstalledApp records.
|
||||
*/
|
||||
class InstallStatusReceiver : BroadcastReceiver() {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstallStatusReceiver"
|
||||
}
|
||||
|
||||
override fun onReceive(context: Context, intent: Intent) {
|
||||
when (intent.action) {
|
||||
"uk.silverlabs.silverdroid.INSTALL_STATUS" -> {
|
||||
val status = intent.getIntExtra(PackageInstaller.EXTRA_STATUS, -1)
|
||||
val msg = intent.getStringExtra(PackageInstaller.EXTRA_STATUS_MESSAGE)
|
||||
when (status) {
|
||||
PackageInstaller.STATUS_SUCCESS ->
|
||||
Log.i(TAG, "Package install succeeded")
|
||||
PackageInstaller.STATUS_FAILURE,
|
||||
PackageInstaller.STATUS_FAILURE_ABORTED,
|
||||
PackageInstaller.STATUS_FAILURE_BLOCKED,
|
||||
PackageInstaller.STATUS_FAILURE_CONFLICT,
|
||||
PackageInstaller.STATUS_FAILURE_INCOMPATIBLE,
|
||||
PackageInstaller.STATUS_FAILURE_INVALID,
|
||||
PackageInstaller.STATUS_FAILURE_STORAGE ->
|
||||
Log.e(TAG, "Package install failed: status=$status msg=$msg")
|
||||
PackageInstaller.STATUS_PENDING_USER_ACTION -> {
|
||||
// Need to prompt user — launch the confirmation intent
|
||||
val confirmIntent = intent.getParcelableExtra<Intent>(Intent.EXTRA_INTENT)
|
||||
confirmIntent?.let {
|
||||
it.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
|
||||
context.startActivity(it)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Intent.ACTION_PACKAGE_REPLACED,
|
||||
Intent.ACTION_PACKAGE_ADDED -> {
|
||||
val pkg = intent.data?.schemeSpecificPart ?: return
|
||||
Log.i(TAG, "Package installed/replaced: $pkg")
|
||||
// Update DB — we don't know the new version here, the bridge handles that
|
||||
CoroutineScope(Dispatchers.IO).launch {
|
||||
val dao = uk.silverlabs.silverdroid.data.PwaDatabase
|
||||
.getInstance(context).installedAppDao()
|
||||
val app = dao.getByPackageName(pkg) ?: return@launch
|
||||
// Clear the pending update flag; version is set by AppStoreJsBridge.installApp
|
||||
if (app.pendingVersion != null) {
|
||||
dao.markUpdateInstalled(app.slug, app.pendingVersion)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,208 @@
|
||||
package uk.silverlabs.silverdroid.update
|
||||
|
||||
import android.app.DownloadManager
|
||||
import android.content.BroadcastReceiver
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.content.IntentFilter
|
||||
import android.content.pm.PackageInstaller
|
||||
import android.net.Uri
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.content.FileProvider
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.suspendCancellableCoroutine
|
||||
import kotlinx.coroutines.withContext
|
||||
import uk.silverlabs.silverdroid.data.PwaDatabase
|
||||
import java.io.File
|
||||
import java.io.FileInputStream
|
||||
import java.security.MessageDigest
|
||||
import kotlin.coroutines.resume
|
||||
|
||||
class InstallerService(private val context: Context) {
|
||||
|
||||
companion object {
|
||||
private const val TAG = "InstallerService"
|
||||
const val FILE_PROVIDER_AUTHORITY = "uk.silverlabs.silverdroid.fileprovider"
|
||||
}
|
||||
|
||||
/**
|
||||
* Download APK, verify SHA-256, then install via PackageInstaller session API.
|
||||
* Returns true if install session was created successfully (actual install is user-confirmed).
|
||||
*/
|
||||
suspend fun downloadAndInstall(
|
||||
slug: String,
|
||||
downloadUrl: String,
|
||||
sha256: String?,
|
||||
appName: String,
|
||||
version: String,
|
||||
onProgress: (Int) -> Unit = {}
|
||||
): Boolean = withContext(Dispatchers.IO) {
|
||||
try {
|
||||
val apkFile = downloadApk(downloadUrl, slug, appName, onProgress)
|
||||
?: return@withContext false
|
||||
|
||||
if (sha256 != null && !verifySha256(apkFile, sha256)) {
|
||||
Log.e(TAG, "SHA-256 verification failed for $slug")
|
||||
apkFile.delete()
|
||||
return@withContext false
|
||||
}
|
||||
|
||||
installApk(apkFile)
|
||||
true
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "Failed to download/install $slug", e)
|
||||
false
|
||||
}
|
||||
}
|
||||
|
||||
private suspend fun downloadApk(
|
||||
url: String,
|
||||
slug: String,
|
||||
appName: String,
|
||||
onProgress: (Int) -> Unit
|
||||
): File? = suspendCancellableCoroutine { cont ->
|
||||
val cacheDir = context.externalCacheDir ?: context.cacheDir
|
||||
val apkFile = File(cacheDir, "$slug.apk")
|
||||
|
||||
val request = DownloadManager.Request(Uri.parse(url)).apply {
|
||||
setTitle("Downloading $appName")
|
||||
setDescription("Preparing update...")
|
||||
setDestinationUri(Uri.fromFile(apkFile))
|
||||
setNotificationVisibility(DownloadManager.Request.VISIBILITY_VISIBLE)
|
||||
setAllowedOverMetered(true)
|
||||
setAllowedOverRoaming(false)
|
||||
}
|
||||
|
||||
val dm = context.getSystemService(Context.DOWNLOAD_SERVICE) as DownloadManager
|
||||
val downloadId = dm.enqueue(request)
|
||||
|
||||
// Poll for completion
|
||||
val receiver = object : BroadcastReceiver() {
|
||||
override fun onReceive(ctx: Context, intent: Intent) {
|
||||
val id = intent.getLongExtra(DownloadManager.EXTRA_DOWNLOAD_ID, -1)
|
||||
if (id != downloadId) return
|
||||
|
||||
context.unregisterReceiver(this)
|
||||
|
||||
val query = DownloadManager.Query().setFilterById(downloadId)
|
||||
val cursor = dm.query(query)
|
||||
if (cursor.moveToFirst()) {
|
||||
val statusCol = cursor.getColumnIndex(DownloadManager.COLUMN_STATUS)
|
||||
val status = cursor.getInt(statusCol)
|
||||
cursor.close()
|
||||
if (status == DownloadManager.STATUS_SUCCESSFUL) {
|
||||
onProgress(100)
|
||||
cont.resume(apkFile)
|
||||
} else {
|
||||
cont.resume(null)
|
||||
}
|
||||
} else {
|
||||
cursor.close()
|
||||
cont.resume(null)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
context.registerReceiver(
|
||||
receiver,
|
||||
IntentFilter(DownloadManager.ACTION_DOWNLOAD_COMPLETE),
|
||||
Context.RECEIVER_NOT_EXPORTED
|
||||
)
|
||||
|
||||
cont.invokeOnCancellation {
|
||||
dm.remove(downloadId)
|
||||
try { context.unregisterReceiver(receiver) } catch (_: Exception) {}
|
||||
}
|
||||
}
|
||||
|
||||
private fun verifySha256(file: File, expectedHex: String): Boolean {
|
||||
val digest = MessageDigest.getInstance("SHA-256")
|
||||
FileInputStream(file).use { fis ->
|
||||
val buf = ByteArray(8192)
|
||||
var read: Int
|
||||
while (fis.read(buf).also { read = it } != -1) {
|
||||
digest.update(buf, 0, read)
|
||||
}
|
||||
}
|
||||
val actualHex = digest.digest().joinToString("") { "%02x".format(it) }
|
||||
return actualHex.equals(expectedHex, ignoreCase = true)
|
||||
}
|
||||
|
||||
private fun installApk(apkFile: File) {
|
||||
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
|
||||
installViaPackageInstaller(apkFile)
|
||||
} else {
|
||||
installViaIntent(apkFile)
|
||||
}
|
||||
}
|
||||
|
||||
private fun installViaPackageInstaller(apkFile: File) {
|
||||
val pi = context.packageManager.packageInstaller
|
||||
val params = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
|
||||
params.setSize(apkFile.length())
|
||||
|
||||
val sessionId = pi.createSession(params)
|
||||
val session = pi.openSession(sessionId)
|
||||
|
||||
FileInputStream(apkFile).use { fis ->
|
||||
session.openWrite("package", 0, apkFile.length()).use { os ->
|
||||
fis.copyTo(os)
|
||||
session.fsync(os)
|
||||
}
|
||||
}
|
||||
|
||||
val intent = Intent(context, InstallStatusReceiver::class.java).apply {
|
||||
action = "uk.silverlabs.silverdroid.INSTALL_STATUS"
|
||||
}
|
||||
val pi2 = android.app.PendingIntent.getBroadcast(
|
||||
context, sessionId, intent,
|
||||
android.app.PendingIntent.FLAG_UPDATE_CURRENT or android.app.PendingIntent.FLAG_MUTABLE
|
||||
)
|
||||
session.commit(pi2.intentSender)
|
||||
session.close()
|
||||
}
|
||||
|
||||
private fun installViaIntent(apkFile: File) {
|
||||
val apkUri = FileProvider.getUriForFile(context, FILE_PROVIDER_AUTHORITY, apkFile)
|
||||
val intent = Intent(Intent.ACTION_VIEW).apply {
|
||||
setDataAndType(apkUri, "application/vnd.android.package-archive")
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_GRANT_READ_URI_PERMISSION
|
||||
}
|
||||
context.startActivity(intent)
|
||||
}
|
||||
|
||||
/**
|
||||
* Mark app as successfully updated in the DB after PACKAGE_REPLACED broadcast.
|
||||
*/
|
||||
suspend fun onPackageInstalled(packageName: String, newVersion: String) {
|
||||
val dao = PwaDatabase.getInstance(context).installedAppDao()
|
||||
val app = dao.getByPackageName(packageName) ?: return
|
||||
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)
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
package uk.silverlabs.silverdroid.update
|
||||
|
||||
import kotlinx.serialization.Serializable
|
||||
|
||||
@Serializable
|
||||
data class UpdateCheckResponseDto(
|
||||
val hasUpdate: Boolean,
|
||||
val latestVersion: String,
|
||||
val downloadUrl: String? = null,
|
||||
val sha256: String? = null,
|
||||
val releaseNotes: String? = null
|
||||
)
|
||||
@@ -0,0 +1,152 @@
|
||||
package uk.silverlabs.silverdroid.update
|
||||
|
||||
import android.app.NotificationChannel
|
||||
import android.app.NotificationManager
|
||||
import android.app.PendingIntent
|
||||
import android.content.Context
|
||||
import android.content.Intent
|
||||
import android.os.Build
|
||||
import android.util.Log
|
||||
import androidx.core.app.NotificationCompat
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
import uk.silverlabs.silverdroid.MainActivity
|
||||
import uk.silverlabs.silverdroid.R
|
||||
import uk.silverlabs.silverdroid.data.PwaDatabase
|
||||
import uk.silverlabs.silverdroid.data.model.InstalledApp
|
||||
import java.net.HttpURLConnection
|
||||
import java.net.URL
|
||||
|
||||
class UpdateCheckerWorker(
|
||||
private val context: Context,
|
||||
params: WorkerParameters
|
||||
) : CoroutineWorker(context, params) {
|
||||
|
||||
companion object {
|
||||
const val WORK_NAME = "silverdroid_update_checker"
|
||||
const val NOTIFICATION_CHANNEL_ID = "app-updates"
|
||||
private const val TAG = "UpdateCheckerWorker"
|
||||
private const val NOTIFICATION_ID = 1001
|
||||
|
||||
fun createNotificationChannel(context: Context) {
|
||||
val channel = NotificationChannel(
|
||||
NOTIFICATION_CHANNEL_ID,
|
||||
"App Updates",
|
||||
NotificationManager.IMPORTANCE_DEFAULT
|
||||
).apply {
|
||||
description = "Notifications for available app updates from SilverSHELL AppStore"
|
||||
}
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
nm.createNotificationChannel(channel)
|
||||
}
|
||||
}
|
||||
|
||||
private val json = Json { ignoreUnknownKeys = true }
|
||||
|
||||
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()
|
||||
|
||||
val updatesFound = mutableListOf<InstalledApp>()
|
||||
|
||||
for (app in installedApps) {
|
||||
try {
|
||||
val response = checkUpdate(app.slug, app.installedVersion)
|
||||
if (response?.hasUpdate == true) {
|
||||
val updated = app.copy(
|
||||
hasUpdate = true,
|
||||
pendingVersion = response.latestVersion,
|
||||
pendingDownloadUrl = response.downloadUrl,
|
||||
pendingsha256 = response.sha256,
|
||||
pendingReleaseNotes = response.releaseNotes
|
||||
)
|
||||
dao.update(updated)
|
||||
updatesFound.add(updated)
|
||||
}
|
||||
} catch (e: Exception) {
|
||||
Log.w(TAG, "Failed to check update for ${app.slug}", e)
|
||||
}
|
||||
}
|
||||
|
||||
if (updatesFound.isNotEmpty()) {
|
||||
fireUpdateNotification(updatesFound)
|
||||
}
|
||||
|
||||
Result.success()
|
||||
} catch (e: Exception) {
|
||||
Log.e(TAG, "UpdateCheckerWorker failed", e)
|
||||
Result.retry()
|
||||
}
|
||||
}
|
||||
|
||||
private fun checkUpdate(slug: String, currentVersion: String): UpdateCheckResponseDto? {
|
||||
val url = URL("https://store.silverlabs.uk/api/apps/$slug/check-update?platform=Android&version=$currentVersion")
|
||||
val conn = url.openConnection() as HttpURLConnection
|
||||
return try {
|
||||
conn.connectTimeout = 15_000
|
||||
conn.readTimeout = 15_000
|
||||
conn.requestMethod = "GET"
|
||||
if (conn.responseCode == 200) {
|
||||
val body = conn.inputStream.bufferedReader().readText()
|
||||
json.decodeFromString<UpdateCheckResponseDto>(body)
|
||||
} else null
|
||||
} finally {
|
||||
conn.disconnect()
|
||||
}
|
||||
}
|
||||
|
||||
private fun fireUpdateNotification(updates: List<InstalledApp>) {
|
||||
val nm = context.getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager
|
||||
|
||||
val intent = Intent(context, MainActivity::class.java).apply {
|
||||
flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TASK
|
||||
}
|
||||
val pendingIntent = PendingIntent.getActivity(
|
||||
context, 0, intent,
|
||||
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_IMMUTABLE
|
||||
)
|
||||
|
||||
val title = if (updates.size == 1) {
|
||||
"${updates[0].appName} update available"
|
||||
} else {
|
||||
"${updates.size} app updates available"
|
||||
}
|
||||
val body = if (updates.size == 1) {
|
||||
"Version ${updates[0].pendingVersion} is ready to install"
|
||||
} else {
|
||||
updates.joinToString(", ") { it.appName }
|
||||
}
|
||||
|
||||
val notification = NotificationCompat.Builder(context, NOTIFICATION_CHANNEL_ID)
|
||||
.setSmallIcon(android.R.drawable.stat_sys_download_done)
|
||||
.setContentTitle(title)
|
||||
.setContentText(body)
|
||||
.setPriority(NotificationCompat.PRIORITY_DEFAULT)
|
||||
.setContentIntent(pendingIntent)
|
||||
.setAutoCancel(true)
|
||||
.build()
|
||||
|
||||
nm.notify(NOTIFICATION_ID, notification)
|
||||
}
|
||||
}
|
||||
7
app/src/main/res/xml/file_paths.xml
Normal file
7
app/src/main/res/xml/file_paths.xml
Normal file
@@ -0,0 +1,7 @@
|
||||
<?xml version="1.0" encoding="utf-8"?>
|
||||
<paths>
|
||||
<!-- External cache dir — APKs downloaded by InstallerService -->
|
||||
<external-cache-path name="apk_downloads" path="." />
|
||||
<!-- Fallback to internal cache if external storage unavailable -->
|
||||
<cache-path name="apk_downloads_internal" path="." />
|
||||
</paths>
|
||||
Reference in New Issue
Block a user