diff --git a/composeApp/src/androidMain/AndroidManifest.xml b/composeApp/src/androidMain/AndroidManifest.xml
index d6ecdaa1..52f5235c 100644
--- a/composeApp/src/androidMain/AndroidManifest.xml
+++ b/composeApp/src/androidMain/AndroidManifest.xml
@@ -125,6 +125,12 @@
+
+
+
(null)
+ private val shareManager: ShareManager by inject()
override fun onCreate(savedInstanceState: Bundle?) {
installSplashScreen()
enableEdgeToEdge()
+ // Register activity result launcher for file picker (must be before STARTED)
+ (shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this)
+
super.onCreate(savedInstanceState)
handleIncomingIntent(intent)
diff --git a/composeApp/src/androidMain/res/xml/filepaths.xml b/composeApp/src/androidMain/res/xml/filepaths.xml
index 626005cb..99987807 100644
--- a/composeApp/src/androidMain/res/xml/filepaths.xml
+++ b/composeApp/src/androidMain/res/xml/filepaths.xml
@@ -5,4 +5,8 @@
name="ghs_downloads"
path="/" />
+
+
\ No newline at end of file
diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt
index c62822dc..7b215245 100644
--- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt
+++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt
@@ -1,10 +1,12 @@
package zed.rainxch.core.data.services
import android.content.Context
+import android.content.pm.ApplicationInfo
import android.content.pm.PackageManager
import android.os.Build
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
+import zed.rainxch.core.domain.model.DeviceApp
import zed.rainxch.core.domain.model.SystemPackageInfo
import zed.rainxch.core.domain.system.PackageMonitor
@@ -55,4 +57,40 @@ class AndroidPackageMonitor(
packages.map { it.packageName }.toSet()
}
+
+ override suspend fun getAllInstalledApps(): List =
+ withContext(Dispatchers.IO) {
+ val packages =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
+ packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0L))
+ } else {
+ @Suppress("DEPRECATION")
+ packageManager.getInstalledPackages(0)
+ }
+
+ packages
+ .filter { pkg ->
+ // Exclude system apps (keep user-installed + updated system apps)
+ val isSystemApp = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0
+ val isUpdatedSystem = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
+ !isSystemApp || isUpdatedSystem
+ }
+ .map { pkg ->
+ val versionCode =
+ if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
+ pkg.longVersionCode
+ } else {
+ @Suppress("DEPRECATION")
+ pkg.versionCode.toLong()
+ }
+
+ DeviceApp(
+ packageName = pkg.packageName,
+ appName = pkg.applicationInfo?.loadLabel(packageManager)?.toString() ?: pkg.packageName,
+ versionName = pkg.versionName,
+ versionCode = versionCode,
+ )
+ }
+ .sortedBy { it.appName.lowercase() }
+ }
}
diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt
index 4024cc89..888537ae 100644
--- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt
+++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt
@@ -1,12 +1,49 @@
package zed.rainxch.core.data.utils
+import android.app.Activity
import android.content.Context
import android.content.Intent
+import android.net.Uri
+import androidx.activity.ComponentActivity
+import androidx.activity.result.ActivityResultLauncher
+import androidx.activity.result.contract.ActivityResultContracts
+import androidx.core.content.FileProvider
import zed.rainxch.core.domain.utils.ShareManager
+import java.io.File
class AndroidShareManager(
private val context: Context,
) : ShareManager {
+ private var filePickerCallback: ((String?) -> Unit)? = null
+ private var filePickerLauncher: ActivityResultLauncher? = null
+
+ fun registerActivityResultLauncher(activity: ComponentActivity) {
+ filePickerLauncher = activity.registerForActivityResult(
+ ActivityResultContracts.StartActivityForResult()
+ ) { result ->
+ val callback = filePickerCallback
+ filePickerCallback = null
+
+ if (result.resultCode == Activity.RESULT_OK) {
+ val uri = result.data?.data
+ if (uri != null) {
+ try {
+ val content = context.contentResolver.openInputStream(uri)
+ ?.bufferedReader()
+ ?.use { it.readText() }
+ callback?.invoke(content)
+ } catch (e: Exception) {
+ callback?.invoke(null)
+ }
+ } else {
+ callback?.invoke(null)
+ }
+ } else {
+ callback?.invoke(null)
+ }
+ }
+ }
+
override fun shareText(text: String) {
val intent =
Intent(Intent.ACTION_SEND).apply {
@@ -21,4 +58,60 @@ class AndroidShareManager(
context.startActivity(chooser)
}
+
+ override fun shareFile(fileName: String, content: String, mimeType: String) {
+ val cacheDir = File(context.cacheDir, "exports")
+ cacheDir.mkdirs()
+
+ val file = File(cacheDir, fileName)
+ file.writeText(content)
+
+ val uri: Uri = FileProvider.getUriForFile(
+ context,
+ "${context.packageName}.fileprovider",
+ file
+ )
+
+ val intent = Intent(Intent.ACTION_SEND).apply {
+ type = mimeType
+ putExtra(Intent.EXTRA_STREAM, uri)
+ addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ val chooser = Intent.createChooser(intent, null).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+
+ context.startActivity(chooser)
+ }
+
+ override fun pickFile(mimeType: String, onResult: (String?) -> Unit) {
+ filePickerCallback = onResult
+
+ val launcher = filePickerLauncher
+ if (launcher != null) {
+ val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = mimeType
+ }
+ launcher.launch(intent)
+ } else {
+ // Fallback: try with ACTION_GET_CONTENT
+ val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
+ addCategory(Intent.CATEGORY_OPENABLE)
+ type = mimeType
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ }
+ try {
+ context.startActivity(Intent.createChooser(intent, null).apply {
+ addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
+ })
+ // Note: fallback won't deliver result without launcher
+ onResult(null)
+ } catch (e: Exception) {
+ onResult(null)
+ }
+ }
+ }
}
diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
index d2e290d9..ec7c5f45 100644
--- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
+++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
@@ -75,6 +75,7 @@ val coreModule =
historyDao = get(),
installer = get(),
httpClient = get(),
+ themesRepository = get(),
)
}
diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt
index ea98821a..c5ef9c5f 100644
--- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt
@@ -23,6 +23,7 @@ import zed.rainxch.core.domain.model.GithubRelease
import zed.rainxch.core.domain.model.InstallSource
import zed.rainxch.core.domain.model.InstalledApp
import zed.rainxch.core.domain.repository.InstalledAppsRepository
+import zed.rainxch.core.domain.repository.ThemesRepository
import zed.rainxch.core.domain.system.Installer
class InstalledAppsRepositoryImpl(
@@ -31,6 +32,7 @@ class InstalledAppsRepositoryImpl(
private val historyDao: UpdateHistoryDao,
private val installer: Installer,
private val httpClient: HttpClient,
+ private val themesRepository: ThemesRepository,
) : InstalledAppsRepository {
override suspend fun executeInTransaction(block: suspend () -> R): R =
database.useWriterConnection { transactor ->
@@ -76,6 +78,8 @@ class InstalledAppsRepositoryImpl(
repo: String,
): GithubRelease? {
return try {
+ val includePreReleases = themesRepository.getIncludePreReleases().first()
+
val releases =
httpClient
.executeRequest> {
@@ -88,7 +92,8 @@ class InstalledAppsRepositoryImpl(
val latest =
releases
.asSequence()
- .filter { (it.draft != true) && (it.prerelease != true) }
+ .filter { it.draft != true }
+ .filter { includePreReleases || it.prerelease != true }
.maxByOrNull { it.publishedAt ?: it.createdAt ?: "" }
?: return null
@@ -119,7 +124,13 @@ class InstalledAppsRepositoryImpl(
}
val primaryAsset = installer.choosePrimaryAsset(installableAssets)
- val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
+ // Only flag as update if the latest version is actually newer
+ // (not just different — avoids false "downgrade" notifications)
+ val isUpdateAvailable = if (normalizedInstalledTag == normalizedLatestTag) {
+ false
+ } else {
+ isVersionNewer(normalizedLatestTag, normalizedInstalledTag)
+ }
Logger.d {
"Update check for ${app.appName}: " +
@@ -226,4 +237,89 @@ class InstalledAppsRepositoryImpl(
}
private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim()
+
+ /**
+ * Compare two version strings and return true if [candidate] is newer than [current].
+ * Handles semantic versioning (1.2.3), pre-release suffixes (1.2.3-beta.1),
+ * and falls back to lexicographic comparison for non-standard formats.
+ *
+ * Pre-release versions are considered older than their stable counterparts:
+ * 1.2.3-beta < 1.2.3 (per semver spec)
+ *
+ * This prevents false "downgrade" notifications when a user has a pre-release
+ * installed and the latest stable version has a lower or equal base version.
+ */
+ private fun isVersionNewer(candidate: String, current: String): Boolean {
+ val candidateParsed = parseSemanticVersion(candidate)
+ val currentParsed = parseSemanticVersion(current)
+
+ if (candidateParsed != null && currentParsed != null) {
+ // Compare major.minor.patch
+ for (i in 0 until maxOf(candidateParsed.numbers.size, currentParsed.numbers.size)) {
+ val c = candidateParsed.numbers.getOrElse(i) { 0 }
+ val r = currentParsed.numbers.getOrElse(i) { 0 }
+ if (c > r) return true
+ if (c < r) return false
+ }
+ // Numbers are equal; compare pre-release suffixes
+ // No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta)
+ return when {
+ candidateParsed.preRelease == null && currentParsed.preRelease != null -> true
+ candidateParsed.preRelease != null && currentParsed.preRelease == null -> false
+ candidateParsed.preRelease != null && currentParsed.preRelease != null ->
+ comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0
+ else -> false // both null, versions are equal
+ }
+ }
+
+ // Fallback: lexicographic comparison (better than just "not equal")
+ return candidate > current
+ }
+
+ private data class SemanticVersion(
+ val numbers: List,
+ val preRelease: String?,
+ )
+
+ private fun parseSemanticVersion(version: String): SemanticVersion? {
+ // Split off pre-release suffix: "1.2.3-beta.1" -> "1.2.3" and "beta.1"
+ val hyphenIndex = version.indexOf('-')
+ val numberPart = if (hyphenIndex >= 0) version.substring(0, hyphenIndex) else version
+ val preRelease = if (hyphenIndex >= 0) version.substring(hyphenIndex + 1) else null
+
+ val parts = numberPart.split(".")
+ val numbers = parts.mapNotNull { it.toIntOrNull() }
+
+ // Only valid if we could parse at least one number and all parts were valid numbers
+ if (numbers.isEmpty() || numbers.size != parts.size) return null
+
+ return SemanticVersion(numbers, preRelease)
+ }
+
+ /**
+ * Compare pre-release identifiers per semver spec:
+ * Identifiers consisting of only digits are compared numerically.
+ * Identifiers with letters are compared lexically.
+ * Numeric identifiers always have lower precedence than alphanumeric.
+ * A larger set of pre-release fields has higher precedence if all preceding are equal.
+ */
+ private fun comparePreRelease(a: String, b: String): Int {
+ val aParts = a.split(".")
+ val bParts = b.split(".")
+
+ for (i in 0 until minOf(aParts.size, bParts.size)) {
+ val aNum = aParts[i].toIntOrNull()
+ val bNum = bParts[i].toIntOrNull()
+
+ val cmp = when {
+ aNum != null && bNum != null -> aNum.compareTo(bNum)
+ aNum != null -> -1 // numeric < alphanumeric
+ bNum != null -> 1
+ else -> aParts[i].compareTo(bParts[i])
+ }
+ if (cmp != 0) return cmp
+ }
+
+ return aParts.size.compareTo(bParts.size)
+ }
}
diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt
index 68eb2d64..89ae3202 100644
--- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt
+++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt
@@ -24,6 +24,7 @@ class ThemesRepositoryImpl(
private val INSTALLER_TYPE_KEY = stringPreferencesKey("installer_type")
private val AUTO_UPDATE_KEY = booleanPreferencesKey("auto_update_enabled")
private val UPDATE_CHECK_INTERVAL_KEY = longPreferencesKey("update_check_interval_hours")
+ private val INCLUDE_PRE_RELEASES_KEY = booleanPreferencesKey("include_pre_releases")
override fun getThemeColor(): Flow =
preferences.data.map { prefs ->
@@ -121,6 +122,17 @@ class ThemesRepositoryImpl(
}
}
+ override fun getIncludePreReleases(): Flow =
+ preferences.data.map { prefs ->
+ prefs[INCLUDE_PRE_RELEASES_KEY] ?: false
+ }
+
+ override suspend fun setIncludePreReleases(enabled: Boolean) {
+ preferences.edit { prefs ->
+ prefs[INCLUDE_PRE_RELEASES_KEY] = enabled
+ }
+ }
+
companion object {
const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L
}
diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt
index b9f745dd..cd7e6e08 100644
--- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt
+++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt
@@ -1,5 +1,6 @@
package zed.rainxch.core.data.services
+import zed.rainxch.core.domain.model.DeviceApp
import zed.rainxch.core.domain.model.SystemPackageInfo
import zed.rainxch.core.domain.system.PackageMonitor
@@ -9,4 +10,6 @@ class DesktopPackageMonitor : PackageMonitor {
override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? = null
override suspend fun getAllInstalledPackageNames(): Set = setOf()
+
+ override suspend fun getAllInstalledApps(): List = emptyList()
}
diff --git a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt
index e7336fdb..fd14e4db 100644
--- a/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt
+++ b/core/data/src/jvmMain/kotlin/zed/rainxch/core/data/utils/DesktopShareManager.kt
@@ -1,8 +1,15 @@
package zed.rainxch.core.data.utils
import zed.rainxch.core.domain.utils.ShareManager
+import java.awt.Desktop
+import java.awt.FileDialog
+import java.awt.Frame
import java.awt.Toolkit
import java.awt.datatransfer.StringSelection
+import java.io.File
+import javax.swing.JFileChooser
+import javax.swing.SwingUtilities
+import javax.swing.filechooser.FileNameExtensionFilter
class DesktopShareManager : ShareManager {
override fun shareText(text: String) {
@@ -10,4 +17,44 @@ class DesktopShareManager : ShareManager {
val selection = StringSelection(text)
clipboard.setContents(selection, null)
}
+
+ override fun shareFile(fileName: String, content: String, mimeType: String) {
+ SwingUtilities.invokeLater {
+ val chooser = JFileChooser().apply {
+ dialogTitle = "Save exported apps"
+ selectedFile = File(fileName)
+ fileFilter = FileNameExtensionFilter("JSON files", "json")
+ }
+
+ val result = chooser.showSaveDialog(null)
+ if (result == JFileChooser.APPROVE_OPTION) {
+ var file = chooser.selectedFile
+ if (!file.name.endsWith(".json")) {
+ file = File(file.absolutePath + ".json")
+ }
+ file.writeText(content)
+ }
+ }
+ }
+
+ override fun pickFile(mimeType: String, onResult: (String?) -> Unit) {
+ SwingUtilities.invokeLater {
+ val chooser = JFileChooser().apply {
+ dialogTitle = "Select file to import"
+ fileFilter = FileNameExtensionFilter("JSON files", "json")
+ }
+
+ val result = chooser.showOpenDialog(null)
+ if (result == JFileChooser.APPROVE_OPTION) {
+ try {
+ val content = chooser.selectedFile.readText()
+ onResult(content)
+ } catch (e: Exception) {
+ onResult(null)
+ }
+ } else {
+ onResult(null)
+ }
+ }
+ }
}
diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt
new file mode 100644
index 00000000..645964e3
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/DeviceApp.kt
@@ -0,0 +1,8 @@
+package zed.rainxch.core.domain.model
+
+data class DeviceApp(
+ val packageName: String,
+ val appName: String,
+ val versionName: String?,
+ val versionCode: Long,
+)
diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt
new file mode 100644
index 00000000..a92d1a2f
--- /dev/null
+++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/ExportedApp.kt
@@ -0,0 +1,18 @@
+package zed.rainxch.core.domain.model
+
+import kotlinx.serialization.Serializable
+
+@Serializable
+data class ExportedApp(
+ val packageName: String,
+ val repoOwner: String,
+ val repoName: String,
+ val repoUrl: String,
+)
+
+@Serializable
+data class ExportedAppList(
+ val version: Int = 1,
+ val exportedAt: Long = 0L,
+ val apps: List = emptyList(),
+)
diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt
index aad26498..f307ca04 100644
--- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt
+++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ThemesRepository.kt
@@ -22,4 +22,6 @@ interface ThemesRepository {
suspend fun setAutoUpdateEnabled(enabled: Boolean)
fun getUpdateCheckInterval(): Flow
suspend fun setUpdateCheckInterval(hours: Long)
+ fun getIncludePreReleases(): Flow
+ suspend fun setIncludePreReleases(enabled: Boolean)
}
\ No newline at end of file
diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt
index e8d80e01..041a0a9f 100644
--- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt
+++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/PackageMonitor.kt
@@ -1,5 +1,6 @@
package zed.rainxch.core.domain.system
+import zed.rainxch.core.domain.model.DeviceApp
import zed.rainxch.core.domain.model.SystemPackageInfo
interface PackageMonitor {
@@ -8,4 +9,6 @@ interface PackageMonitor {
suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo?
suspend fun getAllInstalledPackageNames(): Set
+
+ suspend fun getAllInstalledApps(): List
}
diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt
index 05b18b39..262c7e44 100644
--- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt
+++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/utils/ShareManager.kt
@@ -2,4 +2,6 @@ package zed.rainxch.core.domain.utils
interface ShareManager {
fun shareText(text: String)
+ fun shareFile(fileName: String, content: String, mimeType: String = "application/json")
+ fun pickFile(mimeType: String = "application/json", onResult: (String?) -> Unit)
}
diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml
index 886071dd..b0ddca4d 100644
--- a/core/presentation/src/commonMain/composeResources/values/strings.xml
+++ b/core/presentation/src/commonMain/composeResources/values/strings.xml
@@ -533,4 +533,23 @@
6h
12h
24h
+
+ Add by link
+ Link app to repository
+ Pick an installed app to link with a GitHub repository
+ Search apps…
+ GitHub repository URL
+ github.com/owner/repo
+ Validating…
+ Link & Track
+ Export
+ Import
+ Import apps
+ Paste the exported JSON to restore your tracked apps
+ Paste exported JSON here…
+
+ Include pre-releases
+ Track pre-release versions when checking for updates. When disabled, only stable releases are considered.
+ Uninstall app?
+ Are you sure you want to uninstall %1$s? This action cannot be undone and app data may be lost.
\ No newline at end of file
diff --git a/feature/apps/data/build.gradle.kts b/feature/apps/data/build.gradle.kts
index 8deab868..f452adf3 100644
--- a/feature/apps/data/build.gradle.kts
+++ b/feature/apps/data/build.gradle.kts
@@ -14,6 +14,7 @@ kotlin {
implementation(projects.feature.apps.domain)
implementation(libs.kotlinx.coroutines.core)
+ implementation(libs.kotlinx.datetime)
implementation(libs.bundles.ktor.common)
implementation(libs.bundles.koin.common)
}
diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt
index cb1f7189..ffe42d76 100644
--- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt
+++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/di/SharedModule.kt
@@ -12,6 +12,8 @@ val appsModule =
appsRepository = get(),
logger = get(),
httpClient = get(),
+ packageMonitor = get(),
+ themesRepository = get(),
)
}
}
diff --git a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt
index e3be78bd..d6be1a6c 100644
--- a/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt
+++ b/feature/apps/data/src/commonMain/kotlin/zed/rainxch/apps/data/repository/AppsRepositoryImpl.kt
@@ -6,23 +6,40 @@ import io.ktor.client.request.header
import io.ktor.client.request.parameter
import io.ktor.http.HttpHeaders
import kotlinx.coroutines.flow.Flow
+import kotlinx.coroutines.flow.first
+import kotlinx.serialization.json.Json
+import zed.rainxch.apps.domain.model.GithubRepoInfo
+import zed.rainxch.apps.domain.model.ImportResult
import zed.rainxch.apps.domain.repository.AppsRepository
+import zed.rainxch.core.data.dto.GithubRepoNetworkModel
import zed.rainxch.core.data.dto.ReleaseNetwork
import zed.rainxch.core.data.mappers.toDomain
import zed.rainxch.core.data.network.executeRequest
import zed.rainxch.core.domain.logging.GitHubStoreLogger
+import zed.rainxch.core.domain.model.DeviceApp
+import zed.rainxch.core.domain.model.ExportedApp
+import zed.rainxch.core.domain.model.ExportedAppList
import zed.rainxch.core.domain.model.GithubRelease
+import zed.rainxch.core.domain.model.InstallSource
import zed.rainxch.core.domain.model.InstalledApp
import zed.rainxch.core.domain.model.RateLimitException
import zed.rainxch.core.domain.repository.InstalledAppsRepository
+import zed.rainxch.core.domain.repository.ThemesRepository
+import zed.rainxch.core.domain.system.PackageMonitor
import zed.rainxch.core.domain.utils.AppLauncher
+import kotlin.time.Clock
class AppsRepositoryImpl(
private val appLauncher: AppLauncher,
private val appsRepository: InstalledAppsRepository,
private val logger: GitHubStoreLogger,
private val httpClient: HttpClient,
+ private val packageMonitor: PackageMonitor,
+ private val themesRepository: ThemesRepository,
) : AppsRepository {
+
+ private val json = Json { ignoreUnknownKeys = true }
+
override suspend fun getApps(): Flow> = appsRepository.getAllInstalledApps()
override suspend fun openApp(
@@ -48,6 +65,8 @@ class AppsRepositoryImpl(
repo: String,
): GithubRelease? =
try {
+ val includePreReleases = themesRepository.getIncludePreReleases().first()
+
val releases =
httpClient
.executeRequest> {
@@ -59,7 +78,8 @@ class AppsRepositoryImpl(
releases
.asSequence()
- .filter { (it.draft != true) && (it.prerelease != true) }
+ .filter { it.draft != true }
+ .filter { includePreReleases || it.prerelease != true }
.maxByOrNull { it.publishedAt ?: it.createdAt ?: "" }
?.toDomain()
} catch (e: RateLimitException) {
@@ -68,4 +88,160 @@ class AppsRepositoryImpl(
logger.error("Failed to fetch latest release for $owner/$repo: ${e.message}")
null
}
+
+ override suspend fun getDeviceApps(): List =
+ packageMonitor.getAllInstalledApps()
+
+ override suspend fun getTrackedPackageNames(): Set =
+ appsRepository.getAllInstalledApps().first().map { it.packageName }.toSet()
+
+ override suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo? =
+ try {
+ val repoModel =
+ httpClient
+ .executeRequest {
+ get("/repos/$owner/$repo") {
+ header(HttpHeaders.Accept, "application/vnd.github+json")
+ }
+ }.getOrThrow()
+
+ // Also fetch latest release tag
+ val includePreReleases = themesRepository.getIncludePreReleases().first()
+ val latestTag = try {
+ val releases =
+ httpClient
+ .executeRequest> {
+ get("/repos/$owner/$repo/releases") {
+ header(HttpHeaders.Accept, "application/vnd.github+json")
+ parameter("per_page", 5)
+ }
+ }.getOrThrow()
+
+ releases
+ .asSequence()
+ .filter { it.draft != true }
+ .filter { includePreReleases || it.prerelease != true }
+ .maxByOrNull { it.publishedAt ?: it.createdAt ?: "" }
+ ?.tagName
+ } catch (_: Exception) {
+ null
+ }
+
+ GithubRepoInfo(
+ id = repoModel.id,
+ name = repoModel.name,
+ owner = repoModel.owner.login,
+ ownerAvatarUrl = repoModel.owner.avatarUrl,
+ description = repoModel.description,
+ language = repoModel.language,
+ htmlUrl = repoModel.htmlUrl,
+ latestReleaseTag = latestTag,
+ )
+ } catch (e: RateLimitException) {
+ throw e
+ } catch (e: Exception) {
+ logger.error("Failed to fetch repo info for $owner/$repo: ${e.message}")
+ null
+ }
+
+ override suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo) {
+ val now = Clock.System.now().toEpochMilliseconds()
+
+ val installedApp = InstalledApp(
+ packageName = deviceApp.packageName,
+ repoId = repoInfo.id,
+ repoName = repoInfo.name,
+ repoOwner = repoInfo.owner,
+ repoOwnerAvatarUrl = repoInfo.ownerAvatarUrl,
+ repoDescription = repoInfo.description,
+ primaryLanguage = repoInfo.language,
+ repoUrl = repoInfo.htmlUrl,
+ installedVersion = deviceApp.versionName ?: "unknown",
+ installedAssetName = null,
+ installedAssetUrl = null,
+ latestVersion = repoInfo.latestReleaseTag,
+ latestAssetName = null,
+ latestAssetUrl = null,
+ latestAssetSize = null,
+ appName = deviceApp.appName,
+ installSource = InstallSource.MANUAL,
+ installedAt = now,
+ lastCheckedAt = 0L,
+ lastUpdatedAt = now,
+ isUpdateAvailable = false,
+ updateCheckEnabled = true,
+ releaseNotes = null,
+ systemArchitecture = "",
+ fileExtension = "apk",
+ isPendingInstall = false,
+ installedVersionName = deviceApp.versionName,
+ installedVersionCode = deviceApp.versionCode,
+ )
+
+ appsRepository.saveInstalledApp(installedApp)
+ }
+
+ override suspend fun exportApps(): String {
+ val apps = appsRepository.getAllInstalledApps().first()
+ val exported = ExportedAppList(
+ version = 1,
+ exportedAt = Clock.System.now().toEpochMilliseconds(),
+ apps = apps.map { app ->
+ ExportedApp(
+ packageName = app.packageName,
+ repoOwner = app.repoOwner,
+ repoName = app.repoName,
+ repoUrl = app.repoUrl,
+ )
+ },
+ )
+ return json.encodeToString(ExportedAppList.serializer(), exported)
+ }
+
+ override suspend fun importApps(jsonString: String): ImportResult {
+ val exportedList = try {
+ json.decodeFromString(ExportedAppList.serializer(), jsonString)
+ } catch (e: Exception) {
+ logger.error("Failed to parse import JSON: ${e.message}")
+ return ImportResult(imported = 0, skipped = 0, failed = 1)
+ }
+
+ val trackedPackages = getTrackedPackageNames()
+ var imported = 0
+ var skipped = 0
+ var failed = 0
+
+ for (exportedApp in exportedList.apps) {
+ if (exportedApp.packageName in trackedPackages) {
+ skipped++
+ continue
+ }
+
+ try {
+ val repoInfo = fetchRepoInfo(exportedApp.repoOwner, exportedApp.repoName)
+ if (repoInfo == null) {
+ failed++
+ continue
+ }
+
+ // Try to get device app info if installed
+ val systemInfo = packageMonitor.getInstalledPackageInfo(exportedApp.packageName)
+
+ val deviceApp = DeviceApp(
+ packageName = exportedApp.packageName,
+ appName = exportedApp.repoName,
+ versionName = systemInfo?.versionName,
+ versionCode = systemInfo?.versionCode ?: 0L,
+ )
+
+ linkAppToRepo(deviceApp, repoInfo)
+ imported++
+ } catch (e: Exception) {
+ logger.error("Failed to import ${exportedApp.repoOwner}/${exportedApp.repoName}: ${e.message}")
+ failed++
+ }
+ }
+
+ return ImportResult(imported = imported, skipped = skipped, failed = failed)
+ }
}
diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt
new file mode 100644
index 00000000..c3509ee9
--- /dev/null
+++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/GithubRepoInfo.kt
@@ -0,0 +1,12 @@
+package zed.rainxch.apps.domain.model
+
+data class GithubRepoInfo(
+ val id: Long,
+ val name: String,
+ val owner: String,
+ val ownerAvatarUrl: String,
+ val description: String?,
+ val language: String?,
+ val htmlUrl: String,
+ val latestReleaseTag: String?,
+)
diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt
new file mode 100644
index 00000000..4fe815bb
--- /dev/null
+++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/model/ImportResult.kt
@@ -0,0 +1,7 @@
+package zed.rainxch.apps.domain.model
+
+data class ImportResult(
+ val imported: Int,
+ val skipped: Int,
+ val failed: Int,
+)
diff --git a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt
index e281b77f..8397a60d 100644
--- a/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt
+++ b/feature/apps/domain/src/commonMain/kotlin/zed/rainxch/apps/domain/repository/AppsRepository.kt
@@ -1,6 +1,9 @@
package zed.rainxch.apps.domain.repository
import kotlinx.coroutines.flow.Flow
+import zed.rainxch.apps.domain.model.GithubRepoInfo
+import zed.rainxch.apps.domain.model.ImportResult
+import zed.rainxch.core.domain.model.DeviceApp
import zed.rainxch.core.domain.model.GithubRelease
import zed.rainxch.core.domain.model.InstalledApp
@@ -16,4 +19,16 @@ interface AppsRepository {
owner: String,
repo: String,
): GithubRelease?
+
+ suspend fun getDeviceApps(): List
+
+ suspend fun getTrackedPackageNames(): Set
+
+ suspend fun fetchRepoInfo(owner: String, repo: String): GithubRepoInfo?
+
+ suspend fun linkAppToRepo(deviceApp: DeviceApp, repoInfo: GithubRepoInfo)
+
+ suspend fun exportApps(): String
+
+ suspend fun importApps(json: String): ImportResult
}
diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt
index 5a5d134b..8c10d2c2 100644
--- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt
+++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt
@@ -1,5 +1,6 @@
package zed.rainxch.apps.presentation
+import zed.rainxch.core.domain.model.DeviceApp
import zed.rainxch.core.domain.model.InstalledApp
sealed interface AppsAction {
@@ -36,4 +37,21 @@ sealed interface AppsAction {
data class OnUninstallApp(
val app: InstalledApp,
) : AppsAction
+
+ // Uninstall confirmation
+ data class OnUninstallConfirmed(val app: InstalledApp) : AppsAction
+ data object OnDismissUninstallDialog : AppsAction
+
+ // Link app to repo
+ data object OnAddByLinkClick : AppsAction
+ data object OnDismissLinkSheet : AppsAction
+ data class OnDeviceAppSearchChange(val query: String) : AppsAction
+ data class OnDeviceAppSelected(val app: DeviceApp) : AppsAction
+ data class OnRepoUrlChanged(val url: String) : AppsAction
+ data object OnValidateAndLinkRepo : AppsAction
+ data object OnBackToAppPicker : AppsAction
+
+ // Export/Import
+ data object OnExportApps : AppsAction
+ data object OnImportApps : AppsAction
}
diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt
index 5eab3e72..f717bd78 100644
--- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt
+++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsEvent.kt
@@ -1,5 +1,7 @@
package zed.rainxch.apps.presentation
+import zed.rainxch.apps.domain.model.ImportResult
+
sealed interface AppsEvent {
data class ShowError(
val message: String,
@@ -12,4 +14,16 @@ sealed interface AppsEvent {
data class NavigateToRepo(
val repoId: Long,
) : AppsEvent
+
+ data class AppLinkedSuccessfully(
+ val appName: String,
+ ) : AppsEvent
+
+ data class ExportReady(
+ val json: String,
+ ) : AppsEvent
+
+ data class ImportComplete(
+ val result: ImportResult,
+ ) : AppsEvent
}
diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt
index dd44378d..69bf413f 100644
--- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt
+++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt
@@ -22,6 +22,7 @@ import androidx.compose.foundation.shape.RoundedCornerShape
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.automirrored.filled.ArrowBack
import androidx.compose.material.icons.automirrored.filled.OpenInNew
+import androidx.compose.material.icons.filled.Add
import androidx.compose.material.icons.filled.Cancel
import androidx.compose.material.icons.filled.CheckCircle
import androidx.compose.material.icons.filled.Close
@@ -29,15 +30,22 @@ import androidx.compose.material.icons.filled.Refresh
import androidx.compose.material.icons.filled.Search
import androidx.compose.material.icons.filled.Update
import androidx.compose.material.icons.outlined.DeleteOutline
+import androidx.compose.material.icons.outlined.FileDownload
+import androidx.compose.material.icons.outlined.FileUpload
+import androidx.compose.material.icons.outlined.MoreVert
+import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Button
import androidx.compose.material3.ButtonDefaults
import androidx.compose.material3.Card
import androidx.compose.material3.CardDefaults
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.CircularWavyProgressIndicator
+import androidx.compose.material3.DropdownMenu
+import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ElevatedCard
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.ExperimentalMaterial3ExpressiveApi
+import androidx.compose.material3.FloatingActionButton
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.LinearWavyProgressIndicator
@@ -46,14 +54,17 @@ import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
+import androidx.compose.material3.TextButton
import androidx.compose.material3.TextField
import androidx.compose.material3.TextFieldDefaults
import androidx.compose.material3.TopAppBar
import androidx.compose.material3.pulltorefresh.PullToRefreshBox
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
+import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
+import androidx.compose.runtime.setValue
import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.draw.clip
@@ -68,6 +79,7 @@ import io.github.fletchmckee.liquid.liquefiable
import kotlinx.coroutines.launch
import org.jetbrains.compose.resources.stringResource
import org.koin.compose.viewmodel.koinViewModel
+import zed.rainxch.apps.presentation.components.LinkAppBottomSheet
import zed.rainxch.apps.presentation.model.AppItem
import zed.rainxch.apps.presentation.model.UpdateAllProgress
import zed.rainxch.apps.presentation.model.UpdateState
@@ -105,6 +117,25 @@ fun AppsRoot(
snackbarHostState.showSnackbar(event.message)
}
}
+
+ is AppsEvent.AppLinkedSuccessfully -> {
+ coroutineScope.launch {
+ snackbarHostState.showSnackbar("${event.appName} linked successfully")
+ }
+ }
+
+ is AppsEvent.ExportReady -> {
+ // Share already handled by ShareManager in ViewModel
+ }
+
+ is AppsEvent.ImportComplete -> {
+ val r = event.result
+ coroutineScope.launch {
+ snackbarHostState.showSnackbar(
+ "Imported ${r.imported}, skipped ${r.skipped}, failed ${r.failed}",
+ )
+ }
+ }
}
}
@@ -133,6 +164,7 @@ fun AppsScreen(
) {
val liquidState = LocalBottomNavigationLiquid.current
val bottomNavHeight = LocalBottomNavigationHeight.current
+ var showOverflowMenu by remember { mutableStateOf(false) }
Scaffold(
topBar = {
@@ -154,9 +186,54 @@ fun AppsScreen(
contentDescription = stringResource(Res.string.check_for_updates),
)
}
+
+ Box {
+ IconButton(onClick = { showOverflowMenu = true }) {
+ Icon(
+ imageVector = Icons.Outlined.MoreVert,
+ contentDescription = null,
+ )
+ }
+ DropdownMenu(
+ expanded = showOverflowMenu,
+ onDismissRequest = { showOverflowMenu = false },
+ ) {
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.export_apps)) },
+ onClick = {
+ showOverflowMenu = false
+ onAction(AppsAction.OnExportApps)
+ },
+ leadingIcon = {
+ Icon(Icons.Outlined.FileUpload, contentDescription = null)
+ },
+ )
+ DropdownMenuItem(
+ text = { Text(stringResource(Res.string.import_apps)) },
+ onClick = {
+ showOverflowMenu = false
+ onAction(AppsAction.OnImportApps)
+ },
+ leadingIcon = {
+ Icon(Icons.Outlined.FileDownload, contentDescription = null)
+ },
+ )
+ }
+ }
},
)
},
+ floatingActionButton = {
+ FloatingActionButton(
+ onClick = { onAction(AppsAction.OnAddByLinkClick) },
+ modifier = Modifier.padding(bottom = bottomNavHeight),
+ ) {
+ Icon(
+ imageVector = Icons.Default.Add,
+ contentDescription = stringResource(Res.string.add_by_link),
+ )
+ }
+ },
snackbarHost = {
SnackbarHost(
hostState = snackbarHostState,
@@ -165,6 +242,50 @@ fun AppsScreen(
},
modifier = Modifier.liquefiable(liquidState),
) { innerPadding ->
+
+ // Link app bottom sheet
+ if (state.showLinkSheet) {
+ LinkAppBottomSheet(
+ state = state,
+ onAction = onAction,
+ )
+ }
+
+ // Uninstall confirmation dialog
+ state.appPendingUninstall?.let { app ->
+ AlertDialog(
+ onDismissRequest = { onAction(AppsAction.OnDismissUninstallDialog) },
+ title = {
+ Text(
+ text = stringResource(Res.string.confirm_uninstall_title),
+ fontWeight = FontWeight.Bold,
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(Res.string.confirm_uninstall_message, app.appName),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = { onAction(AppsAction.OnUninstallConfirmed(app)) },
+ ) {
+ Text(
+ text = stringResource(Res.string.uninstall),
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = { onAction(AppsAction.OnDismissUninstallDialog) },
+ ) {
+ Text(text = stringResource(Res.string.cancel))
+ }
+ },
+ )
+ }
+
PullToRefreshBox(
isRefreshing = state.isRefreshing,
onRefresh = { onAction(AppsAction.OnRefresh) },
diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt
index e6c8b1e3..adcc80e1 100644
--- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt
+++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsState.kt
@@ -1,7 +1,10 @@
package zed.rainxch.apps.presentation
+import zed.rainxch.apps.domain.model.GithubRepoInfo
import zed.rainxch.apps.presentation.model.AppItem
import zed.rainxch.apps.presentation.model.UpdateAllProgress
+import zed.rainxch.core.domain.model.DeviceApp
+import zed.rainxch.core.domain.model.InstalledApp
data class AppsState(
val apps: List = emptyList(),
@@ -14,4 +17,34 @@ data class AppsState(
val isCheckingForUpdates: Boolean = false,
val lastCheckedTimestamp: Long? = null,
val isRefreshing: Boolean = false,
-)
+ // Link app to repo
+ val showLinkSheet: Boolean = false,
+ val linkStep: LinkStep = LinkStep.PickApp,
+ val deviceApps: List = emptyList(),
+ val deviceAppSearchQuery: String = "",
+ val selectedDeviceApp: DeviceApp? = null,
+ val repoUrl: String = "",
+ val isValidatingRepo: Boolean = false,
+ val repoValidationError: String? = null,
+ val fetchedRepoInfo: GithubRepoInfo? = null,
+ // Export/Import
+ val isExporting: Boolean = false,
+ val isImporting: Boolean = false,
+ // Uninstall confirmation
+ val appPendingUninstall: InstalledApp? = null,
+) {
+ val filteredDeviceApps: List
+ get() = if (deviceAppSearchQuery.isBlank()) {
+ deviceApps
+ } else {
+ deviceApps.filter {
+ it.appName.contains(deviceAppSearchQuery, ignoreCase = true) ||
+ it.packageName.contains(deviceAppSearchQuery, ignoreCase = true)
+ }
+ }
+}
+
+enum class LinkStep {
+ PickApp,
+ EnterUrl,
+}
diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
index 41524c27..9d65fab7 100644
--- a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
+++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
@@ -26,6 +26,7 @@ import zed.rainxch.core.domain.network.Downloader
import zed.rainxch.core.domain.repository.InstalledAppsRepository
import zed.rainxch.core.domain.system.Installer
import zed.rainxch.core.domain.use_cases.SyncInstalledAppsUseCase
+import zed.rainxch.core.domain.utils.ShareManager
import zed.rainxch.githubstore.core.presentation.res.*
import java.io.File
@@ -36,6 +37,7 @@ class AppsViewModel(
private val installedAppsRepository: InstalledAppsRepository,
private val syncInstalledAppsUseCase: SyncInstalledAppsUseCase,
private val logger: GitHubStoreLogger,
+ private val shareManager: ShareManager,
) : ViewModel() {
companion object {
private const val UPDATE_CHECK_COOLDOWN_MS = 30 * 60 * 1000L // 30 minutes
@@ -204,7 +206,58 @@ class AppsViewModel(
}
is AppsAction.OnUninstallApp -> {
+ _state.update { it.copy(appPendingUninstall = action.app) }
+ }
+
+ // Link app to repo
+ AppsAction.OnAddByLinkClick -> openLinkSheet()
+ AppsAction.OnDismissLinkSheet -> dismissLinkSheet()
+ is AppsAction.OnDeviceAppSearchChange -> {
+ _state.update { it.copy(deviceAppSearchQuery = action.query) }
+ }
+ is AppsAction.OnDeviceAppSelected -> {
+ _state.update {
+ it.copy(
+ selectedDeviceApp = action.app,
+ linkStep = LinkStep.EnterUrl,
+ repoUrl = "",
+ repoValidationError = null,
+ fetchedRepoInfo = null,
+ )
+ }
+ }
+ is AppsAction.OnRepoUrlChanged -> {
+ _state.update {
+ it.copy(
+ repoUrl = action.url,
+ repoValidationError = null,
+ )
+ }
+ }
+ AppsAction.OnValidateAndLinkRepo -> validateAndLinkRepo()
+ AppsAction.OnBackToAppPicker -> {
+ _state.update {
+ it.copy(
+ linkStep = LinkStep.PickApp,
+ selectedDeviceApp = null,
+ repoUrl = "",
+ repoValidationError = null,
+ fetchedRepoInfo = null,
+ )
+ }
+ }
+
+ // Export/Import
+ AppsAction.OnExportApps -> exportApps()
+ AppsAction.OnImportApps -> importAppsFromFile()
+
+ // Uninstall confirmation
+ is AppsAction.OnUninstallConfirmed -> {
uninstallApp(action.app)
+ _state.update { it.copy(appPendingUninstall = null) }
+ }
+ AppsAction.OnDismissUninstallDialog -> {
+ _state.update { it.copy(appPendingUninstall = null) }
}
}
}
@@ -634,6 +687,178 @@ class AppsViewModel(
}
}
+ // ── Link app to repo ──────────────────────────────────────────
+
+ private fun openLinkSheet() {
+ viewModelScope.launch {
+ _state.update {
+ it.copy(
+ showLinkSheet = true,
+ linkStep = LinkStep.PickApp,
+ deviceApps = emptyList(),
+ deviceAppSearchQuery = "",
+ selectedDeviceApp = null,
+ repoUrl = "",
+ repoValidationError = null,
+ fetchedRepoInfo = null,
+ )
+ }
+
+ try {
+ val trackedPackages = appsRepository.getTrackedPackageNames()
+ val deviceApps = appsRepository.getDeviceApps()
+ .filter { it.packageName !in trackedPackages }
+
+ _state.update { it.copy(deviceApps = deviceApps) }
+ } catch (e: Exception) {
+ logger.error("Failed to load device apps: ${e.message}")
+ _events.send(AppsEvent.ShowError("Failed to load installed apps"))
+ }
+ }
+ }
+
+ private fun dismissLinkSheet() {
+ _state.update {
+ it.copy(
+ showLinkSheet = false,
+ linkStep = LinkStep.PickApp,
+ deviceApps = emptyList(),
+ deviceAppSearchQuery = "",
+ selectedDeviceApp = null,
+ repoUrl = "",
+ repoValidationError = null,
+ fetchedRepoInfo = null,
+ isValidatingRepo = false,
+ )
+ }
+ }
+
+ private fun validateAndLinkRepo() {
+ val selectedApp = _state.value.selectedDeviceApp ?: return
+ val url = _state.value.repoUrl.trim()
+
+ // Parse owner/repo from URL
+ val (owner, repo) = parseGithubUrl(url) ?: run {
+ _state.update { it.copy(repoValidationError = "Invalid GitHub URL. Use format: github.com/owner/repo") }
+ return
+ }
+
+ viewModelScope.launch {
+ _state.update { it.copy(isValidatingRepo = true, repoValidationError = null) }
+
+ try {
+ val repoInfo = appsRepository.fetchRepoInfo(owner, repo)
+ if (repoInfo == null) {
+ _state.update {
+ it.copy(
+ isValidatingRepo = false,
+ repoValidationError = "Repository not found: $owner/$repo",
+ )
+ }
+ return@launch
+ }
+
+ appsRepository.linkAppToRepo(selectedApp, repoInfo)
+
+ _state.update {
+ it.copy(
+ isValidatingRepo = false,
+ showLinkSheet = false,
+ )
+ }
+
+ _events.send(AppsEvent.AppLinkedSuccessfully(selectedApp.appName))
+ _events.send(AppsEvent.ShowSuccess("${selectedApp.appName} linked to ${repoInfo.owner}/${repoInfo.name}"))
+ } catch (e: RateLimitException) {
+ _state.update {
+ it.copy(
+ isValidatingRepo = false,
+ repoValidationError = "GitHub API rate limit exceeded. Try again later.",
+ )
+ }
+ } catch (e: Exception) {
+ logger.error("Failed to link app: ${e.message}")
+ _state.update {
+ it.copy(
+ isValidatingRepo = false,
+ repoValidationError = "Failed to link: ${e.message}",
+ )
+ }
+ }
+ }
+ }
+
+ private fun parseGithubUrl(input: String): Pair? {
+ val cleaned = input.trim()
+ .removePrefix("https://")
+ .removePrefix("http://")
+ .removePrefix("www.")
+ .removePrefix("github.com/")
+ .removeSuffix("/")
+ .split("?")[0] // remove query params
+ .split("#")[0] // remove fragment
+
+ val parts = cleaned.split("/")
+ if (parts.size < 2) return null
+
+ val owner = parts[0]
+ val repo = parts[1]
+
+ if (owner.isBlank() || repo.isBlank()) return null
+ if (owner.length > 39 || repo.length > 100) return null
+
+ return owner to repo
+ }
+
+ // ── Export/Import ─────────────────────────────────────────────
+
+ private fun exportApps() {
+ viewModelScope.launch {
+ _state.update { it.copy(isExporting = true) }
+ try {
+ val json = appsRepository.exportApps()
+ val fileName = "github-store-apps-${System.currentTimeMillis()}.json"
+ shareManager.shareFile(fileName, json)
+ _events.send(AppsEvent.ExportReady(json))
+ } catch (e: Exception) {
+ logger.error("Export failed: ${e.message}")
+ _events.send(AppsEvent.ShowError("Export failed: ${e.message}"))
+ } finally {
+ _state.update { it.copy(isExporting = false) }
+ }
+ }
+ }
+
+ private fun importAppsFromFile() {
+ shareManager.pickFile("application/json") { content ->
+ if (content != null) {
+ viewModelScope.launch {
+ importApps(content)
+ }
+ }
+ }
+ }
+
+ private suspend fun importApps(json: String) {
+ _state.update { it.copy(isImporting = true) }
+ try {
+ val result = appsRepository.importApps(json)
+ _events.send(AppsEvent.ImportComplete(result))
+ _events.send(
+ AppsEvent.ShowSuccess(
+ "Imported ${result.imported} apps" +
+ if (result.skipped > 0) ", ${result.skipped} skipped" else "" +
+ if (result.failed > 0) ", ${result.failed} failed" else "",
+ ),
+ )
+ } catch (e: Exception) {
+ logger.error("Import failed: ${e.message}")
+ _events.send(AppsEvent.ShowError("Import failed: ${e.message}"))
+ } finally {
+ _state.update { it.copy(isImporting = false) }
+ }
+ }
+
override fun onCleared() {
super.onCleared()
diff --git a/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt
new file mode 100644
index 00000000..9b84ed75
--- /dev/null
+++ b/feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/LinkAppBottomSheet.kt
@@ -0,0 +1,342 @@
+package zed.rainxch.apps.presentation.components
+
+import androidx.compose.animation.AnimatedContent
+import androidx.compose.animation.fadeIn
+import androidx.compose.animation.fadeOut
+import androidx.compose.animation.slideInHorizontally
+import androidx.compose.animation.slideOutHorizontally
+import androidx.compose.animation.togetherWith
+import androidx.compose.foundation.clickable
+import androidx.compose.foundation.layout.Arrangement
+import androidx.compose.foundation.layout.Box
+import androidx.compose.foundation.layout.Column
+import androidx.compose.foundation.layout.Row
+import androidx.compose.foundation.layout.Spacer
+import androidx.compose.foundation.layout.fillMaxWidth
+import androidx.compose.foundation.layout.height
+import androidx.compose.foundation.layout.padding
+import androidx.compose.foundation.layout.size
+import androidx.compose.foundation.layout.width
+import androidx.compose.foundation.lazy.LazyColumn
+import androidx.compose.foundation.lazy.items
+import androidx.compose.foundation.shape.RoundedCornerShape
+import androidx.compose.material.icons.Icons
+import androidx.compose.material.icons.automirrored.filled.ArrowBack
+import androidx.compose.material.icons.filled.Search
+import androidx.compose.material3.CircularProgressIndicator
+import androidx.compose.material3.ExperimentalMaterial3Api
+import androidx.compose.material3.FilledTonalButton
+import androidx.compose.material3.HorizontalDivider
+import androidx.compose.material3.Icon
+import androidx.compose.material3.IconButton
+import androidx.compose.material3.MaterialTheme
+import androidx.compose.material3.ModalBottomSheet
+import androidx.compose.material3.OutlinedTextField
+import androidx.compose.material3.Text
+import androidx.compose.material3.TextField
+import androidx.compose.material3.TextFieldDefaults
+import androidx.compose.material3.rememberModalBottomSheetState
+import androidx.compose.runtime.Composable
+import androidx.compose.ui.Alignment
+import androidx.compose.ui.Modifier
+import androidx.compose.ui.draw.clip
+import androidx.compose.ui.graphics.Color
+import androidx.compose.ui.text.font.FontWeight
+import androidx.compose.ui.text.style.TextOverflow
+import androidx.compose.ui.unit.dp
+import org.jetbrains.compose.resources.stringResource
+import zed.rainxch.apps.presentation.AppsAction
+import zed.rainxch.apps.presentation.AppsState
+import zed.rainxch.apps.presentation.LinkStep
+import zed.rainxch.core.domain.model.DeviceApp
+import zed.rainxch.githubstore.core.presentation.res.*
+
+@OptIn(ExperimentalMaterial3Api::class)
+@Composable
+fun LinkAppBottomSheet(
+ state: AppsState,
+ onAction: (AppsAction) -> Unit,
+) {
+ val sheetState = rememberModalBottomSheetState(skipPartiallyExpanded = true)
+
+ ModalBottomSheet(
+ onDismissRequest = { onAction(AppsAction.OnDismissLinkSheet) },
+ sheetState = sheetState,
+ ) {
+ AnimatedContent(
+ targetState = state.linkStep,
+ transitionSpec = {
+ if (targetState == LinkStep.EnterUrl) {
+ (slideInHorizontally { it } + fadeIn()) togetherWith
+ (slideOutHorizontally { -it } + fadeOut())
+ } else {
+ (slideInHorizontally { -it } + fadeIn()) togetherWith
+ (slideOutHorizontally { it } + fadeOut())
+ }
+ },
+ label = "link_step",
+ ) { step ->
+ when (step) {
+ LinkStep.PickApp -> PickAppStep(
+ deviceApps = state.filteredDeviceApps,
+ searchQuery = state.deviceAppSearchQuery,
+ onSearchChange = { onAction(AppsAction.OnDeviceAppSearchChange(it)) },
+ onAppSelected = { onAction(AppsAction.OnDeviceAppSelected(it)) },
+ )
+
+ LinkStep.EnterUrl -> EnterUrlStep(
+ selectedApp = state.selectedDeviceApp,
+ repoUrl = state.repoUrl,
+ isValidating = state.isValidatingRepo,
+ validationError = state.repoValidationError,
+ onUrlChanged = { onAction(AppsAction.OnRepoUrlChanged(it)) },
+ onConfirm = { onAction(AppsAction.OnValidateAndLinkRepo) },
+ onBack = { onAction(AppsAction.OnBackToAppPicker) },
+ )
+ }
+ }
+ }
+}
+
+@Composable
+private fun PickAppStep(
+ deviceApps: List,
+ searchQuery: String,
+ onSearchChange: (String) -> Unit,
+ onAppSelected: (DeviceApp) -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp),
+ ) {
+ Text(
+ text = stringResource(Res.string.link_app_title),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+
+ Spacer(Modifier.height(4.dp))
+
+ Text(
+ text = stringResource(Res.string.pick_installed_app),
+ style = MaterialTheme.typography.bodyMedium,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+
+ Spacer(Modifier.height(12.dp))
+
+ TextField(
+ value = searchQuery,
+ onValueChange = onSearchChange,
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(16.dp)),
+ placeholder = {
+ Text(stringResource(Res.string.search_apps_hint))
+ },
+ leadingIcon = {
+ Icon(
+ imageVector = Icons.Default.Search,
+ contentDescription = null,
+ )
+ },
+ singleLine = true,
+ colors = TextFieldDefaults.colors(
+ focusedIndicatorColor = Color.Transparent,
+ unfocusedIndicatorColor = Color.Transparent,
+ ),
+ )
+
+ Spacer(Modifier.height(8.dp))
+
+ LazyColumn(
+ modifier = Modifier
+ .fillMaxWidth()
+ .height(400.dp),
+ ) {
+ items(
+ items = deviceApps,
+ key = { it.packageName },
+ ) { app ->
+ DeviceAppItem(
+ app = app,
+ onClick = { onAppSelected(app) },
+ )
+ HorizontalDivider(
+ color = MaterialTheme.colorScheme.outlineVariant.copy(alpha = 0.3f),
+ )
+ }
+
+ if (deviceApps.isEmpty()) {
+ item {
+ Box(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(32.dp),
+ contentAlignment = Alignment.Center,
+ ) {
+ Text(
+ text = stringResource(Res.string.no_apps_found),
+ style = MaterialTheme.typography.bodyLarge,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+ }
+}
+
+@Composable
+private fun DeviceAppItem(
+ app: DeviceApp,
+ onClick: () -> Unit,
+) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clickable(onClick = onClick)
+ .padding(vertical = 12.dp, horizontal = 4.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ ) {
+ Text(
+ text = app.appName,
+ style = MaterialTheme.typography.bodyLarge,
+ fontWeight = FontWeight.Medium,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ Text(
+ text = app.packageName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ maxLines = 1,
+ overflow = TextOverflow.Ellipsis,
+ )
+ }
+
+ Spacer(Modifier.width(8.dp))
+
+ app.versionName?.let { version ->
+ Text(
+ text = version,
+ style = MaterialTheme.typography.labelMedium,
+ color = MaterialTheme.colorScheme.outline,
+ )
+ }
+ }
+}
+
+@Composable
+private fun EnterUrlStep(
+ selectedApp: DeviceApp?,
+ repoUrl: String,
+ isValidating: Boolean,
+ validationError: String?,
+ onUrlChanged: (String) -> Unit,
+ onConfirm: () -> Unit,
+ onBack: () -> Unit,
+) {
+ Column(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(horizontal = 16.dp)
+ .padding(bottom = 24.dp),
+ ) {
+ Row(
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ IconButton(onClick = onBack) {
+ Icon(
+ imageVector = Icons.AutoMirrored.Filled.ArrowBack,
+ contentDescription = null,
+ )
+ }
+
+ Text(
+ text = stringResource(Res.string.link_app_title),
+ style = MaterialTheme.typography.titleLarge,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ // Selected app info
+ if (selectedApp != null) {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .clip(RoundedCornerShape(12.dp))
+ .padding(12.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ ) {
+ Column(modifier = Modifier.weight(1f)) {
+ Text(
+ text = selectedApp.appName,
+ style = MaterialTheme.typography.titleMedium,
+ fontWeight = FontWeight.SemiBold,
+ )
+ Text(
+ text = selectedApp.packageName,
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant,
+ )
+ }
+ selectedApp.versionName?.let {
+ Text(
+ text = it,
+ style = MaterialTheme.typography.labelLarge,
+ color = MaterialTheme.colorScheme.primary,
+ )
+ }
+ }
+ }
+
+ Spacer(Modifier.height(16.dp))
+
+ OutlinedTextField(
+ value = repoUrl,
+ onValueChange = onUrlChanged,
+ modifier = Modifier.fillMaxWidth(),
+ label = { Text(stringResource(Res.string.enter_repo_url)) },
+ placeholder = { Text(stringResource(Res.string.repo_url_hint)) },
+ singleLine = true,
+ isError = validationError != null,
+ supportingText = validationError?.let {
+ { Text(it, color = MaterialTheme.colorScheme.error) }
+ },
+ shape = RoundedCornerShape(12.dp),
+ )
+
+ Spacer(Modifier.height(20.dp))
+
+ FilledTonalButton(
+ onClick = onConfirm,
+ modifier = Modifier.fillMaxWidth(),
+ enabled = repoUrl.isNotBlank() && !isValidating,
+ shape = RoundedCornerShape(12.dp),
+ ) {
+ if (isValidating) {
+ CircularProgressIndicator(
+ modifier = Modifier.size(20.dp),
+ strokeWidth = 2.dp,
+ )
+ Spacer(Modifier.width(8.dp))
+ Text(stringResource(Res.string.validating_repo))
+ } else {
+ Text(
+ text = stringResource(Res.string.link_and_track),
+ style = MaterialTheme.typography.titleSmall,
+ fontWeight = FontWeight.Bold,
+ )
+ }
+ }
+ }
+}
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt
index e60447b5..9594dd12 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsAction.kt
@@ -14,6 +14,9 @@ sealed interface DetailsAction {
data object OnDismissDowngradeWarning : DetailsAction
data object UninstallApp : DetailsAction
+ data object OnRequestUninstall : DetailsAction
+ data object OnDismissUninstallConfirmation : DetailsAction
+ data object OnConfirmUninstall : DetailsAction
data class DownloadAsset(
val downloadUrl: String,
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
index f61c236f..e573949d 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsRoot.kt
@@ -189,6 +189,47 @@ fun DetailsRoot(
)
}
+ // Uninstall confirmation dialog
+ if (state.showUninstallConfirmation) {
+ val appName = state.installedApp?.appName ?: ""
+ AlertDialog(
+ onDismissRequest = {
+ viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation)
+ },
+ title = {
+ Text(
+ text = stringResource(Res.string.confirm_uninstall_title),
+ )
+ },
+ text = {
+ Text(
+ text = stringResource(Res.string.confirm_uninstall_message, appName),
+ )
+ },
+ confirmButton = {
+ TextButton(
+ onClick = {
+ viewModel.onAction(DetailsAction.OnConfirmUninstall)
+ },
+ ) {
+ Text(
+ text = stringResource(Res.string.uninstall),
+ color = MaterialTheme.colorScheme.error,
+ )
+ }
+ },
+ dismissButton = {
+ TextButton(
+ onClick = {
+ viewModel.onAction(DetailsAction.OnDismissUninstallConfirmation)
+ },
+ ) {
+ Text(text = stringResource(Res.string.cancel))
+ }
+ },
+ )
+ }
+
if (state.showExternalInstallerPrompt) {
AlertDialog(
onDismissRequest = {
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt
index bd337ee6..e137302b 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsState.kt
@@ -61,6 +61,7 @@ data class DetailsState(
val downgradeWarning: DowngradeWarning? = null,
val showExternalInstallerPrompt: Boolean = false,
val pendingInstallFilePath: String? = null,
+ val showUninstallConfirmation: Boolean = false,
) {
val filteredReleases: List
get() =
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
index b32c05a9..3ef82419 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
@@ -399,7 +399,34 @@ class DetailsViewModel(
}
}
+ DetailsAction.OnRequestUninstall -> {
+ _state.update { it.copy(showUninstallConfirmation = true) }
+ }
+
+ DetailsAction.OnDismissUninstallConfirmation -> {
+ _state.update { it.copy(showUninstallConfirmation = false) }
+ }
+
+ DetailsAction.OnConfirmUninstall -> {
+ _state.update { it.copy(showUninstallConfirmation = false) }
+ val installedApp = _state.value.installedApp ?: return
+ logger.debug("Uninstalling app (confirmed): ${installedApp.packageName}")
+ viewModelScope.launch {
+ try {
+ installer.uninstall(installedApp.packageName)
+ } catch (e: Exception) {
+ logger.error("Failed to request uninstall for ${installedApp.packageName}: ${e.message}")
+ _events.send(
+ DetailsEvent.OnMessage(
+ getString(Res.string.failed_to_uninstall, installedApp.packageName),
+ ),
+ )
+ }
+ }
+ }
+
DetailsAction.UninstallApp -> {
+ // Legacy direct uninstall (used from downgrade warning flow)
val installedApp = _state.value.installedApp ?: return
logger.debug("Uninstalling app: ${installedApp.packageName}")
viewModelScope.launch {
diff --git a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt
index 17dbd1ad..766e7473 100644
--- a/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt
+++ b/feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/components/SmartInstallButton.kt
@@ -104,7 +104,7 @@ fun SmartInstallButton(
) {
// Uninstall button
ElevatedCard(
- onClick = { onAction(DetailsAction.UninstallApp) },
+ onClick = { onAction(DetailsAction.OnRequestUninstall) },
modifier =
Modifier
.weight(1f)
diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt
index 5306e43a..e160afdd 100644
--- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt
+++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileAction.kt
@@ -71,6 +71,7 @@ sealed interface ProfileAction {
data object OnRequestShizukuPermission : ProfileAction
data class OnAutoUpdateToggled(val enabled: Boolean) : ProfileAction
data class OnUpdateCheckIntervalChanged(val hours: Long) : ProfileAction
+ data class OnIncludePreReleasesToggled(val enabled: Boolean) : ProfileAction
data class OnAutoDetectClipboardToggled(
val enabled: Boolean,
diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt
index 647a184a..0c0d8afa 100644
--- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt
+++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileState.kt
@@ -28,4 +28,5 @@ data class ProfileState(
val shizukuAvailability: ShizukuAvailability = ShizukuAvailability.UNAVAILABLE,
val autoUpdateEnabled: Boolean = false,
val updateCheckIntervalHours: Long = 6L,
+ val includePreReleases: Boolean = false,
)
diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt
index 2d2e9d2e..75f452b4 100644
--- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt
+++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/ProfileViewModel.kt
@@ -51,6 +51,7 @@ class ProfileViewModel(
observeShizukuStatus()
loadAutoUpdatePreference()
loadUpdateCheckInterval()
+ loadIncludePreReleases()
hasLoadedInitialData = true
}
@@ -242,6 +243,16 @@ class ProfileViewModel(
}
}
+ private fun loadIncludePreReleases() {
+ viewModelScope.launch {
+ themesRepository.getIncludePreReleases().collect { enabled ->
+ _state.update {
+ it.copy(includePreReleases = enabled)
+ }
+ }
+ }
+ }
+
fun onAction(action: ProfileAction) {
when (action) {
ProfileAction.OnHelpClick -> {
@@ -422,6 +433,12 @@ class ProfileViewModel(
}
}
+ is ProfileAction.OnIncludePreReleasesToggled -> {
+ viewModelScope.launch {
+ themesRepository.setIncludePreReleases(action.enabled)
+ }
+ }
+
ProfileAction.OnProxySave -> {
val currentState = _state.value
val port =
diff --git a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt
index f11cea95..5af80b49 100644
--- a/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt
+++ b/feature/profile/presentation/src/commonMain/kotlin/zed/rainxch/profile/presentation/components/sections/Installation.kt
@@ -123,6 +123,15 @@ fun LazyListScope.updatesSection(
onAction(ProfileAction.OnUpdateCheckIntervalChanged(hours))
}
)
+
+ Spacer(Modifier.height(12.dp))
+
+ PreReleaseToggleCard(
+ enabled = state.includePreReleases,
+ onToggle = { enabled ->
+ onAction(ProfileAction.OnIncludePreReleasesToggled(enabled))
+ }
+ )
}
}
@@ -449,6 +458,44 @@ private fun UpdateCheckIntervalCard(
}
}
+@OptIn(ExperimentalMaterial3ExpressiveApi::class)
+@Composable
+private fun PreReleaseToggleCard(
+ enabled: Boolean,
+ onToggle: (Boolean) -> Unit,
+) {
+ ExpressiveCard {
+ Row(
+ modifier = Modifier
+ .fillMaxWidth()
+ .padding(16.dp),
+ verticalAlignment = Alignment.CenterVertically,
+ horizontalArrangement = Arrangement.spacedBy(12.dp)
+ ) {
+ Column(
+ modifier = Modifier.weight(1f),
+ verticalArrangement = Arrangement.spacedBy(2.dp)
+ ) {
+ Text(
+ text = stringResource(Res.string.include_pre_releases_title),
+ style = MaterialTheme.typography.titleMedium,
+ color = MaterialTheme.colorScheme.onSurface,
+ fontWeight = FontWeight.SemiBold
+ )
+ Text(
+ text = stringResource(Res.string.include_pre_releases_description),
+ style = MaterialTheme.typography.bodySmall,
+ color = MaterialTheme.colorScheme.onSurfaceVariant
+ )
+ }
+ Switch(
+ checked = enabled,
+ onCheckedChange = onToggle
+ )
+ }
+ }
+}
+
@Composable
private fun HintText(text: String) {
Text(