13 Commits

Author SHA1 Message Date
0b756788c2 fix(updater): self-register SilverDROID in installed_apps before update check
The worker exited early when the DB was empty (fresh installs via browser).
Now upserts a self-record for slug='silverdroid' before the isEmpty guard
so update notifications fire even when no other apps have been installed
via the AppStore.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 22:43:29 +00:00
df58e3b0df fix(appstore): runtime install permission gate and DB reconciliation
- AppStoreJsBridge: add canInstallApps() JS interface for UI gating
- AppStoreJsBridge: gate installApp() on canRequestPackageInstalls() at
  runtime; redirect to Settings and fire onInstallError callback on deny
- AppStoreJsBridge: fix getInstalledVersion() to cross-check PackageManager,
  correcting stale version strings and removing uninstalled app records
- InstallerService: add reconcileWithPackageManager() to sync Room DB with
  actual installed packages on every app start
- MainActivity: launch reconciliation on start before WebView loads
- WasmWebView: set webView ref on bridge for evaluateJavascript callbacks

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-05 18:21:15 +00:00
0dea0bb506 feat(appstore): add AppStore client features
- AppStoreJsBridge: JS bridge for native APK install, version check, update
- InstalledAppDao, InstalledApp entity: Room DB for tracking installed apps
- UpdateCheckerWorker: background update polling via WorkManager
- InstallerService: APK download + SHA-256 verify + PackageInstaller session
- Updated AndroidManifest: REQUEST_INSTALL_PACKAGES, FileProvider, receivers
- Updated MainActivity: notification channel, WorkManager, JS bridge wiring
- config.json: targetUrl = https://store.silverlabs.uk
- file_paths.xml: FileProvider paths for APK installs

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 14:06:04 +00:00
0f1b6a6157 feat(appstore): rebrand as SilverSHELL AppStore client
- applicationId: uk.silverlabs.silverdroid → uk.silverlabs.appstore
- app_name: SilverDROID → SilverSHELL AppStore
- user agent: SilverDROID/1.0 → SilverAppStore/1.0
- versionCode/versionName: override via Gradle -P args for CI builds

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
2026-03-04 13:34:47 +00:00
ba9bba1503 Remove companion object from RemoteConfigLoader
Objects cannot have companion objects in Kotlin. Moved TAG constant
to top-level instead.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 20:10:13 +01:00
876db1751a Fix RemoteConfigLoader to use Kotlin's built-in Result type
Changed from custom Result sealed class to kotlin.Result which has
proper success/failure factory methods. Updated pattern matching to
use getOrElse for cleaner error handling.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 20:05:48 +01:00
d173c08a0c Add remote configuration support and fix app name
Features:
- Fixed app name from "Dark Side Admin" to "SilverDROID"
- Added remote configuration loader with AppStore integration
- Support for user-specific configurations
- Bearer token authentication for secure config retrieval
- Automatic fallback to local config if remote fails
- Remote config refresh interval support

Configuration example updated with remoteConfig section.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:58:19 +01:00
9f33b5a332 Remove netcipher libraries and use Tor/VPN stubs
Removed netcipher libraries causing duplicate class errors.
Both VPN and Tor managers are now stubs that log configuration
and can be extended in future releases with proper implementations.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:36:21 +01:00
e9093b2822 Fix duplicate netcipher classes error
Exclude netcipher from netcipher-webkit to avoid duplicate R classes

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:26:45 +01:00
1d2b6f2d87 Fix build errors - use stub VPN implementation
Removed incompatible WireGuard library dependency and created
stub implementation for VPN that logs configuration. Tor integration
remains functional via Orbot. Full WireGuard support requires native
library integration in future update.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 19:19:52 +01:00
94887f6cf7 Add configurable deployment system with VPN and Tor support
Features:
- Hidden config.json in assets for per-deployment customization
- Configure target URL, app name, and branding
- Optional WireGuard VPN with auto-connect
- Optional Tor routing via Orbot
- Custom theme colors
- Configuration-driven app behavior

Configuration file location: app/src/main/assets/config.json
Example configuration: config.example.json

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 18:51:01 +01:00
a083606b9e Update app to load SilverDesk staging URL
Changed target URL from admin.dark.side to silverdesk-staging.silverlabs.uk

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 17:57:55 +01:00
f66cdcfa42 Use debug signing for release builds
Removed custom signing config and use debug keystore for release
builds to enable installation. Disabled minification temporarily.

🤖 Generated with [Claude Code](https://claude.com/claude-code)

Co-Authored-By: Claude <noreply@anthropic.com>
2025-10-05 17:20:48 +01:00
22 changed files with 1719 additions and 201 deletions

305
DEPLOYMENT_STATUS.md Normal file
View File

@@ -0,0 +1,305 @@
# Deployment Status - SilverDROID
## ✅ Successfully Pushed to GitLab!
**Timestamp:** 2025-09-30 18:13:47 +02:00
**Commit:** c667765
**Branch:** main
---
## 📍 Project URLs
**Project Homepage:**
```
https://gitlab.silverlabs.uk/silverlabs/silverdroid
```
**Pipeline Dashboard:**
```
https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/pipelines
```
**First Pipeline:**
```
https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/pipelines/205
```
**Repository:**
```
https://gitlab.silverlabs.uk/silverlabs/silverdroid.git
```
---
## 📦 What Was Pushed
**41 files committed:**
- ✅ Complete Android project structure
- ✅ MainActivity configured for admin.dark.side
- ✅ WebView with WASM support
- ✅ Glassmorphism UI components
- ✅ Material Design 3 theme
- ✅ Room database layer
-`.gitlab-ci.yml` pipeline configuration
- ✅ Comprehensive documentation (12 files)
- ✅ Push automation scripts
**Total lines:** ~4,857 insertions
---
## ⚠️ Pipeline Status: Failed (Expected)
**Pipeline #205** failed immediately because:
- **No GitLab Runners are configured** for the project
This is **normal** for a new GitLab installation. You need to set up a Runner first.
---
## 🔧 Next Step: Configure GitLab Runner
### Option 1: Register a New Runner
**On your GitLab server (or dedicated runner machine):**
```bash
# Install GitLab Runner (if not already installed)
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt-get install gitlab-runner
# Register the runner
sudo gitlab-runner register \
--url https://gitlab.silverlabs.uk \
--token <PROJECT_REGISTRATION_TOKEN> \
--executor docker \
--docker-image mingc/android-build-box:latest \
--description "Android Build Runner" \
--tag-list "android,docker" \
--run-untagged=true \
--locked=false
```
**Get the registration token:**
1. Go to: https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/settings/ci_cd
2. Expand "Runners"
3. Copy the registration token
4. Use it in the command above
### Option 2: Use an Existing Runner
If you already have GitLab Runners:
1. Go to: https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/settings/ci_cd
2. Expand "Runners"
3. Find "Available specific runners"
4. Click "Enable" for an existing runner
### Option 3: Use Shared Runners (If Available)
If your GitLab instance has shared runners:
1. Go to: https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/settings/ci_cd
2. Expand "Runners"
3. Enable "Shared runners"
---
## 🚀 After Runner is Configured
### Trigger a New Pipeline
**Option A: Push a new commit**
```bash
cd /mnt/c/Production/Source/SilverLABS/SilverDROID
echo "# Trigger pipeline" >> README.md
git add README.md
git commit -m "Trigger CI/CD pipeline with runner"
git push origin main
```
**Option B: Manual trigger via Web UI**
1. Go to: https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/pipelines
2. Click "Run pipeline"
3. Select "main" branch
4. Click "Run pipeline"
**Option C: API trigger**
```bash
curl -X POST "https://gitlab.silverlabs.uk/api/v4/projects/10/pipeline" \
--header "PRIVATE-TOKEN: glpat-wqUcD7mg53F1mgM-N-PdiW86MQp1OjEH.01.0w074ox93" \
--data "ref=main"
```
### Expected Pipeline Duration
Once a runner is available:
- **prepare:** ~1 minute (download dependencies)
- **test:** ~2 minutes (lint + unit tests)
- **build:** ~3-5 minutes (compile APKs)
- **deploy:** ~30 seconds (store artifacts)
**Total:** ~5-8 minutes
---
## 📦 What You'll Get
### Build Artifacts
Once the pipeline completes successfully:
**Debug APK:**
- Path: `app/build/outputs/apk/debug/app-debug.apk`
- Size: ~10-15 MB
- Download: https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/jobs/artifacts/main/raw/app/build/outputs/apk/debug/app-debug.apk?job=build:debug
**Release APK:**
- Path: `app/build/outputs/apk/release/app-release-unsigned.apk`
- Size: ~8-10 MB
- Download: https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/jobs/artifacts/main/raw/app/build/outputs/apk/release/app-release-unsigned.apk?job=build:release
**Android App Bundle:**
- Path: `app/build/outputs/bundle/release/app-release.aab`
- Size: ~8-10 MB
- For: Google Play Store submission
### Test Reports
- **Lint Report:** HTML + XML format
- **Unit Tests:** JUnit XML format
- **Security Scan:** Dependency check report
---
## 📊 Current Project Status
### Git Status
- ✅ Repository initialized
- ✅ Remote configured
- ✅ Initial commit created
- ✅ Pushed to GitLab
- ✅ Project created successfully
### CI/CD Status
-`.gitlab-ci.yml` configured
- ⚠️ **Runner needed** - Pipeline waiting for executor
- ⏳ Pending first successful build
### App Status
- ✅ Android project complete
- ✅ MainActivity loads admin.dark.side
- ✅ WASM/PWA support enabled
- ✅ Glassmorphism UI implemented
- ⏳ APK needs to be built
---
## 🎯 Immediate Action Required
**To get your APK, you must configure a GitLab Runner:**
1. **SSH into your GitLab server:**
```bash
ssh sysadmin@gitlab.silverlabs.uk
```
2. **Install GitLab Runner** (if not installed):
```bash
curl -L https://packages.gitlab.com/install/repositories/runner/gitlab-runner/script.deb.sh | sudo bash
sudo apt-get install gitlab-runner
```
3. **Register the runner** (see commands above)
4. **Verify runner is active:**
```bash
sudo gitlab-runner list
```
5. **Trigger a new pipeline** (see options above)
6. **Monitor the build:**
```
https://gitlab.silverlabs.uk/silverlabs/silverdroid/-/pipelines
```
7. **Download APK** once complete
---
## 🔄 Alternative: Build Locally
If you can't configure a runner right now, build locally:
### Windows PowerShell
```powershell
cd C:\Production\Source\SilverLABS\SilverDROID
# First time: setup Gradle wrapper
# (Download gradle-8.9-bin.zip and extract, then run gradle wrapper)
# Build debug APK
.\gradlew.bat assembleDebug
# Output location
C:\Production\Source\SilverLABS\SilverDROID\app\build\outputs\apk\debug\app-debug.apk
```
### Android Studio
1. Open: `C:\Production\Source\SilverLABS\SilverDROID`
2. Wait for Gradle sync
3. Build → Build Bundle(s) / APK(s) → Build APK(s)
4. Find APK in: `app\build\outputs\apk\debug\`
---
## 📞 Support Resources
### Documentation
- **GITLAB_CICD_SETUP.md** - Complete runner setup guide
- **BUILD.md** - Local build instructions
- **QUICK_REFERENCE.md** - Command reference
### URLs
- **GitLab:** https://gitlab.silverlabs.uk
- **Project:** https://gitlab.silverlabs.uk/silverlabs/silverdroid
- **TeamCity:** https://cis1.silverlabs.uk
### Commands
```bash
# Check runners
sudo gitlab-runner list
# Restart runner
sudo gitlab-runner restart
# View runner logs
sudo gitlab-runner --debug run
```
---
## ✅ Summary
**What's working:**
- ✅ Code pushed to GitLab successfully
- ✅ Project created and accessible
- ✅ CI/CD pipeline configured
- ✅ Complete Android app ready
**What's needed:**
- ⚠️ **GitLab Runner registration** (see above)
- ⏳ First successful pipeline run
- ⏳ APK artifacts download
**Next action:**
Configure a GitLab Runner, then re-run the pipeline to get your APK!
---
**Status:** Ready for Runner Configuration
**Project ID:** 10
**Commit SHA:** c667765
**Created:** 2025-09-30 18:13:47

View File

@@ -2,6 +2,7 @@ plugins {
id("com.android.application")
id("org.jetbrains.kotlin.android")
id("org.jetbrains.kotlin.plugin.compose")
id("org.jetbrains.kotlin.plugin.serialization") version "2.1.0"
id("com.google.devtools.ksp")
}
@@ -10,11 +11,11 @@ android {
compileSdk = 35
defaultConfig {
applicationId = "uk.silverlabs.silverdroid"
applicationId = "uk.silverlabs.appstore"
minSdk = 26
targetSdk = 35
versionCode = 1
versionName = "1.0.0"
versionCode = (project.findProperty("versionCode") as String?)?.toInt() ?: 1
versionName = (project.findProperty("versionName") as String?) ?: "1.0.0"
testInstrumentationRunner = "androidx.test.runner.AndroidJUnitRunner"
vectorDrawables {
@@ -22,27 +23,19 @@ android {
}
}
signingConfigs {
create("release") {
// Using debug keystore for now - replace with your own keystore
storeFile = file("${System.getProperty("user.home")}/.android/debug.keystore")
storePassword = "android"
keyAlias = "androiddebugkey"
keyPassword = "android"
}
}
buildTypes {
debug {
signingConfig = signingConfigs.getByName("debug")
}
release {
isMinifyEnabled = true
// Temporarily disable minification for debugging
isMinifyEnabled = false
proguardFiles(
getDefaultProguardFile("proguard-android-optimize.txt"),
"proguard-rules.pro"
)
signingConfig = signingConfigs.getByName("release")
// Using debug signing for now - unsigned release
signingConfig = signingConfigs.getByName("debug")
}
}
@@ -91,6 +84,9 @@ dependencies {
implementation("androidx.room:room-ktx:2.6.1")
ksp("androidx.room:room-compiler:2.6.1")
// WorkManager for background update checks
implementation("androidx.work:work-runtime-ktx:2.10.0")
// Coroutines
implementation("org.jetbrains.kotlinx:kotlinx-coroutines-android:1.10.1")

View File

@@ -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>

View File

@@ -0,0 +1,7 @@
{
"appName": "SilverSHELL AppStore",
"appVersion": "1.0.0",
"targetUrl": "https://store.silverlabs.uk",
"showUrlBar": false,
"allowNavigation": true
}

View File

@@ -7,43 +7,114 @@ import androidx.activity.enableEdgeToEdge
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.material3.MaterialTheme
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 - Direct Load Version
* SilverDROID - Configurable Android Browser
*
* This version loads https://admin.dark.side directly on launch,
* bypassing the launcher screen.
* Loads configuration from assets/config.json to customize:
* - Target URL and app branding
* - Optional WireGuard VPN connection
* - Optional Tor routing via Orbot
* - Custom themes and styling
*/
class MainActivity : ComponentActivity() {
// Direct load configuration
private val targetUrl = "https://admin.dark.side"
private val appName = "Dark Side Admin"
private lateinit var vpnManager: WireGuardManager
private lateinit var torManager: TorManager
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
enableEdgeToEdge()
// Load admin.dark.side directly
setContent {
SilverDROIDTheme {
Surface(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
WasmWebView(
url = targetUrl,
appName = appName,
onBackPressed = {
// Exit app on back press
finish()
// 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)
// Initialize VPN and Tor managers
vpnManager = WireGuardManager(this@MainActivity)
torManager = TorManager(this@MainActivity)
// 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(
modifier = Modifier.fillMaxSize(),
color = MaterialTheme.colorScheme.background
) {
// Auto-connect VPN/Tor if configured
LaunchedEffect(Unit) {
config.vpn?.let { vpnConfig ->
if (vpnConfig.enabled && vpnConfig.autoConnect) {
vpnManager.connect(vpnConfig)
}
}
config.tor?.let { torConfig ->
if (torConfig.enabled && torConfig.autoConnect) {
torManager.connect()
}
}
}
)
WasmWebView(
url = config.targetUrl,
appName = config.appName,
onBackPressed = { finish() },
jsInterface = jsBridge
)
}
}
}
}
}
override fun onDestroy() {
super.onDestroy()
lifecycleScope.launch {
vpnManager.disconnect()
torManager.disconnect()
}
}
}

View File

@@ -0,0 +1,74 @@
package uk.silverlabs.silverdroid.config
import kotlinx.serialization.Serializable
/**
* Application configuration that can be customized per deployment
*/
@Serializable
data class AppConfig(
// App branding
val appName: String = "SilverDROID",
val appVersion: String = "1.0.0",
// Target URL configuration
val targetUrl: String,
val showUrlBar: Boolean = false,
val allowNavigation: Boolean = true,
// Remote configuration
val remoteConfig: RemoteConfigSettings? = null,
// VPN configuration (optional)
val vpn: VpnConfig? = null,
// Tor configuration (optional)
val tor: TorConfig? = null,
// Theme customization
val theme: ThemeConfig = ThemeConfig()
)
@Serializable
data class RemoteConfigSettings(
val enabled: Boolean = true,
val url: String,
val authToken: String? = null,
val userSpecific: Boolean = false,
val refreshInterval: Long = 3600000 // 1 hour in milliseconds
)
@Serializable
data class VpnConfig(
val enabled: Boolean = false,
val autoConnect: Boolean = true,
val privateKey: String,
val address: String,
val dns: List<String> = emptyList(),
val peers: List<PeerConfig>
)
@Serializable
data class PeerConfig(
val publicKey: String,
val endpoint: String,
val allowedIps: List<String> = listOf("0.0.0.0/0"),
val persistentKeepalive: Int = 25
)
@Serializable
data class TorConfig(
val enabled: Boolean = false,
val autoConnect: Boolean = true,
val useBridges: Boolean = false,
val bridges: List<String> = emptyList(),
val socksPort: Int = 9050,
val controlPort: Int = 9051
)
@Serializable
data class ThemeConfig(
val primaryColor: String? = null,
val backgroundColor: String? = null,
val statusBarColor: String? = null
)

View File

@@ -0,0 +1,82 @@
package uk.silverlabs.silverdroid.config
import android.content.Context
import kotlinx.serialization.json.Json
import java.io.IOException
/**
* Loads app configuration from assets/config.json
* Falls back to default configuration if file doesn't exist
*/
object ConfigLoader {
private const val CONFIG_FILE = "config.json"
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
prettyPrint = true
}
fun loadConfig(context: Context): AppConfig {
return try {
val configJson = context.assets.open(CONFIG_FILE).bufferedReader().use { it.readText() }
json.decodeFromString<AppConfig>(configJson)
} catch (e: IOException) {
// File doesn't exist, return default config
getDefaultConfig()
} catch (e: Exception) {
// Parsing error, log and return default
android.util.Log.e("ConfigLoader", "Error loading config", e)
getDefaultConfig()
}
}
private fun getDefaultConfig() = AppConfig(
appName = "SilverDROID",
targetUrl = "https://silverdesk-staging.silverlabs.uk/",
showUrlBar = false,
allowNavigation = true
)
/**
* Example configuration for reference
*/
fun getExampleConfig() = """
{
"appName": "SilverDesk Staging",
"appVersion": "1.0.0",
"targetUrl": "https://silverdesk-staging.silverlabs.uk/",
"showUrlBar": false,
"allowNavigation": true,
"vpn": {
"enabled": true,
"autoConnect": true,
"privateKey": "YOUR_PRIVATE_KEY_HERE",
"address": "10.0.0.2/24",
"dns": ["1.1.1.1", "1.0.0.1"],
"peers": [
{
"publicKey": "SERVER_PUBLIC_KEY_HERE",
"endpoint": "vpn.example.com:51820",
"allowedIps": ["0.0.0.0/0"],
"persistentKeepalive": 25
}
]
},
"tor": {
"enabled": false,
"autoConnect": false,
"useBridges": false,
"bridges": [],
"socksPort": 9050,
"controlPort": 9051
},
"theme": {
"primaryColor": "#1976D2",
"backgroundColor": "#FFFFFF",
"statusBarColor": "#1976D2"
}
}
""".trimIndent()
}

View File

@@ -0,0 +1,112 @@
package uk.silverlabs.silverdroid.config
import android.content.Context
import android.util.Log
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
import kotlinx.serialization.json.Json
import java.net.HttpURLConnection
import java.net.URL
private const val TAG = "RemoteConfigLoader"
/**
* Loads configuration from remote AppStore server
*/
object RemoteConfigLoader {
private val json = Json {
ignoreUnknownKeys = true
isLenient = true
}
/**
* Fetch configuration from remote URL with optional authentication
*/
suspend fun fetchConfig(
remoteSettings: RemoteConfigSettings,
userId: String? = null
): kotlin.Result<AppConfig> = withContext(Dispatchers.IO) {
try {
val url = buildConfigUrl(remoteSettings, userId)
Log.i(TAG, "Fetching config from: $url")
val connection = URL(url).openConnection() as HttpURLConnection
connection.requestMethod = "GET"
connection.connectTimeout = 10000
connection.readTimeout = 10000
// Add authentication if provided
remoteSettings.authToken?.let {
connection.setRequestProperty("Authorization", "Bearer $it")
}
// Add user ID if user-specific config
userId?.let {
connection.setRequestProperty("X-User-ID", it)
}
val responseCode = connection.responseCode
if (responseCode == HttpURLConnection.HTTP_OK) {
val response = connection.inputStream.bufferedReader().use { it.readText() }
val config = json.decodeFromString<AppConfig>(response)
Log.i(TAG, "Successfully loaded remote config for: ${config.appName}")
kotlin.Result.success(config)
} else {
val error = "HTTP $responseCode: ${connection.responseMessage}"
Log.e(TAG, "Failed to fetch config: $error")
kotlin.Result.failure(Exception(error))
}
} catch (e: Exception) {
Log.e(TAG, "Error fetching remote config", e)
kotlin.Result.failure(e)
}
}
private fun buildConfigUrl(settings: RemoteConfigSettings, userId: String?): String {
var url = settings.url
// Add user parameter if user-specific
if (settings.userSpecific && userId != null) {
url = if (url.contains("?")) {
"$url&userId=$userId"
} else {
"$url?userId=$userId"
}
}
return url
}
/**
* Load config with remote fallback
* 1. Try to load local config
* 2. If remote is configured, fetch from server
* 3. Merge remote with local (remote takes precedence)
*/
suspend fun loadConfigWithRemote(
context: Context,
userId: String? = null
): AppConfig {
// Load local config first
val localConfig = ConfigLoader.loadConfig(context)
// Check if remote config is enabled
val remoteSettings = localConfig.remoteConfig
if (remoteSettings == null || !remoteSettings.enabled) {
Log.i(TAG, "Remote config disabled, using local config")
return localConfig
}
// Fetch remote config
return fetchConfig(remoteSettings, userId).getOrElse { error ->
Log.w(TAG, "Remote config failed, falling back to local", error)
localConfig
}.also {
if (it != localConfig) {
Log.i(TAG, "Using remote configuration")
}
}
}
}

View File

@@ -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
}

View File

@@ -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

View File

@@ -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
)

View File

@@ -0,0 +1,71 @@
package uk.silverlabs.silverdroid.tor
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import uk.silverlabs.silverdroid.config.TorConfig
/**
* Manages Tor connections via Orbot
* Note: Full Tor integration requires Orbot and netcipher libraries
* This is a stub for configuration support
*/
class TorManager(private val context: Context) {
private val _connectionState = MutableStateFlow(TorState.DISCONNECTED)
val connectionState: StateFlow<TorState> = _connectionState
private var torConfig: TorConfig? = null
fun initialize(config: TorConfig) {
this.torConfig = config
Log.i(TAG, "Tor configuration initialized")
}
suspend fun connect(): Result<Unit> {
val config = torConfig ?: return Result.failure(Exception("Tor not configured"))
return try {
Log.i(TAG, "Tor configuration loaded")
Log.i(TAG, "SOCKS Port: ${config.socksPort}")
// TODO: Implement actual Tor/Orbot connection
_connectionState.value = TorState.CONNECTED
Log.w(TAG, "Tor stub - not actually connecting")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to configure Tor", e)
_connectionState.value = TorState.ERROR
Result.failure(e)
}
}
fun disconnect() {
try {
_connectionState.value = TorState.DISCONNECTED
Log.i(TAG, "Tor disconnected")
} catch (e: Exception) {
Log.e(TAG, "Error disconnecting Tor", e)
}
}
fun getSocksProxy(): String {
val config = torConfig ?: return "127.0.0.1:9050"
return "127.0.0.1:${config.socksPort}"
}
companion object {
private const val TAG = "TorManager"
}
}
enum class TorState {
NOT_INSTALLED,
DISCONNECTED,
CONNECTING,
CONNECTED,
DISCONNECTING,
ERROR
}

View File

@@ -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("-", ".")}"
}

View File

@@ -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,188 +14,157 @@ import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.unit.dp
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun WasmWebView(
url: String,
appName: String,
onBackPressed: () -> Unit,
modifier: Modifier = Modifier
modifier: Modifier = Modifier,
jsInterface: AppStoreJsBridge? = null
) {
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 +
" SilverDROID/1.0 (PWA/WASM Launcher)"
// 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()
}
}
}
}
}

View File

@@ -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)
}
}
}
}
}
}

View File

@@ -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)
}
}
}
}

View File

@@ -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
)

View File

@@ -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)
}
}

View File

@@ -0,0 +1,58 @@
package uk.silverlabs.silverdroid.vpn
import android.content.Context
import android.util.Log
import kotlinx.coroutines.flow.MutableStateFlow
import kotlinx.coroutines.flow.StateFlow
import uk.silverlabs.silverdroid.config.VpnConfig
/**
* Manages WireGuard VPN connections
* Note: Full WireGuard implementation requires native libraries
* This is a stub that logs configuration for future implementation
*/
class WireGuardManager(private val context: Context) {
private val _connectionState = MutableStateFlow(VpnState.DISCONNECTED)
val connectionState: StateFlow<VpnState> = _connectionState
suspend fun connect(vpnConfig: VpnConfig): Result<Unit> {
return try {
Log.i(TAG, "WireGuard VPN configuration loaded")
Log.i(TAG, "Address: ${vpnConfig.address}")
Log.i(TAG, "Peers: ${vpnConfig.peers.size}")
// TODO: Implement actual WireGuard connection
// For now, log that it would connect
_connectionState.value = VpnState.CONNECTED
Log.w(TAG, "WireGuard stub - not actually connecting")
Result.success(Unit)
} catch (e: Exception) {
Log.e(TAG, "Failed to configure VPN", e)
_connectionState.value = VpnState.ERROR
Result.failure(e)
}
}
suspend fun disconnect() {
try {
_connectionState.value = VpnState.DISCONNECTED
Log.i(TAG, "WireGuard VPN disconnected")
} catch (e: Exception) {
Log.e(TAG, "Error disconnecting VPN", e)
}
}
companion object {
private const val TAG = "WireGuardManager"
}
}
enum class VpnState {
DISCONNECTED,
CONNECTING,
CONNECTED,
DISCONNECTING,
ERROR
}

View File

@@ -1,6 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Dark Side Admin</string>
<string name="app_name">SilverSHELL AppStore</string>
<string name="launcher_title">Your Apps</string>
<string name="add_pwa">Add App</string>
<string name="settings">Settings</string>

View 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>

54
config.example.json Normal file
View File

@@ -0,0 +1,54 @@
{
"_comment": "SilverDROID Configuration Example",
"_comment2": "Copy this file to app/src/main/assets/config.json and customize",
"appName": "SilverDesk Staging",
"appVersion": "1.0.0",
"targetUrl": "https://silverdesk-staging.silverlabs.uk/",
"showUrlBar": false,
"allowNavigation": true,
"remoteConfig": {
"_comment": "Optional remote configuration from AppStore",
"enabled": false,
"url": "https://appstore.silverlabs.uk/api/config/silverdroid",
"authToken": "YOUR_APPSTORE_TOKEN_HERE",
"userSpecific": true,
"refreshInterval": 3600000
},
"vpn": {
"_comment": "Optional WireGuard VPN configuration",
"enabled": false,
"autoConnect": true,
"privateKey": "YOUR_PRIVATE_KEY_HERE",
"address": "10.0.0.2/24",
"dns": ["1.1.1.1", "1.0.0.1"],
"peers": [
{
"publicKey": "SERVER_PUBLIC_KEY_HERE",
"endpoint": "vpn.silverlabs.uk:51820",
"allowedIps": ["0.0.0.0/0"],
"persistentKeepalive": 25
}
]
},
"tor": {
"_comment": "Optional Tor routing via Orbot",
"_comment2": "Requires Orbot app to be installed",
"enabled": false,
"autoConnect": false,
"useBridges": false,
"bridges": [],
"socksPort": 9050,
"controlPort": 9051
},
"theme": {
"_comment": "Optional custom theming (hex colors)",
"primaryColor": "#1976D2",
"backgroundColor": "#FFFFFF",
"statusBarColor": "#1976D2"
}
}