Skip to content
232 changes: 91 additions & 141 deletions CLAUDE.md

Large diffs are not rendered by default.

Original file line number Diff line number Diff line change
Expand Up @@ -243,6 +243,7 @@ class InstalledAppsRepositoryImpl(
pickedIndex: Int?,
pickedSiblingCount: Int?,
trackedPackageName: String,
installedAssetName: String?,
): ResolvedRelease? {
if (releases.isEmpty()) return null

Expand Down Expand Up @@ -307,8 +308,36 @@ class InstalledAppsRepositoryImpl(
// tracked `.fdroid` package keeps fdroid-named assets.
// Pinned variants are unaffected — fingerprintMatch /
// positionMatch run against the full installable set above.
// Stem filter (issue #591): when the tracked app has a known
// prior asset name, restrict the auto-pick pool to release
// assets that share its base-name stem. Stops sibling apps
// shipped from the same repo (`AppA-1.10.apk` vs
// `AppB-2.20.apk`) from cross-pollinating: without this,
// `choosePrimaryAsset` would pick the highest numeric
// version regardless of the filename prefix.
val installedStem =
installedAssetName
?.let { AssetVariant.extractBaseStem(it) }
?.takeIf { it.isNotEmpty() }
val autoPickPool =
AssetVariant.filterByPackageFlavor(installableForApp, trackedPackageName)
AssetVariant
.filterByPackageFlavor(installableForApp, trackedPackageName)
.let { pool ->
if (installedStem == null) {
pool
} else {
val matching =
pool.filter {
AssetVariant.extractBaseStem(it.name) == installedStem
}
// Only honour the stem filter when it
// actually keeps something; otherwise fall
// back to the broader pool. A maintainer
// legitimately renaming the binary should
// not strand the update check.
if (matching.isNotEmpty()) matching else pool
}
}
val primary = fingerprintMatch
?: positionMatch
?: installer.choosePrimaryAsset(autoPickPool)
Expand Down Expand Up @@ -374,6 +403,7 @@ class InstalledAppsRepositoryImpl(
pickedIndex = app.pickedAssetIndex,
pickedSiblingCount = app.pickedAssetSiblingCount,
trackedPackageName = app.packageName,
installedAssetName = app.installedAssetName,
)

if (resolved == null) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -257,6 +257,78 @@ object AssetVariant {
* the glob would just equal the filename and provides no rescue
* value beyond exact-match.
*/
/**
* Extracts the **base-name stem** of an asset — the lowercased,
* separator-stripped concatenation of every token that isn't a
* version-like number, an arch token, or a flavor token. Used to
* detect "sibling app in the same repo" cases where two releases
* ship `AppA-1.10.apk` and `AppB-2.20.apk` and the auto-picker
* would otherwise swap one for the other based on numeric version
* alone (issue #591).
*
* `AppA-1.10.apk` → `"appa"`
* `AppB-2.20.apk` → `"appb"`
* `app-arm64-v8a-1.10.apk` and `app-x86_64-1.10.apk` → both `"app"`
* `app-1.0.apk` and `app-fdroid-1.0.apk` → both `"app"`
*
* Returns an empty string when stripping leaves nothing behind
* (release ships only a versioned filename like `2.0.apk`). Callers
* treat empty as "no stem signal — don't filter".
*/
fun extractBaseStem(assetName: String): String {
Comment on lines +260 to +278
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 Orphaned KDoc for deriveGlob

extractBaseStem (and isVersionLikeToken) were inserted immediately after the closing */ of deriveGlob's KDoc block, placing them between the doc comment and the fun deriveGlob(...) declaration at line 332. As a result deriveGlob now has no KDoc attached (the floating comment above extractBaseStem's own block is not attributed to it), and IDEs will show deriveGlob as undocumented. Moving extractBaseStem + isVersionLikeToken to before the deriveGlob KDoc block restores the association.

val tokens = tokenize(assetName)
if (tokens.isEmpty()) return ""

// Mirror `extractTokens`' n-gram consumption pass so the
// fragments of compound vocab entries (e.g. `arm64-v8a` →
// tokens `["arm64","v8a"]`) are both stripped, not just the
// canonical-form half. Without this, `v8a` / `v7a` would
// survive the filter and `app-arm64-v8a-1.10.apk` would yield
// a different stem than `app-x86_64-1.10.apk`, defeating the
// sibling-app detection for arch-variant releases.
val consumed = BooleanArray(tokens.size)

for (i in 0 until tokens.size - 2) {
if (consumed[i] || consumed[i + 1] || consumed[i + 2]) continue
val candidate = "${tokens[i]}-${tokens[i + 1]}-${tokens[i + 2]}"
if (candidate in VOCABULARY) {
consumed[i] = true; consumed[i + 1] = true; consumed[i + 2] = true
}
}
for (i in 0 until tokens.size - 1) {
if (consumed[i] || consumed[i + 1]) continue
val dashed = "${tokens[i]}-${tokens[i + 1]}"
val underscored = "${tokens[i]}_${tokens[i + 1]}"
if (dashed in VOCABULARY || underscored in VOCABULARY) {
consumed[i] = true; consumed[i + 1] = true
}
}

val out = StringBuilder()
for (i in tokens.indices) {
if (consumed[i]) continue
val t = tokens[i]
if (t in VOCABULARY) continue
if (isVersionLikeToken(t)) continue
out.append(t)
}
return out.toString()
}

/**
* `1`, `10`, `1.0.0`, `v2.0.1`, `2024.04.10`, `1.0-rc1`, `beta3` —
* common patterns used in release filenames to encode the version.
* Conservative on purpose: false positives here just lose a stem
* character; false negatives would let a numeric variant leak into
* the stem and break the sibling-app detection.
*/
Comment on lines +318 to +324
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P2 The KDoc examples include 1.0-rc1 and beta3, implying the function recognises those patterns. After tokenisation, rc1 and beta3 are single tokens that don't satisfy either branch (all { it.isDigit() } or startsWith("v") && rest.all { it.isDigit() }), so they survive into the stem. A filename like AppA-1.0-rc1.apk produces stem "apparc1" while AppA-2.0.apk produces "appa" — no match, triggering the fallback pool (correct behaviour, but the doc implies it was handled). Removing 1.0-rc1 and beta3 from the examples aligns the doc with the conservative implementation.

Suggested change
/**
* `1`, `10`, `1.0.0`, `v2.0.1`, `2024.04.10`, `1.0-rc1`, `beta3` —
* common patterns used in release filenames to encode the version.
* Conservative on purpose: false positives here just lose a stem
* character; false negatives would let a numeric variant leak into
* the stem and break the sibling-app detection.
*/
/**
* `1`, `10`, `1.0.0`, `v2.0.1`, `2024.04.10` —
* common patterns used in release filenames to encode the version.
* Conservative on purpose: false positives here just lose a stem
* character; false negatives (e.g. `rc1`, `beta3` tokens after
* tokenisation) would let a non-numeric variant leak into the stem —
* the caller's pool fallback handles those cases gracefully.
*/

private fun isVersionLikeToken(token: String): Boolean {
if (token.isEmpty()) return false
if (token.all { it.isDigit() }) return true
if (token.startsWith("v") && token.drop(1).all { it.isDigit() }) return true
return false
}
Comment on lines +325 to +330
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 | ⚡ Quick win

Broaden version-token stripping to keep stems stable across prerelease bumps.

At Line 325, isVersionLikeToken() only strips numeric and v-numeric tokens. Qualifier counters like rc1/beta3 leak into stems, so names like AppA-1.0-rc1.apkAppA-1.0-rc2.apk no longer share a stem and can bypass sibling filtering.

💡 Suggested fix
+    private val QUALIFIER_VERSION_TOKEN =
+        Regex("""^(alpha|beta|rc|pre|preview)\d+$""")

     private fun isVersionLikeToken(token: String): Boolean {
-        if (token.isEmpty()) return false
-        if (token.all { it.isDigit() }) return true
-        if (token.startsWith("v") && token.drop(1).all { it.isDigit() }) return true
+        if (token.isEmpty()) return false
+        val t = token.lowercase()
+        if (t.all { it.isDigit() }) return true
+        if (t.startsWith("v") && t.length > 1 && t.drop(1).all { it.isDigit() }) return true
+        if (QUALIFIER_VERSION_TOKEN.matches(t)) return true
         return false
     }
🤖 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/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt`
around lines 325 - 330, The current isVersionLikeToken(token: String) only
recognizes pure digits and v+digits, so qualifier counters like "rc1" or "beta3"
are treated as stem content; update isVersionLikeToken to also recognize and
return true for tokens that combine alphabetical qualifiers with numeric
counters (e.g. "rc1", "beta3", "alpha2" and optionally with a leading 'v', e.g.
"vrc1") by adding a regex/conditional branch that matches patterns like optional
leading 'v' plus letters+digits (and optionally digits+letters if you want to be
liberal), keeping the existing digit and v+digit checks and using the function
name isVersionLikeToken to locate and replace the logic.


fun deriveGlob(assetName: String): String? {
val lower = assetName.lowercase()
// Match either:
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -23,7 +23,8 @@
"Better version detection — releases that share a numeric version but ship distinct build artifacts are no longer falsely flagged as 'already installed'.",
"Updating multiple apps in a row now works reliably — installs serialize so each one shows the correct APK in the system dialog. Apps screen versions also stay in sync after every install.",
"Auto-update picks the right APK when a release ships both a regular and an F-Droid variant — no more accidentally installing a sibling app with a different package id.",
"Search no longer shows a result count over an empty list — when 'Hide seen' filters out every hit, the screen now explains why and offers a one-tap reset."
"Search no longer shows a result count over an empty list — when 'Hide seen' filters out every hit, the screen now explains why and offers a one-tap reset.",
"Sibling-app detection — auto-update no longer mistakes a different app shipped from the same repo (e.g. APP A → APP B) for a higher version of the tracked one."
]
},
{
Expand Down
42 changes: 18 additions & 24 deletions feature/apps/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,28 +1,21 @@
# CLAUDE.md - Apps Feature
# Apps Feature

## Purpose
Installed-apps manager. Lists apps installed through GHS, launches them, checks updates. Android-only in nav (bottom-nav hidden on Desktop).

Manages installed applications. Lists all apps installed through GitHub Store, allows launching them, and checks for available updates. Primarily relevant on **Android** (apps section is hidden on Desktop).

## Module Structure
## Structure

```
feature/apps/
├── domain/
│ └── repository/AppsRepository.kt # Installed apps, launch, update check
├── data/
│ ├── di/SharedModule.kt # Koin: appsModule
│ └── repository/AppsRepositoryImpl.kt # Implementation using core InstalledAppsRepository
├── domain/repository/AppsRepository.kt
├── data/ AppsRepositoryImpl + di
└── presentation/
├── AppsViewModel.kt # State management for installed apps list
├── AppsState.kt # apps list, loading, error
├── AppsAction.kt # Refresh, OpenApp, CheckUpdates, clicks
├── AppsEvent.kt # One-off events
├── AppsRoot.kt # Main composable (apps list)
└── components/ # App item cards, update badges
├── AppsViewModel / State / Action / Event / Root
├── components/ app item cards, update badges, LinkAppBottomSheet, AdvancedAppSettingsBottomSheet, ApkInspectSheet, import banner
├── import/ # ExternalImportRoot/ViewModel — Obtainium import/export + manual-link
└── starred/ # StarredPickerRoot/ViewModel — APK-shipping subset of user's GitHub stars
```

## Key Interfaces
## Key interface

```kotlin
interface AppsRepository {
Expand All @@ -34,12 +27,13 @@ interface AppsRepository {

## Navigation

Route: `GithubStoreGraph.AppsScreen` (data object, no params)
`GithubStoreGraph.AppsScreen`, `ExternalImportScreen`, `StarredPickerScreen`.

## Implementation Notes
## Notes

- Uses `InstalledAppsRepository` and `SyncInstalledAppsUseCase` from core/domain
- `openApp()` uses `AppLauncher` from core/domain to launch the installed app
- `getLatestRelease()` checks if a newer version is available
- Platform-specific: `PackageMonitor` and `Installer` handle Android package management
- The apps section in the home screen bottom nav is only visible on `Platform.ANDROID`
- Uses `InstalledAppsRepository` + `SyncInstalledAppsUseCase`. `openApp` via `AppLauncher`. `PackageMonitor` + `Installer` (Android).
- Sort + search: `AppSortRule` enum (UpdatesFirst default, AlphabeticalAZ, RecentlyAdded, RecentlyUpdated). Persisted in DataStore. Inline search filters appName / packageName.
- Per-app actions: Ignore-updates (silence badge), Skip-this-release (per-tag, auto-clear on next release), Advanced filter (regex on asset names + monorepo fallback), Pin variant (token-set + glob fingerprint), Inspect APK (decoded manifest sheet).
- Auto-update on resume: `OnLifecycleResume` fires `autoCheckForUpdatesIfNeeded` (30-min cooldown) — catches drift after external install while GHS background-killed.
- External import (`import/`): Obtainium JSON import/export with pre-import summary buckets (imported / already-tracked / non-GitHub-skipped); manual-link-only path.
- Starred picker (`starred/`): scans signed-in user's GitHub stars, surfaces APK-shipping repos. Resumes mid-scan on rate-limit.
42 changes: 16 additions & 26 deletions feature/auth/CLAUDE.md
Original file line number Diff line number Diff line change
@@ -1,29 +1,19 @@
# CLAUDE.md - Auth Feature
# Auth Feature

## Purpose
GitHub OAuth via **device flow** — user visits a URL + enters code shown in app. No browser redirect. Works on Android + Desktop.

GitHub OAuth authentication using the **device flow**. Users authenticate by visiting a URL and entering a code displayed in the app. No browser redirect needed, making it suitable for both Android and Desktop.

## Module Structure
## Structure

```
feature/auth/
├── domain/
│ └── repository/AuthenticationRepository.kt # Device flow interface
├── data/
│ ├── di/SharedModule.kt # Koin: authModule
│ ├── repository/AuthenticationRepositoryImpl.kt # OAuth device flow implementation
│ └── network/GitHubAuthApi.kt # GitHub OAuth API endpoints
├── domain/repository/AuthenticationRepository.kt
├── data/ AuthenticationRepositoryImpl, network/GitHubAuthApi, di
└── presentation/
├── AuthenticationViewModel.kt # Manages device flow lifecycle
├── AuthenticationState.kt # Code, URL, loading, error
├── AuthenticationAction.kt # StartAuth, Cancel, etc.
├── AuthenticationEvent.kt # One-off events
├── AuthenticationRoot.kt # UI: displays code + verification URL
└── components/ # Auth UI components
├── AuthenticationViewModel / State / Action / Event / Root
└── components/
```

## Key Interfaces
## Key interface

```kotlin
interface AuthenticationRepository {
Expand All @@ -35,13 +25,13 @@ interface AuthenticationRepository {

## Navigation

Route: `GithubStoreGraph.AuthenticationScreen` (data object, no params)
`GithubStoreGraph.AuthenticationScreen`.

## Implementation Notes
## Notes

- Uses GitHub's [device authorization flow](https://docs.github.com/en/apps/oauth-apps/building-oauth-apps/authorizing-oauth-apps#device-flow)
- `startDeviceFlow()` returns a user code + verification URL to display
- `awaitDeviceToken()` polls GitHub until the user completes verification
- Token is stored via `TokenStore` in core/data (DataStore-backed)
- `GITHUB_CLIENT_ID` must be set in `local.properties` for builds
- `accessTokenFlow` is observed app-wide by `MainViewModel` for auth state
- Token stored via `TokenStore` (DataStore-backed). `accessTokenFlow` observed app-wide.
- `GITHUB_CLIENT_ID` in `local.properties` for builds.
- **Backend-proxied primary path:** `/v1/auth/device/start` + `/poll` on GHS backend so users on networks throttling `github.com` (China, corporate filters) can still log in. Each session picks one `AuthPath` (`Backend`|`Direct`), persists in `SavedStateHandle`. Only escalates `Backend → Direct` on infra errors (timeout/5xx). HTTP 4xx + GitHub negative 200-bodies (`authorization_pending`, `slow_down`, `access_denied`, `expired_token`, `bad_verification_code`) are real answers — never cause fallback.
- Backend rate limits hard: 10 starts/hr, 200 polls/hr per IP. Don't add retry loops on top of Ktor's `HttpRequestRetry(maxRetries = 2)`.
- Backend responses carry `X-Request-ID` — `GitHubAuthApi` embeds it in error messages via `asRequestIdTag()` (maps to backend logs).
- Both paths share same OAuth App — client `GITHUB_CLIENT_ID` must match backend's `GITHUB_OAUTH_CLIENT_ID`. Backend endpoints in `core/data/network/BackendEndpoints.kt`.
Loading