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.
trackreturnsUnit. 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 SwiftPropertyValueenum makes the same list a type.
The stored event:
| Field | Purpose |
|---|---|
eventId | A random UUID created at track. This is the key the server uses to remove repeats |
localSeq | A 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, properties | The payload after validation and removal of personal data |
clientTime | The device wall-clock time at track, plus a monotonic offset |
anonymousId, userId, sessionId | The 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.
trackcreates the event and passes it on without blocking. On Android this is a boundedChannelwithtrySend. On iOS it isAsyncStream.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 aCoroutineExceptionHandlercovers 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.
flushOncetakes aMutex(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 rethrowsCancellationException, so cancellation still works. Ifsendthrows, 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.appendfor 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 returnsREJECTEDonly for the permanent4xxcodes in section 5. It returnsRETRYfor401and403, because the batch is fine and the write key is wrong. The sketch comment onREJECTEDsays 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:
| State | Meaning | Next |
|---|---|---|
| Open | Events are waiting. No batch ID yet | The first pick gives them a batch ID and leases them |
| Leased | The batch has an ID and one upload may be in flight | The server says yes (delete), a permanent error (delete and count), or a retry (release) |
| Released | The upload failed in a way that may pass later | The 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
| Trigger | Why |
|---|---|
| Pending count reaches N | Bounds the batch size and the delay under load |
| T seconds since the oldest pending event | Bounds the delay at low traffic |
| The app moves to the background | The process may die soon |
| The app launches | Sends 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,429and5xx. Retry them, and obeyRetry-Afterwhen the server sends it. - Permanent errors are any other
4xx, such as400. Delete the batch and count it, so a bad batch (a poison batch) cannot block the queue. 401and403are 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.
7. Privacy and consent
Make the safe behavior the default.
- No collection before consent. Consent is
UNKNOWN,GRANTEDorDENIED.UNKNOWNacts likeDENIED. 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_IDpermission. 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
| Limit | Android | iOS | So the design changes by |
|---|---|---|---|
| Periodic background work | The minimum repeat is 15 minutes, and a run can be delayed or skipped | BackgroundTasks only schedules a later chance to run. It does not keep work alive after the process is gone | The "T seconds" flush is an in-process timer. Background scheduling is a backup |
| Apps used rarely | A restricted app runs jobs about once a day in a 10-minute session, after 8 days of non-use on Android 13 and later | A suspended app runs no code. A background URL session upload from a file continues, but not after a force quit | The next foreground flush is the real recovery. I promise no delivery time |
| Finishing at the background step | A short flush on lifecycle stop | beginBackgroundTask gives finite time. The expiration handler runs on the main thread | Keep the final flush small and end the task early |
| Advertising ID | AD_ID permission for target 33 and above, merged from the SDK manifest | The ATT prompt, owned by the host | No ad ID by default. Declare nothing the SDK does not need |
| Privacy files and forms | The Data safety form | A privacy manifest and the App Privacy details | Include a field-by-field description and a manifest with the SDK |
| Start-up cost | A content provider is expensive to start, and starts in no set order | No provider. The host creates the SDK | Explicit 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.
| Concern | Android | iOS |
|---|---|---|
| Worker isolation | Single-threaded dispatcher + Channel | actor + AsyncStream |
| Durable store | SQLite or Room | Plain SQLite or files. I would avoid Core Data in an SDK |
| Retry after process death | WorkManager | Next launch or foreground; optionally a background URLSession upload |
| Finish at backgrounding | Short flush on lifecycle stop | beginBackgroundTask |
| Network | Injected client | URLSession, optionally waitsForConnectivity (it only applies while a connection is being established, and background sessions ignore it) |
| Consent surface | AD_ID manifest permission and the user's ad-ID reset or opt-out (no prompt) | ATTrackingManager prompt, owned by the host app |
| Disclosure | Data safety form | Privacy 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
NSPrivacyTrackingistrue, the manifest listsNSPrivacyTrackingDomains. Requests to those domains fail without tracking permission. Treat that as a normal network failure. - Background time is finite. A
beginBackgroundTasktask 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)
- Analytics SDK interview case: the 45 minutes for this case
- App Startup (Android): the shared
InitializationProvider, lazy initialization, disabling auto-init - WorkManager getting started (Android):
Result.retry(), timing caveats - Define work requests (Android): constraints, backoff policy and delays
- App Standby Buckets (Android): limits for restricted apps
- About user safety and SDKs (Android): respect the host's consent
- Guide to app architecture: recommendations (Android): DI, fakes over mocks, Context
- Data layer (Android): work lifetimes, external scope, single source of truth
- Offline-first (Android): queued writes, exponential backoff
- Advertising ID (Android): reset, opt-out, caching
- Behavior changes for Android 13:
AD_ID - Safeguard logs and sensitive data (Android)
- Provide information for Google Play's Data safety section: third-party SDK disclosure
- App Tracking Transparency (Apple): when the framework applies
- Privacy manifest files (Apple): SDK requirements, top-level keys, tracking domains, static libraries
- Describing use of required reason API (Apple): per-SDK declarations
- beginBackgroundTask(withName:expirationHandler:) (Apple): finite time, expiration handler
- waitsForConnectivity (Apple): behavior when offline
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.