Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
4 changes: 4 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
@@ -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.
Expand Down
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down
105 changes: 105 additions & 0 deletions src/main/kotlin/so/kontext/kit/deviceinfo/InstallIdProvider.kt
Original file line number Diff line number Diff line change
@@ -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)
}
180 changes: 180 additions & 0 deletions src/test/kotlin/so/kontext/kit/deviceinfo/InstallIdProviderTest.kt
Original file line number Diff line number Diff line change
@@ -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"))
}
}
Loading