Skip to content

feat(apps): track direct download URLs via HEAD polling#630

Closed
rainxchzed wants to merge 3 commits into
mainfrom
feat/direct-url-tracking
Closed

feat(apps): track direct download URLs via HEAD polling#630
rainxchzed wants to merge 3 commits into
mainfrom
feat/direct-url-tracking

Conversation

@rainxchzed
Copy link
Copy Markdown
Member

@rainxchzed rainxchzed commented May 17, 2026

Summary

Sprint 3 Task #3. Paste any direct download URL (APK/installer) in the Apps overflow menu; GHS stores it as a new InstallSource.DIRECT_URL row and polls the URL each update cycle via HEAD requests. Update is flagged when ETag / Last-Modified changes vs. the previously observed values.

Schema: 3 new nullable columns on installed_apps (directUrlPollUrl, directUrlLastEtag, directUrlLastModified). DB v17 → v18 with MIGRATION_17_18. Synthetic packageName direct-url:<ts> keeps the existing PK contract; dedupe by URL before insert.

UI: new overflow menu item "Track a direct URL" + DirectUrlBottomSheet (URL / name / version / optional icon). Apps with installSource == DIRECT_URL render a tertiary "URL" chip in both compact + full rows.

checkForUpdates() dispatches to checkDirectUrlForUpdates(app) when the source is direct-url; checkAllForUpdates() picks it up unchanged → UpdateCheckWorker polls direct-URL apps alongside GitHub repos. HEAD failures soft-fail (no badge flap, just bumps lastCheckedAt).

Test plan

  • Apps screen → overflow → "Track a direct URL" → sheet opens.
  • Paste valid URL + name → snackbar confirms, app row appears with "URL" badge.
  • Force-check from menu → row's lastCheckedAt updates, ETag/Last-Modified persisted.
  • Replace upstream file (or override headers) → next check flips isUpdateAvailable = true.
  • DB migrate from v17 (existing user) → app starts; new columns NULL on existing rows.
  • Invalid URL → inline error in sheet, no save.

Summary by CodeRabbit

  • New Features

    • Track direct download URLs for APK/installer files: paste a URL, name it, and the app monitors for updates using HTTP validators (ETag/Last-Modified).
    • New "Track a direct URL" menu action with a guided sheet (URL, name, version, optional icon), validation, saving state and success/failure feedback.
    • Direct URL badge marks tracked items; direct-URL installs avoid repo navigation.
  • Improved

    • Section-aware sorting now persists each section's independent sort choice.
  • Documentation

    • What's New updated in multiple languages.

Review Change Stack

@coderabbitai
Copy link
Copy Markdown
Contributor

coderabbitai Bot commented May 17, 2026

No actionable comments were generated in the recent review. 🎉

ℹ️ Recent review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro

Run ID: 4854af63-a888-4d58-87a2-47f27009dbe2

📥 Commits

Reviewing files that changed from the base of the PR and between dd699ec and 2e4d79a.

📒 Files selected for processing (13)
  • core/presentation/src/commonMain/composeResources/files/whatsnew/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/18.json
✅ Files skipped from review due to trivial changes (10)
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/18.json
🚧 Files skipped from review as they are similar to previous changes (3)
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/18.json

Walkthrough

This PR implements direct URL tracking: schema v18 adds polling metadata, repository polls HEAD (ETag/Last-Modified) to detect changes and persists state, presentation adds sheet/state/actions/viewmodel logic, UI components (menu, bottom sheet, badge) and localized strings/release notes.

Changes

Direct URL Tracking Feature

Layer / File(s) Summary
Database schema and migration (version 17→18)
core/data/schemas/.../18.json, core/data/src/androidMain/.../migrations/MIGRATION_17_18.kt, core/data/src/commonMain/.../AppDatabase.kt, core/data/src/androidMain/.../initDatabase.kt
Room schema bumped to v18; full schema JSON added. Migration adds nullable TEXT columns directUrlPollUrl, directUrlLastEtag, directUrlLastModified to installed_apps and is registered in DB init.
Domain model contracts
core/domain/src/commonMain/.../InstallSource.kt, core/domain/src/commonMain/.../InstalledApp.kt, core/domain/src/commonMain/.../InstalledAppsRepository.kt
Adds InstallSource.DIRECT_URL; InstalledApp gains directUrlPollUrl, directUrlLastEtag, directUrlLastModified; repository interface declares saveDirectUrlApp.
Data persistence layer
core/data/src/commonMain/.../InstalledAppEntity.kt, core/data/src/commonMain/.../dao/InstalledAppDao.kt, core/data/src/commonMain/.../mappers/InstalledAppsMappers.kt
Entity adds three nullable columns; DAO adds getAppByDirectUrl and updateDirectUrlPollState; mappers map the fields.
Repository HTTP polling and save logic
core/data/src/commonMain/.../repository/InstalledAppsRepositoryImpl.kt
Imports HTTP HEAD support; checkForUpdates short-circuits for DIRECT_URL; adds saveDirectUrlApp (dedupe + initial HEAD) and checkDirectUrlForUpdates (HEAD, compare headers, update DB state, handle errors/cancellations).
Presentation state and viewmodel logic
feature/apps/presentation/.../AppsAction.kt, .../AppsState.kt, .../AppsViewModel.kt
Adds actions for direct-URL sheet and field edits; state tracks drafts/saving/error; ViewModel validates inputs (http/https, name required, default version), calls repository, and manages UI success/error state.
UI screens and components
feature/apps/presentation/.../AppsRoot.kt, .../components/DirectUrlBadge.kt, .../components/DirectUrlBottomSheet.kt, .../components/CompactAppRow.kt
Overflow menu gains "direct URL" item; DirectUrlBottomSheet binds four inputs and save/cancel actions; DirectUrlBadge composable added; app-row rendering shows badge + repoUrl for DIRECT_URL installs and disables repo navigation for them.
Localization
core/presentation/.../whatsnew/*/18.json, core/presentation/.../values*/strings*.xml
Whatsnew updated in multiple locales; new direct_url_* string keys added in English and corresponding localized resource files for menu/sheet titles, help, form fields, validation, snackbar, and badge.

Sequence Diagram(s)

sequenceDiagram
  participant User
  participant UI as DirectUrlBottomSheet
  participant VM as AppsViewModel
  participant Repo as InstalledAppsRepositoryImpl
  participant HTTP as httpClient
  participant DAO as InstalledAppDao

  User->>UI: Fill URL/name/version/icon
  UI->>VM: OnConfirmAddDirectUrl
  VM->>Repo: saveDirectUrlApp(pollUrl,...)
  Repo->>HTTP: HEAD pollUrl
  HTTP-->>Repo: ETag/Last-Modified
  Repo->>DAO: getAppByDirectUrl(pollUrl)
  DAO-->>Repo: InstalledAppEntity?
  Repo->>DAO: updateDirectUrlPollState(...)
  Repo-->>VM: InstalledApp
  VM-->>UI: close sheet + show snackbar
Loading

Estimated code review effort

🎯 4 (Complex) | ⏱️ ~60 minutes

Possibly related PRs

"I hopped and I watched each ETag line,
Pasted a URL, named it fine,
Head requests whispered what’s new,
Last-Modified and tags told true,
A rabbit’s cheer — your updates shine!" 🐰✨

🚥 Pre-merge checks | ✅ 4 | ❌ 1

❌ Failed checks (1 warning)

Check name Status Explanation Resolution
Docstring Coverage ⚠️ Warning Docstring coverage is 15.00% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (4 passed)
Check name Status Explanation
Description Check ✅ Passed Check skipped - CodeRabbit’s high-level summary is enabled.
Title check ✅ Passed The PR title 'feat(apps): track direct download URLs via HEAD polling' clearly and concisely summarizes the main feature addition—enabling tracking of direct download URLs using HTTP HEAD polling. It accurately reflects the primary change across the changeset.
Linked Issues check ✅ Passed Check skipped because no linked issues were found for this pull request.
Out of Scope Changes check ✅ Passed Check skipped because no linked issues were found for this pull request.

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

✨ Finishing Touches
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/direct-url-tracking

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

❤️ Share

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

Copy link
Copy Markdown
Contributor

@coderabbitai coderabbitai Bot left a comment

Choose a reason for hiding this comment

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

Actionable comments posted: 3

🧹 Nitpick comments (2)
core/presentation/src/commonMain/composeResources/values/strings.xml (1)

738-738: 💤 Low value

Optional: Consider adding a comment header for documentation.

The new direct-URL strings are well-organized but lack a preceding XML comment (like <!-- Direct URL tracking feature -->). While the direct_url_ prefix makes the strings self-documenting, a comment would improve maintainability for future contributors.

📝 Suggested comment header
 <string name="add_from_starred_title">Add from starred</string>
+    <!-- Direct URL tracking feature -->
 <string name="direct_url_menu_title">Track a direct URL</string>
🤖 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/presentation/src/commonMain/composeResources/values/strings.xml` at line
738, Add an XML comment header above the block of direct-URL related strings to
document the feature for future readers; locate the block using the direct_url_
prefix (and nearby string name add_from_starred_title) and insert a brief
comment such as "Direct URL tracking feature" immediately before that group of
<string> entries to improve maintainability.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt (1)

893-899: 💤 Low value

Consider adding a brief comment explaining the change-detection priority.

The when block correctly prioritizes ETag over Last-Modified and treats newly-appearing headers as baseline establishment rather than changes. This is sound HTTP caching semantics, but the multi-branch logic isn't immediately obvious to future readers.

A one-line comment like // ETag takes precedence; new headers establish baseline without flagging change would make the intent clearer without requiring full KDoc.

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

In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt`
around lines 893 - 899, Add a one-line comment above the val changed = when {
... } block in InstalledAppsRepositoryImpl explaining the change-detection
priority, e.g. note that ETag takes precedence over Last-Modified and that
newly-appearing headers (etag or lastModified when the previous value is null)
are treated as baseline establishment rather than a detected change; keep it
concise (for example: "// ETag takes precedence; new headers establish baseline
without flagging change").
🤖 Prompt for all review comments with AI agents
Verify each finding against current code. Fix only still-valid issues, skip the
rest with a brief reason, keep changes minimal, and validate.

Inline comments:
In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt`:
- Around line 987-1023: Rows for DIRECT_URL apps (InstallSource.DIRECT_URL)
currently still trigger repo navigation which calls
OnNavigateToRepo(appItem.installedApp.repoId) with repoId=0L; update the click
handling in AppItemCard and CompactAppRow so that before invoking
OnNavigateToRepo you check installedApp.installSource !=
InstallSource.DIRECT_URL and either skip the click or call an alternative
handler (e.g., showSnackbar or noop) for DIRECT_URL items; alternatively, when
building lists filter out items with InstallSource.DIRECT_URL so they never
receive the clickable modifier. Ensure you reference AppItemCard, CompactAppRow,
OnNavigateToRepo and installedApp.repoId when making the change.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt`:
- Around line 657-667: Handlers for AppsAction.OnDirectUrlNameChanged,
OnDirectUrlVersionChanged and OnDirectUrlIconChanged don't clear stale
directUrlError, so after a validation failure the error remains until submit;
update each _state.update call in these handlers (the ones setting
directUrlNameDraft, directUrlVersionDraft and directUrlIconDraft) to also set
directUrlError = null (mirroring the existing URL-change handler) so any
previous direct-URL validation error is cleared when the user edits
name/version/icon.

In
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/DirectUrlBottomSheet.kt`:
- Line 59: The three hardcoded placeholder literals in the DirectUrlBottomSheet
composable (the Text placeholders currently set to "https://example.com/app.apk"
at the three placeholder= { Text(...) } sites) must be replaced with localized
string resources; create a resource key (e.g., placeholder_direct_url) in your
i18n files and load it inside DirectUrlBottomSheet using the
platform-appropriate lookup (e.g.,
stringResource(R.string.placeholder_direct_url) or your project's i18n helper)
and use that value for each placeholder Text call so the placeholder is
localized for all locales.

---

Nitpick comments:
In
`@core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt`:
- Around line 893-899: Add a one-line comment above the val changed = when { ...
} block in InstalledAppsRepositoryImpl explaining the change-detection priority,
e.g. note that ETag takes precedence over Last-Modified and that newly-appearing
headers (etag or lastModified when the previous value is null) are treated as
baseline establishment rather than a detected change; keep it concise (for
example: "// ETag takes precedence; new headers establish baseline without
flagging change").

In `@core/presentation/src/commonMain/composeResources/values/strings.xml`:
- Line 738: Add an XML comment header above the block of direct-URL related
strings to document the feature for future readers; locate the block using the
direct_url_ prefix (and nearby string name add_from_starred_title) and insert a
brief comment such as "Direct URL tracking feature" immediately before that
group of <string> entries to improve maintainability.
🪄 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: c620cea0-7200-49f9-8c8d-31fff223e373

📥 Commits

Reviewing files that changed from the base of the PR and between 6632fd3 and e6b6909.

📒 Files selected for processing (44)
  • core/data/schemas/zed.rainxch.core.data.local.db.AppDatabase/18.json
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/initDatabase.kt
  • core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_17_18.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/AppDatabase.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/mappers/InstalledAppsMappers.kt
  • core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstallSource.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/model/InstalledApp.kt
  • core/domain/src/commonMain/kotlin/zed/rainxch/core/domain/repository/InstalledAppsRepository.kt
  • core/presentation/src/commonMain/composeResources/files/whatsnew/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ar/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/bn/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/es/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/fr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/hi/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/it/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ja/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ko/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/pl/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/ru/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/tr/18.json
  • core/presentation/src/commonMain/composeResources/files/whatsnew/zh-CN/18.json
  • core/presentation/src/commonMain/composeResources/values-ar/strings-ar.xml
  • core/presentation/src/commonMain/composeResources/values-bn/strings-bn.xml
  • core/presentation/src/commonMain/composeResources/values-es/strings-es.xml
  • core/presentation/src/commonMain/composeResources/values-fr/strings-fr.xml
  • core/presentation/src/commonMain/composeResources/values-hi/strings-hi.xml
  • core/presentation/src/commonMain/composeResources/values-it/strings-it.xml
  • core/presentation/src/commonMain/composeResources/values-ja/strings-ja.xml
  • core/presentation/src/commonMain/composeResources/values-ko/strings-ko.xml
  • core/presentation/src/commonMain/composeResources/values-pl/strings-pl.xml
  • core/presentation/src/commonMain/composeResources/values-ru/strings-ru.xml
  • core/presentation/src/commonMain/composeResources/values-tr/strings-tr.xml
  • core/presentation/src/commonMain/composeResources/values-zh-rCN/strings-zh-rCN.xml
  • core/presentation/src/commonMain/composeResources/values/strings.xml
  • 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/AppsState.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/CompactAppRow.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/DirectUrlBadge.kt
  • feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/DirectUrlBottomSheet.kt

value = state.directUrlDraft,
onValueChange = { onAction(AppsAction.OnDirectUrlChanged(it)) },
label = { Text(stringResource(Res.string.direct_url_field_url)) },
placeholder = { Text("https://example.com/app.apk") },
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 | 🟡 Minor | ⚡ Quick win

Localize placeholder text instead of hardcoding English literals.

The placeholders on Line 59, Line 77, and Line 86 are hardcoded, so non-English locales will still show English text.

Suggested fix
             OutlinedTextField(
                 value = state.directUrlDraft,
                 onValueChange = { onAction(AppsAction.OnDirectUrlChanged(it)) },
                 label = { Text(stringResource(Res.string.direct_url_field_url)) },
-                placeholder = { Text("https://example.com/app.apk") },
+                placeholder = { Text(stringResource(Res.string.direct_url_placeholder_url)) },
                 singleLine = true,
                 isError = state.directUrlError != null,
                 modifier = Modifier.fillMaxWidth(),
             )
@@
             OutlinedTextField(
                 value = state.directUrlVersionDraft,
                 onValueChange = { onAction(AppsAction.OnDirectUrlVersionChanged(it)) },
                 label = { Text(stringResource(Res.string.direct_url_field_version)) },
-                placeholder = { Text("1.0.0") },
+                placeholder = { Text(stringResource(Res.string.direct_url_placeholder_version)) },
                 singleLine = true,
                 modifier = Modifier.fillMaxWidth(),
             )
@@
             OutlinedTextField(
                 value = state.directUrlIconDraft,
                 onValueChange = { onAction(AppsAction.OnDirectUrlIconChanged(it)) },
                 label = { Text(stringResource(Res.string.direct_url_field_icon)) },
-                placeholder = { Text("https://example.com/icon.png") },
+                placeholder = { Text(stringResource(Res.string.direct_url_placeholder_icon)) },
                 singleLine = true,
                 modifier = Modifier.fillMaxWidth(),
             )

Also applies to: 77-77, 86-86

🤖 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
`@feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/DirectUrlBottomSheet.kt`
at line 59, The three hardcoded placeholder literals in the DirectUrlBottomSheet
composable (the Text placeholders currently set to "https://example.com/app.apk"
at the three placeholder= { Text(...) } sites) must be replaced with localized
string resources; create a resource key (e.g., placeholder_direct_url) in your
i18n files and load it inside DirectUrlBottomSheet using the
platform-appropriate lookup (e.g.,
stringResource(R.string.placeholder_direct_url) or your project's i18n helper)
and use that value for each placeholder Text call so the placeholder is
localized for all locales.

@greptile-apps
Copy link
Copy Markdown

greptile-apps Bot commented May 17, 2026

Greptile Summary

This PR implements direct-download-URL tracking as a new InstallSource.DIRECT_URL type: users paste any APK/installer URL, the app stores it with a synthetic direct-url:<ts> package name, and a HEAD-polling loop detects updates via ETag/Last-Modified changes. The schema migrates to v18 with three nullable columns and a new MIGRATION_17_18.

  • Data layer: saveDirectUrlApp() dedupes by URL, fetches initial ETag/Last-Modified, and persists the entry; checkDirectUrlForUpdates() re-polls on each update cycle, writing changed validators back to the DB via updateDirectUrlPollState().
  • UI layer: New "Track a direct URL" overflow menu item opens DirectUrlBottomSheet; DIRECT_URL rows render a teal "URL" chip and suppress the GitHub repo navigation tap target.
  • Known open items (flagged in prior threads): runCatching swallows CancellationException on the initial HEAD; packageName uses millisecond timestamp (collision risk); repoId = 0L can collide with getAppByRepoId; isUpdateAvailable is never cleared once set.

Confidence Score: 4/5

Safe to merge after resolving the open items from prior review threads; no new blocking issues were found in this pass.

Several non-trivial issues flagged in earlier rounds (packageName timestamp collision, repoId=0 sentinel conflict, CancellationException swallowed by runCatching, isUpdateAvailable never cleared) remain unaddressed. The new code in this cycle introduces only a minor form-validation UX inaccuracy.

InstalledAppsRepositoryImpl.kt — the saveDirectUrlApp / checkDirectUrlForUpdates implementations still carry the open items from prior threads.

Important Files Changed

Filename Overview
core/data/src/commonMain/kotlin/zed/rainxch/core/data/repository/InstalledAppsRepositoryImpl.kt Adds saveDirectUrlApp() and checkDirectUrlForUpdates(); key issues (runCatching swallowing CancellationException, packageName timestamp collision, repoId=0 sentinel conflict, isUpdateAvailable never cleared) were flagged in prior review threads.
feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsViewModel.kt Adds full direct-URL bottom-sheet lifecycle and addDirectUrlApp() with URL+name validation; CancellationException is re-thrown correctly in the save path.
feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/components/DirectUrlBottomSheet.kt New bottom sheet for URL tracking; isError prop is bound only to the URL field but fires for name-validation errors too, giving misleading red outline on the wrong input.
core/data/src/androidMain/kotlin/zed/rainxch/core/data/local/db/migrations/MIGRATION_17_18.kt Adds three nullable TEXT columns via ALTER TABLE; migration is correct and backward-compatible.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/dao/InstalledAppDao.kt Adds getAppByDirectUrl() for dedup and updateDirectUrlPollState() for persisting HEAD poll results; both queries are correct.
feature/apps/presentation/src/commonMain/kotlin/zed/rainxch/apps/presentation/AppsRoot.kt Adds overflow menu item, DirectUrlBottomSheet conditional, and guards onRepoClick/onRowClick to no-op for DIRECT_URL apps.
core/data/src/commonMain/kotlin/zed/rainxch/core/data/local/db/entities/InstalledAppEntity.kt Three nullable columns added with DEFAULT NULL; Room annotations are correct.

Sequence Diagram

sequenceDiagram
    participant User
    participant DirectUrlBottomSheet
    participant AppsViewModel
    participant InstalledAppsRepository
    participant HTTP as HEAD (remote URL)
    participant DAO as InstalledAppDao

    User->>DirectUrlBottomSheet: paste URL + name, tap Track
    DirectUrlBottomSheet->>AppsViewModel: OnConfirmAddDirectUrl
    AppsViewModel->>AppsViewModel: validate URL scheme + name
    AppsViewModel->>InstalledAppsRepository: saveDirectUrlApp(url, name, version, icon)
    InstalledAppsRepository->>DAO: getAppByDirectUrl(url)
    InstalledAppsRepository->>HTTP: HEAD pollUrl
    HTTP-->>InstalledAppsRepository: ETag / Last-Modified (or null)
    InstalledAppsRepository->>DAO: insertApp(entity)
    InstalledAppsRepository-->>AppsViewModel: InstalledApp
    AppsViewModel-->>DirectUrlBottomSheet: dismiss + ShowSuccess snackbar

    Note over InstalledAppsRepository,DAO: Update cycle (UpdateCheckWorker)
    InstalledAppsRepository->>InstalledAppsRepository: checkForUpdates(entity)
    InstalledAppsRepository->>HTTP: HEAD pollUrl
    HTTP-->>InstalledAppsRepository: ETag / Last-Modified
    InstalledAppsRepository->>DAO: updateDirectUrlPollState(...)
Loading

Fix All in Claude Code

Reviews (3): Last reviewed commit: "Merge branch 'main' into feat/direct-url..." | Re-trigger Greptile

}

val now = System.currentTimeMillis()
val packageName = "direct-url:$now"
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 packageName collision on same-millisecond saves

packageName = "direct-url:$now" where now = System.currentTimeMillis() gives two rapid saves within the same millisecond an identical primary key. InstalledAppDao.insertApp uses OnConflictStrategy.REPLACE, so the second insert will silently DELETE the first row and replace it, permanently losing the earlier-added app. On some lower-resolution clock devices (or under emulator conditions), same-millisecond collisions are reproducible. Adding a UUID suffix or using the URL hash removes the race entirely.

Fix in Claude Code

Comment on lines +893 to +909
val changed = when {
etag != null && previousEtag != null -> etag != previousEtag
lastModified != null && previousLastModified != null -> lastModified != previousLastModified
etag != null && previousEtag == null -> false
lastModified != null && previousLastModified == null -> false
else -> false
}

installedAppsDao.updateDirectUrlPollState(
packageName = app.packageName,
etag = etag ?: previousEtag,
lastModified = lastModified ?: previousLastModified,
isUpdateAvailable = changed || app.isUpdateAvailable,
timestamp = now,
)

return changed
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 isUpdateAvailable flag can never be cleared by polling

updateDirectUrlPollState is always called with isUpdateAvailable = changed || app.isUpdateAvailable. Once the flag is set to true (ETag changed), subsequent polls where changed == false still pass false || true = true, so the update badge is permanent. There is no user-facing action in onAction or DAO path to dismiss/acknowledge the update for a DIRECT_URL app (no clearUpdateMetadata call, no "mark installed" path), so the badge will stick indefinitely after the first detected change. If the user then taps the "Update" row action, updateSingleApp calls appsRepository.getLatestRelease(owner = "", repo = appName), which makes a malformed GitHub API request and produces a confusing error snackbar.

Fix in Claude Code

Comment on lines +840 to +841
packageName = packageName,
repoId = 0L,
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 All direct-URL apps share repoId = 0L, breaking repo-based lookups

repoId = 0L is a sentinel that doesn't conflict with any real GitHub repo ID, but InstalledAppDao.getAppByRepoId(0) (used by isAppInstalled, getAppByRepoIdAsFlow, getAppsByRepoId) will return an arbitrary DIRECT_URL app — whichever SQLite happens to surface first — rather than something app-specific. After a second DIRECT_URL entry is added, isAppInstalled(0) returns true for any caller that checks repo 0, and getAppByRepoIdAsFlow(0) used by the Details screen would show whichever row comes back first. Consider using a different sentinel (e.g. -1) or a dedicated column/query that callers can distinguish from a real repo lookup.

Fix in Claude Code

Comment on lines +109 to +120
Button(
onClick = { onAction(AppsAction.OnConfirmAddDirectUrl) },
enabled = !state.directUrlSaving,
) {
if (state.directUrlSaving) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 8.dp),
strokeWidth = 2.dp,
)
}
Text(stringResource(Res.string.direct_url_add_button))
}
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 CircularProgressIndicator has no explicit size, so it uses the 40 dp default and will overflow the button's internal layout when saving is in progress. Adding Modifier.size(18.dp) keeps it flush with the text height.

Suggested change
Button(
onClick = { onAction(AppsAction.OnConfirmAddDirectUrl) },
enabled = !state.directUrlSaving,
) {
if (state.directUrlSaving) {
CircularProgressIndicator(
modifier = Modifier.padding(end = 8.dp),
strokeWidth = 2.dp,
)
}
Text(stringResource(Res.string.direct_url_add_button))
}
Button(
onClick = { onAction(AppsAction.OnConfirmAddDirectUrl) },
enabled = !state.directUrlSaving,
) {
if (state.directUrlSaving) {
CircularProgressIndicator(
modifier = Modifier.size(18.dp).padding(end = 8.dp),
strokeWidth = 2.dp,
)
}
Text(stringResource(Res.string.direct_url_add_button))
}

Fix in Claude Code

Comment on lines +834 to +837
val (initialEtag, initialLastModified) = runCatching {
val response = httpClient.head(trimmedUrl)
response.headers[HttpHeaders.ETag] to response.headers[HttpHeaders.LastModified]
}.getOrElse { null to null }
Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 runCatching catches all Throwable, including CancellationException, which breaks structured concurrency. If the coroutine is cancelled while httpClient.head() is in flight, the exception is swallowed and (null, null) is returned, causing the app to be inserted with no initial ETag/Last-Modified — the first poll will then always see headers as "new" and never detect a change because the comparison falls to the else -> false branch. The private checkDirectUrlForUpdates in the same class already shows the correct pattern.

Suggested change
val (initialEtag, initialLastModified) = runCatching {
val response = httpClient.head(trimmedUrl)
response.headers[HttpHeaders.ETag] to response.headers[HttpHeaders.LastModified]
}.getOrElse { null to null }
val (initialEtag, initialLastModified) = try {
val response = httpClient.head(trimmedUrl)
response.headers[HttpHeaders.ETag] to response.headers[HttpHeaders.LastModified]
} catch (e: CancellationException) {
throw e
} catch (e: Throwable) {
null to null
}

Fix in Claude Code

@rainxchzed rainxchzed closed this May 18, 2026
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