Skip to content

Latest commit

 

History

History
328 lines (228 loc) · 10.2 KB

File metadata and controls

328 lines (228 loc) · 10.2 KB

CLAUDE.md

This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository.

Project Overview

This is an Android SDK library for collecting device data and sending it to MaxMind servers. The project uses Kotlin with Java compatibility (@JvmStatic, @JvmOverloads) and is designed to be published to Maven Central.

Key Design Principles:

  • Kotlin-first with explicit API mode (-Xexplicit-api=strict)
  • Java compatibility for broader adoption
  • Coroutine-based async operations with callback alternatives
  • Singleton pattern with initialization guard
  • Builder pattern for configuration

Naming Conventions

Follow Kotlin conventions (kotlinlang.org/docs/coding-conventions.html):

  • 2-letter acronyms: ALL CAPS (ID, OS)
  • 3+ letter acronyms: First letter only (Gpu, Drm, Api, Sdk, Cpu, Dpi)

Build Commands

Core Development

# Build SDK library only (fastest for SDK development)
./gradlew :device-sdk:assemble

# Build debug variants (no minification, so faster)
./gradlew assembleDebug

# Build SDK library with all variants
./gradlew :device-sdk:build

# Install sample app to connected device
./gradlew :sample:installDebug

Testing

# Run unit tests for SDK. Under AGP 9 this runs the suite once, against the
# debug variant: AGP 9 no longer registers testReleaseUnitTest.
./gradlew :device-sdk:test

# Run specific test class
./gradlew :device-sdk:test --tests "com.maxmind.device.DeviceTrackerTest"

Code Quality

# Run all quality checks
./gradlew detekt ktlintCheck

# Auto-fix formatting issues
./gradlew ktlintFormat

# Generate API documentation
./gradlew :device-sdk:dokkaGenerate
# Output: device-sdk/build/dokka/

Pre-commit Formatting

This project uses precious for pre-commit hooks. Before committing, run:

# Tidy all staged files (fixes formatting issues)
precious tidy -g

# Then stage the tidied files and commit
git add -u && git commit

If a commit fails due to formatting, run precious tidy -g and retry.

Publishing

See README.dev.md for the full release process. For manual publishing:

# Publish to Maven Central via Central Portal (requires credentials in local.properties)
./gradlew :device-sdk:publishAndReleaseToMavenCentral

Architecture

SDK Entry Point Pattern

The SDK uses a singleton pattern with initialization guard:

  1. DeviceTracker - Main singleton entry point

    • Private constructor prevents direct instantiation
    • initialize(Context, SdkConfig) must be called first
    • getInstance() returns the initialized instance or throws
    • isInitialized() checks initialization state
  2. Lifecycle Management

    • Stores applicationContext (not activity context)
    • Creates coroutine scope with SupervisorJob + Dispatchers.IO
    • shutdown() cancels scope and closes HTTP client
    • Automatic collection runs in background if collectionIntervalMs > 0

Component Architecture

Four-layer architecture:

  1. Public API Layer (DeviceTracker.kt)

    • Singleton facade pattern
    • Both suspend functions and callback-based methods
    • Returns Result<TrackingResult> containing tracking token
    • Example: collectAndSend() (suspend) and collectAndSend(callback) (callbacks)
  2. Configuration Layer (config/SdkConfig.kt)

    • Immutable configuration with builder pattern
    • SdkConfig.Builder validates inputs in build()
    • Default servers: d-ipv6.mmapiws.com and d-ipv4.mmapiws.com (dual-request flow)
  3. Data Collection Layer (collector/DeviceDataCollector.kt)

    • Collects device information via Android APIs
    • Uses WindowManager for display metrics
    • Returns DeviceData serializable model
  4. Network Layer (network/DeviceApiClient.kt)

    • Ktor HTTP client with Android engine
    • kotlinx.serialization for JSON
    • Optional logging based on enableLogging config
    • Returns Result<ServerResponse> internally for error handling

    Dual-Request Flow (IPv6/IPv4): To capture both IP addresses for a device, the SDK uses a dual-request flow:

    1. First request sent to d-ipv6.mmapiws.com/device/android
    2. If response contains ip_version: 6, a second request is sent to d-ipv4.mmapiws.com/device/android
    3. The IPv4 request is fire-and-forget (failures don't affect the result)
    4. The stored ID from the IPv6 response is returned and persisted

    If a custom server URL is configured via SdkConfig.Builder.serverUrl(), the dual-request flow is disabled and only a single request is sent.

Terminology: Stored ID vs Tracking Token

These are two names for related but distinct concepts:

  • Stored ID (stored_id): The server-generated identifier returned in the API response and persisted locally via StoredIDStorage. Used internally throughout the SDK (model classes, storage, network layer, comments, logs).
  • Tracking token (trackingToken): The value exposed to SDK consumers via TrackingResult.trackingToken, intended for passing to the minFraud API's /device/tracking_token field.

Today they happen to be the same value, but the abstraction exists so the public-facing token format can change independently of the internal stored ID. Use "stored ID" in internal code and "tracking token" only in public API surfaces.

Data Model

DeviceData (model/DeviceData.kt):

  • Internal data class marked with @Serializable for kotlinx.serialization
  • Immutable with default values for optional fields

Java Compatibility Strategy

When adding new public APIs:

  1. Use @JvmStatic for static/companion methods

    companion object {
        @JvmStatic
        fun initialize(context: Context, config: SdkConfig): DeviceTracker
    }
  2. Use @JvmOverloads for optional parameters

    @JvmOverloads
    public fun collectAndSend(callback: ((Result<TrackingResult>) -> Unit)? = null)
  3. Provide callback-based alternatives to suspend functions

    // Suspend function for Kotlin
    suspend fun collectAndSend(): Result<TrackingResult>
    
    // Callback version for Java
    fun collectAndSend(callback: (Result<TrackingResult>) -> Unit)
  4. Use explicit visibility modifiers

    • All public APIs must have public keyword (enforced by -Xexplicit-api=strict)

Dependency Management

All dependencies are centralized in gradle/libs.versions.toml:

To update a dependency:

  1. Edit version in gradle/libs.versions.toml
  2. Sync Gradle
  3. Run ./gradlew :device-sdk:build to verify

ProGuard/R8 Configuration

The SDK includes consumer ProGuard rules in consumer-rules.pro:

  • Keeps public SDK API classes (DeviceTracker, SdkConfig, TrackingResult)
  • Keeps kotlinx.serialization classes
  • Apps using this SDK automatically inherit these rules

Environment Setup

Quick setup with mise (recommended for headless environments):

mise install        # Installs Java 21, Android SDK cmdline-tools, etc.
mise run setup      # Accepts licenses, installs platform packages, creates local.properties

Manual setup:

  1. Java 21. The version is pinned by mise.toml, not by the build scripts — there is no org.gradle.java.home in gradle.properties. Without mise, put a Java 21 JDK on JAVA_HOME.

  2. Android SDK with the platform and build-tools matching compileSdk in gradle/libs.versions.toml

  3. local.properties file (gitignored):

    sdk.dir=/path/to/your/Android/Sdk

Java Version Issues:

  • The project requires Java 17+; mise.toml pins Java 21
  • If Gradle picks up a different JDK, check mise current and JAVA_HOME

Common Issues

Build Failures

"SDK licenses not accepted"

~/Android/Sdk/cmdline-tools/latest/bin/sdkmanager --licenses

Detekt/ktlint failures

  • Skip with: ./gradlew build -x detekt -x ktlintCheck
  • Auto-fix formatting: ./gradlew ktlintFormat

Release Build MinifyEnabled

The sample app has isMinifyEnabled = true for release builds, so :sample:assembleRelease is the only thing that runs R8 over the SDK's classes. CI runs it on every pull request and on pushes to main, so it is expected to pass; if it starts failing, treat that as a real problem rather than an expected quirk of the sample.

Note what a green run does and does not prove. It shows R8 completed, not that consumer-rules.pro still keeps everything kotlinx.serialization needs at runtime — that would fail as a SerializationException inside a consumer app, not as a build failure. AGP also wires lintVitalRelease into this task, so a failure is not necessarily R8's.

Testing Strategy

  • Unit tests in device-sdk/src/test/ use JUnit 5, MockK, and Robolectric
  • Android instrumented tests in device-sdk/src/androidTest/

When adding features, write unit tests that:

  • Mock the Android Context with MockK or use Robolectric
  • Test both success and failure paths
  • Test Java compatibility if API is public

Version Catalog Structure

Uses Gradle version catalog in gradle/libs.versions.toml:

  • [versions] - Version numbers
  • [libraries] - Individual dependencies
  • [plugins] - Gradle plugins
  • [bundles] - Grouped dependencies (e.g., ktor, testing)

Access in build files: libs.ktor.client.core, libs.plugins.android.library

Maven Publishing Configuration

Publishing uses the Vanniktech Maven Publish plugin configured in device-sdk/build.gradle.kts. The plugin publishes to Maven Central via Central Portal with automatic release.

Credentials are read from ~/.m2/settings.xml (server id central) to share credentials with other MaxMind Maven projects. GPG signing uses the system gpg command, so existing ~/.gnupg configuration is used automatically.

See README.dev.md for the full release process and credential setup.

Module Structure

  • device-sdk/ - Android library module (the SDK)
  • sample/ - Android application module (demo app)
  • config/detekt/ - Shared Detekt configuration
  • gradle/ - Gradle wrapper and version catalog

Both modules are independent but sample depends on device-sdk via implementation(project(":device-sdk")).