refactor: split into app-store (server) and app-store-android
Some checks are pending
CI / lint (push) Waiting to run
CI / test (push) Waiting to run
CI / docker-build (push) Waiting to run
CI / security (push) Waiting to run
CI / build-result (push) Blocked by required conditions

Move the Kotlin Android client into its own repository and drop
the helloworld sample. This repo keeps the FastAPI distribution
server with its web frontend.
This commit is contained in:
Jarian Cottingham 2026-08-21 18:34:25 +00:00
parent aa8bba2825
commit bd2e0e3413
57 changed files with 0 additions and 3092 deletions

View File

@ -1,85 +0,0 @@
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

@ -1,32 +0,0 @@
<?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

@ -1,15 +0,0 @@
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

@ -1,40 +0,0 @@
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

@ -1,186 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -1,80 +0,0 @@
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] ?: "8080"
}
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

@ -1,122 +0,0 @@
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

@ -1,29 +0,0 @@
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

@ -1,168 +0,0 @@
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

@ -1,86 +0,0 @@
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

@ -1,71 +0,0 @@
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

@ -1,107 +0,0 @@
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

@ -1,61 +0,0 @@
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

@ -1,60 +0,0 @@
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

@ -1,92 +0,0 @@
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

@ -1,89 +0,0 @@
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

@ -1,124 +0,0 @@
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

@ -1,59 +0,0 @@
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

@ -1,6 +0,0 @@
<?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

@ -1,16 +0,0 @@
<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

@ -1,26 +0,0 @@
<?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

@ -1,160 +0,0 @@
<?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

@ -1,62 +0,0 @@
<?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

@ -1,74 +0,0 @@
<?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

@ -1,105 +0,0 @@
<?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

@ -1,47 +0,0 @@
<?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

@ -1,64 +0,0 @@
<?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

@ -1,15 +0,0 @@
<?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

@ -1,5 +0,0 @@
<?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

@ -1,5 +0,0 @@
<?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

@ -1,31 +0,0 @@
<?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

@ -1,13 +0,0 @@
<?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

@ -1,53 +0,0 @@
<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

@ -1,11 +0,0 @@
<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

@ -1,9 +0,0 @@
<?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>

View File

@ -1,9 +0,0 @@
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

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

Binary file not shown.

View File

@ -1,7 +0,0 @@
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
View File

@ -1,249 +0,0 @@
#!/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
View File

@ -1,92 +0,0 @@
@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

View File

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

View File

@ -1,53 +0,0 @@
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

@ -1,22 +0,0 @@
<?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

@ -1,12 +0,0 @@
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

@ -1,13 +0,0 @@
<?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

@ -1,27 +0,0 @@
<?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

@ -1,5 +0,0 @@
<?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

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

View File

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

View File

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

View File

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

Binary file not shown.

View File

@ -1,7 +0,0 @@
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
View File

@ -1,249 +0,0 @@
#!/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

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