Skip to content

Commit b7204c6

Browse files
committed
feat: Implement support for TelemetryDeckIdentityProvider
1 parent aadc13b commit b7204c6

File tree

7 files changed

+271
-15
lines changed

7 files changed

+271
-15
lines changed

README.md

Lines changed: 28 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -81,6 +81,34 @@ To enqueue a signal to be sent by TelemetryDeck at a later time
8181
TelemetryDeck.signal("appLaunchedRegularly")
8282
```
8383

84+
## User Identifiers
85+
86+
When `TelemetryDeck` is started for the first time, it will create a user identifier for the user that is specific to the app installation.
87+
88+
* The identity is stored within the application's file folder on the user's device.
89+
90+
* The identifier will be removed when a user uninstalls an app. The KotlinSDK will not "bridge" the user's identity between installations.
91+
92+
* Users can reset the identifier at any time by using the "Clear Data" action in Settings of their device.
93+
94+
If you have a better user identifier available, such as an email address or a username, you can use that instead, by setting `defaultUser` (the identifier will be hashed before sending it) in configuration, or by passing the value when sending signals.
95+
96+
97+
### Custom User Identifiers
98+
99+
If you need a more robust mechanism for keep track of the user's identity, you can replace the default behaviour by providing your own implementation of `TelemetryDeckIdentityProvider`:
100+
101+
```kotlin
102+
val builder = TelemetryDeck.Builder()
103+
.appID("XXXXXXXX-XXXX-XXXX-XXXX-XXXXXXXXXXXX")
104+
.showDebugLogs(true)
105+
.defaultUser("Person")
106+
.identityProvider(YourIdentityProvider())
107+
108+
TelemetryDeck.start(application, builder)
109+
```
110+
111+
84112
### Environment Parameters
85113

86114
By default, Kotlin SDK for TelemetryDeck will include the following environment parameters for each outgoing signal
Lines changed: 77 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,77 @@
1+
package com.telemetrydeck.sdk
2+
3+
import android.app.Application
4+
import androidx.test.core.app.ApplicationProvider
5+
import androidx.test.ext.junit.runners.AndroidJUnit4
6+
import com.telemetrydeck.sdk.providers.FileUserIdentityProvider
7+
import org.junit.Assert.assertEquals
8+
import org.junit.Assert.assertNotEquals
9+
import org.junit.Test
10+
import org.junit.runner.RunWith
11+
import java.io.File
12+
13+
@RunWith(AndroidJUnit4::class)
14+
class FileUserIdentityProviderTest {
15+
16+
private fun createSut(): FileUserIdentityProvider {
17+
val appContext = ApplicationProvider.getApplicationContext<Application>()
18+
val sut = FileUserIdentityProvider()
19+
sut.register(appContext, TelemetryDeck(configuration = TelemetryManagerConfiguration("32CB6574-6732-4238-879F-582FEBEB6536"), providers = emptyList()))
20+
return sut
21+
}
22+
23+
private fun prepareIdentity(value: String) {
24+
val appContext = ApplicationProvider.getApplicationContext<Application>()
25+
val file = File(appContext.filesDir, "telemetrydeckid")
26+
file.writeText(value)
27+
}
28+
29+
@Test
30+
fun providesConfigUserIdentity() {
31+
prepareIdentity("1851B82D-3D39-469D-BED4-19E69C09AF49")
32+
33+
val sut = createSut()
34+
val result = sut.calculateIdentity(null, "default user id")
35+
assertEquals("default user id", result)
36+
}
37+
38+
@Test
39+
fun prefersSignalIdWhenProvided() {
40+
prepareIdentity("1851B82D-3D39-469D-BED4-19E69C09AF49")
41+
42+
val sut = createSut()
43+
val result = sut.calculateIdentity("signal id", "default user id")
44+
assertEquals("signal id", result)
45+
}
46+
47+
@Test
48+
fun usesDefaultUserIdentityWhenSignalAndConfigIsNull() {
49+
prepareIdentity("1851B82D-3D39-469D-BED4-19E69C09AF49")
50+
51+
val sut = createSut()
52+
val result = sut.calculateIdentity(null, null)
53+
assertEquals("1851B82D-3D39-469D-BED4-19E69C09AF49", result)
54+
}
55+
56+
@Test
57+
fun createsStableUserIdentity() {
58+
val sut1 = createSut()
59+
val first_result = sut1.calculateIdentity(null, null)
60+
assert(!first_result.isBlank())
61+
62+
val sut2 = createSut()
63+
val second_result = sut2.calculateIdentity(null, null)
64+
assertEquals(first_result, second_result)
65+
}
66+
67+
@Test
68+
fun resetsStableUserIdentity() {
69+
val sut1 = createSut()
70+
val first_result = sut1.calculateIdentity(null, null)
71+
sut1.resetIdentity()
72+
73+
val sut2 = createSut()
74+
val second_result = sut2.calculateIdentity(null, null)
75+
assertNotEquals(first_result, second_result)
76+
}
77+
}

lib/src/main/java/com/telemetrydeck/sdk/TelemetryDeck.kt

Lines changed: 17 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -5,6 +5,7 @@ import android.content.Context
55
import android.content.pm.ApplicationInfo
66
import com.telemetrydeck.sdk.params.Navigation
77
import com.telemetrydeck.sdk.providers.EnvironmentParameterProvider
8+
import com.telemetrydeck.sdk.providers.FileUserIdentityProvider
89
import com.telemetrydeck.sdk.providers.PlatformContextProvider
910
import com.telemetrydeck.sdk.providers.SessionAppProvider
1011
import java.lang.ref.WeakReference
@@ -20,6 +21,7 @@ class TelemetryDeck(
2021
) : TelemetryDeckClient, TelemetryDeckSignalProcessor {
2122
var cache: SignalCache? = null
2223
var logger: DebugLogger? = null
24+
var identityProvider: TelemetryDeckIdentityProvider = FileUserIdentityProvider()
2325
private val navigationStatus: NavigationStatus = MemoryNavigationStatus()
2426

2527
override val signalCache: SignalCache?
@@ -140,6 +142,7 @@ class TelemetryDeck(
140142
logger?.debug("Installing provider ${provider::class}.")
141143
provider.register(context?.applicationContext as Application?, this)
142144
}
145+
identityProvider.register(context?.applicationContext as Application?, this)
143146
}
144147

145148
private fun createSignal(
@@ -152,7 +155,8 @@ class TelemetryDeck(
152155
for (provider in this.providers) {
153156
enrichedPayload = provider.enrich(signalType, clientUser, enrichedPayload)
154157
}
155-
val userValue = clientUser ?: configuration.defaultUser ?: ""
158+
159+
val userValue = identityProvider.calculateIdentity(clientUser, configuration.defaultUser)
156160

157161
val userValueWithSalt = userValue + (configuration.salt ?: "")
158162
val hashedUser = hashString(userValueWithSalt)
@@ -222,6 +226,7 @@ class TelemetryDeck(
222226
for (provider in manager.providers) {
223227
provider.stop()
224228
}
229+
manager.identityProvider.stop()
225230
synchronized(this) {
226231
instance = null
227232
}
@@ -329,7 +334,8 @@ class TelemetryDeck(
329334
private var sendNewSessionBeganSignal: Boolean? = null,
330335
private var apiBaseURL: URL? = null,
331336
private var logger: DebugLogger? = null,
332-
private var salt: String? = null
337+
private var salt: String? = null,
338+
private var identityProvider: TelemetryDeckIdentityProvider? = null
333339
) {
334340
/**
335341
* Set the TelemetryManager configuration.
@@ -396,6 +402,10 @@ class TelemetryDeck(
396402
this.salt = salt
397403
}
398404

405+
fun identityProvider(identityProvider: TelemetryDeckIdentityProvider) = apply {
406+
this.identityProvider = identityProvider
407+
}
408+
399409
/**
400410
* Provide a custom logger implementation to be used by [TelemetryDeck] when logging internal messages.
401411
*/
@@ -486,6 +496,11 @@ class TelemetryDeck(
486496
manager.cache = MemorySignalCache()
487497
}
488498

499+
val userIdentityProvider = this.identityProvider
500+
if (userIdentityProvider != null) {
501+
manager.identityProvider = userIdentityProvider
502+
}
503+
489504
return manager
490505
}
491506
}
Lines changed: 33 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,33 @@
1+
package com.telemetrydeck.sdk
2+
3+
import android.app.Application
4+
5+
/**
6+
* Generic interface for plugins which can calculate anonymous user identifier
7+
*/
8+
interface TelemetryDeckIdentityProvider {
9+
/**
10+
* Registers the provider with the telemetry manager.
11+
*/
12+
fun register(ctx: Application?, client: TelemetryDeckSignalProcessor)
13+
14+
/**
15+
* Calling stop deactivates the provider, performs any cleanup work if necessary.
16+
*/
17+
fun stop()
18+
19+
20+
/**
21+
* Calculate the user identifier to be attached to a signal.
22+
*
23+
* @param[signalClientUser] The existing `clientUser` value of the signal (if any has been provided).
24+
* @param[configurationDefaultUser] The default user value from the [TelemetryDeck] configuration (if any has been provided).
25+
* @return the user identifier to be associated with the outgoing signal.
26+
*/
27+
fun calculateIdentity(signalClientUser: String?, configurationDefaultUser: String?): String
28+
29+
/**
30+
* Permanently removes any persisted user identifier.
31+
*/
32+
fun resetIdentity()
33+
}
Lines changed: 71 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,71 @@
1+
package com.telemetrydeck.sdk.providers
2+
3+
import android.app.Application
4+
import com.telemetrydeck.sdk.TelemetryDeckIdentityProvider
5+
import com.telemetrydeck.sdk.TelemetryDeckSignalProcessor
6+
import java.io.File
7+
import java.lang.ref.WeakReference
8+
import java.util.UUID
9+
10+
/**
11+
* [FileUserIdentityProvider] attempts to provide a stable user identifier across multiple sessions.
12+
*
13+
* When no user identifier has been provided to [com.telemetrydeck.sdk.TelemetryDeck], [FileUserIdentityProvider] uses a file
14+
* in the application's folder to store a randomly generated user identifier.
15+
*
16+
* The identifier will be removed when a user uninstalls an app. The KotlinSDK will not "bridge" the user's identity between installations.
17+
* Users can reset the identifier at any time by using the "Clear Data" action in Settings of their device.
18+
*
19+
*
20+
* */
21+
class FileUserIdentityProvider: TelemetryDeckIdentityProvider {
22+
private var app: WeakReference<Application?>? = null
23+
private var manager: WeakReference<TelemetryDeckSignalProcessor>? = null
24+
private val fileName = "telemetrydeckid"
25+
private val fileEncoding = Charsets.UTF_8
26+
27+
override fun register(ctx: Application?, client: TelemetryDeckSignalProcessor) {
28+
this.app = WeakReference(ctx)
29+
this.manager = WeakReference(client)
30+
}
31+
32+
override fun stop() {
33+
// nothing to do here
34+
}
35+
36+
override fun calculateIdentity(signalClientUser: String?, configurationDefaultUser: String?): String {
37+
val simpleValue = signalClientUser ?: configurationDefaultUser ?: readOrCreateStableIdentity() ?: ""
38+
return simpleValue
39+
}
40+
41+
override fun resetIdentity() {
42+
// to reset, we delete the identity file if it exists
43+
val context = this.app?.get()?.applicationContext ?: return
44+
val file = File(context.filesDir, fileName)
45+
try {
46+
if (file.exists()) {
47+
file.delete()
48+
}
49+
} catch (e: Exception) {
50+
this.manager?.get()?.debugLogger?.error(e.stackTraceToString())
51+
}
52+
}
53+
54+
private fun readOrCreateStableIdentity(): String? {
55+
val context = this.app?.get()?.applicationContext ?: return null
56+
57+
try {
58+
val file = File(context.filesDir, fileName)
59+
if (!file.exists()) {
60+
val newId = UUID.randomUUID().toString()
61+
file.writeText(newId, fileEncoding)
62+
return newId
63+
}
64+
65+
return file.readText(fileEncoding)
66+
} catch (e: Exception) {
67+
this.manager?.get()?.debugLogger?.error(e.stackTraceToString())
68+
return null
69+
}
70+
}
71+
}

lib/src/main/java/com/telemetrydeck/sdk/providers/PlatformContextProvider.kt

Lines changed: 3 additions & 4 deletions
Original file line numberDiff line numberDiff line change
@@ -1,7 +1,6 @@
11
package com.telemetrydeck.sdk.providers
22

33
import android.app.Application
4-
import android.content.Context
54
import com.telemetrydeck.sdk.TelemetryDeckProvider
65
import com.telemetrydeck.sdk.TelemetryDeckSignalProcessor
76
import com.telemetrydeck.sdk.TelemetryProviderFallback
@@ -17,7 +16,7 @@ import java.lang.ref.WeakReference
1716
internal class PlatformContextProvider : TelemetryDeckProvider, TelemetryProviderFallback {
1817
private var enabled: Boolean = true
1918
private var manager: WeakReference<TelemetryDeckSignalProcessor>? = null
20-
private var appContext: WeakReference<Context?>? = null
19+
private var appContext: WeakReference<Application?>? = null
2120
private var metadata = mutableMapOf<String, String>()
2221

2322
override fun fallbackRegister(ctx: Application?, client: TelemetryDeckSignalProcessor) {
@@ -30,7 +29,7 @@ internal class PlatformContextProvider : TelemetryDeckProvider, TelemetryProvide
3029

3130
override fun register(ctx: Application?, client: TelemetryDeckSignalProcessor) {
3231
this.manager = WeakReference(client)
33-
this.appContext = WeakReference(ctx?.applicationContext)
32+
this.appContext = WeakReference(ctx)
3433

3534
if (ctx == null) {
3635
this.manager?.get()?.debugLogger?.error("RunContextProvider requires a context but received null. Signals will contain incomplete metadata.")
@@ -91,7 +90,7 @@ internal class PlatformContextProvider : TelemetryDeckProvider, TelemetryProvide
9190
// TODO: Use onConfigurationChanged instead
9291

9392
private fun getDynamicAttributes(): Map<String, String> {
94-
val ctx = this.appContext?.get()
93+
val ctx = this.appContext?.get()?.applicationContext
9594
?: // can't read without a context!
9695
return emptyMap()
9796

0 commit comments

Comments
 (0)