Add Local App Store: server, Android client, upload endpoints, and skills

This commit is contained in:
Jarian 2026-05-13 04:10:51 +00:00
parent ad51890cb2
commit 549877ab4b
67 changed files with 4174 additions and 0 deletions

30
.gitignore vendored Normal file
View File

@ -0,0 +1,30 @@
# Build outputs
*/app/build/
*/.gradle/
*.apk
# Local properties (machine-specific paths)
local.properties
# Binaries and credentials
signing/
# SDK installation (install locally)
android-sdk/
# Python
__pycache__/
*.pyc
server/venv/
# Server repos and downloads (APKs)
server/repos/
server/downloads/
# Log files
nohup.out
# OS files
.DS_Store
*.swp
*.swo

102
UPLOAD_APP.md Normal file
View File

@ -0,0 +1,102 @@
# Upload Android App to Local App Store
Follow these steps to add a new Android app to the Local App Store.
## Prerequisites
- Java 17: `export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64`
- Android SDK: `export ANDROID_HOME=/home/jarian/android-sdk`
- Gradle wrapper in your app project (run `gradle wrapper` if missing)
- Signing keystore: `/home/jarian/playground/app-store/signing/localstore.keystore` (alias `localstore`, password `localstore123`)
- Server running on port 9800
## Step 1: Build the APK
```bash
export JAVA_HOME=/usr/lib/jvm/java-17-openjdk-amd64
export ANDROID_HOME=/home/jarian/android-sdk
cd /path/to/your/app/project
./gradlew :app:assembleRelease
```
The signed release APK will be at `app/build/outputs/apk/release/app-release.apk`.
## Step 2: Copy APK to repos
```bash
cp app/build/outputs/apk/release/app-release.apk /home/jarian/playground/app-store/server/repos/<app_id>.apk
```
Use a lowercase, hyphen-separated ID (e.g., `myapp`, `hello-world`).
## Step 3: Create metadata JSON
Create `<app_id>.json` in the repos directory alongside the APK:
```json
{
"name": "App Display Name",
"description": "One or two sentences describing the app.",
"icon": null,
"screenshots": [],
"package_name": "com.your.package",
"version_name": "1.0",
"version_code": "1",
"min_sdk": "26",
"target_sdk": "34",
"permissions": ["android.permission.INTERNET"]
}
```
**Field notes:**
- `name`: Human-readable display name
- `description`: Short description shown in the store
- `icon`: `null` if no PNG icon in the APK, otherwise the resource path (e.g., `res/drawable-mdpi/ic_launcher.png`)
- `screenshots`: Array of screenshot filenames, or `[]` if none
- `package_name`: From `AndroidManifest.xml` or `build.gradle`
- `version_name` / `version_code`: From `build.gradle` `defaultConfig`
- `min_sdk` / `target_sdk`: From `build.gradle` `defaultConfig`
- `permissions`: Array of `<uses-permission>` entries from `AndroidManifest.xml`
## Step 4: Trigger server scan
```bash
curl -X POST http://localhost:9800/api/scan
```
Or restart the server if preferred:
```bash
screen -S appstore -X quit 2>/dev/null
sleep 1
screen -dmS appstore bash -c "cd /home/jarian/playground/app-store/server && source venv/bin/activate && python -c 'import uvicorn, sys; sys.path.insert(0, \".\"); from main import app; uvicorn.run(app, host=\"0.0.0.0\", port=9800)'"
```
## Step 5: Verify
```bash
curl -s http://localhost:9800/api/apps | python3 -c "
import json, sys
data = json.load(sys.stdin)
print(f'Total: {data[\"total\"]} apps')
for app in data['apps']:
print(f' - {app[\"name\"]} (v{app[\"version_name\"]}, pkg={app[\"package_name\"]})')
"
```
## Optional: Add to downloads directory
For easy browser download via `/dl/` endpoint:
```bash
cp app/build/outputs/apk/release/app-release.apk /home/jarian/playground/app-store/server/downloads/<app_id>.apk
```
## Common Issues
- **Binary XML manifest parsing**: The scanner reads APKs directly without `aapt`. Fields like `version_name` may show `unknown` if the string parser misses them — always set them in the JSON override.
- **No PNG icon**: If the APK only has XML vector drawables, set `"icon": null` in the JSON.
- **KSP plugin**: Required for Room. Keep only in `app/build.gradle`, not root `build.gradle`.
- **Navigation**: Use `<fragment>` tag (not `FrameLayout`) with `android:name="androidx.navigation.fragment.NavHostFragment"`, or add NavHostFragment programmatically in MainActivity.
- **Data binding**: Cannot create typed properties for `<fragment>` tags. Use programmatic NavHostFragment instead.
- **Lint**: Set `checkReleaseBuilds false` to avoid blocking builds.

93
UPLOAD_APP_REMOTE.md Normal file
View File

@ -0,0 +1,93 @@
# Upload Android App to Local App Store (Remote)
Use this skill to upload a built Android APK to the Local App Store server remotely via HTTP. The server is at **192.168.8.128:9800**.
## Prerequisites
- A signed release APK file (`.apk`)
- `curl` available on the machine running this skill
## Step 1: Create Metadata JSON
Create a JSON file with app metadata. All fields except `screenshots` are required:
```json
{
"name": "App Display Name",
"description": "One or two sentences describing the app.",
"icon": null,
"screenshots": [],
"package_name": "com.your.package",
"version_name": "1.0",
"version_code": "1",
"min_sdk": "26",
"target_sdk": "34",
"permissions": ["android.permission.INTERNET"]
}
```
**Field notes:**
- **`name`**: Human-readable display name shown in the store
- **`description`**: Short description shown in app details
- **`icon`**: `null` if no PNG icon in the APK, otherwise the resource path inside the APK (e.g., `res/drawable-mdpi/ic_launcher.png`). If the APK only has XML vector drawables, use `null`.
- **`package_name`**: Must match `applicationId` in `build.gradle`
- **`version_name` / `version_code`**: Must match `build.gradle` `defaultConfig`
- **`min_sdk` / `target_sdk`**: Must match `build.gradle` `defaultConfig`
- **`permissions`**: Array of `<uses-permission android:name="..." />` values from `AndroidManifest.xml`, or `[]` if none
- **`screenshots`**: `[]` unless you have screenshot PNG files to upload separately
Save this as `metadata.json`.
## Step 2: Choose App ID
Pick a lowercase, hyphen-separated ID for the app (e.g., `my-app`, `hello-world`, `flappy-bird`). This becomes the app's identifier in the store.
## Step 3: Upload via curl
```bash
curl -X POST http://192.168.8.128:9800/api/upload \
-F "apk=@/path/to/app-release.apk" \
-F "app_id=YOUR_APP_ID" \
-F "metadata=@/path/to/metadata.json"
```
**Expected response** (HTTP 200):
```json
{
"id": "YOUR_APP_ID",
"name": "App Display Name",
"package_name": "com.your.package",
"version_name": "1.0",
"version_code": "1",
"message": "App 'YOUR_APP_ID' uploaded successfully"
}
```
**Error responses:**
- `400` — Metadata JSON is missing required fields (`name`, `description`) or APK is invalid
- `409` — Same app_id + version_code already exists in the store (use a different app_id or bump version)
## Step 4: Verify
```bash
curl -s http://192.168.8.128:9800/api/apps | python3 -c "
import json, sys
data = json.load(sys.stdin)
for app in data['apps']:
if app['id'] == 'YOUR_APP_ID':
print('SUCCESS: Found', app['name'], '(v' + app['version_name'] + ')')
sys.exit(0)
print('FAILURE: App not found')
"
```
## Updating an Existing App
To push a new version of an existing app, use the **same `app_id`** but with updated `version_name` and `version_code` in the metadata JSON. The server will replace the APK if the new version_code is higher.
## Download the Uploaded APK (Optional)
After upload, the APK is also available for browser download at:
```
http://192.168.8.128:9800/dl/YOUR_APP_ID.apk
```

85
android/app/build.gradle Normal file
View File

@ -0,0 +1,85 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
id 'com.google.devtools.ksp'
}
android {
namespace 'com.localstore'
compileSdk 34
defaultConfig {
applicationId "com.localstore"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
}
signingConfigs {
release {
storeFile file("${System.getenv('HOME')}/playground/app-store/signing/localstore.keystore")
storePassword "localstore123"
keyAlias "localstore"
keyPassword "localstore123"
}
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
}
debug {
signingConfig signingConfigs.debug
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
buildFeatures {
viewBinding true
}
lint {
checkReleaseBuilds false
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
implementation 'androidx.constraintlayout:constraintlayout:2.1.4'
implementation 'androidx.recyclerview:recyclerview:1.3.2'
implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.1.0'
implementation 'androidx.coordinatorlayout:coordinatorlayout:1.2.0'
implementation 'androidx.lifecycle:lifecycle-viewmodel-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-runtime-ktx:2.8.4'
implementation 'androidx.lifecycle:lifecycle-livedata-ktx:2.8.4'
implementation 'androidx.navigation:navigation-fragment-ktx:2.8.0'
implementation 'androidx.navigation:navigation-ui-ktx:2.8.0'
implementation 'com.squareup.retrofit2:retrofit:2.11.0'
implementation 'com.squareup.retrofit2:converter-gson:2.11.0'
implementation 'com.squareup.okhttp3:logging-interceptor:4.12.0'
implementation 'io.coil-kt:coil:2.6.0'
implementation 'androidx.room:room-runtime:2.6.1'
implementation 'androidx.room:room-ktx:2.6.1'
ksp 'androidx.room:room-compiler:2.6.1'
implementation 'org.jetbrains.kotlinx:kotlinx-coroutines-android:1.8.1'
implementation 'androidx.datastore:datastore-preferences:1.1.1'
}

View File

@ -0,0 +1,32 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools">
<uses-permission android:name="android.permission.INTERNET" />
<uses-permission android:name="android.permission.ACCESS_NETWORK_STATE" />
<uses-permission android:name="android.permission.REQUEST_INSTALL_PACKAGES" />
<application
android:name=".LocalStoreApp"
android:allowBackup="true"
android:icon="@mipmap/ic_launcher"
android:label="@string/app_name"
android:networkSecurityConfig="@xml/network_security_config"
android:roundIcon="@mipmap/ic_launcher_round"
android:supportsRtl="true"
android:theme="@style/Theme.LocalAppStore"
tools:targetApi="31">
<activity
android:name=".ui.MainActivity"
android:exported="true"
android:theme="@style/Theme.LocalAppStore">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,15 @@
package com.localstore
import android.app.Application
import com.localstore.data.AppDatabase
class LocalStoreApp : Application() {
val database: AppDatabase by lazy {
AppDatabase.getInstance(this)
}
override fun onCreate() {
super.onCreate()
}
}

View File

@ -0,0 +1,40 @@
package com.localstore.data
import okhttp3.ResponseBody
import retrofit2.http.Body
import retrofit2.http.GET
import retrofit2.http.POST
import retrofit2.http.Path
import retrofit2.http.Query
interface AppApi {
@GET("api/apps")
suspend fun getApps(
@Query("search") search: String? = null,
@Query("offset") offset: Int = 0,
@Query("limit") limit: Int = 50
): AppListResponse
@GET("api/apps/{appId}")
suspend fun getApp(@Path("appId") appId: String): App
@GET("api/apps/{appId}/download")
suspend fun downloadApp(@Path("appId") appId: String): ResponseBody
@GET("api/apps/{appId}/icon")
suspend fun getAppIcon(@Path("appId") appId: String): ResponseBody
@POST("api/apps/update-check")
suspend fun checkForUpdate(@Body request: UpdateCheckRequest): UpdateCheckResponse
@POST("api/scan")
suspend fun triggerScan(): ScanResult
@GET("api/status")
suspend fun getServerStatus(): ServerStatus
}
data class ScanResult(
val scanned: Boolean,
val appsCount: Int
)

View File

@ -0,0 +1,186 @@
package com.localstore.data
import android.content.Context
import androidx.room.Dao
import androidx.room.Database
import androidx.room.Delete
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Room
import androidx.room.RoomDatabase
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import androidx.room.Update
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
import kotlinx.coroutines.flow.Flow
@Entity(tableName = "apps")
data class AppEntity(
@PrimaryKey val id: String,
val packageName: String,
val versionName: String,
val versionCode: String,
val name: String,
val description: String,
val icon: String?,
val size: Long,
val screenshotsJson: String = "[]",
val minSdk: String?,
val targetSdk: String?,
val permissionsJson: String = "[]"
)
data class App(
val id: String,
val packageName: String,
val versionName: String,
val versionCode: String,
val name: String,
val description: String,
val icon: String?,
val size: Long,
val screenshots: List<String> = emptyList(),
val minSdk: String?,
val targetSdk: String?,
val permissions: List<String> = emptyList()
)
fun App.toEntity(): AppEntity {
return AppEntity(
id = id,
packageName = packageName,
versionName = versionName,
versionCode = versionCode,
name = name,
description = description,
icon = icon,
size = size,
screenshotsJson = Gson().toJson(screenshots),
minSdk = minSdk,
targetSdk = targetSdk,
permissionsJson = Gson().toJson(permissions)
)
}
fun AppEntity.toApp(): App {
return App(
id = id,
packageName = packageName,
versionName = versionName,
versionCode = versionCode,
name = name,
description = description,
icon = icon,
size = size,
screenshots = try {
val type = object : TypeToken<List<String>>() {}.type
Gson().fromJson<List<String>>(screenshotsJson, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
},
minSdk = minSdk,
targetSdk = targetSdk,
permissions = try {
val type = object : TypeToken<List<String>>() {}.type
Gson().fromJson<List<String>>(permissionsJson, type) ?: emptyList()
} catch (e: Exception) {
emptyList()
}
)
}
data class AppListResponse(
val apps: List<App>,
val total: Int
)
data class UpdateCheckRequest(
val packageName: String,
val currentVersionCode: String
)
data class UpdateCheckResponse(
val hasUpdate: Boolean,
val app: App?,
val currentVersionCode: String,
val latestVersionCode: String?
)
data class ServerStatus(
val server: String,
val version: String,
val appsCount: Int
)
class AppTypeConverter {
private val gson = Gson()
@TypeConverter
fun fromStringList(value: String?): List<String> {
if (value.isNullOrEmpty()) return emptyList()
return try {
val type = object : TypeToken<List<String>>() {}.type
gson.fromJson<List<String>>(value, type)
} catch (e: Exception) {
emptyList()
}
}
@TypeConverter
fun toStringList(list: List<String>): String {
return gson.toJson(list)
}
}
@Dao
@TypeConverters(AppTypeConverter::class)
interface AppDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
suspend fun insertApps(apps: List<AppEntity>)
@Query("SELECT * FROM apps ORDER BY name ASC")
fun getAllApps(): Flow<List<AppEntity>>
@Query("SELECT * FROM apps WHERE id = :id")
suspend fun getAppById(id: String): AppEntity?
@Query("SELECT * FROM apps WHERE packageName = :packageName")
suspend fun getAppByPackage(packageName: String): AppEntity?
@Query("SELECT * FROM apps WHERE name LIKE :query OR description LIKE :query ORDER BY name ASC")
fun searchApps(query: String): Flow<List<AppEntity>>
@Delete
suspend fun deleteApp(app: AppEntity)
@Query("DELETE FROM apps")
suspend fun clearAll()
@Update
suspend fun updateApp(app: AppEntity)
}
@Database(entities = [AppEntity::class], version = 1, exportSchema = false)
@TypeConverters(AppTypeConverter::class)
abstract class AppDatabase : RoomDatabase() {
abstract fun appDao(): AppDao
companion object {
@Volatile
private var INSTANCE: AppDatabase? = null
fun getInstance(context: Context): AppDatabase {
return INSTANCE ?: synchronized(this) {
INSTANCE ?: Room.databaseBuilder(
context.applicationContext,
AppDatabase::class.java,
"app_store_database"
).build().also { INSTANCE = it }
}
}
}
}

View File

@ -0,0 +1,86 @@
package com.localstore.data
import android.content.Context
import com.localstore.LocalStoreApp
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.map
import okhttp3.OkHttpClient
import okhttp3.ResponseBody
import okhttp3.logging.HttpLoggingInterceptor
import retrofit2.Retrofit
import retrofit2.converter.gson.GsonConverterFactory
import java.util.concurrent.TimeUnit
class AppRepository(
private val context: Context,
defaultServerUrl: String = "http://192.168.8.128:9800"
) {
private val appDao = (context.applicationContext as LocalStoreApp).database.appDao()
@Volatile private var serverUrl = defaultServerUrl
@Volatile private var api: AppApi = createApi(defaultServerUrl)
private fun createApi(url: String): AppApi {
val logging = HttpLoggingInterceptor().apply {
level = HttpLoggingInterceptor.Level.BODY
}
val client = OkHttpClient.Builder()
.addInterceptor(logging)
.connectTimeout(10, TimeUnit.SECONDS)
.readTimeout(30, TimeUnit.SECONDS)
.build()
return Retrofit.Builder()
.baseUrl("$url/")
.client(client)
.addConverterFactory(GsonConverterFactory.create())
.build()
.create(AppApi::class.java)
}
fun setServerUrl(url: String) {
serverUrl = url
api = createApi(url)
}
fun getAppsFlow(): Flow<List<App>> = appDao.getAllApps().map { entities ->
entities.map { it.toApp() }
}
fun searchAppsFlow(query: String): Flow<List<App>> =
appDao.searchApps("%$query%").map { entities ->
entities.map { it.toApp() }
}
suspend fun refreshApps() {
val response = api.getApps()
appDao.clearAll()
appDao.insertApps(response.apps.map { it.toEntity() })
}
suspend fun getAppById(id: String): App? {
val cached = appDao.getAppById(id)?.toApp()
if (cached != null) return cached
return runCatching {
val response = api.getApp(id)
val entity = response.toEntity()
appDao.insertApps(listOf(entity))
response
}.getOrNull()
}
suspend fun getAppByPackage(packageName: String): App? {
return appDao.getAppByPackage(packageName)?.toApp()
}
suspend fun checkForUpdate(packageName: String, currentVersionCode: String): UpdateCheckResponse {
return api.checkForUpdate(UpdateCheckRequest(packageName, currentVersionCode))
}
suspend fun downloadApp(id: String): ResponseBody {
return api.downloadApp(id)
}
suspend fun getServerStatus(): ServerStatus {
return api.getServerStatus()
}
}

View File

@ -0,0 +1,80 @@
package com.localstore.data
import android.content.Context
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.stringPreferencesKey
import androidx.datastore.preferences.preferencesDataStore
import androidx.datastore.preferences.core.edit
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.catch
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.flow.map
import retrofit2.Retrofit
object PreferencesKeys {
val SERVER_ADDRESS = stringPreferencesKey("server_address")
val SERVER_PORT = stringPreferencesKey("server_port")
}
private val Context.dataStore by preferencesDataStore("settings")
class SettingsManager(private val context: Context) {
fun getServerAddressFlow(): Flow<String> =
context.dataStore.data
.catch { throw it }
.map { prefs ->
prefs[PreferencesKeys.SERVER_ADDRESS] ?: "192.168.8.128"
}
fun getServerPortFlow(): Flow<String> =
context.dataStore.data
.catch { throw it }
.map { prefs ->
prefs[PreferencesKeys.SERVER_PORT] ?: "9800"
}
fun getServerUrlFlow(): Flow<String> =
context.dataStore.data
.catch { throw it }
.map { prefs ->
val address = prefs[PreferencesKeys.SERVER_ADDRESS] ?: "192.168.8.128"
val port = prefs[PreferencesKeys.SERVER_PORT] ?: "9800"
"http://$address:$port"
}
suspend fun setServerAddress(address: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.SERVER_ADDRESS] = address
}
}
suspend fun setServerPort(port: String) {
context.dataStore.edit { prefs ->
prefs[PreferencesKeys.SERVER_PORT] = port
}
}
suspend fun testConnection(): Result<ServerStatus> {
val address = context.dataStore.data.catch { throw it }
.map { prefs -> prefs[PreferencesKeys.SERVER_ADDRESS] ?: "192.168.8.128" }
.first()
val port = context.dataStore.data.catch { throw it }
.map { prefs -> prefs[PreferencesKeys.SERVER_PORT] ?: "9800" }
.first()
return runCatching {
val client = okhttp3.OkHttpClient.Builder()
.connectTimeout(5, java.util.concurrent.TimeUnit.SECONDS)
.readTimeout(10, java.util.concurrent.TimeUnit.SECONDS)
.build()
val retrofit = Retrofit.Builder()
.baseUrl("http://$address:$port/")
.client(client)
.addConverterFactory(retrofit2.converter.gson.GsonConverterFactory.create())
.build()
val api = retrofit.create(AppApi::class.java)
api.getServerStatus()
}
}
}

View File

@ -0,0 +1,122 @@
package com.localstore.installer
import android.content.Context
import android.content.Intent
import android.content.pm.PackageInstaller
import android.content.pm.PackageManager
import android.net.Uri
import android.os.Build
import android.provider.Settings
import android.util.Log
import java.io.File
import java.io.InputStream
class AppInstaller(private val context: Context) {
private val packageManager: PackageManager = context.packageManager
fun isAppInstalled(packageName: String): Boolean {
return try {
packageManager.getPackageInfo(packageName, 0)
true
} catch (e: Exception) {
false
}
}
fun getInstalledPackages(): Set<String> {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageManager.getInstalledPackages(PackageManager.MATCH_ALL)
.map { it.packageName }
.toSet()
} else {
@Suppress("DEPRECATION")
packageManager.getInstalledPackages(0)
.map { it.packageName }
.toSet()
}
}
fun getPackageInfo(packageName: String): android.content.pm.PackageInfo? {
return try {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
packageManager.getPackageInfo(packageName, PackageManager.MATCH_ALL)
} else {
@Suppress("DEPRECATION")
packageManager.getPackageInfo(packageName, 0)
}
} catch (e: Exception) {
null
}
}
fun canInstall(): Boolean {
return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
packageManager.canRequestPackageInstalls()
} else {
true
}
}
fun openInstallPermissionSettings() {
val intent = Intent(Settings.ACTION_MANAGE_UNKNOWN_APP_SOURCES)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.data = Uri.parse("package:${context.packageName}")
context.startActivity(intent)
}
fun prepareInstall(inputStream: InputStream): File {
val tempFile = File(context.cacheDir, "download.apk")
tempFile.outputStream().use { outputStream ->
inputStream.copyTo(outputStream)
}
return tempFile
}
fun install(apkFile: File): Boolean {
if (!canInstall()) {
Log.e("AppInstaller", "Install permission not granted")
return false
}
val packageInstaller = packageManager.packageInstaller
val sessionParams = PackageInstaller.SessionParams(PackageInstaller.SessionParams.MODE_FULL_INSTALL)
val sessionId = packageInstaller.createSession(sessionParams)
packageInstaller.openSession(sessionId).use { session ->
val fis = java.io.FileInputStream(apkFile)
val buffer = ByteArray(65536)
var bytes: Int
session.openWrite("base", 0, -1).use { outputStream ->
while (fis.read(buffer).also { bytes = it } > 0) {
outputStream.write(buffer, 0, bytes)
session.fsync(outputStream)
}
}
fis.close()
val intent = Intent(context, com.localstore.ui.MainActivity::class.java)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_CLEAR_TOP
val pendingIntent = android.app.PendingIntent.getActivity(
context, 0, intent,
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.S)
android.app.PendingIntent.FLAG_IMMUTABLE or android.app.PendingIntent.FLAG_UPDATE_CURRENT
else
android.app.PendingIntent.FLAG_UPDATE_CURRENT
)
session.commit(pendingIntent.intentSender)
}
apkFile.delete()
return true
}
@Suppress("DEPRECATION")
fun installViaIntent(apkFile: File): Intent {
val intent = Intent(Intent.ACTION_INSTALL_PACKAGE)
intent.flags = Intent.FLAG_ACTIVITY_NEW_TASK
intent.data = Uri.fromFile(apkFile)
intent.putExtra(Intent.EXTRA_RETURN_RESULT, true)
return intent
}
}

View File

@ -0,0 +1,29 @@
package com.localstore.ui
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
import androidx.navigation.findNavController
import androidx.navigation.fragment.NavHostFragment
import androidx.navigation.ui.setupWithNavController
import com.localstore.R
import com.localstore.databinding.ActivityMainBinding
class MainActivity : AppCompatActivity() {
lateinit var binding: ActivityMainBinding
lateinit var navController: androidx.navigation.NavController
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
binding = ActivityMainBinding.inflate(layoutInflater)
setContentView(binding.root)
val navHostFragment = NavHostFragment.create(R.navigation.nav_graph)
supportFragmentManager.beginTransaction()
.replace(R.id.nav_host_container, navHostFragment)
.commitAllowingStateLoss()
navController = navHostFragment.navController
binding.bottomNav.setupWithNavController(navController)
}
}

View File

@ -0,0 +1,168 @@
package com.localstore.ui.details
import android.content.Context
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import coil.load
import com.localstore.R
import com.localstore.data.App
import com.localstore.databinding.FragmentDetailsBinding
import com.localstore.installer.AppInstaller
import kotlinx.coroutines.flow.first
import kotlinx.coroutines.launch
import java.text.DecimalFormat
class DetailsFragment : Fragment() {
private var _binding: FragmentDetailsBinding? = null
private val binding get() = _binding!!
private val viewModel: DetailsViewModel by viewModels {
androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory(requireActivity().application)
}
private val installer = AppInstaller(requireContext())
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentDetailsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
val appId = arguments?.getString(ARG_APP_ID) ?: run {
requireActivity().onBackPressedDispatcher.onBackPressed()
return
}
viewModel.loadApp(appId)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.app.collect { app ->
app?.let { bindApp(it) }
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.installState.collect { state ->
binding.installButton.apply {
when (state) {
is InstallState.Idle -> {
text = if (viewModel.isInstalled.value) requireContext().getString(R.string.installed) else requireContext().getString(R.string.install)
isEnabled = true
binding.progressBar.visibility = View.GONE
}
is InstallState.Downloading -> {
text = requireContext().getString(R.string.downloading)
isEnabled = false
binding.progressBar.visibility = View.VISIBLE
}
is InstallState.Installing -> {
text = requireContext().getString(R.string.installing)
isEnabled = false
binding.progressBar.visibility = View.VISIBLE
}
is InstallState.Success -> {
text = requireContext().getString(R.string.installed)
isEnabled = false
binding.progressBar.visibility = View.GONE
Toast.makeText(requireContext(), requireContext().getString(R.string.install_success), Toast.LENGTH_SHORT).show()
}
is InstallState.Failed -> {
text = requireContext().getString(R.string.install)
isEnabled = true
binding.progressBar.visibility = View.GONE
Toast.makeText(requireContext(), state.message, Toast.LENGTH_LONG).show()
}
}
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.isInstalled.collect { installed ->
if (!installed && viewModel.installState.value is InstallState.Idle) {
binding.installButton.text = requireContext().getString(R.string.install)
}
}
}
binding.installButton.setOnClickListener {
if (!installer.canInstall()) {
Toast.makeText(requireContext(), R.string.grant_install_permission, Toast.LENGTH_LONG).show()
installer.openInstallPermissionSettings()
return@setOnClickListener
}
viewModel.installApp()
}
binding.backButton.setOnClickListener {
requireActivity().onBackPressedDispatcher.onBackPressed()
}
}
private fun bindApp(app: App) {
val serverHost = viewModel.serverUrl.value
val host = serverHost.replace("http://", "").split(":").first()
binding.apply {
appName.text = app.name
appVersion.text = "Version: ${app.versionName}"
appPackage.text = app.packageName
val sizeStr = when {
app.size > 1_000_000 -> String.format("%.1f MB", app.size / 1_000_000.0)
app.size > 1_000 -> String.format("%.1f KB", app.size / 1_000.0)
else -> "${app.size} B"
}
appSize.text = "Size: $sizeStr"
appDescription.text = if (app.description.isNotBlank()) app.description else "No description available"
if (!app.icon.isNullOrBlank()) {
appIcon.load("http://$host:9800/api/apps/${app.id}/icon") {
crossfade(true)
placeholder(android.R.drawable.ic_dialog_info)
}
} else {
appIcon.setImageResource(android.R.drawable.ic_dialog_info)
}
if (app.permissions.isNotEmpty()) {
appPermissionsContainer.visibility = View.VISIBLE
val permissionsText = app.permissions.joinToString("\n") { "$it" }
appPermissionsList.text = permissionsText
} else {
appPermissionsContainer.visibility = View.GONE
}
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
companion object {
private const val ARG_APP_ID = "app_id"
fun newInstance(appId: String): DetailsFragment {
return DetailsFragment().apply {
arguments = Bundle().apply {
putString(ARG_APP_ID, appId)
}
}
}
}
}

View File

@ -0,0 +1,86 @@
package com.localstore.ui.details
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.localstore.data.App
import com.localstore.data.AppRepository
import com.localstore.data.SettingsManager
import com.localstore.installer.AppInstaller
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
sealed class InstallState {
object Idle : InstallState()
object Downloading : InstallState()
object Installing : InstallState()
data class Success(val app: App) : InstallState()
data class Failed(val message: String) : InstallState()
}
class DetailsViewModel(
private val context: Context
) : ViewModel() {
private val settingsManager = SettingsManager(context)
private val repository = AppRepository(context)
private val installer = AppInstaller(context)
val serverUrl = MutableStateFlow("http://192.168.8.128:9800")
init {
viewModelScope.launch {
settingsManager.getServerUrlFlow().collect { url ->
serverUrl.value = url
repository.setServerUrl(url)
}
}
}
val app = MutableStateFlow<App?>(null)
val installState = MutableStateFlow<InstallState>(InstallState.Idle)
val downloadProgress = MutableStateFlow(0)
val isInstalled = MutableStateFlow(false)
fun loadApp(appId: String) {
viewModelScope.launch {
try {
val loadedApp = repository.getAppById(appId)
app.value = loadedApp
isInstalled.value = installer.isAppInstalled(loadedApp?.packageName ?: "")
} catch (e: Exception) {
e.printStackTrace()
}
}
}
fun installApp() {
val currentApp = app.value ?: return
installState.value = InstallState.Downloading
downloadProgress.value = 0
viewModelScope.launch {
try {
val responseBody = repository.downloadApp(currentApp.id)
val tempFile = installer.prepareInstall(responseBody.byteStream())
installState.value = InstallState.Installing
val result = installer.install(tempFile)
if (result) {
installState.value = InstallState.Success(currentApp)
isInstalled.value = true
} else {
installState.value = InstallState.Failed("Installation failed")
}
} catch (e: Exception) {
installState.value = InstallState.Failed(e.message ?: "Unknown error")
}
}
}
fun resetInstallState() {
installState.value = InstallState.Idle
downloadProgress.value = 0
}
}

View File

@ -0,0 +1,71 @@
package com.localstore.ui.home
import android.content.Context
import android.view.LayoutInflater
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import com.localstore.data.App
import com.localstore.databinding.ItemAppBinding
class AppsAdapter(
private val onItemClick: (App) -> Unit
) : ListAdapter<App, AppsAdapter.AppViewHolder>(AppDiffCallback()) {
var serverHost: String = "192.168.8.128"
var serverPort: Int = 9800
var contextRef: Context? = null
fun setServerInfo(host: String, port: Int, ctx: Context) {
serverHost = host
serverPort = port
contextRef = ctx
}
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val binding = ItemAppBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return AppViewHolder(binding, serverHost, serverPort)
}
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(getItem(position), onItemClick)
}
class AppViewHolder(
private val binding: ItemAppBinding,
private val serverHost: String,
private val serverPort: Int
) : RecyclerView.ViewHolder(binding.root) {
fun bind(app: App, onItemClick: (App) -> Unit) {
binding.apply {
appName.text = app.name
appVersion.text = app.versionName
appIcon.apply {
if (!app.icon.isNullOrBlank()) {
load("http://$serverHost:$serverPort/api/apps/${app.id}/icon") {
crossfade(true)
placeholder(android.R.drawable.ic_dialog_info)
}
} else {
setImageResource(android.R.drawable.ic_dialog_info)
}
}
root.setOnClickListener { onItemClick(app) }
}
}
}
class AppDiffCallback : DiffUtil.ItemCallback<App>() {
override fun areItemsTheSame(oldItem: App, newItem: App) = oldItem.id == newItem.id
override fun areContentsTheSame(oldItem: App, newItem: App) = oldItem == newItem
}
}

View File

@ -0,0 +1,107 @@
package com.localstore.ui.home
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.GridLayoutManager
import com.localstore.data.App
import com.localstore.databinding.FragmentHomeBinding
import com.localstore.ui.MainActivity
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class HomeFragment : Fragment() {
private var _binding: FragmentHomeBinding? = null
private val binding get() = _binding!!
private val viewModel: HomeViewModel by viewModels {
androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory(requireActivity().application)
}
private lateinit var adapter: AppsAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentHomeBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = AppsAdapter { app ->
(requireActivity() as MainActivity).navController
.navigate(com.localstore.R.id.nav_details, Bundle().apply { putString("app_id", app.id) })
}
binding.recyclerView.apply {
layoutManager = GridLayoutManager(requireContext(), 3)
adapter = this@HomeFragment.adapter
}
binding.searchEditText.addTextChangedListener(object : android.text.TextWatcher {
override fun beforeTextChanged(s: CharSequence?, start: Int, count: Int, after: Int) {}
override fun onTextChanged(s: CharSequence?, start: Int, before: Int, count: Int) {
viewModel.setSearchQuery(s.toString())
}
override fun afterTextChanged(s: android.text.Editable?) {}
})
binding.swipeRefresh.setOnRefreshListener {
viewModel.refresh()
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.apps.collect { apps ->
adapter.submitList(apps)
binding.swipeRefresh.isRefreshing = false
updateEmptyState(apps)
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.searchResults.collect { apps ->
if (binding.searchEditText.text.isNotEmpty()) {
adapter.submitList(apps)
updateEmptyState(apps)
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.isLoading.collect { isLoading ->
binding.swipeRefresh.isRefreshing = isLoading
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.error.collect { error ->
error?.let {
Toast.makeText(requireContext(), it, Toast.LENGTH_LONG).show()
binding.swipeRefresh.isRefreshing = false
}
}
}
viewModel.refresh()
}
private fun updateEmptyState(apps: List<App>) {
binding.emptyState.visibility = if (apps.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (apps.isEmpty()) View.GONE else View.VISIBLE
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,61 @@
package com.localstore.ui.home
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.localstore.data.App
import com.localstore.data.AppRepository
import com.localstore.data.SettingsManager
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class HomeViewModel(
private val context: Context
) : ViewModel() {
private val settingsManager = SettingsManager(context)
private val repository = AppRepository(context)
init {
viewModelScope.launch {
settingsManager.getServerUrlFlow().collect { url ->
repository.setServerUrl(url)
}
}
}
val apps = repository.getAppsFlow()
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val searchQuery = MutableStateFlow("")
val searchResults = searchQuery
.combine(repository.getAppsFlow()) { query, allApps ->
if (query.isBlank()) allApps
else allApps.filter {
it.name.lowercase().contains(query.lowercase()) ||
it.description.lowercase().contains(query.lowercase())
}
}
.stateIn(viewModelScope, SharingStarted.Lazily, emptyList())
val isLoading = MutableStateFlow(false)
val error = MutableStateFlow<String?>(null)
fun refresh() {
isLoading.value = true
error.value = null
viewModelScope.launch {
try {
repository.refreshApps()
} catch (e: Exception) {
error.value = e.message ?: "Refresh failed"
} finally {
isLoading.value = false
}
}
}
fun setSearchQuery(query: String) {
searchQuery.value = query
}
}

View File

@ -0,0 +1,60 @@
package com.localstore.ui.myapps
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.recyclerview.widget.DiffUtil
import androidx.recyclerview.widget.ListAdapter
import androidx.recyclerview.widget.RecyclerView
import coil.load
import com.localstore.databinding.ItemInstalledAppBinding
class InstalledAppsAdapter(
private val onItemClick: (InstalledApp) -> Unit
) : ListAdapter<InstalledApp, InstalledAppsAdapter.AppViewHolder>(InstalledAppDiffCallback()) {
override fun onCreateViewHolder(parent: ViewGroup, viewType: Int): AppViewHolder {
val binding = ItemInstalledAppBinding.inflate(
LayoutInflater.from(parent.context),
parent,
false
)
return AppViewHolder(binding)
}
override fun onBindViewHolder(holder: AppViewHolder, position: Int) {
holder.bind(getItem(position), onItemClick)
}
class AppViewHolder(
private val binding: ItemInstalledAppBinding
) : RecyclerView.ViewHolder(binding.root) {
fun bind(app: InstalledApp, onItemClick: (InstalledApp) -> Unit) {
binding.apply {
appName.text = app.app.name
appVersion.text = "v${app.installedVersionName}"
if (!app.app.icon.isNullOrBlank()) {
appIcon.load("http://192.168.8.128:9800/api/apps/${app.app.id}/icon") {
crossfade(true)
placeholder(android.R.drawable.ic_dialog_info)
}
} else {
appIcon.setImageResource(android.R.drawable.ic_dialog_info)
}
updateBadge.visibility = if (app.hasUpdate) View.VISIBLE else View.GONE
root.setOnClickListener { onItemClick(app) }
}
}
}
class InstalledAppDiffCallback : DiffUtil.ItemCallback<InstalledApp>() {
override fun areItemsTheSame(oldItem: InstalledApp, newItem: InstalledApp) =
oldItem.app.id == newItem.app.id
override fun areContentsTheSame(oldItem: InstalledApp, newItem: InstalledApp) =
oldItem == newItem
}
}

View File

@ -0,0 +1,92 @@
package com.localstore.ui.myapps
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import androidx.recyclerview.widget.LinearLayoutManager
import com.localstore.databinding.FragmentMyAppsBinding
import com.localstore.ui.MainActivity
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class MyAppsFragment : Fragment() {
private var _binding: FragmentMyAppsBinding? = null
private val binding get() = _binding!!
private val viewModel: MyAppsViewModel by viewModels {
androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory(requireActivity().application)
}
private lateinit var adapter: InstalledAppsAdapter
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentMyAppsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
adapter = InstalledAppsAdapter { app ->
(requireActivity() as MainActivity).navController
.navigate(com.localstore.R.id.nav_details, Bundle().apply { putString("app_id", app.app.id) })
}
binding.recyclerView.apply {
layoutManager = LinearLayoutManager(requireContext())
adapter = this@MyAppsFragment.adapter
}
binding.checkUpdatesButton.setOnClickListener {
viewModel.checkForUpdates()
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.installedApps.collect { apps ->
adapter.submitList(apps)
updateEmptyState(apps)
val updateCount = apps.count { it.hasUpdate }
if (updateCount > 0) {
binding.updatesBadge.visibility = View.VISIBLE
binding.updatesBadge.text = updateCount.toString()
} else {
binding.updatesBadge.visibility = View.GONE
}
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.isLoading.collect { isLoading ->
binding.progressBar.visibility = if (isLoading) View.VISIBLE else View.GONE
binding.checkUpdatesButton.isEnabled = !isLoading
}
}
viewModel.loadInstalledApps()
}
private fun updateEmptyState(apps: List<InstalledApp>) {
binding.emptyState.visibility = if (apps.isEmpty()) View.VISIBLE else View.GONE
binding.recyclerView.visibility = if (apps.isEmpty()) View.GONE else View.VISIBLE
}
override fun onResume() {
super.onResume()
viewModel.loadInstalledApps()
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,89 @@
package com.localstore.ui.myapps
import android.content.Context
import android.content.pm.PackageInfo
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.localstore.data.App
import com.localstore.data.AppRepository
import com.localstore.data.SettingsManager
import com.localstore.installer.AppInstaller
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
data class InstalledApp(
val app: App,
val installedVersionCode: Int,
val installedVersionName: String,
val hasUpdate: Boolean = false
)
class MyAppsViewModel(
private val context: Context
) : ViewModel() {
private val settingsManager = SettingsManager(context)
private val repository = AppRepository(context)
private val installer = AppInstaller(context)
init {
viewModelScope.launch {
settingsManager.getServerUrlFlow().collect { url ->
repository.setServerUrl(url)
}
}
}
val installedApps = MutableStateFlow<List<InstalledApp>>(emptyList())
val isLoading = MutableStateFlow(false)
fun loadInstalledApps() {
isLoading.value = true
viewModelScope.launch {
try {
val repoApps = repository.getAppsFlow().first()
val installed = installer.getInstalledPackages()
val installedAppsList = repoApps.filter { repoApp ->
installed.contains(repoApp.packageName)
}.map { repoApp ->
val pkgInfo = installer.getPackageInfo(repoApp.packageName)
InstalledApp(
app = repoApp,
installedVersionCode = pkgInfo?.longVersionCode?.toInt() ?: 0,
installedVersionName = pkgInfo?.versionName ?: "unknown",
hasUpdate = false
)
}
installedApps.value = installedAppsList
} catch (e: Exception) {
e.printStackTrace()
} finally {
isLoading.value = false
}
}
}
fun checkForUpdates() {
isLoading.value = true
viewModelScope.launch {
val currentApps = installedApps.value.toMutableList()
for ((index, installedApp) in currentApps.withIndex()) {
try {
val response = repository.checkForUpdate(
installedApp.app.packageName,
installedApp.installedVersionCode.toString()
)
currentApps[index] = installedApp.copy(hasUpdate = response.hasUpdate)
installedApps.value = currentApps
} catch (e: Exception) {
e.printStackTrace()
}
}
isLoading.value = false
}
}
}

View File

@ -0,0 +1,124 @@
package com.localstore.ui.settings
import android.os.Bundle
import android.view.LayoutInflater
import android.view.View
import android.view.ViewGroup
import android.widget.Toast
import androidx.fragment.app.Fragment
import androidx.fragment.app.viewModels
import androidx.lifecycle.lifecycleScope
import com.localstore.R
import com.localstore.databinding.FragmentSettingsBinding
import kotlinx.coroutines.flow.collect
import kotlinx.coroutines.launch
class SettingsFragment : Fragment() {
private var _binding: FragmentSettingsBinding? = null
private val binding get() = _binding!!
private val viewModel: SettingsViewModel by viewModels {
androidx.lifecycle.ViewModelProvider.AndroidViewModelFactory(requireActivity().application)
}
override fun onCreateView(
inflater: LayoutInflater,
container: ViewGroup?,
savedInstanceState: Bundle?
): View {
_binding = FragmentSettingsBinding.inflate(inflater, container, false)
return binding.root
}
override fun onViewCreated(view: View, savedInstanceState: Bundle?) {
super.onViewCreated(view, savedInstanceState)
viewLifecycleOwner.lifecycleScope.launch {
viewModel.serverAddress.collect { address ->
binding.serverAddressEditText.setText(address)
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.serverPort.collect { port ->
binding.serverPortEditText.setText(port)
}
}
viewLifecycleOwner.lifecycleScope.launch {
viewModel.testResult.collect { result ->
when (result) {
is SettingsViewModel.TestResult.Idle -> {
binding.testButton.isEnabled = true
binding.testResultText.visibility = View.GONE
}
is SettingsViewModel.TestResult.Testing -> {
binding.testButton.isEnabled = false
binding.testResultText.visibility = View.VISIBLE
binding.testResultText.text = "${requireContext().getString(R.string.connection_test)}..."
binding.testResultText.setTextColor(requireContext().getColor(android.R.color.darker_gray))
}
is SettingsViewModel.TestResult.Success -> {
binding.testButton.isEnabled = true
binding.testResultText.visibility = View.VISIBLE
binding.testResultText.text = String.format(
requireContext().getString(R.string.connection_success),
result.status.appsCount
)
binding.testResultText.setTextColor(requireContext().getColor(R.color.primary))
}
is SettingsViewModel.TestResult.Failed -> {
binding.testButton.isEnabled = true
binding.testResultText.visibility = View.VISIBLE
binding.testResultText.text = result.message
binding.testResultText.setTextColor(requireContext().getColor(R.color.error))
}
null -> {
binding.testButton.isEnabled = true
binding.testResultText.visibility = View.GONE
}
}
}
}
binding.saveButton.setOnClickListener {
val address = binding.serverAddressEditText.text.toString().trim()
val port = binding.serverPortEditText.text.toString().trim()
if (address.isEmpty()) {
Toast.makeText(requireContext(), "Server address is required", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
viewModel.setServerAddress(address)
if (port.isNotEmpty()) {
viewModel.setServerPort(port)
}
Toast.makeText(requireContext(), "Settings saved", Toast.LENGTH_SHORT).show()
}
binding.testButton.setOnClickListener {
val address = binding.serverAddressEditText.text.toString().trim()
val port = binding.serverPortEditText.text.toString().trim()
if (address.isEmpty()) {
Toast.makeText(requireContext(), "Enter server address first", Toast.LENGTH_SHORT).show()
return@setOnClickListener
}
viewModel.setServerAddress(address)
if (port.isNotEmpty()) {
viewModel.setServerPort(port)
}
viewModel.testConnection()
}
}
override fun onDestroyView() {
super.onDestroyView()
_binding = null
}
}

View File

@ -0,0 +1,59 @@
package com.localstore.ui.settings
import android.content.Context
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import com.localstore.data.AppApi
import com.localstore.data.ServerStatus
import com.localstore.data.SettingsManager
import kotlinx.coroutines.flow.*
import kotlinx.coroutines.launch
class SettingsViewModel(
private val context: Context
) : ViewModel() {
private val settingsManager = SettingsManager(context)
val serverAddress = settingsManager.getServerAddressFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, "192.168.8.128")
val serverPort = settingsManager.getServerPortFlow()
.stateIn(viewModelScope, SharingStarted.Eagerly, "9800")
val serverUrl = settingsManager.getServerUrlFlow()
.stateIn(viewModelScope, SharingStarted.Lazily, "")
val testResult = MutableStateFlow<TestResult?>(null)
sealed class TestResult {
object Idle : TestResult()
object Testing : TestResult()
data class Success(val status: ServerStatus) : TestResult()
data class Failed(val message: String) : TestResult()
}
fun setServerAddress(address: String) {
viewModelScope.launch {
settingsManager.setServerAddress(address)
}
}
fun setServerPort(port: String) {
viewModelScope.launch {
settingsManager.setServerPort(port)
}
}
fun testConnection() {
testResult.value = TestResult.Testing
viewModelScope.launch {
val result = settingsManager.testConnection()
testResult.value = if (result.isSuccess) {
TestResult.Success(result.getOrNull()!!)
} else {
TestResult.Failed(result.exceptionOrNull()?.message ?: "Connection failed")
}
}
}
}

View File

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="utf-8"?>
<shape xmlns:android="http://schemas.android.com/apk/res/android"
android:shape="rectangle">
<corners android:radius="12dp" />
<solid android:color="@color/accent" />
</shape>

View File

@ -0,0 +1,16 @@
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M60,54 L54,48 L54,60 L66,60 Z" />
<path
android:fillColor="#FFFFFF"
android:pathData="M36,30 L72,30 L72,36 L36,36 Z" />
<path
android:pathData="M36,36 L36,78 L72,78 L72,36"
android:strokeWidth="4"
android:strokeColor="#FFFFFF" />
</vector>

View File

@ -0,0 +1,26 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<FrameLayout
android:id="@+id/nav_host_container"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1" />
<com.google.android.material.bottomnavigation.BottomNavigationView
android:id="@+id/bottom_nav"
android:layout_width="match_parent"
android:layout_height="wrap_content"
app:menu="@menu/bottom_nav_menu" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,160 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<ScrollView
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<ImageButton
android:id="@+id/backButton"
android:layout_width="40dp"
android:layout_height="40dp"
android:src="@android:drawable/ic_menu_revert"
android:background="?attr/selectableItemBackgroundBorderless"
android:contentDescription="Back" />
<ImageView
android:id="@+id/appIcon"
android:layout_width="80dp"
android:layout_height="80dp"
android:layout_gravity="center_horizontal"
android:layout_marginTop="16dp"
android:scaleType="centerInside"
android:contentDescription="App icon" />
<TextView
android:id="@+id/appName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textSize="22sp"
android:textStyle="bold"
android:gravity="center"
android:textColor="?android:textColorPrimary" />
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center"
android:layout_marginTop="4dp">
<TextView
android:id="@+id/appVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="14sp"
android:textColor="?android:textColorSecondary" />
<TextView
android:id="@+id/appSize"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginStart="16dp"
android:textSize="14sp"
android:textColor="?android:textColorSecondary" />
</LinearLayout>
<TextView
android:id="@+id/appPackage"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="12sp"
android:gravity="center"
android:textColor="?android:textColorSecondary" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/description"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?android:textColorPrimary" />
<TextView
android:id="@+id/appDescription"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="4dp"
android:textSize="14sp"
android:lineSpacingExtra="4dp"
android:textColor="?android:textColorSecondary" />
<com.google.android.material.divider.MaterialDivider
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="16dp"
android:layout_marginBottom="16dp" />
<FrameLayout
android:id="@+id/appPermissionsContainer"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:visibility="gone">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/permissions"
android:textSize="14sp"
android:textStyle="bold"
android:textColor="?android:textColorPrimary" />
<TextView
android:id="@+id/appPermissionsList"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="24dp"
android:textSize="13sp"
android:lineSpacingExtra="4dp"
android:textColor="?android:textColorSecondary" />
</FrameLayout>
</LinearLayout>
</ScrollView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_gravity="bottom"
android:orientation="vertical"
android:padding="16dp"
android:background="?android:windowBackground">
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/installButton"
android:layout_width="match_parent"
android:layout_height="56dp"
android:text="@string/install"
app:cornerRadius="28dp" />
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,62 @@
<?xml version="1.0" encoding="utf-8"?>
<androidx.coordinatorlayout.widget.CoordinatorLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="match_parent">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<com.google.android.material.search.SearchBar
android:id="@+id/searchBar"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:padding="8dp">
<EditText
android:id="@+id/searchEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/search_apps"
android:inputType="text"
android:background="@null"
android:padding="8dp" />
</com.google.android.material.search.SearchBar>
<androidx.swiperefreshlayout.widget.SwipeRefreshLayout
android:id="@+id/swipeRefresh"
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<FrameLayout
android:layout_width="match_parent"
android:layout_height="match_parent">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
<TextView
android:id="@+id/emptyState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_apps_found"
android:textSize="16sp"
android:textColor="?android:textColorSecondary"
android:visibility="gone" />
</FrameLayout>
</androidx.swiperefreshlayout.widget.SwipeRefreshLayout>
</LinearLayout>
</androidx.coordinatorlayout.widget.CoordinatorLayout>

View File

@ -0,0 +1,74 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:orientation="vertical">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="16dp"
android:gravity="center_vertical">
<TextView
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:text="@string/nav_my_apps"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="?android:textColorPrimary" />
<TextView
android:id="@+id/updatesBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:padding="6dp"
android:textColor="@android:color/white"
android:textSize="12sp"
android:visibility="gone" />
<com.google.android.material.button.MaterialButton
android:id="@+id/checkUpdatesButton"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/check_for_updates"
style="@style/Widget.Material3.Button.TextButton" />
</LinearLayout>
<ProgressBar
android:id="@+id/progressBar"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:visibility="gone" />
<FrameLayout
android:layout_width="match_parent"
android:layout_height="0dp"
android:layout_weight="1">
<androidx.recyclerview.widget.RecyclerView
android:id="@+id/recyclerView"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="8dp" />
<TextView
android:id="@+id/emptyState"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_gravity="center"
android:text="@string/no_installed_apps"
android:textSize="16sp"
android:textColor="?android:textColorSecondary"
android:gravity="center"
android:visibility="gone" />
</FrameLayout>
</LinearLayout>

View File

@ -0,0 +1,105 @@
<?xml version="1.0" encoding="utf-8"?>
<ScrollView xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:clipToPadding="false"
android:padding="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_settings"
android:textSize="20sp"
android:textStyle="bold"
android:textColor="?android:textColorPrimary"
android:layout_marginBottom="24dp" />
<com.google.android.material.card.MaterialCardView
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginBottom="16dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="16dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_address"
android:textSize="14sp"
android:textColor="?android:textColorSecondary" />
<EditText
android:id="@+id/serverAddressEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_address_hint"
android:inputType="textNoSuggestions"
android:padding="12dp"
android:textSize="16sp" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/server_port"
android:textSize="14sp"
android:textColor="?android:textColorSecondary"
android:layout_marginTop="16dp" />
<EditText
android:id="@+id/serverPortEditText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:hint="@string/server_port_hint"
android:inputType="number"
android:padding="12dp"
android:textSize="16sp" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:gravity="center_vertical">
<com.google.android.material.button.MaterialButton
android:id="@+id/saveButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:layout_marginEnd="8dp"
android:text="@string/save" />
<com.google.android.material.button.MaterialButton
android:id="@+id/testButton"
android:layout_width="0dp"
android:layout_height="48dp"
android:layout_weight="1"
android:layout_marginStart="8dp"
android:text="@string/connection_test"
style="@style/Widget.Material3.Button.OutlinedButton" />
</LinearLayout>
<TextView
android:id="@+id/testResultText"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="12dp"
android:textSize="14sp"
android:visibility="gone" />
</LinearLayout>
</ScrollView>

View File

@ -0,0 +1,47 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="12dp"
app:cardElevation="2dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="vertical"
android:padding="12dp"
android:gravity="center_horizontal">
<ImageView
android:id="@+id/appIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerInside"
android:contentDescription="App icon" />
<TextView
android:id="@+id/appName"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_marginTop="8dp"
android:textSize="13sp"
android:textStyle="bold"
android:maxLines="2"
android:ellipsize="end"
android:gravity="center"
android:textColor="?android:textColorPrimary" />
<TextView
android:id="@+id/appVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="11sp"
android:textColor="?android:textColorSecondary" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,64 @@
<?xml version="1.0" encoding="utf-8"?>
<com.google.android.material.card.MaterialCardView xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:layout_margin="4dp"
app:cardCornerRadius="8dp"
app:cardElevation="1dp"
app:strokeWidth="0dp">
<LinearLayout
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:orientation="horizontal"
android:padding="12dp"
android:gravity="center_vertical">
<ImageView
android:id="@+id/appIcon"
android:layout_width="48dp"
android:layout_height="48dp"
android:scaleType="centerInside"
android:contentDescription="App icon" />
<LinearLayout
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_weight="1"
android:layout_marginStart="12dp"
android:orientation="vertical">
<TextView
android:id="@+id/appName"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:textSize="15sp"
android:textStyle="bold"
android:textColor="?android:textColorPrimary" />
<TextView
android:id="@+id/appVersion"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_marginTop="2dp"
android:textSize="12sp"
android:textColor="?android:textColorSecondary" />
</LinearLayout>
<TextView
android:id="@+id/updateBadge"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:background="@drawable/badge_background"
android:paddingHorizontal="8dp"
android:paddingVertical="4dp"
android:text="@string/updating"
android:textColor="@android:color/white"
android:textSize="11sp"
android:visibility="gone" />
</LinearLayout>
</com.google.android.material.card.MaterialCardView>

View File

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/nav_home"
android:icon="@android:drawable/ic_menu_gallery"
android:title="@string/nav_home" />
<item
android:id="@+id/nav_my_apps"
android:icon="@android:drawable/ic_menu_myplaces"
android:title="@string/nav_my_apps" />
<item
android:id="@+id/nav_settings"
android:icon="@android:drawable/ic_menu_preferences"
android:title="@string/nav_settings" />
</menu>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/primary" />
<foreground android:drawable="@drawable/ic_launcher_foreground" />
</adaptive-icon>

View File

@ -0,0 +1,31 @@
<?xml version="1.0" encoding="utf-8"?>
<navigation xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto"
android:id="@+id/nav_graph"
app:startDestination="@id/nav_home">
<fragment
android:id="@+id/nav_home"
android:name="com.localstore.ui.home.HomeFragment"
android:label="@string/nav_home" />
<fragment
android:id="@+id/nav_my_apps"
android:name="com.localstore.ui.myapps.MyAppsFragment"
android:label="@string/nav_my_apps" />
<fragment
android:id="@+id/nav_settings"
android:name="com.localstore.ui.settings.SettingsFragment"
android:label="@string/nav_settings" />
<fragment
android:id="@+id/nav_details"
android:name="com.localstore.ui.details.DetailsFragment"
android:label="App Details">
<argument
android:name="app_id"
app:argType="string" />
</fragment>
</navigation>

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="primary">#1B96FF</color>
<color name="primary_dark">#006EC4</color>
<color name="primary_light">#73BFFF</color>
<color name="accent">#FF4081</color>
<color name="background">#FAFAFA</color>
<color name="surface">#FFFFFF</color>
<color name="on_primary">#FFFFFF</color>
<color name="on_background">#1C1C1C</color>
<color name="on_surface">#1C1C1C</color>
<color name="error">#B00020</color>
</resources>

View File

@ -0,0 +1,53 @@
<resources>
<string name="app_name">Local App Store</string>
<string name="default_server_host">192.168.8.128</string>
<!-- Navigation -->
<string name="nav_home">Apps</string>
<string name="nav_my_apps">My Apps</string>
<string name="nav_settings">Settings</string>
<!-- Home Screen -->
<string name="search_apps">Search apps...</string>
<string name="no_apps_found">No apps found</string>
<string name="pull_to_refresh">Pull to refresh</string>
<!-- App Details -->
<string name="install">Install</string>
<string name="updating">Update</string>
<string name="installed">Installed</string>
<string name="downloading">Downloading...</string>
<string name="installing">Installing...</string>
<string name="description">Description</string>
<string name="version">Version</string>
<string name="size">Size</string>
<string name="package_name">Package</string>
<string name="permissions">Permissions</string>
<!-- My Apps -->
<string name="no_installed_apps">No apps installed from this store</string>
<string name="check_for_updates">Check for updates</string>
<string name="updates_available">Updates available</string>
<string name="up_to_date">All apps up to date</string>
<!-- Settings -->
<string name="server_settings">Server Settings</string>
<string name="server_address">Server Address</string>
<string name="server_address_hint">e.g. 192.168.8.128</string>
<string name="server_port">Server Port</string>
<string name="server_port_hint">e.g. 9800</string>
<string name="connection_test">Test Connection</string>
<string name="connection_success">Connected! %d apps available.</string>
<string name="connection_failed">Connection failed. Check server address.</string>
<string name="save">Save</string>
<!-- Install Results -->
<string name="install_success">App installed successfully</string>
<string name="install_failed">Installation failed: %s</string>
<string name="download_failed">Download failed: %s</string>
<!-- Errors -->
<string name="error_network">Network error. Check your connection.</string>
<string name="error_unknown">An unexpected error occurred.</string>
<string name="grant_install_permission">Please grant "Install unknown apps" permission in Settings.</string>
</resources>

View File

@ -0,0 +1,11 @@
<resources>
<style name="Theme.LocalAppStore" parent="Theme.Material3.Light.NoActionBar">
<item name="colorPrimary">@color/primary</item>
<item name="colorPrimaryDark">@color/primary_dark</item>
<item name="colorAccent">@color/accent</item>
<item name="android:colorBackground">@color/background</item>
<item name="colorOnPrimary">@color/on_primary</item>
<item name="colorOnBackground">@color/on_background</item>
<item name="colorOnSurface">@color/on_surface</item>
</style>
</resources>

View File

@ -0,0 +1,9 @@
<?xml version="1.0" encoding="utf-8"?>
<network-security-config>
<domain-config cleartextTrafficPermitted="true">
<domain includeSubdomains="true">192.168.8.128</domain>
<domain includeSubdomains="true">192.168.</domain>
<domain includeSubdomains="true">10.0.</domain>
<domain includeSubdomains="true">localhost</domain>
</domain-config>
</network-security-config>

9
android/build.gradle Normal file
View File

@ -0,0 +1,9 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
id 'com.google.devtools.ksp' version '1.9.23-1.0.20' apply false
}
tasks.register("clean", Delete) {
delete rootProject.buildDir
}

View File

@ -0,0 +1,4 @@
org.gradle.jvmargs=-Xmx2048m -Dfile.encoding=UTF-8
android.useAndroidX=true
kotlin.code.style=official
android.nonTransitiveRClass=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
android/gradlew vendored Executable file
View File

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

92
android/gradlew.bat vendored Normal file
View File

@ -0,0 +1,92 @@
@rem
@rem Copyright 2015 the original author or authors.
@rem
@rem Licensed under the Apache License, Version 2.0 (the "License");
@rem you may not use this file except in compliance with the License.
@rem You may obtain a copy of the License at
@rem
@rem https://www.apache.org/licenses/LICENSE-2.0
@rem
@rem Unless required by applicable law or agreed to in writing, software
@rem distributed under the License is distributed on an "AS IS" BASIS,
@rem WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
@rem See the License for the specific language governing permissions and
@rem limitations under the License.
@rem
@if "%DEBUG%"=="" @echo off
@rem ##########################################################################
@rem
@rem Gradle startup script for Windows
@rem
@rem ##########################################################################
@rem Set local scope for the variables with windows NT shell
if "%OS%"=="Windows_NT" setlocal
set DIRNAME=%~dp0
if "%DIRNAME%"=="" set DIRNAME=.
@rem This is normally unused
set APP_BASE_NAME=%~n0
set APP_HOME=%DIRNAME%
@rem Resolve any "." and ".." in APP_HOME to make it shorter.
for %%i in ("%APP_HOME%") do set APP_HOME=%%~fi
@rem Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
set DEFAULT_JVM_OPTS="-Xmx64m" "-Xms64m"
@rem Find java.exe
if defined JAVA_HOME goto findJavaFromJavaHome
set JAVA_EXE=java.exe
%JAVA_EXE% -version >NUL 2>&1
if %ERRORLEVEL% equ 0 goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH. 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:findJavaFromJavaHome
set JAVA_HOME=%JAVA_HOME:"=%
set JAVA_EXE=%JAVA_HOME%/bin/java.exe
if exist "%JAVA_EXE%" goto execute
echo. 1>&2
echo ERROR: JAVA_HOME is set to an invalid directory: %JAVA_HOME% 1>&2
echo. 1>&2
echo Please set the JAVA_HOME variable in your environment to match the 1>&2
echo location of your Java installation. 1>&2
goto fail
:execute
@rem Setup the command line
set CLASSPATH=%APP_HOME%\gradle\wrapper\gradle-wrapper.jar
@rem Execute Gradle
"%JAVA_EXE%" %DEFAULT_JVM_OPTS% %JAVA_OPTS% %GRADLE_OPTS% "-Dorg.gradle.appname=%APP_BASE_NAME%" -classpath "%CLASSPATH%" org.gradle.wrapper.GradleWrapperMain %*
:end
@rem End local scope for the variables with windows NT shell
if %ERRORLEVEL% equ 0 goto mainEnd
:fail
rem Set variable GRADLE_EXIT_CONSOLE if you need the _script_ return code instead of
rem the _cmd.exe /c_ return code!
set EXIT_CODE=%ERRORLEVEL%
if %EXIT_CODE% equ 0 set EXIT_CODE=1
if not ""=="%GRADLE_EXIT_CONSOLE%" exit %EXIT_CODE%
exit /b %EXIT_CODE%
:mainEnd
if "%OS%"=="Windows_NT" endlocal
:omega

18
android/settings.gradle Normal file
View File

@ -0,0 +1,18 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositoriesMode.set(RepositoriesMode.FAIL_ON_PROJECT_REPOS)
repositories {
google()
mavenCentral()
}
}
rootProject.name = "LocalAppStore"
include ':app'

View File

@ -0,0 +1,53 @@
plugins {
id 'com.android.application'
id 'org.jetbrains.kotlin.android'
}
repositories {
google()
mavenCentral()
}
android {
namespace 'com.helloworld'
compileSdk 34
defaultConfig {
applicationId "com.helloworld"
minSdk 26
targetSdk 34
versionCode 1
versionName "1.0"
}
signingConfigs {
release {
storeFile file("${System.getenv('HOME')}/playground/app-store/signing/localstore.keystore")
storePassword "localstore123"
keyAlias "localstore"
keyPassword "localstore123"
}
}
buildTypes {
release {
minifyEnabled false
signingConfig signingConfigs.release
}
}
compileOptions {
sourceCompatibility JavaVersion.VERSION_17
targetCompatibility JavaVersion.VERSION_17
}
kotlinOptions {
jvmTarget = "17"
}
}
dependencies {
implementation 'androidx.core:core-ktx:1.13.1'
implementation 'androidx.appcompat:appcompat:1.7.0'
implementation 'com.google.android.material:material:1.12.0'
}

View File

@ -0,0 +1,22 @@
<?xml version="1.0" encoding="utf-8"?>
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<application
android:allowBackup="true"
android:icon="@drawable/ic_launcher_foreground"
android:label="@string/app_name"
android:supportsRtl="true"
android:theme="@style/Theme.AppCompat.Light.DarkActionBar">
<activity
android:name=".MainActivity"
android:exported="true">
<intent-filter>
<action android:name="android.intent.action.MAIN" />
<category android:name="android.intent.category.LAUNCHER" />
</intent-filter>
</activity>
</application>
</manifest>

View File

@ -0,0 +1,12 @@
package com.helloworld
import android.os.Bundle
import androidx.appcompat.app.AppCompatActivity
class MainActivity : AppCompatActivity() {
override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
setContentView(R.layout.activity_main)
}
}

View File

@ -0,0 +1,13 @@
<?xml version="1.0" encoding="utf-8"?>
<vector xmlns:android="http://schemas.android.com/apk/res/android"
android:width="108dp"
android:height="108dp"
android:viewportWidth="108"
android:viewportHeight="108">
<path
android:fillColor="#FFFFFF"
android:pathData="M54,30 C65,30 74,39 74,50 C74,61 65,70 54,70 C43,70 34,61 34,50 C34,39 43,30 54,30Z"/>
<path
android:fillColor="#1B96FF"
android:pathData="M54,40 C59,40 63,44 63,49 C63,54 59,58 54,58 C49,58 45,54 45,49 C45,44 49,40 54,40Z"/>
</vector>

View File

@ -0,0 +1,27 @@
<?xml version="1.0" encoding="utf-8"?>
<LinearLayout xmlns:android="http://schemas.android.com/apk/res/android"
android:layout_width="match_parent"
android:layout_height="match_parent"
android:gravity="center"
android:orientation="vertical"
android:padding="32dp">
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="@string/hello_world"
android:textSize="28sp"
android:textColor="#1B96FF"
android:textStyle="bold"
android:gravity="center" />
<TextView
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:text="from Local App Store"
android:textSize="16sp"
android:textColor="#666666"
android:layout_marginTop="8dp"
android:gravity="center" />
</LinearLayout>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<adaptive-icon xmlns:android="http://schemas.android.com/apk/res/android">
<background android:drawable="@color/ic_launcher_background"/>
<foreground android:drawable="@drawable/ic_launcher_foreground"/>
</adaptive-icon>

View File

@ -0,0 +1,4 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<color name="ic_launcher_background">#1B96FF</color>
</resources>

View File

@ -0,0 +1,5 @@
<?xml version="1.0" encoding="utf-8"?>
<resources>
<string name="app_name">Hello World</string>
<string name="hello_world">Hello World!</string>
</resources>

4
helloworld/build.gradle Normal file
View File

@ -0,0 +1,4 @@
plugins {
id 'com.android.application' version '8.5.2' apply false
id 'org.jetbrains.kotlin.android' version '1.9.23' apply false
}

View File

@ -0,0 +1 @@
android.useAndroidX=true

Binary file not shown.

View File

@ -0,0 +1,7 @@
distributionBase=GRADLE_USER_HOME
distributionPath=wrapper/dists
distributionUrl=https\://services.gradle.org/distributions/gradle-8.7-bin.zip
networkTimeout=10000
validateDistributionUrl=true
zipStoreBase=GRADLE_USER_HOME
zipStorePath=wrapper/dists

249
helloworld/gradlew vendored Executable file
View File

@ -0,0 +1,249 @@
#!/bin/sh
#
# Copyright © 2015-2021 the original authors.
#
# Licensed under the Apache License, Version 2.0 (the "License");
# you may not use this file except in compliance with the License.
# You may obtain a copy of the License at
#
# https://www.apache.org/licenses/LICENSE-2.0
#
# Unless required by applicable law or agreed to in writing, software
# distributed under the License is distributed on an "AS IS" BASIS,
# WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied.
# See the License for the specific language governing permissions and
# limitations under the License.
#
##############################################################################
#
# Gradle start up script for POSIX generated by Gradle.
#
# Important for running:
#
# (1) You need a POSIX-compliant shell to run this script. If your /bin/sh is
# noncompliant, but you have some other compliant shell such as ksh or
# bash, then to run this script, type that shell name before the whole
# command line, like:
#
# ksh Gradle
#
# Busybox and similar reduced shells will NOT work, because this script
# requires all of these POSIX shell features:
# * functions;
# * expansions «$var», «${var}», «${var:-default}», «${var+SET}»,
# «${var#prefix}», «${var%suffix}», and «$( cmd )»;
# * compound commands having a testable exit status, especially «case»;
# * various built-in commands including «command», «set», and «ulimit».
#
# Important for patching:
#
# (2) This script targets any POSIX shell, so it avoids extensions provided
# by Bash, Ksh, etc; in particular arrays are avoided.
#
# The "traditional" practice of packing multiple parameters into a
# space-separated string is a well documented source of bugs and security
# problems, so this is (mostly) avoided, by progressively accumulating
# options in "$@", and eventually passing that to Java.
#
# Where the inherited environment variables (DEFAULT_JVM_OPTS, JAVA_OPTS,
# and GRADLE_OPTS) rely on word-splitting, this is performed explicitly;
# see the in-line comments for details.
#
# There are tweaks for specific operating systems such as AIX, CygWin,
# Darwin, MinGW, and NonStop.
#
# (3) This script is generated from the Groovy template
# https://github.com/gradle/gradle/blob/HEAD/subprojects/plugins/src/main/resources/org/gradle/api/internal/plugins/unixStartScript.txt
# within the Gradle project.
#
# You can find Gradle at https://github.com/gradle/gradle/.
#
##############################################################################
# Attempt to set APP_HOME
# Resolve links: $0 may be a link
app_path=$0
# Need this for daisy-chained symlinks.
while
APP_HOME=${app_path%"${app_path##*/}"} # leaves a trailing /; empty if no leading path
[ -h "$app_path" ]
do
ls=$( ls -ld "$app_path" )
link=${ls#*' -> '}
case $link in #(
/*) app_path=$link ;; #(
*) app_path=$APP_HOME$link ;;
esac
done
# This is normally unused
# shellcheck disable=SC2034
APP_BASE_NAME=${0##*/}
# Discard cd standard output in case $CDPATH is set (https://github.com/gradle/gradle/issues/25036)
APP_HOME=$( cd "${APP_HOME:-./}" > /dev/null && pwd -P ) || exit
# Use the maximum available, or set MAX_FD != -1 to use that value.
MAX_FD=maximum
warn () {
echo "$*"
} >&2
die () {
echo
echo "$*"
echo
exit 1
} >&2
# OS specific support (must be 'true' or 'false').
cygwin=false
msys=false
darwin=false
nonstop=false
case "$( uname )" in #(
CYGWIN* ) cygwin=true ;; #(
Darwin* ) darwin=true ;; #(
MSYS* | MINGW* ) msys=true ;; #(
NONSTOP* ) nonstop=true ;;
esac
CLASSPATH=$APP_HOME/gradle/wrapper/gradle-wrapper.jar
# Determine the Java command to use to start the JVM.
if [ -n "$JAVA_HOME" ] ; then
if [ -x "$JAVA_HOME/jre/sh/java" ] ; then
# IBM's JDK on AIX uses strange locations for the executables
JAVACMD=$JAVA_HOME/jre/sh/java
else
JAVACMD=$JAVA_HOME/bin/java
fi
if [ ! -x "$JAVACMD" ] ; then
die "ERROR: JAVA_HOME is set to an invalid directory: $JAVA_HOME
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
else
JAVACMD=java
if ! command -v java >/dev/null 2>&1
then
die "ERROR: JAVA_HOME is not set and no 'java' command could be found in your PATH.
Please set the JAVA_HOME variable in your environment to match the
location of your Java installation."
fi
fi
# Increase the maximum file descriptors if we can.
if ! "$cygwin" && ! "$darwin" && ! "$nonstop" ; then
case $MAX_FD in #(
max*)
# In POSIX sh, ulimit -H is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
MAX_FD=$( ulimit -H -n ) ||
warn "Could not query maximum file descriptor limit"
esac
case $MAX_FD in #(
'' | soft) :;; #(
*)
# In POSIX sh, ulimit -n is undefined. That's why the result is checked to see if it worked.
# shellcheck disable=SC2039,SC3045
ulimit -n "$MAX_FD" ||
warn "Could not set maximum file descriptor limit to $MAX_FD"
esac
fi
# Collect all arguments for the java command, stacking in reverse order:
# * args from the command line
# * the main class name
# * -classpath
# * -D...appname settings
# * --module-path (only if needed)
# * DEFAULT_JVM_OPTS, JAVA_OPTS, and GRADLE_OPTS environment variables.
# For Cygwin or MSYS, switch paths to Windows format before running java
if "$cygwin" || "$msys" ; then
APP_HOME=$( cygpath --path --mixed "$APP_HOME" )
CLASSPATH=$( cygpath --path --mixed "$CLASSPATH" )
JAVACMD=$( cygpath --unix "$JAVACMD" )
# Now convert the arguments - kludge to limit ourselves to /bin/sh
for arg do
if
case $arg in #(
-*) false ;; # don't mess with options #(
/?*) t=${arg#/} t=/${t%%/*} # looks like a POSIX filepath
[ -e "$t" ] ;; #(
*) false ;;
esac
then
arg=$( cygpath --path --ignore --mixed "$arg" )
fi
# Roll the args list around exactly as many times as the number of
# args, so each arg winds up back in the position where it started, but
# possibly modified.
#
# NB: a `for` loop captures its iteration list before it begins, so
# changing the positional parameters here affects neither the number of
# iterations, nor the values presented in `arg`.
shift # remove old arg
set -- "$@" "$arg" # push replacement arg
done
fi
# Add default JVM options here. You can also use JAVA_OPTS and GRADLE_OPTS to pass JVM options to this script.
DEFAULT_JVM_OPTS='"-Xmx64m" "-Xms64m"'
# Collect all arguments for the java command:
# * DEFAULT_JVM_OPTS, JAVA_OPTS, JAVA_OPTS, and optsEnvironmentVar are not allowed to contain shell fragments,
# and any embedded shellness will be escaped.
# * For example: A user cannot expect ${Hostname} to be expanded, as it is an environment variable and will be
# treated as '${Hostname}' itself on the command line.
set -- \
"-Dorg.gradle.appname=$APP_BASE_NAME" \
-classpath "$CLASSPATH" \
org.gradle.wrapper.GradleWrapperMain \
"$@"
# Stop when "xargs" is not available.
if ! command -v xargs >/dev/null 2>&1
then
die "xargs is not available"
fi
# Use "xargs" to parse quoted args.
#
# With -n1 it outputs one arg per line, with the quotes and backslashes removed.
#
# In Bash we could simply go:
#
# readarray ARGS < <( xargs -n1 <<<"$var" ) &&
# set -- "${ARGS[@]}" "$@"
#
# but POSIX shell has neither arrays nor command substitution, so instead we
# post-process each arg (as a line of input to sed) to backslash-escape any
# character that might be a shell metacharacter, then use eval to reverse
# that process (while maintaining the separation between arguments), and wrap
# the whole thing up as a single "set" statement.
#
# This will of course break if any of these variables contains a newline or
# an unmatched quote.
#
eval "set -- $(
printf '%s\n' "$DEFAULT_JVM_OPTS $JAVA_OPTS $GRADLE_OPTS" |
xargs -n1 |
sed ' s~[^-[:alnum:]+,./:=@_]~\\&~g; ' |
tr '\n' ' '
)" '"$@"'
exec "$JAVACMD" "$@"

View File

@ -0,0 +1,17 @@
pluginManagement {
repositories {
google()
mavenCentral()
gradlePluginPortal()
}
}
dependencyResolutionManagement {
repositories {
google()
mavenCentral()
}
}
rootProject.name = "HelloWorld"
include ':app'

38
server/app.py Normal file
View File

@ -0,0 +1,38 @@
from pydantic import BaseModel
from typing import Optional, List
class AppMetadata(BaseModel):
"""Represents a single app in the repository."""
id: str
package_name: str
version_name: str
version_code: str
name: str
description: str = ""
icon: Optional[str] = None
size: int = 0
screenshots: List[str] = []
min_sdk: Optional[str] = None
target_sdk: Optional[str] = None
permissions: List[str] = []
class AppListResponse(BaseModel):
"""Response for listing apps."""
apps: List[AppMetadata]
total: int
class UpdateCheckRequest(BaseModel):
"""Request to check for updates."""
package_name: str
current_version_code: str
class UpdateCheckResponse(BaseModel):
"""Response for update check."""
has_update: bool
app: Optional[AppMetadata] = None
current_version_code: str
latest_version_code: Optional[str] = None

7
server/config.yaml Normal file
View File

@ -0,0 +1,7 @@
server:
host: "0.0.0.0"
port: 8080
repository:
# Directory where APKs are stored
path: "./repos"

372
server/main.py Normal file
View File

@ -0,0 +1,372 @@
import os
import re
import json
import yaml
import zipfile
import hashlib
import shutil
from pathlib import Path
from typing import Optional
from fastapi import FastAPI, HTTPException, Query, UploadFile, File, Form
from fastapi.responses import FileResponse, Response, JSONResponse, HTMLResponse
from fastapi.middleware.cors import CORSMiddleware
from fastapi.staticfiles import StaticFiles
from app import AppMetadata, AppListResponse, UpdateCheckRequest, UpdateCheckResponse
from scanner import scan_repository, parse_manifest
# Load config
config_path = Path(__file__).parent / "config.yaml"
with open(config_path) as f:
config = yaml.safe_load(f)
REPO_PATH = Path(__file__).parent / config["repository"]["path"]
HOST = config["server"]["host"]
PORT = config["server"]["port"]
# Ensure repos directory exists
REPO_PATH.mkdir(parents=True, exist_ok=True)
app = FastAPI(title="Local App Store", version="1.0.0")
app.add_middleware(
CORSMiddleware,
allow_origins=["*"],
allow_methods=["*"],
allow_headers=["*"],
)
# Serve web frontend
web_dir = Path(__file__).parent / "web"
if web_dir.exists():
app.mount("/static", StaticFiles(directory=str(web_dir)), name="static")
@app.get("/", response_class=HTMLResponse)
def serve_web():
"""Serve the web frontend."""
index_path = web_dir / "index.html"
if index_path.exists():
return HTMLResponse(content=index_path.read_text())
return HTMLResponse(content="<h1>Local App Store</h1><p>Web frontend not found.</p>", status_code=503)
# Serve pre-built APKs for easy download
downloads_dir = Path(__file__).parent / "downloads"
downloads_dir.mkdir(exist_ok=True)
app.mount("/dl", StaticFiles(directory=str(downloads_dir)), name="downloads")
# Cache of scanned apps
_apps_cache: list = []
_cache_valid = False
def get_apps() -> list:
"""Get list of apps, scanning if cache is invalid."""
global _apps_cache, _cache_valid
if not _cache_valid:
_apps_cache = scan_repository(str(REPO_PATH))
_cache_valid = True
print(f"Repository scanned: {_apps_cache.__len__()} apps found")
return _apps_cache
def invalidate_cache():
"""Invalidate the apps cache."""
global _cache_valid
_cache_valid = False
def find_app(app_id: str) -> Optional[dict]:
"""Find app by ID."""
for app in get_apps():
if app["id"] == app_id:
return app
return None
def find_app_by_package(package_name: str) -> Optional[dict]:
"""Find app by package name."""
for app in get_apps():
if app["package_name"] == package_name:
return app
return None
@app.get("/api/apps")
def list_apps(search: Optional[str] = Query(None), offset: int = 0, limit: int = 50):
"""List all apps in the repository."""
apps = get_apps()
if search:
search_lower = search.lower()
apps = [
a for a in apps
if search_lower in a["name"].lower() or search_lower in a["description"].lower()
]
total = len(apps)
paginated = apps[offset:offset + limit]
return AppListResponse(
apps=[AppMetadata(**a) for a in paginated],
total=total,
)
@app.get("/api/apps/{app_id}")
def get_app(app_id: str):
"""Get details for a specific app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
return AppMetadata(**app_data)
@app.get("/api/apps/{app_id}/download")
def download_app(app_id: str):
"""Download the APK file for an app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
apk_path = Path(app_data["file_path"])
if not apk_path.exists():
raise HTTPException(status_code=404, detail="APK file not found")
# Generate ETag from file content hash
file_stat = apk_path.stat()
etag = hashlib.md5(
f"{apk_path.name}:{file_stat.st_size}:{file_stat.st_mtime}".encode()
).hexdigest()
return FileResponse(
path=str(apk_path),
media_type="application/vnd.android.package-archive",
filename=f"{app_data['package_name']}_{app_data['version_name']}.apk",
headers={
"Content-Disposition": f"attachment; filename={app_data['package_name']}_{app_data['version_name']}.apk",
"ETag": etag,
"X-App-Package": app_data["package_name"],
"X-App-Version": app_data["version_name"],
"X-App-VersionCode": app_data["version_code"],
},
)
@app.get("/api/apps/{app_id}/icon")
def get_app_icon(app_id: str):
"""Get the icon for an app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
if not app_data["icon"]:
raise HTTPException(status_code=404, detail="No icon available for this app")
apk_path = Path(app_data["file_path"])
if not apk_path.exists():
raise HTTPException(status_code=404, detail="APK file not found")
try:
import zipfile
from io import BytesIO
from PIL import Image
with zipfile.ZipFile(apk_path) as z:
if app_data["icon"] not in z.namelist():
raise HTTPException(status_code=404, detail="Icon resource not found in APK")
icon_data = z.read(app_data["icon"])
# Try to process as image (for proper format/size)
img = Image.open(BytesIO(icon_data))
img = img.resize((128, 128), Image.Resampling.LANCZOS)
output = BytesIO()
img.save(output, format="PNG")
return Response(
content=output.getvalue(),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
except Exception as e:
# If icon processing fails, return raw data
try:
with zipfile.ZipFile(apk_path) as z:
icon_data = z.read(app_data["icon"])
return Response(
content=icon_data,
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
except Exception:
raise HTTPException(status_code=500, detail=f"Error reading icon: {e}")
@app.get("/api/apps/{app_id}/screenshots")
def get_app_screenshots(app_id: str):
"""Get screenshots for an app."""
app_data = find_app(app_id)
if not app_data:
raise HTTPException(status_code=404, detail=f"App '{app_id}' not found")
screenshots_dir = REPO_PATH / f"{app_id}_screenshots"
if not screenshots_dir.exists():
return {"screenshots": []}
screenshots = []
for img_file in sorted(screenshots_dir.glob("*.png")):
screenshots.append({
"name": img_file.stem,
"url": f"/api/apps/{app_id}/screenshots/{img_file.name}",
})
return {"screenshots": screenshots}
@app.get("/api/apps/{app_id}/screenshots/{filename}")
def get_screenshot(app_id: str, filename: str):
"""Get a specific screenshot."""
screenshots_dir = REPO_PATH / f"{app_id}_screenshots"
screenshot_path = screenshots_dir / filename
if not screenshot_path.exists():
raise HTTPException(status_code=404, detail="Screenshot not found")
return FileResponse(
path=str(screenshot_path),
media_type="image/png",
headers={"Cache-Control": "public, max-age=86400"},
)
@app.post("/api/apps/update-check")
def check_for_updates(request: UpdateCheckRequest):
"""Check if an installed app has an available update."""
app_data = find_app_by_package(request.package_name)
if not app_data:
return UpdateCheckResponse(
has_update=False,
current_version_code=request.current_version_code,
)
current = int(request.current_version_code)
latest = int(app_data["version_code"])
return UpdateCheckResponse(
has_update=latest > current,
app=AppMetadata(**app_data) if latest > current else None,
current_version_code=request.current_version_code,
latest_version_code=app_data["version_code"],
)
def _sanitize_app_id(name: str) -> str:
"""Derive a safe app_id from filename or provided name."""
base = Path(name).stem
sanitized = re.sub(r'[^a-z0-9\-]', '-', base.lower())
sanitized = re.sub(r'-+', '-', sanitized).strip('-')
return sanitized or "app"
@app.post("/api/upload")
async def upload_app(
apk: UploadFile = File(...),
metadata: Optional[UploadFile] = File(None),
app_id: Optional[str] = Form(None),
):
"""Upload an APK to the repository.
- **apk**: The APK file (required)
- **app_id**: Short lowercase ID for the app (optional, derived from filename)
- **metadata**: JSON metadata file (optional, required for name/description)
"""
if not apk.filename or not apk.filename.endswith(".apk"):
raise HTTPException(400, "File must be an APK")
target_id = app_id or _sanitize_app_id(apk.filename)
apk_target = REPO_PATH / f"{target_id}.apk"
# Check for duplicate
if apk_target.exists():
existing = parse_manifest(str(apk_target))
new = parse_manifest(str(apk.filename))
if existing.get("version_code") == new.get("version_code"):
raise HTTPException(409, f"App '{target_id}' already exists with same version")
# Save APK
apk_target.write_bytes(await apk.read())
# Save metadata JSON if provided
if metadata and metadata.filename and metadata.filename.endswith(".json"):
json_bytes = await metadata.read()
metadata_json = json.loads(json_bytes)
required_fields = ("name", "description")
missing = [f for f in required_fields if f not in metadata_json]
if missing:
apk_target.unlink(missing_ok=True)
raise HTTPException(400, f"Metadata JSON requires: {', '.join(missing)}")
json_target = REPO_PATH / f"{target_id}.json"
json_target.write_text(json.dumps(metadata_json, indent=2))
# Handle screenshots if included in metadata
screenshots = metadata_json.get("screenshots", [])
if screenshots:
ss_dir = REPO_PATH / f"{target_id}_screenshots"
ss_dir.mkdir(exist_ok=True)
# Copy to downloads directory
dl_target = downloads_dir / f"{target_id}.apk"
shutil.copy2(str(apk_target), str(dl_target))
# Rescan
invalidate_cache()
apps = get_apps()
uploaded = find_app(target_id)
if not uploaded:
apk_target.unlink(missing_ok=True)
dl_target.unlink(missing_ok=True)
json_target_exists = REPO_PATH / f"{target_id}.json"
if json_target_exists.exists():
json_target_exists.unlink()
raise HTTPException(500, "Upload succeeded but app not found after scan")
return {
"id": uploaded["id"],
"name": uploaded["name"],
"package_name": uploaded["package_name"],
"version_name": uploaded["version_name"],
"version_code": uploaded["version_code"],
"message": f"App '{target_id}' uploaded successfully",
}
@app.post("/api/scan")
def trigger_scan():
"""Manually trigger repository scan."""
invalidate_cache()
apps = get_apps()
return {"scanned": True, "apps_count": len(apps)}
@app.get("/api/status")
def server_status():
"""Get server status information."""
apps = get_apps()
return {
"server": "Local App Store",
"version": "1.0.0",
"apps_count": len(apps),
"repo_path": str(REPO_PATH),
}
if __name__ == "__main__":
import uvicorn
# Initial scan on startup
get_apps()
uvicorn.run(app, host=HOST, port=PORT)

5
server/requirements.txt Normal file
View File

@ -0,0 +1,5 @@
fastapi==0.115.0
uvicorn==0.32.0
pydantic==2.10.0
pyyaml==6.0.2
Pillow==11.0.0

231
server/scanner.py Normal file
View File

@ -0,0 +1,231 @@
import os
import re
import zipfile
import xml.etree.ElementTree as ET
from typing import Dict, List, Optional
from pathlib import Path
def parse_manifest(apk_path: str) -> Dict:
"""Extract metadata from an APK by reading AndroidManifest.xml directly."""
metadata = {
"package_name": None,
"version_name": None,
"version_code": None,
"label": None,
"icon": None,
"min_sdk": None,
"target_sdk": None,
"permissions": [],
}
try:
with zipfile.ZipFile(apk_path) as z:
# Check for compiled manifest (binary XML)
if "AndroidManifest.xml" in z.namelist():
# Binary XML - try to extract what we can
data = z.read("AndroidManifest.xml")
metadata = _parse_binary_manifest(data, metadata)
else:
# No manifest found
return metadata
# Extract package name from filename if not found in manifest
if not metadata["package_name"]:
apk_name = Path(apk_path).stem
metadata["package_name"] = apk_name
# Look for icon resource
icon_res = _find_icon_resource(apk_path)
if icon_res:
metadata["icon"] = icon_res
except Exception as e:
print(f"Error parsing {apk_path}: {e}")
return metadata
def _parse_binary_manifest(data: bytes, metadata: Dict) -> Dict:
"""Parse binary XML manifest. Android uses a proprietary binary XML format."""
try:
# Try to decode string table - the binary XML format has a header
# then a string table, then keypool, then resources
# This is a simplified parser that extracts common fields
# Check if it's binary XML (starts with XML header magic)
if len(data) < 20:
return metadata
# Binary XML header magic: 0x00080003
if data[0:4] == b'\x03\x00\x08\x00' or data[0:4] == b'\x00\x08\x00\x03':
# Try to extract strings from the string table
strings = _extract_strings(data)
# Look for package attribute
for s in strings:
if s and "." in s and not s.startswith("android"):
# Likely a package name
if re.match(r'^[a-zA-Z][a-zA-Z0-9_.]*$', s) and len(s.split('.')) >= 2:
metadata["package_name"] = s
break
# Look for versionName
for s in strings:
if s and re.match(r'^\d+\.\d+', s):
metadata["version_name"] = s
break
# Look for versionCode (numeric)
for s in strings:
if s and re.match(r'^\d{1,10}$', s) and len(s) < 10:
# Could be version code or other number
if metadata["version_code"] is None:
metadata["version_code"] = s
break
# Look for app label - usually contains spaces or title-case words
for s in strings:
if s and len(s) > 1 and len(s) < 50 and not s.startswith("android"):
if any(c.isupper() for c in s) or " " in s:
if s != metadata["package_name"]:
metadata["label"] = s
break
except Exception as e:
print(f"Error parsing binary manifest: {e}")
return metadata
def _extract_strings(data: bytes) -> List[str]:
"""Extract string table from binary XML."""
strings = []
try:
# Binary XML header structure:
# 4 bytes: type (0x0008)
# 4 bytes: reserved
# 4 bytes: header size
# 4 bytes: end of strings offset
# 4 bytes: start of string data offset
if len(data) < 20:
return strings
header_size = int.from_bytes(data[8:12], 'little')
strings_end = int.from_bytes(data[12:16], 'little')
strings_start = int.from_bytes(data[16:20], 'little')
if strings_end <= strings_start or strings_start < header_size:
return strings
# String count is at offset header_size (right after header)
str_count_offset = header_size
if str_count_offset + 4 > len(data):
return strings
str_count = int.from_bytes(data[str_count_offset:str_count_offset + 4], 'little')
# Index table starts after count
index_offset = str_count_offset + 4
# Extract string indices
indices = []
for i in range(min(str_count, 1000)): # limit to avoid issues
idx_off = index_offset + (i * 4)
if idx_off + 4 > len(data):
break
idx = int.from_bytes(data[idx_off:idx_off + 4], 'little')
indices.append(idx)
# Extract strings using indices
for idx in indices:
str_offset = strings_start + idx
if str_offset + 4 > len(data):
continue
# String length is stored as 2 bytes (UTF-16 length)
utf16_len = int.from_bytes(data[str_offset:str_offset + 2], 'little')
# Then 2 bytes for UTF-8 length
utf8_len = int.from_bytes(data[str_offset + 2:str_offset + 4], 'little')
if utf8_len == 0 or utf8_len > 500:
continue
str_data_start = str_offset + 4
if str_data_start + utf8_len > len(data):
continue
try:
s = data[str_data_start:str_data_start + utf8_len].decode('utf-8', errors='ignore')
if s:
strings.append(s)
except Exception:
continue
except Exception as e:
print(f"Error extracting strings: {e}")
return strings
def _find_icon_resource(apk_path: str) -> Optional[str]:
"""Find icon resource path in APK."""
try:
with zipfile.ZipFile(apk_path) as z:
names = z.namelist()
# Look for icon in res/drawable-* folders
for name in names:
if "ic_launcher" in name and name.endswith(".png"):
return name
if "icon" in name and name.endswith(".png"):
return name
except Exception:
pass
return None
def scan_repository(repo_path: str) -> List[Dict]:
"""Scan repository directory for APKs and return metadata."""
apps = []
repo_dir = Path(repo_path)
if not repo_dir.exists():
return apps
for apk_file in sorted(repo_dir.glob("*.apk")):
metadata = parse_manifest(str(apk_file))
if not metadata["package_name"]:
continue
app = {
"id": apk_file.stem,
"package_name": metadata["package_name"],
"version_name": metadata["version_name"] or "unknown",
"version_code": metadata["version_code"] or "0",
"name": metadata["label"] or apk_file.stem,
"description": "",
"icon": metadata["icon"],
"size": apk_file.stat().st_size,
"file_path": str(apk_file),
"min_sdk": metadata["min_sdk"],
"target_sdk": metadata["target_sdk"],
"permissions": metadata["permissions"],
}
# Check for README or info file alongside APK
info_file = repo_dir / f"{apk_file.stem}.json"
if info_file.exists():
import json
with open(info_file) as f:
info = json.load(f)
for key in ("name", "description", "icon", "package_name",
"version_name", "version_code", "min_sdk",
"target_sdk", "permissions", "screenshots"):
if key in info:
app[key] = info[key]
apps.append(app)
return apps

204
server/web/index.html Normal file
View File

@ -0,0 +1,204 @@
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="UTF-8">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Local App Store</title>
<style>
*{margin:0;padding:0;box-sizing:border-box}
:root{--primary:#1B96FF;--primary-dark:#006EC4;--bg:#FAFAFA;--surface:#FFF;--text:#1C1C1C;--text-secondary:#666;--border:#E0E0E0;--shadow:0 2px 8px rgba(0,0,0,.1)}
body{font-family:-apple-system,BlinkMacSystemFont,'Segoe UI',Roboto,sans-serif;background:var(--bg);color:var(--text)}
header{background:var(--primary);color:#fff;padding:16px 24px;position:sticky;top:0;z-index:100;box-shadow:var(--shadow)}
header h1{font-size:20px;font-weight:600;display:flex;align-items:center;gap:10px}
header h1 svg{width:28px;height:28px}
.header-right{margin-left:auto;display:flex;align-items:center;gap:16px}
.status{font-size:13px;opacity:.8;display:flex;align-items:center;gap:6px}
.status-dot{width:8px;height:8px;border-radius:50%;background:#4CAF50;animation:pulse 2s infinite}
@keyframes pulse{0%,100%{opacity:1}50%{opacity:.4}}
.search-wrap{display:flex;gap:10px;max-width:500px}
.search-wrap input{flex:1;padding:10px 14px;border:none;border-radius:8px;font-size:14px;outline:none}
.search-wrap button{padding:10px 16px;border:none;border-radius:8px;background:var(--primary-dark);color:#fff;cursor:pointer;font-size:14px;font-weight:500}
.search-wrap button:hover{opacity:.9}
.container{max-width:1100px;margin:0 auto;padding:24px}
.grid{display:grid;grid-template-columns:repeat(auto-fill,minmax(200px,1fr));gap:16px}
.card{background:var(--surface);border-radius:12px;padding:20px;cursor:pointer;transition:transform .2s,box-shadow .2s;box-shadow:var(--shadow);display:flex;flex-direction:column;align-items:center;text-align:center}
.card:hover{transform:translateY(-2px);box-shadow:0 4px 16px rgba(0,0,0,.15)}
.card-icon{width:72px;height:72px;border-radius:16px;object-fit:contain;margin-bottom:12px;background:#f0f0f0}
.card-name{font-size:15px;font-weight:600;margin-bottom:4px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis;max-width:100%}
.card-version{font-size:12px;color:var(--text-secondary)}
.card-size{font-size:11px;color:var(--text-secondary);margin-top:2px}
.empty{text-align:center;padding:60px 20px;color:var(--text-secondary)}
.empty svg{width:64px;height:64px;margin-bottom:16px;opacity:.3}
.empty h2{font-size:18px;margin-bottom:8px;color:var(--text)}
.empty p{font-size:14px}
.loader{text-align:center;padding:40px}
.loader span{display:inline-block;width:10px;height:10px;border-radius:50%;background:var(--primary);animation:bounce .6s infinite alternate}
.loader span:nth-child(2){animation-delay:.2s}
.loader span:nth-child(3){animation-delay:.4s}
@keyframes bounce{to{transform:translateY(-12px)}}
.back-btn{display:inline-flex;align-items:center;gap:4px;padding:8px 16px;border:none;border-radius:8px;background:var(--surface);color:var(--text);cursor:pointer;font-size:14px;margin-bottom:20px;box-shadow:var(--shadow)}
.back-btn:hover{background:#f0f0f0}
.detail{background:var(--surface);border-radius:16px;padding:32px;box-shadow:var(--shadow);max-width:700px;margin:0 auto}
.detail-header{display:flex;gap:24px;align-items:flex-start;margin-bottom:24px}
.detail-icon{width:96px;height:96px;border-radius:20px;object-fit:contain;background:#f0f0f0;flex-shrink:0}
.detail-title{font-size:24px;font-weight:700;margin-bottom:4px}
.detail-meta{font-size:14px;color:var(--text-secondary);line-height:1.8}
.detail-desc{margin:20px 0;padding:16px;background:#f8f9fa;border-radius:10px;font-size:14px;line-height:1.7;white-space:pre-wrap}
.permissions{margin:20px 0}
.permissions h3{font-size:14px;font-weight:600;margin-bottom:10px;color:var(--text-secondary)}
.permissions ul{list-style:none;display:flex;flex-wrap:wrap;gap:6px}
.permissions li{padding:4px 12px;background:#e8f0fe;border-radius:20px;font-size:12px;color:var(--primary-dark)}
.screenshots{margin:24px 0}
.screenshots h3{font-size:14px;font-weight:600;margin-bottom:12px;color:var(--text-secondary)}
.screenshots-scroll{display:flex;gap:12px;overflow-x:auto;padding-bottom:8px}
.screenshots-scroll img{height:160px;border-radius:8px;object-fit:cover}
.btn{display:inline-flex;align-items:center;justify-content:center;gap:8px;padding:12px 32px;border:none;border-radius:10px;font-size:15px;font-weight:600;cursor:pointer;transition:background .2s}
.btn-primary{background:var(--primary);color:#fff}
.btn-primary:hover{background:var(--primary-dark)}
.btn-secondary{background:#f0f0f0;color:var(--text)}
.btn-secondary:hover{background:#e0e0e0}
.actions{display:flex;gap:12px;margin-top:24px}
.toast{position:fixed;bottom:24px;right:24px;padding:12px 20px;border-radius:10px;color:#fff;font-size:14px;box-shadow:0 4px 20px rgba(0,0,0,.2);transform:translateY(100px);opacity:0;transition:all .3s;z-index:1000}
.toast.show{transform:translateY(0);opacity:1}
.toast.success{background:#4CAF50}
.toast.error{background:#f44336}
.scan-btn{padding:6px 12px;border:1px solid rgba(255,255,255,.4);border-radius:6px;background:transparent;color:#fff;cursor:pointer;font-size:12px}
.scan-btn:hover{background:rgba(255,255,255,.15)}
@media(max-width:600px){.grid{grid-template-columns:repeat(2,1fr)}.detail-header{flex-direction:column;align-items:center;text-align:center}.header-right{flex-direction:column;gap:8px}.search-wrap{width:100%}}
</style>
</head>
<body>
<header>
<h1>
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2"><rect x="3" y="3" width="7" height="7" rx="1"/><rect x="14" y="3" width="7" height="7" rx="1"/><rect x="3" y="14" width="7" height="7" rx="1"/><rect x="14" y="14" width="7" height="7" rx="1"/></svg>
Local App Store
</h1>
<div class="header-right">
<div class="search-wrap">
<input type="text" id="searchInput" placeholder="Search apps..." />
<button onclick="doSearch()">Search</button>
</div>
<div class="status"><span class="status-dot"></span><span id="statusText">Connected</span></div>
<button class="scan-btn" onclick="doScan()">Scan repos</button>
</div>
</header>
<div class="container">
<div id="homeView">
<div class="grid" id="appGrid"></div>
<div class="empty" id="emptyState" style="display:none">
<svg viewBox="0 0 24 24" fill="currentColor"><path d="M6 2h12v20H6V2zm5 5v4h4V7h-4zm-2 0v4h2V7H9zm6 8v4h4v-4h-4zm-6 0v4h4v-4H9z"/></svg>
<h2>No apps found</h2>
<p>Add APKs to the repos/ folder and click "Scan repos"</p>
</div>
<div class="loader" id="loader" style="display:none"><span></span><span></span><span></span></div>
</div>
<div id="detailView" style="display:none"></div>
</div>
<div class="toast" id="toast"></div>
<script>
const API='/api';
let allApps=[];
let currentApp=null;
document.getElementById('searchInput').addEventListener('keydown',e=>{if(e.key==='Enter')doSearch()});
async function fetchJSON(url){const r=await fetch(url);if(!r.ok)throw new Error(`${r.status} ${r.statusText}`);return r.json()}
function showToast(msg,type='success'){const t=document.getElementById('toast');t.textContent=msg;t.className=`toast ${type} show`;setTimeout(()=>t.classList.remove('show'),3000)}
function formatSize(bytes){if(!bytes)return'N/A';if(bytes>1e6)return(bytes/1e6).toFixed(1)+' MB';if(bytes>1e3)return(bytes/1e3).toFixed(1)+' KB';return bytes+' B'}
function renderIcon(app){return`${API}/apps/${app.id}/icon`}
async function loadApps(){
document.getElementById('loader').style.display='block';
document.getElementById('appGrid').style.display='none';
document.getElementById('emptyState').style.display='none';
try{
const data=await fetchJSON(`${API}/apps?limit=200`);
allApps=data.apps||[];
renderHome(allApps);
}catch(e){
showToast('Failed to load apps: '+e.message,'error');
document.getElementById('statusText').textContent='Disconnected';
}finally{
document.getElementById('loader').style.display='none';
}
}
function renderHome(apps){
const grid=document.getElementById('appGrid');
const empty=document.getElementById('emptyState');
if(apps.length===0){grid.style.display='none';empty.style.display='block';return}
grid.style.display='grid';empty.style.display='none';
grid.innerHTML=apps.map(app=>`<div class="card" onclick="showDetail('${app.id}')">
<img class="card-icon" src="${renderIcon(app)}" onerror="this.src='data:image/svg+xml,<svg xmlns=%22http://www.w3.org/2000/svg%22 viewBox=%220 0 100 100%22><rect fill=%22%23e0e0e0%22 width=%22100%22 height=%22100%22/><text x=%2250%22 y=%2255%22 text-anchor=%22middle%22 fill=%22%23999%22 font-size=%2240%22>${app.name[0]}</text></svg>'" />
<div class="card-name">${app.name}</div>
<div class="card-version">v${app.versionName}</div>
<div class="card-size">${formatSize(app.size)}</div>
</div>`).join('');
}
async function showDetail(id){
currentApp=allApps.find(a=>a.id===id);
if(!currentApp){try{currentApp=await fetchJSON(`${API}/apps/${id}`)}catch(e){showToast('App not found','error');return}}
document.getElementById('homeView').style.display='none';
const dv=document.getElementById('detailView');
dv.style.display='block';
const app=currentApp;
let html=`<button class="back-btn" onclick="goBack()">&#8592; Back</button>`;
html+=`<div class="detail">
<div class="detail-header">
<img class="detail-icon" src="${renderIcon(app)}" onerror="this.style.display='none'" />
<div>
<div class="detail-title">${app.name}</div>
<div class="detail-meta">
Version: ${app.versionName} (${app.versionCode})<br/>
Package: ${app.packageName}<br/>
Size: ${formatSize(app.size)}
${app.minSdk?`<br/>Min SDK: ${app.minSdk}`:''}
${app.targetSdk?`<br/>Target SDK: ${app.targetSdk}`:''}
</div>
</div>
</div>`;
if(app.description)html+=`<div class="detail-desc">${app.description}</div>`;
if(app.permissions&&app.permissions.length){
html+=`<div class="permissions"><h3>Permissions</h3><ul>${app.permissions.map(p=>`<li>${p}</li>`).join('')}</ul></div>`;
}
if(app.screenshots&&app.screenshots.length){
html+=`<div class="screenshots"><h3>Screenshots</h3><div class="screenshots-scroll">${app.screenshots.map(s=>`<img src="${API}/apps/${app.id}/screenshots/${s}" />`).join('')}</div></div>`;
}
html+=`<div class="actions">
<a class="btn btn-primary" href="${API}/apps/${app.id}/download" download>&#11015; Download APK</a>
</div></div>`;
dv.innerHTML=html;
}
function goBack(){
document.getElementById('detailView').style.display='none';
document.getElementById('homeView').style.display='block';
currentApp=null;
}
function doSearch(){
const q=document.getElementById('searchInput').value.trim().toLowerCase();
if(!q){renderHome(allApps);return}
const filtered=allApps.filter(a=>a.name.toLowerCase().includes(q)||a.description.toLowerCase().includes(q)||a.packageName.toLowerCase().includes(q));
renderHome(filtered);
}
async function doScan(){
showToast('Scanning repository...');
try{
await fetchJSON(`${API}/scan`);
await loadApps();
showToast('Scan complete');
}catch(e){
showToast('Scan failed: '+e.message,'error');
}
}
loadApps();
</script>
</body>
</html>