@advenue/react-native 1.0.1 → 1.1.0
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.
- package/android/src/main/java/expo/modules/advenue/AdvenueAndroidModule.kt +4 -0
- package/android/src/main/kotlin/io/advenue/Advenue.kt +49 -9
- package/android/src/main/kotlin/io/advenue/AdvenueConfig.kt +9 -1
- package/android/src/main/kotlin/io/advenue/DebugLog.kt +51 -0
- package/android/src/main/kotlin/io/advenue/core/Backoff.kt +14 -1
- package/android/src/main/kotlin/io/advenue/core/CommandPipe.kt +28 -2
- package/android/src/main/kotlin/io/advenue/core/Engine.kt +73 -3
- package/android/src/main/kotlin/io/advenue/core/EventQueue.kt +12 -0
- package/android/src/main/kotlin/io/advenue/core/SessionTracker.kt +109 -33
- package/android/src/main/kotlin/io/advenue/platform/ConversionFetcher.kt +24 -10
- package/android/src/main/kotlin/io/advenue/platform/ForegroundTracker.kt +9 -2
- package/android/src/main/kotlin/io/advenue/platform/HttpUrlTransport.kt +21 -8
- package/android/src/main/kotlin/io/advenue/platform/InstallEnrichment.kt +7 -0
- package/android/src/main/kotlin/io/advenue/platform/LifecycleBridge.kt +23 -1
- package/dist/index.cjs +9 -2
- package/dist/index.d.cts +13 -3
- package/dist/index.d.ts +13 -3
- package/dist/index.js +9 -2
- package/ios/AdvenueIosModule.swift +4 -1
- package/ios/vendor/Advenue/Advenue.swift +52 -17
- package/ios/vendor/Advenue/AdvenueConfig.swift +10 -2
- package/ios/vendor/Advenue/AdvenueSetupError.swift +24 -0
- package/ios/vendor/Advenue/DebugLog.swift +46 -0
- package/ios/vendor/AdvenueCore/Backoff.swift +16 -2
- package/ios/vendor/AdvenueCore/Engine.swift +58 -4
- package/ios/vendor/AdvenueCore/EventQueue.swift +12 -0
- package/ios/vendor/AdvenueCore/SessionTracker.swift +89 -32
- package/ios/vendor/AdvenuePlatform/ChallengeFetcher.swift +14 -2
- package/ios/vendor/AdvenuePlatform/ConversionFetcher.swift +14 -3
- package/ios/vendor/AdvenuePlatform/DeviceInfo.swift +19 -0
- package/ios/vendor/AdvenuePlatform/HttpTransport.swift +28 -4
- package/ios/vendor/AdvenuePlatform/InstallEnrichment.swift +4 -0
- package/ios/vendor/AdvenuePlatform/SkanConfigFetcher.swift +14 -2
- package/package.json +9 -9
- package/src/index.ts +11 -1
- package/src/types.ts +13 -3
|
@@ -24,23 +24,37 @@ public class HttpConversionFetcher(
|
|
|
24
24
|
private val timeoutMs: Int = 15_000,
|
|
25
25
|
) : ConversionFetcher {
|
|
26
26
|
|
|
27
|
-
|
|
27
|
+
internal val requestMethod: String = "GET"
|
|
28
|
+
|
|
29
|
+
/**
|
|
30
|
+
* Sends `?platform=android`: this fetcher is Android-only by construction,
|
|
31
|
+
* and a project key spans listings, so the server needs the platform to pick
|
|
32
|
+
* the one this install was filed under (Y-LINKS-1). Without it the lookup
|
|
33
|
+
* refused the project key the dashboard mints, and the deferred deep link
|
|
34
|
+
* never arrived. No `store`: the SDK does not report one on events either, so
|
|
35
|
+
* both sides fall back to the project's default Android store.
|
|
36
|
+
*/
|
|
37
|
+
internal fun requestUrl(): URL {
|
|
28
38
|
val escaped = URLEncoder.encode(deviceId, "UTF-8")
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
|
|
39
|
+
return URL("$endpoint/sdk/conversion-data?deviceId=$escaped&platform=android")
|
|
40
|
+
}
|
|
41
|
+
|
|
42
|
+
internal fun requestHeaders(): Map<String, String> = mapOf("x-api-key" to apiKey)
|
|
43
|
+
|
|
44
|
+
override fun fetch(): ConversionResult {
|
|
45
|
+
val connection = requestUrl().openConnection() as HttpURLConnection
|
|
32
46
|
try {
|
|
33
|
-
connection.requestMethod =
|
|
47
|
+
connection.requestMethod = requestMethod
|
|
34
48
|
connection.connectTimeout = timeoutMs
|
|
35
49
|
connection.readTimeout = timeoutMs
|
|
36
|
-
connection.setRequestProperty(
|
|
50
|
+
for ((name, value) in requestHeaders()) connection.setRequestProperty(name, value)
|
|
37
51
|
|
|
38
52
|
val status =
|
|
39
53
|
try {
|
|
40
54
|
connection.responseCode
|
|
41
|
-
} catch (
|
|
55
|
+
} catch (error: IOException) {
|
|
42
56
|
// No status to reason about; 408 marks it retryable, as sdk-core does.
|
|
43
|
-
throw IngestError(408)
|
|
57
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
44
58
|
}
|
|
45
59
|
if (status < 200 || status >= 300) throw IngestError(status)
|
|
46
60
|
|
|
@@ -59,8 +73,8 @@ public class HttpConversionFetcher(
|
|
|
59
73
|
)
|
|
60
74
|
} catch (error: IngestError) {
|
|
61
75
|
throw error
|
|
62
|
-
} catch (
|
|
63
|
-
throw IngestError(408)
|
|
76
|
+
} catch (error: IOException) {
|
|
77
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
64
78
|
} finally {
|
|
65
79
|
connection.disconnect()
|
|
66
80
|
}
|
|
@@ -38,9 +38,16 @@ internal enum class ForegroundTransition {
|
|
|
38
38
|
* `ProcessLifecycleOwner`'s constant. We cannot take the dependency, but we can
|
|
39
39
|
* take its number.
|
|
40
40
|
*/
|
|
41
|
-
internal class ForegroundTracker {
|
|
41
|
+
internal class ForegroundTracker(seededInForeground: Boolean = false) {
|
|
42
42
|
private var started = 0
|
|
43
|
-
|
|
43
|
+
|
|
44
|
+
/**
|
|
45
|
+
* Seeded by initialize to match the session it did or did not open. Seeded
|
|
46
|
+
* as foregrounded, the launcher activity's first start is not a second
|
|
47
|
+
* foreground, and the stop of an activity that started before the bridge
|
|
48
|
+
* was registered still reaches the background.
|
|
49
|
+
*/
|
|
50
|
+
private var inForeground = seededInForeground
|
|
44
51
|
private var backgroundPending = false
|
|
45
52
|
|
|
46
53
|
public fun onSignal(signal: LifecycleSignal): ForegroundTransition =
|
|
@@ -5,6 +5,7 @@ import io.advenue.core.ClientEvent
|
|
|
5
5
|
import io.advenue.core.EventTransport
|
|
6
6
|
import io.advenue.core.IngestError
|
|
7
7
|
import io.advenue.core.JvmHmacSigner
|
|
8
|
+
import io.advenue.core.iso8601
|
|
8
9
|
import io.advenue.core.Signer
|
|
9
10
|
import io.advenue.core.signRequest
|
|
10
11
|
import io.advenue.core.parseJson
|
|
@@ -48,8 +49,17 @@ internal class HttpUrlTransport(
|
|
|
48
49
|
* wire shape without a network round trip, which is the part that has to be
|
|
49
50
|
* right.
|
|
50
51
|
*/
|
|
51
|
-
internal fun buildBody(events: List<ClientEvent
|
|
52
|
-
writeCanonicalJson(
|
|
52
|
+
internal fun buildBody(events: List<ClientEvent>, nowMs: Long = clock.nowMs()): String =
|
|
53
|
+
writeCanonicalJson(
|
|
54
|
+
mapOf(
|
|
55
|
+
"apiKey" to apiKey,
|
|
56
|
+
"events" to events.map { it.toMap() },
|
|
57
|
+
// The device clock at THIS attempt (spec 2026-10-01-data-fidelity D3):
|
|
58
|
+
// the server compares it with its receive time to measure the clock
|
|
59
|
+
// offset, so it is stamped per send and never persisted with the queue.
|
|
60
|
+
"sentAt" to iso8601(nowMs),
|
|
61
|
+
),
|
|
62
|
+
)
|
|
53
63
|
|
|
54
64
|
/** The headers for [body]; empty of signature headers when no secret is set. */
|
|
55
65
|
internal fun buildHeaders(body: String, timestamp: String): Map<String, String> {
|
|
@@ -61,8 +71,11 @@ internal class HttpUrlTransport(
|
|
|
61
71
|
}
|
|
62
72
|
|
|
63
73
|
override fun send(events: List<ClientEvent>) {
|
|
64
|
-
|
|
65
|
-
|
|
74
|
+
// One reading for both `sentAt` and the signed timestamp: they describe the
|
|
75
|
+
// same instant, and two reads could straddle a clock change.
|
|
76
|
+
val nowMs = clock.nowMs()
|
|
77
|
+
val body = buildBody(events, nowMs)
|
|
78
|
+
val headers = buildHeaders(body, nowMs.toString())
|
|
66
79
|
|
|
67
80
|
val connection = URL("$endpoint/v1/events").openConnection() as HttpURLConnection
|
|
68
81
|
try {
|
|
@@ -77,9 +90,9 @@ internal class HttpUrlTransport(
|
|
|
77
90
|
val status =
|
|
78
91
|
try {
|
|
79
92
|
connection.responseCode
|
|
80
|
-
} catch (
|
|
93
|
+
} catch (error: IOException) {
|
|
81
94
|
// No response at all is a transient failure, not a poison payload.
|
|
82
|
-
throw IngestError(408)
|
|
95
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
83
96
|
}
|
|
84
97
|
if (status < 200 || status >= 300) throw IngestError(status)
|
|
85
98
|
|
|
@@ -92,8 +105,8 @@ internal class HttpUrlTransport(
|
|
|
92
105
|
}
|
|
93
106
|
} catch (error: IngestError) {
|
|
94
107
|
throw error
|
|
95
|
-
} catch (
|
|
96
|
-
throw IngestError(408)
|
|
108
|
+
} catch (error: IOException) {
|
|
109
|
+
throw IngestError(408, networkCause = error.javaClass.simpleName)
|
|
97
110
|
} finally {
|
|
98
111
|
connection.disconnect()
|
|
99
112
|
}
|
|
@@ -28,6 +28,13 @@ import java.util.concurrent.TimeUnit
|
|
|
28
28
|
*/
|
|
29
29
|
internal const val REFERRER_DEADLINE_MS: Long = 5_000
|
|
30
30
|
|
|
31
|
+
/**
|
|
32
|
+
* How long past [REFERRER_DEADLINE_MS] flushes stay held for a pending install:
|
|
33
|
+
* the enrichment thread still has to submit the install through the pipe after
|
|
34
|
+
* its deadline.
|
|
35
|
+
*/
|
|
36
|
+
internal const val INSTALL_HOLD_MARGIN_MS: Long = 2_000
|
|
37
|
+
|
|
31
38
|
/** A shorter budget for the enrichment that is not attribution-critical. */
|
|
32
39
|
internal const val ENRICHMENT_DEADLINE_MS: Long = 3_000
|
|
33
40
|
|
|
@@ -1,6 +1,7 @@
|
|
|
1
1
|
package io.advenue.platform
|
|
2
2
|
|
|
3
3
|
import android.app.Activity
|
|
4
|
+
import android.app.ActivityManager
|
|
4
5
|
import android.app.Application
|
|
5
6
|
import android.os.Bundle
|
|
6
7
|
import android.os.Handler
|
|
@@ -19,8 +20,9 @@ internal class LifecycleBridge(
|
|
|
19
20
|
private val application: Application,
|
|
20
21
|
private val onForeground: () -> Unit,
|
|
21
22
|
private val onBackground: () -> Unit,
|
|
23
|
+
seededInForeground: Boolean,
|
|
22
24
|
) : Application.ActivityLifecycleCallbacks {
|
|
23
|
-
private val tracker = ForegroundTracker()
|
|
25
|
+
private val tracker = ForegroundTracker(seededInForeground)
|
|
24
26
|
private val handler = Handler(Looper.getMainLooper())
|
|
25
27
|
private val debounce = Runnable { apply(tracker.onSignal(LifecycleSignal.DebounceElapsed)) }
|
|
26
28
|
|
|
@@ -70,3 +72,23 @@ internal class LifecycleBridge(
|
|
|
70
72
|
|
|
71
73
|
override fun onActivityDestroyed(activity: Activity): Unit = Unit
|
|
72
74
|
}
|
|
75
|
+
|
|
76
|
+
/**
|
|
77
|
+
* Whether this process is running for a user-visible launch, read at
|
|
78
|
+
* initialize. `Application.onCreate` runs for a tapped icon and equally for a
|
|
79
|
+
* push, a WorkManager job or a broadcast that wakes the process with no
|
|
80
|
+
* activity; only the first is a session. During a user launch the system has
|
|
81
|
+
* already made the process top, so its importance is `IMPORTANCE_FOREGROUND`;
|
|
82
|
+
* a background wake reports a service or cached importance. The same check
|
|
83
|
+
* crash reporters use to tell a cold start from a background start. True
|
|
84
|
+
* wherever it cannot be read, which keeps the pre-existing behaviour: the
|
|
85
|
+
* launch is a foreground.
|
|
86
|
+
*/
|
|
87
|
+
internal fun readLaunchedInForeground(): Boolean =
|
|
88
|
+
try {
|
|
89
|
+
val info = ActivityManager.RunningAppProcessInfo()
|
|
90
|
+
ActivityManager.getMyMemoryState(info)
|
|
91
|
+
info.importance == ActivityManager.RunningAppProcessInfo.IMPORTANCE_FOREGROUND
|
|
92
|
+
} catch (_: Throwable) {
|
|
93
|
+
true
|
|
94
|
+
}
|
package/dist/index.cjs
CHANGED
|
@@ -65,7 +65,7 @@ module.exports = __toCommonJS(index_exports);
|
|
|
65
65
|
// package.json
|
|
66
66
|
var package_default = {
|
|
67
67
|
name: "@advenue/react-native",
|
|
68
|
-
version: "1.0
|
|
68
|
+
version: "1.1.0",
|
|
69
69
|
description: "Advenue MMP React Native SDK \u2014 attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
|
|
70
70
|
type: "module",
|
|
71
71
|
license: "MIT",
|
|
@@ -225,6 +225,7 @@ var started = false;
|
|
|
225
225
|
var deepLinkSub = null;
|
|
226
226
|
var errorSub = null;
|
|
227
227
|
var onErrorHook;
|
|
228
|
+
var debugLogging = false;
|
|
228
229
|
var consentCache = false;
|
|
229
230
|
function bridge() {
|
|
230
231
|
return { ios: getIosNative(), android: getAndroidNative() };
|
|
@@ -249,6 +250,7 @@ function report(context, cause) {
|
|
|
249
250
|
onErrorHook?.(context, cause);
|
|
250
251
|
} catch {
|
|
251
252
|
}
|
|
253
|
+
if (debugLogging) console.warn(`[Advenue] ${context}`, cause);
|
|
252
254
|
}
|
|
253
255
|
function requireStarted() {
|
|
254
256
|
if (started) return true;
|
|
@@ -262,6 +264,7 @@ var Advenue = {
|
|
|
262
264
|
*/
|
|
263
265
|
async initialize(config) {
|
|
264
266
|
onErrorHook = config.onError;
|
|
267
|
+
debugLogging = config.debug === true;
|
|
265
268
|
for (const option of IGNORED_OPTIONS) {
|
|
266
269
|
if (config[option] !== void 0) {
|
|
267
270
|
report(
|
|
@@ -280,7 +283,11 @@ var Advenue = {
|
|
|
280
283
|
signingSecret: config.signingSecret,
|
|
281
284
|
fbAppId: config.fbAppId,
|
|
282
285
|
autoTrackInstall: config.autoTrackInstall !== false,
|
|
283
|
-
|
|
286
|
+
// Names the wrapper, like Flutter's `advenue_sdk/…`: a bare version reads
|
|
287
|
+
// like the native SDK's own stamp.
|
|
288
|
+
sdkVersion: `react-native/${package_default.version}`,
|
|
289
|
+
debug: config.debug === true,
|
|
290
|
+
allowInsecureHttp: config.allowInsecureHttp === true
|
|
284
291
|
});
|
|
285
292
|
started = true;
|
|
286
293
|
const { ios: iosForErrors, android: androidForErrors } = bridge();
|
package/dist/index.d.cts
CHANGED
|
@@ -54,14 +54,12 @@ interface TrackOptions {
|
|
|
54
54
|
interface AdvenueConfig {
|
|
55
55
|
apiKey: string;
|
|
56
56
|
endpoint?: string;
|
|
57
|
-
platform?: 'ios' | 'android' | 'web' | string;
|
|
58
57
|
/**
|
|
59
58
|
* @deprecated Ignored: the native SDK resolves the app version from the
|
|
60
59
|
* package/bundle. Passing it reports `config.ignored:appVersion` via
|
|
61
60
|
* `onError`. Removed in the next major.
|
|
62
61
|
*/
|
|
63
62
|
appVersion?: string;
|
|
64
|
-
osVersion?: string;
|
|
65
63
|
/** Gates every event on `setTrackingConsent(true)`. */
|
|
66
64
|
requireConsent?: boolean;
|
|
67
65
|
/** Events per request. */
|
|
@@ -74,9 +72,21 @@ interface AdvenueConfig {
|
|
|
74
72
|
* extracted from it.
|
|
75
73
|
*/
|
|
76
74
|
signingSecret?: string;
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* **Test only.** Allows an `http://` endpoint — a local self-hosted ingest,
|
|
77
|
+
* or a test server — which the native SDKs otherwise refuse at initialize.
|
|
78
|
+
* Never set it in a production build: everything, the API key included,
|
|
79
|
+
* then travels unencrypted.
|
|
80
|
+
*/
|
|
81
|
+
allowInsecureHttp?: boolean;
|
|
78
82
|
/** Called when the SDK swallows a best-effort failure. Never receives PII. */
|
|
79
83
|
onError?: (context: string, cause: unknown) => void;
|
|
84
|
+
/**
|
|
85
|
+
* Development aid: the native SDKs log their start, every swallowed failure
|
|
86
|
+
* and each sent batch to Xcode's console / logcat, and every failure is also
|
|
87
|
+
* written to the JS console as `[Advenue] <context>`. Never logs an
|
|
88
|
+
* identifier or a payload. Leave off in production; use `onError`.
|
|
89
|
+
*/
|
|
80
90
|
debug?: boolean;
|
|
81
91
|
}
|
|
82
92
|
|
package/dist/index.d.ts
CHANGED
|
@@ -54,14 +54,12 @@ interface TrackOptions {
|
|
|
54
54
|
interface AdvenueConfig {
|
|
55
55
|
apiKey: string;
|
|
56
56
|
endpoint?: string;
|
|
57
|
-
platform?: 'ios' | 'android' | 'web' | string;
|
|
58
57
|
/**
|
|
59
58
|
* @deprecated Ignored: the native SDK resolves the app version from the
|
|
60
59
|
* package/bundle. Passing it reports `config.ignored:appVersion` via
|
|
61
60
|
* `onError`. Removed in the next major.
|
|
62
61
|
*/
|
|
63
62
|
appVersion?: string;
|
|
64
|
-
osVersion?: string;
|
|
65
63
|
/** Gates every event on `setTrackingConsent(true)`. */
|
|
66
64
|
requireConsent?: boolean;
|
|
67
65
|
/** Events per request. */
|
|
@@ -74,9 +72,21 @@ interface AdvenueConfig {
|
|
|
74
72
|
* extracted from it.
|
|
75
73
|
*/
|
|
76
74
|
signingSecret?: string;
|
|
77
|
-
|
|
75
|
+
/**
|
|
76
|
+
* **Test only.** Allows an `http://` endpoint — a local self-hosted ingest,
|
|
77
|
+
* or a test server — which the native SDKs otherwise refuse at initialize.
|
|
78
|
+
* Never set it in a production build: everything, the API key included,
|
|
79
|
+
* then travels unencrypted.
|
|
80
|
+
*/
|
|
81
|
+
allowInsecureHttp?: boolean;
|
|
78
82
|
/** Called when the SDK swallows a best-effort failure. Never receives PII. */
|
|
79
83
|
onError?: (context: string, cause: unknown) => void;
|
|
84
|
+
/**
|
|
85
|
+
* Development aid: the native SDKs log their start, every swallowed failure
|
|
86
|
+
* and each sent batch to Xcode's console / logcat, and every failure is also
|
|
87
|
+
* written to the JS console as `[Advenue] <context>`. Never logs an
|
|
88
|
+
* identifier or a payload. Leave off in production; use `onError`.
|
|
89
|
+
*/
|
|
80
90
|
debug?: boolean;
|
|
81
91
|
}
|
|
82
92
|
|
package/dist/index.js
CHANGED
|
@@ -54,7 +54,7 @@ var require_native_require = __commonJS({
|
|
|
54
54
|
// package.json
|
|
55
55
|
var package_default = {
|
|
56
56
|
name: "@advenue/react-native",
|
|
57
|
-
version: "1.0
|
|
57
|
+
version: "1.1.0",
|
|
58
58
|
description: "Advenue MMP React Native SDK \u2014 attribution, install tracking, and deep links (bundled iOS/Android Expo native modules)",
|
|
59
59
|
type: "module",
|
|
60
60
|
license: "MIT",
|
|
@@ -214,6 +214,7 @@ var started = false;
|
|
|
214
214
|
var deepLinkSub = null;
|
|
215
215
|
var errorSub = null;
|
|
216
216
|
var onErrorHook;
|
|
217
|
+
var debugLogging = false;
|
|
217
218
|
var consentCache = false;
|
|
218
219
|
function bridge() {
|
|
219
220
|
return { ios: getIosNative(), android: getAndroidNative() };
|
|
@@ -238,6 +239,7 @@ function report(context, cause) {
|
|
|
238
239
|
onErrorHook?.(context, cause);
|
|
239
240
|
} catch {
|
|
240
241
|
}
|
|
242
|
+
if (debugLogging) console.warn(`[Advenue] ${context}`, cause);
|
|
241
243
|
}
|
|
242
244
|
function requireStarted() {
|
|
243
245
|
if (started) return true;
|
|
@@ -251,6 +253,7 @@ var Advenue = {
|
|
|
251
253
|
*/
|
|
252
254
|
async initialize(config) {
|
|
253
255
|
onErrorHook = config.onError;
|
|
256
|
+
debugLogging = config.debug === true;
|
|
254
257
|
for (const option of IGNORED_OPTIONS) {
|
|
255
258
|
if (config[option] !== void 0) {
|
|
256
259
|
report(
|
|
@@ -269,7 +272,11 @@ var Advenue = {
|
|
|
269
272
|
signingSecret: config.signingSecret,
|
|
270
273
|
fbAppId: config.fbAppId,
|
|
271
274
|
autoTrackInstall: config.autoTrackInstall !== false,
|
|
272
|
-
|
|
275
|
+
// Names the wrapper, like Flutter's `advenue_sdk/…`: a bare version reads
|
|
276
|
+
// like the native SDK's own stamp.
|
|
277
|
+
sdkVersion: `react-native/${package_default.version}`,
|
|
278
|
+
debug: config.debug === true,
|
|
279
|
+
allowInsecureHttp: config.allowInsecureHttp === true
|
|
273
280
|
});
|
|
274
281
|
started = true;
|
|
275
282
|
const { ios: iosForErrors, android: androidForErrors } = bridge();
|
|
@@ -44,11 +44,14 @@ public class AdvenueIosModule: Module {
|
|
|
44
44
|
},
|
|
45
45
|
// The wrapper's version, not this SDK's: a React Native release pins
|
|
46
46
|
// the native snapshot inside it, so its version identifies both.
|
|
47
|
-
sdkVersion: options["sdkVersion"] as? String
|
|
47
|
+
sdkVersion: options["sdkVersion"] as? String,
|
|
48
|
+
// Test only; decided at init, where the https precondition runs.
|
|
49
|
+
allowInsecureHttp: (options["allowInsecureHttp"] as? Bool) ?? false)
|
|
48
50
|
if let flushInterval = options["flushIntervalMs"] as? Int {
|
|
49
51
|
config.flushIntervalMs = flushInterval
|
|
50
52
|
}
|
|
51
53
|
if let batchSize = options["batchSize"] as? Int { config.batchSize = batchSize }
|
|
54
|
+
config.debug = (options["debug"] as? Bool) ?? false
|
|
52
55
|
Advenue.initialize(config)
|
|
53
56
|
// Firebase App Instance ID, when the host app already ships Firebase.
|
|
54
57
|
// `AdvenueFirebase` guards its import with `#if canImport`, which is
|
|
@@ -129,14 +129,11 @@ public enum Advenue {
|
|
|
129
129
|
/// is empty or a backoff window is open.
|
|
130
130
|
public static func flush() { state.submit(.flush) }
|
|
131
131
|
|
|
132
|
-
public static func notifyForeground() { state.
|
|
132
|
+
public static func notifyForeground() { state.notifyForeground() }
|
|
133
133
|
|
|
134
134
|
/// Backgrounding both closes the session and flushes: a batch stranded at the
|
|
135
135
|
/// moment the app leaves the foreground may not be sent for hours.
|
|
136
|
-
public static func notifyBackground() {
|
|
137
|
-
state.submit(.background)
|
|
138
|
-
state.submit(.flush)
|
|
139
|
-
}
|
|
136
|
+
public static func notifyBackground() { state.notifyBackground() }
|
|
140
137
|
|
|
141
138
|
/// Presents the ATT prompt. The app decides when; the SDK never prompts on
|
|
142
139
|
/// its own.
|
|
@@ -167,10 +164,14 @@ final class AcceptedAppId: @unchecked Sendable {
|
|
|
167
164
|
return value
|
|
168
165
|
}
|
|
169
166
|
|
|
170
|
-
|
|
167
|
+
/// True the first time this app id is seen.
|
|
168
|
+
@discardableResult
|
|
169
|
+
func set(_ appId: String) -> Bool {
|
|
171
170
|
lock.lock()
|
|
171
|
+
defer { lock.unlock() }
|
|
172
|
+
let changed = value != appId
|
|
172
173
|
value = appId
|
|
173
|
-
|
|
174
|
+
return changed
|
|
174
175
|
}
|
|
175
176
|
}
|
|
176
177
|
|
|
@@ -209,17 +210,21 @@ final class FacadeState: @unchecked Sendable {
|
|
|
209
210
|
transport: (any EventTransport)? = nil,
|
|
210
211
|
sources: EnrichmentSources? = nil,
|
|
211
212
|
skan skanReporter: (any SkanReporter)? = nil,
|
|
212
|
-
appVersion readVersion: () -> String? = readAppVersion
|
|
213
|
+
appVersion readVersion: () -> String? = readAppVersion,
|
|
214
|
+
launchedInBackground: () -> Bool = readLaunchedInBackground,
|
|
215
|
+
logSink: (any AdvenueLogSink)? = nil
|
|
213
216
|
) {
|
|
214
217
|
// Replace-and-shut-down, never add.
|
|
215
218
|
stop()
|
|
216
219
|
|
|
220
|
+
// `debug`: every swallowed failure goes through onError, so logging there
|
|
221
|
+
// covers all of them — wrapped once, before anything below captures it.
|
|
222
|
+
let (config, log) = withDebugLogging(config, sink: logSink)
|
|
223
|
+
|
|
217
224
|
// The SDK resolves the app version itself (readAppVersion); a config value
|
|
218
225
|
// is ignored — and said so, rather than looking like it took effect.
|
|
219
226
|
if config.appVersion != nil {
|
|
220
|
-
config.onError(
|
|
221
|
-
"config.ignored:appVersion",
|
|
222
|
-
IngestError(status: 0))
|
|
227
|
+
config.onError("config.ignored:appVersion", AdvenueSetupError.appVersionIgnored)
|
|
223
228
|
}
|
|
224
229
|
|
|
225
230
|
// B4: kuyruk blob'u Caches dosyalarına (yedek dışı); UserDefaults'ta
|
|
@@ -230,7 +235,7 @@ final class FacadeState: @unchecked Sendable {
|
|
|
230
235
|
store = CompositeStore(files: files)
|
|
231
236
|
} else {
|
|
232
237
|
store = UserDefaultsStore()
|
|
233
|
-
config.onError("store.cache_unavailable",
|
|
238
|
+
config.onError("store.cache_unavailable", AdvenueSetupError.cacheUnavailable)
|
|
234
239
|
}
|
|
235
240
|
let secure = KeychainStore()
|
|
236
241
|
let uuid = SystemUUIDs()
|
|
@@ -240,7 +245,7 @@ final class FacadeState: @unchecked Sendable {
|
|
|
240
245
|
// Deferred: the Keychain could not be read. Nothing is minted and
|
|
241
246
|
// nothing starts, so no event carries an invented identifier. The next
|
|
242
247
|
// launch after first unlock resolves it.
|
|
243
|
-
config.onError("identity.deferred",
|
|
248
|
+
config.onError("identity.deferred", AdvenueSetupError.identityDeferred)
|
|
244
249
|
return
|
|
245
250
|
}
|
|
246
251
|
|
|
@@ -251,14 +256,19 @@ final class FacadeState: @unchecked Sendable {
|
|
|
251
256
|
osVersion = nil
|
|
252
257
|
#endif
|
|
253
258
|
|
|
254
|
-
let
|
|
259
|
+
let baseTransport =
|
|
255
260
|
transport
|
|
256
261
|
?? HttpTransport(
|
|
257
262
|
endpoint: config.endpoint, apiKey: config.apiKey,
|
|
258
263
|
signingSecret: config.signingSecret,
|
|
259
264
|
// Diagnostics only: it runs after the batch is already accepted, so
|
|
260
265
|
// nothing it does can turn a successful ingest into a failure.
|
|
261
|
-
onAccepted: { [acceptedAppId] appId in
|
|
266
|
+
onAccepted: { [acceptedAppId] appId in
|
|
267
|
+
// A key pasted from the wrong app is otherwise silent.
|
|
268
|
+
if acceptedAppId.set(appId) { log("[Advenue] ingesting into app \(appId)") }
|
|
269
|
+
})
|
|
270
|
+
let eventTransport: any EventTransport =
|
|
271
|
+
config.debug ? LoggingTransport(inner: baseTransport, log: log) : baseTransport
|
|
262
272
|
|
|
263
273
|
let engine = AdvenueEngine(
|
|
264
274
|
config: EngineConfig(
|
|
@@ -330,13 +340,32 @@ final class FacadeState: @unchecked Sendable {
|
|
|
330
340
|
pendingDeepLinks = []
|
|
331
341
|
lock.unlock()
|
|
332
342
|
|
|
343
|
+
// Before anything else is recorded: a launch that still owes its install
|
|
344
|
+
// stamps it now and holds every flush until the install is enqueued, so
|
|
345
|
+
// the server never sees this launch's session or events ahead of it. The
|
|
346
|
+
// hold outlives the enrichment deadline by a margin and then lapses.
|
|
347
|
+
pipe.submit(.beginInstall(holdMs: INSTALL_WINDOW_MS + INSTALL_HOLD_MARGIN_MS))
|
|
348
|
+
|
|
333
349
|
// Replayed in arrival order, before the first session, so a deferred deep
|
|
334
350
|
// link is attributed to the launch it belongs to.
|
|
335
351
|
for url in buffered { send(url) }
|
|
336
|
-
|
|
352
|
+
// A launch into the background (fetch, silent push, location) is not a
|
|
353
|
+
// session: the first didBecomeActive opens it instead. iOS posts no
|
|
354
|
+
// didEnterBackground for an app that never left the background, so a
|
|
355
|
+
// tracker seeded as foregrounded would swallow the user's real open.
|
|
356
|
+
let inForeground = !launchedInBackground()
|
|
357
|
+
if inForeground { pipe.submit(.foreground) }
|
|
337
358
|
// Seeded to match: the line above IS this launch's foreground, so the
|
|
338
359
|
// activation notification that follows must not open a second session.
|
|
339
|
-
observeLifecycle(seededInForeground:
|
|
360
|
+
observeLifecycle(seededInForeground: inForeground)
|
|
361
|
+
|
|
362
|
+
log(
|
|
363
|
+
"[Advenue] initialized — endpoint \(config.endpoint), "
|
|
364
|
+
+ "sdk \(config.sdkVersion ?? AdvenueVersion.current), "
|
|
365
|
+
// Not "background launch": a scene-based (SwiftUI) app is still
|
|
366
|
+
// reported in the background during didFinishLaunching even when the
|
|
367
|
+
// user opened it. Either way the session opens on activation.
|
|
368
|
+
+ (inForeground ? "launched active" : "not active yet, the session opens on activation"))
|
|
340
369
|
|
|
341
370
|
if config.flushIntervalMs > 0 {
|
|
342
371
|
let timer = DispatchSource.makeTimerSource(queue: .global(qos: .utility))
|
|
@@ -593,6 +622,12 @@ final class FacadeState: @unchecked Sendable {
|
|
|
593
622
|
#endif
|
|
594
623
|
}
|
|
595
624
|
|
|
625
|
+
/// The public lifecycle calls go through the same tracker as UIKit's signals,
|
|
626
|
+
/// so one forwarded while already foregrounded (or already backgrounded) is a
|
|
627
|
+
/// no-op instead of a session split.
|
|
628
|
+
func notifyForeground() { handle(.didBecomeActive) }
|
|
629
|
+
func notifyBackground() { handle(.didEnterBackground) }
|
|
630
|
+
|
|
596
631
|
/// Applies one lifecycle signal. Public routing lives here rather than in the
|
|
597
632
|
/// observer closure so a test can drive it without UIKit.
|
|
598
633
|
func handle(_ signal: LifecycleSignal) {
|
|
@@ -29,6 +29,12 @@ public struct AdvenueConfig: Sendable {
|
|
|
29
29
|
/// Called when the SDK swallows a best-effort failure. Never receives PII.
|
|
30
30
|
public var onError: @Sendable (String, any Error) -> Void
|
|
31
31
|
|
|
32
|
+
/// Development aid: logs the SDK's start, every failure `onError` sees and
|
|
33
|
+
/// each accepted batch to the unified log (subsystem `io.advenue.sdk`) —
|
|
34
|
+
/// Xcode's console and Console.app. Never logs an identifier or a payload.
|
|
35
|
+
/// Leave off in production; route failures through `onError` instead.
|
|
36
|
+
public var debug: Bool
|
|
37
|
+
|
|
32
38
|
/// Overrides the version stamped on every event. Set by a WRAPPER SDK, never
|
|
33
39
|
/// by an app.
|
|
34
40
|
///
|
|
@@ -61,7 +67,8 @@ public struct AdvenueConfig: Sendable {
|
|
|
61
67
|
conversionValues: ConversionValueConfig? = nil,
|
|
62
68
|
onError: @escaping @Sendable (String, any Error) -> Void = { _, _ in },
|
|
63
69
|
sdkVersion: String? = nil,
|
|
64
|
-
allowInsecureHttp: Bool = false
|
|
70
|
+
allowInsecureHttp: Bool = false,
|
|
71
|
+
debug: Bool = false
|
|
65
72
|
) {
|
|
66
73
|
precondition(
|
|
67
74
|
allowInsecureHttp || Self.isSecureEndpoint(endpoint),
|
|
@@ -70,6 +77,7 @@ public struct AdvenueConfig: Sendable {
|
|
|
70
77
|
self.endpoint = endpoint
|
|
71
78
|
self.appVersion = appVersion
|
|
72
79
|
self.requireConsent = requireConsent
|
|
80
|
+
self.debug = debug
|
|
73
81
|
self.sessionWindowMs = sessionWindowMs
|
|
74
82
|
self.batchSize = batchSize
|
|
75
83
|
self.flushIntervalMs = flushIntervalMs
|
|
@@ -85,5 +93,5 @@ public struct AdvenueConfig: Sendable {
|
|
|
85
93
|
/// release tag unless something checks. CI does, because the first question
|
|
86
94
|
/// every field report raises is which build produced the event.
|
|
87
95
|
public enum AdvenueVersion {
|
|
88
|
-
public static let current = "1.
|
|
96
|
+
public static let current = "1.1.0"
|
|
89
97
|
}
|
|
@@ -0,0 +1,24 @@
|
|
|
1
|
+
/// A condition met while starting the SDK, reported through `onError` under
|
|
2
|
+
/// the context in parentheses. Not a transport failure — these used to arrive
|
|
3
|
+
/// as `IngestError(status: 0)`, which read as an HTTP error that never
|
|
4
|
+
/// happened and named no cause.
|
|
5
|
+
public enum AdvenueSetupError: Error, Equatable, Sendable, CustomStringConvertible {
|
|
6
|
+
/// `config.ignored:appVersion`
|
|
7
|
+
case appVersionIgnored
|
|
8
|
+
/// `store.cache_unavailable`
|
|
9
|
+
case cacheUnavailable
|
|
10
|
+
/// `identity.deferred`
|
|
11
|
+
case identityDeferred
|
|
12
|
+
|
|
13
|
+
public var description: String {
|
|
14
|
+
switch self {
|
|
15
|
+
case .appVersionIgnored:
|
|
16
|
+
return "appVersion is read from the bundle; the config value has no effect"
|
|
17
|
+
case .cacheUnavailable:
|
|
18
|
+
return "the Caches directory is unavailable; the event queue falls back to UserDefaults"
|
|
19
|
+
case .identityDeferred:
|
|
20
|
+
return "the Keychain is unavailable (not unlocked since boot, or the app is not "
|
|
21
|
+
+ "code-signed); nothing starts until the next launch that can read it"
|
|
22
|
+
}
|
|
23
|
+
}
|
|
24
|
+
}
|
|
@@ -0,0 +1,46 @@
|
|
|
1
|
+
import Foundation
|
|
2
|
+
import os
|
|
3
|
+
|
|
4
|
+
/// Where `AdvenueConfig.debug` writes. The unified logging system in an app;
|
|
5
|
+
/// a recorder in tests.
|
|
6
|
+
protocol AdvenueLogSink: Sendable {
|
|
7
|
+
func log(_ message: String)
|
|
8
|
+
}
|
|
9
|
+
|
|
10
|
+
/// `os.Logger`, public privacy: the lines carry failure contexts, counts and
|
|
11
|
+
/// the app id the server resolved — never an identifier or a payload — and a
|
|
12
|
+
/// debug aid that Console.app shows as `<private>` would not be one.
|
|
13
|
+
struct UnifiedLogSink: AdvenueLogSink {
|
|
14
|
+
private let logger = Logger(subsystem: "io.advenue.sdk", category: "Advenue")
|
|
15
|
+
func log(_ message: String) {
|
|
16
|
+
logger.notice("\(message, privacy: .public)")
|
|
17
|
+
}
|
|
18
|
+
}
|
|
19
|
+
|
|
20
|
+
/// Logs each accepted batch; failures already reach the log through the
|
|
21
|
+
/// wrapped `onError` (`flush.transport`, `flush.poison`).
|
|
22
|
+
struct LoggingTransport: EventTransport {
|
|
23
|
+
let inner: any EventTransport
|
|
24
|
+
let log: @Sendable (String) -> Void
|
|
25
|
+
|
|
26
|
+
func send(_ events: [ClientEvent]) async throws {
|
|
27
|
+
try await inner.send(events)
|
|
28
|
+
log("[Advenue] sent \(events.count) event(s)")
|
|
29
|
+
}
|
|
30
|
+
}
|
|
31
|
+
|
|
32
|
+
/// With `debug` on: the config with `onError` also writing to the log, and the
|
|
33
|
+
/// log itself. Off: the config unchanged and a log that discards.
|
|
34
|
+
func withDebugLogging(
|
|
35
|
+
_ config: AdvenueConfig, sink: (any AdvenueLogSink)?
|
|
36
|
+
) -> (AdvenueConfig, @Sendable (String) -> Void) {
|
|
37
|
+
guard config.debug else { return (config, { _ in }) }
|
|
38
|
+
let sink = sink ?? UnifiedLogSink()
|
|
39
|
+
var logged = config
|
|
40
|
+
let report = config.onError
|
|
41
|
+
logged.onError = { context, error in
|
|
42
|
+
sink.log("[Advenue] \(context): \(error)")
|
|
43
|
+
report(context, error)
|
|
44
|
+
}
|
|
45
|
+
return (logged, { sink.log($0) })
|
|
46
|
+
}
|
|
@@ -3,9 +3,23 @@ import Foundation
|
|
|
3
3
|
/// A non-2xx ingest response. `isRetryable` separates a transient failure from
|
|
4
4
|
/// a poison payload: retrying a 400 forever would block the queue head, and
|
|
5
5
|
/// dropping a 429 would discard events over a throttle.
|
|
6
|
-
public struct IngestError: Error, Equatable, Sendable {
|
|
6
|
+
public struct IngestError: Error, Equatable, Sendable, CustomStringConvertible {
|
|
7
7
|
public let status: Int
|
|
8
|
-
|
|
8
|
+
/// Set when no response arrived at all — DNS, refused connection, timeout,
|
|
9
|
+
/// no network. `status` is then 408 so the backoff retries it, but that 408
|
|
10
|
+
/// was never sent by a server, and logging it as one sends whoever debugs it
|
|
11
|
+
/// looking at the wrong end (F-SDK-10).
|
|
12
|
+
public let networkCause: String?
|
|
13
|
+
|
|
14
|
+
public init(status: Int, networkCause: String? = nil) {
|
|
15
|
+
self.status = status
|
|
16
|
+
self.networkCause = networkCause
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
public var description: String {
|
|
20
|
+
if let networkCause { return "IngestError: no response (network error: \(networkCause))" }
|
|
21
|
+
return "IngestError(status: \(status))"
|
|
22
|
+
}
|
|
9
23
|
|
|
10
24
|
public var isRetryable: Bool {
|
|
11
25
|
status == 408 || status == 429 || status >= 500
|