react-native-cliqit 0.0.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (39) hide show
  1. package/README.md +209 -0
  2. package/android/build.gradle +27 -0
  3. package/android/src/main/AndroidManifest.xml +1 -0
  4. package/android/src/main/java/com/cliqit/CliqItModule.kt +242 -0
  5. package/android/src/main/java/com/cliqit/CliqItPackage.kt +14 -0
  6. package/android/src/main/java/com/cliqit/sdk/CliqItDefaults.kt +17 -0
  7. package/android/src/main/java/com/cliqit/sdk/CliqItDeviceInfo.kt +196 -0
  8. package/android/src/main/java/com/cliqit/sdk/CliqItHttp.kt +98 -0
  9. package/android/src/main/java/com/cliqit/sdk/CliqItModels.kt +130 -0
  10. package/android/src/main/java/com/cliqit/sdk/CliqItSDK.kt +514 -0
  11. package/android/src/main/java/com/cliqit/sdk/CliqItWebFingerprint.kt +230 -0
  12. package/ios/CliqItModule.m +14 -0
  13. package/ios/CliqItModule.swift +238 -0
  14. package/ios/Frameworks/CliqIt.xcframework/Info.plist +44 -0
  15. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/CliqIt +0 -0
  16. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Headers/CliqIt-Swift.h +382 -0
  17. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Info.plist +0 -0
  18. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios.abi.json +12111 -0
  19. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios.private.swiftinterface +419 -0
  20. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios.swiftdoc +0 -0
  21. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios.swiftinterface +419 -0
  22. package/ios/Frameworks/CliqIt.xcframework/ios-arm64/CliqIt.framework/Modules/module.modulemap +3 -0
  23. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/CliqIt +0 -0
  24. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Headers/CliqIt-Swift.h +760 -0
  25. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Info.plist +0 -0
  26. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios-simulator.abi.json +12111 -0
  27. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios-simulator.private.swiftinterface +419 -0
  28. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios-simulator.swiftdoc +0 -0
  29. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/arm64-apple-ios-simulator.swiftinterface +419 -0
  30. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/x86_64-apple-ios-simulator.abi.json +12111 -0
  31. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/x86_64-apple-ios-simulator.private.swiftinterface +419 -0
  32. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/x86_64-apple-ios-simulator.swiftdoc +0 -0
  33. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/CliqIt.swiftmodule/x86_64-apple-ios-simulator.swiftinterface +419 -0
  34. package/ios/Frameworks/CliqIt.xcframework/ios-arm64_x86_64-simulator/CliqIt.framework/Modules/module.modulemap +3 -0
  35. package/package.json +48 -0
  36. package/react-native-cliqit.podspec +27 -0
  37. package/react-native.config.js +11 -0
  38. package/src/index.d.ts +92 -0
  39. package/src/index.js +152 -0
@@ -0,0 +1,514 @@
1
+ package com.cliqit.sdk
2
+
3
+ import android.content.Context
4
+ import android.net.Uri
5
+ import android.os.Handler
6
+ import android.os.Looper
7
+ import android.util.Log
8
+ import org.json.JSONObject
9
+ import java.util.concurrent.Executors
10
+ import java.util.concurrent.atomic.AtomicBoolean
11
+
12
+ /**
13
+ * Android port of iOS CliqItSDK — configure, deferred match, direct link lookup, handle(url).
14
+ */
15
+ object CliqItSDK {
16
+ private const val TAG = "CliqIt"
17
+
18
+ @Volatile private var appContext: Context? = null
19
+ @Volatile private var apiKey: String? = null
20
+
21
+ private var deepLinkHandler: ((CliqItPayload) -> Unit)? = null
22
+ private var deferredHandler: ((DeferredMatchOutcome) -> Unit)? = null
23
+ private var linkLookupHandler: ((LinkLookupResult) -> Unit)? = null
24
+ private var verifyHandler: ((VerifyOutcome) -> Unit)? = null
25
+
26
+ private var pendingPayload: CliqItPayload? = null
27
+ private var lastLinkDetails: LinkDetails? = null
28
+ private var lastDeferredOutcome: DeferredMatchOutcome? = null
29
+ private var lastVerifyJSON: String? = null
30
+ private var lastVerifyOutcome: VerifyOutcome? = null
31
+ private var launchClickSessionId: String? = null
32
+ private var receivedDirectThisSession = false
33
+ private val matchInFlight = AtomicBoolean(false)
34
+
35
+ private val mainHandler = Handler(Looper.getMainLooper())
36
+ private val io = Executors.newSingleThreadExecutor()
37
+
38
+ val isConfigured: Boolean get() = apiKey != null && appContext != null
39
+
40
+ val hasDeferredMatchBeenReported: Boolean
41
+ get() = prefs()?.getBoolean(CliqItDefaults.KEY_MATCH_REPORTED, false) == true
42
+
43
+ val isDeferredMatchInFlight: Boolean get() = matchInFlight.get()
44
+
45
+ fun init(context: Context) {
46
+ appContext = context.applicationContext
47
+ }
48
+
49
+ fun configure(apiKey: String) {
50
+ if (appContext == null) {
51
+ throw IllegalStateException("Call CliqItSDK.init(context) before configure")
52
+ }
53
+ this.apiKey = apiKey
54
+ Log.i(TAG, "✅ configured")
55
+ verifyInBackground()
56
+ beginDeferredMatchIfNeeded()
57
+ }
58
+
59
+ fun onDeepLink(handler: (CliqItPayload) -> Unit) {
60
+ deepLinkHandler = handler
61
+ pendingPayload?.let {
62
+ pendingPayload = null
63
+ mainHandler.post { handler(it) }
64
+ }
65
+ }
66
+
67
+ fun onDeferredMatch(handler: (DeferredMatchOutcome) -> Unit) {
68
+ deferredHandler = handler
69
+ lastDeferredOutcome?.let { outcome ->
70
+ mainHandler.post { handler(outcome) }
71
+ }
72
+ }
73
+
74
+ fun onDirectLinkLookup(handler: (LinkLookupResult) -> Unit) {
75
+ linkLookupHandler = handler
76
+ lastLinkDetails?.let { details ->
77
+ mainHandler.post { handler(LinkLookupResult.Resolved(details)) }
78
+ }
79
+ }
80
+
81
+ fun onVerify(handler: (VerifyOutcome) -> Unit) {
82
+ verifyHandler = handler
83
+ lastVerifyOutcome?.let { outcome ->
84
+ mainHandler.post { handler(outcome) }
85
+ }
86
+ }
87
+
88
+ fun handle(urlString: String): Boolean {
89
+ val uri = Uri.parse(urlString) ?: return false
90
+ return handle(uri)
91
+ }
92
+
93
+ fun handle(uri: Uri): Boolean {
94
+ captureClickSessionId(uri)
95
+ val key = apiKey
96
+ val ctx = appContext
97
+ if (key == null || ctx == null) {
98
+ pendingPayload = parsePayload(uri, source = "unknown", isDeferred = false)
99
+ return false
100
+ }
101
+
102
+ if (!isAllowedUrl(uri)) {
103
+ Log.i(TAG, "Ignored unsupported URL: $uri")
104
+ return false
105
+ }
106
+
107
+ val payload = parsePayload(uri, source = detectSource(uri), isDeferred = false)
108
+ receivedDirectThisSession = true
109
+
110
+ val slug = slugFrom(uri)
111
+ if (slug != null) {
112
+ fetchLinkDetails(key, uri, slug, payload)
113
+ return true
114
+ }
115
+
116
+ deliver(payload)
117
+ return true
118
+ }
119
+
120
+ private fun parseVerifyOutcome(body: String): VerifyOutcome {
121
+ return try {
122
+ val root = JSONObject(body)
123
+ val checksObj = root.optJSONObject("checks")
124
+ val checks = mutableMapOf<String, VerifyCheck>()
125
+ if (checksObj != null) {
126
+ val keys = checksObj.keys()
127
+ while (keys.hasNext()) {
128
+ val key = keys.next()
129
+ val item = checksObj.optJSONObject(key) ?: continue
130
+ checks[key] = VerifyCheck(
131
+ actual = CliqItHttp.optString(item, "actual"),
132
+ expected = CliqItHttp.optString(item, "expected"),
133
+ match = item.optBoolean("match", false),
134
+ )
135
+ }
136
+ }
137
+ val result = VerifyResult(
138
+ ok = root.optBoolean("ok", false),
139
+ appId = CliqItHttp.optString(root, "appId"),
140
+ appName = CliqItHttp.optString(root, "appName"),
141
+ checks = checks,
142
+ rawJSON = body,
143
+ )
144
+ if (result.ok) VerifyOutcome.Passed(result) else VerifyOutcome.Mismatched(result)
145
+ } catch (e: Exception) {
146
+ VerifyOutcome.Error(e.message ?: "Invalid verify response")
147
+ }
148
+ }
149
+
150
+ private fun beginDeferredMatchIfNeeded() {
151
+ if (hasDeferredMatchBeenReported) return
152
+ val key = apiKey ?: return
153
+ val ctx = appContext ?: return
154
+ performDeferredMatch(ctx, key, markReported = true)
155
+ }
156
+
157
+ private fun verifyInBackground() {
158
+ val key = apiKey ?: return
159
+ val ctx = appContext ?: return
160
+ io.execute {
161
+ val body = JSONObject().apply {
162
+ put("platform", "android")
163
+ put("packageName", CliqItDeviceInfo.packageName(ctx))
164
+ putOpt("androidSha256", CliqItDeviceInfo.androidSha256(ctx))
165
+ }
166
+ val url = CliqItHttp.joinUrl(
167
+ CliqItDefaults.SERVER_URL,
168
+ *CliqItDefaults.VERIFY_PATH.split('/').filter { it.isNotEmpty() }.toTypedArray(),
169
+ )
170
+ CliqItHttp.postJson(url, key, body.toString()).fold(
171
+ onSuccess = { response ->
172
+ lastVerifyJSON = response.body
173
+ val outcome = parseVerifyOutcome(response.body)
174
+ lastVerifyOutcome = outcome
175
+ when (outcome) {
176
+ is VerifyOutcome.Passed ->
177
+ Log.i(TAG, "✅ verify OK — ${outcome.result.appName ?: "app"} (${outcome.result.appId ?: "-"})")
178
+ is VerifyOutcome.Mismatched -> {
179
+ Log.e(TAG, "❌ verify MISMATCH (ok=false)")
180
+ Log.e(TAG, " appName: ${outcome.result.appName ?: "-"}")
181
+ Log.e(TAG, " appId: ${outcome.result.appId ?: "-"}")
182
+ outcome.result.failedChecks.forEach { (field, check) ->
183
+ Log.e(TAG, " • $field: actual=${check.actual ?: "-"} expected=${check.expected ?: "-"}")
184
+ }
185
+ Log.e(TAG, " Fix: use this app's API key, or match package / SHA in admin.")
186
+ }
187
+ is VerifyOutcome.Error ->
188
+ Log.e(TAG, "❌ verify error: ${outcome.message}")
189
+ }
190
+ mainHandler.post { verifyHandler?.invoke(outcome) }
191
+ },
192
+ onFailure = { err ->
193
+ val message = err.message ?: "verify failed"
194
+ Log.e(TAG, "❌ verify error: $message")
195
+ val outcome = VerifyOutcome.Error(message)
196
+ lastVerifyOutcome = outcome
197
+ mainHandler.post { verifyHandler?.invoke(outcome) }
198
+ },
199
+ )
200
+ }
201
+ }
202
+
203
+ private fun performDeferredMatch(ctx: Context, key: String, markReported: Boolean) {
204
+ if (!matchInFlight.compareAndSet(false, true)) return
205
+
206
+ io.execute {
207
+ try {
208
+ val body = buildMatchBody(ctx)
209
+ val bodyJson = body.toString()
210
+ val url = CliqItHttp.joinUrl(
211
+ CliqItDefaults.SERVER_URL,
212
+ *CliqItDefaults.DEFERRED_MATCH_PATH.split('/').filter { it.isNotEmpty() }.toTypedArray(),
213
+ )
214
+
215
+ CliqItHttp.postJson(url, key, bodyJson).fold(
216
+ onSuccess = { response ->
217
+ val outcome = parseMatchOutcome(response.body)
218
+ lastDeferredOutcome = outcome
219
+ if (markReported && outcome is DeferredMatchOutcome.Matched) {
220
+ prefs()?.edit()?.putBoolean(CliqItDefaults.KEY_MATCH_REPORTED, true)?.apply()
221
+ }
222
+ when (outcome) {
223
+ is DeferredMatchOutcome.Matched ->
224
+ Log.i(TAG, "✅ deferred match OK — path=${outcome.info.destinationPath ?: "-"} slug=${outcome.info.slug ?: "-"}")
225
+ is DeferredMatchOutcome.NotMatched ->
226
+ Log.i(TAG, "✅ deferred match finished — notMatched score=${outcome.info.score}")
227
+ is DeferredMatchOutcome.Failed ->
228
+ Log.e(TAG, "❌ deferred match error: ${outcome.error}")
229
+ }
230
+ mainHandler.post { deferredHandler?.invoke(outcome) }
231
+ if (outcome is DeferredMatchOutcome.Matched) {
232
+ maybeDeliverDeferred(outcome.info)
233
+ }
234
+ },
235
+ onFailure = { err ->
236
+ val message = err.message ?: "match failed"
237
+ Log.e(TAG, "❌ deferred match error: $message")
238
+ val outcome = DeferredMatchOutcome.Failed(message)
239
+ lastDeferredOutcome = outcome
240
+ mainHandler.post { deferredHandler?.invoke(outcome) }
241
+ },
242
+ )
243
+ } catch (e: Exception) {
244
+ val message = e.message ?: e.toString()
245
+ Log.e(TAG, "❌ deferred match error: $message", e)
246
+ val outcome = DeferredMatchOutcome.Failed(message)
247
+ lastDeferredOutcome = outcome
248
+ mainHandler.post { deferredHandler?.invoke(outcome) }
249
+ } finally {
250
+ matchInFlight.set(false)
251
+ }
252
+ }
253
+ }
254
+
255
+ private fun maybeDeliverDeferred(info: DeferredMatchInfo) {
256
+ val prefs = prefs() ?: return
257
+ if (prefs.getBoolean(CliqItDefaults.KEY_DEFERRED_DELIVERED, false)) return
258
+ if (receivedDirectThisSession || pendingPayload != null) return
259
+ val path = LinkDetails.normalizePath(info.destinationPath) ?: return
260
+ prefs.edit().putBoolean(CliqItDefaults.KEY_DEFERRED_DELIVERED, true).apply()
261
+ deliver(
262
+ CliqItPayload(
263
+ url = "${CliqItDefaults.SERVER_URL}$path",
264
+ path = path,
265
+ pathComponents = path.split('/').filter { it.isNotEmpty() },
266
+ query = emptyMap(),
267
+ source = "deferred",
268
+ isDeferred = true,
269
+ ),
270
+ )
271
+ }
272
+
273
+ private fun buildMatchBody(ctx: Context): JSONObject {
274
+ val (level, charging) = CliqItDeviceInfo.battery(ctx)
275
+ // Collect WebView fingerprints before POST (same signals as iOS).
276
+ val web = CliqItWebFingerprint.collect(ctx)
277
+ return JSONObject().apply {
278
+ put("platform", "android")
279
+ put("packageName", CliqItDeviceInfo.packageName(ctx))
280
+ putOpt("androidSha256", CliqItDeviceInfo.androidSha256(ctx))
281
+ put("osVersionMajor", CliqItDeviceInfo.osVersionMajor())
282
+ put("osVersionMajorMinor", CliqItDeviceInfo.osVersionMajorMinor())
283
+ put("deviceModelClass", "android")
284
+ put("deviceName", CliqItDeviceInfo.deviceName())
285
+ put("locale", CliqItDeviceInfo.matchLocale())
286
+ put("timezone", CliqItDeviceInfo.timezone())
287
+ put("screenBucket", CliqItDeviceInfo.screenBucket(ctx))
288
+ put("appOpenAt", System.currentTimeMillis())
289
+ putOpt("connectionType", CliqItDeviceInfo.connectionType(ctx))
290
+ putOpt("batteryLevel", level)
291
+ putOpt("batteryCharging", charging)
292
+ put("languagesOrdered", CliqItDeviceInfo.languagesOrdered())
293
+ put("colorScheme", CliqItDeviceInfo.colorScheme(ctx))
294
+ put("hourCycle", CliqItDeviceInfo.hourCycle())
295
+ putOpt("currency", CliqItDeviceInfo.currencyCode())
296
+ putOpt("regionCode", CliqItDeviceInfo.regionCode())
297
+ put("dynamicTypeSize", CliqItDeviceInfo.fontScale(ctx))
298
+ put("boldText", CliqItDeviceInfo.boldText(ctx))
299
+ put("reduceMotion", CliqItDeviceInfo.reduceMotion(ctx))
300
+ put("increaseContrast", CliqItDeviceInfo.increaseContrast(ctx))
301
+ put("hardwareConcurrency", CliqItDeviceInfo.hardwareConcurrency())
302
+ put("devicePixelRatioBucket", CliqItDeviceInfo.devicePixelRatioBucket(ctx))
303
+ putOpt("clickSessionId", launchClickSessionId)
304
+ web?.clockSkewMs?.let { put("clockSkewMs", it) }
305
+ putOpt("canvasHash", web?.canvasHash)
306
+ putOpt("webglHash", web?.webglHash)
307
+ putOpt("webglVendor", web?.webglVendor)
308
+ putOpt("gpuRenderer", web?.gpuRenderer)
309
+ putOpt("audioFingerprint", web?.audioFingerprint)
310
+ }
311
+ }
312
+
313
+ private fun parseMatchOutcome(body: String): DeferredMatchOutcome {
314
+ return try {
315
+ val json = JSONObject(body)
316
+ // Some APIs return { status:false, message:"..." } even with HTTP 200
317
+ if (json.has("status") && !json.optBoolean("status", true) && !json.has("matched")) {
318
+ val msg = CliqItHttp.optString(json, "message") ?: body
319
+ return DeferredMatchOutcome.Failed(msg)
320
+ }
321
+ val info = DeferredMatchInfo(
322
+ matched = json.optBoolean("matched", false),
323
+ tier = CliqItHttp.optString(json, "tier"),
324
+ confidence = when {
325
+ json.has("confidence") && !json.isNull("confidence") ->
326
+ json.opt("confidence")?.toString()
327
+ else -> null
328
+ },
329
+ score = CliqItHttp.optDouble(json, "score"),
330
+ destinationPath = CliqItHttp.optString(json, "destinationPath"),
331
+ slug = CliqItHttp.optString(json, "slug"),
332
+ )
333
+ if (info.matched) DeferredMatchOutcome.Matched(info) else DeferredMatchOutcome.NotMatched(info)
334
+ } catch (e: Exception) {
335
+ DeferredMatchOutcome.Failed(e.message ?: "decode failed")
336
+ }
337
+ }
338
+
339
+ private fun fetchLinkDetails(
340
+ key: String,
341
+ uri: Uri,
342
+ slug: String,
343
+ fallback: CliqItPayload,
344
+ ) {
345
+ val ctx = appContext ?: return
346
+ io.execute {
347
+ var url = CliqItHttp.joinUrl(
348
+ CliqItDefaults.SERVER_URL,
349
+ *CliqItDefaults.LINK_LOOKUP_PATH.split('/').filter { it.isNotEmpty() }.toTypedArray(),
350
+ slug,
351
+ )
352
+ url = CliqItHttp.appendIdentityQuery(url, ctx)
353
+ CliqItHttp.get(url, key).fold(
354
+ onSuccess = { response ->
355
+ when (val parsed = parseLinkDetails(response.body)) {
356
+ is LinkLookupResult.Resolved -> {
357
+ lastLinkDetails = parsed.details
358
+ Log.i(TAG, "✅ link lookup OK — path=${parsed.details.resolvedPath ?: "-"} slug=${parsed.details.slug ?: slug}")
359
+ val resolved = resolvedPayload(parsed.details, uri, fallback)
360
+ mainHandler.post {
361
+ linkLookupHandler?.invoke(parsed)
362
+ deliver(resolved ?: fallback)
363
+ }
364
+ }
365
+ is LinkLookupResult.Failed -> {
366
+ Log.e(TAG, "❌ link lookup error: ${parsed.error}")
367
+ mainHandler.post {
368
+ linkLookupHandler?.invoke(parsed)
369
+ deliver(fallback)
370
+ }
371
+ }
372
+ }
373
+ },
374
+ onFailure = { err ->
375
+ val message = err.message ?: "lookup failed"
376
+ Log.e(TAG, "❌ link lookup error: $message")
377
+ mainHandler.post {
378
+ linkLookupHandler?.invoke(LinkLookupResult.Failed(message))
379
+ deliver(fallback)
380
+ }
381
+ },
382
+ )
383
+ }
384
+ }
385
+
386
+ private fun parseLinkDetails(body: String): LinkLookupResult {
387
+ return try {
388
+ val root = JSONObject(body)
389
+ if (!root.optBoolean("status", false)) {
390
+ return LinkLookupResult.Failed(CliqItHttp.optString(root, "message") ?: "Link lookup failed")
391
+ }
392
+ val data = root.optJSONObject("data")
393
+ ?: return LinkLookupResult.Failed("Missing data")
394
+ LinkLookupResult.Resolved(
395
+ LinkDetails(
396
+ destination = CliqItHttp.optString(data, "destination"),
397
+ iosDestination = CliqItHttp.optString(data, "iosDestination"),
398
+ androidDestination = CliqItHttp.optString(data, "androidDestination"),
399
+ ogTitle = CliqItHttp.optString(data, "ogTitle"),
400
+ ogDescription = CliqItHttp.optString(data, "ogDescription"),
401
+ ogImage = CliqItHttp.optString(data, "ogImage"),
402
+ ogUrl = CliqItHttp.optString(data, "ogUrl"),
403
+ slug = CliqItHttp.optString(data, "slug"),
404
+ webFallback = CliqItHttp.optString(data, "webFallback"),
405
+ showInterstitial = CliqItHttp.optBool(data, "showInterstitial"),
406
+ isDeepLink = CliqItHttp.optBool(data, "isDeepLink"),
407
+ appleTeamId = CliqItHttp.optString(data, "appleTeamId"),
408
+ iosBundleId = CliqItHttp.optString(data, "iosBundleId"),
409
+ androidPackageName = CliqItHttp.optString(data, "androidPackageName"),
410
+ ),
411
+ )
412
+ } catch (e: Exception) {
413
+ LinkLookupResult.Failed(e.message ?: "Invalid link lookup response")
414
+ }
415
+ }
416
+
417
+ private fun resolvedPayload(details: LinkDetails, uri: Uri, fallback: CliqItPayload): CliqItPayload? {
418
+ val path = details.resolvedPath ?: return null
419
+ return CliqItPayload(
420
+ url = uri.toString(),
421
+ path = path,
422
+ pathComponents = path.split('/').filter { it.isNotEmpty() },
423
+ query = fallback.query,
424
+ source = fallback.source,
425
+ isDeferred = false,
426
+ )
427
+ }
428
+
429
+ private fun deliver(payload: CliqItPayload) {
430
+ val handler = deepLinkHandler
431
+ if (handler != null) {
432
+ mainHandler.post { handler(payload) }
433
+ } else {
434
+ pendingPayload = payload
435
+ }
436
+ }
437
+
438
+ private fun prefs() =
439
+ appContext?.getSharedPreferences(CliqItDefaults.PREFS, Context.MODE_PRIVATE)
440
+
441
+ private fun slugFrom(uri: Uri): String? {
442
+ val last = uri.pathSegments.lastOrNull()?.takeIf { it.isNotEmpty() }
443
+ if (last != null) return last
444
+ return uri.host?.takeIf { it.isNotEmpty() }
445
+ }
446
+
447
+ private fun captureClickSessionId(uri: Uri) {
448
+ if (launchClickSessionId != null) return
449
+ for (key in listOf("session", "clickSessionId", "click_session_id")) {
450
+ val v = uri.getQueryParameter(key)?.trim()
451
+ if (!v.isNullOrEmpty()) {
452
+ launchClickSessionId = v
453
+ return
454
+ }
455
+ }
456
+ }
457
+
458
+ private fun detectSource(uri: Uri): String {
459
+ val scheme = uri.scheme?.lowercase().orEmpty()
460
+ return when {
461
+ scheme == "http" || scheme == "https" -> "universalLink"
462
+ scheme.isNotEmpty() -> "customScheme"
463
+ else -> "unknown"
464
+ }
465
+ }
466
+
467
+ /** Only `*.theblockyapp.com` https hosts, or custom schemes registered by this app. */
468
+ private fun isAllowedUrl(uri: Uri): Boolean {
469
+ val scheme = uri.scheme?.lowercase().orEmpty()
470
+ return when {
471
+ scheme == "http" || scheme == "https" -> {
472
+ val host = uri.host?.lowercase() ?: return false
473
+ isAllowedHost(host)
474
+ }
475
+ scheme.isNotEmpty() -> isRegisteredScheme(scheme)
476
+ else -> false
477
+ }
478
+ }
479
+
480
+ private fun isAllowedHost(host: String): Boolean {
481
+ val root = CliqItDefaults.ALLOWED_LINK_DOMAIN.lowercase()
482
+ return host == root || host.endsWith(".$root")
483
+ }
484
+
485
+ private fun isRegisteredScheme(scheme: String): Boolean {
486
+ val ctx = appContext ?: return false
487
+ return try {
488
+ val probe = android.content.Intent(
489
+ android.content.Intent.ACTION_VIEW,
490
+ Uri.parse("$scheme://cliqit-allow-check"),
491
+ ).setPackage(ctx.packageName)
492
+ ctx.packageManager.queryIntentActivities(probe, 0).isNotEmpty()
493
+ } catch (_: Exception) {
494
+ false
495
+ }
496
+ }
497
+
498
+ private fun parsePayload(uri: Uri, source: String, isDeferred: Boolean): CliqItPayload {
499
+ val path = uri.path?.takeIf { it.isNotEmpty() } ?: "/"
500
+ val components = uri.pathSegments.filter { it.isNotEmpty() }
501
+ val query = mutableMapOf<String, String>()
502
+ uri.queryParameterNames.forEach { name ->
503
+ uri.getQueryParameter(name)?.let { query[name] = it }
504
+ }
505
+ return CliqItPayload(
506
+ url = uri.toString(),
507
+ path = path,
508
+ pathComponents = components,
509
+ query = query,
510
+ source = source,
511
+ isDeferred = isDeferred,
512
+ )
513
+ }
514
+ }