From b8e9f29a986727fbef6dc324ce7f89cfbc24e7bf Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Mon, 18 May 2026 16:30:17 +0200 Subject: [PATCH 1/2] Add InstallIdProvider for stable per-install identity MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit UUID v7 generator (RFC 9562) plus persistent storage in a dedicated kontextso SharedPreferences file under the installId key. Survives launches, resets only on uninstall / app-data clear. Mirrors iOS InstallIdProvider (UserDefaults.standard, kontextso.installId key) and the kontextso:installId localStorage key used by @kontextso/sdk-js so web and native installs share the same shape on the wire. The validator is intentionally version-agnostic — checks 8-4-4-4-12 hex shape only, not v7-specific bits — so a future generator change doesn't invalidate IDs already on disk. Uses a strict regex rather than java.util.UUID.fromString, which accepts shorthand like "1-2-3-4-5". Bumps version 0.0.3 → 0.0.4. Consumer SDKs (sdk-kotlin) will pin to 0.0.4 and thread installId through their request DTOs. Co-Authored-By: Claude Opus 4.7 (1M context) --- CHANGELOG.md | 4 + gradle/libs.versions.toml | 2 +- .../kit/deviceinfo/InstallIdProvider.kt | 93 +++++++++ .../kit/deviceinfo/InstallIdProviderTest.kt | 180 ++++++++++++++++++ 4 files changed, 278 insertions(+), 1 deletion(-) create mode 100644 src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt create mode 100644 src/test/kotlin/so/kontext/kit/deviceinfo/InstallIdProviderTest.kt diff --git a/CHANGELOG.md b/CHANGELOG.md index 426ec50..0643d46 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -1,5 +1,9 @@ # Changelog +## 0.0.4 + +* `InstallIdProvider`: new `deviceinfo/InstallIdProvider.kt`. Returns a UUID v7 per-app-install identifier persisted in a dedicated `kontextso` `SharedPreferences` file under the `installId` key. Generated on first call, survives launches, resets only on uninstall / app-data clear. Mirrors iOS `InstallIdProvider` (UserDefaults-backed) so consumer SDKs can thread the same `installId` field through `/init`, `/preload`, `/error`, and `/debug` request payloads. + ## 0.0.3 Bug fixes and test coverage. diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index a860a42..02f94b3 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -14,7 +14,7 @@ # `./gradlew publish -PkitVersion=1.1.0` so CI can cut releases # without bumping this file. Consumed by build.gradle.kts as the # fallback when the `kitVersion` Gradle property is absent. -kit = "0.0.3" +kit = "0.0.4" # ---- Build tooling ---- agp = "8.6.1" # Android Gradle Plugin diff --git a/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt b/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt new file mode 100644 index 0000000..fdaab4b --- /dev/null +++ b/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt @@ -0,0 +1,93 @@ +package so.kontext.kit.deviceinfo + +import android.content.Context +import java.nio.ByteBuffer +import java.security.SecureRandom +import java.util.UUID + +/** + * Per-app-install identifier. Generated as a UUID v7 on first SDK use, + * persisted to a dedicated `SharedPreferences` file, and attached to every + * ad-server request (`/init`, `/preload`, `/error`, `/debug`) so the server + * can key pacing, frequency caps, and per-install diagnostics to a stable + * client identity independent of `conversationId` or `userId`. + * + * Survives app launches; resets only when the user uninstalls the app or + * clears app data. Mirrors iOS `InstallIdProvider` (which reads/writes + * `UserDefaults.standard` under the same `kontextso:installId` field name) + * and the `kontextso:installId` localStorage key used by `@kontextso/sdk-js` + * so web and native installs share the same shape on the wire. + * + * Storage location: a dedicated `kontextso` prefs file rather than the + * host app's default prefs. Keeps the install ID out of any prefs file + * the IAB TCF library reads/writes through + * `PreferenceManager.getDefaultSharedPreferences`, and gives the host + * app a single file name to clear if they ever need to reset the ID. + */ +public object InstallIdProvider { + + private const val PREFS_FILE: String = "kontextso" + private const val STORAGE_KEY: String = "installId" + + private val canonicalUuidRegex: Regex = + Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + + /** + * Returns the persisted install ID, generating + storing one on first + * call. The stored value is validated against the canonical UUID shape + * (8-4-4-4-12 hex) and overwritten if corrupted — guards against + * accidental tampering, partial preferences-file migration, or a + * future change to the generator. + */ + public fun getOrCreate(context: Context): String { + val prefs = context.getSharedPreferences(PREFS_FILE, Context.MODE_PRIVATE) + val existing = prefs.getString(STORAGE_KEY, null) + if (existing != null && isCanonicalUuid(existing)) return existing + val fresh = uuidv7() + prefs.edit().putString(STORAGE_KEY, fresh).apply() + return fresh + } + + /** + * Generates a UUID v7 string per RFC 9562: + * + * 48-bit big-endian Unix-epoch milliseconds | version (0111) | 12 random bits | + * variant (10) | 62 random bits + * + * Time-ordered, so server-side sorts and B-tree indexes stay friendly. + */ + @Suppress("MagicNumber") // bit positions and masks defined by RFC 9562 §5.7 + internal fun uuidv7(): String { + val ts = System.currentTimeMillis() + val bytes = ByteArray(16) + // 48-bit timestamp, big-endian. + bytes[0] = ((ts ushr 40) and 0xff).toByte() + bytes[1] = ((ts ushr 32) and 0xff).toByte() + bytes[2] = ((ts ushr 24) and 0xff).toByte() + bytes[3] = ((ts ushr 16) and 0xff).toByte() + bytes[4] = ((ts ushr 8) and 0xff).toByte() + bytes[5] = (ts and 0xff).toByte() + // Random bytes for the remaining 10 octets. + val random = ByteArray(10) + SecureRandom().nextBytes(random) + System.arraycopy(random, 0, bytes, 6, 10) + // Version 7 in the high nibble of byte 6. + bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x70).toByte() + // Variant 10 in the high two bits of byte 8. + bytes[8] = ((bytes[8].toInt() and 0x3f) or 0x80).toByte() + + val buffer = ByteBuffer.wrap(bytes) + val mostSig = buffer.long + val leastSig = buffer.long + return UUID(mostSig, leastSig).toString() + } + + /** + * Validates any canonical UUID shape (8-4-4-4-12 hex). Intentionally + * not version- or variant-locked so a future change to the generator + * (e.g. v8) doesn't invalidate the IDs already in users' preferences. + * `UUID.fromString` is too lenient (accepts `"1-2-3-4-5"`), so the + * regex enforces exact group widths. + */ + internal fun isCanonicalUuid(value: String): Boolean = canonicalUuidRegex.matches(value) +} diff --git a/src/test/kotlin/so/kontext/kit/deviceinfo/InstallIdProviderTest.kt b/src/test/kotlin/so/kontext/kit/deviceinfo/InstallIdProviderTest.kt new file mode 100644 index 0000000..4b2479f --- /dev/null +++ b/src/test/kotlin/so/kontext/kit/deviceinfo/InstallIdProviderTest.kt @@ -0,0 +1,180 @@ +package so.kontext.kit.deviceinfo + +import android.content.Context +import android.content.SharedPreferences +import androidx.test.core.app.ApplicationProvider +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import org.robolectric.RobolectricTestRunner + +/** + * Mirrors `InstallIdProviderTests.swift` (kontextkit-ios), plus an + * Android-specific check that the canonical-shape validator rejects + * `UUID.fromString`-acceptable shorthand (`"1-2-3-4-5"`). + * + * Robolectric is needed because the production code reads/writes + * `SharedPreferences`. A `@Before` block clears the prefs file each + * run so cross-test contamination can't leak — the prefs file is a + * real file backed by Robolectric's shadow filesystem and the + * `kontextso` file is shared across tests in the same Robolectric + * process. + */ +@RunWith(RobolectricTestRunner::class) +class InstallIdProviderTest { + + private val uuidV7Pattern: Regex = + Regex("^[0-9a-f]{8}-[0-9a-f]{4}-7[0-9a-f]{3}-[89ab][0-9a-f]{3}-[0-9a-f]{12}$") + private val prefsFile = "kontextso" + private val storageKey = "installId" + + private lateinit var context: Context + + @Before + fun setUp() { + context = ApplicationProvider.getApplicationContext() + // Wipe any value carried over from a previous test in this + // Robolectric process. SharedPreferences are backed by a real + // file under Robolectric's shadow filesystem. + prefs(context).edit().clear().apply() + } + + private fun prefs(ctx: Context): SharedPreferences = + ctx.getSharedPreferences(prefsFile, Context.MODE_PRIVATE) + + // ---- uuidv7 generator ----------------------------------------------------- + + @Test + fun `uuidv7 produces canonical v7 shape`() { + val id = InstallIdProvider.uuidv7() + assertTrue("expected v7 shape, got: $id", uuidV7Pattern.matches(id)) + } + + @Test + fun `uuidv7 encodes current timestamp in prefix`() { + val before = System.currentTimeMillis() + val id = InstallIdProvider.uuidv7() + val after = System.currentTimeMillis() + // First 12 hex chars (without the hyphen) are the 48-bit timestamp. + val tsHex = id.substring(0, 8) + id.substring(9, 13) + val ts = tsHex.toLong(radix = 16) + assertTrue("ts $ts should be >= before $before", ts >= before) + assertTrue("ts $ts should be <= after $after", ts <= after) + } + + @Test + fun `uuidv7 emits time-ordered IDs across distinct milliseconds`() { + val a = InstallIdProvider.uuidv7() + // Spin until the millisecond changes so the timestamp prefix differs. + val baseline = System.currentTimeMillis() + while (System.currentTimeMillis() == baseline) { /* spin */ } + val b = InstallIdProvider.uuidv7() + assertTrue("expected $a < $b lexicographically", a < b) + } + + // ---- getOrCreate ---------------------------------------------------------- + + @Test + fun `getOrCreate generates and persists on first call`() { + val id = InstallIdProvider.getOrCreate(context) + assertTrue("expected v7 shape, got: $id", uuidV7Pattern.matches(id)) + assertEquals(id, prefs(context).getString(storageKey, null)) + } + + @Test + fun `getOrCreate returns same value on subsequent calls`() { + val first = InstallIdProvider.getOrCreate(context) + val second = InstallIdProvider.getOrCreate(context) + assertEquals(first, second) + } + + @Test + fun `getOrCreate reuses value already in storage`() { + val seeded = "01890000-0000-7000-8000-000000000000" + prefs(context).edit().putString(storageKey, seeded).apply() + assertEquals(seeded, InstallIdProvider.getOrCreate(context)) + } + + @Test + fun `getOrCreate accepts non-v7 UUID for forward compat`() { + // v4 UUID — the validator is intentionally version-agnostic so a + // future generator change doesn't invalidate IDs already on disk. + val v4 = "550e8400-e29b-41d4-a716-446655440000" + prefs(context).edit().putString(storageKey, v4).apply() + assertEquals(v4, InstallIdProvider.getOrCreate(context)) + } + + // ---- malformed-value overwrite ------------------------------------------- + + @Test + fun `getOrCreate overwrites empty stored value`() { + assertOverwritten("") + } + + @Test + fun `getOrCreate overwrites non-UUID stored value`() { + assertOverwritten("lol-not-a-uuid") + } + + @Test + fun `getOrCreate overwrites truncated UUID`() { + assertOverwritten("01890000-0000-7000-8000-00000000") + } + + @Test + fun `getOrCreate overwrites UUID with trailing extras`() { + assertOverwritten("01890000-0000-7000-8000-000000000000-extra") + } + + @Test + fun `getOrCreate overwrites JSON-shaped stored value`() { + assertOverwritten("""{"foo":"bar"}""") + } + + @Test + fun `getOrCreate overwrites UUID with non-hex chars`() { + assertOverwritten("0189zzzz-0000-7000-8000-000000000000") + } + + @Test + fun `getOrCreate overwrites UUID-fromString shorthand`() { + // Android-specific: java.util.UUID.fromString accepts "1-2-3-4-5" + // as a valid UUID. The shape validator must reject anything that + // doesn't match the strict 8-4-4-4-12 hex layout — otherwise the + // generator could store a non-canonical value and round-trip it + // forever. + assertOverwritten("1-2-3-4-5") + } + + private fun assertOverwritten(malformed: String) { + prefs(context).edit().putString(storageKey, malformed).apply() + val id = InstallIdProvider.getOrCreate(context) + assertTrue("expected v7 shape, got: $id", uuidV7Pattern.matches(id)) + assertNotEquals(malformed, id) + // Replacement is persisted so subsequent calls see the same value. + assertEquals(id, prefs(context).getString(storageKey, null)) + } + + // ---- isCanonicalUuid (direct) -------------------------------------------- + + @Test + fun `isCanonicalUuid accepts well-formed UUIDs of any version`() { + assertTrue(InstallIdProvider.isCanonicalUuid("01890000-0000-7000-8000-000000000000")) + assertTrue(InstallIdProvider.isCanonicalUuid("550e8400-e29b-41d4-a716-446655440000")) + // Case-insensitive (java.util.UUID toString is lowercase, but stored + // values from an older generator may be uppercase). + assertTrue(InstallIdProvider.isCanonicalUuid("550E8400-E29B-41D4-A716-446655440000")) + } + + @Test + fun `isCanonicalUuid rejects shorthand and malformed inputs`() { + assertEquals(false, InstallIdProvider.isCanonicalUuid("")) + assertEquals(false, InstallIdProvider.isCanonicalUuid("1-2-3-4-5")) + assertEquals(false, InstallIdProvider.isCanonicalUuid("0189zzzz-0000-7000-8000-000000000000")) + assertEquals(false, InstallIdProvider.isCanonicalUuid("01890000-0000-7000-8000-00000000")) + assertEquals(false, InstallIdProvider.isCanonicalUuid("01890000-0000-7000-8000-000000000000-extra")) + } +} From 970f41d3a1a43ea3bcca3a99ecb02434e162af3e Mon Sep 17 00:00:00 2001 From: JakubMrozek Date: Mon, 18 May 2026 16:44:27 +0200 Subject: [PATCH 2/2] InstallIdProvider: reuse SecureRandom; clarify storage-vs-wire docstring MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit SecureRandom is documented thread-safe and meant to be reused; instantiating one per uuidv7() call re-seeds from /dev/urandom every time, which is measurably slow on older Android devices. Lift to a private val so the seed-and-construct cost is paid once. Also fixes a misleading claim in the class docstring. The previous wording said this mirrors iOS's "kontextso:installId field name" — but iOS actually stores under "kontextso.installId" (flat UserDefaults key with dot), and sdk-js uses localStorage key "kontextso:installId" (with colon). Only the on-the-wire DTO field name (installId) is shared across all three; the local-storage layout is per-platform by design. Co-Authored-By: Claude Opus 4.7 (1M context) --- .../kit/deviceinfo/InstallIdProvider.kt | 22 ++++++++++++++----- 1 file changed, 17 insertions(+), 5 deletions(-) diff --git a/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt b/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt index fdaab4b..6a74745 100644 --- a/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt +++ b/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt @@ -13,10 +13,14 @@ import java.util.UUID * client identity independent of `conversationId` or `userId`. * * Survives app launches; resets only when the user uninstalls the app or - * clears app data. Mirrors iOS `InstallIdProvider` (which reads/writes - * `UserDefaults.standard` under the same `kontextso:installId` field name) - * and the `kontextso:installId` localStorage key used by `@kontextso/sdk-js` - * so web and native installs share the same shape on the wire. + * clears app data. The on-the-wire DTO field is `installId` — the same + * field name iOS `InstallIdProvider` writes and the same field name + * `@kontextso/sdk-js` writes, so web, iOS, and Android installs share the + * same shape in the request body. Per-platform local-storage layout + * differs and is intentionally not part of the shared contract: iOS uses + * a flat `UserDefaults` key (`kontextso.installId`); sdk-js uses + * `localStorage` key `kontextso:installId`; Android uses a dedicated + * `kontextso` `SharedPreferences` file with the `installId` key. * * Storage location: a dedicated `kontextso` prefs file rather than the * host app's default prefs. Keeps the install ID out of any prefs file @@ -32,6 +36,14 @@ public object InstallIdProvider { private val canonicalUuidRegex: Regex = Regex("^[0-9a-fA-F]{8}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{4}-[0-9a-fA-F]{12}$") + /** + * Shared, thread-safe PRNG. `SecureRandom` is documented thread-safe and + * is intended to be reused — instantiating one per `uuidv7()` call would + * re-seed from `/dev/urandom` every time, which is measurably slow on + * older Android devices. + */ + private val secureRandom: SecureRandom = SecureRandom() + /** * Returns the persisted install ID, generating + storing one on first * call. The stored value is validated against the canonical UUID shape @@ -69,7 +81,7 @@ public object InstallIdProvider { bytes[5] = (ts and 0xff).toByte() // Random bytes for the remaining 10 octets. val random = ByteArray(10) - SecureRandom().nextBytes(random) + secureRandom.nextBytes(random) System.arraycopy(random, 0, bytes, 6, 10) // Version 7 in the high nibble of byte 6. bytes[6] = ((bytes[6].toInt() and 0x0f) or 0x70).toByte()