Skip to main content

iOS SDK Parity Notes — from the Android SDK study

Purpose: prepare optolink-ios by recording what optolink-android (v0.1.0) actually is: its full feature inventory, exact public API surface, the deferred-match handshake, and what is Android-platform-specific vs. portable contract an iOS SDK must replicate. Sources read: optolink-android/README.md, all 16 files under optolink-android/src/main/kotlin/com/optomatica/optolink/, example/, build.gradle.kts/manifests, docs/sdk/android-sdk-spec.md, docs/research/android-sdk-kickoff-brief.md, plus a spot-check of the backend resolution/match-store.service.ts (platform handling) and the Flutter iOS pod (currently a no-op stub — iOS deferred matching in Flutter runs entirely in Dart).


1. Feature inventory​

AreaAndroid implementationFile(s)
Entry point / initOptoLink singleton; idempotent, mutex-guarded initialize that runs the once-per-install deferred match; instance registered before the match so handleIntent can park during the network callOptoLink.kt
ConfigImmutable data class: apiKey (CLIENT tier opl_sdk_…, blank rejected at construction), orgKey, baseUrl (trailing / trimmed), timeout 10s per attempt, maxRetries 2, clipboardEnabled true, optional loggerOptoLinkConfig.kt
Direct linkshandleIntent(intent) parses three pinned URI shapes; canonical App Links resolved via GET /\{orgKey}/\{shortCode}/data?deviceId=…, everything else emitted raw; hot Flow<OptoLinkData> with replay = 1 that never errorslinks/LinksEngine.kt
Intent parkingBefore init completes, latest intent parked in an atomic field; double-check closes the park-vs-flush race; flushed after the deferred emissionlinks/LinksEngine.kt
Deferred matchFirst-launch-only bulk POST /match with all signal legs; backend owns the ladder; flag marked complete regardless of outcome; resolveDeferredLink() re-runs regardless of flagmatch/MatchClient.kt, match/FirstLaunchTracker.kt
Clipboard legRead once in the first-launch window; requires opl_ prefix, ≤100 chars; cleared on hit (writes empty clip — clearPrimaryClip is API 28+, minSdk 23); fail-soft; skipped when clipboardEnabled=falsematch/ClipboardTokenReader.kt
Install referrerGoogle Play Install Referrer library behind an internal ReferrerClient seam; 3s connection guard; every failure → null; pure parseClickToken extracts bare opl_… from optolink_click= (last duplicate wins, URL-decoded)match/referrer/
FingerprintExactly 5 fields: platform="Android", osVersion=Build.VERSION.RELEASE, deviceModel=Build.MODEL, language=Locale.toLanguageTag(), timezone=IANA id. Always-on, no GAID, no screen size, no App Set IDmatch/FingerprintCollector.kt
Device IDUUID v4 generated once, persisted in SharedPreferences file optolink_sdk (shared with the first-launch flag); survives reinstall via Android auto-backup when the host opts instore/DeviceIdStore.kt
First-launch flagfirstLaunchDone boolean in the same prefs; "Reset install state" in the example app clears both keysmatch/FirstLaunchTracker.kt
TelemetryPOST /sdk/session (fire-and-forget fallback when no deferred hit), POST /sdk/identity, POST /sdk/identity/clear, POST /sdk/events — all Bearer Authorizationtelemetry/TelemetryClient.kt
HTTP coreOkHttp; per-attempt call timeout; audit headers X-API-Key + X-OptoLink-SDK: optolink-android/0.1.0 on every request (backend never validates them); JSON via platform org.jsonnet/OptoLinkHttp.kt
Error modelTwo sealed exceptions (below); envelope parsing, 429 retryAfterSeconds regexed from the body message Retry after N second(s), details[] only on 400, raw = body verbatimnet/OptoLinkErrors.kt, error/OptoLinkException.kt
Retry matrixREPEAT_SAFE (/match, /data): retry conn/timeout, 429, 5xx. TELEMETRY (identity/events): retry conn/429 only, never 5xx (double-count risk). SESSION: never retry. Backoff: 429 → its seconds, else min(0.5·2^n, 5)s; one shared budget per public callnet/OptoLinkHttp.kt
ThreadingCoroutines throughout: suspend public API, hot SharedFlow for links, internal CoroutineScope(SupervisorJob + Dispatchers.IO), Mutex-protected singleton initall

2. Public API surface (exact, spec §4 "do not reshape")​

class OptoLink private constructor(context, config, dispatcher, referrerClient) {
companion object {
suspend fun initialize(context: Context, config: OptoLinkConfig): OptoLink // idempotent, awaits the deferred match; network failure logged, NOT thrown
fun instance(): OptoLink // IllegalStateException before initialize
}
val deviceId: String // UUID v4, persisted
val config: OptoLinkConfig
val links: Flow<OptoLinkData> // replay = 1; never errors
fun handleIntent(intent: Intent) // onCreate AND onNewIntent; parks before init completes
suspend fun resolveDeferredLink(): OptoLinkData? // null = no match; THROWS on network failure
suspend fun setIdentity(externalId: String) // throws on failure
suspend fun clearIdentity() // throws on failure; idempotent server-side
suspend fun trackEvent(name: String, properties: Map<String, String> = emptyMap()) // throws
fun dispose() // resets singleton; not atomic vs concurrent init (documented ponytail note)
}

data class OptoLinkData(
val linkId: String?, // null for raw direct links
val path: String, // "" (never null) for raw links
val params: Map<String, String>?, // flat, null when absent
val matchMethod: MatchMethod,
val confidence: MatchConfidence,
val isDeferred: Boolean,
)
enum class MatchMethod { CLIPBOARD, INSTALL_REFERRER, FINGERPRINT_EXACT, FINGERPRINT_SCORED, IP_FUZZY, DIRECT, NONE }
enum class MatchConfidence { EXACT, HIGH, MEDIUM, LOW } // unknown wire value → MEDIUM (method → NONE)

sealed class OptoLinkException(message: String) : Exception(message)
class OptoLinkApiException(
val status: Int, val requestId: String?, // requestId only if caller sent x-request-id — SDK never does
val details: List<OptoLinkErrorDetail>?, // 400 only
val retryAfterSeconds: Long?, // 429 only, regexed from body
val raw: String?, // full body verbatim
) : OptoLinkException(...)
class OptoLinkConnectionException(cause: IOException) : OptoLinkException(...) // timeout surfaces as "timed out"
data class OptoLinkErrorDetail(val message: String, val field: String?)

data class OptoLinkConfig(
val apiKey: String, // required non-blank, CLIENT tier opl_sdk_…, no env fallback
val orgKey: String,
val baseUrl: String,
val timeout: Duration = 10.seconds, // per attempt
val maxRetries: Int = 2, // retries AFTER first attempt
val clipboardEnabled: Boolean = true,
val logger: OptoLinkLogger? = null,
)
fun interface OptoLinkLogger { fun log(level: OptoLinkLogLevel, message: String, error: Throwable?) }
enum class OptoLinkLogLevel { WARNING, ERROR } // SDK never logs INFO or finer

Dependencies: kotlinx-coroutines-core (api) + OkHttp 5.4 (implementation) + Play Install Referrer 2.2 (implementation). So NOT zero-dependency — the Node SDK is; Android's budget is "stdlib/coroutines exempt + OkHttp + installreferrer" (the referrer lib is the recorded spec erratum's one addition). minSdk 23, compileSdk 36, explicit API mode, binary-compat-validator gate, Maven Central via vanniktech. Manifest needs only INTERNET; integrators add the autoVerify intent-filter.

3. The deferred-match handshake, end-to-end​

Token generation (server-side, upstream): portal link → click → redirect page embeds optolink_click=opl_… into the Play Store URL and writes the token to the clipboard (&lt;input id="cbt">). Then:

  1. Token storage (device entry points). Play delivers the referrer string (optolink_click=opl_…&utm_source=…) via the Play Install Referrer IPC; the OS clipboard holds the raw token from the redirect page. The SDK holds no token state itself — it reads both sources at match time.
  2. First launch. initialize → deviceId ensured → FirstLaunchTracker.isFirstLaunch() true → collect all legs in one shot: clipboard (opl_ prefix, ≤100 chars, cleared on hit) + referrer (3s-bounded bind/read, parseClickToken → bare opl_…) + fingerprint (5 fields).
  3. One bulk POST /match. Body = 5 fingerprint fields + deviceId + optional clipboardToken / installReferrer (keys omitted, never JSON-null when uncollected). No Authorization — audit headers only. RetryPolicy.REPEAT_SAFE. The backend runs the ladder (P1 clipboard → P1.5 referrer → P2 fingerprint exact → P2.5 scored → P3 IP fuzzy); the SDK never sequences it.
  4. Response mapping. Miss body is exactly \{"matched":false,"matchMethod":"NONE"} → null. Hit keys: matched, matchMethod, matchConfidence, matchScore?, linkId, path, params — nothing else (matchScore tolerated but unread; params may be null; unknown enum values → NONE/MEDIUM). Mapped to OptoLinkData(isDeferred = true) and emitted on links (replay 1 → the first collector gets it even if it subscribed late).
  5. Aftermath. Flag marked complete regardless of outcome (a network failure consumes the first launch — retry is resolveDeferredLink(), which re-runs everything regardless of flag; clipboard is gone by then, so re-attempts carry referrer + fingerprint + any fresh clipboard content). On a miss/failure, fire-and-forget POST /sdk/session (never retried — next app open retries naturally). On a hit, /match's deviceId already upserted the profile — no session call. Finally markInitialized() flushes the parked intent, ordered after the deferred emission.

Direct-link resolution is a separate path: handleIntent → shape 1 (https://\{domain}/\{orgKey}/\{shortCode} with matching orgKey) → GET /\{orgKey}/\{shortCode}/data?deviceId=… (query param upserts the profile) → OptoLinkData(…, DIRECT, EXACT, isDeferred=false). Shapes 2/3 (Chrome-intent rebuilds, custom schemes, non-matching org, or any resolution failure) emit raw: linkId=null, path=uri.path, params=query.

4. Android-platform-specific vs. portable contract​

Android-specific (iOS gets a different mechanism or none)​

Android pieceiOS reality
Play Install Referrer library + optolink_click= URL parseNo equivalent. No App Store install-referrer API exists. iOS candidates: NONE (App Store pushes no referrer), or URL-session-based (Universal Link click → App Store → open) — see open questions
Clipboard read restricted to first-launch-foreground; Android 12+ toastiOS UIPasteboard.general is readable whenever the app is foreground/active; iOS 16+ shows its own paste notification (Settings-granted). Same first-launch-foreground window should be kept, but the platform constraint differs
autoVerify App Links + assetlinks.json (sha256 cert fingerprints, ANDROID AppConfig)Universal Links + apple-app-site-association (team ID + bundle ID, APPLE/IOS AppConfig). Same backend domain-serving concept, different file/registration
Chrome intent fallback (intent://…#Intent;… rebuild) shape 2N/A — iOS has Safari Universal Link behavior instead
Fingerprint sources: Build.MODEL, Build.VERSION.RELEASE, Locale, TimeZoneSame 5-field contract, iOS sources: platform="iOS", UIDevice/ProcessInfo/operatingSystemVersion, Locale.current, TimeZone.current. Backend's scored matcher weights platformMatch: 10 and compares case-insensitively — arbitrary platform strings accepted
SharedPreferences + auto-backup persistenceUserDefaults (+ whether to keychain or exclude-from-backup is a decision)
Activity onCreate/onNewIntent intent forwardingSceneDelegate/UIApplicationDelegate scene(_:continue:)/userActivity + scene(_:openURLContexts:) (+ cold-start connectionOptions)

Portable contract (iOS must replicate verbatim)​

  • The wire contract (spec §6.1/§6.2, live-verified): all six endpoints, auth modes, /match miss/hit body shapes, error envelope, 429 regex Retry after (\d+) second, flat string→string params, omitted-not-null keys.
  • The match orchestration semantics: one bulk POST, backend owns the ladder, once-per-install flag consumed regardless of outcome, clipboard cleared only on hit, session fallback only when no deferred hit, resolveDeferredLink bypasses the flag and throws on network failure while init never throws.
  • The retry matrix and shared budget, backoff min(0.5·2^n, 5)s.
  • The error model: two error types (API vs connection), status/requestId/ details/retryAfterSeconds/raw payloads, success-implicit throwing telemetry.
  • The replay-last-link stream semantics (replay 1, never errors, latest pre-subscription link wins) — Swift equivalent: AsyncStream/AsyncPublisher with buffering, or a current-value @Published-style property.
  • The init race handling: link arrival before init completes must be parked (latest wins) and flushed after the deferred emission, in that order.
  • Privacy posture: no ad identifiers (GAID ↔ IDFA/ATT — never read), 5-field coarse fingerprint only, own UUID install ID, "app's own install ID" / App-Set-ID-equivalent privacy category.
  • Config shape and defaults: timeout 10s, maxRetries 2, clipboardEnabled true, optional warning/error-only logger, non-blank apiKey validation.

5. Spec/kickoff items deferred or iOS-relevant​

From docs/sdk/android-sdk-spec.md §1.2/§13 and the kickoff brief:

  • Parity contract names Flutter, not Android, as the behavioral reference — the Flutter SDK is the 1:1 mirror; where the spec is silent, optolink-flutter/lib/src/ is the reference. An iOS SDK has a head start: the Flutter iOS pod is a no-op stub, so Flutter's iOS behavior (clipboard + fingerprint only, in Dart) is the de-facto iOS baseline the native SDK must match or exceed.
  • First-click timezone limitation (§5.5): client hints are per-origin opt-in; first-click journeys store timezone '', so FINGERPRINT_EXACT is unreachable on a true first click — attribution lands on FINGERPRINT_SCORED (medium). Decision: keep sending timezone. Shared by all SDKs; iOS inherits it until the backend ships Critical-CH replay (product decision, out of scope).
  • Android 15 dynamic_app_link_components not served — the iOS analogue question is AASA appclips/components support, unaddressed anywhere.
  • Deliberately absent on Android (apply by analogy to iOS): GAID ↔ IDFA (never), App Set ID (future option only), multi-store referrers, auto-init via ContentProvider (footgun; iOS analogue would be a load-time +initialize — same call: explicit init only), screen dimensions in the fingerprint.
  • minSdk 23 ↔ iOS deployment target decision (Flutter pod targets iOS 13; backend requires nothing specific).
  • Backend compatibility line: "Compatible with OptoLink backend v2.1.0+"; SDKs run their own semver clock; a breaking backend change forces an SDK major (no backend API versioning).
  • Click tracking is 100% server-side — SDKs contribute only headers/UA + deviceId via resolution endpoints; an iOS SDK must not call any tracking endpoint.
  • Live e2e precedent: keyed, self-skipping suite (OPTOLINK_E2E_SDK_KEY) with clipboard-deferred + two-visit FINGERPRINT_EXACT dance simulated in tests; iOS needs its own version of this (harder — no Robolectric equivalent on device-less CI).

6. Open questions an iOS port must answer​

  1. Install referrer leg: what replaces it? There is no App Store Install Referrer. Options: (a) drop the leg — iOS deferred matching is clipboard + fingerprint + IP only (matches what Flutter-on-iOS does today); (b) add a Universal-Link-click handoff (e.g. first click stores the token via the website → NSUserActivity/pasteboard, or an App Store campaign/AdServices attribution token). Decision shapes MatchMethod.INSTALL_REFERRER's fate on iOS: never emitted, or emitted from a different source.
  2. Clipboard on iOS: which token format and which window? The redirect page writes the token via a "copy" interaction (user gesture). On iOS, UIPasteboard reads require foreground and iOS 16+ raises a paste prompt unless the app sets UIPasteboardDetectionPattern / uses detectPatterns. Does the redirect page's clipboard content reach iOS Safari reliably, and do we keep the plain opl_… prefix check (≤100 chars) or adopt probabilistic detection patterns?
  3. First-launch flag & device-ID persistence: UserDefaults or Keychain? Keychain survives uninstall/reinstall (matching Android's auto-backup direction: continuity is desired), but then "fresh install" never re-arms the match and re-install attribution is impossible. Also: does the once-per-install flag live in the same store so a "reset install state" debug action can clear both?
  4. Links stream API shape in Swift: AsyncSequence with replay-last-value semantics (never throws, deferred result replays to the first consumer) vs. callback/delegate API. And what replaces handleIntent: one entry point fed from both scene(_:continue:) (Universal Links, NSUserActivity.webpageURL) and openURLContexts (custom schemes), plus connectionOptions for cold start?
  5. Universal Links server side: does the backend's well-known serving (currently ANDROID AppConfig-driven assetlinks.json) support the APPLE/IOS AppConfig (apple-app-site-association with appID = teamId.bundleId, no checksums, details array for multiple apps)? Spec §12.1 mentions IOS AppConfig in portal onboarding but the wire surface for AASA wasn't verified like assetlinks was (t003). An iOS SDK spec needs that live verification equivalent.
  6. Fingerprint field values on iOS: exact osVersion string format (e.g. "18.1" vs "18.1.0" — the backend hashes ip:platform:majorMinor(osVersion):lang:tz for P2 exact, so the browser-side and SDK-side formats must agree), deviceModel (e.g. "iPhone" vs "iPhone15,3" — browser UA says "iPhone"; hardware model string would never match), and whether platform should be "iOS" (backend compares case-insensitively; Flutter test fixtures use "iOS").
  7. ATT/privacy manifest: no IDFA ever (Android's GAID decision transfers), but iOS requires a PrivacyInfo.xcprivacy (UserDefaults/device-ID required-reason API categories, pasteboard usage) and App Store privacy labels — what exact categories does OptoLink declare?
  8. Distribution & dependency budget: SPM package? CocoaPods too? Is the budget "zero dependencies (URLSession + Foundation JSON)" like the Node SDK, or "one dependency" like Android? Zero-dep is easy on iOS (URLSession covers everything OkHttp does here) — the Android README's "two dependencies" framing does not automatically transfer.
  9. min iOS version: Flutter pod says iOS 13.0; what does OptoLink pick (Android chose 23 against the competitors' 21 — the same "modern floor vs. competitor floor" question)?
  10. resolveDeferredLink on iOS: if init's automatic match failed on first launch, re-attempts carry fingerprint (+ fresh clipboard). On iOS the clipboard prompt may not reappear — confirm the re-attempt UX is acceptable, or whether the flag should be consumed only after a successful collection window on iOS (a deliberate spec delta if so).