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..6a74745 --- /dev/null +++ b/src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt @@ -0,0 +1,105 @@ +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. 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 + * 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}$") + + /** + * 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 + * (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")) + } +}