Skip to content

E1: external-import domain interfaces and models#461

Merged
rainxchzed merged 47 commits intomainfrom
feature/e1-external-imports
Apr 28, 2026
Merged

E1: external-import domain interfaces and models#461
rainxchzed merged 47 commits intomainfrom
feature/e1-external-imports

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented Apr 27, 2026

Summary by CodeRabbit

  • New Features
    • External app import wizard: automatic matching & auto-link summary, manual add/search override, per-app review cards, proposal banner with rescan, permission prompt, unlink confirmation, apps tab badge, add-by-link UI, signing-fingerprint syncing, desktop support, many UI strings/components.
  • Documentation
    • Backend handoff roadmap for external-match and signing-seeds endpoints.
  • Chores
    • Updated ignore rules and local permission allowlist.

- Introduce `DesktopAppDataPaths` to manage OS-specific application data directories (macOS, Windows, Linux).
- Migrate database and DataStore files from the system temporary directory to the new persistent app data location.
- Ensure all SQLite sidecar files (`.db-shm`, `.db-wal`) are included in the migration to preserve WAL transactions.
- Update `initDatabase` and `createDataStore` to utilize the new persistent storage paths.
- Add `roadmap/` to `.gitignore`.
@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented Apr 27, 2026

Note

Reviews paused

It looks like this branch is under active development. To avoid overwhelming you with review comments due to an influx of new commits, CodeRabbit has automatically paused this review. You can configure this behavior by changing the reviews.auto_review.auto_pause_after_reviewed_commits setting.

Use the following commands to manage reviews:

  • @coderabbitai resume to resume automatic reviews.
  • @coderabbitai review to trigger a single review.

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review

Walkthrough

Implements a complete external-app import feature: platform scanners (Android/JVM), DB schema v15 and migration, new DAOs/entities, backend match APIs/DTOs, repository logic and telemetry, tweaks, UI flow/components (Compose), DI/viewmodel wiring, startup/worker changes, and supporting docs/config updates.

Changes

Cohort / File(s) Summary
Config & Git
\.claude/settings.local.json, \.gitignore
Extended local Claude allowlist with git checkout * and rtk proxy *; added roadmap/ to .gitignore and adjusted trailing newline.
Android manifest & app startup
composeApp/src/androidMain/AndroidManifest.xml, composeApp/src/androidMain/kotlin/.../GithubStoreApp.kt
Added <queries> visibility block; GithubStoreApp now launches background coroutines to schedule initial import scan and sync signing-fingerprint seed; PackageEventReceiver instantiation updated to accept new deps.
DI / ViewModels
composeApp/.../di/ViewModelsModule.kt, core/data/.../di/PlatformModule.android.kt, core/data/.../di/PlatformModule.jvm.kt, core/data/.../di/SharedModule.kt
Registered ExternalImportViewModel and ExternalImportRepository bindings; added platform scanner bindings (Android/JVM), manifest/installer helpers, DAOs, and ExternalMatchApi selector (backend vs mock).
DB schema, migration & init
core/data/schemas/.../AppDatabase/15.json, .../migrations/MIGRATION_14_15.kt, core/data/.../local/db/initDatabase.kt, core/data/.../local/db/AppDatabase.kt
Bumped Room version to 15, added external_links and signing_fingerprints tables, added migration MIGRATION_14_15 and registered it in DB init; new DAO accessors added to AppDatabase.
Entities & DAOs
core/data/.../entities/ExternalLinkEntity.kt, SigningFingerprintEntity.kt, core/data/.../dao/ExternalLinkDao.kt, SigningFingerprintDao.kt
New Room entities and DAOs for external links and signing fingerprints with queries, flows, upsert/delete/prune operations and indexes.
Domain types & interfaces
core/domain/.../ExternalAppCandidate.kt, ExternalAppScanner.kt, ExternalLinkState.kt, InstallerKind.kt, ManifestHint.kt, ExternalDecisionSnapshot.kt, RepoMatchResult.kt, RepoMatchSource.kt, ScanResult.kt
Added domain models and interfaces for scanner, candidates, manifest hints, installer kinds, link states, snapshots, match results and scan summaries.
Repository & business logic
core/data/.../repository/ExternalImportRepositoryImpl.kt, core/domain/.../repository/ExternalImportRepository.kt
New ExternalImportRepository interface and implementation: scanning (initial/full/delta), candidate merging, evidence rules, fingerprint/manifest/backend matching, auto-link, manual link/skip/unlink, search, signing-seed sync, snapshot/undo and telemetry hooks.
Networking & DTOs
core/data/.../network/BackendApiClient.kt, ExternalMatchApi.kt, core/data/.../dto/ExternalMatchRequest.kt, ExternalMatchResponse.kt, SigningFingerprintSeedResponse.kt
Added backend client methods for external-match and signing-seeds; introduced ExternalMatchApi (backend/mock/selector) and serializable request/response DTOs.
Android scanner & helpers
core/data/src/androidMain/.../AndroidExternalAppScanner.kt, ManifestHintExtractor.kt, InstallerSourceClassifier.kt, SigningFingerprintComputer.kt
Android implementation for permission-aware package enumeration, manifest hint extraction, installer-source classification, and signing-fingerprint computation (API-aware, IO-dispatched, guarded).
JVM / Desktop support
core/data/src/jvmMain/.../DesktopExternalAppScanner.kt, DesktopAppDataPaths.kt, createDataStore.kt, initDatabase.kt
Desktop no-op scanner; app-data path utilities and migration helper; DataStore and DB paths moved to app data dir with migration from tmp.
Services & background workers
core/data/.../services/PackageEventReceiver.kt, UpdateCheckWorker.kt
PackageEventReceiver now accepts ExternalImportRepository/ExternalLinkDao and conditionally triggers guarded delta scans and unlink cleanup; UpdateCheckWorker runs best-effort external delta scans post-update check.
Telemetry & Tweaks
core/data/.../repository/TelemetryRepositoryImpl.kt, TweaksRepositoryImpl.kt, core/domain/.../repository/TelemetryRepository.kt, TweaksRepository.kt, core/data/.../dto/EventRequest.kt
Added external-import telemetry methods and enqueueExt path; extended EventRequest with bucket/flag fields; added persisted tweaks for enabling import/search and banner dismissal counter.
Presentation — Import feature (models, viewmodel, UI)
feature/apps/presentation/.../import/*
Full Compose-based import feature: models, ExternalImportViewModel, screens (permission, progress, auto-summary, review, empty, completion), components (cards, search override, confetti), undo flow, and localized strings.
Presentation — Apps & Details integration
feature/apps/presentation/.../AppsRoot.kt, AppsViewModel.kt, AppsState.kt, AppsAction.kt, AppsEvent.kt, feature/details/.../DetailsViewModel.kt, DetailsRoot.kt, DetailsAction.kt, DetailsState.kt
Apps: import-proposal banner, navigation wiring to ExternalImportScreen, rescan action and FAB adjustment; Details: unlink confirmation UI and transaction-backed unlink handling with telemetry.
Localization & docs
core/presentation/.../values/strings.xml, roadmap/E1_BACKEND_HANDOFF.md
Added ~90 localized strings/plurals for import flow and a new E1 backend handoff roadmap document under roadmap/.

Sequence Diagram(s)

sequenceDiagram
    participant User as UI/User
    participant VM as ExternalImportViewModel
    participant Repo as ExternalImportRepository
    participant Scanner as ExternalAppScanner
    participant PM as PackageManager
    participant DB as AppDatabase
    participant MatchAPI as ExternalMatchApi

    User->>VM: Start import flow / user actions
    VM->>Repo: scheduleInitialScanIfNeeded / runFullScan
    Repo->>Scanner: snapshot()
    Scanner->>PM: query installed packages & package info
    PM-->>Scanner: package list & metadata
    Scanner-->>Repo: ExternalAppCandidate list
    Repo->>DB: read existing external_links & signing_fingerprints
    DB-->>Repo: existing rows
    Repo->>MatchAPI: postExternalMatch(batch)
    MatchAPI-->>Repo: ExternalMatchResponse
    Repo->>DB: upsert merged external_links / signing_fingerprints
    DB-->>Repo: ack
    Repo-->>VM: emit ScanResult + pendingCandidatesFlow updates
    User->>VM: pick/link/skip/unlink
    VM->>Repo: linkManually / skipPackage / unlink / rescanSinglePackage
    Repo->>DB: persist changes
    DB-->>Repo: ack
    Repo-->>VM: updated flows/results
Loading

Estimated Code Review Effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

Poem

🐰 I hopped through APKs and sniffed each signature bright,

I matched owners, repos, by moon and by byte.
Cards to review, a skip, a link — undo if you please,
Confetti pops, the bunny grins, nibbling telemetry cheese. 🥕

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feature/e1-external-imports

@rainxchzed
Copy link
Copy Markdown
Member Author

CodeRabbit review: 7 applied, 4 pushed back

Commit c858b7cb addresses the 11 unresolved CodeRabbit threads on this PR.

Applied (7)

# Issue Resolution
2 Add dev.imranr.obtainium.fdroid to manifest queries + classifier Added to <queries> block and OBTAINIUM_PACKAGES set so manifest visibility, AndroidInstaller.isObtainiumInstalled(), and InstallerSourceClassifier agree on all three Obtainium variants.
3 signingCertificateHistory.firstOrNull() returned the original cert for v3-rotated apps SigningFingerprintComputer.certBytes now prefers apkContentsSigners.firstOrNull() (the active signer set) and falls back to signingCertificateHistory.lastOrNull().
5 importAutoMatched was dead code with a duplicate threshold constant Removed the method from ExternalImportRepositoryImpl, the matching declaration from the ExternalImportRepository interface, the orphaned AUTO_LINK_CONFIDENCE_THRESHOLD constant, and the now-unused ImportSummary data class. The VM's materializeAndMark → linkManually is now the single source of truth (single threshold lives in the VM as AUTO_LINK_THRESHOLD).
6 hadInstalledAppRow was hardcoded false on every snapshot Dropped the field from ExternalDecisionSnapshot. The VM already tracks the equivalent on its own PendingUndo.hadInstalledAppRowBefore via a fresh installedAppsRepository.getAppByPackage, so the snapshot field was a footgun for future callers and added nothing.
7 Non-atomic WAL migration could orphan WAL on partial failure initDatabase (jvmMain) now migrates .db-wal and .db-shm first, .db last. If sidecar migration fails, the .db stays in tmp and Room never opens a stranded migrated DB without its WAL.
8 PackageVisibilityRequester.isGranted performed a binder IPC on the main thread Changed the contract to suspend fun isGranted(): Boolean. Android impl wraps pm.getInstalledPackages(0) in withContext(Dispatchers.IO). JVM stub adapted. Single call site in PermissionRationaleScreen updated.
9 Banner re-flash race when tapping Review while flow emits before async DataStore write completes OnImportProposalReview now snapshots pendingExternalImportCount to the dismiss watermark (symmetric with OnImportProposalDismiss) instead of resetting the watermark to 0. The combine condition count > dismissedAt now evaluates false immediately and stays consistent across the navigation. The "reset to 0 on Review" semantic was overly clever and created the race; the new behavior is "Review acknowledges current candidates, banner re-shows when new ones arrive" — the same contract Dismiss has.

Pushed back (4)

# Issue Reasoning
1 Restrict broad shell perms in .claude/settings.local.json This file is the user's local Claude Code config, not a project policy. Project-wide command policy belongs in settings.json, not settings.local.json. The decision on whether to remove Bash(git checkout *) / Bash(rtk proxy *) from a personal allowlist is the user's call, not a code-review item. No change made.
4 observedAt seconds-vs-milliseconds magic conversion The backend OpenAPI contract (E1 plan §7.4) explicitly specifies epoch milliseconds. Adding "if value < 1e12 multiply by 1000" magic detection is YAGNI and papers over what should surface as a backend regression test failure if the contract ever drifts. Added a confirming inline comment on Row.observedAt instead.
10 / 11 import is a Kotlin hard keyword and the package name needs renaming Empirically false: the project compiles successfully with package zed.rainxch.apps.presentation.import.* across ~50 files. Per the Kotlin keyword reference, import is not in the hard-keywords list — it's only reserved at the start of a file in import declarations, not as a package segment. Renaming would touch ~50 files for zero technical benefit. The reviewer cited the Kotlin spec incorrectly. No change made.

Build verification

./gradlew :composeApp:assembleDebug :composeApp:jvmJarBUILD SUCCESSFUL. Both Android APK and Desktop JAR build clean.

Commit

c858b7cb E1: address coderabbit review on PR #461

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: 3

♻️ Duplicate comments (1)
feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt (1)

106-121: ⚠️ Potential issue | 🟠 Major

Banner dismissal can still race and re-show itself.

Both handlers hide the banner locally, but observePendingExternalImports() can still recompute showImportProposalBanner = true before setExternalImportBannerDismissedAtCount(current) finishes. The isExternalImportInFlight guard does not help here because this file never flips it to true, so the earlier race is still present and now affects both Review and Dismiss paths.

You need a synchronous guard/local watermark that is applied before launching the async write, then cleared from a completion path after the flow is done.

Also applies to: 471-491

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt`
around lines 106 - 121, observePendingExternalImports() can recompute
showImportProposalBanner true before the async write in
setExternalImportBannerDismissedAtCount(...) completes; add a synchronous local
watermark flag on the ViewModel (e.g., private var
localBannerDismissalWatermark: Long? or private var isLocalBannerDismissed:
Boolean) that the Review/Dismiss handlers set immediately before launching the
suspend write, use that flag in observePendingExternalImports()'s shouldShow
computation to suppress the banner (in addition to isExternalImportInFlight),
and clear/reset the watermark in the write completion/failure callbacks (and/or
when the repository flow emits an updated dismissedAt) so the flow can return to
normal; apply the same pattern for the duplicate handler region around the code
at 471-491.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt`:
- Around line 118-121: runDeltaScan currently deletes external-link rows
whenever a package has no current evidence, which can remove preserved decisions
(MATCHED/NEVER_ASK/SKIPPED); change the logic in runDeltaScan to first determine
whether the package truly no longer exists vs exists-but-has-no-evidence and
only hard-delete when the package is absent or the existing external-link state
is PENDING_REVIEW. Concretely, update the code around runDeltaScan and the calls
to externalLinkDao.prunePendingReviewNotIn / any delete calls so you: (1)
compute currentPackages (like livePackages in runFullScan), (2) for rows whose
package is absent perform hard delete, (3) for rows whose package exists but has
no evidence keep the row unless its state is PENDING_REVIEW (in which case you
may delete or move to appropriate state), and (4) mirror this same fix for the
similar block at the other occurrence (lines ~150-155). Ensure you reference and
adjust methods handling deletes (e.g., prunePendingReviewNotIn and whatever
delete/update methods used in runDeltaScan) so preserved decisions are not
removed on transient evidence misses.

In
`@feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt`:
- Around line 36-50: The requestOrOpenSettings() implementation is misleading
because QUERY_ALL_PACKAGES is not user-grantable and opening
Settings.ACTION_APPLICATION_DETAILS_SETTINGS cannot change package visibility;
remove or refactor requestOrOpenSettings() in
PackageVisibilityRequester.android.kt: either delete the method entirely and
rely on manifest-declared QUERY_ALL_PACKAGES with graceful degradation in
callers, or turn it into a clear no-op that immediately returns false and
documents that the user cannot change package visibility at runtime (update
callers of isGranted() accordingly to avoid expecting a runtime remediation
path).

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt`:
- Around line 80-90: The code currently calls
ExternalImportAction.OnPermissionGranted(sdkInt) immediately after
requester.requestOrOpenSettings(), which can mark the permission granted before
the user actually grants it; change the Button onClick handler (the scope.launch
block that calls onAction and uses requester.isGranted/requestOrOpenSettings) so
it only dispatches OnPermissionGranted(sdkInt) when requester.isGranted() truly
returns true (e.g., re-check on resume or after request completes), and instead
dispatch a new explicit action such as OnPermissionSettingsOpened or
OnPermissionDenied when the settings deep-link is launched or permission remains
false; remove the optimistic immediate OnPermissionGranted dispatch to ensure
the requester contract’s re-check is respected.

---

Duplicate comments:
In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt`:
- Around line 106-121: observePendingExternalImports() can recompute
showImportProposalBanner true before the async write in
setExternalImportBannerDismissedAtCount(...) completes; add a synchronous local
watermark flag on the ViewModel (e.g., private var
localBannerDismissalWatermark: Long? or private var isLocalBannerDismissed:
Boolean) that the Review/Dismiss handlers set immediately before launching the
suspend write, use that flag in observePendingExternalImports()'s shouldShow
computation to suppress the banner (in addition to isExternalImportInFlight),
and clear/reset the watermark in the write completion/failure callbacks (and/or
when the repository flow emits an updated dismissedAt) so the flow can return to
normal; apply the same pattern for the duplicate handler region around the code
at 471-491.
🪄 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: 77a10f29-7016-4c16-a757-465cc4f170bc

📥 Commits

Reviewing files that changed from the base of the PR and between 0980417 and c858b7c.

📒 Files selected for processing (14)
  • composeApp/src/androidMain/AndroidManifest.xml
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/SigningFingerprintComputer.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ExternalDecisionSnapshot.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/system/ScanResult.kt
  • feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt
  • feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt
✅ Files skipped from review due to trivial changes (3)
  • feature/apps/presentation/src/jvmMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.jvm.kt
  • composeApp/src/androidMain/AndroidManifest.xml
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/SigningFingerprintSeedResponse.kt
🚧 Files skipped from review as they are similar to previous changes (4)
  • core/data/src/jvmMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/external/InstallerSourceClassifier.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/ExternalImportRepository.kt

@rainxchzed
Copy link
Copy Markdown
Member Author

Round 2 — CodeRabbit review fixes + E1 closure

CodeRabbit fixes (3)

Issue Resolution
runDeltaScan deleted committed decisions on transient evidence miss ad313fdb. Now distinguishes "package uninstalled" (hard-delete) from "package present but no current evidence" (only delete PENDING_REVIEW rows; preserve MATCHED / NEVER_ASK / SKIPPED). Transient evidence misses — F-Droid seed not yet synced, manifest hint changed across reinstall — no longer wipe user decisions.
requestOrOpenSettings() deep-linked to App Info Settings as if QUERY_ALL_PACKAGES were grantable there 3b2bb378. The method is now a documented no-op returning false. QUERY_ALL_PACKAGES is install-time only on stock Android; deep-linking to App Info doesn't surface a toggle. Some OEMs (Samsung One UI 4+) expose "Special access → Allow access to all apps" but no public Intent reliably reaches it. Callers rely on isGranted() and the scanner's heuristic-degraded path.
PermissionRationaleScreen optimistically dispatched OnPermissionGranted after the settings deep-link Same commit. Now: if requester.isGranted() returns true → OnPermissionGranted; otherwise → OnPermissionDenied. We never lie to telemetry or skip the degraded-path UX.
Banner re-flash race despite the prior watermark-snapshot fix (DataStore write still async) f04c0da1. Added a @Volatile synchronous localBannerDismissedAtCount field on AppsViewModel. Review/Dismiss handlers set it BEFORE the async write; observePendingExternalImports OR's it into the persisted watermark via maxOf(...). Closes the window between state mutation and DataStore commit.

E1 closure (4)

Item Resolution
Strip E1Debug instrumentation ac5a1d31. ~30 log call sites + the helper field + the E1_DEBUG_TAG constant + upsertWithLog/deleteWithLog thin wrappers all removed. DAO calls inlined back.
Manual "Scan for GitHub apps" entry 413136e4. New overflow menu item on the Apps screen with Icons.Outlined.Search. Resets the banner watermark and routes into the wizard, which runs a fresh scan + match cycle on entry. Power users with a dismissed banner now have a discoverable rescan path without waiting for PACKAGE_ADDED broadcasts.
Apps-tab notification badge for pending external-import count b5d57319. The existing BottomNavigation.isUpdateAvailable parameter now unions update-availability with appsState.showImportProposalBanner. Single dot — honest "you have something to look at on this tab."
Backend handoff doc f02ec950. New file roadmap/E1_BACKEND_HANDOFF.md (gitignore-overridden into the repo) with the full spec for POST /v1/external-match and GET /v1/signing-seeds, plus the E6 telemetry overlap question (E1 already wires import_* events via TelemetryRepository — does E6's ProductTelemetry replace that or coexist?). Send to backend agent for action.

Build status

Both :composeApp:assembleDebug and :composeApp:jvmJar build clean.

What's left for E1 GA

Engineering: none from the closure list.

Tests (recommended, not blocking):

  • ExternalImportRepositoryImpl.resolveMatches — manifest-hint merging + dedup + sort
  • ExternalImportViewModel state machine
  • InstallerSourceClassifier — Samsung apps, OEM prefixes, Obtainium variants
  • ManifestHintExtractor — priority order, URL parsing
  • MIGRATION_14_15 via MigrationTestHelper

Backend (out of our scope): deploy POST /v1/external-match and GET /v1/signing-seeds, then flip tweaks.externalMatchSearchEnabled server-side. Until then, manifest hints + signing-cert seed work; backend search is mocked.

i18n backfill: the 12 non-English locales pick up the 54 new keys from the existing translation pipeline.

Manual TalkBack pass: flagged by the Accessibility Auditor as a pre-ship verification step.

E1 is functionally feature-complete and merge-ready.

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: 7

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/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt (1)

109-113: ⚠️ Potential issue | 🔴 Critical

The new delta scan is unreachable for first-time installs.

getAppByPackage(packageName) ?: return exits onPackageInstalled() before the getBackstopScope().launch { shouldRescan(...) } block runs. For previously-untracked installs, the import flow never sees the package, so the banner/wizard won't pick up new sideloaded apps at all.

Possible fix
-            val app = repo.getAppByPackage(packageName) ?: return
-
-            if (app.isPendingInstall) {
+            val app = repo.getAppByPackage(packageName)
+
+            if (app?.isPendingInstall == true) {
                 val systemInfo = monitor.getInstalledPackageInfo(packageName)
                 if (systemInfo != null) {
                     val expectedVersionCode = app.latestVersionCode ?: 0L
                     val wasActuallyUpdated =
                         expectedVersionCode > 0L &&
                             systemInfo.versionCode >= expectedVersionCode
@@
-            } else {
+            } else if (app != null) {
                 handleExternalInstall(packageName, app, repo, monitor)
             }

Also applies to: 165-179

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt`
around lines 109 - 113, The onPackageInstalled handler exits early due to
getAppByPackage(packageName) ?: return, which prevents the backstop rescan
launched via getBackstopScope().launch { shouldRescan(...) } from running for
first-time installs; change the control flow in onPackageInstalled (and the
similar block around lines 165-179) so that the backstop rescan is invoked
regardless of whether repo.getAppByPackage(...) returned an app — i.e., call
getBackstopScope().launch { shouldRescan(packageName, monitor, repo, /*...*/) }
before returning or in the null branch (use the packageName and
monitor/getRepository() as needed), while keeping the early return only for any
operations that truly require an existing App object.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In `@core/presentation/src/commonMain/composeResources/values/strings.xml`:
- Around line 806-809: The privacy text in the string resource
external_import_permission_body understates the backend payload; update that
string so it accurately lists the additional optional fields sent to
/v1/external-match (signingFingerprint, installerKind, manifestHint) along with
package name and app label, and rephrase the sentence about what is sent (e.g.,
"The optional backend lookup sends only the package name and app label, and when
available the signingFingerprint, installerKind, and manifestHint — never a full
list of what's installed.") to ensure the permission rationale matches the
actual payload.

In
`@feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt`:
- Around line 25-30: The current isGranted() in PackageVisibilityRequester uses
a fragile count heuristic (visible.size >= GRANT_THRESHOLD); replace this with
the scanner's sentinel-based check or rename the API to reflect it's only a
heuristic. Concretely, modify PackageVisibilityRequester.isGranted() to attempt
to resolve or getPackageInfo for the same sentinel package name used by the
scanner (e.g., call packageManager.getPackageInfo(sentinelPackage, 0) or
queryIntentActivities for a sentinel Intent wrapped in runCatching and return
true if the sentinel is visible) so the result matches the scanner logic, or
alternatively rename the method (and its occurrences) to
isLikelyGranted/isHeuristicGranted and keep the existing implementation if you
prefer the heuristic approach.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`:
- Around line 568-622: autoSummaryUndoAll currently deletes installed app rows
unconditionally; mirror the per-row undo logic by recording whether an
installed_apps row existed before auto-linking in autoMaterialize and consulting
that when undoing. In autoMaterialize (function autoMaterialize) capture
hadInstalledAppRowBefore per package (e.g., alongside
autoLinkedPackages/autoLinkedLabels or in a new autoLinkedHadInstalledRow map)
and persist it into state/pendingUndo; then in autoSummaryUndoAll only call
installedAppsRepository.deleteInstalledApp(pkg) when that recorded flag for pkg
is false (the same check used in undoLast: PendingUndo.Kind.Link &&
!hadInstalledAppRowBefore). Ensure the new field is cleared when you reset
autoLinkedPackages so the undo-all logic uses the recorded pre-link state.
- Around line 398-423: The skip flow currently logs failures from
externalImportRepository.skipPackage(...) but continues to
removeCardFromState(...), set pendingUndo and emit
ExternalImportEvent.ShowUndoSnackbar; change this so non-CancellationException
failures short-circuit: after catching Exception (non-Cancellation) send an
error event (e.g., ExternalImportEvent.ShowError with a clear message), do not
call removeCardFromState, do not set pendingUndo, do not send ShowUndoSnackbar,
and do not record telemetry.importSkipped; only proceed with
removeCardFromState, pendingUndo assignment, _events.send(ShowUndoSnackbar...)
and telemetry.importSkipped when skipPackage completes successfully.
- Around line 830-839: The when in InstallerKind.toUiLabel currently handles
only six enum values and uses an else branch that accidentally swallows
STORE_PLAY, STORE_AURORA, STORE_GALAXY, STORE_OEM_OTHER and SYSTEM; update the
function to remove the else branch and add explicit branches for STORE_PLAY,
STORE_AURORA, STORE_GALAXY, STORE_OEM_OTHER and SYSTEM (mapping each to the
appropriate string resource, e.g. Res.string.external_import_installer_play /
aurora / galaxy / oem_other / system or, if the intended behavior is to treat
them as unknown, explicitly map each of those five values to
getString(Res.string.external_import_installer_unknown)) so every InstallerKind
enum value is handled explicitly in toUiLabel.

In `@roadmap/E1_BACKEND_HANDOFF.md`:
- Line 79: The cache-key description is incorrect: currently stating that
excluding signingFingerprint forces fresh lookups is inverted; update the text
or the key so behavior matches intent — either change the cache key tuple to
include signingFingerprint (i.e., use (packageName, appLabel,
signingFingerprint) instead of (packageName, appLabel)) or rewrite the sentence
to say that excluding signingFingerprint will cause users with a different
fingerprint to reuse the same cached entry, not get a fresh lookup; reference
the cache key tuple `(packageName, appLabel)` and the `signingFingerprint`
symbol in your edit.
- Around line 68-71: The roadmap currently promises a 429 retry path but the
client does not implement WorkManager retries; update the text so it does not
instruct backends to rely on client retry behavior. Specifically, remove or
reword the `429 — rate limited; include Retry-After (client schedules
WorkManager retry)` line and instead state that 429 may be returned and that
clients should include Retry-After, but note that
ExternalImportRepositoryImpl.resolveMatches() only logs `external-match`
failures and does not schedule retries (or add instructions to implement retry
behavior in ExternalImportRepositoryImpl.resolveMatches() if backend must depend
on it). Ensure you reference ExternalImportRepositoryImpl.resolveMatches() in
the note so readers know the current client behavior.

---

Outside diff comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt`:
- Around line 109-113: The onPackageInstalled handler exits early due to
getAppByPackage(packageName) ?: return, which prevents the backstop rescan
launched via getBackstopScope().launch { shouldRescan(...) } from running for
first-time installs; change the control flow in onPackageInstalled (and the
similar block around lines 165-179) so that the backstop rescan is invoked
regardless of whether repo.getAppByPackage(...) returned an app — i.e., call
getBackstopScope().launch { shouldRescan(packageName, monitor, repo, /*...*/) }
before returning or in the null branch (use the packageName and
monitor/getRepository() as needed), while keeping the early return only for any
operations that truly require an existing App object.
🪄 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: 0303e299-a2d5-4b16-90be-4d4693b8cdc2

📥 Commits

Reviewing files that changed from the base of the PR and between c858b7c and f02ec95.

📒 Files selected for processing (13)
  • composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt
  • composeApp/src/commonMain/kotlin/zed/rainxch/githubstore/app/navigation/AppNavigation.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/ExternalImportRepositoryImpl.kt
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/components/PermissionRationaleScreen.kt
  • roadmap/E1_BACKEND_HANDOFF.md
✅ Files skipped from review due to trivial changes (1)
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsAction.kt
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/UpdateCheckWorker.kt
  • composeApp/src/androidMain/kotlin/zed/rainxch/githubstore/app/GithubStoreApp.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt

Comment on lines +25 to +30
override suspend fun isGranted(): Boolean =
withContext(Dispatchers.IO) {
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.R) return@withContext true
val pm = context.packageManager
val visible = runCatching { pm.getInstalledPackages(0) }.getOrElse { emptyList() }
visible.size >= GRANT_THRESHOLD
Copy link
Copy Markdown
Contributor

Choose a reason for hiding this comment

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

⚠️ Potential issue | 🟠 Major

🧩 Analysis chain

🌐 Web query:

On Android 11+, is using PackageManager.getInstalledPackages(0).size >= N a reliable way to determine whether an app has broad package visibility / QUERY_ALL_PACKAGES access, especially on devices with few installed apps?

💡 Result:

No, using PackageManager.getInstalledPackages(0).size >= N is not a reliable way to determine whether an app has broad package visibility or QUERY_ALL_PACKAGES access, especially on devices with few installed apps.

Citations:


Replace package-count heuristic with actual permission check or rename the API.

Using PackageManager.getInstalledPackages(0).size >= GRANT_THRESHOLD to determine broad package visibility is unreliable. A device with QUERY_ALL_PACKAGES access but fewer than 30 installed apps will be incorrectly classified as denied. Either switch to a proper sentinel-based check (matching the scanner logic) or rename isGranted() to reflect that it's a heuristic, not a real permission check.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/apps/presentation/src/androidMain/kotlin/zed/rainxch/apps/presentation/import/util/PackageVisibilityRequester.android.kt`
around lines 25 - 30, The current isGranted() in PackageVisibilityRequester uses
a fragile count heuristic (visible.size >= GRANT_THRESHOLD); replace this with
the scanner's sentinel-based check or rename the API to reflect it's only a
heuristic. Concretely, modify PackageVisibilityRequester.isGranted() to attempt
to resolve or getPackageInfo for the same sentinel package name used by the
scanner (e.g., call packageManager.getPackageInfo(sentinelPackage, 0) or
queryIntentActivities for a sentinel Intent wrapped in runCatching and return
true if the sentinel is visible) so the result matches the scanner logic, or
alternatively rename the method (and its occurrences) to
isLikelyGranted/isHeuristicGranted and keep the existing implementation if you
prefer the heuristic approach.

Comment thread roadmap/E1_BACKEND_HANDOFF.md Outdated
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: 3

♻️ Duplicate comments (1)
feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt (1)

690-727: ⚠️ Potential issue | 🟠 Major

Don't clear the whole wizard when bulk skip partially fails.

This still has the old per-card skip failure mode in bulk form: each skipPackage() error is only logged, but the flow clears all cards, emits confetti, and records telemetry for remaining.size anyway. Any package whose skip did not persist will silently come back on the next scan.

Suggested direction
         viewModelScope.launch {
+            val skippedPackages = mutableListOf<String>()
             remaining.forEach { card ->
                 try {
                     externalImportRepository.skipPackage(card.packageName, neverAsk = false)
+                    skippedPackages += card.packageName
                 } catch (e: CancellationException) {
                     throw e
                 } catch (e: Exception) {
                     logger.error("Skip-remaining failed for ${card.packageName}: ${e.message}")
                 }
             }

+            if (skippedPackages.isEmpty()) {
+                _events.send(
+                    ExternalImportEvent.ShowError(
+                        getString(Res.string.external_import_error_link_failed),
+                    ),
+                )
+                return@launch
+            }
+
             runCatching {
                 telemetry.importSkipped(
-                    countBucket = bucketCount(remaining.size),
+                    countBucket = bucketCount(skippedPackages.size),
                     persisted = "7day",
                 )
             }

             pendingUndo = null

             _state.update {
+                val failed = it.cards.filter { card -> card.packageName !in skippedPackages }
                 it.copy(
-                    cards = persistentListOf(),
-                    expandedPackages = persistentSetOf(),
-                    activeSearchPackage = null,
-                    searchQuery = "",
-                    searchResults = persistentListOf(),
-                    isSearching = false,
-                    searchError = null,
-                    phase = ImportPhase.Done,
-                    skipped = it.skipped + remaining.size,
-                    showCompletionToast = true,
+                    cards = failed.toPersistentList(),
+                    phase = if (failed.isEmpty()) ImportPhase.Done else ImportPhase.AwaitingReview,
+                    skipped = it.skipped + skippedPackages.size,
+                    showCompletionToast = failed.isEmpty(),
                 )
             }
-            _events.send(ExternalImportEvent.PlayConfetti)
+            if (_state.value.cards.isEmpty()) {
+                _events.send(ExternalImportEvent.PlayConfetti)
+            }
         }
🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`
around lines 690 - 727, The bulk-skip currently logs per-package failures but
proceeds to clear the entire wizard, emit confetti, and record telemetry using
remaining.size even if some skipPackage calls failed; change the flow to collect
which packages actually succeeded and which failed: inside viewModelScope.launch
iterate remaining calling externalImportRepository.skipPackage, rethrow
CancellationException, and for each success add to a successes list and for each
failure add to a failures list (and log); after the loop, call
telemetry.importSkipped with the count of successes (not remaining.size), update
_state to remove only the successfully skipped cards and increment skipped by
successes.size, keep pendingUndo unchanged (or set appropriately), and only send
ExternalImportEvent.PlayConfetti and set phase=Done/showCompletionToast when
failures.isEmpty(); if there are failures, preserve the failed cards in the
wizard and surface an error/snackbar instead of clearing everything.
🧹 Nitpick comments (1)
core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt (1)

195-203: Replace raw link-state strings with typed constants/enums.

Using "MATCHED" / "NEVER_ASK" as literals makes scan gating brittle to typos or future renames. Prefer a shared state enum/constant source from the external-link model/DAO.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt`
around lines 195 - 203, The shouldRescan function currently compares link?.state
to raw strings "MATCHED" and "NEVER_ASK"; replace those literals with the shared
typed constants or enum values from the external-link model/DAO (e.g.,
ExternalLink.State.MATCHED / ExternalLinkState.NEVER_ASK or
ExternalLink.STATE_MATCHED) by importing the model/enum used by
getExternalLinkDao() and comparing link.state to those typed values; if such an
enum/constant does not yet exist, add a single source-of-truth enum/companion
object in the external link model and use it here (update shouldRescan and any
other callers to use the enum/constants instead of raw strings).
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt`:
- Around line 310-311: The unlink call for external imports (runCatching {
getExternalImport().unlink(packageName) }) can fail and leave stale
external_links (MATCHED/NEVER_ASK) preventing future rescans; modify the failure
handling to retry the unlink once on the application coroutine scope: onFailure
log the error via Logger.w and then launch a single retry task (with a short
delay/backoff) that calls getExternalImport().unlink(packageName) again and logs
success or final failure, so shouldRescan() won’t be permanently suppressed by a
transient unlink error.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`:
- Around line 590-621: autoSummaryUndoAll currently calls
externalImportRepository.snapshotDecision after the auto-link exists, so
restoreDecision just re-applies the linked state; change autoSummaryUndoAll to
capture and store each package's pre-link snapshot via
externalImportRepository.snapshotDecision before any auto-linking occurs (so
restoreDecision will revert to the true pre-link row), and then continue the
existing delete/restore/unlink rollback logic using those pre-link snapshots;
apply the same change to the analogous undo path referenced around the other
block (the similar logic at the 731-759 region), and ensure that when
autoLinkedHadInstalledRow is reset you also clear autoLinkedSnapshots so no
stale snapshots remain.

In `@roadmap/E1_BACKEND_HANDOFF.md`:
- Around line 68-71: Update the 503 fallback wording to say the client falls
back to local matching (e.g., signing-cert seed matching) rather than
"manifest-only"; edit the text around the bullet for `503 — partial outage` and
any duplicate mention (also at the later occurrence) so it references local
matching capability and how it interacts with
`BackendApiClient.postExternalMatch` and
`ExternalImportRepositoryImpl.resolveMatches` behavior, and ensure the "Cannot
defer" section is consistent with this change.

---

Duplicate comments:
In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`:
- Around line 690-727: The bulk-skip currently logs per-package failures but
proceeds to clear the entire wizard, emit confetti, and record telemetry using
remaining.size even if some skipPackage calls failed; change the flow to collect
which packages actually succeeded and which failed: inside viewModelScope.launch
iterate remaining calling externalImportRepository.skipPackage, rethrow
CancellationException, and for each success add to a successes list and for each
failure add to a failures list (and log); after the loop, call
telemetry.importSkipped with the count of successes (not remaining.size), update
_state to remove only the successfully skipped cards and increment skipped by
successes.size, keep pendingUndo unchanged (or set appropriately), and only send
ExternalImportEvent.PlayConfetti and set phase=Done/showCompletionToast when
failures.isEmpty(); if there are failures, preserve the failed cards in the
wizard and surface an error/snackbar instead of clearing everything.

---

Nitpick comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt`:
- Around line 195-203: The shouldRescan function currently compares link?.state
to raw strings "MATCHED" and "NEVER_ASK"; replace those literals with the shared
typed constants or enum values from the external-link model/DAO (e.g.,
ExternalLink.State.MATCHED / ExternalLinkState.NEVER_ASK or
ExternalLink.STATE_MATCHED) by importing the model/enum used by
getExternalLinkDao() and comparing link.state to those typed values; if such an
enum/constant does not yet exist, add a single source-of-truth enum/companion
object in the external link model and use it here (update shouldRescan and any
other callers to use the enum/constants instead of raw strings).
🪄 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: 60ee65d5-9b6b-4894-839c-1fa18037d974

📥 Commits

Reviewing files that changed from the base of the PR and between f02ec95 and 843ebf2.

📒 Files selected for processing (4)
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt
  • roadmap/E1_BACKEND_HANDOFF.md
✅ Files skipped from review due to trivial changes (1)
  • core/presentation/src/commonMain/composeResources/values/strings.xml

Comment thread roadmap/E1_BACKEND_HANDOFF.md Outdated
@rainxchzed
Copy link
Copy Markdown
Member Author

Round 3 — CodeRabbit review fixes

7 commits added on top of round 2. Both Android APK and Desktop JAR build clean.

Applied (6)

Issue Resolution Commit
external_import_permission_body undersold backend payload Body now lists "package name and app label, and when available the signing fingerprint, install source, and any GitHub-repo hint declared in the app's manifest." Matches what the wire actually carries. caa07b7d
autoSummaryUndoAll wiped installed_apps rows that pre-existed before auto-link Added autoLinkedHadInstalledRow: Map<String, Boolean> field. Captured per-package in autoMaterialize BEFORE materializeAndMark writes the row. Bulk undo now mirrors undoLast's policy: only delete the installed_apps row when it was created by auto-link (hadRowBefore == false). Map is cleared on both autoSummaryContinue and autoSummaryUndoAll so it can't leak into a subsequent run. d11d09bb
Skip flow continued on failure (showed undo snackbar + telemetry for actions that didn't actually persist) skipPackage now captures success of externalImportRepository.skipPackage. On failure: send ShowError with external_import_error_link_failed, return early. No removeCardFromState, no pendingUndo, no telemetry, no snackbar. Same commit
InstallerKind.toUiLabel else swallowed STORE_PLAY / STORE_AURORA / STORE_GALAXY / STORE_OEM_OTHER / SYSTEM Removed else. Listed all 11 enum values explicitly. The five filtered-at-scanner values map to external_import_installer_unknown since they shouldn't reach the wizard, but if a future scanner change lets them through they'll render as "Unknown source" rather than crash. Same commit
PackageEventReceiver.onPackageInstalled early-returned before backstop rescan launch Restructured: getAppByPackage is called as ?: null, all tracked-app handling moved inside if (app != null), the backstop launch sits outside and always fires. First-time installs of GitHub-published apps now correctly trigger runDeltaScan(setOf(packageName)) so the wizard surfaces them. e6cae90c
Handoff doc cache-key wording was logically inverted Rewrote: "keyed on (packageName, appLabel, signingFingerprint). Including signingFingerprint in the key means a returning user with a different fingerprint bypasses the cache and gets a fresh look-up." Plus a note that the original plan §3.2 had the same inversion — this is the corrected version. 843ebf28
Handoff doc promised WorkManager-backed retry that the client doesn't implement Doc now states actual client behavior: BackendApiClient.postExternalMatch throws RateLimitedException on 429, ExternalImportRepositoryImpl.resolveMatches logs and continues with remaining batches, no automatic retry today. Backend that hard-rate-limits aggressively will see partial-result wizard sessions until that path lands. Same commit

Pushed back (1)

Issue Reasoning
PackageVisibilityRequester.isGranted() count heuristic should be replaced with sentinel-based check matching the scanner The reviewer's premise is incorrect: AndroidExternalAppScanner.isPermissionGranted() uses the same count heuristic (pkgs.size >= GRANT_THRESHOLD), and the constant is duplicated in the requester with a sync comment. The scanner does not use a sentinel-based check. There is no public Android API to query QUERY_ALL_PACKAGES grant directly; a sentinel-based approach would have its own pitfalls (which sentinel? users may or may not have it installed). The current implementation is consistent with the scanner and as accurate as the underlying Android visibility model permits. No change.

Build

./gradlew :composeApp:assembleDebug :composeApp:jvmJarBUILD SUCCESSFUL. Both targets clean.

Commit log (round 3)

843ebf28  E1 handoff: clarify cache key includes fingerprint, document actual 429 client behavior
e6cae90c  core/data: backstop delta scan fires for first-time installs of untracked packages
d11d09bb  E1: track installed-app pre-state for bulk undo, short-circuit failed skip, exhaustive installer label
caa07b7d  E1: permission rationale lists optional backend payload fields

@rainxchzed
Copy link
Copy Markdown
Member Author

Round 4 — CodeRabbit review fixes

3 commits added on top of round 3. Both targets build clean.

Applied (5)

Issue Resolution Commit
unlink failure on PACKAGE_FULLY_REMOVED left a stale MATCHED row → shouldRescan returned false on reinstall, silently dropping the user's previously-tracked app On the initial runCatching failure, log the warning, then schedule a single retry on the app coroutine scope after UNLINK_RETRY_DELAY_MS = 1_000 ms. Retry success/final-failure logged. If the retry also fails, the next UpdateCheckWorker.runPeriodicExternalDeltaScan sweep eventually reconciles. 8a16f59f
shouldRescan compared link?.state against raw "MATCHED" / "NEVER_ASK" strings Imported ExternalLinkState and compares against .MATCHED.name / .NEVER_ASK.name. Single source of truth, compiler catches typos. Same commit
autoSummaryUndoAll snapshotted decisions AFTER auto-link → restoreDecision was a no-op (re-applied the linked state) Added autoLinkedPreSnapshots: Map<String, ExternalDecisionSnapshot?> field. autoMaterialize captures snapshotDecision(pkg) BEFORE materializeAndMark writes the MATCHED row. Bulk undo now restores from the genuine pre-link state (typically PENDING_REVIEW). Map is cleared alongside autoLinkedHadInstalledRow on both autoSummaryContinue and autoSummaryUndoAll. c9104640
Bulk skipRemaining cleared the entire wizard, fired confetti + telemetry on remaining.size, even when per-package skips failed Tracks successes: Set<String> vs failures: List<String>. Only successfully-skipped cards are removed from state. Telemetry's countBucket reflects successes.size, not remaining.size. Failed cards stay in the wizard. Phase only transitions to Done if failures.isEmpty(). On any failure, sends ShowError instead of PlayConfetti. Same commit
Handoff doc 503 wording said "client falls back to manifest-only" — incomplete Doc now spells out actual resolveMatches behavior: three independent strategies (manifest hints, signing-cert seed lookup, backend match), and 503 only drops the backend strategy. Manifest + signing-cert seed continue to flow through. Vocabulary now consistent with the "Cannot defer" section. 076b7c84

Build

./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinJvmBUILD SUCCESSFUL. Both targets clean.

Behavior changes worth knowing

  • Reinstall recovery now works. A user who uninstalls a tracked app and reinstalls it later: the original PACKAGE_FULLY_REMOVED handler retries unlink on transient failure, ensuring shouldRescan returns true on the subsequent PACKAGE_ADDED. The user's app re-surfaces correctly.
  • "Undo all" on the auto-import summary actually rolls back. Previously the in-memory wizard cards were rebuilt but the DAO state was unchanged (silent UI/DAO mismatch). Now the DAO genuinely returns to the pre-link state.
  • Bulk skip is honest about failures. A partial failure no longer claims the whole wizard cleared.

Commits (round 4)

076b7c84  E1 handoff: 503 fallback covers manifest hints AND signing-cert seed
c9104640  E1: capture pre-link snapshots for true bulk-undo, track per-package success in skip-remaining
8a16f59f  core/data: retry unlink on transient failure, use typed ExternalLinkState in shouldRescan

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

♻️ Duplicate comments (1)
feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt (1)

403-405: ⚠️ Potential issue | 🟠 Major

Don’t collapse snapshot-read failures into “no previous decision.”

getOrNull() makes snapshot == null mean both “there was no prior row” and “snapshotDecision(...)threw”. In the failure case,undoLast()/autoSummaryUndoAll()will fall back tounlink(...)and can delete a pre-existingexternal_links` row instead of restoring it. Please fail the action/auto-link when the pre-link snapshot cannot be read, or carry an explicit capture-status flag so rollback never treats I/O failure as “first-ever decision.”

Also applies to: 468-470, 772-774

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`
around lines 403 - 405, The code currently collapses snapshot read failures into
a null "no previous decision" by using runCatching {
externalImportRepository.snapshotDecision(packageName) }.getOrNull(); change
this to capture the runCatching result explicitly (e.g., val snapshotResult =
runCatching { externalImportRepository.snapshotDecision(packageName) }) and then
branch: if snapshotResult.isFailure then fail/abort the action (or set an
explicit error flag) so undoLast/autoSummaryUndoAll do not treat I/O errors as
"no prior row", and only treat snapshotResult.getOrNull() as meaning "no row"
when snapshotResult.isSuccess; apply the same fix to the other snapshotDecision
usages in the file (the other runCatching(...).getOrNull() instances) and ensure
undoLast, autoSummaryUndoAll and unlink logic check the failure path and do not
perform destructive fallback on I/O failures.
🧹 Nitpick comments (1)
roadmap/E1_BACKEND_HANDOFF.md (1)

44-67: 🟡 Minor: Response example doesn’t include DTO coverage (but DTO context indicates it exists).

The response example (Lines 46-63) and surrounding text describe matches[].candidates[] fields, but don’t mention the coverage field present in ExternalMatchResponse.MatchEntry (from the provided DTO context snippet).

If coverage is actually emitted by the backend, the roadmap should document it (optional/nullable + semantics). If it’s not emitted, the DTO might be stale or should be clarified.

🤖 Prompt for AI Agents
Verify each finding against the current code and only fix it if needed.

In `@roadmap/E1_BACKEND_HANDOFF.md` around lines 44 - 67, The example response and
docs are missing the matches[].candidates[].coverage field that exists on the
DTO ExternalMatchResponse.MatchEntry; either update the example JSON and field
list to include "coverage" (document it as nullable/optional and describe its
semantics and units/range) or remove/clarify the stale coverage property from
the DTO; specifically edit the roadmap example (the Response (200) JSON and the
bullets describing candidates fields) to add "coverage": null | number (and a
short sentence about its meaning and whether it can be omitted), or mark
ExternalMatchResponse.MatchEntry.coverage as deprecated/removed if backend does
not emit it.
🤖 Prompt for all review comments with AI agents
Verify each finding against the current code and only fix it if needed.

Inline comments:
In
`@core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt`:
- Around line 319-331: The retry-success path that only deletes the
external-import row can miss fast reinstall races; update the onSuccess handler
inside the getBackstopScope().launch retry so that after successful
getExternalImport().unlink(packageName) it also triggers an immediate recovery
delta scan (call the same runDeltaScan() logic or a package-scoped equivalent)
for packageName on the appropriate coroutine scope; this ensures a reinstall
that happened during the 1s backoff is detected rather than waiting for the
periodic sweep.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`:
- Around line 520-535: The undo rollback currently swallows failures (in the
runCatching blocks around installedAppsRepository.deleteInstalledApp,
externalImportRepository.restoreDecision, and externalImportRepository.unlink),
allowing UI state and undo metadata to be cleared even when persistence fails;
change these to fail-fast: remove silent runCatching usage or check its result
and if an exception occurred, throw or return early to abort the undo flow so
autoSummaryUndoAll() and subsequent UI updates do not run and the existing undo
metadata is preserved, ensuring callers of the block see the error and can
retry; apply the same change to the analogous code paths (including the block
referenced at lines 616-635) so all repository rollback failures prevent local
state changes.

In `@roadmap/E1_BACKEND_HANDOFF.md`:
- Around line 26-43: Add a one-line clarification to the request contract
explaining the semantics of candidates[].manifestHint and its owner/repo fields:
state whether the manifestHint object may be omitted entirely (versus present
with nulls), confirm whether omitted and present-but-null are treated
identically by the backend, and specify that validation is applied only to
non-null fields (e.g., if owner is null and repo is non-null, only repo is
validated against `^[\w.-]{1,100}$`). Reference the symbols
`candidates[].manifestHint`, `candidates[].manifestHint.owner`, and
`candidates[].manifestHint.repo` when adding this sentence.
- Around line 73-78: The roadmap omits whether server-side scoring allows
negative confidence after applying the −0.20 no‑APK penalty; update the document
to explicitly state the backend's behavior for final confidence values (either
allow negatives as a valid low score or clamp post-calculation), reference the
scoring rules involving manifestHint.owner/repo HEAD validation,
signingFingerprint → signing_fingerprint lookup, and the search-only cap at
0.85, and specify the numeric clamp range the backend will enforce (e.g., clamp
to [0,1] or [0,∞) with explicit justification) so it aligns with client-side
display clamping (.coerceIn(0,100)) and tier logic (>=0.85/>=0.5).

---

Duplicate comments:
In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt`:
- Around line 403-405: The code currently collapses snapshot read failures into
a null "no previous decision" by using runCatching {
externalImportRepository.snapshotDecision(packageName) }.getOrNull(); change
this to capture the runCatching result explicitly (e.g., val snapshotResult =
runCatching { externalImportRepository.snapshotDecision(packageName) }) and then
branch: if snapshotResult.isFailure then fail/abort the action (or set an
explicit error flag) so undoLast/autoSummaryUndoAll do not treat I/O errors as
"no prior row", and only treat snapshotResult.getOrNull() as meaning "no row"
when snapshotResult.isSuccess; apply the same fix to the other snapshotDecision
usages in the file (the other runCatching(...).getOrNull() instances) and ensure
undoLast, autoSummaryUndoAll and unlink logic check the failure path and do not
perform destructive fallback on I/O failures.

---

Nitpick comments:
In `@roadmap/E1_BACKEND_HANDOFF.md`:
- Around line 44-67: The example response and docs are missing the
matches[].candidates[].coverage field that exists on the DTO
ExternalMatchResponse.MatchEntry; either update the example JSON and field list
to include "coverage" (document it as nullable/optional and describe its
semantics and units/range) or remove/clarify the stale coverage property from
the DTO; specifically edit the roadmap example (the Response (200) JSON and the
bullets describing candidates fields) to add "coverage": null | number (and a
short sentence about its meaning and whether it can be omitted), or mark
ExternalMatchResponse.MatchEntry.coverage as deprecated/removed if backend does
not emit it.
🪄 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: 0ea53297-0800-4f2d-9f6b-c5da2828c369

📥 Commits

Reviewing files that changed from the base of the PR and between 843ebf2 and c910464.

📒 Files selected for processing (3)
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/services/PackageEventReceiver.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/import/ExternalImportViewModel.kt
  • roadmap/E1_BACKEND_HANDOFF.md

Comment thread roadmap/E1_BACKEND_HANDOFF.md
Comment thread roadmap/E1_BACKEND_HANDOFF.md
@rainxchzed
Copy link
Copy Markdown
Member Author

Round 5 — CodeRabbit review fixes

3 commits added on top of round 4. Both targets build clean.

Applied (5)

Issue Resolution Commit
Reinstall race during the 1s unlink retry backoff: PACKAGE_ADDED could fire while external_links still held the stale MATCHED row, so shouldRescan returned false and the reinstalled app was silently dropped from tracking After the retry's onSuccess, evaluate shouldRescan(packageName) again; if it returns true, fire runDeltaScan(setOf(packageName)) immediately. Failures here are logged but don't escalate — the periodic worker sweep is still the eventual backstop. 5dde6d62
undoLast mutated UI state and consumed pendingUndo even when DAO rollback ops failed silently (runCatching wrappers swallowed the error) Removed the inner runCatching wrappers around deleteInstalledApp / unlink. DAO ops now bubble exceptions to the outer try/catch. pendingUndo is cleared and UI state mutated ONLY after every op succeeds. On failure: log, surface external_import_undo_failed snackbar, preserve pendingUndo so the user can retry from the snackbar. e6dceca6
autoSummaryUndoAll had the same swallowed-failure issue + UI claimed "undid everything" while DAO state was partial Wrapped the per-package rollback loop in a single try/catch. Any failure aborts mid-stream, surfaces error, leaves the user on AutoImportSummary so they can retry. Partial rollback is idempotent on retry (already-restored rows just get the same restoration applied again). Pre-link metadata maps stay populated until success — clearing them was moved past the failure boundary. Same commit
Snapshot-read failures were collapsed into null ("no prior row") via .getOrNull(), allowing unlink fallback in undo to wipe a row that should have been preserved skipPackage and pickSuggestion now capture runCatching { snapshotDecision(...) } explicitly. On isFailure they abort the action with external_import_error_link_failed — no DAO write, no card removal, no telemetry. autoMaterialize does the same per-candidate: snapshot failure → log warning and return@forEach (skip this candidate from auto-link, push it through manual review). Same commit
Handoff doc didn't specify manifestHint nullability semantics or confidence clamping Added: (a) one paragraph clarifying manifestHint may be omitted entirely OR sent fully-null and these are equivalent, validation runs per non-null field, partial values (one null one not) are treated as "no hint"; (b) one paragraph specifying backend MUST clamp confidence to [0.0, 1.0] before emitting — the −0.20 no-APK penalty can produce negative pre-clamp scores. References to client tier logic (≥0.85/≥0.5) and RepoCandidateRow's defensive coerceIn(0,100) for context. 7ab23bbe

Pushed back (1)

Issue Reasoning
Doc missing matches[].candidates[].coverage field Verified core/data/src/commonMain/kotlin/zed/rainxch/core/data/dto/ExternalMatchResponse.kt: MatchCandidate has owner, repo, confidence, source, stars?, description?no coverage field on the DTO. Reviewer's premise is incorrect. No change. If the field were intended for a future revision, it'd need to land in the DTO first; the doc is currently in sync with the DTO as it actually is.

Behavior changes worth knowing

  • Reinstall during the unlink retry window now recovers. The user uninstalls + immediately reinstalls; even if the initial unlink fails transiently, the retry success branch fires a delta scan that re-detects the reinstall. Previously this silently dropped the app from tracking.
  • Undo is honest about failures. Per-row Undo tap on a snackbar that fails the DAO write now keeps the snackbar action available (preserves pendingUndo) and shows an error toast. Bulk "Undo all" surfaces error and leaves the user on the summary screen instead of pretending the rollback worked.
  • Snapshot I/O failures no longer cascade into wrong undo decisions. A DB read error during action capture now aborts the action upfront rather than silently treating the failure as "no row" (which would have made later undo paths take the destructive unlink branch).

Build

./gradlew :composeApp:compileDebugKotlinAndroid :composeApp:compileKotlinJvmBUILD SUCCESSFUL. Both targets clean.

Commits (round 5)

7ab23bbe  E1 handoff: manifestHint nullability semantics, confidence clamping contract
e6dceca6  E1: fail-fast on undo and snapshot-capture failures (preserves undo metadata, surfaces error)
5dde6d62  core/data: post-retry delta scan recovers reinstalls during the unlink backoff window

@rainxchzed rainxchzed merged commit 83f3f97 into main Apr 28, 2026
1 check passed
@rainxchzed rainxchzed deleted the feature/e1-external-imports branch April 28, 2026 01:15
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