Technical Documentation: duckduckgo/Android
โน๏ธ Provenance: Multi-Tier Ingestion ยท CodeWiki Reference
Overview (README.md)
DuckDuckGo Android
Welcome to our android application. We are excited to engage the community in development, see CONTRIBUTING.md.
We are hiring!
DuckDuckGo is growing fast and we continue to expand our fully distributed team. We embrace diverse perspectives, and seek out passionate, self-motivated people, committed to our shared vision of raising the standard of trust online. If you are a senior software engineer capable in either iOS or Android, visit our careers page to find out more about our openings!
Building the Project
We use git submodules and so when you are checking out the app, you'll need to ensure the submodules are initialized properly. You can use the --recursive flag when cloning the project to do this.
git clone --recursive https://github.com/duckduckgo/android.gitAlternatively, if you already have the project checked out, you can initialize the submodules manually.
git submodule update --initTerminology
We have taken steps to update our terminology and remove words with problematic racial connotations, most notably the change to main branches, allow lists, and blocklists. Closed issues or PRs may contain deprecated terminology that should not be used going forward.
Contribute
Please refer to contributing.
Discuss
Contact us at https://duckduckgo.com/feedback if you have feedback, questions or want to chat. You can also use the feedback form embedded within our Mobile App - to do so please navigate to Settings and select "Leave Feedback".
License
DuckDuckGo android is distributed under the Apache 2.0 license.
.Claude Docs Android Design System (`.claude/docs/android-design-system.md`)
Android Design System (ADS)
The design system lives in android-design-system/design-system/; View components are undercom.duckduckgo.common.ui.view.*. Use ADS components instead of raw Android/Material widgets.
This rule is the component index and the constraints. For a component's API, read its class โ the
signatures are not repeated here.
What breaks the build
Lint fails the build on all of these (lint-rules/src/main/java/com/duckduckgo/lint/ui/):
- Raw
Button,TextView,SwitchViewin XML style=on any ADS component; a custom style not prefixedWidget.DuckDuckGo.@color/references in XML โ use?attr/daxColor*so both themes work- A plain
Viewused as a divider AlertDialog/MaterialAlertDialog; a rawBottomSheetDialogwithout the ADS stylebackground_skeletonset as the background of aSkeletonView- On
DaxButton*:android:textStyle,android:textColor,android:textAppearance,android:textAllCaps,android:tint, andandroid:layout_heightunlesswrap_content - On
DaxTextView:android:textAppearance,android:textAllCaps,android:tint,android:textSize,android:textStyle,android:fontFamily
Components
Pick by intent, never by style.
Buttons โ `view.button.*`
DaxButtonPrimary ยท DaxButtonSecondary ยท DaxButtonGhost ยท DaxButtonGhostAlt ยทDaxButtonDestructive ยท DaxButtonDestructiveSecondary ยท DaxButtonGhostDestructive
GhostAlt is a ghost button placed on an alternate (non-default) background. Of the destructive
variants, pick by emphasis: Destructive (filled) โ DestructiveSecondary โ GhostDestructive.
Accepted attributes: android:text, android:enabled, app:icon,app:daxButtonSize="large|small" (default small โ note the dax prefix).
Between stacked buttons use @dimen/keyline_1 (4dp), not 16dp: buttons carry 6dp insets top and
bottom, so 4+6+6 gives the intended 16dp of visual spacing.
Text โ `view.text.DaxTextView`
One component for all text. Pick app:typography by semantic role; never set size or weight.
title ยท h1 ยท h2 ยท h3 ยท h4 ยท h5 ยท body1 (default) ยท body1_bold ยท body1_mono ยทbody2 ยท body2_bold ยท button ยท caption ยท caption_allCaps ยท onboarding_title ยทonboarding_body
app:textType: primary (default) ยท secondary
Text input โ `view.text.DaxTextInput`
app:type: single_line ยท multi_line ยท password ยท form_mode ยท ip_address ยท url.app:editable toggles editability.
Set error for invalid user input only. Network and other external failures go in a Snackbar, not
the field.
List items โ `view.listitem.*`
All extend DaxListItem, which carries the shared primary/secondary text, leading/trailing icon,
and switch API. Pick by the content you have:
| Class | Use for |
|---|---|
OneLineListItem |
primary text only |
TwoLineListItem |
primary + secondary text |
SectionHeaderListItem |
section title above a group (app:showOverflowMenu) |
SettingsListItem |
settings row carrying a StatusIndicatorView |
RadioListItem |
single-selection row |
BookmarksListItem |
bookmark / favourite row |
DaxGridItem |
grid cell (favourites) |
app:leadingIconImageBackground: none ยท circular ยท rounded.
Leading-icon sizes come from IconSize: Small, Medium, Large, ExtraLarge.
Dialogs โ `view.dialog.*`
Builders taking an EventListener for button callbacks: TextAlertDialogBuilder (standard alert),StackedAlertDialogBuilder (three or more vertically stacked options), RadioListAlertDialogBuilder
(single selection).
Bottom sheets
ActionBottomSheetDialog and PromoBottomSheetDialog cover the two standard patterns. A customBottomSheetDialog must be constructed with the ADS style
(com.duckduckgo.mobile.android.R.style.Widget_DuckDuckGo_FireDialog); a BottomSheetDialogFragment
inherits it from the theme.
Other components
| Component | Notes |
|---|---|
view.DaxSwitch |
quietlySetIsChecked(state, listener) changes state without firing the listener |
view.divider.HorizontalDivider / VerticalDivider |
app:fullWidth, app:defaultPadding |
view.MenuItemView |
browser menu entries |
view.PopupMenuItemView |
popup / context menus; app:primaryTextType="destructive" |
view.SkeletonView |
loading placeholder โ must be wrapped in com.facebook.shimmer.ShimmerFrameLayout |
view.notifyme.NotifyMeView |
self-hiding prompt, shown only when system notifications are disabled |
Colors
Theme attributes, never @color/:
?attr/daxColorBackground (screen) ยท ?attr/daxColorSurface (toolbars, navigation, dialogs) ยท?attr/daxColorContainer (object containers, e.g. favicon background) ยท ?attr/daxColorPrimaryText ยท?attr/daxColorSecondaryText ยท ?attr/daxColorTertiaryText (hints, placeholders) ยท?attr/daxColorPrimaryIcon ยท ?attr/daxColorSecondaryIcon ยท ?attr/daxColorAccentBlue (accent,
CTAs) ยท ?attr/daxColorDestructive ยท ?attr/daxColorLines (dividers)
In Kotlin: context.getColorFromAttr(R.attr.daxColorPrimaryText).
In a vector drawable that must follow the theme: android:fillColor="?attr/daxColorPrimaryIcon".
Adding an icon the project doesn't have yet: read .claude/docs/icons.md โ it must be fetched from the
internal Icons repository, never invented.
Spacing
Use keylines rather than arbitrary dp. The values are here so a dp figure from a design spec can be
mapped back to the right resource:
| Resource | Value |
|---|---|
@dimen/keyline_0 |
2dp |
@dimen/keyline_1 |
4dp โ between buttons |
@dimen/keyline_2 |
8dp |
@dimen/keyline_3 |
12dp |
@dimen/keyline_4 |
16dp โ screen edge โ content |
@dimen/keyline_5 |
24dp โ dialog edge โ content |
@dimen/keyline_6 |
32dp |
@dimen/keyline_7 |
48dp |
Defined in design-system-dimensions.xml.
.Claude Docs Architecture (`.claude/docs/architecture.md`)
Android Architecture
Core Principle: Decoupling Over Everything
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.
Module Structure
Every feature follows the -api / -impl split:
my-feature/
my-feature-api/ โ interfaces, data classes, no implementation
my-feature-impl/ โ implementation, UI, DI bindingsmy-feature-impldepends onmy-feature-apimy-feature-impldepends on other features'-apimodules only โ never their-implsettings.gradleauto-discovers modules 2 levels deep โ no manualincludeneeded- New
-implmodules must be added toapp/build.gradleto enter the Dagger graph - UI resources (layouts, drawables, strings) live inside the
-implmodule, not a separate UI module - String resource files are named by feature:
strings-my-feature.xml(notstrings.xml)
Compile-time dependency rules
Enforced in the root build.gradle. Violations fail the build.
| Rule | Detail |
|---|---|
| API modules cannot use Anvil | No com.squareup.anvil plugin in -api modules |
| API modules cannot depend on Dagger | Except :feature-toggles-api and :settings-api |
| API modules cannot depend on other APIs | Except :feature-toggles-api, :navigation-api, :js-messaging-api |
API modules cannot depend on :di |
DI wiring belongs in -impl |
Only :app depends on -impl modules |
Features communicate through -api only |
-internal modules use internalImplementation |
Excluded from non-internal builds (Play, F-Droid) |
No KAPT anywhere (except :app) |
Use KSP for annotation processing |
No strings.xml outside :app |
Use strings-<feature>.xml instead |
| Android tests restricted | Only in: app, sync-lib, httpsupgrade-impl, pir-impl, feature-toggles-impl |
No module can depend on :app |
App is the composition root |
Conventions enforced by lint
The lint-rules module fails the build on these (UI/design-system rules are covered separately):
- Hardcoded coroutine dispatchers โ inject them instead
@Singletonโ use@SingleInstanceIn- Extending
Fragmentdirectly โ useDuckDuckGoFragment lifecycleScopeinside a Fragment โ useviewLifecycleOwner.lifecycleScope- Registering lifecycle observers directly โ drive state from a ViewModel
retrofit.create()โ get the service from DINonCancellablewhen launching a coroutinepostValue()on aSingleLiveEvent- Underscores in a
RemoteFeaturename - Raw
WebViewCompatAPIs โ useWebViewCompatWrapper; post messages viaPostMessageWrapperPlugin RobolectricTestRunnerin@RunWith
Dependency Injection (Anvil / Dagger)
Scopes
| Scope | Use for |
|---|---|
AppScope |
Singletons that live for the app lifetime |
ActivityScope |
Things scoped to a single Activity (gets activity context) |
FragmentScope |
ViewModels and things scoped to a Fragment |
ViewScope |
Custom views that need injected dependencies |
ReceiverScope |
BroadcastReceiver implementations |
ServiceScope |
Service implementations |
VpnScope |
VPN-process-specific services and receivers |
Use @SingleInstanceIn(AppScope::class) โ not @Singleton (javax). @Singleton conflicts with AppComponent's scope.
Common annotations
// Singleton binding
@SingleInstanceIn(AppScope::class)
@ContributesBinding(AppScope::class)
class RealFoo @Inject constructor(...) : Foo
// Override an existing binding (higher rank wins)
@ContributesBinding(AppScope::class, rank = 1)
// ViewModel
@ContributesViewModel(FragmentScope::class)
class FooViewModel @Inject constructor(...) : ViewModel()
// Plugin contribution (multibinding)
@ContributesMultibinding(AppScope::class)
class MyPlugin @Inject constructor(...) : SomePlugin
// Remote feature flag
@ContributesRemoteFeature(scope = AppScope::class, featureName = "myFeature")
interface MyFeature : Feature {
@Toggle.DefaultValue(DefaultFeatureValue.INTERNAL)
fun myToggle(): Toggle
}DefaultFeatureValue.INTERNAL = enabled only in internal/debug builds.
What each scope can reach
- A Fragment or View (
FragmentScope,ViewScope) parents toActivityComponent, so it can accessActivityScopebindings such as@ActivityContext Context. - A Receiver or Service (
ReceiverScope,ServiceScope) parents directly toAppComponent, so it can
only accessAppScopebindings โ there is no@ActivityContextavailable. VpnScopealso parents toAppComponentand is only for the VPN secondary process.
For the component diagram and for diagnosing "could not find dagger component" crashes, read.claude/docs/dagger-scopes.md.
Activity Context
@ActivityContext Context and AppCompatActivity are provided at ActivityScope via DaggerActivityScopedModule. Inject them with:
@ContributesBinding(ActivityScope::class)
class RealFoo @Inject constructor(
@ActivityContext private val context: Context,
) : FooNever pass Context as a parameter through an interface if DI can provide it at the right scope.
App Coroutine Scope
@AppCoroutineScope private val appScope: CoroutineScopeUI Patterns
Activity and Fragment base classes
Activities extend DuckDuckGoActivity, fragments extend DuckDuckGoFragment (lint enforces the
latter). These are not optional conveniences: they are the DaggerActivity/DaggerFragment
subclasses carrying@HasMemberInjections, so a screen that extends AppCompatActivity or Fragment directly gets no
member injection and no viewModelFactory. DuckDuckGoActivity additionally applies the stored
theme, listens for theme changes, and handles edge-to-edge setup.
Override applyFireTheme to true only on activities whose look should follow the fire-mode theme;
those must inject BrowserMode to compute it.
ViewModels
Commands are emitted via a Channel<Command>:
private val _commands = Channel<Command>(Channel.BUFFERED)
val commands: Flow<Command> = _commands.receiveAsFlow()State is StateFlow derived via combine + stateIn.
Coroutine Jobs
Prefer ConflatedJob over a raw Job variable or a Map<Key, Job> when you need to cancel-and-replace a running job:
private var dwellJob by ConflatedJob()
dwellJob = scope.launch { /* cancels previous */ }In a Fragment, collect on viewLifecycleOwner.lifecycleScope, never the Fragment's ownlifecycleScope โ the latter outlives the view and leaks collectors across view recreation.
Navigation
Navigate between activities with GlobalActivityStarter (from navigation-api). Never construct or
start an Intent directly โ the starter keeps navigation decoupled across modules.
Do not use startIntent() + launcher.launch(intent) โ use startForResult() instead.startIntent() returns a nullable Intent and launch(null) crashes.
Registering a screen, the overloads and deeplink support: .claude/docs/navigation.md.
Deciding whether typed input is a URL or a search query: .claude/docs/url-classification.md
(UriString.isWebUrl() is not the answer).
Logging
import logcat.logcat // correct
// NOT: import com.squareup.logcat.logcatTesting
- JUnit4 (
@Test, not JUnit5/Jupiter) - Assertions:
org.junit.Assert.* - Mocking:
org.mockito.kotlin.mock()+whenever() - Coroutines:
CoroutineTestRule+runTest { } - Test files mirror the class:
RealFoo.ktโRealFooTest.kt - No coroutine test setup needed for pure logic classes
RobolectricTestRunneris banned in@RunWith(lint)
UI tests are Maestro flows under .maestro/, grouped by feature area; the conventions for writing and
running them are in .claude/rules/maestro-ui-tests.md.
.Claude Docs Contributions (`.claude/docs/contributions.md`)
Contributions
Branch Naming
<purpose>/<git-username>/<feature-name>- purpose:
feature,fix,refactor,chore,poc, etc. - name: the author's name or alias
- feature-name: kebab-case description
Examples: feature/jsmith/duck-ai-url-routing, fix/jsmith/bookmark-crash
Commit Messages
Use the imperative mood in the subject line (e.g. "Add feature X", not "Added feature X"). Keep the subject under 72 characters. Add a blank line before the body if more context is needed. Reference the Asana task URL in the body.
PR body
Read @.github/PULL_REQUEST_TEMPLATE.md and use it as the PR body exactly โ do not skip or reorder sections, do not add content above the first line of the template.
Fill in the template sections as follows:
- Task/Issue URL: the Asana task URL
- Description: summarise what was changed and why
- Steps to test: reviewer-facing steps per scenario; specific enough that someone unfamiliar with the code can follow them
- UI changes: before/after screenshots or recordings; write
No UI changesin both columns for pure logic/backend changes
Tech Design
Larger or cross-cutting changes โ work that spans multiple modules or APIs, introduces a new integration, or otherwise can't be captured by a single self-contained API proposal โ should also be backed by a tech design document. The tech design covers the overall approach; the API proposals cover each public surface it introduces. A big feature often has one tech design and several API proposals.
Surface this early. When you first scope a change, judge whether it's cross-cutting like this. If it is, tell the user up front that a tech design will be needed, so it can be written and reviewed before implementation โ don't wait until PR time to raise it.
Before opening a PR for a change of this size, ask the user for the tech design link and add it to the Tech Design URL field of the PR body โ do not open the PR unless the tech design exists and is linked, or the user explicitly confirms that no tech design is needed. Small, self-contained changes don't need one; leave the field blank.
API Proposals
Any change to a public API โ a .kt/.java file under a *-api module's src/main โ must be accompanied by an approved API Proposal. -api modules are the contract other modules depend on, so the team reviews the public surface before it ships.
Surface this early. When you first scope a change, check whether it will touch a -api public surface. If it will, tell the user up front that an approved API Proposal is required, so it can be started before implementation โ don't wait until PR time to raise it.
Before opening a PR, check whether the changes touch a public API (any .kt/.java file under a *-api module's src/main). If they do, stop and explicitly ask the user for the API Proposal link(s) โ do not open the PR until they provide them. The proposal should exist and be linked before the PR goes up. If the user confirms the change does not alter the public surface (e.g. KDoc, comments, tests, dependency bumps), record None instead.
An API Proposal is an Asana task in the Android Proposals project.
In the PR: list the proposal link(s) in the API Proposals field of the PR body โ one per line; a single PR may carry more than one. Use None for the no-public-API-change case described above.
Creating PRs
This repository uses GitHub stacked PRs (gh stack) for stacked work.
gh stack initto start a stack on top ofdevelop(orgh stack init ...to stack existing branches)gh stack addto add a new branch on top of the current stackgh stack submitto push all branches and create or update their PRs โ pass--autoin non-interactive environmentsgh stack sync/gh stack rebaseto keep the stack up to date with the remote anddevelop
For a single PR that isn't part of a stack, push the branch and open the PR with gh api repos/duckduckgo/Android/pulls --method POST โ do not use gh pr create, which fails with a Projects Classic deprecation warning.
.Claude Docs Dagger Scopes (`.claude/docs/dagger-scopes.md`)
Dagger scope hierarchy and injection crashes
The hierarchy determines what dependencies each scope can access, where its factory lookup happens,
and which HasDaggerInjector performs the injection. It is encoded implicitly in the Anvil-generated_SubComponent files โ this diagram is the source of truth:
DuckDuckGoApplication [HasDaggerInjector]
โโโ AppComponent [AppScope]
โ Factory map contains: ActivityComponent.Factory, ReceiverSubComponent factories,
โ ServiceSubComponent factories, VpnScope factories
โ
โโโ ActivityComponent [ActivityScope] โ subcomponent of AppComponent
โ โ Factory map contains: FragmentSubComponent factories, ViewSubComponent factories
โ โ Provided bindings: @ActivityContext Context, AppCompatActivity
โ โ
โ โโโ EachFragment_SubComponent [FragmentScope] โ subcomponent of ActivityComponent
โ โโโ EachView_SubComponent [ViewScope] โ subcomponent of ActivityComponent
โ
โโโ EachReceiver_SubComponent [ReceiverScope] โ subcomponent of AppComponent
โโโ EachService_SubComponent [ServiceScope] โ subcomponent of AppComponentFragmentScope and ViewScope factories are looked up through DaggerActivity.injectorFactoryMap.ReceiverScope, ServiceScope and VpnScope factories are looked up throughDuckDuckGoApplication.injectorFactoryMap.
The parent scope follows from the scope passed to @InjectWith; Anvil generates the ParentComponent
and its @ContributesTo. You never set it manually.
Debugging "could not find dagger component"
- Crash in a Fragment/View injection โ check
DaggerActivity.injectorFactoryMap; the class is missing@InjectWith(FragmentScope::class)or@InjectWith(ViewScope::class). - Crash in a Receiver/Service injection โ check
DuckDuckGoApplication.injectorFactoryMap; the class
is missing@InjectWith(ReceiverScope::class)or@InjectWith(ServiceScope::class).
Member injection into a transitively-typed field
Anvil reporting Couldn't resolve ClassReference โฆ File is unknown for an @Inject field means the
field's type is only available transitively. Add the module that declares it as a direct dependency.
.Claude Docs Icons (`.claude/docs/icons.md`)
Adding an icon
Ask the user for the icon name, then fetch it from the internal DDG Icons repository at
https://dub.duckduckgo.com/duckduckgo/Icons. Do not guess icon names and do not skip this step โ
inventing a name produces an asset that doesn't match the design system.
- Keep the SVG file name as close to the original as possible, and import it as a vector asset
- An icon used by a single feature belongs in that feature's
-implmodule, notcommon-ui - For an icon that must follow the theme, set
android:fillColor="?attr/daxColorPrimaryIcon"
.Claude Docs Lateinit Hazards (`.claude/docs/lateinit-hazards.md`)
`lateinit var` initialization hazards
Reading a lateinit var before it has been assigned throws UninitializedPropertyAccessException at runtime. There is no compile-time check โ only review and runtime crashes catch it. When writing or reviewing code that declares or reads a lateinit var, walk through these cases.
View subclasses with `@Inject lateinit var`
A View annotated with @InjectWith(ViewScope::class) injects its @Inject lateinit var members inside onAttachedToWindow(), via AndroidSupportInjection.inject(this). The fields are uninitialized between construction and attach.
The trap: a parent or manager class can call a public/internal/override method on the view immediately after addView(โฆ) โ before the view tree is attached. If that method reads an @Inject lateinit var directly, it crashes.
When reviewing a View subclass, treat every public / internal / override method that reads an @Inject lateinit var as suspect. It is safe only if at least one is true:
- the read happens in
onAttachedToWindow()(afterinject(this)) oronDetachedFromWindow() - the read is inside
doOnAttach { โฆ }(fromandroidx.core.view) - the read is inside a lambda that only fires post-attach (click listener, flow collector that is
launchIn(โฆ)started insideonAttachedToWindow,post { โฆ }, etc.) - the read is guarded by
::propertyName.isInitialized - the method is
privateand every call site is one of the above (verify by checking each caller โ do not assume)
When in doubt, the standard fix is to wrap the body in doOnAttach { โฆ }. It runs immediately if the view is already attached, and defers otherwise โ same behaviour as before in the common case, no crash in the edge case.
Real-world bugs caused by missing this:
- PR #8424 โ
NativeInputModeWidget.configure()/configureContextual()/setWidgetPosition()readviewModel(a@Inject lateinit var) directly;RealNativeInputManager.attachWidget()called them right afteraddView(), before attach. Fix: wrap each body indoOnAttach { โฆ }. - PR #8461 โ
DuckDuckGoWebView.setContentAllowsSwipeToRefresh()readbrowserUiLockFeatureunconditionally. Fix:if (!allowed || (::browserUiLockFeature.isInitialized && browserUiLockFeature.self().isEnabled())). - PR #8577 โ
NativeInputModeWidget.applyDefaultTogglePosition()read injected state directly. Fix: wrap indoOnAttach { โฆ }.
Construction-time reads in any class
Independent of class type โ even a plain Kotlin class with no DI involvement โ a lateinit var is also uninitialized during the primary constructor's run, which includes:
- property initializers (
val x = lateinitProp.foo()) init { โฆ }blocks- secondary constructor bodies that delegate to the primary
If a property initializer or init block reads a lateinit var before any earlier init block or initializer has assigned it, that read crashes. The order is source declaration order, not visual placement of the lateinit var declaration.
class Foo {
lateinit var bar: String
val length: Int = bar.length // โ crash โ bar not assigned yet
init { bar = "x" } // assignment runs after the val above
}class Foo {
lateinit var bar: String
init { bar = "x" } // assigns first
val length: Int = bar.length // โ safe โ bar already assigned
}For Fragments, Activities, Services, ViewModels and other framework-managed classes, the construction-time hazard still applies even though their regular method bodies are safe (those run post-injection in onAttach / onCreate). Concretely:
- Fragment/Activity: an
init { โฆ }or property initializer reading an@Inject lateinit varcrashes โ injection has not run yet. By the timeonCreate/onAttachruns, the read is safe. - ViewModel with
@Inject constructor(โฆ): prefer constructor-injectedvalfor required deps.lateinit varon a ViewModel is a smell โ if you see it, ask why it isn't a constructor parameter.
The universal escape hatch โ if (::propertyName.isInitialized) { โฆ } โ works for any class type. Reach for it only when the access really is optional. If the property is required, fix the initialization order instead, don't paper over it with the guard.
.Claude Docs Navigation (`.claude/docs/navigation.md`)
Navigating between screens
Navigation goes through GlobalActivityStarter (from navigation-api) so screens stay decoupled
across modules.
Registering a screen
Define an ActivityParams type and annotate the activity. Anvil codegen generates the mapper.
// In the feature's -api module
data class MyScreenParams(val id: String) : GlobalActivityStarter.ActivityParams
// In the feature's -impl module
@ContributeToActivityStarter(MyScreenParams::class)
class MyActivity : DuckDuckGoActivity() {
private val params by lazy { intent.getActivityParams(MyScreenParams::class.java) }
}Use screenName to opt into deeplink support:
@ContributeToActivityStarter(MyScreenParams::class, screenName = "myScreen")Choosing the overload
For ActivityParams:
| Situation | Use |
|---|---|
| Fire and forget | globalActivityStarter.start(context, params) |
| Need a result back | globalActivityStarter.startForResult(context, params, launcher) |
Need the raw Intent (e.g. PendingIntent for a notification) |
globalActivityStarter.startIntent(context, params) |
Each takes an optional trailing options: Bundle?, and each has a DeeplinkActivityParams
counterpart for deeplink entry points.
FLAG_ACTIVITY_NEW_TASK is added automatically when context is not an Activity (Services,
broadcast receivers, JS message handlers, etc.) โ do not add it manually.
.Claude Docs Pixels (`.claude/docs/pixels.md`)
Pixels
Pixels are lightweight telemetry events sent via HTTP with a name and optional parameters. They are used for:
- Feature usage events (e.g., button clicks, screen impressions, toggle changes)
- Error monitoring (e.g., network failures, parsing errors, crash reports)
- Conversion and retention (e.g., subscription purchase, activation, onboarding completion)
The privacy invariants every pixel must satisfy (no PII, no URLs, no correlation IDs, bucketed numbers,
bounded enums) are in CLAUDE.md and apply to all outbound data โ they are not repeated here.
Types of Pixels
Standard Pixels
Sent every time the event occurs. Use for events where per-occurrence volume matters.
Daily Pixels
Sent at most once per calendar day per event. Use to measure the number of unique users affected by something (e.g., how many users hit a particular error per day). Most daily pixel implementations also support a "count" variant that fires every time.
When a pixel fires as a plain count+daily pair with identical parameters, register it as one
definition entry using the first_daily_count suffix rather than two separate _count/_daily
entries โ see .claude/rules/pixel-definitions.md. The firing code is unchanged either way: it still
calls pixel.fire() twice with the explicit _count/_daily wire-string names.
Unique Pixels
Sent once per install for the lifetime of the install. Use for one-time lifecycle events (e.g., first activation, first use of a feature).
Pixel Naming
- Use underscores or hyphens as word separators. Be consistent within a feature area.
- Use clear, descriptive names. Anyone reading the pixel name should understand what it represents without additional context. Avoid cryptic abbreviations.
- Group related pixels with a common prefix (e.g.,
burn_started,burn_completed,burn_error). - Embed dynamic data in parameters, not pixel names. Pixel names should be static string literals. Use query parameters for variable data.
# Good โ static name with parameter
pixel: "burn_error", parameters: { "operation": "delete", "exceptionType": "IOException" }
# Bad โ dynamic data in pixel name
pixel: "burn_error_delete_IOException"Pixel Parameters
- Prefer shared dictionary entries. If a parameter is used by multiple pixels, define it in
params_dictionaryand reference it by key. - Use structured, typed parameters. Each parameter should have a clear type (
string,integer,number,boolean) and, where possible, a boundedenumof allowed values. - Include error context for error pixels. When a pixel reports an error, include enough information to diagnose the issue โ error type, error code, failing step โ without leaking PII.
- Bucket all numeric values. Durations, counts, sizes, and other continuous values must be bucketed into ranges.
# Good โ bucketed duration
"duration_bucket": "1_to_5_min"
# Bad โ exact value
"duration_ms": "237841"Pixel Definition Files
Every pixel fired in code must have a corresponding entry in a definition file under PixelDefinitions/pixels/definitions/. Without this, the pixel validation CI check will fail.
- Find the appropriate definition file for your feature area (or create a new one)
- Add an entry keyed by the full pixel name
- Include
description,owners,triggers,parameters, andsuffixesfields - Choose
triggerswith the decision procedure in.claude/rules/pixel-definitions.mdโ classify by
the event that causes the pixel to fire, and never default to["other"] - The
ownersfield must contain the author's GitHub username - Match the format of existing entries in the same file
For the definition-file schema, the dictionaries, and the local validation commands, read.claude/rules/pixel-definitions.md.
Privacy Review & Triage
Any PR that adds a new pixel or modifies the pixel registry (anything under PixelDefinitions/pixels โ pixel definitions, wide-event definitions, and the shared params/suffixes dictionaries) must go through privacy triage. The mechanism is a single label:
- Apply the
privacy review requiredlabel to the PR. This automatically creates a Privacy Triage task in Asana and adds the PR author as a collaborator; the Privacy Triage DRI follows up there. Applying the label is how the triage is created โ there is no separate manual step. - When an agent creates or helps create a pixel PR, it MUST add this label when opening the PR โ apply it after creating the PR with
gh pr edit <number> --add-label "privacy review required"(see.claude/docs/contributions.mdfor how the PR itself is opened;gh pr createis not used in this repo). If the agent cannot set labels, it must say so explicitly so the author applies it. This is in addition to updating the pixel definitions โ both are required, do not skip either. - Self-service still applies: you may proceed with implementation without blocking on triage where appropriate, but the label must still be applied so triage happens.
Modifying an existing pixel that isn't in the registry yet: first document the old pixel in the registry. If the change is small, note in the PR/code which parts are new; if it's large, split into two commits/PRs (one documenting the already-approved existing pixel, one for the change). If a prior triage is known, record it in the pixel's privacyReview property.
Full process: Pixels Triage using the Pixel Registry.
When to Use a Wide Event Instead
Use a wide event rather than a pixel when:
- A user journey has multiple steps that can succeed or fail independently
- You need to understand where users drop off or encounter errors in a multi-step process
- The outcome of earlier steps affects the interpretation of later steps
Use both when a wide event makes sense but the journey spans a long time period where waiting for completion would delay monitoring.
For designing and implementing wide events, read .claude/rules/wide-events.md.
.Claude Docs Plugin System (`.claude/docs/plugin-system.md`)
Plugin System
Two kinds of plugin points exist. Pick based on whether you need remote feature flag control.
`@ContributesPluginPoint` โ basic
PluginPoint<T> โ Dagger multibinding under the hood. Returns all registered plugins, no runtime filtering.
// Declare (in -api or -impl module)
@ContributesPluginPoint(AppScope::class)
interface MyPlugin { fun doThing() }
// Contribute
@ContributesMultibinding(AppScope::class)
class MyPluginImpl @Inject constructor() : MyPlugin
// Contribute with explicit ordering (lower value = higher priority)
@ContributesMultibinding(AppScope::class)
@PriorityKey(100)
class MyPluginImpl @Inject constructor() : MyPlugin
// Consume
class Foo @Inject constructor(private val plugins: PluginPoint<MyPlugin>)
// plugins.getPlugins() โ all plugins, in priority order if @PriorityKey is used`@ContributesActivePluginPoint` โ with remote feature flags + codegen
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.
Plugin point must be declared on a private interface (the codegen is the only consumer):
// The plugin interface must extend ActivePlugin
interface MyPlugin : ActivePlugin { fun doThing() }
// Declared with a private trigger interface (in -impl)
@ContributesActivePluginPoint(
scope = AppScope::class,
boundType = MyPlugin::class,
featureName = "pluginPointMyPlugin", // required, must start with "pluginPoint"
)
private interface MyPluginPointTriggerContribute a plugin:
@ContributesActivePlugin(
scope = AppScope::class,
boundType = MyPlugin::class,
featureName = "pluginMyPluginImpl", // required, must start with "plugin" (not "pluginPoint")
parentFeatureName = "pluginPointMyPlugin", // required, must match an existing plugin point's featureName
)
class MyPluginImpl @Inject constructor() : MyPlugin {
// isActive() is generated โ backed by its own remote feature flag
}Consume:
class Foo @Inject constructor(private val plugins: ActivePluginPoint<MyPlugin>)
// plugins.getPlugins() โ only plugins whose feature flag is enabled AND isActive() == trueHow the gating works at runtime:
- If the plugin point's own
self()toggle is OFF โemptyList()immediately - Otherwise, filter each plugin by its individual
pluginXxx()toggle (viaisActive())
Naming conventions (enforced at compile time):
| Parameter | Prefix | Example |
|---|---|---|
@ContributesActivePluginPoint.featureName |
pluginPoint |
"pluginPointMyPlugin" |
@ContributesActivePlugin.featureName |
plugin (not pluginPoint) |
"pluginMyPluginImpl" |
@ContributesActivePlugin.parentFeatureName |
pluginPoint |
"pluginPointMyPlugin" |
featureNameandparentFeatureNameare required โ blank or missing values fail the build.parentFeatureNamemust match an existing@ContributesActivePluginPoint'sfeatureName. This is validated at compile time across modules (including sibling modules that don't depend on each other) via a sentinel/deferred-marker mechanism. A typo inparentFeatureNamewill fail the build.
All flags default to TRUE so newly contributed plugins are on by default and can be killed remotely.
.Claude Docs Translations (`.claude/docs/translations.md`)
Translations
The app ships in 24 languages. Translation is automated: a Smartling repository connector reads
English strings from branches, and commits the translated files back to the same branch.
smartling-config.json (repo root) is the source of truth for the locale list and the file mapping:
every **/values/strings*.xml is sent for translation and comes back asvalues-<locale>/strings*.xml.
Where strings go
| File | Translated? |
|---|---|
<module>/src/main/res/values/strings-<module>.xml |
yes โ picked up by the connector |
<module>/src/main/res/values/donottranslate.xml |
no โ excluded |
Translatable string files must be named strings-<module>.xml (app uses strings.xml), and must
carry both Smartling directives above the <resources> element โ the MissingSmartlingRequiredDirectives
lint rule fails the build otherwise:
During development, add new strings to donottranslate.xml. Move them into the real strings file
only when the copy is final and you're ready to request translations: pushing half-finished copy costs
days, since each round trip through Smartling takes a few.
Rules for translatable strings
Add an
instructiononly where the string isn't self-evident โ always for a string with a
placeholder, and for copy whose meaning depends on the screen around it. Translators see theinstructionattribute and nothing else, not the surrounding code and not the screen. Plain
unambiguous copy ("Settings", "Done") needs none; don't add one for its own sake.xml<string name="authenticationDialogMessage" instruction="Placeholder is the name of a website">%1$s requires a username and password.</string>Reuse copy that is already translated instead of adding a key for it. Common words like "Cancel"
or "Done" live in the design system'sstrings-common-ui.xmland are translated in all 24 locales,
so point at the existing string. A duplicate key means paying for a translation we already have.Use positional placeholders โ
%1$s,%2$s, never bare%s. Word order differs by language, so
translators need to be able to reorder them.Don't skip plurals. Use `` where the English has a count; quantity sets differ per language.
Entity escaping is off globally. A string that needs escaping has to flip the directive around
itself, and flip it back afterwards:xml<string name="example">My escaped string here</string>Constrained UI takes a character limit, declared above the string:
xmlNever put lint annotations inside a translated string โ the next translation job overwrites the
file and deletes them. Fix the underlying issue, or record the check in the lint baseline instead.
Prefer whole sentences with placeholders over sentence fragments concatenated in code: languages
reorder and inflect, and a fragment gives the translator nothing to work with.
Changing strings that are already translated
| Goal | What to do |
|---|---|
| Add a string | Add the English string to the strings file |
| Change English copy | Delete the old key, add a new key with the new copy, update references. Leave the other languages alone โ Smartling prunes them |
| Remove a string | Delete the English string only. Smartling removes the translations |
| Fix a bad translation | A developer edits it in the Smartling dashboard (DuckDuckGo Android project โ language โ search the key โ Edit Translation). It lands with the next job; to ship sooner, also edit the values-<locale> file directly |
Reusing a key with different copy leaves every locale holding a stale translation of the old text,
which is why an English change is delete-plus-new-key rather than an edit in place.
Fixing a translation is the one job that happens outside the repo โ it needs a Smartling login, so an
agent cannot do it and should hand it to the developer.
Reference
This file is the working reference โ enough to add a string or request a translation without opening
anything else. The Asana sources are the authority behind it; read one only when this file is silent or
looks wrong, not up front:
- Android Smartling Translation Guide โ the connector, branch naming, the Smartling rules
- Typical Development Flow and FAQs โ an add/change/remove case this file doesn't cover
- Tips for better translation tokens โ writing copy translators can work with
- How to Use Smartling for Translations โ Smartling account setup, for a developer who needs dashboard access
When translations stall or a translation looks wrong, ask the
Translation/Localization AOR โ people to
talk to, not a document to read.
.Claude Docs Url Classification (`.claude/docs/url-classification.md`)
Deciding whether a string is a URL or a search query
Use QueryUrlPredictor (from browser-api). Do not use UriString.isWebUrl() for the decision โ
its regex is too permissive, e.g. bbc.comcomcomcom passes.
private fun isNavigate(query: String): Boolean =
if (queryUrlPredictor.isReady()) {
queryUrlPredictor.classify(query) is Decision.Navigate
} else {
UriString.isWebUrl(query) // fallback while native lib initialises
}Decisionis a sealed interface withNavigate(url)andSearch(query)โ both data classes.isReady()is false briefly at startup while the native library loads; always guard with aUriString.isWebUrlfallback.QueryUrlPredictorlives inbrowser-apiand is injectable atAppScope.Decisionis exported
transitively viabrowser-api'sapidependency onurl-predictor-android.
--- METRICS ---
- Files Extracted: 12
- Estimated Token Budget: ~11200 tokens
- Canonical Reference: https://codewiki.google/github.com/duckduckgo/Android