* Fix#347: Client-subject notification copy + deep-link taps + Manage action
Rewrite per-tool-call and session-summary notification copy per the locked
#342 design, wire up notification tap deep-links, and add a Manage action
button.
Per-tool-call notifications now read "${clientLabel} used ${humanized(toolName)}"
with the integration display name as body and the call timestamp rendered
natively via setWhen(). Session-summary notifications partition by client
label and emit one notification per client titled "${clientLabel} used
${count} ${tool|tools}" with up to 3 unique humanized tool names in the
body (Oxford comma, "and N more" past the cap).
Tap deep-links route to the audit-history screen — scrolled to the
specific call (per-tool-call) or filtered to the session time window
(summary) — and a single Manage action button points at the integration
manage page (or home, when a summary's calls span multiple integrations).
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
* Update integration tests for per-client summary notification ids
#347 changed SessionSummaryNotifier from a single-id summary to one
notification per client, keyed by the clientLabel hash. The existing
:app:integrationTest fixtures still asserted against the legacy
SessionSummaryNotifier.NOTIFICATION_ID constant, so they failed in CI.
Updated each affected test to compute the expected id via
SessionSummaryNotifier.idForClient(<clientLabel>) and dropped the
now-unused NOTIFICATION_ID companion constant. The InMemoryTokenStore
resolves clientName=null to the clientId, so for tests that pass
clientName=null the expected label is their clientId string (e.g.
"race-test", "multi-test", "runtime-mode-switch"). ToolCallHappyPathTest
passes clientName = "Test Client" explicitly.
Also retargeted the MultiToolSessionTest title-string assertion from the
old "{count} calls across {N} integrations" copy to the new
"{clientLabel} used {count} tools" title shape.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
* Move summary notification id bucket below per-call range
The previous summary id bucket (6000..15999) overlapped with
PerToolCallNotifier.BASE_ID = 7000, so any clientLabel whose hash
.mod(10000) landed in 1000..9999 produced an id that collided with
per-call notifications. RuntimeNotificationModeSwitchTest failed in CI
because its clientLabel "runtime-mode-switch" hashes to
idForClient(...) = 7465, which the test's "id >= PerToolCallNotifier.BASE_ID"
probe flagged as a per-call notification during phase 1 (SUMMARY mode).
Narrow the bucket to 4000..4999 so summary ids stay clear of the per-call
range. Collisions inside the 1000-entry bucket remain acceptable for the
expected <100-clients-on-a-device cardinality.
Co-Authored-By: Claude Opus 4 (1M context) <noreply@anthropic.com>
---------
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Post-ship crashes now surface in the Firebase console so we can react to
regressions without waiting for users to file manual bug reports. Debug
builds skip collection so local repros don't pollute the dashboard.
- Add Crashlytics Gradle plugin + dependency (piggybacks on the existing
google-services.json already shipped for FCM, so no new secrets).
- Release builds upload R8 mapping files via the plugin so stack traces
in the console stay deobfuscated; debug builds skip that slow step.
- Add a thin CrashReporter interface in :api so :app, :work, and other
feature modules can log caught exceptions without taking a direct
dependency on Firebase. Production impl (FirebaseCrashReporter) lives
in :app; all other modules see only the contract plus CrashReporter.NoOp.
- Initialize collection state in RouseApplication.configureCrashReporting
gated on BuildConfig.DEBUG — release builds collect by default, debug
builds stay silent.
- Surface previously-swallowed failures at the sites called out in the
issue: cert provisioning storage/key-gen failures in IntegrationSetup,
tunnel connect/session-handler errors in TunnelForegroundService,
terminal KeyGenerationFailed + CnMismatch outcomes in CertRenewalWorker,
and KeyInfo inspection failures in AndroidKeystoreDeviceKeyManager.
Uncaught exceptions are still handled automatically by Crashlytics'
default UncaughtExceptionHandler, so these explicit reports only cover
the paths we'd otherwise lose.
Privacy note: enabling Crashlytics collects crash stacks + Firebase
installation IDs, which will change the Play Store data-safety
declaration when we list. A user-facing opt-out toggle was scoped out
of this PR (see follow-up candidates) — current behaviour is on-by-
default for release builds.
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
Applies the `org.jetbrains.kotlinx.kover` 0.9.8 plugin to every production
module and wires the root project as the merging target, so
`./gradlew koverHtmlReport` produces one aggregated HTML + XML report across
`:app`, `:core:{tunnel,mcp,bridge}`, `:api`, `:notifications`, `:work`, and
`:integrations`.
Key pieces:
- `:core:tunnel:integrationTest` (real-relay subprocess tier) is now
registered as a `KotlinJvmTest`, not a plain `Test`. Kover's KMP locator
only auto-discovers tasks whose superclass is `KotlinJvmTest`; without
this change, coverage from integration tests (e.g.
`LazyMtlsKeyManager.chooseClientAlias`, exercised only by the real-relay
flow) would silently be dropped from the report.
- Android `testReleaseUnitTest` tasks are excluded via
`instrumentation.disabledForTestTasks`. CI only runs debug unit tests, and
some release-variant unit tests aren't configured to work (e.g. Roborazzi
screenshot tests in `:notifications`).
- Filters exclude generated code: `BuildConfig`, Compose `ComposableSingletons`
+ `*_ComposableKt`, kotlinx.serialization `$$serializer`, Room `*_Impl`,
and Hilt/Koin glue for future-proofing.
- `:app`'s release-signing check is tightened to the exact signing-triggering
task names so Kover's transitively-scheduled `packageReleaseResources` /
`packageReleaseUnitTestForUnitTest` no longer trip the fail-fast guard.
- CI runs `koverHtmlReport koverXmlReport` after the existing test steps,
uploads the HTML as `test-coverage` artifact, and writes a per-module line
coverage summary to `$GITHUB_STEP_SUMMARY` (awk over each module's XML).
No `koverVerify` rule — report-only for the first pass as specified in #248.
Verified on the aggregated XML: `LazyMtlsKeyManager.chooseClientAlias` reports
`LINE covered=1 missed=0` (was unreachable from unit tests alone).
Co-authored-by: Claude Opus 4 (1M context) <noreply@anthropic.com>
"AI client wants to open Spotify" is now "Claude wants to open Spotify"
when the calling client supplied client_name during OAuth DCR. Falls
back to the generic "AI client" label when no name is set.
Plumbing:
- TokenStore gains resolveClientName(integrationId, token). Both the
in-memory and Room-backed implementations resolve from the stored
DCR label (or null when that equals the opaque clientId).
- McpRouting.handleMcp reads the name right after bearer validation
and wraps the SDK dispatch in withContext(McpClientContext(...)),
a tiny CoroutineContext.Element in :core:mcp.
- LaunchRequestNotifierApi.postLaunchApp / postOpenLink take an
optional clientName. LaunchRequestNotifier uses it to format the
notification title (%1$s wants to open %2$s).
- OutreachMcpProvider's launch_app and open_link tools read the name
from coroutineContext[McpClientContext] and pass it to the notifier
fallback.
Non-goals: this only touches the launch-app/open-link notifications.
Other user-facing prompts (auth approval, per-tool-call) already show
client identity via a different path and are unchanged.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Extract all hard-coded user-facing strings from the seven notifier files
in :notifications into api/src/main/res/values/strings.xml so they are
audit-able in one place and can be referenced from any module that can
depend on :api. Follows the pattern established by the colors migration
(86b763ef): shared user-facing resources live in :api, notifiers look up
strings via context.getString(ApiR.string.*).
Covers:
- ForegroundNotifier (foreground service notification copy)
- FgsLimitNotifier (6-hour FGS limit copy)
- AuthRequestNotifier (approval request + Approve/Deny actions)
- LaunchRequestNotifier (AI-client launch / open-link + Open action)
- PerToolCallNotifier (tool-call title + body)
- SessionSummaryNotifier (session summary title with plurals)
- AndroidSecurityCheckNotifier (alert / info titles, check labels)
Compose UI strings across :app/ui/screens and dimens consolidation are
deferred to a follow-up pass under the same issue — notifier strings are
the well-defined first slice called out in the staged migration plan.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Replace the six synchronous DataStore reads that required runBlocking on
the main/Koin thread with suspending APIs and a live-snapshot holder.
This removes a long-standing violation of the project's coroutine rules
("test and main() only") and fixes the detail-screen staleness that
came from one-shot preference reads.
Changes:
- IntegrationStateStore, NotificationSettingsProvider, and
IntegrationSettingsStore now expose suspend reads and
Flow-returning observers; synchronous getters are gone.
- New PreferencesSnapshotHolder aggregates every UI-facing preference
into a single StateFlow, collected WhileSubscribed via appScope.
Compose and ViewModels read from .snapshot.value / observeX flows
instead of blocking DataStore.
- IntegrationProviderRegistry caches enabled IDs in a StateFlow
launched eagerly in appScope, preserving its synchronous
ProviderRegistry contract for FCM and foreground-service callers.
- OutreachIntegration mirrors the DIRECT_LAUNCH toggle into a StateFlow
so the canLaunchDirectly tool-call predicate stays non-suspending.
- DeviceRegistrationStatus starts false and transitions asynchronously
once the cert store has been read.
- New OAuthHostnameProvider resolves the metadata hostname lazily in
a suspend context; the McpSession starts with the "localhost"
fallback that the Host header will override in real traffic.
- PerCallObserver.onToolCallRecorded is now suspend so
PerToolCallNotifier can await NotificationSettingsProvider.settings().
- CI guardrail grep added to .github/workflows/ci.yml (lint job) that
fails on any new production runBlocking. Commented KDoc references
and the synchronous-API bridge in TlsAcceptor.kt (InputStream.read /
OutputStream.write, non-suspending by java.io contract) are
explicitly excluded.
Tests:
- New PreferencesSnapshotHolderTest exercises the snapshot flow.
- Existing store tests rewrap assertions in runBlocking (allowed in
tests per project rules).
- MockK call sites for suspend methods migrated from every/verify
to coEvery/coVerify as required.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Add api/src/main/res/values/colors.xml mirroring the Theme.kt palette so
:notifications and :work can reference brand colors via R.color.* instead
of inlining hex literals. Replace inline 0xFF0a1628 in FgsLimitNotifier
and ForegroundNotifier, and 0xFFea4335 in AuthRequestNotifier, with
ContextCompat.getColor(context, ApiR.color.rouse_*) lookups.
Theme.kt stays as-is (option a in the plan); palette is duplicated but
XML mirrors the Kotlin source of truth. Strings and dimens migration
will land in follow-up PRs.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Follow-up to #105, which landed the backend (RoomAuditListener.onRequest,
mcp_request_entries table, NotificationSettings.showAllMcpMessages). This
wires the flag through the UI so users can actually see the extra entries.
SettingsScreen gains a SwitchRow in the Audit Notifications section. The
toggle binds to SettingsViewModel.showAllMcpMessages, which observes a new
NotificationSettingsProvider.observeSettings() Flow so toggle state reacts
to writes from either the Settings screen or the Audit tab.
AuditHistoryViewModel now takes an optional McpRequestDao and
NotificationSettingsProvider. When the toggle is off, only tool-call
entries surface (unchanged). When on, generic MCP request entries are
queried and merged with tool calls, sorted timestamp-desc within each
day group. The display type is a sealed AuditHistoryItem hierarchy with
ToolCall and Request variants so the view renders each shape correctly
(tool calls stay clickable; requests show a muted Api icon + method).
Sealed class chosen over a polymorphic AuditEntry because the two row
shapes differ meaningfully in interactivity (detail navigation vs none)
and visual treatment, and the compiler-enforced exhaustiveness prevents
missing a variant when future row types are added.
Screenshot goldens regenerated: existing Settings screens pick up the new
SwitchRow; a new 36a_audit_history_show_all_mcp_messages pair captures
the interleaved mixed view.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Android 14+ tightened background-activity-start rules: the sender of a
PendingIntent must hold BAL privilege at send() time. Rouse's
FOREGROUND_SERVICE_TYPE_DATA_SYNC does NOT grant BAL, so open_link and
launch_app silently no-op when the app is backgrounded on Pixel 10 Pro
and similar API 34+ devices.
This change adds a two-part fix:
1. SYSTEM_ALERT_WINDOW opt-in in the Outreach setup screen. When the user
enables it and grants the OS permission, the PendingIntent path keeps
working as before. The new SwitchRow only appears on API 34+
(`directLaunchApplicable`); older devices don't need the workaround.
2. Tap-to-launch notification fallback when the opt-in is off or denied.
A new LaunchRequestNotifier in :notifications posts a notification
whose tap fires the PendingIntent — notification-tap launches bypass
BAL restrictions. The channel `rouse_outreach_launch` defaults to
IMPORTANCE_DEFAULT so it shows in the shade without heads-up.
OutreachMcpProvider now takes a `canLaunchDirectly` predicate and an
optional `LaunchRequestNotifierApi`. OutreachIntegration wires both from
the IntegrationSettingsStore toggle and the overlay permission check,
so the decision is re-evaluated on every tool call.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Previously, only `tools/call` invocations were surfaced to the audit
listener, so a connected client could enumerate the full tool menu
(`tools/list`, `resources/read`, etc.) without leaving any user-visible
trace.
Changes:
- Extend `AuditListener` with a generic `onRequest(event: McpRequestEvent)`
that fires for every JSON-RPC method. `onToolCall` remains the
specialized path for `tools/call` so the existing tool-call UI stays
intact.
- `McpRouting.emitAuditEvent` now fires `onRequest` for every dispatched
method, and additionally fires `onToolCall` for `tools/call` (helpers
split for detekt complexity).
- Add Room `McpRequestEntry` entity + `McpRequestDao` parallel to the
existing `AuditEntry`. DB version 2 -> 3 with a migration that creates
the new `mcp_request_entries` table without touching existing data.
- `RoomAuditListener` accepts an optional `McpRequestDao` and persists
every request when supplied, encrypting `paramsJson` at rest via the
existing `FieldEncryptor` (covers `prompts/get` / `resources/read`
which may carry user data).
- `NotificationSettingsProvider` + DataStore gain
`showAllMcpMessages` (default false) so the UI can later gate
visibility behind a user opt-in.
Tests:
- `McpProtocolTest`: `onRequest` fires for every method; `tools/call`
fires both callbacks; params/duration/resultBytes captured;
onToolCall does not fire for non-tool methods.
- `RoomAuditListenerTest`: `onRequest` persists to the request DAO;
is a no-op when the DAO is absent.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Regenerate ic_stat_rouse as a vector drawable (24dp viewport) with a
solid circle path and vertical alpha gradient (40% -> 100% opacity).
The alpha gradient survives Android's status-bar tinting (which
flattens color gradients) and evokes the halftone-density feel of the
brand icon at notification-shade size.
Deletes five PNG density variants in favor of the single vector — the
previous PNG conversion from #55 quantized the halftone dot pattern
into an unrecognizable silhouette at 24dp.
No code changes: AuthRequestNotifier, SessionSummaryPoster,
NotificationAdapter, and ForegroundNotification continue to reference
ApiR.drawable.ic_stat_rouse, which now resolves to the vector.
Launcher monochrome variant (for Android 13+ themed icons) deferred
as a follow-up — requires a matching foreground silhouette asset.
Adds a one-time NotificationPreferences step between the Welcome screen
and Home so users pick their audit notification mode (Summary / Each
usage / Suppress) once during onboarding rather than being re-prompted
in every per-integration setup. The setting remains editable from the
global Settings screen, and both surfaces now read/write through a
shared NotificationSettingsProvider backed by DataStore.
Per-integration setup screens (Notification, Outreach, Usage, Health
Connect) never surfaced the mode in the first place, so no UI removal
is required there — they keep their integration-specific options only.
- Adds NotificationSettingsProvider.setPostSessionMode() and implements
persistence in DataStoreNotificationSettingsProvider.
- Wires SettingsViewModel.setPostSessionMode to actually persist (was a
no-op TODO).
- Adds NotificationPreferencesViewModel seeded from the provider so the
previously-chosen option is pre-selected if the screen is revisited.
- Adds Routes.NOTIFICATION_PREFERENCES as an onboarding route; Welcome
"Get Started" now navigates there, and Continue on the preferences
screen kicks off relay registration before popping to HOME.
- Covers new flow with ViewModel tests, SettingsViewModel persistence
tests, and BackStack/NavigationBarVisibility tests for the new route.
Closes#69.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Vector drawable notification icons can render incorrectly in the
notification tray. Replace with density-specific PNGs (mdpi through
xxxhdpi) generated from the same sun-rising pattern, white on
transparent for proper Android tinting.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Move ic_stat_rouse drawable from :notifications to :api so that
:outreach can reference it without depending on :notifications.
Replace :notifications dependency with :api in outreach/build.gradle.kts.
Add :api dependency to :work for the same icon reference.
Update all R.drawable.ic_stat_rouse references to use ApiR import alias
in notifications module sources (required by nonTransitiveRClass=true).
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
- Enable core library desugaring for java.time and java.util.Base64 usage
in app, core:mcp, health, and notifications modules
- Guard NotificationChannel creation (API 26+) in NotificationChannels
and OutreachMcpProvider
- Guard FOREGROUND_SERVICE_TYPE_DATA_SYNC (API 29+) in
TunnelForegroundService, fall back to type-less startForeground
- Use ContextCompat.startForegroundService() in FcmReceiver and
TestWakeReceiver for pre-API-26 compatibility
- Guard Health Connect availability check for API 28+ in
HealthConnectIntegration
- Add tools:overrideLibrary for Health Connect in health manifest
- Pin Robolectric to SDK 33 in outreach, work, and notifications test
resources to match app module
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
The dashboard was stale because it used a manual refreshTrigger instead of
reactive data sources. Now IntegrationStateStore exposes observeChanges()
and AuditDao exposes observeRecent() — both Flows that the ViewModel combines
so the UI updates immediately when integrations are toggled or new audit
entries arrive.
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
Implement the :api module provider contract (T-6):
- McpIntegration interface for integration modules to implement
- IntegrationStateStore interface for user enable/disable persistence
- IntegrationState enum with deriveIntegrationState() derivation function
- NotificationSettingsProvider and NotificationSettings for notification model
- Tests for state derivation logic and state store contract
Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>