iOS SDK Parity Notes — from the Android SDK study
Purpose: prepare
optolink-iosby recording whatoptolink-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 underoptolink-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 backendresolution/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
| Area | Android implementation | File(s) |
|---|---|---|
| Entry point / init | OptoLink 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 call | OptoLink.kt |
| Config | Immutable data class: apiKey (CLIENT tier opl_sdk_…, blank rejected at construction), orgKey, baseUrl (trailing / trimmed), timeout 10s per attempt, maxRetries 2, clipboardEnabled true, optional logger | OptoLinkConfig.kt |
| Direct links | handleIntent(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 errors | links/LinksEngine.kt |
| Intent parking | Before init completes, latest intent parked in an atomic field; double-check closes the park-vs-flush race; flushed after the deferred emission | links/LinksEngine.kt |
| Deferred match | First-launch-only bulk POST /match with all signal legs; backend owns the ladder; flag marked complete regardless of outcome; resolveDeferredLink() re-runs regardless of flag | match/MatchClient.kt, match/FirstLaunchTracker.kt |
| Clipboard leg | Read 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=false | match/ClipboardTokenReader.kt |
| Install referrer | Google 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/ |
| Fingerprint | Exactly 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 ID | match/FingerprintCollector.kt |
| Device ID | UUID 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 in | store/DeviceIdStore.kt |
| First-launch flag | firstLaunchDone boolean in the same prefs; "Reset install state" in the example app clears both keys | match/FirstLaunchTracker.kt |
| Telemetry | POST /sdk/session (fire-and-forget fallback when no deferred hit), POST /sdk/identity, POST /sdk/identity/clear, POST /sdk/events — all Bearer Authorization | telemetry/TelemetryClient.kt |
| HTTP core | OkHttp; 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.json | net/OptoLinkHttp.kt |
| Error model | Two sealed exceptions (below); envelope parsing, 429 retryAfterSeconds regexed from the body message Retry after N second(s), details[] only on 400, raw = body verbatim | net/OptoLinkErrors.kt, error/OptoLinkException.kt |
| Retry matrix | REPEAT_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 call | net/OptoLinkHttp.kt |
| Threading | Coroutines throughout: suspend public API, hot SharedFlow for links, internal CoroutineScope(SupervisorJob + Dispatchers.IO), Mutex-protected singleton init | all |
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 (<input id="cbt">). Then:
- 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. - First launch.
initialize→deviceIdensured →FirstLaunchTracker.isFirstLaunch()true → collect all legs in one shot: clipboard (opl_prefix, ≤100 chars, cleared on hit) + referrer (3s-bounded bind/read,parseClickToken→ bareopl_…) + fingerprint (5 fields). - One bulk
POST /match. Body = 5 fingerprint fields +deviceId+ optionalclipboardToken/installReferrer(keys omitted, never JSON-null when uncollected). NoAuthorization— 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. - Response mapping. Miss body is exactly
\{"matched":false,"matchMethod":"NONE"}→null. Hit keys:matched, matchMethod, matchConfidence, matchScore?, linkId, path, params— nothing else (matchScoretolerated but unread;paramsmay be null; unknown enum values → NONE/MEDIUM). Mapped toOptoLinkData(isDeferred = true)and emitted onlinks(replay 1 → the first collector gets it even if it subscribed late). - 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-forgetPOST /sdk/session(never retried — next app open retries naturally). On a hit,/match'sdeviceIdalready upserted the profile — no session call. FinallymarkInitialized()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 piece | iOS reality |
|---|---|
Play Install Referrer library + optolink_click= URL parse | No 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+ toast | iOS 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 2 | N/A — iOS has Safari Universal Link behavior instead |
Fingerprint sources: Build.MODEL, Build.VERSION.RELEASE, Locale, TimeZone | Same 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 persistence | UserDefaults (+ whether to keychain or exclude-from-backup is a decision) |
Activity onCreate/onNewIntent intent forwarding | SceneDelegate/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,
/matchmiss/hit body shapes, error envelope, 429 regexRetry after (\d+) second, flat string→stringparams, 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,
resolveDeferredLinkbypasses 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/rawpayloads, success-implicit throwing telemetry. - The replay-last-link stream semantics (replay 1, never errors, latest
pre-subscription link wins) — Swift equivalent:
AsyncStream/AsyncPublisherwith 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
'', soFINGERPRINT_EXACTis unreachable on a true first click — attribution lands onFINGERPRINT_SCORED(medium). Decision: keep sending timezone. Shared by all SDKs; iOS inherits it until the backend shipsCritical-CHreplay (product decision, out of scope). - Android 15
dynamic_app_link_componentsnot served — the iOS analogue question is AASAappclips/componentssupport, 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-visitFINGERPRINT_EXACTdance 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
- 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 Storecampaign/AdServices attribution token). Decision shapesMatchMethod.INSTALL_REFERRER's fate on iOS: never emitted, or emitted from a different source. - Clipboard on iOS: which token format and which window? The redirect page
writes the token via a "copy" interaction (user gesture). On iOS,
UIPasteboardreads require foreground and iOS 16+ raises a paste prompt unless the app setsUIPasteboardDetectionPattern/ usesdetectPatterns. Does the redirect page's clipboard content reach iOS Safari reliably, and do we keep the plainopl_…prefix check (≤100 chars) or adopt probabilistic detection patterns? - 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?
- Links stream API shape in Swift:
AsyncSequencewith replay-last-value semantics (never throws, deferred result replays to the first consumer) vs. callback/delegate API. And what replaceshandleIntent: one entry point fed from bothscene(_:continue:)(Universal Links,NSUserActivity.webpageURL) andopenURLContexts(custom schemes), plusconnectionOptionsfor cold start? - 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-associationwithappID= teamId.bundleId, no checksums,detailsarray 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. - Fingerprint field values on iOS: exact
osVersionstring format (e.g."18.1"vs"18.1.0"— the backend hashesip:platform:majorMinor(osVersion):lang:tzfor 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 whetherplatformshould be"iOS"(backend compares case-insensitively; Flutter test fixtures use"iOS"). - 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? - 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.
- 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)?
resolveDeferredLinkon 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).