Mobile Design Rounds
Build guideAndroid + iOS18 min read

Build it: an analytics SDK on Android and iOS

Last reviewed . Drafted with AI assistance. Checked against official documentation where noted (how this is checked). Part of: Design an analytics SDK (Android and iOS): queue, batching, retry and consent · The framework.

This is the build material behind the analytics SDK interview case. It covers:

  • The public API and the stored event
  • The write path, the flush rules, retry and clocks
  • Privacy, versions, tests and platform limits
  • The Swift side

None of it belongs in the 45 minutes. I draw it only if asked.

The sketches are simplified. The Kotlin sketches were compiled and the Swift sketch was type-checked, as the closing note says. WorkManager (Android's scheduler for work that survives restarts) and the storage layer are not part of them.

1. The public API and the stored event

Keep the public surface small. Every public method is a contract for years.

// Sketch: public surface (Kotlin)
class Analytics private constructor(/* internal wiring */) {
    companion object {
        fun init(context: Context, config: AnalyticsConfig): Analytics   // cheap; no I/O; keeps only applicationContext
    }
    fun track(name: String, properties: Map<String, Any?> = emptyMap())   // main-safe, non-suspend
    fun screen(name: String)
    fun identify(userId: String, traits: Map<String, Any?> = emptyMap())
    fun flush()                       // a request, returns immediately
    fun reset()                       // new anonymous ID, clear user ID
    fun setConsent(consent: Consent)  // GRANTED, DENIED, UNKNOWN (default)
    fun optOut()                      // stop collecting, drop the queue
}

Choices to defend:

  • No return value to wait on. track returns Unit. A flush that you can wait for lives in the test module.
  • Properties are a restricted map. The Kotlin type is Map<String, Any?>, so the compiler accepts anything. Validation lets through only strings, numbers, booleans, null, and bounded nested maps and lists. It rejects the rest at the boundary. The Swift PropertyValue enum makes the same list a type.

The stored event:

FieldPurpose
eventIdA random UUID created at track. This is the key the server uses to remove repeats
localSeqA counter kept on the device, per install, for ordering. It is not the server's seq from the chat and ticker cases. schemaVersion gives the envelope a version
name, propertiesThe payload after validation and removal of personal data
clientTimeThe device wall-clock time at track, plus a monotonic offset
anonymousId, userId, sessionIdThe identity at track time, not at send time

Validation. Check the name format, the number of properties, the value types and the encoded size. In a release build, drop the event and count it by reason. In a debug build, log loudly. Never throw to the caller.

2. Threading and startup

The host's main thread (the thread that draws the screen) does not belong to the SDK.

  • track creates the event and passes it on without blocking. On Android this is a bounded Channel with trySend. On iOS it is AsyncStream.Continuation.yield.
  • Every entry point is wrapped in a catch-all. A bug in the SDK becomes a dropped event and a counter, not a host crash. The engine in section 3 does this in guard, and a CoroutineExceptionHandler covers anything else.

Auto-init. Some SDKs start through a ContentProvider (an Android component that runs at app start), so hosts write no code. The App Startup page says content providers are expensive to start, can slow the app, and start in no set order. App Startup shares one InitializationProvider. It also supports lazy initialization with AppInitializer.initializeComponent, after you disable automatic start with tools:node="remove".

For an SDK, the better default is explicit start. The host calls Analytics.init, and the SDK includes no provider. An SDK that collects data before consent is a privacy bug, not a convenience.

3. The write path and the flush

Use SQLite directly, or Room (Android's database library) as an internal detail. Use one table for events and one for batches.

// Sketch: write path and flush (Kotlin, simplified)
internal class Engine(
    scope: CoroutineScope,                 // SDK-owned external scope; injected single-threaded dispatcher + SupervisorJob
    private val store: EventStore,
    private val uploader: Uploader,
    private val policy: FlushPolicy,
) {
    private val dropped = AtomicLong()        // inbox overflow, one of the drop counters
    private val internalErrors = AtomicLong() // bugs of ours that we swallowed
    private val inbox = Channel<Event>(
        capacity = 1_000,
        onBufferOverflow = BufferOverflow.DROP_OLDEST,
        onUndeliveredElement = { dropped.incrementAndGet() },
    )
    private val flushRequests = Channel<Unit>(Channel.CONFLATED)
    private val flushLock = Mutex()

    init {
        val guarded = scope + CoroutineExceptionHandler { _, _ -> internalErrors.incrementAndGet() }
        guarded.launch {                      // the writer: appends only, never waits on the network
            for (event in inbox) guard {
                store.append(event)
                if (store.pendingCount() >= policy.flushAt) requestFlush()   // COUNT(*) per event; keep a counter in real code
            }
        }
        guarded.launch {                      // the uploader: one flush at a time
            for (request in flushRequests) guard { flushOnce() }
        }
    }

    fun enqueue(event: Event) { inbox.trySend(event) }   // never suspends, never throws
    fun requestFlush() { flushRequests.trySend(Unit) }   // timer, background and flush() land here

    // WorkManager calls this too, so the lock covers both callers
    suspend fun flushOnce() = flushLock.withLock {
        val batch = store.leaseBatch(policy.maxBatchSize) ?: return@withLock   // reuses an open lease first
        when (uploader.send(batch)) {                                          // enum SendResult
            SendResult.OK, SendResult.REJECTED -> store.delete(batch.id)       // REJECTED: a permanent 4xx other than 408 and 429
            SendResult.RETRY -> store.release(batch.id)                        // same batch next time
        }
    }

    private suspend fun guard(block: suspend () -> Unit) {
        try { block() }
        catch (e: CancellationException) { throw e }
        catch (e: Exception) { internalErrors.incrementAndGet() }
    }
}

Three choices in that sketch fix problems in my first draft:

  • The writer and the uploader are separate coroutines, so a slow upload cannot fill the inbox. In a small JVM test, 2,500 events arrived during a one-second upload. All of them were either stored or counted as dropped.
  • flushOnce takes a Mutex (a lock), so the in-process trigger and the WorkManager worker never upload at the same time.
  • Every loop body runs inside guard. It counts an exception and keeps the loop alive, so one error does not end the only consumer. It rethrows CancellationException, so cancellation still works. If send throws, the batch stays leased and the next flush reuses it.

What the sketch leaves out:

  • The timer and the background trigger. Both would call requestFlush().
  • The drop counters by reason, beyond the inbox counter, and the rate limit per event name.
  • The consent gate, and the backoff with jitter that wait between retries.
  • The group commit. The sketch calls store.append for each event. The case page assumes the store saves events in groups, every 1 second or every 20 events.
  • The status mapping inside Uploader. It returns REJECTED only for the permanent 4xx codes in section 5. It returns RETRY for 401 and 403, because the batch is fine and the write key is wrong. The sketch comment on REJECTED says this in short form.
  • A counter for pending events. The sketch runs COUNT(*) on every event. Real code keeps a counter.

The detail that matters most is leaseBatch. The first time events are selected, the store gives them a batchId and records which event IDs belong to the batch. A retry sends the same batch with the same ID and the same events, even if newer events have arrived. The server can then ignore any batch ID it has seen.

A batch has three states:

StateMeaningNext
OpenEvents are waiting. No batch ID yetThe first pick gives them a batch ID and leases them
LeasedThe batch has an ID and one upload may be in flightThe server says yes (delete), a permanent error (delete and count), or a retry (release)
ReleasedThe upload failed in a way that may pass laterThe next flush reuses the same leased batch

Crash safety. SQLite commits are atomic, so a half-written row is never sent. Committing every event is safest and slowest. Grouping appends into one transaction is the usual compromise, and a kill before the commit loses that group. State the trade-off.

4. Flush policy and background work

TriggerWhy
Pending count reaches NBounds the batch size and the delay under load
T seconds since the oldest pending eventBounds the delay at low traffic
The app moves to the backgroundThe process may die soon
The app launchesSends what an earlier process could not

N, T and the batch cap come from remote config, with defaults bundled in the app. Any number I quote is an example.

I do not rely on the process staying alive. The queue is on disk, so a flush can happen later. While the process runs, the engine flushes on count, on time and when the app goes to the background. It uses its own scope, not the caller's. When the process is gone, WorkManager takes over:

  • One unique periodic job and one retry job.
  • Each needs a network.
  • Each calls flushOnce.

The offline-first page lists analytics events as a use for queued writes. It says to empty the queue with exponential backoff (wait longer after each failure).

Result.retry() uses exponential backoff by default. The default delay is 30 seconds, the minimum is 10 seconds, and delays are inexact. So I promise nobody a delivery time. Periodic work cannot repeat faster than 15 minutes, and constraints can delay or skip a run. The "T seconds" row above is therefore the job of the in-process timer, never of periodic work.

Use a worker that the default factory can build, so a host's custom WorkerFactory cannot break it. That is my design advice, not from the docs.

Rarely used apps get less. The App Standby Buckets page says a restricted app may run jobs about once a day, in a 10-minute session. Those jobs need another job running or pending. An app moves to restricted after 8 days of non-use on Android 13 and later. For these apps, the next foreground flush is the real recovery. WorkManager is only an extra that may not run.

5. Retry, repeats, clocks and the drop policy

Retry rules.

  • Transient errors are network errors, timeouts, 408, 429 and 5xx. Retry them, and obey Retry-After when the server sends it.
  • Permanent errors are any other 4xx, such as 400. Delete the batch and count it, so a bad batch (a poison batch) cannot block the queue.
  • 401 and 403 are the exception. They mean the write key is wrong. Keep the batch, wait, and raise a counter instead of deleting data.
  • Use exponential backoff with jitter (a random extra delay) and a cap. Allow one upload at a time, and save the failure count on disk.

A start I would try is a few seconds, doubling up to a cap of a few minutes. Those numbers are examples. The chat app case has the same transient and permanent split for sends.

Idempotency. An operation is idempotent when doing it twice has the same result as doing it once. The server removes repeats by event ID within a retention window. It can skip a whole batch by batch ID. Exactly-once delivery is not on offer. At-least-once delivery plus removal of repeats is.

Clock skew. Device clocks are wrong. Send clientTime on each event and sentAt on the batch, both from the device clock. The server records receivedAt. Then receivedAt - (sentAt - clientTime) estimates the true event time. It assumes a constant offset and small network delay. A monotonic delta per event keeps the order inside one run, even if the user changes the clock.

Backpressure and the drop policy.

  • The inbox is bounded and drops the oldest event.
  • The disk is capped by count and by bytes. When it is full, it drops the oldest event.
  • A rate limit and a size cap per event name stop a faulty loop in the host's code.
  • The SDK exposes counters: drops by reason, queue depth and the age of the oldest event.

Dropping the oldest keeps fresh data. Dropping the newest keeps the first events of each user path, which helps funnels. Document the choice.

6. Network, sessions and sampling

Network. Send one gzip-compressed body per batch, with shared context (app, OS and SDK version) once. Keep the number of calls low. Assume anyone can read the write key (the secret the SDK sends with data) from the app file (APK). The key should only be able to write events, never read data, and the server should rate-limit by it.

Sessions. A new session starts at the first launch, or when the app returns to the foreground after an idle gap that the host can configure. The SDK stamps the session at track time because it sees lifecycle events.

Auto-capture. I turn it off by default. Automatic screen and tap events add volume. They can also collect text the host never meant to send, and the host cannot see what the SDK decides to record. Lifecycle events for sessions are the exception, since the SDK needs them anyway.

Identity. The anonymous ID is a random UUID, not derived from hardware. identify labels later events only. The server joins identities.

Sampling. Decide once per install or session. Hash the anonymous ID and compare it with a rate from remote config. Stamp the rate on events so analysts can reweight. Sampling per event breaks funnels.

Make the safe behavior the default.

  • No collection before consent. Consent is UNKNOWN, GRANTED or DENIED. UNKNOWN acts like DENIED. Save the choice, and never write events to disk before consent.
  • Opt-out must act. Stop collecting, delete the queue, and offer a deletion hook for erasure requests.
  • Data minimization. No advertising ID by default, no fingerprinting, no automatic text capture. Remove known personal data keys, and accept a redaction callback from the host.
  • The host owns the prompt. The Android guidance for SDK authors says to follow the consent the host collected, or to let the host start the SDK to match the user's choice.
  • Advertising ID on Android. Users can reset it and opt out, and apps should not cache it. For apps that target Android 13 (API 33) or higher, the Play services ID needs the AD_ID permission. Without it, the ID is replaced with zeros. The permission is merged into the host manifest. So an SDK that does not need the ad ID should not declare it.
  • App Tracking Transparency (ATT) on iOS. Apple says an app that collects data about people and shares it with other companies to track them across apps and sites needs this framework. The host app owns that prompt, not the SDK.
  • Disclosure. Google Play says developers must show data collected or shared through third-party SDKs in the Data safety form. Provide a field-by-field description of what the SDK collects.

Who is legally responsible under GDPR or CCPA is a question for a lawyer. Whether first-party analytics counts as tracking under ATT is also a legal question. The SDK's job is to make the compliant path the easy one.

8. Versioning, remote config and several SDKs

Versioning. Semantic versions cover the public API and the wire format. Put schemaVersion on events and sdkVersion on batches. The server must accept old versions while old apps are in the field. Behavior counts as part of the API. A change to a default flush interval or a drop policy changes someone's data volume.

Remote config. A small document fetched at launch carries the flush limits, the sampling rate and a kill switch (a flag that stops collection and upload). Cache the last good copy, bundle defaults in the app, and accept only documented keys. A bad cached copy can outlive its fix, so give the cache an expiry.

Several SDKs. Chain the uncaught-exception handler (call the previous handler too). Give your database and work names a prefix. Avoid heavy libraries that your SDK would bring into the host.

9. Testing and debugging

Use constructor injection for the clock, the dispatcher, the network and the store. Do not force Hilt (a dependency injection library) on the host. If the host uses @HiltWorker, give it a WorkerFactory.

Include FakeEventStore and FakeUploader in a test module, because official guidance prefers fakes to mocks. Test these cases:

  • A kill between the write and the send
  • Duplicate delivery
  • A clock jump

For debugging, offer a debug flag, a queue dump and a counters snapshot. Android's logging guidance says to minimize sensitive output and strip verbose logs from release builds. So keep logging off by default and hide values.

10. Platform limits

LimitAndroidiOSSo the design changes by
Periodic background workThe minimum repeat is 15 minutes, and a run can be delayed or skippedBackgroundTasks only schedules a later chance to run. It does not keep work alive after the process is goneThe "T seconds" flush is an in-process timer. Background scheduling is a backup
Apps used rarelyA restricted app runs jobs about once a day in a 10-minute session, after 8 days of non-use on Android 13 and laterA suspended app runs no code. A background URL session upload from a file continues, but not after a force quitThe next foreground flush is the real recovery. I promise no delivery time
Finishing at the background stepA short flush on lifecycle stopbeginBackgroundTask gives finite time. The expiration handler runs on the main threadKeep the final flush small and end the task early
Advertising IDAD_ID permission for target 33 and above, merged from the SDK manifestThe ATT prompt, owned by the hostNo ad ID by default. Declare nothing the SDK does not need
Privacy files and formsThe Data safety formA privacy manifest and the App Privacy detailsInclude a field-by-field description and a manifest with the SDK
Start-up costA content provider is expensive to start, and starts in no set orderNo provider. The host creates the SDKExplicit init that does no disk work

11. Doing it on iOS

The architecture carries over. The differences are lifecycle, background time and the privacy manifest.

ConcernAndroidiOS
Worker isolationSingle-threaded dispatcher + Channelactor + AsyncStream
Durable storeSQLite or RoomPlain SQLite or files. I would avoid Core Data in an SDK
Retry after process deathWorkManagerNext launch or foreground; optionally a background URLSession upload
Finish at backgroundingShort flush on lifecycle stopbeginBackgroundTask
NetworkInjected clientURLSession, optionally waitsForConnectivity (it only applies while a connection is being established, and background sessions ignore it)
Consent surfaceAD_ID manifest permission and the user's ad-ID reset or opt-out (no prompt)ATTrackingManager prompt, owned by the host app
DisclosureData safety formPrivacy manifest and App Privacy details
// Sketch: iOS facade (Swift 5.9+ for AsyncStream.makeStream; makeStream is back-deployed to iOS 13, OSAllocatedUnfairLock needs iOS 16)
public enum PropertyValue: Sendable {
    case string(String), number(Double), bool(Bool), null
    indirect case array([PropertyValue])
    indirect case object([String: PropertyValue])
}

struct Event: Sendable {
    let id: String, name: String, clientTime: Date   // the real one also carries localSeq, identity and sessionId
    let properties: [String: PropertyValue]
}

public final class Analytics: Sendable {
    private let inbox: AsyncStream<Event>.Continuation
    private let dropped = OSAllocatedUnfairLock(initialState: 0)   // drop counter, by reason in the real one

    public init(config: AnalyticsConfig) {
        let (stream, continuation) = AsyncStream.makeStream(
            of: Event.self, bufferingPolicy: .bufferingNewest(1_000))   // drops the oldest event when full; same choice as the Android inbox
        self.inbox = continuation
        let engine = Engine(config: config)
        Task.detached(priority: .utility) { await engine.run(stream) }
    }

    deinit { inbox.finish() }   // ends the stream, so the engine task ends too

    public func track(_ name: String, properties: [String: PropertyValue] = [:]) {
        let event = Event(id: UUID().uuidString, name: name,
                          clientTime: Date(), properties: properties)
        if case .dropped = inbox.yield(event) { dropped.withLock { $0 += 1 } }   // sync, ordered
    }
}

actor Engine {
    init(config: AnalyticsConfig) { /* store opened lazily */ }
    func run(_ events: AsyncStream<Event>) async {
        for await event in events { /* append to SQLite, maybe flush */ }
    }
}

The sketch is shortened. The real Event also carries localSeq, the identity and sessionId, and the drop counter is kept by reason. What is different on iOS:

  • Privacy manifests are part of the SDK. Apple's page says to include one if the SDK is on Apple's list of SDKs that require a manifest and a signature. Otherwise include one if the SDK uses a required reasons API, collects data about people, helps the app collect data, or contacts tracking domains. The data types and API reasons are recorded in the manifest.
  • Static libraries can bundle it. Use the support for static frameworks in Xcode 15 or later to bundle resources, including the manifest.
  • Tracking domains can fail. If NSPrivacyTracking is true, the manifest lists NSPrivacyTrackingDomains. Requests to those domains fail without tracking permission. Treat that as a normal network failure.
  • Background time is finite. A beginBackgroundTask task must end before time runs out, or the system kills the app. The expiration handler runs on the main thread, so keep it tiny.
  • BackgroundTasks does not keep work alive. It only schedules a chance to run later. For a flush that must survive suspension, save the batch to a file and upload it from a background URLSession. The system keeps that running while the app is suspended, but not after a force quit.

12. More practice prompts

These do not escalate. They are extra questions, not a claim about what interviewers ask.

  • Typed events or strings? What does a typed event look like to the host developer?
  • How do you design sessions, and what happens when the idle gap is very short?
  • How do you debug a data gap? Use drop counters by reason, queue age, the last accepted batch ID and delivery rate per SDK version.
  • Two SDKs in one app both set the uncaught-exception handler. What do you do?
  • Add a crash reporter to the same pipe. What must change, given that a crash handler has no network?

Further reading (official docs)

Verification notes (last reviewed 13 Oct 2026)

this is the build material of the case. It moved here from the live analytics SDK page on 2026-10-14. The three code blocks (public surface, Engine, Swift facade) are unchanged and were diffed byte for byte against the audited page. The prose around them was rewritten in plain English on 2026-10-14 with no change to any claim, except for the additions listed next. Added on 2026-10-14 from pages fetched that day: the App Standby Buckets figures (restricted jobs once a day in a 10 minute batched session, needing another job running or pending; restricted after 8 days of non-use on Android 13 and later, 45 days on Android 12; the page gives no figures for the rare bucket and does not say whether 8 days still holds on Android 14 and later); the Android guidance for SDK authors on following the host's consent; Apple's privacy manifest page, read from Apple's documentation data (the manifest is needed if the SDK is on the list of SDKs that require one and a signature, or uses a required reasons API, collects data about people, enables the app to collect data, or contacts tracking domains; requests to listed tracking domains fail without ATT permission; a static library can bundle the manifest with Xcode 15 or later); the batch state table, which is my design. Define work, fetched again: the minimum repeat interval is 15 minutes and constraints can delay or skip a run; the default backoff is exponential with a 30 second delay and a 10 second minimum, delays are inexact, and the page gives no cap and no formula. App Startup, fetched again: content providers are expensive to start and start in no set order, lazy initialization and tools:node="remove". Audit changes on 2026-10-13: renamed the sequence counter to localSeq and said it is client-side; wrote the queue and retry rules to match the other cases (transient: network, 408, 429, 5xx; permanent: other 4xx); added the 15 minute periodic floor; changed the makeStream comment to back-deployed to iOS 13 (re-fetched from Apple's JSON endpoint for makeStream); replaced the BackgroundTasks claim with a background URLSession upload (background-session behavior re-fetched; that uploads must come from a file is general URLSession knowledge I did not confirm on that page); rewrote the ATT and privacy manifest sentences in my own words; the waitsForConnectivity caveats and the remaining Apple points were relayed from the audit's JSON endpoint reads, not re-fetched by me. Audited 2026-10-13 against the architecture recommendations, data layer, offline-first, UI, domain, App Startup, define-work and Channel pages. Remaining deviations: official docs are silent on SDK layering and unique-work details (from API knowledge); the SDK keeps its own in-process retry beside WorkManager. Verified by fetching: App Startup, WorkManager getting started and define-work, the advertising ID page, Android 13 behavior changes, logging guidance, the Play Data safety help page. Apple claims were verified through the developer.apple.com JSON endpoints (ATT, BackgroundTasks, privacy manifest files, required reason API, URLSession, waitsForConnectivity, beginBackgroundTask); the links point to the matching human-readable pages, which were not fetched directly. Not verified: Apple's list of SDKs that require a manifest and signature, the exact required reasons category names, and the Play Data safety wording. SQLite commit behavior, gzip guidance, Retry-After, the clock-skew formula, sampling and session design, the remote config expiry advice and the drop-oldest choice are the author's synthesis. Whether first-party analytics counts as ATT tracking is a legal question. Inbox size and flush values are illustrative. AsyncStream.makeStream needs the Swift 5.9 toolchain, not an iOS 17 target. An iOS engineer should review section 11. Known shortcuts in the unchanged code, kept and named in the prose: COUNT(*) per event, the REJECTED comment that must not include 401 and 403, and the shortened Swift Event. The platform limits table restates facts verified on this page, and its last column is design intent. Code checks run on 2026-10-13, all offline with nothing downloaded: the public-surface sketch and the Engine sketch were compiled with Gradle 9.8.1 (offline), AGP 9.1.0, Kotlin 2.3.21, compileSdk 36 and kotlinx.coroutines 1.10.2 (compileDebugKotlin clean); the public surface only as declarations with TODO() bodies, and Event, Batch, EventStore, Uploader, FlushPolicy and SendResult are stubs. The Engine was also run in a small JVM program (coroutines 1.10.2) with three checks: a store that throws leaves the writer alive and counts the error, 2,500 events during a one second upload were all either stored or counted as dropped, and five concurrent flushOnce calls never uploaded in parallel. The Swift facade was type-checked with Swift 6.4 against the iOS 27 simulator SDK (-target arm64-apple-ios17.0-simulator), language modes 5 and 6, both clean apart from one unused-variable warning on the placeholder event loop; AnalyticsConfig is a stub. Not run: the WorkManager worker, Room or SQLite behind EventStore, Uploader against a real network, and any Swift runtime test, so the Swift deinit and drop counter are type-checked only. Hilt and Retrofit are not used in these snippets; the other case studies stub them. Second plain-English and accuracy pass on 2026-10-14, with the official pages fetched again that day. Confirmed: App Startup (content provider cost and order, shared InitializationProvider, lazy start, tools:node="remove"); define work (15 minute floor, constraints can delay or skip a run, exponential default with 30 seconds, 10 second minimum, inexact delays, no cap); App Standby Buckets (restricted: once a day in a 10 minute session, needs another job, 8 days on Android 13 and later); the SDK guidance on host consent; offline-first (analytics events as queued writes, drained with exponential backoff); the advertising ID page (reset, opt-out, no caching); Android 13 behavior changes (AD_ID, zeros without it, merged from library manifests); the logging page; the Data safety help page; the architecture recommendations (fakes over mocks, constructor injection); Apple's privacy manifest, ATT, beginBackgroundTask (handler on the main thread, app killed if the task is not ended), waitsForConnectivity (ignored by background sessions) and background URLSession pages (a force quit from the multitasking screen cancels background transfers), all through Apple's documentation data. Removed because no fetched page supports it: the sentence that WorkManager itself starts through App Startup (the getting-started page does not mention App Startup). Changed: the analytics-events sentence now names the offline-first page; the ATT bullet now follows Apple's wording; the Play Data safety page says the developer must declare data handled through third-party SDKs. Made the prose match the code: the sketch does not show the group commit, the rate limit per event name, the consent gate or the backoff and jitter, and the list of what the sketch leaves out now says so. Terms now defined at first use: ContentProvider, kill switch, backoff, ATT, write key, APK, Hilt, chaining a handler. Words replaced: ship, hand off, favors, honor, runaway, safety net, best-effort. Not verified: that a background upload must come from a file (general URLSession knowledge), and Apple's human-readable pages, which were not fetched. No code block was changed (diffed byte for byte against the old live page).

Questions or corrections?

Add them to the discussion thread for this case on GitHub. To post, you need a free GitHub account. What you write there is public and follows GitHub's terms. Good answers are added to this page.

Keep reading

Get the next case study when it is published.

You get an email when a new case study is published, and nothing else.

You can unsubscribe at any time. Privacy.