diff --git a/CLAUDE.md b/CLAUDE.md index eea96de57..2fbb804fc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -1,173 +1,123 @@ -# CLAUDE.md - GitHub Store +# GitHub Store -## Project Overview +Cross-platform app store for GitHub releases. **Kotlin Multiplatform** + **Compose Multiplatform**. Android (min API 26) + Desktop (JVM: Win/macOS/Linux). Package `zed.rainxch.githubstore`. Version 1.8.2 (code 17). Target SDK 36. -GitHub Store is a cross-platform app store for GitHub releases, built with **Kotlin Multiplatform (KMP)** and **Compose Multiplatform**. Targets **Android** (min API 26) and **Desktop** (Windows, macOS, Linux via JVM). - -Package: `zed.rainxch.githubstore` | Version: 1.6.2 (code 13) | Target SDK: 36 - -## Build & Run Commands +## Build ```bash -# Android -./gradlew :composeApp:assembleDebug -./gradlew :composeApp:assembleRelease - -# Desktop (run in dev mode) -./gradlew :composeApp:run - -# Desktop installers -./gradlew :composeApp:packageExe :composeApp:packageMsi # Windows -./gradlew :composeApp:packageDmg :composeApp:packagePkg # macOS -./gradlew :composeApp:packageDeb :composeApp:packageRpm # Linux - -# Full build check -./gradlew build +./gradlew :composeApp:assembleDebug # Android +./gradlew :composeApp:run # Desktop dev +./gradlew :composeApp:packageExe :composeApp:packageMsi # Win installer +./gradlew :composeApp:packageDmg :composeApp:packagePkg # macOS +./gradlew :composeApp:packageDeb :composeApp:packageRpm # Linux +./gradlew build # full ``` -**Requirements:** JDK 21+ (Temurin recommended), Android SDK for Android builds. +JDK 21+. Android SDK for Android. -## Project Structure +## Structure ``` -composeApp/ # Main app module (entry points, navigation, DI wiring) - src/commonMain/ # Shared UI & app wiring - src/androidMain/ # Android entry point (MainActivity) - src/jvmMain/ # Desktop entry point (DesktopApp.kt) +composeApp/ # entry points, navigation, DI wiring (commonMain / androidMain / jvmMain) core/ - domain/ # Shared interfaces, models, use cases (no framework deps) - data/ # Shared repos, networking (Ktor), database (Room), DI - presentation/ # Shared theming (Material 3) & reusable UI components + domain/ # interfaces, models, use cases (no framework deps) + data/ # repos, Ktor, Room, Koin, platform impls + presentation/ # Material 3 theme + reusable components + 13-locale strings feature/ - apps/ # Installed applications management - auth/ # GitHub OAuth device flow authentication - details/ # Repository details, releases, readme, downloads - dev-profile/ # Developer/user profile display - favourites/ # Saved favorite repositories (presentation-only) - home/ # Main discovery screen (trending, hot, popular) - profile/ # User profile, settings, appearance, proxy, Shizuku installer - search/ # Repository search with filters - starred/ # Starred repositories (presentation-only) -build-logic/convention/ # Custom Gradle convention plugins + apps auth details dev-profile favourites home profile recently-viewed search starred tweaks +build-logic/convention/ # convention plugins ``` -Each feature has up to 3 sub-modules: `domain/` (interfaces & models), `data/` (implementations & DI), `presentation/` (screens & ViewModels). Some features (favourites, starred) are presentation-only and use core repositories directly. +Each feature: up to 3 sub-modules (`domain/`, `data/`, `presentation/`). `favourites`, `starred`, `recently-viewed` are presentation-only. ## Architecture -**Clean Architecture + MVVM** with strict layer separation per feature module: - -- **Domain** - Repository interfaces, models, use cases (no framework dependencies) -- **Data** - Repository implementations, Ktor API clients, Room DAOs, DTOs, mappers -- **Presentation** - ViewModels with `StateFlow`/`Channel`, Compose screens - -### State Management Pattern +Clean Architecture + MVVM. Layers: **Domain** (contracts), **Data** (Ktor + Room + Koin DI), **Presentation** (ViewModels with `StateFlow`/`Channel`, Compose). -Every screen follows the same State/Action/Event pattern: +### State pattern (every screen) ```kotlin class XViewModel : ViewModel() { private val _state = MutableStateFlow(XState()) - val state = _state.asStateFlow() // or .stateIn() with WhileSubscribed - + val state = _state.asStateFlow() // or .stateIn(WhileSubscribed) private val _events = Channel() val events = _events.receiveAsFlow() - fun onAction(action: XAction) { ... } } ``` -- `State` - data class holding all UI state -- `Action` - sealed interface for user input (clicks, refreshes, etc.) -- `Event` - sealed interface for one-off effects (navigation, toasts, scroll) +`State` = data class. `Action` = sealed (user input). `Event` = sealed (one-off effects). ### Navigation -Type-safe navigation using `@Serializable` sealed interface `GithubStoreGraph`: +`@Serializable` sealed interface `GithubStoreGraph` in `composeApp/.../app/navigation/`. Routes: `HomeScreen`, `SearchScreen`, `AuthenticationScreen`, `ProfileScreen`, `TweaksScreen`, `FavouritesScreen`, `StarredReposScreen`, `RecentlyViewedScreen`, `AppsScreen`, `SponsorScreen`, `ExternalImportScreen`, `MirrorPickerScreen`, `StarredPickerScreen`, `SkippedUpdatesScreen`, `HiddenRepositoriesScreen`, `WhatsNewHistoryScreen`, `AnnouncementsScreen`, `DetailsScreen(repositoryId, owner, repo, isComingFromUpdate)`, `DeveloperProfileScreen(username)`. -``` -HomeScreen, SearchScreen, AuthenticationScreen, ProfileScreen, -FavouritesScreen, StarredReposScreen, AppsScreen, SponsorScreen -DetailsScreen(repositoryId, owner, repo, isComingFromUpdate) -DeveloperProfileScreen(username) -``` +### DI + +Koin. Feature modules in `data/di/SharedModule.kt`. ViewModels in `composeApp/.../app/di/ViewModelsModule.kt` (`viewModelOf(::X)` or explicit `viewModel { ... }`). Wired in `initKoin.kt`. + +## Core repositories (`core/domain`) + +`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `SeenReposRepository`, `HiddenReposRepository`, `SearchHistoryRepository`, `TweaksRepository`, `AuthenticationState`, `ThemesRepository`, `ProxyRepository`, `RateLimitRepository`, `ExternalImportRepository`, `TelemetryRepository`. Util: `AssetVariant` (token/glob/stem fingerprinting), `assetPlatformOf`. System interfaces: `Installer`, `InstallerStatusProvider`, `PackageMonitor`, `SystemInstallSerializer`. + +## Tech + +Kotlin 2.3.10, Compose Multiplatform 1.10.3, Ktor 3.4.0, Room 2.8.4, Koin 4.1.1, kotlinx.serialization 1.10.0, DataStore 1.2.0, Landscapist 2.9.5, Kermit 2.0.8, MOKO Permissions 0.20.1, Navigation Compose 2.9.2, multiplatform-markdown-renderer 0.39.2, Shizuku 13.1.5, WorkManager 2.11.1, kotlinx.datetime 0.7.1. Versions in `gradle/libs.versions.toml`. + +## Convention plugins (`build-logic/convention/`) + +`convention.kmp.library` (domain/data), `convention.cmp.library` (core/presentation), `convention.cmp.feature` (feature presentation), `convention.cmp.application` (main app), `convention.room`, `convention.buildkonfig`. + +## Adding a feature + +1. `feature//{domain,data,presentation}/` with appropriate convention plugin +2. `include` in `settings.gradle.kts` +3. Domain interfaces → impl + Koin module in `data/di/SharedModule.kt` → ViewModel + Screen +4. Route in `GithubStoreGraph.kt` + wire in `AppNavigation.kt` + register Koin in `initKoin.kt` + +## Key configuration + +- **GitHub OAuth:** `GITHUB_CLIENT_ID` in `local.properties`. Callback `githubstore://callback`. Deep link `githubstore://repo`. +- **Shizuku (Android):** silent install via `ShizukuProvider` → AIDL → `pm install -S`. Fallback to standard installer on failure. +- **Desktop logs:** `CrashReporter` (first line of `DesktopApp.main`) tees stdout/stderr to rotating `session.log` + writes `crash-.log` on uncaught. Paths: `~/Library/Logs/GitHub-Store/` (macOS), `%LOCALAPPDATA%/GitHub-Store/logs/` (Win), `$XDG_STATE_HOME/GitHub-Store/logs/` (Linux). Android = Logcat. +- **`X-GitHub-Token` header:** Client attaches when `TokenStore.currentToken()` is non-null on `/v1/search`, `/v1/search/explore`, `/v1/repo`, `/v1/releases`, `/v1/readme`, `/v1/user`. Backend re-sends as `Authorization: token $token` to GitHub. Without it, backend round-robins a 4-token service pool. Upstream 401 remapped to backend `502` (handled like "GitHub unreachable" — fall back via `shouldFallbackToGithubOrRethrow`). `429` = no fallback (same wall), only backoff. `UnauthorizedInterceptor` only on direct-GitHub client; `AuthenticationStateImpl` debounces consecutive 401s by token snapshot. +- **Device-flow auth proxy:** `feature/auth` calls backend `/v1/auth/device/start` + `/poll` as primary path. Each session picks one `AuthPath` (`Backend`|`Direct`), persists in `SavedStateHandle`, only escalates to `Direct` on infra errors (timeout/5xx); HTTP 4xx + GitHub negative 200-bodies are real answers. 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)`. Endpoints in `core/data/network/BackendEndpoints.kt`. +- **Windows installer signing (SignPath Foundation):** CI workflow `.github/workflows/build-desktop-platforms.yml` job `sign-windows` after every push to `generate-installers` branch. Action pinned to commit SHA (not `@v2`). Secrets: `SIGNPATH_API_TOKEN`, `SIGNPATH_ORGANIZATION_ID` (`1ecf111e-...`). Variable `SIGNPATH_SIGNING_POLICY_SLUG` = `test-signing` until prod cert issued; flip to `release-signing`. Project slug `GitHub-Store`, artifact config slug `initial`. Unsigned artifact deleted post-sign; only `windows-installers-signed` reaches the draft release. +- **Gradle:** Config + build cache enabled. 4GB Gradle heap, 3GB Kotlin daemon. Official Kotlin style. + +## Active skills (apply on matching domain) + +- **caveman** — session default, terse output. +- **karpathy-guidelines** — anti-overcomplication, minimal diffs, surface assumptions, verifiable success criteria. Every coding task. +- **one-skill-to-rule-them-all** — watch for skill-capture opportunities during multi-step work. +- **gsd-inbox** - Triage open GitHub issues + PRs against templates. Our exact pattern — automate the "check issue #N, draft reply, ship fix" loop. +- **gsd-ship** - Create PR + review + prep for merge. Every task ends here. +- **gsd-quick** - Trivial task with atomic commits + state tracking. Matches our small-commit policy. +- **gsd-debug** - Systematic debugging with persistent state across context resets. For bug-hunt cycles. +- **android-* skills** (`~/.claude/skills/android/`) — auto-fire by description match; apply when in matching domain: + - `android-compose-ui` — composables, recomposition, animations, modifiers, design system + - `android-data-layer` — repos, DTOs, Room, Ktor, mappers + - `android-di-koin` — Koin module setup, ViewModel injection + - `android-error-handling` — Result wrapper, typed errors + - `android-module-structure` — feature-layered modules, convention plugins + - `android-navigation` — type-safe Compose nav + - `android-presentation-mvi` — State/Action/Event, Root/Screen split, UiText, SavedStateHandle + - `android-testing` — testing patterns + +## Conventions + +- Packages `zed.rainxch.{module}.{layer}` +- Private state fields prefix `_state` +- Sealed routes/actions/events +- Repository pattern: interface in `domain/`, impl in `data/` +- Source sets: `commonMain` shared, `androidMain`, `jvmMain` +- **No KDoc, no inline comments** unless the user explicitly asks. No function/class docs. Inline only for non-obvious invariants, tricky concurrency, workarounds. Applies globally. +- Feature-specific guidance in each `feature/*/CLAUDE.md` -Routes defined in `composeApp/.../app/navigation/GithubStoreGraph.kt`, wired in `AppNavigation.kt`. - -### Dependency Injection - -**Koin** - modules defined in each feature's `data/di/SharedModule.kt`, registered in `composeApp/.../app/di/initKoin.kt`. ViewModels injected via `koinViewModel()`. - -### Core Modules - -| Module | Purpose | Key Contents | -|--------|---------|--------------| -| `core/domain` | Shared contracts | Repository interfaces (`FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `ThemesRepository`, `ProxyRepository`, `RateLimitRepository`), models (`GithubRepoSummary`, `GithubRelease`, `InstalledApp`, `ProxyConfig`, `InstallerType`, `ShizukuAvailability`), system interfaces (`Installer`, `InstallerInfoExtractor`, `InstallerStatusProvider`, `PackageMonitor`) | -| `core/data` | Shared implementations | `HttpClientFactory` (Ktor + interceptors), `AppDatabase` (Room), `ProxyManager`, `TokenStore`, `LocalizationManager`, platform-specific clients (OkHttp for Android, CIO for Desktop), Shizuku integration (Android: `ShizukuServiceManager`, `ShizukuInstallerWrapper`, `ShizukuInstallerServiceImpl`, `AndroidInstallerStatusProvider`; Desktop: `DesktopInstallerStatusProvider`) | -| `core/presentation` | Shared UI | `GithubStoreTheme` (Material 3), reusable components (`RepositoryCard`, `GithubStoreButton`), formatting utils, localized strings (13 languages) | - -## Tech Stack - -| Area | Library | Version | -|------|---------|---------| -| Language | Kotlin | 2.3.10 | -| UI | Compose Multiplatform | 1.10.1 | -| HTTP | Ktor | 3.4.0 | -| Database | Room | 2.8.4 | -| DI | Koin | 4.1.1 | -| Serialization | Kotlinx Serialization | 1.10.0 | -| Preferences | DataStore | 1.2.0 | -| Image Loading | Landscapist (Coil3) | 2.9.5 | -| Logging | Kermit | 2.0.8 | -| Permissions | MOKO Permissions | 0.20.1 | -| Navigation | Navigation Compose | 2.9.2 | -| Markdown | Multiplatform Markdown Renderer | 0.39.2 | -| Shizuku | Shizuku API | 13.1.5 | -| Background Work | WorkManager | 2.11.1 | -| Date/Time | Kotlinx Datetime | 0.7.1 | - -All versions managed in `gradle/libs.versions.toml` (Version Catalog). - -## Convention Plugins - -Custom Gradle plugins in `build-logic/convention/` standardize module setup: - -| Plugin | Use For | -|--------|---------| -| `convention.kmp.library` | KMP shared library modules (domain, data) | -| `convention.cmp.library` | Compose Multiplatform library modules (core/presentation) | -| `convention.cmp.feature` | Feature presentation modules (auto-adds Compose + Koin + core:presentation) | -| `convention.cmp.application` | Main app module | -| `convention.room` | Room database modules | -| `convention.buildkonfig` | Build-time config (API keys from local.properties) | - -## Adding a New Feature - -1. Create `feature//domain/`, `feature//data/`, `feature//presentation/` -2. Add `build.gradle.kts` in each using the appropriate convention plugin -3. Add `include` entries in `settings.gradle.kts` -4. Define domain interfaces/models in `domain/` -5. Implement repository + Koin DI module in `data/di/SharedModule.kt` -6. Create ViewModel (State/Action/Event pattern) and Screen in `presentation/` -7. Add navigation route to `GithubStoreGraph.kt` and wire in `AppNavigation.kt` -8. Register the Koin module in `initKoin.kt` - -## Key Configuration - -- **GitHub OAuth:** Set `GITHUB_CLIENT_ID` in `local.properties`. Callback URL: `githubstore://callback`. Deep link: `githubstore://repo` -- **Shizuku (Android):** Optional silent install via `ShizukuProvider` (registered in AndroidManifest). Requires Shizuku app running with ADB or root. AIDL service passes APK via `ParcelFileDescriptor` to `pm install -S`. Falls back to standard installer on failure. -- **Gradle properties:** Config cache enabled, build cache enabled, 4GB Gradle heap, 3GB Kotlin daemon heap -- **Code style:** Official Kotlin style (`kotlin.code.style=official`) -- **Desktop logs:** `CrashReporter` (installed as the first line of `DesktopApp.main`) tees `System.out`/`System.err` to a rotating `session.log` and writes `crash-.log` on uncaught exceptions. Paths: `~/Library/Logs/GitHub-Store/` (macOS), `%LOCALAPPDATA%/GitHub-Store/logs/` (Windows), `$XDG_STATE_HOME/GitHub-Store/logs/` (Linux). Android uses Logcat — no CrashReporter. -- **`X-GitHub-Token` header:** **Client-side**, `BackendApiClient.getRepo` / `getReleases` / `getReadme` / `getUser` always attach the header when a token exists in `TokenStore.currentToken()` (sourced through the `private` helper `currentUserGithubToken()`); they don't gate it on what the backend will do with it. **Backend-side**, the same header is consumed on every passthrough route — `/v1/search`, `/v1/search/explore`, `/v1/repo/{owner}/{name}` (only when the lazy-fetch DB-miss path actually hits GitHub upstream — cached hits don't need it), `/v1/releases/{owner}/{name}`, `/v1/readme/{owner}/{name}`, `/v1/user/{username}` — and re-sent as `Authorization: token $token` to `api.github.com`. The upstream call then runs under the user's own 5000/hr OAuth quota; the per-token rate-limit bucket on the backend's `search` plugin is keyed by token-hash (or IP for anon), so a logged-in user always gets their own per-user 60/min slot regardless of POP. **Without the header**, the backend round-robins a 4-token service pool (20k/hr aggregate) outside the daily quiet window; if the user's own token gets 403/429 from GitHub, the backend retries once with a pool token (also outside the quiet window) before surfacing the failure. The DB-side resource cache slot is keyed `|authed` vs `|anon` per token presence, so a 4xx response under one token tier doesn't poison the cache for the other tier. DB-only routes never read the header: `/v1/categories`, `/v1/topics`, `/v1/events`, `/v1/auth/device/start`, `/v1/auth/device/poll`, `/v1/badge/...` — and the client doesn't send it on those either (the per-route `httpClient.get` block decides). Never logged (no Ktor `Logging` plugin installed). **Status-code semantics** on passthrough routes: backend remaps GitHub-upstream 401 (rejected token, e.g. revoked PAT) into a backend `502` so the client never sees a "session expired" 401 on these endpoints; `502` therefore means *either* "GitHub unreachable" *or* "upstream rejected our auth" and is handled the same way — fall back to direct GitHub via `shouldFallbackToGithubOrRethrow`. `429` from these routes means the backend exhausted both the user's token bucket *and* the pool retry (or the request landed in the quiet window where pool retry is disabled); the client must **not** fall back to direct GitHub on `429` (same wall, same token), only back off and retry later — `shouldFallbackToGithubOrRethrow` already returns `false` for the entire 4xx range. The client's `UnauthorizedInterceptor` only installs on `createGitHubHttpClient` (direct GitHub calls), and `AuthenticationStateImpl` additionally debounces consecutive 401s under the same token (token snapshot threaded through from the request, reset on any non-401 response) so a single transient direct-GitHub 401 can't sign the user out. -- **Device-flow auth proxy:** `feature/auth` calls `/v1/auth/device/start` and `/v1/auth/device/poll` on the backend as the primary path so users on networks that throttle `github.com` (China, corporate filters) can still complete login. Each session picks one `AuthPath` (`Backend` or `Direct`) at start and sticks to it; `AuthenticationRepositoryImpl` only escalates `Backend → Direct` on infrastructure errors (`HttpRequestTimeoutException`, `SocketTimeoutException`, `ConnectTimeoutException`, `BackendHttpException` with 5xx). HTTP 4xx and GitHub's valid-but-negative 200-bodies (`authorization_pending`, `slow_down`, `access_denied`, `expired_token`, `bad_verification_code`) are real answers, never cause fallback. `AuthenticationViewModel` persists `auth_path` in `SavedStateHandle` so activity recreation resumes on the same path. The Direct path still requires `BuildKonfig.GITHUB_CLIENT_ID` — both paths use the same OAuth App, so client-side `GITHUB_CLIENT_ID` must match the backend's `GITHUB_OAUTH_CLIENT_ID`. Shared backend constants live in `core/data/network/BackendEndpoints.kt` (`BACKEND_ORIGIN`, `BACKEND_BASE_URL`). Backend responses carry `X-Request-ID` — `GitHubAuthApi` embeds it in every error message via `asRequestIdTag()` so bug reports can cite the ID and it maps straight to backend logs. Backend rate limits (10 starts/hr, 200 polls/hr per source IP) are hard — do not add retry loops on top of Ktor's existing `HttpRequestRetry(maxRetries = 2)`. - -## Coding Conventions - -- Packages follow `zed.rainxch.{module}.{layer}` pattern -- Private state properties use underscore prefix: `_state` -- Sealed classes/interfaces for type-safe navigation routes, actions, events -- Repository pattern: interface in `domain/`, implementation in `data/` -- Composition over inheritance via Koin DI -- Source sets: `commonMain` for shared, `androidMain` for Android, `jvmMain` for Desktop -- Feature CLAUDE.md files exist in each `feature/` directory for module-specific guidance +## Approach +- Read existing files before writing. Don't re-read unless changed. +- Thorough in reasoning, concise in output. +- Skip files over 100KB unless required. +- No sycophantic openers or closing fluff. +- No emojis or em-dashes. +- Do not guess APIs, versions, flags, commit SHAs, or package names. Verify by reading code or docs before asserting, researching if necessary. \ No newline at end of file diff --git a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt index 137c2c99a..29ea37641 100644 --- a/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt +++ b/core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt @@ -243,6 +243,7 @@ class InstalledAppsRepositoryImpl( pickedIndex: Int?, pickedSiblingCount: Int?, trackedPackageName: String, + installedAssetName: String?, ): ResolvedRelease? { if (releases.isEmpty()) return null @@ -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) @@ -374,6 +403,7 @@ class InstalledAppsRepositoryImpl( pickedIndex = app.pickedAssetIndex, pickedSiblingCount = app.pickedAssetSiblingCount, trackedPackageName = app.packageName, + installedAssetName = app.installedAssetName, ) if (resolved == null) { diff --git a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt index 2ffd42e01..48d4cffa2 100644 --- a/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt +++ b/core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/util/AssetVariant.kt @@ -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 { + 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. + */ + 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 + } + fun deriveGlob(assetName: String): String? { val lower = assetName.lowercase() // Match either: diff --git a/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json b/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json index cd4a2f279..32898077a 100644 --- a/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json +++ b/core/presentation/src/commonMain/composeResources/files/whatsnew/17.json @@ -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." ] }, { diff --git a/feature/apps/CLAUDE.md b/feature/apps/CLAUDE.md index 01e5f346b..4ff8ac1cb 100644 --- a/feature/apps/CLAUDE.md +++ b/feature/apps/CLAUDE.md @@ -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 { @@ -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. diff --git a/feature/auth/CLAUDE.md b/feature/auth/CLAUDE.md index 7434fbafa..1af619674 100644 --- a/feature/auth/CLAUDE.md +++ b/feature/auth/CLAUDE.md @@ -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 { @@ -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`. diff --git a/feature/details/CLAUDE.md b/feature/details/CLAUDE.md index c2d280610..b97388df6 100644 --- a/feature/details/CLAUDE.md +++ b/feature/details/CLAUDE.md @@ -1,105 +1,34 @@ -# CLAUDE.md - Details Feature +# Details Feature -## Purpose +Repository detail screen — owner, stats, releases with download/install, readme (with translation), per-app install/update flow. Most complex feature. -Repository detail screen. Displays full info for a GitHub repository including owner profile, stats, releases with download links, readme rendering (with translation support), and installation/update flow. This is the most complex feature module. - -## Module Structure +## Structure ``` feature/details/ -├── domain/ -│ ├── model/ -│ │ ├── ReleaseCategory.kt # Release filtering categories -│ │ ├── RepoStats.kt # Stars, forks, open issues -│ │ ├── SupportedLanguage.kt # Languages for readme translation -│ │ └── TranslationResult.kt # Translation response model -│ └── repository/ -│ ├── DetailsRepository.kt # Repo, releases, readme, stats, user profile -│ └── TranslationRepository.kt # Readme translation -├── data/ -│ ├── di/SharedModule.kt # Koin: detailsModule -│ ├── repository/ -│ │ ├── DetailsRepositoryImpl.kt # API calls + readme localization -│ │ └── TranslationRepositoryImpl.kt # Translation API integration -│ ├── model/ReadmeAttempt.kt # Readme fetch attempt tracking -│ └── utils/ -│ ├── ReadmeLocalizationHelper.kt # Find readme in user's language -│ └── preprocessMarkdown.kt # Markdown preprocessing +├── domain/ # DetailsRepository, TranslationRepository; ReleaseCategory, RepoStats, SupportedLanguage, TranslationResult +├── data/ # impls + ReadmeLocalizationHelper, preprocessMarkdown └── presentation/ - ├── DetailsViewModel.kt # State management for detail screen - ├── DetailsState.kt # Repo, releases, readme, download progress, etc. - ├── DetailsAction.kt # Load, download, install, favourite, star, etc. - ├── DetailsEvent.kt # Navigation, toast events - ├── DetailsRoot.kt # Main composable - ├── model/ - │ ├── DownloadStage.kt # Download progress tracking - │ ├── InstallLogItem.kt # Installation log entries - │ ├── LogResult.kt # Log result types - │ ├── ShowDowngradeWarning.kt # Downgrade confirmation model - │ ├── SupportedLanguages.kt # UI language list - │ ├── TranslationState.kt # Translation UI state - │ └── TranslationTarget.kt # Translation target selection + ├── DetailsViewModel.kt / State / Action / Event / Root + ├── model/ # DownloadStage, InstallLogItem, LogResult, ShowDowngradeWarning, SupportedLanguages, TranslationState ├── components/ - │ ├── AppHeader.kt # App icon, name, developer - │ ├── LanguagePicker.kt # Readme translation language selector - │ ├── ReleaseAssetsPicker.kt # Asset selection for download - │ ├── SmartInstallButton.kt # Context-aware install/update/open button - │ ├── StatItem.kt # Individual stat display - │ ├── TranslationControls.kt # Translation UI controls - │ ├── VersionPicker.kt # Release version selector - │ ├── VersionTypePicker.kt # Stable/pre-release filter - │ └── sections/ - │ ├── About.kt # Description & topics - │ ├── Header.kt # Top header section - │ ├── Logs.kt # Installation/download logs - │ ├── Owner.kt # Repository owner info - │ ├── ReportIssue.kt # Issue reporting section - │ ├── Stats.kt # Stars, forks, issues - │ └── WhatsNew.kt # Release changelog - ├── states/ErrorState.kt # Error display composable - └── utils/ - ├── LocalTopbarLiquidState.kt - ├── LogResultAsText.kt # Log result formatting - ├── MarkdownImageTransformer.kt # Transform relative image URLs - ├── MarkdownUtils.kt # Markdown preprocessing - └── SystemArchitecture.kt # Platform architecture detection -``` - -## Key Interfaces - -```kotlin -interface DetailsRepository { - suspend fun getRepositoryById(id: Long): GithubRepoSummary - suspend fun getRepositoryByOwnerAndName(owner: String, name: String): GithubRepoSummary - suspend fun getLatestPublishedRelease(owner: String, repo: String, defaultBranch: String): GithubRelease? - suspend fun getAllReleases(owner: String, repo: String, defaultBranch: String): List - suspend fun getReadme(owner: String, repo: String, defaultBranch: String): Triple? - suspend fun getRepoStats(owner: String, repo: String): RepoStats - suspend fun getUserProfile(username: String): GithubUserProfile -} - -interface TranslationRepository { - suspend fun translate(text: String, targetLanguage: SupportedLanguage): TranslationResult -} + │ ├── AppHeader, ReleaseAssetsPicker, VersionPicker, VersionTypePicker, SmartInstallButton, InspectApkButton, ApkInspectSheet, LanguagePicker, TranslationControls, StatItem + │ └── sections/ About, Header, Logs, Owner, ReportIssue, Stats, WhatsNew, ReleaseChannel + ├── states/ErrorState + └── utils/ # MarkdownImageTransformer, MarkdownUtils, SystemArchitecture, LocalTopbarLiquidState, LogResultAsText ``` ## Navigation -Route: `GithubStoreGraph.DetailsScreen(repositoryId: Long, owner: String, repo: String, isComingFromUpdate: Boolean)` - -Can be reached via repo ID or owner+name (for deep links). Falls back to owner+name lookup if `repositoryId == -1`. `isComingFromUpdate` flag indicates navigation from an update notification. +`GithubStoreGraph.DetailsScreen(repositoryId, owner, repo, isComingFromUpdate)`. By ID or owner+name (deep links use latter; `repositoryId == -1` falls back to owner+name lookup). -## Implementation Notes +## Notes -- Readme supports localization: `ReadmeLocalizationHelper` tries to find readme in user's language first -- Readme translation: `TranslationRepository` translates readme content to user's chosen language via `LanguagePicker` -- Markdown rendering uses `multiplatform-markdown-renderer` with custom `MarkdownImageTransformer` for relative URLs -- Download flow tracks stages via `DownloadStage` (idle → downloading → installing → done) -- `SmartInstallButton` changes behavior based on installed/update-available/not-installed state -- `ReleaseAssetsPicker` allows selecting specific assets; `VersionTypePicker` filters stable vs pre-release -- Version picker allows selecting specific releases for download -- Downgrade warning shown when installing an older version than currently installed -- Integrates with `FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository` from core -- Uses `Downloader` and `Installer` interfaces from core/domain for platform-specific download/install -- On Android, install may use Shizuku (silent) or standard system installer depending on user preference in profile settings +- Readme localized via `ReadmeLocalizationHelper`; markdown via `multiplatform-markdown-renderer` + `MarkdownImageTransformer`. Translation via `TranslationRepository` + `LanguagePicker`. +- Download stages: `DownloadStage` idle→downloading→installing→done. `SmartInstallButton` adapts to install state. Downgrade warning before installing older version. +- Injects (lots): `DetailsRepository`, `TranslationRepository`, `FavouritesRepository`, `StarredRepository`, `InstalledAppsRepository`, `SeenReposRepository`, `TweaksRepository`, `TelemetryRepository`, `ExternalImportRepository`, `AuthenticationState`, `ProfileRepository`, `Downloader`, `Installer`, `PackageMonitor`, `SystemInstallSerializer`, `BrowserHelper`, `ShareManager`, `Platform`, `SyncInstalledAppsUseCase`, `InstallationManager`, `AttestationVerifier`, `DownloadOrchestrator`, `ApkInspector`, `GitHubStoreLogger`. +- Android installer paths: Default / Shizuku / Dhizuku / Root. Root via raw `su` (`RootServiceManager`). Dhizuku 14+ retries without installer attribution. +- **Multi-OS picker (E15):** `ReleaseAssetsItemsPicker` toggle flips `TweaksRepository.showAllPlatforms`. ON → assets group by `assetPlatformOf` into `PlatformSectionCard`s with "Your device"/"For transfer" chips. Non-current asset → `OnDownloadForTransfer` → `BrowserHelper.openUrl`. +- **Coachmarks:** APK Inspect button pulse + ReleaseChannel chip Popup. One-shot via `TweaksRepository.get*CoachmarkShown`. +- **Self-owned ✓ badge (E20):** `AppHeader` ✓ next to owner login when `state.isCurrentUserOwner`. Reactive via `combine(profileRepo.getUser(), state.repository.owner.login)`. +- **Skip release (E542):** per-app `skippedReleaseTag` on `InstalledApp`. `SmartInstallButton` suppresses CTA; auto-clears on strictly-newer release. diff --git a/feature/dev-profile/CLAUDE.md b/feature/dev-profile/CLAUDE.md index cdb308f10..7f02c5f5c 100644 --- a/feature/dev-profile/CLAUDE.md +++ b/feature/dev-profile/CLAUDE.md @@ -1,35 +1,19 @@ -# CLAUDE.md - Developer Profile Feature +# Dev Profile Feature -## Purpose +GitHub user/dev profile view — avatar, bio, stats, their repos with filter/sort, followers/following. Reached from any repository card. -Displays a GitHub developer/user profile. Shows user info (avatar, bio, stats), their repositories with filtering and sorting, and follower/following counts. Reached by clicking on a developer's name from any repository card. - -## Module Structure +## Structure ``` feature/dev-profile/ -├── domain/ -│ ├── model/ -│ │ ├── DeveloperProfile.kt # User profile data model -│ │ ├── DeveloperRepository.kt # User's repository model -│ │ ├── RepoFilterType.kt # Filter: All, Sources, Forks, etc. -│ │ └── RepoSortType.kt # Sort: Stars, Name, Updated, etc. -│ └── repository/DeveloperProfileRepository.kt # Profile + repos -├── data/ -│ ├── di/SharedModule.kt # Koin: devProfileModule -│ ├── repository/DeveloperProfileRepositoryImpl.kt -│ ├── dto/ # Network DTOs -│ └── mappers/ # DTO → domain model mappers +├── domain/ # DeveloperProfileRepository; DeveloperProfile, DeveloperRepository, RepoFilterType (All/Sources/Forks/…), RepoSortType (Stars/Name/Updated/…) +├── data/ # impl + dto + mappers + di └── presentation/ - ├── DeveloperProfileViewModel.kt # Profile loading, repo filtering/sorting - ├── DeveloperProfileState.kt # profile, repos, filters, loading - ├── DeveloperProfileAction.kt # Load, filter, sort, click actions - ├── DeveloperProfileEvent.kt # One-off events - ├── DeveloperProfileRoot.kt # Main composable - └── components/ # Profile header, repo list, filter controls + ├── DeveloperProfileViewModel / State / Action / Event / Root + └── components/ profile header, repo list, filter controls ``` -## Key Interfaces +## Key interface ```kotlin interface DeveloperProfileRepository { @@ -40,11 +24,11 @@ interface DeveloperProfileRepository { ## Navigation -Route: `GithubStoreGraph.DeveloperProfileScreen(username: String)` +`GithubStoreGraph.DeveloperProfileScreen(username: String)`. -## Implementation Notes +## Notes -- Profile and repos are fetched in parallel on load -- Client-side filtering by `RepoFilterType` (All, Sources, Forks) and sorting by `RepoSortType` (Stars, Name, Updated) -- Both API calls return `Result` for error handling -- Reached from repository cards throughout the app (home, search, details, favourites, starred) +- Profile + repos fetched in parallel. +- Client-side filter/sort. +- Both API calls return `Result`. +- Reached from cards throughout app (home/search/details/favourites/starred). diff --git a/feature/favourites/CLAUDE.md b/feature/favourites/CLAUDE.md index 4fdad9878..ac2a601a2 100644 --- a/feature/favourites/CLAUDE.md +++ b/feature/favourites/CLAUDE.md @@ -1,35 +1,31 @@ -# CLAUDE.md - Favourites Feature +# Favourites Feature -## Purpose +Local saved favorites view. **Presentation-only** — uses `FavouritesRepository` from `core/domain` directly. -Displays the user's locally saved favorite repositories. This is a **presentation-only** feature with no domain or data layer -- it uses `FavouritesRepository` from `core/domain` directly. - -## Module Structure +## Structure ``` -feature/favourites/ -└── presentation/ - ├── FavouritesViewModel.kt # Observes favourites, handles remove - ├── FavouritesState.kt # favourites list, loading - ├── FavouritesAction.kt # RemoveFavourite, click actions - ├── FavouritesRoot.kt # Main composable (list of favourites) - ├── model/FavouriteRepository.kt # UI model for display - ├── mappers/FavouriteRepositoryMapper.kt # Domain → UI model mapper - └── components/FavouriteRepositoryItem.kt # Individual favourite card +feature/favourites/presentation/ +├── FavouritesViewModel / State / Action / Root +├── model/FavouriteRepository +├── mappers/FavouriteRepositoryMapper +└── components/FavouriteRepositoryItem ``` -## Key Dependencies +## Deps -- `FavouritesRepository` (from `core/domain`) - CRUD operations for favourites -- Favourites are stored locally in Room database (`FavoriteRepoDao` in `core/data`) +- `FavouritesRepository` (core/domain) — CRUD +- `ProfileRepository` (feature/profile/domain) — E20 self-owned badge +- Local Room (`FavoriteRepoDao` in core/data) ## Navigation -Route: `GithubStoreGraph.FavouritesScreen` (data object, no params) +`GithubStoreGraph.FavouritesScreen`. -## Implementation Notes +## Notes -- No network calls -- all data is local (Room database) -- Uses a presentation-layer `FavouriteRepository` UI model mapped from the domain `FavoriteRepo` -- Adding to favourites happens in other features (home, details, search); this feature only displays and removes -- The Koin module for this feature is registered in `composeApp/.../app/di/ViewModelsModule.kt` since there's no `data/di/` layer +- No network. All local Room. +- Adding to favourites happens elsewhere (home/details/search). This module displays + removes. +- Inline search bar (E562) when list non-empty — filters by name/owner/description/language client-side. +- `FavouriteRepository.isCurrentUserOwner` set when signed-in user owns the repo (E20). +- Koin module registered in `composeApp/.../app/di/ViewModelsModule.kt` (no `data/di/`). diff --git a/feature/home/CLAUDE.md b/feature/home/CLAUDE.md index cfb29c569..520370fdd 100644 --- a/feature/home/CLAUDE.md +++ b/feature/home/CLAUDE.md @@ -1,34 +1,21 @@ -# CLAUDE.md - Home Feature +# Home Feature -## Purpose +Main discovery — Trending, Hot Releases, Most Popular. Infinite-scroll pagination. Integrates installed-app / favourite / starred status badges. -Main discovery screen of the app. Displays repositories in three categories: **Trending**, **Hot Releases**, and **Most Popular**. Supports infinite-scroll pagination and integrates with installed apps, favourites, and starred status. - -## Module Structure +## Structure ``` feature/home/ -├── domain/ -│ ├── model/HomeCategory.kt # Enum: TRENDING, HOT_RELEASE, MOST_POPULAR -│ └── repository/HomeRepository.kt # Paginated flows per category -├── data/ -│ ├── di/SharedModule.kt # Koin: homeModule -│ ├── repository/HomeRepositoryImpl.kt # GitHub API calls with caching & pagination -│ ├── data_source/CachedRepositoriesDataSource.kt # Per-category cache (7-day expiry) -│ ├── dto/ # Network DTOs -│ └── mappers/ # DTO → domain model mappers +├── domain/ # HomeRepository + HomeCategory (TRENDING / HOT_RELEASE / MOST_POPULAR), TopicCategory +├── data/ # HomeRepositoryImpl, CachedRepositoriesDataSource (per-category 7-day TTL), dto, mappers, di └── presentation/ - ├── HomeViewModel.kt # State management, pagination logic - ├── HomeState.kt # repos, isLoading, category, hasMorePages, etc. - ├── HomeAction.kt # Refresh, Retry, LoadMore, SwitchCategory, clicks - ├── HomeEvent.kt # OnScrollToListTop - ├── HomeRoot.kt # Main composable (staggered grid + filter chips) - ├── components/HomeFilterChips.kt # Category filter chip row - ├── locals/LocalHomeTopBarLiquid.kt - └── utils/HomeCategoryMapper.kt # Map HomeCategory to display strings + ├── HomeViewModel / State / Action / Event / Root + ├── components/HomeFilterChips + ├── locals/LocalHomeTopBarLiquid + └── utils/HomeCategoryMapper ``` -## Key Interfaces +## Key interface ```kotlin interface HomeRepository { @@ -38,19 +25,19 @@ interface HomeRepository { } ``` -## ViewModel Dependencies +## Navigation -`HomeViewModel` depends on: `HomeRepository`, `InstalledAppsRepository`, `Platform`, `SyncInstalledAppsUseCase`, `FavouritesRepository`, `StarredRepository`, `GitHubStoreLogger` +`GithubStoreGraph.HomeScreen`. -## Navigation +## VM injects -Route: `GithubStoreGraph.HomeScreen` (data object, no params) +`HomeRepository`, `InstalledAppsRepository`, `Platform`, `SyncInstalledAppsUseCase`, `FavouritesRepository`, `StarredRepository`, `GitHubStoreLogger`, `ShareManager`, `TweaksRepository`, `SeenReposRepository`, `HiddenReposRepository`, `ProfileRepository`. -## Implementation Notes +## Notes -- Uses `Semaphore` in `HomeRepositoryImpl` for concurrent request control -- Cache is per-category with 7-day TTL in `CachedRepositoriesDataSource` -- Pagination uses `nextPageIndex` tracking; deduplicates by `fullName` -- Apps section visibility is platform-dependent (`Platform.ANDROID` only) -- Observes installed apps, favourites, and starred repos reactively to update status badges -- State uses `onStart` + `stateIn(WhileSubscribed)` for lazy initialization +- `Semaphore` in `HomeRepositoryImpl` for concurrent request control. 7-day per-category cache. Pagination via `nextPageIndex`, dedupe by `fullName`. +- `HomeRoot.visibleRepos` derives display list — filters by `hiddenRepoIds` (E11) and `seenRepoIds` when `isHideSeenEnabled`. +- Apps section in bottom nav: `Platform.ANDROID` only. +- Long-press on `RepositoryCard` opens `RepositoryActionsBottomSheet` (Share / Open on GitHub / Mark seen / Hide). +- `DiscoveryRepositoryUi.isCurrentUserOwner` flipped by `observeCurrentUser` (E20). +- State uses `onStart` + `stateIn(WhileSubscribed)`. diff --git a/feature/profile/CLAUDE.md b/feature/profile/CLAUDE.md index 3b7ac594e..7a4c53643 100644 --- a/feature/profile/CLAUDE.md +++ b/feature/profile/CLAUDE.md @@ -1,44 +1,20 @@ -# CLAUDE.md - Profile Feature +# Profile Feature -## Purpose +Account-level — GitHub user profile, login/logout, sponsor entry, version info. **Settings live in `feature/tweaks/`** — this module narrow on purpose. Owns account identity + exposes `ProfileRepository.getUser()` to other features (home/search/details/starred/favourites/tweaks/dev-profile consume it for E20 self-owned badge and account-aware flows). -User profile screen combining account management, appearance settings, network proxy configuration, installer method selection (including Shizuku silent install on Android), and sponsor/about info. Replaces the former `feature/settings/` module. Accessible from the bottom navigation bar. - -## Module Structure +## Structure ``` feature/profile/ -├── domain/ -│ ├── model/UserProfile.kt # User profile data model -│ └── repository/ProfileRepository.kt # Auth state, user, logout, cache -├── data/ -│ ├── di/SharedModule.kt # Koin: profileModule -│ ├── repository/ProfileRepositoryImpl.kt # Implementation -│ └── mappers/UserProfileMappers.kt # DTO → domain model mappers +├── domain/ # ProfileRepository, UserProfile +├── data/ # ProfileRepositoryImpl, UserProfileMappers └── presentation/ - ├── ProfileViewModel.kt # State management for profile screen - ├── ProfileState.kt # User, theme, proxy, installer, Shizuku status - ├── ProfileAction.kt # Theme, logout, proxy, installer, Shizuku actions - ├── ProfileEvent.kt # One-off events (navigation, etc.) - ├── ProfileRoot.kt # Main composable (LazyColumn of sections) - ├── SponsorScreen.kt # Sponsor/donation screen - ├── model/ProxyType.kt # NONE, HTTP, SOCKS - └── components/ - ├── LogoutDialog.kt # Logout confirmation dialog - ├── SectionText.kt # Section header text component - └── sections/ - ├── Account.kt # Login/logout actions - ├── AccountSection.kt # Account info display - ├── Appearance.kt # Theme color, font, dark mode, AMOLED - ├── Installation.kt # Installer type selector (Default/Shizuku) with status - ├── Network.kt # Proxy configuration (type, host, port, auth) - ├── Options.kt # Favourites, starred, clipboard detection - ├── Others.kt # Help, clear cache, version info - ├── ProfileSection.kt # User avatar, name, bio - └── SettingsSection.kt # Settings group container + ├── ProfileViewModel / State / Action / Event / Root, SponsorScreen + ├── model/ProxyType (legacy — proxy lives in tweaks now) + └── components/ LogoutDialog, SectionText, sections/{Account, AccountSection, ProfileSection} ``` -## Key Interfaces +## Key interface ```kotlin interface ProfileRepository { @@ -51,43 +27,12 @@ interface ProfileRepository { } ``` -## State - -```kotlin -data class ProfileState( - val userProfile: UserProfile?, - val selectedThemeColor: AppTheme, - val selectedFontTheme: FontTheme, - val isLogoutDialogVisible: Boolean, - val isUserLoggedIn: Boolean, - val isAmoledThemeEnabled: Boolean, - val isDarkTheme: Boolean?, - val versionName: String, - val proxyType: ProxyType, - val proxyHost: String, val proxyPort: String, - val proxyUsername: String, val proxyPassword: String, - val isProxyPasswordVisible: Boolean, - val autoDetectClipboardLinks: Boolean, - val cacheSize: String, - val installerType: InstallerType, // DEFAULT or SHIZUKU - val shizukuAvailability: ShizukuAvailability // UNAVAILABLE, NOT_RUNNING, PERMISSION_NEEDED, READY -) -``` - ## Navigation -Routes: -- `GithubStoreGraph.ProfileScreen` (data object, no params) — main profile screen -- `GithubStoreGraph.SponsorScreen` (data object, no params) — sponsor/donation page +`GithubStoreGraph.ProfileScreen`, `SponsorScreen`. -## Implementation Notes +## Notes -- **Installation section** (Android only): Radio-button group to choose between Default (standard system dialog) and Shizuku (silent install). Uses `selectableGroup` + `selectable` with `Role.RadioButton` for accessibility. -- **Shizuku status**: Observes `InstallerStatusProvider.shizukuAvailability` flow to show real-time status (not installed, not running, permission needed, ready). Grant permission button calls `InstallerStatusProvider.requestShizukuPermission()`. -- **Installer preference** stored via `ThemesRepository.setInstallerType()` / `getInstallerType()` (persisted in DataStore). -- **Proxy settings**: Supports HTTP and SOCKS proxies with optional authentication. Saved via `ProxyRepository` from core/domain. -- **Appearance**: Theme color (`AppTheme` enum), font (`FontTheme`), dark mode toggle, AMOLED black toggle. -- **Account**: Shows GitHub user profile when logged in; login/logout with confirmation dialog. -- **Cache management**: Displays cache size and allows clearing. -- **BuildKonfig**: Uses `convention.buildkonfig` plugin for build-time configuration. -- ViewModel depends on: `ProfileRepository`, `ThemesRepository`, `ProxyRepository`, `InstallerStatusProvider`, `Platform` +- `getUser()` cached via `CacheManager` key `profile:me`. Invalidates on logout. +- "Settings" tap → `TweaksScreen`. Don't add settings here. +- VM injects: `ProfileRepository`, `Platform`. diff --git a/feature/recently-viewed/CLAUDE.md b/feature/recently-viewed/CLAUDE.md new file mode 100644 index 000000000..624cde458 --- /dev/null +++ b/feature/recently-viewed/CLAUDE.md @@ -0,0 +1,22 @@ +# Recently Viewed Feature + +Local "recently viewed" history — every repo whose Details screen was opened. **Presentation-only**, backed by `SeenReposRepository` (same pipeline that drives "Hide seen" filter on Home/Search). + +## Structure + +``` +feature/recently-viewed/presentation/ +├── RecentlyViewedViewModel / State / Action / Root +├── model/RecentlyViewedRepo, mappers/RecentlyViewedRepoMapper +└── components/RecentlyViewedItem +``` + +## Navigation + +`GithubStoreGraph.RecentlyViewedScreen`. + +## Notes + +- Visited timestamps come from `seenRepoDao.insert(...)` calls in `DetailsViewModel` on screen open. +- No network. `seenReposRepository.clearAll()` wipes table; `removeFromHistory(repoId)` for single-row. +- Koin in `composeApp/.../app/di/ViewModelsModule.kt` (no `data/di/`). diff --git a/feature/search/CLAUDE.md b/feature/search/CLAUDE.md index 9d1b584d3..a96a2fe88 100644 --- a/feature/search/CLAUDE.md +++ b/feature/search/CLAUDE.md @@ -1,34 +1,19 @@ -# CLAUDE.md - Search Feature +# Search Feature -## Purpose +Repo search with platform / language / sort filters. Paginated results. -Repository search with advanced filters. Users can search GitHub repositories by query and filter by platform (Android, Windows, macOS, Linux), programming language, and sort order. Supports paginated results. - -## Module Structure +## Structure ``` feature/search/ -├── domain/ -│ ├── model/ -│ │ ├── SearchPlatform.kt # All, Android, Windows, macOS, Linux -│ │ ├── ProgrammingLanguage.kt # Language filter options -│ │ └── SortBy.kt # Sort options (stars, updated, etc.) -│ └── repository/SearchRepository.kt # Filtered, paginated search -├── data/ -│ ├── di/SharedModule.kt # Koin: searchModule -│ ├── repository/SearchRepositoryImpl.kt # GitHub search API integration -│ ├── dto/ # Network DTOs -│ └── mappers/ # DTO → domain model mappers +├── domain/ # SearchRepository; SearchPlatform (All/Android/Macos/Windows/Linux), ProgrammingLanguage, SortBy +├── data/ # SearchRepositoryImpl + dto + mappers + di └── presentation/ - ├── SearchViewModel.kt # Search state, filter management, pagination - ├── SearchState.kt # query, results, filters, loading state - ├── SearchAction.kt # Search, filter changes, load more, clicks - ├── SearchEvent.kt # One-off events - ├── SearchRoot.kt # Main composable with search bar + filter dropdowns - └── components/ # Filter UI components + ├── SearchViewModel / State / Action / Event / Root + └── components/ filter chips, ClipboardLinkBanner, etc. ``` -## Key Interfaces +## Key interface ```kotlin interface SearchRepository { @@ -37,19 +22,21 @@ interface SearchRepository { searchPlatform: SearchPlatform, language: ProgrammingLanguage, sortBy: SortBy, - page: Int + page: Int, ): Flow } ``` ## Navigation -Route: `GithubStoreGraph.SearchScreen` (data object, no params) +`GithubStoreGraph.SearchScreen`. -## Implementation Notes +## Notes -- Platform filter maps to GitHub topic searches (e.g., `android` topic for Android platform) -- Language filter maps to GitHub's `language:` qualifier -- Search results use the same `PaginatedDiscoveryRepositories` model as home feature -- Debounce/throttle applied to search queries to avoid excessive API calls -- Integrates with favourites and starred status from core repositories +- Platform filter → GitHub topic search (e.g. `android` topic). Language filter → `language:` qualifier. Debounce/throttle on queries. +- Injects: `SearchRepository`, `InstalledAppsRepository`, `SyncInstalledAppsUseCase`, `FavouritesRepository`, `StarredRepository`, `SeenReposRepository`, `HiddenReposRepository`, `TweaksRepository`, `ProfileRepository`, `TelemetryRepository`, `SearchHistoryRepository`, `ShareManager`, `ClipboardHelper`, `Platform`, `GitHubStoreLogger`. +- `computeVisibleRepos` filters `state.repositories` at render time by `hiddenRepoIds` AND (when `isHideSeenEnabled`) `seenRepoIds`. Unhide restores without re-fetch. +- Empty-grid-after-Hide-seen banner offers one-tap reset (issue #574) → `OnDisableHideSeenForResults`. +- Long-press card → shared `RepositoryActionsBottomSheet`. +- `DiscoveryRepositoryUi.isCurrentUserOwner` flipped by `observeCurrentUser` (E20). +- Clipboard auto-detect surfaces GitHub URLs from clipboard as a dismissible banner. diff --git a/feature/starred/CLAUDE.md b/feature/starred/CLAUDE.md index 31a1d9415..5ac497302 100644 --- a/feature/starred/CLAUDE.md +++ b/feature/starred/CLAUDE.md @@ -1,37 +1,33 @@ -# CLAUDE.md - Starred Feature +# Starred Feature -## Purpose +Locally cached view of user's starred repos. **Presentation-only** — uses `StarredRepository` from `core/domain` directly. -Displays the user's locally saved starred repositories. This is a **presentation-only** feature with no domain or data layer -- it uses `StarredRepository` from `core/domain` directly. - -## Module Structure +## Structure ``` -feature/starred/ -└── presentation/ - ├── StarredReposViewModel.kt # Observes starred repos, handles remove - ├── StarredReposState.kt # starred list, loading - ├── StarredReposAction.kt # RemoveStarred, click actions - ├── StarredReposRoot.kt # Main composable (list of starred repos) - ├── model/StarredRepositoryUi.kt # UI model for display - ├── mappers/StarredRepoToUiMapper.kt # Domain → UI model mapper - ├── utils/TimeFormatUtils.kt # Time formatting utilities - └── components/StarredRepositoryItem.kt # Individual starred repo card +feature/starred/presentation/ +├── StarredReposViewModel / State / Action / Root +├── model/StarredRepositoryUi +├── mappers/StarredRepoToUiMapper +├── utils/TimeFormatUtils +└── components/StarredRepositoryItem ``` -## Key Dependencies +## Deps -- `StarredRepository` (from `core/domain`) - CRUD operations for starred repos -- Starred repos are stored locally in Room database (`StarredRepoDao` in `core/data`) +- `StarredRepository`, `FavouritesRepository`, `AuthenticationState` (core/domain) +- `ProfileRepository` (feature/profile/domain) — current user login for E20 self-owned badge +- Local Room (`StarredRepoDao` in core/data) ## Navigation -Route: `GithubStoreGraph.StarredReposScreen` (data object, no params) +`GithubStoreGraph.StarredReposScreen`. -## Implementation Notes +## Notes -- No network calls -- all data is local (Room database) -- Uses a presentation-layer `StarredRepositoryUi` model mapped from the domain `StarredRepository` entity -- Starring happens in other features (home, details, search); this feature only displays and removes -- Includes its own `TimeFormatUtils` for formatting timestamps on starred items -- The Koin module for this feature is registered in `composeApp/.../app/di/ViewModelsModule.kt` since there's no `data/di/` layer +- Periodic sync against GitHub's `/user/starred` (gated on `isAuthenticated`). Local Room mirror is the read source. +- UI model `StarredRepositoryUi` mapped from domain `StarredRepository`. +- Starring happens elsewhere (home/details/search). This module displays + removes. +- Inline search bar (E562) when list non-empty — filters by name/owner/description/language client-side. `OnRefresh` clears active query so refreshed list isn't masked behind stale filter. +- `StarredRepositoryUi.isCurrentUserOwner` set when signed-in user owns the repo (E20). +- Koin module registered in `composeApp/.../app/di/ViewModelsModule.kt` (no `data/di/`). diff --git a/feature/tweaks/CLAUDE.md b/feature/tweaks/CLAUDE.md new file mode 100644 index 000000000..1fb757a23 --- /dev/null +++ b/feature/tweaks/CLAUDE.md @@ -0,0 +1,35 @@ +# Tweaks Feature + +Single home for every app-level setting. Update prefs, installer choice (Default / Shizuku / Dhizuku / Root), telemetry, translation, mirror, feedback, hidden + skipped list managers. Absorbs settings half of former `feature/profile/`. + +## Structure + +``` +feature/tweaks/presentation/ +├── TweaksViewModel / State / Action / Event / Root, RestartApp +├── components/ +│ ├── sections/ Account, Appearance, Installation, Language, Network, Others, Translation, SettingsSection +│ └── ToggleSettingCard, ClearDownloadsDialog, SectionText +├── feedback/ # FeedbackViewModel + sheet +├── hidden/ # HiddenRepositoriesRoot — Tweaks → Updates → Hidden repos +├── skipped/ # SkippedUpdatesRoot — Tweaks → Updates → Skipped updates +├── mirror/ # MirrorPickerRoot — Tweaks → Network → Mirror picker +└── model/ # ProxyScopeFormState, ProxyType +``` + +## Sub-screen VMs (registered in `composeApp/.../app/di/ViewModelsModule.kt`) + +`SkippedUpdatesViewModel` (unskip via `InstalledAppsRepository.setSkippedReleaseTag`), `HiddenRepositoriesViewModel` (unhide / unhide-all via `HiddenReposRepository`), `FeedbackViewModel`, `AutoSuggestMirrorViewModel`, `MirrorPickerViewModel`. + +## Navigation + +`TweaksScreen`, `SkippedUpdatesScreen`, `HiddenRepositoriesScreen`, `MirrorPickerScreen`. + +## Notes + +- TweaksViewModel injects: `TweaksRepository`, `ThemesRepository`, `ProxyRepository`, `InstalledAppsRepository`, `ProfileRepository`, `InstallerStatusProvider`, `TelemetryRepository`, `Platform`, `BatteryOptimizationManager` (Android), `GitHubStoreLogger`. +- One-shot coachmark flags in `TweaksRepository` (`apk_inspect_coachmark_shown`, `channel_chip_coachmark_shown`). Once persisted true, never re-shown. +- `include_pre_releases` pref read by `InstallationManagerImpl` to seed new install's `InstalledApp.includePreReleases`. Existing rows keep per-app value. +- `show_all_platforms` pref drives cross-platform asset section in Details. +- Mirror picker gated on user locale (suggested in throttled regions). +- `RestartApp.kt` applies locale change (persist tag, restart MainActivity / DesktopApp).