Skip to content

feat(freezer): named freeze profiles (#55a) - #295

Merged
trinadhthatakula merged 18 commits into
devfrom
feat/freeze-profiles
Jul 30, 2026
Merged

feat(freezer): named freeze profiles (#55a)#295
trinadhthatakula merged 18 commits into
devfrom
feat/freeze-profiles

Conversation

@trinadhthatakula

@trinadhthatakula trinadhthatakula commented Jul 30, 2026

Copy link
Copy Markdown
Owner

Closes #55 (the #55a half — the process manager, #55b, stays declined).

A freeze profile is a named set of apps you can freeze or unfreeze in one tap.

  • Two new Room tables, freeze_profiles + freeze_profile_apps, via auto-migration 5 → 6. New tables only, nothing existing touched, so a shipped database gets them and keeps its watchlist. Schema export committed.
  • A profiles sheet (per-row freeze / unfreeze / edit / delete, with a delete confirmation), an editor sheet that reuses the watchlist's app picker, and a "save this selection as a profile" entry in the multi-select toolbox.
  • New toolbar button on the Freezer screen. Not privilege-gated — naming a set of apps is not freezing them.

Profiles are not a subset of the watchlist

Watchlist membership is a standing instruction that the QS tile and both launcher Freeze-all shortcuts act on with no UI at all. Folding profiles into it would mean creating a "Games" profile silently enlists every game in the tile's Freeze-all. The two lists stay independent; what they share is BulkFreezeRunner.

The three risks the roadmap named are closed

  1. Tier gate. Profile runs route through BulkFreezeRunner, so targetsForfreezableCandidates(...) applies the list-level FreezeTier.BLOCKED filter for free. Routing through the runner is what earns the gate — a fourth surface calling setAppDisabled itself would have been the one bulk path able to freeze what every dialog in the app refuses to render a confirm button for. The editor additionally raises AppRiskDialog at add time, because a profile is a standing instruction that later runs act on with no UI.

  2. Coalescing key. The runner's job slot keyed on BulkOp alone would have made "freeze profile A" then "freeze profile B" return the first Deferred and silently never freeze B. The key is now BulkRequest(op, scope):

    • same request → coalesce (unchanged);
    • same op, different scope → serialize (join()), because two freezes do not contradict each other and cancelling A to start B would leave A half-frozen with nothing said about it;
    • conflicting op → replace (cancelAndJoin()), unchanged in spirit but now cancelling the whole unsettled chain rather than just its tail. Found in self-review: the tail of a serialized chain is the run that has not started yet, the head is the one holding packages open, so cancelling the tail alone left a freeze running alongside the replacing unfreeze.
  3. Launcher restore. FreezerBridgeProvider now gates on the watchlist every profile's membership. An app frozen only by a profile it is the sole member of used to be un-unfreezable from the launcher — a dead icon tap with nothing said about why.

Tests

FreezeProfileTest — 17 cases over the two pure pieces: the name rule (trimmed, capped at 40, duplicate detection case-insensitive to match the COLLATE NOCASE unique index, so the inline error and the database cannot disagree) and BulkRequest identity, including a regression test that two profiles under the same op are not the same request.

Suite is 226 tests, 0 failures (was 209 on dev). assembleFossDebug, testFossDebugUnitTest --rerun-tasks and lintFossDebug all green — lint runs with warningsAsErrors, so all five locales are translated.

Notes for review

  • Not fixed here, pre-existing: BulkFreezeRunner's outer catch returns null for a genuinely failed run, which is the same value it uses for "no-op". The profiles sheet therefore reports a rare hard failure as "Nothing to do". Changing it would alter what the tile and both shortcuts report, so it did not belong in this branch.
  • While a profile run is queued behind another run, runningRequest already names the queued one, so the spinner moves to the pending row a moment early. Self-healing (the tile re-sweeps on onStartListening) and left alone deliberately.
  • Needs a device: both new sheets are unverified visually, and a profile run has not been exercised against a real privilege backend.

Summary by CodeRabbit

  • New Features

    • Added freeze profiles for saving, editing, deleting, and reusing groups of apps.
    • Added profile creation from selected apps, app search and filtering, validation, and risk confirmations.
    • Profile-based freeze and restore operations now include all associated apps.
    • Added clearer progress, completion, no-op, and failure feedback for bulk operations.
    • Added database migration support and localized profile-related messaging.
  • Bug Fixes

    • Improved operation tracking when multiple freeze requests run or queue concurrently.
    • Updated restore authorization to recognize apps included in freeze profiles.

A profile is a named set of apps the user can freeze or unfreeze in one
tap. Two new Room tables (auto-migration 5 -> 6, new tables only, so a
shipped database keeps its watchlist), a profiles sheet, an editor sheet
reusing the watchlist's app picker, and a "save this selection as a
profile" entry in the multi-select toolbox.

Profiles are deliberately not a subset of the watchlist. Watchlist
membership is a standing instruction the QS tile and both launcher
Freeze-all shortcuts act on with no UI, so folding profiles into it would
mean creating a "Games" profile silently enlists every game in the tile's
Freeze-all. What the two lists share is the runner.

That sharing is the point, and it closes all three risks the recon pass
named:

- Tier gate. Profile runs go through BulkFreezeRunner, so targetsFor ->
  freezableCandidates applies the list-level FreezeTier.BLOCKED filter.
  A fourth surface calling setAppDisabled itself would have been the one
  bulk path able to freeze what every dialog refuses to offer a confirm
  button for. The editor warns at add time too, because a profile is a
  standing instruction that later runs act on with no UI.

- Coalescing key. The job slot keyed on BulkOp alone would have made
  "freeze profile A" then "freeze profile B" return the first Deferred
  and silently never freeze B. The key is now BulkRequest(op, scope):
  same op / different scope serializes, a conflicting op still replaces.
  Replacement cancels the whole unsettled chain rather than its tail --
  the tail is the run that has not started yet, the head is the one
  holding packages open.

- Launcher restore. FreezerBridgeProvider now gates on the watchlist
  union every profile's membership, so an app frozen only by a profile
  is not a dead launcher tap.

17 unit tests cover the name rule (trimmed, capped, NOCASE-duplicate to
match the index the database enforces) and the request identity.
Only the #55a rows and its detail section, per the convention the
permission-filter branch wrote down: several open branches edit this
file, so each touches its own rows and leaves the shared sequencing and
"in review" paragraphs alone.

The detail section now says which piece exists because of which risk,
rather than listing the risks as open questions.
@github-actions github-actions Bot added documentation Improvements or additions to documentation area: freezer Freeze / unfreeze / suspend / disable area: ui/ux UI, theming, navigation, layout area: localization Translations / i18n labels Jul 30, 2026
@coderabbitai

coderabbitai Bot commented Jul 30, 2026

Copy link
Copy Markdown
Contributor

Review Change Stack

Note

Reviews paused

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

Use the following commands to manage reviews:

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

Use the checkboxes below for quick actions:

  • ▶️ Resume reviews
  • 🔍 Trigger review
📝 Walkthrough

Walkthrough

Adds Room-backed freeze profiles, profile-scoped bulk freeze/unfreeze execution, expanded restore authorization, Compose profile management, typed bulk outcomes, localized strings, tests, and related documentation.

Changes

Freeze profile persistence

Layer / File(s) Summary
Profile schema and repository
app/schemas/.../6.json, app/src/main/java/com/valhalla/thor/data/source/local/room/*, app/src/main/java/com/valhalla/thor/data/repository/*, app/src/main/java/com/valhalla/thor/domain/model/FreezeProfile.kt, app/src/test/.../FreezeProfileTest.kt
Adds Room entities, migration version 6, transactional profile membership operations, repository APIs, profile-name validation, and domain tests.

Scoped bulk execution

Layer / File(s) Summary
Scoped bulk execution and restore state
app/src/main/java/com/valhalla/thor/domain/model/BulkFreeze.kt, app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt, app/src/main/java/com/valhalla/thor/data/provider/FreezerBridgeProvider.kt, app/src/main/java/com/valhalla/thor/presentation/tile/FreezerTileService.kt, app/src/main/java/com/valhalla/thor/presentation/launcher/*
Adds request scopes, typed outcomes, multi-request tracking, profile target resolution, updated launcher and tile reporting, and profile-aware restore gating.

Profile UI and ViewModel flow

Layer / File(s) Summary
Profile UI and ViewModel flow
app/src/main/java/com/valhalla/thor/presentation/freezer/*, app/src/main/res/values*/strings.xml, docs/feature-request-roadmap.md, docs/follow-ups/*
Adds profile browsing and editing sheets, selection-to-profile creation, CRUD state wiring, localized resources, roadmap updates, and follow-up notes.

Estimated code review effort: 4 (Complex) | ~60 minutes

Possibly related PRs

🚥 Pre-merge checks | ✅ 3 | ❌ 2

❌ Failed checks (2 warnings)

Check name Status Explanation Resolution
Linked Issues check ⚠️ Warning The PR delivers freeze profiles, but it does not implement the linked process-manager support for user/system apps via Shizuku. Add the Shizuku-based process-manager support or narrow the linked issue scope to freeze profiles only.
Docstring Coverage ⚠️ Warning Docstring coverage is 26.39% which is insufficient. The required threshold is 80.00%. Write docstrings for the functions missing them to satisfy the coverage threshold.
✅ Passed checks (3 passed)
Check name Status Explanation
Title check ✅ Passed Clear, concise title that accurately summarizes the new named freeze profiles feature.
Description check ✅ Passed It covers the what/why, major changes, and testing, but omits some template items like screenshots and the checklist.
Out of Scope Changes check ✅ Passed The changes stay focused on freeze profiles and supporting runner/UI wiring, with no obvious unrelated code paths.
✨ Finishing Touches 💡 1
📝 Generate docstrings 💡
  • Create stacked PR
  • Commit on current branch
🧪 Generate unit tests (beta)
  • Create PR with unit tests
  • Commit unit tests in branch feat/freeze-profiles

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.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 3

🧹 Nitpick comments (1)
app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfileEditorSheet.kt (1)

116-123: 📐 Maintainability & Code Quality | 🔵 Trivial | ⚡ Quick win

App-picker filtering/toggle logic duplicated between the profile editor and the watchlist manage sheet. Both files copy-paste the same type+search filtering remember block, and separately duplicate the same AppListType ConnectedButtonGroup toggle block (FreezeProfileEditorSheet.kt L204-L218 vs ManageFreezerSheet.kt L119-L131) — the leaf tile (FreezerAppPickerItem) was correctly shared, but the surrounding grid controls were not.

  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfileEditorSheet.kt#L116-L123: extract this filter block (and the L204-L218 type-toggle) into a shared helper/composable reused from ManageFreezerSheet.
  • app/src/main/java/com/valhalla/thor/presentation/freezer/ManageFreezerSheet.kt#L83-L90: source of the duplicated filter logic (paired with L119-L131 for the toggle); consolidate both sheets onto the same shared implementation instead of two copies that can silently drift.
🤖 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
`@app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfileEditorSheet.kt`
around lines 116 - 123, Extract the duplicated app type/search filtering and
AppListType ConnectedButtonGroup toggle into shared helper/composable symbols,
then update FreezeProfileEditorSheet.kt lines 116-123 and 204-218 and
ManageFreezerSheet.kt lines 83-90 and 119-131 to reuse them. Preserve the
existing filtering behavior, selected-type state, and toggle interactions in
both sheets.
🤖 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 `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt`:
- Around line 269-298: Move the _runningRequest.value = request assignment out
of the synchronous launch() setup and into the async job after the previous job
has fully settled, immediately before this run begins package work. Keep
activeRequest and coalescing behavior unchanged, and ensure the running request
remains the previous request until the handoff completes.

In `@app/src/main/java/com/valhalla/thor/domain/model/FreezeProfile.kt`:
- Around line 48-54: The duplicate check in profileNameError must match SQLite
NOCASE rather than Kotlin Unicode-insensitive comparison. Update
FreezeProfile.kt’s duplicate validation to use an ASCII-only NOCASE-equivalent
comparison, and add a regression test in FreezeProfileTest.kt covering distinct
non-ASCII names such as Ä and ä; list both affected sites as required by the
consolidated review.

In
`@app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt`:
- Around line 338-367: Update the profile-write error handling so
SQLiteConstraintException uses an operation-appropriate toast: retain
error_profile_name_taken for create/update name conflicts, but use a
non-name-conflict error for deleteProfile failures. Adjust runProfileWrite or
provide distinct create/update and delete handling while preserving cancellation
propagation and logging.

---

Nitpick comments:
In
`@app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfileEditorSheet.kt`:
- Around line 116-123: Extract the duplicated app type/search filtering and
AppListType ConnectedButtonGroup toggle into shared helper/composable symbols,
then update FreezeProfileEditorSheet.kt lines 116-123 and 204-218 and
ManageFreezerSheet.kt lines 83-90 and 119-131 to reuse them. Preserve the
existing filtering behavior, selected-type state, and toggle interactions in
both sheets.
🪄 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 Plus

Run ID: 0a940e65-d06a-4ddb-8d03-2791086bf15c

📥 Commits

Reviewing files that changed from the base of the PR and between 3d0c70a and c93472b.

📒 Files selected for processing (26)
  • app/schemas/com.valhalla.thor.data.source.local.room.AppDatabase/6.json
  • app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt
  • app/src/main/java/com/valhalla/thor/data/provider/FreezerBridgeProvider.kt
  • app/src/main/java/com/valhalla/thor/data/repository/FreezeProfileRepositoryImpl.kt
  • app/src/main/java/com/valhalla/thor/data/source/local/room/AppDatabase.kt
  • app/src/main/java/com/valhalla/thor/data/source/local/room/FreezeProfileDao.kt
  • app/src/main/java/com/valhalla/thor/data/source/local/room/FreezeProfileEntity.kt
  • app/src/main/java/com/valhalla/thor/di/Modules.kt
  • app/src/main/java/com/valhalla/thor/domain/model/BulkFreeze.kt
  • app/src/main/java/com/valhalla/thor/domain/model/FreezeProfile.kt
  • app/src/main/java/com/valhalla/thor/domain/model/RestoreRequest.kt
  • app/src/main/java/com/valhalla/thor/domain/repository/FreezeProfileRepository.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfileEditorSheet.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfilesSheet.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerScreen.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerSelectToolBox.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/ManageFreezerSheet.kt
  • app/src/main/java/com/valhalla/thor/presentation/tile/FreezerTileService.kt
  • app/src/main/res/values-ar/strings.xml
  • app/src/main/res/values-es/strings.xml
  • app/src/main/res/values-fr/strings.xml
  • app/src/main/res/values-zh-rCN/strings.xml
  • app/src/main/res/values/strings.xml
  • app/src/test/java/com/valhalla/thor/domain/model/FreezeProfileTest.kt
  • docs/feature-request-roadmap.md

Comment thread app/src/main/java/com/valhalla/thor/domain/model/FreezeProfile.kt

@trinadhthatakula trinadhthatakula left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Code review summary

The profile schema, repository transactions, tier filtering, launcher restore gate, and request identity are internally consistent. I found no blocking correctness issue.

Non-blocking suggestions

  1. Test runner behavior, not only request equality. The central risk in this PR is the new same-request coalescing, same-op serialization, and conflicting-op chain cancellation in BulkFreezeRunner. Current profile tests prove BulkRequest identity but do not execute those coroutine handoffs. Deterministic runner tests would protect the behavior that closes the roadmap risk.

  2. Keep editor state until persistence succeeds. FreezerScreen closes the editor immediately after dispatching the asynchronous create/update. A constraint or I/O failure shows a toast after the draft has been discarded. Consider returning save state/result to the UI and dismissing only on success.

Verification

  • Reviewed commit c93472be89e06207a1ca0fc818abcde5b7d4a438 in an isolated clone.
  • FreezeProfileTest passed.
  • assembleFossDebug succeeded.
  • GitHub checks are green.
  • Room schema 6 contains the expected profile, membership, foreign-key, and index definitions.

Verdict

Approve with non-blocking suggestions.

Model: OpenAI GPT-5 (Codex)

Three review findings on the freeze-profiles branch.

`runningRequest` was one slot, so a newer launch overwrote an older one —
and on the serialize path the older one is the run still mutating packages.
Running a profile freeze during a watchlist freeze therefore made the QS
tile paint itself idle, and surface a stale "Froze N apps" subtitle, while
the watchlist freeze it started was still going. The profiles sheet had the
mirror bug: the first profile's spinner moved to the second row.

It is now `runningRequests`, the whole chain in launch order, derived from
one `unsettled` list that also carries the coalescing key — `activeJob` and
`activeRequest` were a second copy of the chain's tail and are gone. The
tile asks for membership, the sheet asks per row, and both are true while
two runs are in flight. Retirement moves to `invokeOnCompletion`, because a
job cancelled before its body ever ran never reaches the coroutine's
`finally`: that job used to sit in `unsettled` for the process lifetime,
which under the new flow would have pinned the tile on "Freezing…".

`profileNameError` folded case the Kotlin way, which is Unicode-aware,
while the `name COLLATE NOCASE` unique index folds ASCII only. It rejected
"Ärger" against an existing "ärger" — a name the database would have taken,
refused by an inline error the user cannot argue with. Now folded to match
the index, with the constraint catch still there as the real backstop.

`runProfileWrite` reported "that profile name is already taken" for every
`SQLiteConstraintException`, including the foreign-key failure a delete can
raise, which its own KDoc calls out. The message is now the caller's, since
only the caller knows which constraint was reachable.

assembleFossDebug + testFossDebugUnitTest + lintFossDebug --rerun-tasks:
228 tests, 0 failures.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

🧹 Nitpick comments (1)
app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt (1)

271-282: 🩺 Stability & Availability | 🔵 Trivial | ⚡ Quick win

A single Room failure permanently stops profile updates.

catch terminates the flow, so after one transient failure state.profiles is frozen for the rest of the process — the profiles sheet keeps showing a stale list with no way to recover short of app restart. observeApps already uses a bounded retryWhen for exactly this; consider the same here before falling through to the toast.

♻️ Proposed change
             freezeProfileRepository.observeProfiles()
+                .retryWhen { cause, attempt ->
+                    if (cause is CancellationException || attempt >= 2) false
+                    else { delay(500); true }
+                }
                 // A Room read failure must not take the whole Freezer screen down with it: the
                 // watchlist is a separate flow and stays perfectly usable without profiles.
                 .catch { e ->
🤖 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 `@app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt`
around lines 271 - 282, Update observeProfiles to add the same bounded retryWhen
recovery used by observeApps before the existing catch block, so transient Room
failures retry and profile updates resume without restarting the app. Preserve
the current logging, toast emission, and profile state collection after retries
are exhausted.
🤖 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 `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt`:
- Around line 284-299: Update the coalescing logic in the surrounding runner
method to search all entries in unsettled for an active job with the same
request, rather than checking only newest. Return the matching in-flight job
before computing previous, cancelPrevious, or doomed, while preserving the
existing chain handoff behavior when no identical active request exists.

---

Nitpick comments:
In
`@app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt`:
- Around line 271-282: Update observeProfiles to add the same bounded retryWhen
recovery used by observeApps before the existing catch block, so transient Room
failures retry and profile updates resume without restarting the app. Preserve
the current logging, toast emission, and profile state collection after retries
are exhausted.
🪄 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 Plus

Run ID: a3edba58-ddc4-4e62-a04e-72253ef98238

📥 Commits

Reviewing files that changed from the base of the PR and between c93472b and 9612901.

📒 Files selected for processing (13)
  • app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt
  • app/src/main/java/com/valhalla/thor/domain/model/FreezeProfile.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezeProfilesSheet.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerScreen.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt
  • app/src/main/java/com/valhalla/thor/presentation/tile/FreezerTileService.kt
  • app/src/main/res/values-ar/strings.xml
  • app/src/main/res/values-es/strings.xml
  • app/src/main/res/values-fr/strings.xml
  • app/src/main/res/values-zh-rCN/strings.xml
  • app/src/main/res/values/strings.xml
  • app/src/test/java/com/valhalla/thor/domain/model/FreezeProfileTest.kt
  • docs/follow-ups/bulk-freeze-runner-concurrency-tests.md
🚧 Files skipped from review as they are similar to previous changes (5)
  • app/src/main/res/values-ar/strings.xml
  • app/src/main/res/values-es/strings.xml
  • app/src/main/res/values-zh-rCN/strings.xml
  • app/src/main/res/values-fr/strings.xml
  • app/src/main/res/values/strings.xml

Comment thread app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt Outdated
…aunch

Fallout from the previous commit. Extending the runner from a single job slot
to a serialized chain fixed what `runningRequests` publishes, but left the
coalescing check reading only the tail.

With a watchlist freeze running and a profile freeze queued behind it, a second
watchlist freeze — a tile tap, a launcher shortcut — no longer matched the tail.
It took the serialize path instead and enqueued a *second* full watchlist batch
that re-acts on every package and posts its own result after the first one had
already reported. That is exactly the double-act the method's contract says
cannot happen, and the chain is the state that made it reachable.

Match any unsettled entry, guarded on the chain being uniform in op. A
conflicting-op launch cancels every entry from inside its own body, so a doomed
entry still reports `isActive` for a moment; coalescing onto one would hand a
caller a run about to be cancelled and its freeze would never happen. If every
entry shares the incoming op, no such launch has happened — it would still be in
the chain, carrying the op that fails the check.

Found by CodeRabbit on the previous commit.
…lure

`catch` ends the flow it is attached to, so one failed read left `profiles`
frozen for the rest of the process: the sheet kept showing a stale list and
nothing short of restarting the app brought it back. The app-list flow in this
same view model already retries twice before falling through to its toast —
this is the same bound, for the same reason.
Non-blocking suggestion from #295's review, recorded rather than fixed: the
shape of the fix is a UX call (dismiss-on-success, optimistic-with-restore, or
validate-first), and the branch's own risk was the bulk-run coalescing a
profile freeze goes through, not this.

@coderabbitai coderabbitai Bot left a comment

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.

Actionable comments posted: 1

Caution

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

⚠️ Outside diff range comments (1)
app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt (1)

372-379: 🎯 Functional Correctness | 🟠 Major | 🏗️ Heavy lift

Keep bulk-run failures distinct from intentional no-ops.

BulkFreezeRunner returns null for both “nothing to do” and caught Room/binder failures. FreezerViewModel then reports profile_nothing_to_do; a failed run, or a successful package mutation followed by notifier failure, can therefore be presented as an empty profile.

  • app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt#L372-L379: return a distinct failure outcome or propagate failures; do not rewrite successful execution as null.
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt#L325-L330: map only the intentional no-op outcome to profile_nothing_to_do.
🤖 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 `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt` around
lines 372 - 379, Keep intentional no-ops distinct from failures in
BulkFreezeRunner: at
app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt:372-379,
return a dedicated failure outcome or propagate the exception instead of
converting caught failures to null, while preserving successful mutation
results. At
app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt:325-330,
map only the explicit no-op outcome to profile_nothing_to_do and handle failure
outcomes separately.
🤖 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 `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt`:
- Around line 295-303: Update the coalescing logic in the unsettled-run lookup
to inspect only the current same-operation suffix, rather than requiring every
unsettled entry to have request.op. Start after the most recent entry with a
different operation, then reuse an active job matching request from that suffix
so queued identical operations share the existing run while older conflicting
operations remain excluded.

---

Outside diff comments:
In `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt`:
- Around line 372-379: Keep intentional no-ops distinct from failures in
BulkFreezeRunner: at
app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt:372-379,
return a dedicated failure outcome or propagate the exception instead of
converting caught failures to null, while preserving successful mutation
results. At
app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt:325-330,
map only the explicit no-op outcome to profile_nothing_to_do and handle failure
outcomes separately.
🪄 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 Plus

Run ID: 6abd6ab7-29a3-481c-b237-5d096c8eba4e

📥 Commits

Reviewing files that changed from the base of the PR and between 9612901 and 95716fd.

📒 Files selected for processing (5)
  • app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt
  • docs/follow-ups/README.md
  • docs/follow-ups/bulk-freeze-runner-concurrency-tests.md
  • docs/follow-ups/profile-editor-dismisses-before-the-save-lands.md
🚧 Files skipped from review as they are similar to previous changes (1)
  • docs/follow-ups/bulk-freeze-runner-concurrency-tests.md

Comment thread app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt Outdated
BulkFreezeRunner returned null both for "there was nothing to act on"
and for "this raised and was caught", so every surface that awaited a
run reported a Room or binder failure as a no-op. That is the one wrong
thing to say about a failure: it tells the user their apps are fine when
nobody knows whether they are, on the run most likely to have left the
watchlist half-frozen.

The awaited value is now a BulkOutcome — Completed, NothingToDo or
Failed. The profile toast and the launcher trampoline each say which of
the three it was; the failure wording deliberately does not claim how
far the batch got, because the runner does not know either.

A notifier.post that throws no longer rewrites a completed run as a
failure. The packages are already mutated by then, and failing to
describe a freeze is not failing to perform it.
@trinadhthatakula

Copy link
Copy Markdown
Owner Author

Latest CodeRabbit finding applied in a9ef0976.

"Keep bulk-run failures distinct from intentional no-ops" (Major) — confirmed, and it is worse than the report suggests, because it is the only thing the surface can say.

BulkFreezeRunner returned null from two places that mean opposite things: run() returning null for "no privilege, or nothing left after the tier filter", and the catch (e: Exception) that keeps a Room or binder failure from killing the process. Every caller that awaited a run therefore reported a failure as "Nothing to do for this profile" — the one reading that is certainly wrong, on the run most likely to have left the watchlist half-frozen.

The awaited value is now BulkOutcome:

sealed interface BulkOutcome {
    data class Completed(val result: BulkResult) : BulkOutcome
    data object NothingToDo : BulkOutcome
    data class Failed(val cause: Throwable) : BulkOutcome
}

NothingToDo stays a first-class outcome rather than a degenerate Completed, for the reason B-8 already gave: BulkResult(0, 0, 0) renders as "Froze 0 apps", a false report of a freeze that never ran.

Both awaiting surfaces were fixed, not just the profile one — FreezerLaunchActivity had the same conflation through run.isCompleted && !run.isCancelled, so an Unfreeze-all launched from a pinned shortcut also said "no apps" when the batch had blown up. The failure wording is deliberately vague about what got frozen (bulk_run_failed: "That run did not finish — check the apps before relying on it") because the runner genuinely does not know: the throw can land before the first package or halfway through the batch.

One thing the finding did not name. notifier.post(result) sat inside the same try, so a run that froze every app it was given was reported as a failure when only the notification about it failed to post. It now catches around post alone: the packages are already mutated by that line, and failing to describe a freeze is not failing to perform it.

Strings added in all five locales. assembleFossDebug testFossDebugUnitTest --rerun-tasksBUILD SUCCESSFUL, 228 tests, 0 failures.

No test covers the new Failed path: BulkFreezeRunner takes Context, the notifier and a SystemGateway, and :app has no mocking library. The mapping is three lines with no branching beyond the when, but that is a reason it is low-risk, not a claim that it is verified.


⚠️ build-and-test red on this branch is not this branch. AGP 9.4.0-alpha07 published today and lint's AndroidGradlePluginVersion check (severity ERROR, so warningsAsErrors was never what promoted it) now fails lintStoreRelease on gradle/libs.versions.toml:6. Every open PR failed within the hour on a line none of them touched. #298 downgrades it in app/lint.xml alongside the two sibling checks that were already downgraded for exactly this reason; once it lands, this branch needs dev merged in.

Lint's AndroidGradlePluginVersion check ships at severity ERROR, not warning,
so `warningsAsErrors` was never what promoted it and the GradleDependency /
NewerVersionAvailable overrides in app/lint.xml never covered it. The moment
alpha07 published upstream, lintStoreRelease began failing on
gradle/libs.versions.toml:6 for every open PR at once, on a line none of them
had touched. Taking the version the check is asking for clears it.

Verified locally: lintFossDebug, lintStoreRelease and the unit tests all pass
on alpha07 with --rerun-tasks.
@github-actions github-actions Bot added the dependencies Pull requests that update a dependency file label Jul 30, 2026

@coderabbitai coderabbitai Bot left a comment

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.

♻️ Duplicate comments (1)
app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt (1)

286-306: 🎯 Functional Correctness | 🟡 Minor | ⚡ Quick win

Coalescing check still doesn't cover a same-op suffix — this is a previously-flagged, still-unresolved issue.

unsettled.all { it.request.op == request.op } requires the entire chain to share the incoming op before attempting to coalesce. Once a conflicting-op replacement is underway (a doomed head entry hasn't retired yet, but a new tail entry with a different op is active), a repeat of that tail's request fails this all check and falls through to the serialize path instead of reusing the active tail job — spawning a duplicate run of the same request (double package mutation report, double notification, double _completions emission).

Example: unsettled = [A(FREEZE, cancelling), B(UNFREEZE, active)]. A second UNFREEZE request matching B fails unsettled.all { op == UNFREEZE } (A is still FREEZE), skips coalescing, and — since cancelPrevious only compares against the tail (B, same op) — takes the serialize path and creates a new run C that duplicates B.

This is the same gap raised in a prior review round on this PR (Coalesce against the current same-operation suffix), which was not marked as addressed.

🔧 Proposed fix: match only the current same-op suffix
-        if (unsettled.all { it.request.op == request.op }) {
-            unsettled.firstOrNull { it.job.isActive && it.request == request }
-                ?.let { return it.job }
-        }
+        val sameOpSuffix = unsettled.asReversed().takeWhile { it.request.op == request.op }
+        sameOpSuffix.firstOrNull { it.job.isActive && it.request == request }
+            ?.let { return it.job }
🤖 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 `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt` around
lines 286 - 306, Update the coalescing logic in launch so it considers only the
contiguous same-operation suffix of unsettled, not the entire chain. For an
incoming request, identify the current tail segment whose entries have
request.op equal to request.op, then reuse an active matching job from that
suffix; preserve the existing behavior for conflicting operations and avoid
coalescing with entries outside the suffix.
🤖 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.

Duplicate comments:
In `@app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt`:
- Around line 286-306: Update the coalescing logic in launch so it considers
only the contiguous same-operation suffix of unsettled, not the entire chain.
For an incoming request, identify the current tail segment whose entries have
request.op equal to request.op, then reuse an active matching job from that
suffix; preserve the existing behavior for conflicting operations and avoid
coalescing with entries outside the suffix.

ℹ️ Review info
⚙️ Run configuration

Configuration used: Path: .coderabbit.yaml

Review profile: CHILL

Plan: Pro Plus

Run ID: 6205fde1-f29e-4cba-8561-db21dd0f321f

📥 Commits

Reviewing files that changed from the base of the PR and between 95716fd and 0daf288.

📒 Files selected for processing (11)
  • app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt
  • app/src/main/java/com/valhalla/thor/data/launcher/FreezerShortcutManager.kt
  • app/src/main/java/com/valhalla/thor/domain/model/BulkFreeze.kt
  • app/src/main/java/com/valhalla/thor/presentation/freezer/FreezerViewModel.kt
  • app/src/main/java/com/valhalla/thor/presentation/launcher/FreezerLaunchActivity.kt
  • app/src/main/res/values-ar/strings.xml
  • app/src/main/res/values-es/strings.xml
  • app/src/main/res/values-fr/strings.xml
  • app/src/main/res/values-zh-rCN/strings.xml
  • app/src/main/res/values/strings.xml
  • gradle/libs.versions.toml
🚧 Files skipped from review as they are similar to previous changes (4)
  • app/src/main/res/values-ar/strings.xml
  • app/src/main/res/values-es/strings.xml
  • app/src/main/res/values-fr/strings.xml
  • app/src/main/res/values/strings.xml

@trinadhthatakula trinadhthatakula left a comment

Copy link
Copy Markdown
Owner Author

Choose a reason for hiding this comment

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

Review summary

This PR adds named freeze profiles, Room persistence and normalization, profile management UI, bulk freeze/unfreeze execution, result reporting, and supporting tests. The persistence and profile-flow changes are internally consistent, and the full Foss debug unit suite plus git diff --check pass at 4b2291091180f8f70914f6ad47e5b222bb7e11ca.

Blocking finding

[P1] Coalesce against the matching operation suffix, not only a completely uniform unsettled chain
app/src/main/java/com/valhalla/thor/data/freezer/BulkFreezeRunner.kt:303-305

The unsettled.all { it.request.op == request.op } guard misses a valid duplicate of the newest active request while a cancelled opposite-operation predecessor is still unwinding. For example:

  1. A FREEZE request is active.
  2. B UNFREEZE arrives, cancels A, and becomes the active replacement.
  3. Before A leaves unsettled, the same UNFREEZE request arrives again.
  4. The chain is temporarily [A(FREEZE), B(UNFREEZE)], so the all check fails and C is enqueued behind B.
  5. B and C both execute the same unfreeze batch and report twice.

Please search only the contiguous same-operation suffix of unsettled for an active identical request (for example, iterate from the end while request.op matches), so stale cancelling predecessors do not prevent coalescing while an earlier same-op request across a conflicting operation is still excluded. A focused regression test for the A/B/repeated-B timing would protect the intended invariant.

Verdict

Changes requested. The feature is otherwise in good shape, but the duplicate bulk action can repeat package operations and user-visible results.

Model used: OpenAI GPT-5 (Codex)

Note: GitHub does not allow the PR author to submit a formal Request changes review, so this verdict is posted as a review comment.

The previous rule asked whether the *whole* unsettled chain shared the
incoming op and only searched it for a match when it did. That is the right
answer for a uniform chain and the wrong one for a mixed chain, which is
exactly the shape a conflicting-op replacement leaves behind:

1. A(FREEZE) is in flight.
2. B(UNFREEZE) arrives, cancels A and becomes the replacement.
3. The same UNFREEZE arrives again before A has left the chain.
4. The chain is [A(FREEZE), B(UNFREEZE)], so the uniformity check fails and
   the repeat does not coalesce.
5. It falls through to the serialize path — same op as B, so nothing is
   cancelled — and queues a second identical unfreeze behind B. Both run the
   same batch, both report.

The eligible set is the chain's trailing same-op run, which is not an
approximation of "not doomed" but equal to it: an entry E is doomed by some
later entry X whose op differs from the entry before X, so if everything from
E to the end shares E's op, no such X can exist after E. Scanning back, the
first op change marks a launch that cancelled everything before it. So the
suffix answers the repeat above with B while still refusing to hand back A —
which is the case the uniformity check was added for and which a plain search
over the chain would get wrong in the other direction.

Lifted out of launch() as `coalesceTargetIndex` so it can be tested at all.
BulkFreezeRunner takes four final collaborators over Context/PackageManager
and cannot be built on a JVM (see the BulkFreezeWorkerTest KDoc), but this
part of it is a pure function of the chain's shape — and both ways of getting
it wrong are silent. Coalescing too little runs the batch twice; coalescing
too much hands back a run that is about to be cancelled, so the caller's
freeze never happens. Indices rather than entries keeps InFlight private and
copies nothing under the monitor.

Nine cases in CoalesceTargetIndexTest, including both directions of the
mixed-chain case and the scope-identity rule.

Verified: 245 unit tests, assembleFossDebug, lintFossDebug, lintStoreRelease.
@trinadhthatakula

Copy link
Copy Markdown
Owner Author

P1 fixed in a8cf5ab7. The analysis is right and so is the suggested shape — the trailing same-op run isn't just a cheaper approximation of "not doomed", it's equal to it:

an entry E is doomed by some later entry X whose op differs from the entry before X; if everything from E to the end shares E's op, no such X can exist after E.

So the suffix answers the repeated-B case with B while still refusing to hand back the cancelled A — which is the case the uniformity check was added for, and the one a plain unconditional search over the chain would get wrong in the other direction. Both failure modes are silent, which is why it now has its own tests.

On the regression test: BulkFreezeRunner can't be constructed on a JVM (four final collaborators over Context/PackageManager — the BulkFreezeWorkerTest KDoc lists them), so the rule is lifted out as coalesceTargetIndex(size, request, requestAt, isActive) and asserted directly. Nine cases in CoalesceTargetIndexTest, including the exact A/B/repeated-B chain you described, its mirror (a repeat of the doomed A must not coalesce), the multi-boundary chain [F1, U, F, F2], and the scope-identity rule.

245 tests / 0 failures; assembleFossDebug, lintFossDebug, lintStoreRelease clean.

trinadhthatakula added a commit that referenced this pull request Jul 30, 2026
Two of the three were fixed by this branch's feature work rather than by
separate commits, which is what "fold into the #30 branch — same files"
meant — but the doc still read as OPEN, so the closure was not checkable.

(1) share now carries the bundle's own MIME from BundleFormat, so a .apks
or .xapk is no longer announced to receivers as an installable package.
(3) exportInto deletes its staged copy in a finally, under a run-owned
staging scope, behind a getAllocatableBytes pre-flight — the export/share
split the doc asked for.

(2) is a decision, not a fix: the OBB field stays for phase 2. Option 1's
case for deleting it rested on "removes a File.exists() off the app-list
mapping path", and that is simply not true — the app list maps with
isLightweight = true and skips the whole block. Deleting it would buy one
stat per details-screen open at the price of a Room migration, with #295
already taking the schema to 6.
trinadhthatakula added a commit that referenced this pull request Jul 30, 2026
Both would have cost whoever picked them up real time.

The ViewModel-behavior-tests row still reads "unblocked — the tests are
just unwritten". They are written and merged: 8 tests on virtual time,
plus the four ports it turned out to need because AppListViewModel was
not constructible on a JVM at all. Its own doc has said so since
2026-07-30; only the index lagged.

The BulkFreezeRunner row names four collaborators that block a JVM test.
One of them stopped blocking when those ports shipped: the runner's sole
use of PrivilegeManager is `state` (BulkFreezeRunner.kt:361), which is
precisely PrivilegeStateProvider. Three left, and the port pattern is the
template for them. Noted that this waits on #295, which rewrites the file.
# Conflicts:
#	docs/follow-ups/README.md
#	docs/follow-ups/bulk-freeze-runner-concurrency-tests.md
It merged. The paragraph is deliberately byte-identical across every open
branch so the merges stay clean, so the correction has to land on each of
them in the same words — a differently-worded fix is the conflict it was
written to avoid.
The byte-identical-across-branches rule that governed this file only
bought anything while several branches edited it at once. With one left,
the note now says when to reinstate it rather than reading as a standing
constraint on a file only this branch touches.
@trinadhthatakula
trinadhthatakula merged commit 2a50395 into dev Jul 30, 2026
6 checks passed
@trinadhthatakula
trinadhthatakula deleted the feat/freeze-profiles branch July 30, 2026 20:30
This was referenced Jul 30, 2026
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: freezer Freeze / unfreeze / suspend / disable area: localization Translations / i18n area: ui/ux UI, theming, navigation, layout dependencies Pull requests that update a dependency file documentation Improvements or additions to documentation

Projects

None yet

Development

Successfully merging this pull request may close these issues.

1 participant