From ae03f3382282b7330e2e0e8ba2848e55290e046c Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 15:34:51 +0500 Subject: [PATCH 1/7] feat(domain): InstallerAttribution model and TweaksRepository wiring --- .../data/repository/TweaksRepositoryImpl.kt | 51 +++++++++++++++++++ .../core/domain/model/InstallerAttribution.kt | 43 ++++++++++++++++ .../domain/repository/TweaksRepository.kt | 4 ++ 3 files changed, 98 insertions(+) create mode 100644 core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt index bc8e768e0..c9eade886 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/TweaksRepositoryImpl.kt @@ -95,6 +95,56 @@ class TweaksRepositoryImpl( } } + override fun getInstallerAttribution(): Flow = + preferences.data.map { prefs -> + val raw = prefs[INSTALLER_ATTRIBUTION_KEY] + decodeInstallerAttribution(raw) + } + + override suspend fun setInstallerAttribution( + attribution: zed.rainxch.core.domain.model.InstallerAttribution, + ) { + preferences.edit { prefs -> + prefs[INSTALLER_ATTRIBUTION_KEY] = encodeInstallerAttribution(attribution) + } + } + + private fun decodeInstallerAttribution( + raw: String?, + ): zed.rainxch.core.domain.model.InstallerAttribution { + if (raw.isNullOrBlank()) return zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault + val parts = raw.split(":", limit = 2) + return when (parts[0]) { + "preset" -> { + val key = parts.getOrNull(1)?.let { + zed.rainxch.core.domain.model.PresetKey.fromName(it) + } + if (key != null) { + zed.rainxch.core.domain.model.InstallerAttribution.Preset(key) + } else { + zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault + } + } + "custom" -> { + val name = parts.getOrNull(1).orEmpty() + if (name.isNotBlank()) { + zed.rainxch.core.domain.model.InstallerAttribution.Custom(name) + } else { + zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault + } + } + else -> zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault + } + } + + private fun encodeInstallerAttribution( + attribution: zed.rainxch.core.domain.model.InstallerAttribution, + ): String = when (attribution) { + zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault -> "" + is zed.rainxch.core.domain.model.InstallerAttribution.Preset -> "preset:${attribution.key.name}" + is zed.rainxch.core.domain.model.InstallerAttribution.Custom -> "custom:${attribution.packageName.trim()}" + } + override fun getAutoUpdateEnabled(): Flow = preferences.data.map { prefs -> prefs[AUTO_UPDATE_KEY] ?: false @@ -368,6 +418,7 @@ class TweaksRepositoryImpl( private val DISCOVERY_PLATFORMS_KEY = stringSetPreferencesKey("discovery_platforms") private val AUTO_DETECT_CLIPBOARD_KEY = booleanPreferencesKey("auto_detect_clipboard_links") private val INSTALLER_TYPE_KEY = stringPreferencesKey("installer_type") + private val INSTALLER_ATTRIBUTION_KEY = stringPreferencesKey("installer_attribution") 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") diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt new file mode 100644 index 000000000..6c97909f0 --- /dev/null +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt @@ -0,0 +1,43 @@ +package zed.rainxch.core.domain.model + +import kotlinx.serialization.Serializable + +@Serializable +sealed interface InstallerAttribution { + @Serializable + data object SystemDefault : InstallerAttribution + + @Serializable + data class Preset(val key: PresetKey) : InstallerAttribution + + @Serializable + data class Custom(val packageName: String) : InstallerAttribution + + fun resolvePackageName(): String? = when (this) { + SystemDefault -> null + is Preset -> key.packageName + is Custom -> packageName.takeIf { it.isNotBlank() } + } +} + +@Serializable +enum class PresetKey(val packageName: String) { + PLAY_STORE("com.android.vending"), + FDROID("org.fdroid.fdroid"), + OBTAINIUM("dev.imranr.obtainium.app"), + ; + + companion object { + fun fromName(name: String?): PresetKey? = entries.find { it.name == name } + } +} + +object InstallerAttributionDefaults { + val packageNamePattern = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+\$", RegexOption.IGNORE_CASE) + + fun isValidPackageName(name: String): Boolean { + val trimmed = name.trim() + if (trimmed.isEmpty()) return false + return packageNamePattern.matches(trimmed) + } +} diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt index c19ec27fb..3f3916fd8 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/TweaksRepository.kt @@ -33,6 +33,10 @@ interface TweaksRepository { suspend fun setInstallerType(type: InstallerType) + fun getInstallerAttribution(): Flow + + suspend fun setInstallerAttribution(attribution: zed.rainxch.core.domain.model.InstallerAttribution) + fun getAutoUpdateEnabled(): Flow suspend fun setAutoUpdateEnabled(enabled: Boolean) From fa15d68391d6f127f40bebc6b285ef403b2431f3 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 15:34:57 +0500 Subject: [PATCH 2/7] feat(installer): pass installerPackageName through Shizuku and Dhizuku silent paths --- .../dhizuku/IDhizukuInstallerService.aidl | 2 +- .../shizuku/IShizukuInstallerService.aidl | 2 +- .../dhizuku/DhizukuInstallerServiceImpl.kt | 10 +++++++++- .../installer/SilentInstallerDispatcher.kt | 16 ++++++++++++++-- .../shizuku/ShizukuInstallerServiceImpl.kt | 16 ++++++++++++---- 5 files changed, 37 insertions(+), 9 deletions(-) diff --git a/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/dhizuku/IDhizukuInstallerService.aidl b/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/dhizuku/IDhizukuInstallerService.aidl index 76f587085..7ca7dffa3 100644 --- a/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/dhizuku/IDhizukuInstallerService.aidl +++ b/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/dhizuku/IDhizukuInstallerService.aidl @@ -1,7 +1,7 @@ package zed.rainxch.core.data.services.dhizuku; interface IDhizukuInstallerService { - int installPackage(in ParcelFileDescriptor pfd, long fileSize, String expectedPackageName, long expectedVersionCode); + int installPackage(in ParcelFileDescriptor pfd, long fileSize, String expectedPackageName, long expectedVersionCode, String installerPackageName); int uninstallPackage(String packageName); void destroy(); } diff --git a/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/shizuku/IShizukuInstallerService.aidl b/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/shizuku/IShizukuInstallerService.aidl index c21ed6856..59c52f6d8 100644 --- a/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/shizuku/IShizukuInstallerService.aidl +++ b/core/data/src/androidMain/aidl/zed/rainxch/core/data/services/shizuku/IShizukuInstallerService.aidl @@ -1,7 +1,7 @@ package zed.rainxch.core.data.services.shizuku; interface IShizukuInstallerService { - int installPackage(in ParcelFileDescriptor pfd, long fileSize); + int installPackage(in ParcelFileDescriptor pfd, long fileSize, String installerPackageName); int uninstallPackage(String packageName); void destroy(); } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt index 83f79f066..c69a5f223 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/dhizuku/DhizukuInstallerServiceImpl.kt @@ -36,8 +36,9 @@ class DhizukuInstallerServiceImpl() : IDhizukuInstallerService.Stub() { fileSize: Long, expectedPackageName: String?, expectedVersionCode: Long, + installerPackageName: String?, ): Int { - log("installPackage() called — fileSize=$fileSize, expected=$expectedPackageName@$expectedVersionCode") + log("installPackage() called — fileSize=$fileSize, expected=$expectedPackageName@$expectedVersionCode, installer=$installerPackageName") log("Process UID: ${android.os.Process.myUid()}, PID: ${android.os.Process.myPid()}") val ctx: Context = currentApplicationOrNull() ?: run { @@ -51,6 +52,13 @@ class DhizukuInstallerServiceImpl() : IDhizukuInstallerService.Stub() { params.setRequireUserAction(PackageInstaller.SessionParams.USER_ACTION_NOT_REQUIRED) } if (fileSize > 0) params.setSize(fileSize) + installerPackageName?.takeIf { it.isNotBlank() }?.let { name -> + try { + params.setInstallerPackageName(name) + } catch (e: Exception) { + logW("setInstallerPackageName($name) failed: ${e.message}") + } + } var sessionId = -1 var session: PackageInstaller.Session? = null diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt index 0c7fe67b3..34fe61042 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/installer/SilentInstallerDispatcher.kt @@ -11,7 +11,9 @@ import zed.rainxch.core.data.services.dhizuku.DhizukuServiceManager import zed.rainxch.core.data.services.dhizuku.model.DhizukuStatus import zed.rainxch.core.data.services.shizuku.ShizukuServiceManager import zed.rainxch.core.data.services.shizuku.model.ShizukuStatus +import kotlinx.coroutines.flow.first import zed.rainxch.core.domain.model.GithubAsset +import zed.rainxch.core.domain.model.InstallerAttribution import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.SystemArchitecture import zed.rainxch.core.domain.repository.TweaksRepository @@ -35,6 +37,9 @@ class SilentInstallerDispatcher( @Volatile private var cachedInstallerType: InstallerType = InstallerType.DEFAULT + @Volatile + private var cachedInstallerAttribution: InstallerAttribution = InstallerAttribution.SystemDefault + fun observeInstallerPreference() { scope.launch { tweaksRepository.getInstallerType().collect { type -> @@ -42,6 +47,12 @@ class SilentInstallerDispatcher( Logger.d(TAG) { "Installer type changed to: $type" } } } + scope.launch { + tweaksRepository.getInstallerAttribution().collect { attribution -> + cachedInstallerAttribution = attribution + Logger.d(TAG) { "Installer attribution changed to: $attribution" } + } + } } override suspend fun isSupported(extOrMime: String): Boolean = androidInstaller.isSupported(extOrMime) @@ -118,6 +129,7 @@ class SilentInstallerDispatcher( private suspend fun trySilentInstall(filePath: String, backend: Backend): InstallOutcome? { Logger.d(TAG) { "Routing install through $backend" } + val installerAttribution = cachedInstallerAttribution.resolvePackageName() return try { val result = withContext(Dispatchers.IO) { val file = File(filePath) @@ -126,11 +138,11 @@ class SilentInstallerDispatcher( when (backend) { Backend.SHIZUKU -> { val service = shizukuServiceManager.getService() ?: return@use null - service.installPackage(pfd, file.length()) + service.installPackage(pfd, file.length(), installerAttribution) } Backend.DHIZUKU -> { val service = dhizukuServiceManager.getService() ?: return@use null - service.installPackage(pfd, file.length(), expectedPkg, expectedVc) + service.installPackage(pfd, file.length(), expectedPkg, expectedVc, installerAttribution) } Backend.DEFAULT -> null } diff --git a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt index 02a235bf4..c2225fd90 100644 --- a/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt +++ b/core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/shizuku/ShizukuInstallerServiceImpl.kt @@ -33,13 +33,21 @@ class ShizukuInstallerServiceImpl() : IShizukuInstallerService.Stub() { private fun logE(msg: String, e: Throwable? = null) = android.util.Log.e(TAG, msg, e) } - override fun installPackage(pfd: ParcelFileDescriptor, fileSize: Long): Int { - log("installPackage() called — fileSize=$fileSize") + override fun installPackage( + pfd: ParcelFileDescriptor, + fileSize: Long, + installerPackageName: String?, + ): Int { + log("installPackage() called — fileSize=$fileSize, installer=$installerPackageName") log("Process UID: ${android.os.Process.myUid()}, PID: ${android.os.Process.myPid()}") return try { - // Use "pm install -S " which reads the APK from stdin - val command = arrayOf("pm", "install", "-S", fileSize.toString()) + val safeInstaller = installerPackageName?.takeIf { it.isNotBlank() } + val command = if (safeInstaller != null) { + arrayOf("pm", "install", "-i", safeInstaller, "-S", fileSize.toString()) + } else { + arrayOf("pm", "install", "-S", fileSize.toString()) + } log("Executing: ${command.joinToString(" ")}") val process = Runtime.getRuntime().exec(command) From 706a6d9e73398edd9d7e13aee7d3a910072500e9 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 15:35:03 +0500 Subject: [PATCH 3/7] feat(tweaks): InstallerAttributionCard with preset radios and custom expander --- .../composeResources/values/strings.xml | 12 ++ .../tweaks/presentation/TweaksAction.kt | 12 ++ .../tweaks/presentation/TweaksState.kt | 5 + .../tweaks/presentation/TweaksViewModel.kt | 82 +++++++++ .../components/sections/Installation.kt | 165 ++++++++++++++++++ 5 files changed, 276 insertions(+) diff --git a/core/presentation/src/commonMain/composeResources/values/strings.xml b/core/presentation/src/commonMain/composeResources/values/strings.xml index 97756bf9f..8a325737a 100644 --- a/core/presentation/src/commonMain/composeResources/values/strings.xml +++ b/core/presentation/src/commonMain/composeResources/values/strings.xml @@ -631,6 +631,18 @@ Auto-update apps Automatically download and install updates in background via the selected silent installer + Installer attribution + Apps can see which installer placed them on your device. By default this is the system shell. + System default + Play Store + F-Droid + Obtainium + Custom… + Installer package name + Apply + Use a valid Android package name (e.g. com.example.installer) + Some apps detect when their installer changes and may refuse to run, or fail security checks (e.g. Play Integrity, banking apps). + Updates Update check interval How often to check for app updates in background diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt index dacc23abc..c01b46f5e 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksAction.kt @@ -79,6 +79,18 @@ sealed interface TweaksAction { data object OnRequestDhizukuPermission : TweaksAction + data object OnInstallerAttributionSystemDefault : TweaksAction + + data class OnInstallerAttributionPresetSelected( + val key: zed.rainxch.core.domain.model.PresetKey, + ) : TweaksAction + + data object OnInstallerAttributionCustomToggleExpanded : TweaksAction + + data class OnInstallerAttributionCustomChanged(val value: String) : TweaksAction + + data object OnInstallerAttributionCustomSave : TweaksAction + data class OnAutoUpdateToggled( val enabled: Boolean, ) : TweaksAction diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt index a77d16f5a..b149cb0b9 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksState.kt @@ -3,6 +3,7 @@ package zed.rainxch.tweaks.presentation import zed.rainxch.core.domain.model.AppTheme import zed.rainxch.core.domain.model.DhizukuAvailability import zed.rainxch.core.domain.model.FontTheme +import zed.rainxch.core.domain.model.InstallerAttribution import zed.rainxch.core.domain.model.InstallerType import zed.rainxch.core.domain.model.ProxyScope import zed.rainxch.core.domain.model.ShizukuAvailability @@ -21,6 +22,10 @@ data class TweaksState( val cacheSize: String = "", val isClearDownloadsDialogVisible: Boolean = false, val installerType: InstallerType = InstallerType.DEFAULT, + val installerAttribution: InstallerAttribution = InstallerAttribution.SystemDefault, + val installerAttributionCustomDraft: String = "", + val installerAttributionCustomExpanded: Boolean = false, + val installerAttributionCustomError: String? = null, val shizukuAvailability: ShizukuAvailability = ShizukuAvailability.UNAVAILABLE, val dhizukuAvailability: DhizukuAvailability = DhizukuAvailability.UNAVAILABLE, val autoUpdateEnabled: Boolean = false, diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index abf3f054d..0bda2dbd6 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -78,6 +78,7 @@ class TweaksViewModel( observeShizukuStatus() observeDhizukuStatus() + observeInstallerAttribution() hasLoadedInitialData = true } @@ -299,6 +300,21 @@ class TweaksViewModel( } } + private fun observeInstallerAttribution() { + viewModelScope.launch { + tweaksRepository.getInstallerAttribution().collect { attribution -> + _state.update { current -> + val customDraft = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Custom)?.packageName + ?: current.installerAttributionCustomDraft + current.copy( + installerAttribution = attribution, + installerAttributionCustomDraft = customDraft, + ) + } + } + } + } + private fun loadAutoUpdatePreference() { viewModelScope.launch { tweaksRepository.getAutoUpdateEnabled().collect { enabled -> @@ -590,6 +606,72 @@ class TweaksViewModel( installerStatusProvider.requestDhizukuPermission() } + TweaksAction.OnInstallerAttributionSystemDefault -> { + viewModelScope.launch { + tweaksRepository.setInstallerAttribution( + zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault, + ) + } + _state.update { + it.copy( + installerAttributionCustomExpanded = false, + installerAttributionCustomError = null, + ) + } + } + + is TweaksAction.OnInstallerAttributionPresetSelected -> { + viewModelScope.launch { + tweaksRepository.setInstallerAttribution( + zed.rainxch.core.domain.model.InstallerAttribution.Preset(action.key), + ) + } + _state.update { + it.copy( + installerAttributionCustomExpanded = false, + installerAttributionCustomError = null, + ) + } + } + + TweaksAction.OnInstallerAttributionCustomToggleExpanded -> { + _state.update { + it.copy( + installerAttributionCustomExpanded = !it.installerAttributionCustomExpanded, + installerAttributionCustomError = null, + ) + } + } + + is TweaksAction.OnInstallerAttributionCustomChanged -> { + _state.update { + it.copy( + installerAttributionCustomDraft = action.value, + installerAttributionCustomError = null, + ) + } + } + + TweaksAction.OnInstallerAttributionCustomSave -> { + val draft = _state.value.installerAttributionCustomDraft.trim() + if (!zed.rainxch.core.domain.model.InstallerAttributionDefaults.isValidPackageName(draft)) { + _state.update { + it.copy( + installerAttributionCustomError = "invalid", + ) + } + } else { + viewModelScope.launch { + tweaksRepository.setInstallerAttribution( + zed.rainxch.core.domain.model.InstallerAttribution.Custom(draft), + ) + } + _state.update { + it.copy(installerAttributionCustomError = null) + } + } + } + is TweaksAction.OnAutoUpdateToggled -> { viewModelScope.launch { tweaksRepository.setAutoUpdateEnabled(action.enabled) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt index b1156b15c..cf18f095f 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/components/sections/Installation.kt @@ -103,6 +103,171 @@ fun LazyListScope.installationSection( onAction(TweaksAction.OnAutoUpdateToggled(enabled)) } ) + + Spacer(Modifier.height(12.dp)) + + InstallerAttributionCard(state = state, onAction = onAction) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun InstallerAttributionCard( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + val attribution = state.installerAttribution + ExpressiveCard { + Column( + modifier = Modifier + .fillMaxWidth() + .padding(16.dp), + verticalArrangement = Arrangement.spacedBy(8.dp), + ) { + Text( + text = stringResource(Res.string.installer_attribution_title), + style = MaterialTheme.typography.titleMedium, + fontWeight = FontWeight.SemiBold, + color = MaterialTheme.colorScheme.onSurface, + ) + Text( + text = stringResource(Res.string.installer_attribution_description), + style = MaterialTheme.typography.bodySmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + Spacer(Modifier.height(4.dp)) + + AttributionRadioRow( + title = stringResource(Res.string.installer_attribution_preset_system), + selected = attribution is zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault, + onClick = { onAction(TweaksAction.OnInstallerAttributionSystemDefault) }, + ) + AttributionRadioRow( + title = stringResource(Res.string.installer_attribution_preset_playstore), + caption = "com.android.vending", + selected = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Preset)?.key + == zed.rainxch.core.domain.model.PresetKey.PLAY_STORE, + onClick = { + onAction( + TweaksAction.OnInstallerAttributionPresetSelected( + zed.rainxch.core.domain.model.PresetKey.PLAY_STORE, + ), + ) + }, + ) + AttributionRadioRow( + title = stringResource(Res.string.installer_attribution_preset_fdroid), + caption = "org.fdroid.fdroid", + selected = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Preset)?.key + == zed.rainxch.core.domain.model.PresetKey.FDROID, + onClick = { + onAction( + TweaksAction.OnInstallerAttributionPresetSelected( + zed.rainxch.core.domain.model.PresetKey.FDROID, + ), + ) + }, + ) + AttributionRadioRow( + title = stringResource(Res.string.installer_attribution_preset_obtainium), + caption = "dev.imranr.obtainium.app", + selected = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Preset)?.key + == zed.rainxch.core.domain.model.PresetKey.OBTAINIUM, + onClick = { + onAction( + TweaksAction.OnInstallerAttributionPresetSelected( + zed.rainxch.core.domain.model.PresetKey.OBTAINIUM, + ), + ) + }, + ) + AttributionRadioRow( + title = stringResource(Res.string.installer_attribution_preset_custom), + caption = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Custom) + ?.packageName, + selected = attribution is zed.rainxch.core.domain.model.InstallerAttribution.Custom, + onClick = { onAction(TweaksAction.OnInstallerAttributionCustomToggleExpanded) }, + ) + + if (state.installerAttributionCustomExpanded || + attribution is zed.rainxch.core.domain.model.InstallerAttribution.Custom + ) { + CustomInstallerEditor(state = state, onAction = onAction) + } + + Spacer(Modifier.height(4.dp)) + HintText(text = stringResource(Res.string.installer_attribution_disclosure)) + } + } +} + +@OptIn(ExperimentalMaterial3ExpressiveApi::class) +@Composable +private fun CustomInstallerEditor( + state: TweaksState, + onAction: (TweaksAction) -> Unit, +) { + Column(verticalArrangement = Arrangement.spacedBy(8.dp)) { + androidx.compose.material3.OutlinedTextField( + value = state.installerAttributionCustomDraft, + onValueChange = { onAction(TweaksAction.OnInstallerAttributionCustomChanged(it)) }, + modifier = Modifier.fillMaxWidth(), + label = { Text(stringResource(Res.string.installer_attribution_custom_label)) }, + placeholder = { Text("com.example.installer") }, + singleLine = true, + isError = state.installerAttributionCustomError != null, + supportingText = state.installerAttributionCustomError?.let { + { + Text(stringResource(Res.string.installer_attribution_custom_error)) + } + }, + ) + FilledTonalButton( + onClick = { onAction(TweaksAction.OnInstallerAttributionCustomSave) }, + modifier = Modifier.fillMaxWidth(), + shape = RoundedCornerShape(12.dp), + ) { + Text( + text = stringResource(Res.string.installer_attribution_custom_apply), + fontWeight = FontWeight.SemiBold, + ) + } + } +} + +@Composable +private fun AttributionRadioRow( + title: String, + caption: String? = null, + selected: Boolean, + onClick: () -> Unit, +) { + Row( + modifier = Modifier + .fillMaxWidth() + .selectable(selected = selected, onClick = onClick, role = Role.RadioButton) + .padding(vertical = 8.dp), + verticalAlignment = Alignment.CenterVertically, + horizontalArrangement = Arrangement.spacedBy(12.dp), + ) { + androidx.compose.material3.RadioButton( + selected = selected, + onClick = onClick, + ) + Column(modifier = Modifier.weight(1f)) { + Text( + text = title, + style = MaterialTheme.typography.bodyLarge, + color = MaterialTheme.colorScheme.onSurface, + ) + if (!caption.isNullOrBlank()) { + Text( + text = caption, + style = MaterialTheme.typography.labelSmall, + color = MaterialTheme.colorScheme.onSurfaceVariant, + ) + } } } } From a186f589ba0203caf213c77418839e1e7fb73c45 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 15:35:09 +0500 Subject: [PATCH 4/7] docs: announce Installer attribution in 1.8.1 what's-new --- .../src/commonMain/composeResources/files/whatsnew/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/ar/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/bn/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/es/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/fr/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/hi/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/it/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/ja/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/ko/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/pl/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/ru/16.json | 3 ++- .../src/commonMain/composeResources/files/whatsnew/tr/16.json | 3 ++- .../commonMain/composeResources/files/whatsnew/zh-CN/16.json | 3 ++- 13 files changed, 26 insertions(+), 13 deletions(-) diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/16.json index 70777b55a..ebbe7ea36 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/16.json @@ -13,7 +13,8 @@ "Announcements feed — privacy notices, surveys, and security advisories in Profile.", "Dhizuku silent install — bypass OEM install prompts on Xiaomi, OPPO, vivo, Huawei devices via Device Owner.", "Obtainium import/export — bring your library over from Obtainium with one tap, or export to Obtainium any time.", - "Add from starred — surface APK-shipping repos from your GitHub stars and jump straight into installing." + "Add from starred — surface APK-shipping repos from your GitHub stars and jump straight into installing.", + "Installer attribution — set what installer name silent installs claim, so apps that gate on installer source can be coaxed into running." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json index 3415f1925..296d6fe36 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/ar/16.json @@ -13,7 +13,8 @@ "بثّ الإعلانات: إشعارات الخصوصية والاستبيانات والتنبيهات الأمنية داخل «الملف الشخصي».", "تثبيت صامت عبر Dhizuku: تجاوز نوافذ التثبيت في أجهزة Xiaomi و OPPO و vivo و Huawei عبر صلاحية «مالك الجهاز».", "استيراد/تصدير Obtainium: انقل مكتبتك من Obtainium بنقرة واحدة، أو صدّر إلى صيغة Obtainium في أي وقت.", - "إضافة من المُنجَّمة: استعرض المستودعات التي تشحن APK ضمن نجوم GitHub لديك وانتقل مباشرة إلى التثبيت." + "إضافة من المُنجَّمة: استعرض المستودعات التي تشحن APK ضمن نجوم GitHub لديك وانتقل مباشرة إلى التثبيت.", + "تخصيص هوية المثبّت: عيّن اسم المثبّت الذي تدّعيه التثبيتات الصامتة، حتى تعمل التطبيقات التي تتحقّق من مصدر التثبيت." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json index fba63f6eb..843078a3e 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/bn/16.json @@ -13,7 +13,8 @@ "ঘোষণা ফিড: গোপনীয়তা নোটিশ, জরিপ আর নিরাপত্তা সতর্কতা প্রোফাইলে এক জায়গায়।", "Dhizuku সাইলেন্ট ইনস্টল — Device Owner-এর মাধ্যমে Xiaomi, OPPO, vivo, Huawei ডিভাইসে OEM ইনস্টল প্রম্পট এড়িয়ে যান।", "Obtainium ইম্পোর্ট/এক্সপোর্ট — এক ট্যাপে Obtainium থেকে লাইব্রেরি আনুন, বা যেকোনো সময় Obtainium ফরম্যাটে এক্সপোর্ট করুন।", - "Add from starred — আপনার GitHub স্টার করা যেসব রিপো APK পাঠায় সেগুলো দেখুন আর সরাসরি ইনস্টলে যান।" + "Add from starred — আপনার GitHub স্টার করা যেসব রিপো APK পাঠায় সেগুলো দেখুন আর সরাসরি ইনস্টলে যান।", + "Installer attribution — সাইলেন্ট ইনস্টল কোন ইনস্টলার নাম দাবি করবে তা সেট করুন, যাতে যেসব অ্যাপ ইনস্টলার সোর্স দেখে সেগুলোও চলতে পারে।" ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json index b2288090e..de4f63c45 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/es/16.json @@ -13,7 +13,8 @@ "Canal de anuncios: avisos de privacidad, encuestas y alertas de seguridad en Perfil.", "Instalación silenciosa con Dhizuku: omite los diálogos de instalación de OEM en Xiaomi, OPPO, vivo y Huawei mediante el propietario del dispositivo.", "Importar/Exportar Obtainium: trae tu biblioteca desde Obtainium con un toque, o exporta a formato Obtainium cuando quieras.", - "Añadir desde estrellas: descubre los repos de tus estrellas en GitHub que envían APK y salta directo a instalar." + "Añadir desde estrellas: descubre los repos de tus estrellas en GitHub que envían APK y salta directo a instalar.", + "Atribución del instalador: define qué nombre de instalador declaran las instalaciones silenciosas, para que las apps que filtran por origen del instalador funcionen." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json index f179401c7..c2355166d 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/fr/16.json @@ -13,7 +13,8 @@ "Fil d’annonces : avis de confidentialité, sondages et alertes de sécurité dans Profil.", "Installation silencieuse via Dhizuku : contournez les fenêtres d’installation des OEM (Xiaomi, OPPO, vivo, Huawei) grâce au statut Propriétaire de l’appareil.", "Import/Export Obtainium : récupérez votre bibliothèque depuis Obtainium en un toucher, ou exportez vers Obtainium quand vous voulez.", - "Ajouter depuis les étoiles : repérez parmi vos repos étoilés sur GitHub ceux qui livrent un APK, puis installez-les directement." + "Ajouter depuis les étoiles : repérez parmi vos repos étoilés sur GitHub ceux qui livrent un APK, puis installez-les directement.", + "Attribution de l’installateur : choisissez le nom d’installateur que les installations silencieuses revendiquent, pour que les apps qui filtrent par source d’installation fonctionnent." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json index 6f528dc2c..a3da92185 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/hi/16.json @@ -13,7 +13,8 @@ "घोषणा फ़ीड: गोपनीयता सूचनाएँ, सर्वे और सुरक्षा अलर्ट प्रोफ़ाइल में मिलेंगे।", "Dhizuku साइलेंट इंस्टॉल — Device Owner के ज़रिए Xiaomi, OPPO, vivo, Huawei डिवाइसों पर OEM इंस्टॉल प्रॉम्प्ट बायपास करें।", "Obtainium इम्पोर्ट/एक्सपोर्ट — एक टैप से Obtainium से अपनी लाइब्रेरी लाएँ, या जब चाहें Obtainium फ़ॉर्मैट में एक्सपोर्ट करें।", - "Add from starred — अपने GitHub स्टार किए हुए रेपो में से APK वाले को सामने लाएँ और सीधे इंस्टॉल पर जाएँ।" + "Add from starred — अपने GitHub स्टार किए हुए रेपो में से APK वाले को सामने लाएँ और सीधे इंस्टॉल पर जाएँ।", + "Installer attribution — साइलेंट इंस्टॉल किस इंस्टॉलर नाम का दावा करेंगे, इसे सेट करें ताकि इंस्टॉलर सोर्स पर निर्भर ऐप्स भी चल सकें।" ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json index da1b7aee2..194d09c40 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/it/16.json @@ -13,7 +13,8 @@ "Feed annunci: avvisi sulla privacy, sondaggi e segnalazioni di sicurezza nel Profilo.", "Installazione silenziosa con Dhizuku: bypassa i prompt di installazione OEM su Xiaomi, OPPO, vivo e Huawei tramite il proprietario del dispositivo.", "Import/Export Obtainium: porta la tua libreria da Obtainium con un tocco, o esporta verso il formato Obtainium quando vuoi.", - "Aggiungi dalle stelle: scopri i repo che spediscono APK fra le tue stelle GitHub e vai dritto all'installazione." + "Aggiungi dalle stelle: scopri i repo che spediscono APK fra le tue stelle GitHub e vai dritto all'installazione.", + "Attribuzione installatore: scegli quale nome di installatore dichiarano le installazioni silenziose, così le app che filtrano sull'origine funzionano." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json index a88aaa1a4..8015405c1 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/ja/16.json @@ -13,7 +13,8 @@ "お知らせフィード: プライバシー通知・アンケート・セキュリティ告知をプロフィール内で配信します。", "Dhizuku によるサイレントインストール — Device Owner 経由で Xiaomi、OPPO、vivo、Huawei 端末の OEM インストール確認をスキップ。", "Obtainium インポート/エクスポート — Obtainium のライブラリをワンタップで取り込み、いつでも Obtainium 形式で書き出し。", - "スター付きから追加 — GitHub のスター付きリポから APK を配布しているものを表示し、そのままインストールに進めます。" + "スター付きから追加 — GitHub のスター付きリポから APK を配布しているものを表示し、そのままインストールに進めます。", + "インストーラー属性 — サイレントインストール時に名乗るインストーラー名を変更でき、インストール元を見るアプリも動かせるようにします。" ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json index 6ab672a6e..963f41fc9 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/ko/16.json @@ -13,7 +13,8 @@ "공지 피드: 개인정보 안내, 설문, 보안 권고를 프로필에서 받아볼 수 있습니다.", "Dhizuku 무음 설치 — 기기 소유자(Device Owner) 권한으로 Xiaomi, OPPO, vivo, Huawei 기기에서 OEM 설치 확인 창을 건너뜁니다.", "Obtainium 가져오기/내보내기 — Obtainium 라이브러리를 한 번에 가져오거나, 언제든지 Obtainium 형식으로 내보낼 수 있습니다.", - "별표한 저장소에서 추가 — GitHub 별표 저장소 중 APK를 배포하는 곳을 보여주고, 바로 설치 단계로 넘어갈 수 있습니다." + "별표한 저장소에서 추가 — GitHub 별표 저장소 중 APK를 배포하는 곳을 보여주고, 바로 설치 단계로 넘어갈 수 있습니다.", + "설치자 속성 — 무음 설치가 어떤 설치자 이름을 사용할지 지정해서, 설치 출처를 확인하는 앱도 실행될 수 있도록 합니다." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json index 56096fb51..ccb18c5d2 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/pl/16.json @@ -13,7 +13,8 @@ "Kanał ogłoszeń — informacje o prywatności, ankiety i alerty bezpieczeństwa w Profilu.", "Cicha instalacja przez Dhizuku — pomijaj okna instalacji OEM na urządzeniach Xiaomi, OPPO, vivo i Huawei dzięki uprawnieniom właściciela urządzenia.", "Import/Eksport Obtainium — przenieś bibliotekę z Obtainium jednym dotknięciem albo wyeksportuj do formatu Obtainium kiedy chcesz.", - "Dodaj z oznaczonych gwiazdką — zobacz, które z twoich oznaczonych repo GitHub publikują APK, i przejdź wprost do instalacji." + "Dodaj z oznaczonych gwiazdką — zobacz, które z twoich oznaczonych repo GitHub publikują APK, i przejdź wprost do instalacji.", + "Atrybucja instalatora — ustaw nazwę instalatora, którą deklarują ciche instalacje, by aplikacje filtrujące po źródle instalacji działały." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json index a63fa6747..c1ad94605 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/ru/16.json @@ -13,7 +13,8 @@ "Лента объявлений: уведомления о приватности, опросы и предупреждения безопасности в Профиле.", "Тихая установка через Dhizuku — обход системных диалогов установки на устройствах Xiaomi, OPPO, vivo и Huawei через статус владельца устройства.", "Импорт/Экспорт Obtainium — перенесите библиотеку из Obtainium одним касанием или выгрузите в формат Obtainium в любой момент.", - "Добавление из звёзд — видите среди ваших звёзд GitHub репозитории с APK и сразу переходите к установке." + "Добавление из звёзд — видите среди ваших звёзд GitHub репозитории с APK и сразу переходите к установке.", + "Атрибуция установщика — задайте имя установщика, которым представляются тихие установки, чтобы работали приложения, проверяющие источник установки." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json index 3625f2bce..9f263dc50 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/tr/16.json @@ -13,7 +13,8 @@ "Duyurular akışı: gizlilik bildirimleri, anketler ve güvenlik uyarıları Profil’de tek yerde.", "Dhizuku ile sessiz kurulum — Xiaomi, OPPO, vivo, Huawei cihazlarında OEM kurulum istemlerini Cihaz Sahibi yetkisiyle atlayın.", "Obtainium içe/dışa aktarma — Obtainium kütüphanenizi tek dokunuşla getirin ya da istediğiniz zaman Obtainium biçiminde dışa aktarın.", - "Yıldızlananlardan ekle — GitHub'da yıldızladıklarınız arasından APK gönderenleri görüp doğrudan kuruluma geçin." + "Yıldızlananlardan ekle — GitHub'da yıldızladıklarınız arasından APK gönderenleri görüp doğrudan kuruluma geçin.", + "Yükleyici atfı — sessiz kurulumların hangi yükleyici adını taşıyacağını ayarlayın; kurulum kaynağını kontrol eden uygulamalar yine çalışsın." ] }, { diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json index d61e1ebf5..8a69c1d97 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/16.json @@ -13,7 +13,8 @@ "公告中心:隐私通告、问卷调研和安全提示统一在「我的」中查看。", "Dhizuku 静默安装:通过设备所有者权限绕过小米、OPPO、vivo、华为等机型的安装确认弹窗。", "Obtainium 导入/导出:一键从 Obtainium 迁移你的应用库,也可以随时导出为 Obtainium 格式。", - "从星标添加:列出你 GitHub 星标里发布 APK 的仓库,直接跳到安装环节。" + "从星标添加:列出你 GitHub 星标里发布 APK 的仓库,直接跳到安装环节。", + "安装来源伪装:可设置静默安装时声明的安装器名称,让那些根据安装来源做判断的应用也能正常运行。" ] }, { From 398dccb6413a06428d1dbd35712217278e473824 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 15:50:56 +0500 Subject: [PATCH 5/7] fix(installer-attribution): enforce lowercase package names, trim custom value, collapse editor on non-custom --- .../zed/rainxch/core/domain/model/InstallerAttribution.kt | 4 ++-- .../zed/rainxch/tweaks/presentation/TweaksViewModel.kt | 6 ++++++ 2 files changed, 8 insertions(+), 2 deletions(-) diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt index 6c97909f0..80a9935bc 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallerAttribution.kt @@ -16,7 +16,7 @@ sealed interface InstallerAttribution { fun resolvePackageName(): String? = when (this) { SystemDefault -> null is Preset -> key.packageName - is Custom -> packageName.takeIf { it.isNotBlank() } + is Custom -> packageName.trim().takeIf { it.isNotBlank() } } } @@ -33,7 +33,7 @@ enum class PresetKey(val packageName: String) { } object InstallerAttributionDefaults { - val packageNamePattern = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+\$", RegexOption.IGNORE_CASE) + val packageNamePattern = Regex("^[a-z][a-z0-9_]*(\\.[a-z][a-z0-9_]*)+\$") fun isValidPackageName(name: String): Boolean { val trimmed = name.trim() diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index 0bda2dbd6..c7e0856f0 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -304,11 +304,17 @@ class TweaksViewModel( viewModelScope.launch { tweaksRepository.getInstallerAttribution().collect { attribution -> _state.update { current -> + val isCustom = attribution is zed.rainxch.core.domain.model.InstallerAttribution.Custom val customDraft = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Custom)?.packageName ?: current.installerAttributionCustomDraft current.copy( installerAttribution = attribution, installerAttributionCustomDraft = customDraft, + installerAttributionCustomExpanded = if (isCustom) { + current.installerAttributionCustomExpanded + } else { + false + }, ) } } From 937f2d9b906d876b48271620cb958e9e000215c4 Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 16:08:15 +0500 Subject: [PATCH 6/7] fix(installer-attribution): catch DataStore write failures and surface to user instead of clearing error eagerly --- .../tweaks/presentation/TweaksViewModel.kt | 20 ++++++++++--------- 1 file changed, 11 insertions(+), 9 deletions(-) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index c7e0856f0..5e2d4cf2d 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -662,18 +662,20 @@ class TweaksViewModel( val draft = _state.value.installerAttributionCustomDraft.trim() if (!zed.rainxch.core.domain.model.InstallerAttributionDefaults.isValidPackageName(draft)) { _state.update { - it.copy( - installerAttributionCustomError = "invalid", - ) + it.copy(installerAttributionCustomError = "invalid") } } else { viewModelScope.launch { - tweaksRepository.setInstallerAttribution( - zed.rainxch.core.domain.model.InstallerAttribution.Custom(draft), - ) - } - _state.update { - it.copy(installerAttributionCustomError = null) + runCatching { + tweaksRepository.setInstallerAttribution( + zed.rainxch.core.domain.model.InstallerAttribution.Custom(draft), + ) + }.onSuccess { + _state.update { it.copy(installerAttributionCustomError = null) } + }.onFailure { error -> + println("TweaksViewModel: failed to persist installer attribution: ${error.message}") + _state.update { it.copy(installerAttributionCustomError = "write_failed") } + } } } } From f68579fc164feec47b64e5bd538edcd107f8276b Mon Sep 17 00:00:00 2001 From: rainxchzed Date: Mon, 4 May 2026 16:24:04 +0500 Subject: [PATCH 7/7] fix(installer-attribution): wrap preset/system writes in runCatching and guard installer attribution flow with catch --- .../tweaks/presentation/TweaksViewModel.kt | 83 +++++++++++-------- 1 file changed, 47 insertions(+), 36 deletions(-) diff --git a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt index 5e2d4cf2d..cc879df3b 100644 --- a/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt +++ b/feature/tweaks/presentation/src/commonMain/kotlin/zed/rainxch/tweaks/presentation/TweaksViewModel.kt @@ -7,6 +7,7 @@ import kotlinx.coroutines.Job import kotlinx.coroutines.channels.Channel import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.SharingStarted +import kotlinx.coroutines.flow.catch import kotlinx.coroutines.flow.onStart import kotlinx.coroutines.flow.receiveAsFlow import kotlinx.coroutines.flow.stateIn @@ -300,27 +301,53 @@ class TweaksViewModel( } } - private fun observeInstallerAttribution() { + private fun persistInstallerAttribution( + attribution: zed.rainxch.core.domain.model.InstallerAttribution, + ) { viewModelScope.launch { - tweaksRepository.getInstallerAttribution().collect { attribution -> - _state.update { current -> - val isCustom = attribution is zed.rainxch.core.domain.model.InstallerAttribution.Custom - val customDraft = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Custom)?.packageName - ?: current.installerAttributionCustomDraft - current.copy( - installerAttribution = attribution, - installerAttributionCustomDraft = customDraft, - installerAttributionCustomExpanded = if (isCustom) { - current.installerAttributionCustomExpanded - } else { - false - }, + runCatching { + tweaksRepository.setInstallerAttribution(attribution) + }.onSuccess { + _state.update { + it.copy( + installerAttributionCustomExpanded = false, + installerAttributionCustomError = null, ) } + }.onFailure { error -> + println("TweaksViewModel: failed to persist installer attribution: ${error.message}") + _state.update { + it.copy(installerAttributionCustomError = "write_failed") + } } } } + private fun observeInstallerAttribution() { + viewModelScope.launch { + tweaksRepository.getInstallerAttribution() + .catch { e -> + println("TweaksViewModel: installer attribution flow error: ${e.message}") + } + .collect { attribution -> + _state.update { current -> + val isCustom = attribution is zed.rainxch.core.domain.model.InstallerAttribution.Custom + val customDraft = (attribution as? zed.rainxch.core.domain.model.InstallerAttribution.Custom)?.packageName + ?: current.installerAttributionCustomDraft + current.copy( + installerAttribution = attribution, + installerAttributionCustomDraft = customDraft, + installerAttributionCustomExpanded = if (isCustom) { + current.installerAttributionCustomExpanded + } else { + false + }, + ) + } + } + } + } + private fun loadAutoUpdatePreference() { viewModelScope.launch { tweaksRepository.getAutoUpdateEnabled().collect { enabled -> @@ -613,31 +640,15 @@ class TweaksViewModel( } TweaksAction.OnInstallerAttributionSystemDefault -> { - viewModelScope.launch { - tweaksRepository.setInstallerAttribution( - zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault, - ) - } - _state.update { - it.copy( - installerAttributionCustomExpanded = false, - installerAttributionCustomError = null, - ) - } + persistInstallerAttribution( + zed.rainxch.core.domain.model.InstallerAttribution.SystemDefault, + ) } is TweaksAction.OnInstallerAttributionPresetSelected -> { - viewModelScope.launch { - tweaksRepository.setInstallerAttribution( - zed.rainxch.core.domain.model.InstallerAttribution.Preset(action.key), - ) - } - _state.update { - it.copy( - installerAttributionCustomExpanded = false, - installerAttributionCustomError = null, - ) - } + persistInstallerAttribution( + zed.rainxch.core.domain.model.InstallerAttribution.Preset(action.key), + ) } TweaksAction.OnInstallerAttributionCustomToggleExpanded -> {