Skip to content

fix(apps): serialize sequential installs and unstick stale installedVersion#541

Merged
rainxchzed merged 6 commits into
mainfrom
fix/apps-install-serialization
May 8, 2026
Merged

fix(apps): serialize sequential installs and unstick stale installedVersion#541
rainxchzed merged 6 commits into
mainfrom
fix/apps-install-serialization

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 7, 2026

Fixes two related bugs surfaced when a user updates several apps in succession from the Apps screen.

Bug 1 — Stacking ACTION_VIEW intents on OEM PackageInstaller activities

AndroidInstaller.install returns DELEGATED_TO_SYSTEM immediately — the system install dialog shows but the coroutine continues. AppsViewModel.updateSingleApp, DefaultDownloadOrchestrator.runStandaloneInstall / runInstall, AutoUpdateWorker.updateApp, and DetailsViewModel.installRelease all then proceed to the next install before the previous system dialog has settled. Android's PackageInstaller activity is singleTask; second ACTION_VIEW intents go through onNewIntent. AOSP refreshes the dialog data, but Xiaomi MIUI / OPPO ColorOS / vivo Funtouch / Honor MagicOS / several Samsung One UI variants do not — the dialog keeps showing the first APK. User confirms what's displayed, the first APK installs again, the second app's install never happens.

Fix

New SystemInstallSerializer (single-flight MutableStateFlow<String?>), registered as a Koin singleton in coreModule:

  • awaitFreeOrTimeout(timeoutMs = 60_000) — suspends until pending package clears or timeout (force-releases on timeout to recover from a dropped broadcast / user dismissal).
  • markPending(packageName) — call right before installer.install.
  • markCompleted(packageName) — released by PackageEventReceiver.onPackageInstalled / onPackageRemoved for the matching package, plus by every error path on the call site.

Wired into all four installer.install call sites and into both broadcast handlers. Sequential installs now wait for each PACKAGE_REPLACED (or 60s timeout) before firing the next ACTION_VIEW, eliminating the stacking on OEM ROMs.

Bug 2 — installedVersion tag stays stale when latestVersionCode == 0

PackageEventReceiver.onPackageInstalled decided "did the install reach the target version" via expectedVersionCode > 0L && systemInfo.versionCode >= expectedVersionCode. When the orchestrator's earlier APK-info extraction failed (split APKs, large APKs, encrypted manifest, AOSP PackageManager.getPackageArchiveInfo edge cases), latestVersionCode was 0L, so the heuristic always returned false → the else-branch ran → repo.updateApp(app.copy(installedVersionName = …, installedVersionCode = …)) wrote the system fields but never touched installedVersion (the release tag). Apps row kept rendering the pre-install tag forever after.

Fix

  • Harden wasActuallyUpdated to fall back to versionName change detection when expectedVersionCode <= 0L.
  • Even on the "didn't reach target" branch, write installedVersion = app.latestVersion ?: systemInfo.versionName so the row's primary version label moves forward whenever the user actually accepted some install.

Test plan

  • Manual on Android device:
    • Update three apps in a row from the Apps screen → confirm each system dialog shows the correct APK (no stacking).
    • Update an app whose APK info-extract fails (e.g. AAB-based split bundle) → confirm installedVersion updates after install.
    • Auto-update via Shizuku → sequential silent installs unaffected.
    • Update via Details screen "Install" CTA → unaffected, gate clears on broadcast.
    • Cancel a system install dialog → 60s gate timeout releases automatically; next install proceeds.
  • Compile clean: :composeApp:compileDebugKotlinAndroid ✅, :core:data:compileKotlinJvm ✅.

Notes

  • Unit tests for DefaultSystemInstallSerializer deferred — repo has zero *Test.kt files. When test infra lands, cover: free path, suspend-on-pending, timeout force-release, compareAndSet on markCompleted.
  • Bug 3 (latent) — AutoUpdateWorker still uses unscoped filenames at line 140/159/162 instead of AssetFileName.scoped. Mitigated by the existing signing-fingerprint + packageName checks before installer.install. Filed for a follow-up sweep that routes AutoUpdateWorker through DownloadOrchestrator.

What's-new

  • New FIXED bullet across 13 locales describing the install reliability + version-update fix.

Summary by CodeRabbit

  • Bug Fixes
    • Fixed version detection so builds with the same version number but different build metadata aren’t misreported as already installed.
    • Improved reliability when updating multiple apps sequentially — installations are serialized so each system dialog shows the correct APK and app versions stay synchronized after each install.

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 7, 2026

Review Change Stack
No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8e74c4c4-4bc7-4ac3-b492-179e2574cb9f

📥 Commits

Reviewing files that changed from the base of the PR and between 4bfccac and 5e3db57.

📒 Files selected for processing (1)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt
🚧 Files skipped from review as they are similar to previous changes (1)
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt

Walkthrough

This PR introduces SystemInstallSerializer and DefaultSystemInstallSerializer, registers them in DI, and uses awaitFreeAndMarkPending/markCompleted to serialize system APK installs across the download orchestrator, auto-update worker, package event receiver, and presentation view models; localized release notes updated.

Changes

Install Serialization Coordination

Layer / File(s) Summary
Domain Contract
core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt
New interface defines awaitFreeAndMarkPending(packageName, timeoutMs) suspend and markCompleted(packageName) plus DEFAULT_TIMEOUT_MS = 60_000L.
Implementation
core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt
Adds DefaultSystemInstallSerializer using a MutableStateFlow<String?> pending slot; awaitFreeAndMarkPending claims slot with suspend-and-timeout semantics and force-claim on timeout; markCompleted clears pending unconditionally.
DI Registration
core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
Registers single<SystemInstallSerializer> { DefaultSystemInstallSerializer() } and injects systemInstallSerializer into DefaultDownloadOrchestrator factory.
Download Orchestrator
core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt
Adds systemInstallSerializer dependency; runInstall and runStandaloneInstall call awaitFreeAndMarkPending(packageName) before installer.install(...) and call markCompleted(packageName) on completion, cancellation, and failures (skipping when delegated to system).
Package Event Receiver
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt
Accepts an explicit serializer override, resolves via getter, calls markCompleted(packageName) at the start of onPackageInstalled and onPackageRemoved, and refines version-match detection using versionCode and versionName fallback.
Auto-Update Worker
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt
Injects systemInstallSerializer; awaits awaitFreeAndMarkPending(packageName) before install; on install exception calls markCompleted(packageName) before clearing repo pending flag and rethrowing.
Presentation Layer
feature/apps/presentation/.../AppsViewModel.kt, feature/details/presentation/.../DetailsViewModel.kt
Constructors now accept SystemInstallSerializer; install flows call awaitFreeAndMarkPending(pkg) before installer.install(...) and call markCompleted(pkg) on installer exceptions.
App Wiring
composeApp/src/.../GithubStoreApp.kt, composeApp/src/.../ViewModelsModule.kt
GithubStoreApp passes systemInstallSerializer = get() into PackageEventReceiver; ViewModelsModule supplies systemInstallSerializer to DetailsViewModel; minor Logger reformat in GithubStoreApp is cosmetic.
Release Notes
core/presentation/src/commonMain/composeResources/files/whatsnew/*/17.json
Localized FIXED bullets added/updated across multiple locales describing improved version detection and serialized multi-app install reliability.

Estimated code review effort

🎯 3 (Moderate) | ⏱️ ~20 minutes

Possibly related PRs

Poem

🐰 I queue the APKs with careful care,

I wait my turn, no race to spare,
One dialog opens, one at a time,
Each package lands in proper line,
Hooray — installs hop on, sublime!

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 13.04% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The title clearly and concisely summarizes the main changes: serializing sequential installs and fixing the stale installedVersion problem.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

✏️ Tip: You can configure your own custom pre-merge checks in the settings.

✨ Finishing Touches
📝 Generate docstrings
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch fix/apps-install-serialization

Thanks for using CodeRabbit! It's free for OSS, and your support helps us grow. If you like it, consider giving us a shout-out.

❤️ Share

Comment @coderabbitai help to get the list of available commands and usage tips.

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 4

Caution

Some comments are outside the diff and can’t be posted inline due to platform limitations.

⚠️ Outside diff range comments (1)
core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt (1)

343-348: ⚠️ Potential issue | 🔴 Critical | ⚡ Quick win

Gate stays locked for 60 seconds after any install exception — missing markCompleted in both runInstall and runStandaloneInstall catch blocks.

Both AutoUpdateWorker and AppsViewModel correctly call markCompleted in their error handlers, but DefaultDownloadOrchestrator does not. If installer.install(...) throws (or the coroutine is cancelled after markPending is called), the pending slot stays occupied and every subsequent queued install must wait the full 60 s timeout before it can fire.

markCompleted uses compareAndSet, so adding it before markPending was ever reached is safe (no-op CAS).

🐛 Proposed fix — `runInstall` (lines 343-348)
         } catch (e: CancellationException) {
+            systemInstallSerializer.markCompleted(spec.packageName)
             throw e
         } catch (t: Throwable) {
             Logger.e(t) { "Orchestrator: install failed for ${spec.packageName}" }
+            systemInstallSerializer.markCompleted(spec.packageName)
             markFailed(spec.packageName, t.message)
         }
🐛 Proposed fix — `runStandaloneInstall` (lines 572-578)
         } catch (e: CancellationException) {
+            systemInstallSerializer.markCompleted(packageName)
             throw e
         } catch (t: Throwable) {
             Logger.e(t) { "Orchestrator: standalone install failed for $packageName" }
+            systemInstallSerializer.markCompleted(packageName)
             markFailed(packageName, t.message)
             null
         }

Also applies to: 572-578

🤖 Prompt for AI Agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt`
around lines 343 - 348, The catch blocks in runInstall and runStandaloneInstall
currently log and call markFailed when installer.install(...) throws (or a
coroutine is cancelled), but they do not call markCompleted, leaving the pending
slot locked for 60s; update both catch (t: Throwable) handlers to call
markCompleted(spec.packageName) (in addition to markFailed(spec.packageName,
t.message)) so the pending slot is cleared immediately; keep the existing
CancellationException rethrow behavior intact and ensure markCompleted is called
safely (it's a no-op if markPending was never set).
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt`:
- Around line 12-28: The current two-step wait-then-claim (awaitFreeOrTimeout +
markPending) is racy; replace it with a single atomic suspend that waits and
claims the slot for a package: add suspend fun
awaitFreeAndMarkPending(packageName: String, timeoutMs: Long) that uses
withTimeoutOrNull(timeoutMs) { while (!pending.compareAndSet(null, packageName))
{ pending.first { it == null } } } and on timeout logs and force-sets
pending.value = packageName; keep awaitFreeOrTimeout as a no-op for binary
compatibility (or call into the new API) and update callers to use
awaitFreeAndMarkPending instead of calling awaitFreeOrTimeout then markPending
to eliminate the TOCTOU race.
- Around line 14-23: The timeout check misinterprets success as timeout because
the withTimeoutOrNull block returns the sentinel null from pending.first, so
change the block in DefaultSystemInstallSerializer that assigns freed (the
withTimeoutOrNull { pending.first { it == null } }) to return Unit on success
(e.g., call pending.first { it == null } and then return Unit) so that
withTimeoutOrNull returns Unit on success and null only on a real timeout;
update the subsequent conditional to treat freed == null as a genuine timeout
and only then log the warning and set pending.value = null.

In
`@feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt`:
- Around line 2081-2083: Calls to systemInstallSerializer.awaitFreeOrTimeout()
and systemInstallSerializer.markPending(...) are executed unconditionally which
causes desktop/JVM installs to block for the full timeout because
markCompleted() is never called there; wrap the two calls in a platform guard so
they only run on Android: check the current platform (compare to
Platform.ANDROID) and only invoke systemInstallSerializer.awaitFreeOrTimeout()
and systemInstallSerializer.markPending(validatedApkInfo?.packageName ?: "")
when on Android, leaving installer.install(filePath, ext) unchanged for
non-Android targets; reference the existing symbols systemInstallSerializer,
awaitFreeOrTimeout, markPending, markCompleted, installer.install, and
Platform.ANDROID when making the change.
- Around line 2081-2083: The gate can be left locked when validatedApkInfo is
null and suffers a race between awaitFreeOrTimeout() and markPending(); fix by
only calling systemInstallSerializer.markPending(packageName) when
validatedApkInfo != null (i.e., pass the real package name instead of ""),
and/or make markCompleted() clear the gate unconditionally (remove the strict
compareAndSet(packageName, null) behavior). Also serialize the check-and-set
pair by adding a Mutex around the sequence
systemInstallSerializer.awaitFreeOrTimeout() followed immediately by
systemInstallSerializer.markPending(...) (or implement an atomic compare-and-set
inside systemInstallSerializer) so two coroutines cannot both pass the free
check and overwrite pending.value. Ensure callers use the
validatedApkInfo.packageName when present and avoid marking pending for null
APKs.

---

Outside diff comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt`:
- Around line 343-348: The catch blocks in runInstall and runStandaloneInstall
currently log and call markFailed when installer.install(...) throws (or a
coroutine is cancelled), but they do not call markCompleted, leaving the pending
slot locked for 60s; update both catch (t: Throwable) handlers to call
markCompleted(spec.packageName) (in addition to markFailed(spec.packageName,
t.message)) so the pending slot is cleared immediately; keep the existing
CancellationException rethrow behavior intact and ensure markCompleted is called
safely (it's a no-op if markPending was never set).
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 8592b693-8ea9-4e6a-a860-39d420f86b71

📥 Commits

Reviewing files that changed from the base of the PR and between 9579325 and 9805a69.

📒 Files selected for processing (23)
  • composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/di/ViewModelsModule.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/di/SharedModule.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/17.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/17.json
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Actionable comments posted: 1

🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt`:
- Around line 347-348: The catch (CancellationException) blocks in
DefaultDownloadOrchestrator (where
systemInstallSerializer.markCompleted(spec.packageName) is called)
unconditionally release the install gate; modify those handlers to check whether
the install was delegated to the platform installer before calling
markCompleted(spec.packageName) — e.g., consult the delegation state variable
used when delegating the install (the same flag checked by the broadcast
receiver/timeout) and only call systemInstallSerializer.markCompleted(...) if
delegation did not occur, otherwise skip immediate release and let the
receiver/timeout complete the flow; apply the same change to the other catch
block at the alternate location referenced in the review.
🪄 Autofix (Beta)

Fix all unresolved CodeRabbit comments on this PR:

  • Push a commit to this branch (recommended)
  • Create a new PR with the fixes

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: bc3f9dab-52b5-4568-96ea-1ba4f01dbafe

📥 Commits

Reviewing files that changed from the base of the PR and between 9805a69 and 4bfccac.

📒 Files selected for processing (6)
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultDownloadOrchestrator.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
🚧 Files skipped from review as they are similar to previous changes (5)
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/SystemInstallSerializer.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/AutoUpdateWorker.kt
  • feature/details/presentation/src/commonMain/kotlin/zed/rainxch/details/presentation/DetailsViewModel.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/services/DefaultSystemInstallSerializer.kt

@rainxchzed rainxchzed merged commit 25f26a8 into main May 8, 2026
1 check passed
@rainxchzed rainxchzed deleted the fix/apps-install-serialization branch May 8, 2026 10:51
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant