Skip to content

Commit 3d8f6a8

Browse files
authored
Merge pull request #331 from OpenHub-Store/feat-manual-install
2 parents 6f92c12 + 456056a commit 3d8f6a8

File tree

37 files changed

+1468
-5
lines changed

37 files changed

+1468
-5
lines changed

composeApp/src/androidMain/AndroidManifest.xml

Lines changed: 6 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -125,6 +125,12 @@
125125
</intent-filter>
126126
</receiver>
127127

128+
<!-- WorkManager foreground service declaration for update workers -->
129+
<service
130+
android:name="androidx.work.impl.foreground.SystemForegroundService"
131+
android:foregroundServiceType="dataSync"
132+
tools:node="merge" />
133+
128134
<!-- Shizuku provider for optional silent install support -->
129135
<provider
130136
android:name="rikka.shizuku.ShizukuProvider"

composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/MainActivity.kt

Lines changed: 7 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -13,15 +13,22 @@ import androidx.compose.runtime.setValue
1313
import androidx.compose.ui.tooling.preview.Preview
1414
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
1515
import androidx.core.util.Consumer
16+
import org.koin.android.ext.android.inject
17+
import zed.rainxch.core.data.utils.AndroidShareManager
18+
import zed.rainxch.core.domain.utils.ShareManager
1619
import zed.rainxch.githubstore.app.deeplink.DeepLinkParser
1720

1821
class MainActivity : ComponentActivity() {
1922
private var deepLinkUri by mutableStateOf<String?>(null)
23+
private val shareManager: ShareManager by inject()
2024

2125
override fun onCreate(savedInstanceState: Bundle?) {
2226
installSplashScreen()
2327
enableEdgeToEdge()
2428

29+
// Register activity result launcher for file picker (must be before STARTED)
30+
(shareManager as? AndroidShareManager)?.registerActivityResultLauncher(this)
31+
2532
super.onCreate(savedInstanceState)
2633

2734
handleIncomingIntent(intent)

composeApp/src/androidMain/res/xml/filepaths.xml

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -5,4 +5,8 @@
55
name="ghs_downloads"
66
path="/" />
77

8+
<cache-path
9+
name="exports"
10+
path="exports/" />
11+
812
</paths>

core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AndroidPackageMonitor.kt

Lines changed: 38 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,12 @@
11
package zed.rainxch.core.data.services
22

33
import android.content.Context
4+
import android.content.pm.ApplicationInfo
45
import android.content.pm.PackageManager
56
import android.os.Build
67
import kotlinx.coroutines.Dispatchers
78
import kotlinx.coroutines.withContext
9+
import zed.rainxch.core.domain.model.DeviceApp
810
import zed.rainxch.core.domain.model.SystemPackageInfo
911
import zed.rainxch.core.domain.system.PackageMonitor
1012

@@ -55,4 +57,40 @@ class AndroidPackageMonitor(
5557

5658
packages.map { it.packageName }.toSet()
5759
}
60+
61+
override suspend fun getAllInstalledApps(): List<DeviceApp> =
62+
withContext(Dispatchers.IO) {
63+
val packages =
64+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) {
65+
packageManager.getInstalledPackages(PackageManager.PackageInfoFlags.of(0L))
66+
} else {
67+
@Suppress("DEPRECATION")
68+
packageManager.getInstalledPackages(0)
69+
}
70+
71+
packages
72+
.filter { pkg ->
73+
// Exclude system apps (keep user-installed + updated system apps)
74+
val isSystemApp = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_SYSTEM != 0
75+
val isUpdatedSystem = (pkg.applicationInfo?.flags ?: 0) and ApplicationInfo.FLAG_UPDATED_SYSTEM_APP != 0
76+
!isSystemApp || isUpdatedSystem
77+
}
78+
.map { pkg ->
79+
val versionCode =
80+
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) {
81+
pkg.longVersionCode
82+
} else {
83+
@Suppress("DEPRECATION")
84+
pkg.versionCode.toLong()
85+
}
86+
87+
DeviceApp(
88+
packageName = pkg.packageName,
89+
appName = pkg.applicationInfo?.loadLabel(packageManager)?.toString() ?: pkg.packageName,
90+
versionName = pkg.versionName,
91+
versionCode = versionCode,
92+
)
93+
}
94+
.sortedBy { it.appName.lowercase() }
95+
}
5896
}

core/data/src/androidMain/kotlin/zed/rainxch/core/data/utils/AndroidShareManager.kt

Lines changed: 93 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,49 @@
11
package zed.rainxch.core.data.utils
22

3+
import android.app.Activity
34
import android.content.Context
45
import android.content.Intent
6+
import android.net.Uri
7+
import androidx.activity.ComponentActivity
8+
import androidx.activity.result.ActivityResultLauncher
9+
import androidx.activity.result.contract.ActivityResultContracts
10+
import androidx.core.content.FileProvider
511
import zed.rainxch.core.domain.utils.ShareManager
12+
import java.io.File
613

714
class AndroidShareManager(
815
private val context: Context,
916
) : ShareManager {
17+
private var filePickerCallback: ((String?) -> Unit)? = null
18+
private var filePickerLauncher: ActivityResultLauncher<Intent>? = null
19+
20+
fun registerActivityResultLauncher(activity: ComponentActivity) {
21+
filePickerLauncher = activity.registerForActivityResult(
22+
ActivityResultContracts.StartActivityForResult()
23+
) { result ->
24+
val callback = filePickerCallback
25+
filePickerCallback = null
26+
27+
if (result.resultCode == Activity.RESULT_OK) {
28+
val uri = result.data?.data
29+
if (uri != null) {
30+
try {
31+
val content = context.contentResolver.openInputStream(uri)
32+
?.bufferedReader()
33+
?.use { it.readText() }
34+
callback?.invoke(content)
35+
} catch (e: Exception) {
36+
callback?.invoke(null)
37+
}
38+
} else {
39+
callback?.invoke(null)
40+
}
41+
} else {
42+
callback?.invoke(null)
43+
}
44+
}
45+
}
46+
1047
override fun shareText(text: String) {
1148
val intent =
1249
Intent(Intent.ACTION_SEND).apply {
@@ -21,4 +58,60 @@ class AndroidShareManager(
2158

2259
context.startActivity(chooser)
2360
}
61+
62+
override fun shareFile(fileName: String, content: String, mimeType: String) {
63+
val cacheDir = File(context.cacheDir, "exports")
64+
cacheDir.mkdirs()
65+
66+
val file = File(cacheDir, fileName)
67+
file.writeText(content)
68+
69+
val uri: Uri = FileProvider.getUriForFile(
70+
context,
71+
"${context.packageName}.fileprovider",
72+
file
73+
)
74+
75+
val intent = Intent(Intent.ACTION_SEND).apply {
76+
type = mimeType
77+
putExtra(Intent.EXTRA_STREAM, uri)
78+
addFlags(Intent.FLAG_GRANT_READ_URI_PERMISSION)
79+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
80+
}
81+
82+
val chooser = Intent.createChooser(intent, null).apply {
83+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
84+
}
85+
86+
context.startActivity(chooser)
87+
}
88+
89+
override fun pickFile(mimeType: String, onResult: (String?) -> Unit) {
90+
filePickerCallback = onResult
91+
92+
val launcher = filePickerLauncher
93+
if (launcher != null) {
94+
val intent = Intent(Intent.ACTION_OPEN_DOCUMENT).apply {
95+
addCategory(Intent.CATEGORY_OPENABLE)
96+
type = mimeType
97+
}
98+
launcher.launch(intent)
99+
} else {
100+
// Fallback: try with ACTION_GET_CONTENT
101+
val intent = Intent(Intent.ACTION_GET_CONTENT).apply {
102+
addCategory(Intent.CATEGORY_OPENABLE)
103+
type = mimeType
104+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
105+
}
106+
try {
107+
context.startActivity(Intent.createChooser(intent, null).apply {
108+
addFlags(Intent.FLAG_ACTIVITY_NEW_TASK)
109+
})
110+
// Note: fallback won't deliver result without launcher
111+
onResult(null)
112+
} catch (e: Exception) {
113+
onResult(null)
114+
}
115+
}
116+
}
24117
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,7 @@ val coreModule =
7575
historyDao = get(),
7676
installer = get(),
7777
httpClient = get(),
78+
themesRepository = get(),
7879
)
7980
}
8081

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt

Lines changed: 98 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -23,6 +23,7 @@ import zed.rainxch.core.domain.model.GithubRelease
2323
import zed.rainxch.core.domain.model.InstallSource
2424
import zed.rainxch.core.domain.model.InstalledApp
2525
import zed.rainxch.core.domain.repository.InstalledAppsRepository
26+
import zed.rainxch.core.domain.repository.ThemesRepository
2627
import zed.rainxch.core.domain.system.Installer
2728

2829
class InstalledAppsRepositoryImpl(
@@ -31,6 +32,7 @@ class InstalledAppsRepositoryImpl(
3132
private val historyDao: UpdateHistoryDao,
3233
private val installer: Installer,
3334
private val httpClient: HttpClient,
35+
private val themesRepository: ThemesRepository,
3436
) : InstalledAppsRepository {
3537
override suspend fun <R> executeInTransaction(block: suspend () -> R): R =
3638
database.useWriterConnection { transactor ->
@@ -76,6 +78,8 @@ class InstalledAppsRepositoryImpl(
7678
repo: String,
7779
): GithubRelease? {
7880
return try {
81+
val includePreReleases = themesRepository.getIncludePreReleases().first()
82+
7983
val releases =
8084
httpClient
8185
.executeRequest<List<ReleaseNetwork>> {
@@ -88,7 +92,8 @@ class InstalledAppsRepositoryImpl(
8892
val latest =
8993
releases
9094
.asSequence()
91-
.filter { (it.draft != true) && (it.prerelease != true) }
95+
.filter { it.draft != true }
96+
.filter { includePreReleases || it.prerelease != true }
9297
.maxByOrNull { it.publishedAt ?: it.createdAt ?: "" }
9398
?: return null
9499

@@ -119,7 +124,13 @@ class InstalledAppsRepositoryImpl(
119124
}
120125
val primaryAsset = installer.choosePrimaryAsset(installableAssets)
121126

122-
val isUpdateAvailable = normalizedInstalledTag != normalizedLatestTag
127+
// Only flag as update if the latest version is actually newer
128+
// (not just different — avoids false "downgrade" notifications)
129+
val isUpdateAvailable = if (normalizedInstalledTag == normalizedLatestTag) {
130+
false
131+
} else {
132+
isVersionNewer(normalizedLatestTag, normalizedInstalledTag)
133+
}
123134

124135
Logger.d {
125136
"Update check for ${app.appName}: " +
@@ -226,4 +237,89 @@ class InstalledAppsRepositoryImpl(
226237
}
227238

228239
private fun normalizeVersion(version: String): String = version.removePrefix("v").removePrefix("V").trim()
240+
241+
/**
242+
* Compare two version strings and return true if [candidate] is newer than [current].
243+
* Handles semantic versioning (1.2.3), pre-release suffixes (1.2.3-beta.1),
244+
* and falls back to lexicographic comparison for non-standard formats.
245+
*
246+
* Pre-release versions are considered older than their stable counterparts:
247+
* 1.2.3-beta < 1.2.3 (per semver spec)
248+
*
249+
* This prevents false "downgrade" notifications when a user has a pre-release
250+
* installed and the latest stable version has a lower or equal base version.
251+
*/
252+
private fun isVersionNewer(candidate: String, current: String): Boolean {
253+
val candidateParsed = parseSemanticVersion(candidate)
254+
val currentParsed = parseSemanticVersion(current)
255+
256+
if (candidateParsed != null && currentParsed != null) {
257+
// Compare major.minor.patch
258+
for (i in 0 until maxOf(candidateParsed.numbers.size, currentParsed.numbers.size)) {
259+
val c = candidateParsed.numbers.getOrElse(i) { 0 }
260+
val r = currentParsed.numbers.getOrElse(i) { 0 }
261+
if (c > r) return true
262+
if (c < r) return false
263+
}
264+
// Numbers are equal; compare pre-release suffixes
265+
// No pre-release > has pre-release (e.g., 1.0.0 > 1.0.0-beta)
266+
return when {
267+
candidateParsed.preRelease == null && currentParsed.preRelease != null -> true
268+
candidateParsed.preRelease != null && currentParsed.preRelease == null -> false
269+
candidateParsed.preRelease != null && currentParsed.preRelease != null ->
270+
comparePreRelease(candidateParsed.preRelease, currentParsed.preRelease) > 0
271+
else -> false // both null, versions are equal
272+
}
273+
}
274+
275+
// Fallback: lexicographic comparison (better than just "not equal")
276+
return candidate > current
277+
}
278+
279+
private data class SemanticVersion(
280+
val numbers: List<Int>,
281+
val preRelease: String?,
282+
)
283+
284+
private fun parseSemanticVersion(version: String): SemanticVersion? {
285+
// Split off pre-release suffix: "1.2.3-beta.1" -> "1.2.3" and "beta.1"
286+
val hyphenIndex = version.indexOf('-')
287+
val numberPart = if (hyphenIndex >= 0) version.substring(0, hyphenIndex) else version
288+
val preRelease = if (hyphenIndex >= 0) version.substring(hyphenIndex + 1) else null
289+
290+
val parts = numberPart.split(".")
291+
val numbers = parts.mapNotNull { it.toIntOrNull() }
292+
293+
// Only valid if we could parse at least one number and all parts were valid numbers
294+
if (numbers.isEmpty() || numbers.size != parts.size) return null
295+
296+
return SemanticVersion(numbers, preRelease)
297+
}
298+
299+
/**
300+
* Compare pre-release identifiers per semver spec:
301+
* Identifiers consisting of only digits are compared numerically.
302+
* Identifiers with letters are compared lexically.
303+
* Numeric identifiers always have lower precedence than alphanumeric.
304+
* A larger set of pre-release fields has higher precedence if all preceding are equal.
305+
*/
306+
private fun comparePreRelease(a: String, b: String): Int {
307+
val aParts = a.split(".")
308+
val bParts = b.split(".")
309+
310+
for (i in 0 until minOf(aParts.size, bParts.size)) {
311+
val aNum = aParts[i].toIntOrNull()
312+
val bNum = bParts[i].toIntOrNull()
313+
314+
val cmp = when {
315+
aNum != null && bNum != null -> aNum.compareTo(bNum)
316+
aNum != null -> -1 // numeric < alphanumeric
317+
bNum != null -> 1
318+
else -> aParts[i].compareTo(bParts[i])
319+
}
320+
if (cmp != 0) return cmp
321+
}
322+
323+
return aParts.size.compareTo(bParts.size)
324+
}
229325
}

core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ThemesRepositoryImpl.kt

Lines changed: 12 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -24,6 +24,7 @@ class ThemesRepositoryImpl(
2424
private val INSTALLER_TYPE_KEY = stringPreferencesKey("installer_type")
2525
private val AUTO_UPDATE_KEY = booleanPreferencesKey("auto_update_enabled")
2626
private val UPDATE_CHECK_INTERVAL_KEY = longPreferencesKey("update_check_interval_hours")
27+
private val INCLUDE_PRE_RELEASES_KEY = booleanPreferencesKey("include_pre_releases")
2728

2829
override fun getThemeColor(): Flow<AppTheme> =
2930
preferences.data.map { prefs ->
@@ -121,6 +122,17 @@ class ThemesRepositoryImpl(
121122
}
122123
}
123124

125+
override fun getIncludePreReleases(): Flow<Boolean> =
126+
preferences.data.map { prefs ->
127+
prefs[INCLUDE_PRE_RELEASES_KEY] ?: false
128+
}
129+
130+
override suspend fun setIncludePreReleases(enabled: Boolean) {
131+
preferences.edit { prefs ->
132+
prefs[INCLUDE_PRE_RELEASES_KEY] = enabled
133+
}
134+
}
135+
124136
companion object {
125137
const val DEFAULT_UPDATE_CHECK_INTERVAL_HOURS = 6L
126138
}

core/data/src/jvmMain/kotlin/zed/rainxch/core/data/services/DesktopPackageMonitor.kt

Lines changed: 3 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -1,5 +1,6 @@
11
package zed.rainxch.core.data.services
22

3+
import zed.rainxch.core.domain.model.DeviceApp
34
import zed.rainxch.core.domain.model.SystemPackageInfo
45
import zed.rainxch.core.domain.system.PackageMonitor
56

@@ -9,4 +10,6 @@ class DesktopPackageMonitor : PackageMonitor {
910
override suspend fun getInstalledPackageInfo(packageName: String): SystemPackageInfo? = null
1011

1112
override suspend fun getAllInstalledPackageNames(): Set<String> = setOf()
13+
14+
override suspend fun getAllInstalledApps(): List<DeviceApp> = emptyList()
1215
}

0 commit comments

Comments
 (0)