Skip to content

Return TrackingResult with tracking token from collectAndSend()#14

Merged
horgh merged 15 commits intomainfrom
greg/eng-4052
Feb 27, 2026
Merged

Return TrackingResult with tracking token from collectAndSend()#14
horgh merged 15 commits intomainfrom
greg/eng-4052

Conversation

@oschwald
Copy link
Member

@oschwald oschwald commented Feb 26, 2026

Summary

  • collectAndSend() now returns Result<TrackingResult> instead of Result<Unit>, exposing an opaque trackingToken for use with the minFraud API's /device/tracking_token field
  • Reduce public API surface: make internal model classes, ServerResponse, SdkConfig host constants, collectDeviceData(), and sendDeviceData() — consumers only need collectAndSend()
  • Simplify sample app to only use the consumer-facing API

Test plan

  • ./gradlew :device-sdk:test — all unit tests pass (includes new DeviceTrackerTest)
  • ./gradlew :sample:assembleDebug — sample app builds
  • ./gradlew detekt ktlintCheck — linting passes
  • Install sample app and verify Initialize → Send flow returns tracking token

🤖 Generated with Claude Code

oschwald and others added 3 commits February 26, 2026 11:46
Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
collectAndSend() and sendDeviceData() now return Result<TrackingResult>
with an opaque trackingToken for use with the minFraud API. This avoids
exposing ServerResponse internals and treats the token as opaque, since
its format may change.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Make internal: 18 model classes, ServerResponse, SdkConfig host constants,
collectDeviceData(), and sendDeviceData(). Only TrackingResult, DeviceTracker,
and SdkConfig+Builder remain public. Simplify sample app to use only
collectAndSend(), removing DeviceData inspection, reflection, and serialization
dependencies.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@gemini-code-assist
Copy link

Summary of Changes

Hello @oschwald, I'm Gemini Code Assist1! I'm currently reviewing this pull request and will post my feedback shortly. In the meantime, here's a summary to help you and other reviewers quickly get up to speed!

This pull request refines the SDK's public interface by making numerous internal components private, thereby simplifying its usage and reducing potential for misuse. It also enhances the utility of the primary data submission method by returning a specific TrackingResult object, which directly provides the necessary token for integration with the minFraud API. The accompanying sample application has been updated to reflect these changes, offering a more focused and accurate demonstration of the intended public API.

Highlights

  • API Surface Reduction: Eighteen model classes, ServerResponse, and SdkConfig host constants were made internal, significantly reducing the public API surface. Only TrackingResult, DeviceTracker, and SdkConfig+Builder remain public.
  • Simplified API Usage: The collectDeviceData() and sendDeviceData() methods are now internal, guiding consumers to use the unified collectAndSend() method for device data operations.
  • Enhanced Return Type: Public API methods like collectAndSend() now return Result<TrackingResult> instead of Result<Unit>, providing a trackingToken property for direct use with the minFraud API.
  • Sample App Simplification: The sample application was streamlined to exclusively utilize the new consumer-facing API, removing dependencies on reflection and serialization for a clearer usage example.
Changelog
  • CHANGELOG.md
    • Updated for version 0.2.0 (unreleased) with breaking changes.
    • Noted that collectAndSend() and sendDeviceData() now return Result<TrackingResult> instead of Result<Unit>.
    • Indicated that collectDeviceData() and sendDeviceData() are no longer public API methods, directing users to collectAndSend().
Activity
  • Unit tests were executed and passed successfully.
  • The sample application was successfully built.
  • Linting checks were performed and passed.
  • Verification of the Initialize → Send flow on a device is pending.
  • The pull request was generated using Claude Code.
Using Gemini Code Assist

The full guide for Gemini Code Assist can be found on our documentation page, here are some quick tips.

Invoking Gemini

You can request assistance from Gemini at any point by creating a comment using either /gemini <command> or @gemini-code-assist <command>. Below is a summary of the supported commands on the current page.

Feature Command Description
Code Review /gemini review Performs a code review for the current pull request in its current state.
Pull Request Summary /gemini summary Provides a summary of the current pull request in its current state.
Comment @gemini-code-assist Responds in comments when explicitly tagged, both in pull request comments and review comments.
Help /gemini help Displays a list of available commands.

Customization

To customize Gemini Code Assist for GitHub experience, repository maintainers can create a configuration file and/or provide a custom code review style guide (such as PEP-8 for Python) by creating and adding files to a .gemini/ folder in the base of the repository. Detailed instructions can be found here.

Limitations & Feedback

Gemini Code Assist may make mistakes. Please leave feedback on any instances where its feedback is incorrect or counter productive. You can react with 👍 and 👎 on @gemini-code-assist comments. If you're interested in giving your feedback about your experience with Gemini Code Assist for Github and other Google products, sign up here.

You can also get AI-powered code generation, chat, as well as code reviews directly in the IDE at no cost with the Gemini Code Assist IDE Extension.

Footnotes

  1. Review the Privacy Notices, Generative AI Prohibited Use Policy, Terms of Service, and learn how to configure Gemini Code Assist in GitHub here. Gemini can make mistakes, so double check it and use code with caution.

@oschwald oschwald changed the title Minimize public API surface Return TrackingResult with tracking token from collectAndSend() Feb 26, 2026
Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request introduces a breaking change to the SDK's public API, primarily by modifying the collectAndSend() and sendDeviceData() methods to return a Result<TrackingResult> which includes a trackingToken for minFraud API integration, replacing the previous Result<Unit>. The collectDeviceData() and sendDeviceData() methods have been made internal, simplifying the public interface to collectAndSend(). These changes are reflected across the CHANGELOG, CLAUDE.md, and README.md documentation, with updated code examples in the README and the sample application. Additionally, many internal data model classes and SdkConfig constants were changed from public to internal visibility to reduce the public API surface. Review comments suggested improving exception handling in DeviceTracker.kt by using more specific exception types like IOException or custom exceptions when the tracking token is missing or blank from the server response.

Comment on lines 87 to 88
response.storedID
?: error("Server response missing tracking token")

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The ?: error(...) construct throws an IllegalStateException. It would be more appropriate to throw an IOException or a custom exception that extends IOException to better reflect the nature of the error (i.e., a missing token from the server, which is an I/O related issue).

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The I/O operation itself succeeded — the server responded, it just didn't include the expected field. This is a protocol/contract violation, not an I/O error, so IllegalStateException (via error()) is the right fit. Additionally, this is wrapped by mapCatching into a Result.failure, so callers never catch the exception type directly — they pattern-match on the Result.

Comment on lines +98 to +106
internal companion object {
/** Default IPv6 server host */
public const val DEFAULT_IPV6_HOST: String = "d-ipv6.mmapiws.com"
internal const val DEFAULT_IPV6_HOST: String = "d-ipv6.mmapiws.com"

/** Default IPv4 server host */
public const val DEFAULT_IPV4_HOST: String = "d-ipv4.mmapiws.com"
internal const val DEFAULT_IPV4_HOST: String = "d-ipv4.mmapiws.com"

/** API endpoint path */
public const val ENDPOINT_PATH: String = "/device/android"
internal const val ENDPOINT_PATH: String = "/device/android"

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Marking these constants as internal is a good step in reducing the public API surface. No comment, just acknowledging the change.

val trackingToken: String,
) {
init {
require(trackingToken.isNotBlank()) { "Tracking token must not be blank" }

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

Consider throwing a more specific exception type than IllegalArgumentException to provide more context about the validation failure. A custom exception class, such as InvalidTrackingTokenException, could be used.

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

require throwing IllegalArgumentException is idiomatic Kotlin for precondition checks. A custom exception type would add a public API surface element for something that's only thrown when the SDK itself has a bug (server returned a blank token). Since it's wrapped in Result.failure, callers handle it via onFailure rather than catching specific exception types.

Copy link
Contributor

@horgh horgh left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

LGTM. The robots had some feedback though 🤖

@oschwald
Copy link
Member Author

@gemini-code-assist review

Copy link

@gemini-code-assist gemini-code-assist bot left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Code Review

This pull request is a great improvement to the SDK. It introduces the TrackingResult with a trackingToken, which is a valuable feature for integrating with the minFraud API. The reduction of the public API surface by making many classes and functions internal is a solid architectural change that improves encapsulation and maintainability. The updates to the documentation, sample app, and ProGuard rules are all consistent with these changes. I have one suggestion to make the collectAndSend function more robust by ensuring it always returns a Result.

Comment on lines 115 to 116
val deviceData = collectDeviceData()
return sendDeviceData(deviceData)

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

medium

The collectAndSend function should be more robust by always returning a Result and not throwing exceptions (except for CancellationException). Currently, if collectDeviceData() throws an exception, collectAndSend will also throw. For a public API function that is declared to return a Result, it's better to catch potential exceptions and wrap them in a Result.failure. This makes the function's contract clearer and simplifies error handling for consumers.

        return try {
            val deviceData = collectDeviceData()
            sendDeviceData(deviceData)
        } catch (e: CancellationException) {
            throw e
        } catch (e: Exception) {
            Result.failure(e)
        }

Copy link
Member Author

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Good catch — wrapping only the call site in startAutomaticCollection() fixed the symptom but not the root cause. Moved the try/catch into collectAndSend() itself so the Result contract is honored for all callers, and simplified startAutomaticCollection() back to just .fold().

oschwald and others added 10 commits February 27, 2026 08:06
Construct TrackingResult (which validates via require(isNotBlank))
before calling storedIDStorage.save(). Previously a blank token
would be persisted and then fail validation.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
collectAndSend() calls collectDeviceData() which can throw before
returning a Result. An uncaught exception kills the coroutine and
stops automatic collection permanently. Wrap in try/catch while
letting CancellationException propagate for structured concurrency.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove "Manual Data Collection" section since collectDeviceData()
  is now internal
- Replace Log.d() with sendToBackend() in examples to guide users
  toward passing the token to their backend for minFraud integration

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Only keep TrackingResult (the sole public model class) in consumer
rules. Internal model classes like DeviceData and ServerResponse
can now be shrunk by R8 in consumer apps. Serialization rules
remain broad since internal classes still need them at runtime.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
These color resources are unused in the sample app.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The catch block `catch (e: Exception)` swallowed
CancellationException, breaking structured concurrency. When
shutdown() cancels the coroutine scope, in-flight HTTP calls would
not propagate cancellation correctly.

This applies the same re-throw pattern already used at both sites in
DeviceTracker.kt.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The data class default toString() would expose the full tracking
token in logs, crash reports, and debug output. Override it with a
redacted version to prevent accidental token leakage.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
The IPv4 request result was silently discarded, making it impossible
to diagnose persistent IPv4 endpoint failures. Log success/failure
at debug level when enableLogging is true.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
- Remove sendDeviceData() from CHANGELOG first bullet since it is no
  longer part of the public API
- Update CLAUDE.md DeviceData section to reflect internal visibility
- Remove "Keeps Ktor classes" from CLAUDE.md ProGuard section (those
  rules are in proguard-rules.pro, not consumer-rules.pro)

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Add a terminology section clarifying the distinction: "stored ID" is
the server-generated value used internally throughout the SDK, while
"tracking token" is the public-facing name exposed to consumers via
TrackingResult. They are currently the same value but the abstraction
allows them to diverge. Also fix the dual-request flow description
which incorrectly said "tracking token".

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

  Issue 1: mapCatching swallows CancellationException (Bug)

  In DeviceTracker.kt:85, sendDeviceData uses mapCatching:

  internal suspend fun sendDeviceData(deviceData: DeviceData): Result<TrackingResult> =
      apiClient.sendDeviceData(deviceData).mapCatching { response ->
          ...
      }

  mapCatching internally uses runCatching which catches Throwable — including CancellationException. If the coroutine is cancelled while executing inside the mapCatching lambda
  (e.g., between getting the server response and completing the storage save), the CancellationException gets wrapped in Result.failure() instead of propagating.

  This also makes the catch (e: CancellationException) { throw e } on line 95-96 inside the storage try-catch effectively useless — the rethrown CancellationException just gets
  caught again by mapCatching.

  This is a well-known Kotlin coroutines footgun. Consider replacing mapCatching with explicit handling, e.g.:

  internal suspend fun sendDeviceData(deviceData: DeviceData): Result<TrackingResult> {
      val response = apiClient.sendDeviceData(deviceData)
          .getOrElse { return Result.failure(it) }
      val token = response.storedID
          ?: return Result.failure(IllegalStateException("Server response missing tracking token"))
      val result = TrackingResult(trackingToken = token)
      try {
          storedIDStorage.save(token)
          ...
      } catch (e: CancellationException) {
          throw e
      } catch (@Suppress("TooGenericExceptionCaught") e: Exception) {
          ...
      }
      return Result.success(result)
  }

Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Unrelated to the current changes, but Claude noticed this:

  Issue 2: prettyPrint = true in production JSON (Performance)

  DeviceApiClient.kt:46:

  private val json =
      Json {
          prettyPrint = true
          ...
      }

  Every API request sent from the SDK uses pretty-printed JSON, adding unnecessary whitespace to every payload. On mobile devices where bandwidth and battery matter, this should
  probably be false (or omitted — it defaults to false). If it's there for debugging, consider gating it behind config.enableLogging.

oschwald and others added 2 commits February 27, 2026 12:44
mapCatching internally catches Throwable (including
CancellationException), which breaks structured concurrency
cancellation. Replace with getOrElse/early-return so only
CancellationException propagates and all other failures are
wrapped in Result.failure().

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
prettyPrint adds unnecessary whitespace to every API request payload,
wasting bandwidth on mobile devices. It defaults to false, which is
the appropriate setting for production use.

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
@horgh horgh merged commit eb8804d into main Feb 27, 2026
7 of 8 checks passed
@horgh horgh deleted the greg/eng-4052 branch February 27, 2026 21:46
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

None yet

Development

Successfully merging this pull request may close these issues.

2 participants