Skip to content

fix: androidTest Hilt binding, boot rescheduling, Spotify security fixes, escapeJson dedup - #516

Merged
yuga-hashimoto merged 7 commits into
mainfrom
worktree-smart-speaker-parity-fixes
Jul 12, 2026
Merged

fix: androidTest Hilt binding, boot rescheduling, Spotify security fixes, escapeJson dedup#516
yuga-hashimoto merged 7 commits into
mainfrom
worktree-smart-speaker-parity-fixes

Conversation

@yuga-hashimoto

Copy link
Copy Markdown
Owner

Summary

Follow-up fix pass on top of the already-merged Phase 20 smart-speaker-parity batch (shopping lists, reminders, in-app alarms, scheduled routines, jokes/trivia, fast-path latency, translation, sports scores, Spotify Connect). Addresses every gap flagged in that batch's own code review plus everything found while actually running the app and its instrumented test suite on-device for the first time.

  • Fix broken androidTest Hilt graphFakeSttTestModule/FakeTtsTestModule used @TestInstallIn(replaces = ...), which drops every binding the replaced module provided, silently breaking WhisperModelDownloader/PiperVoiceDownloader injection for the whole androidTest source set.
  • Reboot survival for alarms/reminders/routines — new BootRescheduler, rewired BootReceiver. Also fixes a bug found in review: a fired one-shot alarm's DB row was never deleted, so it was indistinguishable from a still-pending one and would be resurrected on every subsequent reboot — AlarmFireReceiver now deletes it.
  • Dedup escapeJson() across the 8 new tool executors added in Phase 20 into a shared com.opendash.app.tool.escapeJson (pre-existing duplicates elsewhere left untouched — out of scope).
  • Spotify security fixes: escape trackUri before interpolating into the play() request body (was producing invalid JSON / a soft injection point on a " in the URI); move the PKCE code_verifier/state from plaintext DataStore to encrypted SecurePreferences, with cleanup of the old plaintext keys on next auth attempt.
  • AppLaunchE2ETest fix — this pre-existing E2E test could never actually run before (blocked by the Hilt bug above). Once unblocked, found and fixed three real issues: missing @HiltAndroidTest/HiltAndroidRule, stale EXPECTED_TEXTS (predated the provider-mode-selection screen), and unhandled RECORD_AUDIO/POST_NOTIFICATIONS permission dialogs blocking the UiAutomator foreground check on a fresh install.
  • DB schema version bump (12 → 13) — removing AlarmEntity.enabled changed the compiled Room schema without bumping the version, which would have crashed app launch via Room's identity-hash check on any device with a pre-existing DB.
  • Fixed a literal raw control byte accidentally embedded in JsonEscape.kt's form-feed case during the dedup, replacing it with a safe unicode escape.

Test plan

  • ./gradlew testStandardDebugUnitTest — all unit tests green
  • ./gradlew assembleDebug — both flavors build
  • ./gradlew connectedStandardDebugAndroidTest on a running emulator — Hilt graph confirmed working; AppLaunchE2ETest passes on a genuinely fresh install
  • Manual on-device launch + screenshot verification of the cold-start flow, including after the DB version bump (confirmed no Room identity-hash crash)
  • Final whole-branch code review dispatched; all Critical/Important findings fixed in this branch
  • Known pre-existing, out-of-scope issues surfaced by actually running the full instrumented suite for the first time (not fixed here, need separate follow-up): AssistantProviderE2ETest, FakeSttPipelineE2ETest, LatencyBudgetE2ETest, and VoicePipelineFastPathE2ETest all hit kotlinx.coroutines.test virtual-time timeouts when run under instrumentation — a systemic incompatibility predating this branch (from the original E2E scaffolding PR test(e2e): scaffold instrumented test layer with Hilt + UiAutomator #459), not something introduced here.

🤖 Generated with Claude Code

The entire instrumented-test suite has been broken since before the
provider-mode-selection branch: FakeSttTestModule/FakeTtsTestModule
each replace() the real SttModule/TtsModule wholesale via
@TestInstallIn, but the real modules also provide
WhisperModelDownloader/PiperVoiceDownloader, which
OfflineStackViewModel/WhisperSettingsViewModel/PiperSettingsViewModel
inject directly as concrete classes. Nothing replaced those two
bindings, so the whole androidTest Hilt graph failed to compile.

Both downloader classes only need a Context and make no network call
until .download() is explicitly invoked (which no instrumented test
does), so the real classes are provided as-is rather than fakes.

Unblocks manual on-device verification of Phase 20 features via the
same processUserInput()-bypass pattern used previously to verify real
tool-calling (STT/mic not required).
AlarmManager exact alarms don't survive reboot, so any scheduled
alarm, reminder, or routine silently stopped firing until the user
re-touched it — a known limitation documented but not fixed when
P20.2-4 shipped.

New BootRescheduler re-arms every persisted alarm (recomputing next
occurrence), every upcoming reminder (reusing its stored absolute
trigger time directly, no recompute needed), and every routine with a
schedule. Extracted as a plain class (not inline in BootReceiver) so
it's unit-testable without a real BroadcastReceiver lifecycle, and
each category is wrapped independently so a failure in one (e.g. a DB
error) doesn't prevent the other two from rescheduling.

BootReceiver becomes @androidentrypoint to inject the three DAOs +
Moshi, and uses goAsync() since rescheduling does real DB +
AlarmManager work that must outlive onReceive() returning.

Also removes AlarmEntity.enabled (always true, no toggle tool exists
to set it false) per the earlier review's "wire up a toggle or drop
the column" note — dropped per YAGNI.
Extract the private per-file escapeJson() extension (duplicated
identically in 8 files added this session) into a single shared
com.opendash.app.tool.escapeJson. Pre-existing duplicates in ~15
files predating this session are left untouched to avoid unrelated
refactor scope creep.
An unescaped track URI containing a double quote would produce
invalid JSON in the request body sent to Spotify's /me/player/play.
…references

The PKCE code_verifier and CSRF state for an in-flight Spotify
authorization were stored in the plaintext AppPreferences DataStore
alongside non-secret settings. Move them to the encrypted
SecurePreferences store used for the access/refresh tokens, since
the verifier is a secret the token exchange trusts implicitly.
… dialogs

Discovered by actually running the instrumented suite on-device this
session (previously blocked entirely by the androidTest Hilt binding
bug fixed earlier in this branch):

- Add @HiltAndroidTest + HiltAndroidRule. HiltTestRunner swaps in
  HiltTestApplication process-wide, so any test launching the real
  MainActivity needs the rule to create the test Dagger component
  first, or the launch crashes with "component was not created".
- Update EXPECTED_TEXTS for the ProviderModeScreen landing screen
  added by the provider-mode-selection feature (was stale, only
  listed ModelSetupScreen/OnboardingScreen text).
- Grant RECORD_AUDIO/POST_NOTIFICATIONS via `pm grant` through
  UiAutomation before launch. On a genuinely fresh install those
  permissions trigger a system dialog that owns the foreground
  window, so the UiAutomator foreground check never matched.

Confirmed passing via connectedStandardDebugAndroidTest against a
fresh install on a running emulator.
…control byte)

Fixes from a final whole-branch review of this fix batch before merge:

- Bump AppDatabase.version 12 -> 13. Removing AlarmEntity.enabled in
  17b3b8e changed the compiled schema without bumping the version, so
  Room's identity-hash check would crash app launch on any device that
  already created the DB under the old schema (fallbackToDestructiveMigration
  only runs from onUpgrade, which never fires when the version is
  unchanged). Invisible to this session's own verification since it only
  ever used fresh installs and fresh in-memory test DBs.
- AlarmFireReceiver now deletes a fired one-shot alarm's DB row. Without
  this, a fired one-shot alarm and a still-pending one are indistinguishable
  in storage (both are just hour/minute with an empty repeat mask), so
  BootRescheduler would re-arm long-fired one-shot alarms on every reboot.
- Fix a literal raw control byte embedded in JsonEscape.kt's form-feed
  case, replacing it with the safe unicode escape literal the 8
  duplicated functions it replaced all used -- a raw control byte in
  source is invisible in most editors/diffs and fragile against
  reformatting tools.
- Clean up the legacy plaintext PKCE verifier/state DataStore keys left
  behind by the SecurePreferences migration in efd4493, so a device that
  started an authorization on an earlier build doesn't keep an orphaned
  secret in plaintext indefinitely.
@yuga-hashimoto
yuga-hashimoto merged commit 617f1df into main Jul 12, 2026
1 of 2 checks passed
yuga-hashimoto added a commit that referenced this pull request Jul 12, 2026
…20.4 notes

## Priority 1
PR #516 already shipped BootRescheduler + the one-shot alarm cleanup fix
but never updated the roadmap, leaving P20.3/P20.4 pointing at a "doesn't
survive reboot" limitation that's no longer true.
yuga-hashimoto added a commit that referenced this pull request Jul 12, 2026
## Priority 1
Tracks P21.1 (AEC/noise suppression), P21.2 (androidTest Hilt fix,
already shipped upstream in PR #516), and P21.3 (looping/fading/
full-screen alarm ringing) from this session's gap-review follow-through.
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