Skip to content

Commit cff1a1c

Browse files
authored
Customizable storage (#252)
* event stream draft * extend KVS to support string and remove * replace storage implementation with event stream * draft AndroidStorage * draft InMemoryStorage * typo fix * update EventPipeline with new storage * update unit tests * fix broken unit tests * fix broken android unit tests * add unit test for event stream * add unit test for KVS * add unit test for storage and event stream * fix android unit tests * address comments * add more comments * add unit tests for android kvs * add unit tests for in memory storage * add EncryptedEventStream * finalize storage creation * finalize encrypted storage --------- Co-authored-by: Wenxi Zeng <wzeng@twilio.com>
1 parent b958b36 commit cff1a1c

File tree

26 files changed

+1828
-566
lines changed

26 files changed

+1828
-566
lines changed

android/src/main/java/com/segment/analytics/kotlin/android/Storage.kt

Lines changed: 31 additions & 104 deletions
Original file line numberDiff line numberDiff line change
@@ -5,125 +5,52 @@ import android.content.SharedPreferences
55
import com.segment.analytics.kotlin.android.utilities.AndroidKVS
66
import com.segment.analytics.kotlin.core.Analytics
77
import com.segment.analytics.kotlin.core.Storage
8-
import com.segment.analytics.kotlin.core.Storage.Companion.MAX_PAYLOAD_SIZE
98
import com.segment.analytics.kotlin.core.StorageProvider
10-
import com.segment.analytics.kotlin.core.System
11-
import com.segment.analytics.kotlin.core.UserInfo
12-
import com.segment.analytics.kotlin.core.utilities.EventsFileManager
9+
import com.segment.analytics.kotlin.core.utilities.FileEventStream
10+
import com.segment.analytics.kotlin.core.utilities.StorageImpl
1311
import kotlinx.coroutines.CoroutineDispatcher
1412
import sovran.kotlin.Store
15-
import sovran.kotlin.Subscriber
16-
import java.io.File
1713

18-
// Android specific
14+
@Deprecated("Use StorageProvider to create storage for Android instead")
1915
class AndroidStorage(
2016
context: Context,
2117
private val store: Store,
2218
writeKey: String,
2319
private val ioDispatcher: CoroutineDispatcher,
2420
directory: String? = null,
2521
subject: String? = null
26-
) : Subscriber, Storage {
22+
) : StorageImpl(
23+
propertiesFile = AndroidKVS(context.getSharedPreferences("analytics-android-$writeKey", Context.MODE_PRIVATE)),
24+
eventStream = FileEventStream(context.getDir(directory ?: "segment-disk-queue", Context.MODE_PRIVATE)),
25+
store = store,
26+
writeKey = writeKey,
27+
fileIndexKey = if(subject == null) "segment.events.file.index.$writeKey" else "segment.events.file.index.$writeKey.$subject",
28+
ioDispatcher = ioDispatcher
29+
)
2730

28-
private val sharedPreferences: SharedPreferences =
29-
context.getSharedPreferences("analytics-android-$writeKey", Context.MODE_PRIVATE)
30-
override val storageDirectory: File = context.getDir(directory ?: "segment-disk-queue", Context.MODE_PRIVATE)
31-
internal val eventsFile =
32-
EventsFileManager(storageDirectory, writeKey, AndroidKVS(sharedPreferences), subject)
33-
34-
override suspend fun subscribeToStore() {
35-
store.subscribe(
36-
this,
37-
UserInfo::class,
38-
initialState = true,
39-
handler = ::userInfoUpdate,
40-
queue = ioDispatcher
41-
)
42-
store.subscribe(
43-
this,
44-
System::class,
45-
initialState = true,
46-
handler = ::systemUpdate,
47-
queue = ioDispatcher
48-
)
49-
}
50-
51-
override suspend fun write(key: Storage.Constants, value: String) {
52-
when (key) {
53-
Storage.Constants.Events -> {
54-
if (value.length < MAX_PAYLOAD_SIZE) {
55-
// write to disk
56-
eventsFile.storeEvent(value)
57-
} else {
58-
throw Exception("enqueued payload is too large")
59-
}
60-
}
61-
else -> {
62-
sharedPreferences.edit().putString(key.rawVal, value).apply()
63-
}
64-
}
65-
}
66-
67-
/**
68-
* @returns the String value for the associated key
69-
* for Constants.Events it will return a file url that can be used to read the contents of the events
70-
*/
71-
override fun read(key: Storage.Constants): String? {
72-
return when (key) {
73-
Storage.Constants.Events -> {
74-
eventsFile.read().joinToString()
75-
}
76-
Storage.Constants.LegacyAppBuild -> {
77-
// The legacy app build number was stored as an integer so we have to get it
78-
// as an integer and convert it to a String.
79-
val noBuild = -1
80-
val build = sharedPreferences.getInt(key.rawVal, noBuild)
81-
if (build != noBuild) {
82-
return build.toString()
83-
} else {
84-
return null
85-
}
86-
}
87-
else -> {
88-
sharedPreferences.getString(key.rawVal, null)
89-
}
90-
}
91-
}
92-
93-
override fun remove(key: Storage.Constants): Boolean {
94-
return when (key) {
95-
Storage.Constants.Events -> {
96-
true
97-
}
98-
else -> {
99-
sharedPreferences.edit().putString(key.rawVal, null).apply()
100-
true
101-
}
31+
object AndroidStorageProvider : StorageProvider {
32+
override fun createStorage(vararg params: Any): Storage {
33+
34+
if (params.size < 2 || params[0] !is Analytics || params[1] !is Context) {
35+
throw IllegalArgumentException("""
36+
Invalid parameters for AndroidStorageProvider.
37+
AndroidStorageProvider requires at least 2 parameters.
38+
The first argument has to be an instance of Analytics,
39+
an the second argument has to be an instance of Context
40+
""".trimIndent())
10241
}
103-
}
10442

105-
override fun removeFile(filePath: String): Boolean {
106-
return eventsFile.remove(filePath)
107-
}
43+
val analytics = params[0] as Analytics
44+
val context = params[1] as Context
45+
val config = analytics.configuration
10846

109-
override suspend fun rollover() {
110-
eventsFile.rollover()
111-
}
112-
}
47+
val eventDirectory = context.getDir("segment-disk-queue", Context.MODE_PRIVATE)
48+
val fileIndexKey = "segment.events.file.index.${config.writeKey}"
49+
val sharedPreferences: SharedPreferences =
50+
context.getSharedPreferences("analytics-android-${config.writeKey}", Context.MODE_PRIVATE)
11351

114-
object AndroidStorageProvider : StorageProvider {
115-
override fun getStorage(
116-
analytics: Analytics,
117-
store: Store,
118-
writeKey: String,
119-
ioDispatcher: CoroutineDispatcher,
120-
application: Any
121-
): Storage {
122-
return AndroidStorage(
123-
store = store,
124-
writeKey = writeKey,
125-
ioDispatcher = ioDispatcher,
126-
context = application as Context,
127-
)
52+
val propertiesFile = AndroidKVS(sharedPreferences)
53+
val eventStream = FileEventStream(eventDirectory)
54+
return StorageImpl(propertiesFile, eventStream, analytics.store, config.writeKey, fileIndexKey, analytics.fileIODispatcher)
12855
}
12956
}

android/src/main/java/com/segment/analytics/kotlin/android/utilities/AndroidKVS.kt

Lines changed: 16 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -6,10 +6,23 @@ import com.segment.analytics.kotlin.core.utilities.KVS
66
/**
77
* A key-value store wrapper for sharedPreferences on Android
88
*/
9-
class AndroidKVS(val sharedPreferences: SharedPreferences) : KVS {
10-
override fun getInt(key: String, defaultVal: Int): Int =
9+
class AndroidKVS(val sharedPreferences: SharedPreferences): KVS {
10+
11+
12+
override fun get(key: String, defaultVal: Int) =
1113
sharedPreferences.getInt(key, defaultVal)
1214

13-
override fun putInt(key: String, value: Int): Boolean =
15+
override fun get(key: String, defaultVal: String?) =
16+
sharedPreferences.getString(key, defaultVal) ?: defaultVal
17+
18+
override fun put(key: String, value: Int) =
1419
sharedPreferences.edit().putInt(key, value).commit()
20+
21+
override fun put(key: String, value: String) =
22+
sharedPreferences.edit().putString(key, value).commit()
23+
24+
override fun remove(key: String): Boolean =
25+
sharedPreferences.edit().remove(key).commit()
26+
27+
override fun contains(key: String) = sharedPreferences.contains(key)
1528
}

android/src/test/java/com/segment/analytics/kotlin/android/AndroidContextCollectorTests.kt

Lines changed: 0 additions & 19 deletions
Original file line numberDiff line numberDiff line change
@@ -149,24 +149,5 @@ class AndroidContextCollectorTests {
149149
}
150150
}
151151

152-
153-
154-
@Test
155-
fun `storage directory can be customized`() {
156-
val dir = "test"
157-
val androidStorage = AndroidStorage(
158-
appContext,
159-
Store(),
160-
"123",
161-
UnconfinedTestDispatcher(),
162-
dir
163-
)
164-
165-
Assertions.assertTrue(androidStorage.storageDirectory.name.contains(dir))
166-
Assertions.assertTrue(androidStorage.eventsFile.directory.name.contains(dir))
167-
Assertions.assertTrue(androidStorage.storageDirectory.exists())
168-
Assertions.assertTrue(androidStorage.eventsFile.directory.exists())
169-
}
170-
171152
private fun JsonElement?.asString(): String? = this?.jsonPrimitive?.content
172153
}

android/src/test/java/com/segment/analytics/kotlin/android/StorageTests.kt

Lines changed: 12 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@ class StorageTests {
4141
@Nested
4242
inner class Android {
4343
private var store = Store()
44-
private lateinit var androidStorage: AndroidStorage
44+
private lateinit var androidStorage: Storage
4545
private var mockContext: Context = mockContext()
4646

4747
init {
@@ -74,7 +74,7 @@ class StorageTests {
7474
"123",
7575
UnconfinedTestDispatcher()
7676
)
77-
androidStorage.subscribeToStore()
77+
androidStorage.initialize()
7878
}
7979

8080

@@ -208,9 +208,12 @@ class StorageTests {
208208
}
209209
val stringified: String = Json.encodeToString(event)
210210
androidStorage.write(Storage.Constants.Events, stringified)
211-
androidStorage.eventsFile.rollover()
212-
val storagePath = androidStorage.eventsFile.read()[0]
213-
val storageContents = File(storagePath).readText()
211+
androidStorage.rollover()
212+
val storagePath = androidStorage.read(Storage.Constants.Events)?.let{
213+
it.split(',')[0]
214+
}
215+
assertNotNull(storagePath)
216+
val storageContents = File(storagePath!!).readText()
214217
val jsonFormat = Json.decodeFromString(JsonObject.serializer(), storageContents)
215218
assertEquals(1, jsonFormat["batch"]!!.jsonArray.size)
216219
}
@@ -229,8 +232,8 @@ class StorageTests {
229232
e
230233
}
231234
assertNotNull(exception)
232-
androidStorage.eventsFile.rollover()
233-
assertTrue(androidStorage.eventsFile.read().isEmpty())
235+
androidStorage.rollover()
236+
assertTrue(androidStorage.read(Storage.Constants.Events).isNullOrEmpty())
234237
}
235238

236239
@Test
@@ -248,7 +251,7 @@ class StorageTests {
248251
val stringified: String = Json.encodeToString(event)
249252
androidStorage.write(Storage.Constants.Events, stringified)
250253

251-
androidStorage.eventsFile.rollover()
254+
androidStorage.rollover()
252255
val fileUrl = androidStorage.read(Storage.Constants.Events)
253256
assertNotNull(fileUrl)
254257
fileUrl!!.let {
@@ -270,7 +273,7 @@ class StorageTests {
270273

271274
@Test
272275
fun `reading events with empty storage return empty list`() = runTest {
273-
androidStorage.eventsFile.rollover()
276+
androidStorage.rollover()
274277
val fileUrls = androidStorage.read(Storage.Constants.Events)
275278
assertTrue(fileUrls!!.isEmpty())
276279
}
Lines changed: 44 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,44 @@
1+
package com.segment.analytics.kotlin.android.utilities
2+
3+
import com.segment.analytics.kotlin.android.utils.MemorySharedPreferences
4+
import com.segment.analytics.kotlin.core.utilities.KVS
5+
import org.junit.jupiter.api.Assertions
6+
import org.junit.jupiter.api.BeforeEach
7+
import org.junit.jupiter.api.Test
8+
9+
class AndroidKVSTest {
10+
11+
private lateinit var prefs: KVS
12+
13+
@BeforeEach
14+
fun setup(){
15+
val sharedPreferences = MemorySharedPreferences()
16+
prefs = AndroidKVS(sharedPreferences)
17+
prefs.put("int", 1)
18+
prefs.put("string", "string")
19+
}
20+
21+
@Test
22+
fun getTest() {
23+
Assertions.assertEquals(1, prefs.get("int", 0))
24+
Assertions.assertEquals("string", prefs.get("string", null))
25+
Assertions.assertEquals(0, prefs.get("keyNotExists", 0))
26+
Assertions.assertEquals(null, prefs.get("keyNotExists", null))
27+
}
28+
29+
@Test
30+
fun putTest() {
31+
prefs.put("int", 2)
32+
prefs.put("string", "stringstring")
33+
34+
Assertions.assertEquals(2, prefs.get("int", 0))
35+
Assertions.assertEquals("stringstring", prefs.get("string", null))
36+
}
37+
38+
@Test
39+
fun containsAndRemoveTest() {
40+
Assertions.assertTrue(prefs.contains("int"))
41+
prefs.remove("int")
42+
Assertions.assertFalse(prefs.contains("int"))
43+
}
44+
}

core/src/main/java/com/segment/analytics/kotlin/core/Analytics.kt

Lines changed: 3 additions & 9 deletions
Original file line numberDiff line numberDiff line change
@@ -43,14 +43,8 @@ open class Analytics protected constructor(
4343
}
4444

4545
// use lazy to avoid the instance being leak before fully initialized
46-
val storage: Storage by lazy {
47-
configuration.storageProvider.getStorage(
48-
analytics = this,
49-
writeKey = configuration.writeKey,
50-
ioDispatcher = fileIODispatcher,
51-
store = store,
52-
application = configuration.application!!
53-
)
46+
open val storage: Storage by lazy {
47+
configuration.storageProvider.createStorage(this, configuration.application!!)
5448
}
5549

5650
internal var userInfo: UserInfo = UserInfo.defaultState(storage)
@@ -134,7 +128,7 @@ open class Analytics protected constructor(
134128
it.provide(System.defaultState(configuration, storage))
135129

136130
// subscribe to store after state is provided
137-
storage.subscribeToStore()
131+
storage.initialize()
138132
Telemetry.subscribe(store)
139133
}
140134

core/src/main/java/com/segment/analytics/kotlin/core/Configuration.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -25,7 +25,7 @@ import sovran.kotlin.Store
2525
data class Configuration(
2626
val writeKey: String,
2727
var application: Any? = null,
28-
val storageProvider: StorageProvider = ConcreteStorageProvider,
28+
var storageProvider: StorageProvider = ConcreteStorageProvider,
2929
var collectDeviceId: Boolean = false,
3030
var trackApplicationLifecycleEvents: Boolean = false,
3131
var useLifecycleObserver: Boolean = false,

0 commit comments

Comments
 (0)