Android Documentation

Kotlin library by @duckduckgo focused on duckduckgo-android, hacktoberfest, translations. Includes documentation and AI agent context with 4.9k stars.

4,851 stars Kotlin Kotlin 1 file ยท ~26,256 tokens #android#duckduckgo#duckduckgo-android#hacktoberfest#translations
RAW Doc

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.

text
git clone --recursive https://github.com/duckduckgo/android.git

Alternatively, if you already have the project checked out, you can initialize the submodules manually.

text
git submodule update --init

Terminology

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 under
com.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, SwitchView in XML
  • style= on any ADS component; a custom style not prefixed Widget.DuckDuckGo.
  • @color/ references in XML โ€” use ?attr/daxColor* so both themes work
  • A plain View used as a divider
  • AlertDialog / MaterialAlertDialog; a raw BottomSheetDialog without the ADS style
  • background_skeleton set as the background of a SkeletonView
  • On DaxButton*: android:textStyle, android:textColor, android:textAppearance,
    android:textAllCaps, android:tint, and android:layout_height unless wrap_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 custom
BottomSheetDialog 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:

text
my-feature/
  my-feature-api/       โ† interfaces, data classes, no implementation
  my-feature-impl/      โ† implementation, UI, DI bindings
  • my-feature-impl depends on my-feature-api
  • my-feature-impl depends on other features' -api modules only โ€” never their -impl
  • settings.gradle auto-discovers modules 2 levels deep โ€” no manual include needed
  • New -impl modules must be added to app/build.gradle to enter the Dagger graph
  • UI resources (layouts, drawables, strings) live inside the -impl module, not a separate UI module
  • String resource files are named by feature: strings-my-feature.xml (not strings.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 Fragment directly โ€” use DuckDuckGoFragment
  • lifecycleScope inside a Fragment โ€” use viewLifecycleOwner.lifecycleScope
  • Registering lifecycle observers directly โ€” drive state from a ViewModel
  • retrofit.create() โ€” get the service from DI
  • NonCancellable when launching a coroutine
  • postValue() on a SingleLiveEvent
  • Underscores in a RemoteFeature name
  • Raw WebViewCompat APIs โ€” use WebViewCompatWrapper; post messages via PostMessageWrapperPlugin
  • RobolectricTestRunner in @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

kotlin
// 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 to ActivityComponent, so it can access
    ActivityScope bindings such as @ActivityContext Context.
  • A Receiver or Service (ReceiverScope, ServiceScope) parents directly to AppComponent, so it can
    only access AppScope bindings โ€” there is no @ActivityContext available.
  • VpnScope also parents to AppComponent and 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:

kotlin
@ContributesBinding(ActivityScope::class)
class RealFoo @Inject constructor(
    @ActivityContext private val context: Context,
) : Foo

Never pass Context as a parameter through an interface if DI can provide it at the right scope.

App Coroutine Scope

kotlin
@AppCoroutineScope private val appScope: CoroutineScope

UI 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>:

kotlin
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:

kotlin
private var dwellJob by ConflatedJob()
dwellJob = scope.launch { /* cancels previous */ }

In a Fragment, collect on viewLifecycleOwner.lifecycleScope, never the Fragment's own
lifecycleScope โ€” the latter outlives the view and leaks collectors across view recreation.


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

kotlin
import logcat.logcat   // correct
// NOT: import com.squareup.logcat.logcat

Testing

  • 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
  • RobolectricTestRunner is 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

text
<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 changes in 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 init to start a stack on top of develop (or gh stack init ... to stack existing branches)
  • gh stack add to add a new branch on top of the current stack
  • gh stack submit to push all branches and create or update their PRs โ€” pass --auto in non-interactive environments
  • gh stack sync / gh stack rebase to keep the stack up to date with the remote and develop

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:

text
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 AppComponent

FragmentScope and ViewScope factories are looked up through DaggerActivity.injectorFactoryMap.
ReceiverScope, ServiceScope and VpnScope factories are looked up through
DuckDuckGoApplication.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 -impl module, not common-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() (after inject(this)) or onDetachedFromWindow()
  • the read is inside doOnAttach { โ€ฆ } (from androidx.core.view)
  • the read is inside a lambda that only fires post-attach (click listener, flow collector that is launchIn(โ€ฆ) started inside onAttachedToWindow, post { โ€ฆ }, etc.)
  • the read is guarded by ::propertyName.isInitialized
  • the method is private and 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() read viewModel (a @Inject lateinit var) directly; RealNativeInputManager.attachWidget() called them right after addView(), before attach. Fix: wrap each body in doOnAttach { โ€ฆ }.
  • PR #8461 โ€” DuckDuckGoWebView.setContentAllowsSwipeToRefresh() read browserUiLockFeature unconditionally. Fix: if (!allowed || (::browserUiLockFeature.isInitialized && browserUiLockFeature.self().isEnabled())).
  • PR #8577 โ€” NativeInputModeWidget.applyDefaultTogglePosition() read injected state directly. Fix: wrap in doOnAttach { โ€ฆ }.

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.

kotlin
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
}
kotlin
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 var crashes โ€” injection has not run yet. By the time onCreate / onAttach runs, the read is safe.
  • ViewModel with @Inject constructor(โ€ฆ): prefer constructor-injected val for required deps. lateinit var on 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`)

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.

kotlin
// 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:

kotlin
@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.
text
# 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_dictionary and reference it by key.
  • Use structured, typed parameters. Each parameter should have a clear type (string, integer, number, boolean) and, where possible, a bounded enum of 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.
text
# 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, and suffixes fields
  • Choose triggers with 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 owners field 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 required label 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.md for how the PR itself is opened; gh pr create is 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.

kotlin
// 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):

kotlin
// 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 MyPluginPointTrigger

Contribute a plugin:

kotlin
@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:

kotlin
class Foo @Inject constructor(private val plugins: ActivePluginPoint<MyPlugin>)
// plugins.getPlugins() โ†’ only plugins whose feature flag is enabled AND isActive() == true

How the gating works at runtime:

  1. If the plugin point's own self() toggle is OFF โ†’ emptyList() immediately
  2. Otherwise, filter each plugin by its individual pluginXxx() toggle (via isActive())

Naming conventions (enforced at compile time):

Parameter Prefix Example
@ContributesActivePluginPoint.featureName pluginPoint "pluginPointMyPlugin"
@ContributesActivePlugin.featureName plugin (not pluginPoint) "pluginMyPluginImpl"
@ContributesActivePlugin.parentFeatureName pluginPoint "pluginPointMyPlugin"
  • featureName and parentFeatureName are required โ€” blank or missing values fail the build.
  • parentFeatureName must match an existing @ContributesActivePluginPoint's featureName. 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 in parentFeatureName will 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 as
values-<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:

xml

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

  1. Add an instruction only 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 the
    instruction attribute 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>
  2. Reuse copy that is already translated instead of adding a key for it. Common words like "Cancel"
    or "Done" live in the design system's strings-common-ui.xml and are translated in all 24 locales,
    so point at the existing string. A duplicate key means paying for a translation we already have.

  3. Use positional placeholders โ€” %1$s, %2$s, never bare %s. Word order differs by language, so
    translators need to be able to reorder them.

  4. Don't skip plurals. Use `` where the English has a count; quantity sets differ per language.

  5. 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>
  6. Constrained UI takes a character limit, declared above the string:

    xml
  7. Never 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:

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.

kotlin
private fun isNavigate(query: String): Boolean =
    if (queryUrlPredictor.isReady()) {
        queryUrlPredictor.classify(query) is Decision.Navigate
    } else {
        UriString.isWebUrl(query)  // fallback while native lib initialises
    }
  • Decision is a sealed interface with Navigate(url) and Search(query) โ€” both data classes.
  • isReady() is false briefly at startup while the native library loads; always guard with a
    UriString.isWebUrl fallback.
  • QueryUrlPredictor lives in browser-api and is injectable at AppScope. Decision is exported
    transitively via browser-api's api dependency on url-predictor-android.

--- METRICS ---