Skip to content

Commit 2c2ea4a

Browse files
authored
Add Claude Code architecture and wide events rules (#7813)
Task/Issue URL: https://app.asana.com/1/137249556945/project/1198194956794324/task/1213433097721645?focus=true ### Description Adds two `.claude/rules/` files automatically loaded by Claude Code sessions: - **`architecture.md`** — core architectural constraints: module structure, Anvil/Dagger conventions, the plugin system (`@ContributesPluginPoint` vs `@ContributesActivePluginPoint`), ViewModel/coroutine patterns, and testing conventions. - **`wide-events.md`** — Android-specific wide events API: `WideEventClient` usage, `FlowStatus`, `CleanupPolicy`, the feature-specific wrapper pattern, and reference implementations. This ensures AI agents work within our patterns from the start rather than falling back on generic Android knowledge. ### Steps to test this PR - [ ] Review `.claude/rules/architecture.md` for accuracy and completeness - [ ] Review `.claude/rules/wide-events.md` for accuracy and completeness ### UI changes N/A <!-- CURSOR_SUMMARY --> --- > [!NOTE] > **Low Risk** > Documentation-only change that adds guidance files; no production code or runtime behavior is modified. > > **Overview** > Adds two new Claude Code rules docs under `.claude/rules/` to standardize how AI agents write Android code in this repo. > > `architecture.md` documents enforced module boundaries (`-api`/`-impl`), Anvil/Dagger scope/annotation conventions, plugin-point patterns (including feature-flagged active plugin points), and preferred UI/coroutine/logging/testing idioms. `wide-events.md` documents the Android `WideEventClient` flow API (start/step/interval/finish/abort), cleanup/status semantics, and the recommended feature-level wrapper pattern with references to existing implementations. > > <sup>Written by [Cursor Bugbot](https://cursor.com/dashboard?tab=bugbot) for commit 886fb2c. This will update automatically on new commits. Configure [here](https://cursor.com/dashboard?tab=bugbot).</sup> <!-- /CURSOR_SUMMARY -->
1 parent 461809c commit 2c2ea4a

File tree

2 files changed

+351
-0
lines changed

2 files changed

+351
-0
lines changed

.claude/rules/architecture.md

Lines changed: 206 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,206 @@
1+
# Android Architecture Rules
2+
3+
## Core Principle: Decoupling Over Everything
4+
5+
Features communicate through `-api` modules only. An `-impl` module must never depend on another `-impl` module. If two features need to interact, one exposes an interface in its `-api`, the other injects it. This is non-negotiable — it prevents circular dependencies and keeps the Dagger graph clean.
6+
7+
---
8+
9+
## Module Structure
10+
11+
Every feature follows the `-api` / `-impl` split:
12+
13+
```
14+
my-feature/
15+
my-feature-api/ ← interfaces, data classes, no implementation
16+
my-feature-impl/ ← implementation, UI, DI bindings
17+
```
18+
19+
Rules:
20+
- `my-feature-impl` depends on `my-feature-api`
21+
- `my-feature-impl` depends on other features' `-api` modules only — never their `-impl`
22+
- `settings.gradle` auto-discovers modules 2 levels deep — no manual `include` needed
23+
- New `-impl` modules must be added to `app/build.gradle` to enter the Dagger graph
24+
- UI resources (layouts, drawables, strings) live inside the `-impl` module, not a separate UI module
25+
- String resource files are named by feature: `strings-my-feature.xml` (not `strings.xml`)
26+
27+
---
28+
29+
## Dependency Injection (Anvil / Dagger)
30+
31+
### Scopes
32+
33+
| Scope | Use for |
34+
|---|---|
35+
| `AppScope` | Singletons that live for the app lifetime |
36+
| `ActivityScope` | Things scoped to a single Activity (gets activity context) |
37+
| `FragmentScope` | ViewModels and things scoped to a Fragment |
38+
39+
Use `@SingleInstanceIn(AppScope::class)`**not** `@Singleton` (javax). `@Singleton` conflicts with AppComponent's scope.
40+
41+
### Common Annotations
42+
43+
```kotlin
44+
// Singleton binding
45+
@SingleInstanceIn(AppScope::class)
46+
@ContributesBinding(AppScope::class)
47+
class RealFoo @Inject constructor(...) : Foo
48+
49+
// Override an existing binding (higher rank wins)
50+
@ContributesBinding(AppScope::class, rank = 1)
51+
52+
// ViewModel
53+
@ContributesViewModel(FragmentScope::class)
54+
class FooViewModel @Inject constructor(...) : ViewModel()
55+
56+
// Plugin contribution (multibinding)
57+
@ContributesMultibinding(AppScope::class)
58+
class MyPlugin @Inject constructor(...) : SomePlugin
59+
60+
// Remote feature flag
61+
@ContributesRemoteFeature(scope = AppScope::class, featureName = "myFeature")
62+
interface MyFeature : Feature {
63+
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
64+
fun myToggle(): Toggle
65+
}
66+
```
67+
68+
`DefaultFeatureValue.INTERNAL` = enabled only in internal/debug builds.
69+
70+
### Activity Context
71+
72+
`@ActivityContext Context` and `AppCompatActivity` are provided at `ActivityScope` via `DaggerActivityScopedModule`. Inject them with:
73+
74+
```kotlin
75+
@ContributesBinding(ActivityScope::class)
76+
class RealFoo @Inject constructor(
77+
@ActivityContext private val context: Context,
78+
) : Foo
79+
```
80+
81+
Never pass `Context` as a parameter through an interface if DI can provide it at the right scope.
82+
83+
### App Coroutine Scope
84+
85+
```kotlin
86+
@AppCoroutineScope private val appScope: CoroutineScope
87+
```
88+
89+
---
90+
91+
## Plugin System
92+
93+
Two kinds of plugin points exist. Pick based on whether you need remote feature flag control.
94+
95+
### `@ContributesPluginPoint` — basic
96+
97+
`PluginPoint<T>` — Dagger multibinding under the hood. Returns all registered plugins, no runtime filtering.
98+
99+
```kotlin
100+
// Declare (in -api or -impl module)
101+
@ContributesPluginPoint(AppScope::class)
102+
interface MyPlugin { fun doThing() }
103+
104+
// Contribute
105+
@ContributesMultibinding(AppScope::class)
106+
class MyPluginImpl @Inject constructor() : MyPlugin
107+
108+
// Contribute with explicit ordering (lower value = higher priority)
109+
@ContributesMultibinding(AppScope::class)
110+
@PriorityKey(100)
111+
class MyPluginImpl @Inject constructor() : MyPlugin
112+
113+
// Consume
114+
class Foo @Inject constructor(private val plugins: PluginPoint<MyPlugin>)
115+
// plugins.getPlugins() → all plugins, in priority order if @PriorityKey is used
116+
```
117+
118+
### `@ContributesActivePluginPoint` — with remote feature flags + codegen
119+
120+
`ActivePluginPoint<T>` — wraps a regular plugin point with two levels of feature-flag gating. The annotation processor generates all the boilerplate: a remote feature for the plugin point itself, a remote feature per plugin, a `MultiProcessStore`, and a wrapper that applies both guards at runtime.
121+
122+
**Plugin point must be declared on a private interface** (the codegen is the only consumer):
123+
```kotlin
124+
// The plugin interface must extend ActivePlugin
125+
interface MyPlugin : ActivePlugin { fun doThing() }
126+
127+
// Declared with a private trigger interface (in -impl)
128+
@ContributesActivePluginPoint(
129+
scope = AppScope::class,
130+
boundType = MyPlugin::class,
131+
)
132+
private interface MyPluginPointTrigger
133+
```
134+
135+
**Contribute a plugin:**
136+
```kotlin
137+
@ContributesActivePlugin(
138+
scope = AppScope::class,
139+
boundType = MyPlugin::class,
140+
)
141+
class MyPluginImpl @Inject constructor() : MyPlugin {
142+
// isActive() is generated — backed by its own remote feature flag
143+
}
144+
```
145+
146+
**Consume:**
147+
```kotlin
148+
class Foo @Inject constructor(private val plugins: ActivePluginPoint<MyPlugin>)
149+
// plugins.getPlugins() → only plugins whose feature flag is enabled AND isActive() == true
150+
```
151+
152+
**How the gating works at runtime:**
153+
1. If the plugin point's own `self()` toggle is OFF → `emptyList()` immediately
154+
2. Otherwise, filter each plugin by its individual `pluginXxx()` toggle (via `isActive()`)
155+
156+
**Feature flag naming convention** (generated, useful to know for remote config):
157+
- Plugin point flag: `pluginPoint${InterfaceName}` e.g. `pluginPointMyPlugin`
158+
- Per-plugin flag: a sub-toggle on the same feature, `pluginMyPluginImpl`
159+
160+
All flags default to `TRUE` so newly contributed plugins are on by default and can be killed remotely.
161+
162+
### Notable active plugin points in the browser
163+
- `JsInjectorPlugin` — hooks into `onPageStarted` / `onPageFinished` on the browser WebView
164+
- `ContentScopeJsMessageHandlersPlugin` — handles JS→native messages via content scope scripts
165+
166+
---
167+
168+
## UI Patterns
169+
170+
### ViewModels
171+
172+
Commands are emitted via a `Channel<Command>`:
173+
```kotlin
174+
private val _commands = Channel<Command>(Channel.BUFFERED)
175+
val commands: Flow<Command> = _commands.receiveAsFlow()
176+
```
177+
178+
State is `StateFlow` derived via `combine` + `stateIn`.
179+
180+
### Coroutine Jobs
181+
182+
Prefer `ConflatedJob` over a raw `Job` variable or a `Map<Key, Job>` when you need to cancel-and-replace a running job:
183+
```kotlin
184+
private var dwellJob by ConflatedJob()
185+
dwellJob = scope.launch { /* cancels previous */ }
186+
```
187+
188+
---
189+
190+
## Logging
191+
192+
```kotlin
193+
import logcat.logcat // correct
194+
// NOT: import com.squareup.logcat.logcat
195+
```
196+
197+
---
198+
199+
## Testing
200+
201+
- JUnit4 (`@Test`, not JUnit5/Jupiter)
202+
- Assertions: `org.junit.Assert.*`
203+
- Mocking: `org.mockito.kotlin.mock()` + `whenever()`
204+
- Coroutines: `CoroutineTestRule` + `runTest { }`
205+
- Test files mirror the class: `RealFoo.kt` → `RealFooTest.kt`
206+
- No coroutine test setup needed for pure logic classes

.claude/rules/wide-events.md

Lines changed: 145 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,145 @@
1+
# Wide Events
2+
3+
Wide events measure a user's complete journey through a multi-step flow, sending a single event when the journey concludes. Use them when a flow has multiple steps that can succeed or fail independently and you need to understand drop-off and errors across the whole journey.
4+
5+
**Use a wide event when:** a user journey has multiple steps, the outcome of earlier steps affects the interpretation of later ones, or you need to understand where users drop off.
6+
7+
**Use a pixel when:** recording a single discrete action with no multi-step flow.
8+
9+
For design guidance on naming, status, data selection, latency bucketing, and best practices, refer to the platform-agnostic doc at `windows-browser/.cursor/rules/wide-events.mdc`.
10+
11+
---
12+
13+
## Android API
14+
15+
The entry point is `WideEventClient` from `statistics-api`. Inject it directly or, for complex flows, wrap it in a feature-specific interface (see below).
16+
17+
```kotlin
18+
// Start a flow — returns a flow ID used for all subsequent calls
19+
val flowId: Long = wideEventClient.flowStart(
20+
name = "my-feature-action", // kebab-case, e.g. "subscription-purchase"
21+
flowEntryPoint = origin, // optional: where the flow was triggered from
22+
metadata = mapOf("key" to "value"), // initial data
23+
cleanupPolicy = CleanupPolicy.OnProcessStart(ignoreIfIntervalTimeoutPresent = true),
24+
).getOrNull() ?: return
25+
26+
// Record a step
27+
wideEventClient.flowStep(
28+
wideEventId = flowId,
29+
stepName = "create_account", // snake_case
30+
success = true,
31+
metadata = mapOf("platform" to "google"),
32+
)
33+
34+
// Measure latency between two points (always use bucketed values, not raw ms)
35+
wideEventClient.intervalStart(wideEventId = flowId, key = "creation_latency_ms_bucketed")
36+
// ... operation ...
37+
wideEventClient.intervalEnd(wideEventId = flowId, key = "creation_latency_ms_bucketed")
38+
39+
// Finish the flow
40+
wideEventClient.flowFinish(
41+
wideEventId = flowId,
42+
status = FlowStatus.Success,
43+
metadata = mapOf("last_step" to "activate"),
44+
)
45+
46+
// Or abort silently (no event sent)
47+
wideEventClient.flowAbort(wideEventId = flowId)
48+
```
49+
50+
### FlowStatus
51+
52+
```kotlin
53+
FlowStatus.Success
54+
FlowStatus.Failure(reason = "payment_declined")
55+
FlowStatus.Cancelled
56+
FlowStatus.Unknown // unexpected termination; always pair with a last_step in metadata
57+
```
58+
59+
### CleanupPolicy
60+
61+
Defines what happens to flows abandoned due to app termination or timeout:
62+
63+
```kotlin
64+
// Complete the flow with Unknown status on next process start
65+
CleanupPolicy.OnProcessStart(
66+
ignoreIfIntervalTimeoutPresent = true, // keep alive if an interval timeout is set
67+
flowStatus = FlowStatus.Unknown,
68+
)
69+
70+
// Complete the flow with Unknown status after a duration
71+
CleanupPolicy.OnTimeout(
72+
duration = Duration.ofDays(7),
73+
flowStatus = FlowStatus.Unknown,
74+
)
75+
```
76+
77+
---
78+
79+
## Implementation Pattern
80+
81+
For non-trivial flows, wrap `WideEventClient` in a feature-specific interface. This keeps the calling code clean and makes the flow lifecycle explicit.
82+
83+
```kotlin
84+
// 1. Define a feature-specific interface (in the feature's -impl module)
85+
interface MyFeatureWideEvent {
86+
suspend fun onFlowStarted(origin: String?)
87+
suspend fun onStepCompleted()
88+
suspend fun onFlowSucceeded()
89+
suspend fun onFlowFailed(reason: String)
90+
}
91+
92+
// 2. Implement it, injecting WideEventClient
93+
@SingleInstanceIn(AppScope::class)
94+
@ContributesBinding(AppScope::class)
95+
class MyFeatureWideEventImpl @Inject constructor(
96+
private val wideEventClient: WideEventClient,
97+
) : MyFeatureWideEvent {
98+
99+
private var flowId: Long? = null
100+
101+
override suspend fun onFlowStarted(origin: String?) {
102+
flowId = wideEventClient.flowStart(
103+
name = "my-feature-action",
104+
flowEntryPoint = origin,
105+
cleanupPolicy = CleanupPolicy.OnProcessStart(ignoreIfIntervalTimeoutPresent = false),
106+
).getOrNull()
107+
}
108+
109+
override suspend fun onFlowSucceeded() {
110+
val id = flowId ?: return
111+
wideEventClient.flowFinish(wideEventId = id, status = FlowStatus.Success)
112+
flowId = null
113+
}
114+
115+
override suspend fun onFlowFailed(reason: String) {
116+
val id = flowId ?: return
117+
wideEventClient.flowFinish(
118+
wideEventId = id,
119+
status = FlowStatus.Failure(reason),
120+
metadata = mapOf("last_step" to currentStep),
121+
)
122+
flowId = null
123+
}
124+
}
125+
```
126+
127+
Reference implementations:
128+
- `subscriptions/subscriptions-impl/.../SubscriptionPurchaseWideEvent.kt` — multi-step purchase flow with intervals
129+
- `app/src/main/java/com/duckduckgo/app/browser/pageload/PageLoadWideEvent.kt` — per-tab tracking with `ConcurrentHashMap`
130+
131+
---
132+
133+
## How Events Are Sent
134+
135+
Wide events are persisted to a Room database and sent asynchronously by `CompletedWideEventsProcessor` (a `MainProcessLifecycleObserver`). They are sent via two transports controlled by the `wideEvents` remote feature flag:
136+
- As pixels: `wide_{name}_c` (count) and `wide_{name}_d` (daily)
137+
- Via a dedicated POST endpoint (internal builds only by default)
138+
139+
You don't need to think about transport — just call `flowFinish` and the infrastructure handles the rest.
140+
141+
---
142+
143+
## Feature Flag
144+
145+
Wide events are gated by the `wideEvents` remote feature (`WideEventFeature` in `statistics-impl`). Individual features should also guard their wide event calls behind their own feature flag to avoid sending events when a feature is disabled.

0 commit comments

Comments
 (0)