Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions composeApp/src/androidMain/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -125,6 +125,12 @@
</intent-filter>
</receiver>

<!-- WorkManager foreground service declaration for update workers -->
<service
android:name="androidx.work.impl.foreground.SystemForegroundService"
android:foregroundServiceType="dataSync"
tools:node="merge" />

<!-- Shizuku provider for optional silent install support -->
<provider
android:name="rikka.shizuku.ShizukuProvider"
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -13,15 +13,22 @@ import androidx.compose.runtime.setValue
import androidx.compose.ui.tooling.preview.Preview
import androidx.core.splashscreen.SplashScreen.Companion.installSplashScreen
import androidx.core.util.Consumer
import org.koin.android.ext.android.inject
import zed.rainxch.core.data.utils.AndroidShareManager
import zed.rainxch.core.domain.utils.ShareManager
import zed.rainxch.githubstore.app.deeplink.DeepLinkParser

class MainActivity : ComponentActivity() {
private var deepLinkUri by mutableStateOf<String?>(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)
Expand Down
4 changes: 4 additions & 0 deletions composeApp/src/androidMain/res/xml/filepaths.xml
Original file line number Diff line number Diff line change
Expand Up @@ -5,4 +5,8 @@
name="ghs_downloads"
path="/" />

<cache-path
name="exports"
path="exports/" />

</paths>
Original file line number Diff line number Diff line change
@@ -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

Expand Down Expand Up @@ -55,4 +57,40 @@ class AndroidPackageMonitor(

packages.map { it.packageName }.toSet()
}

override suspend fun getAllInstalledApps(): List<DeviceApp> =
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() }
}
}
Original file line number Diff line number Diff line change
@@ -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<Intent>? = 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 {
Expand All @@ -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)
}
}
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -75,6 +75,7 @@ val coreModule =
historyDao = get(),
installer = get(),
httpClient = get(),
themesRepository = get(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -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(
Expand All @@ -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 <R> executeInTransaction(block: suspend () -> R): R =
database.useWriterConnection { transactor ->
Expand Down Expand Up @@ -76,6 +78,8 @@ class InstalledAppsRepositoryImpl(
repo: String,
): GithubRelease? {
return try {
val includePreReleases = themesRepository.getIncludePreReleases().first()

val releases =
httpClient
.executeRequest<List<ReleaseNetwork>> {
Expand All @@ -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

Expand Down Expand Up @@ -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}: " +
Expand Down Expand Up @@ -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<Int>,
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)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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<AppTheme> =
preferences.data.map { prefs ->
Expand Down Expand Up @@ -121,6 +122,17 @@ class ThemesRepositoryImpl(
}
}

override fun getIncludePreReleases(): Flow<Boolean> =
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
}
Expand Down
Original file line number Diff line number Diff line change
@@ -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

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

override suspend fun getAllInstalledPackageNames(): Set<String> = setOf()

override suspend fun getAllInstalledApps(): List<DeviceApp> = emptyList()
}
Loading