Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Customizable storage #252

Merged
merged 21 commits into from
Feb 3, 2025
Merged
Show file tree
Hide file tree
Changes from 13 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
135 changes: 31 additions & 104 deletions android/src/main/java/com/segment/analytics/kotlin/android/Storage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,125 +5,52 @@ import android.content.SharedPreferences
import com.segment.analytics.kotlin.android.utilities.AndroidKVS
import com.segment.analytics.kotlin.core.Analytics
import com.segment.analytics.kotlin.core.Storage
import com.segment.analytics.kotlin.core.Storage.Companion.MAX_PAYLOAD_SIZE
import com.segment.analytics.kotlin.core.StorageProvider
import com.segment.analytics.kotlin.core.System
import com.segment.analytics.kotlin.core.UserInfo
import com.segment.analytics.kotlin.core.utilities.EventsFileManager
import com.segment.analytics.kotlin.core.utilities.FileEventStream
import com.segment.analytics.kotlin.core.utilities.StorageImpl
import kotlinx.coroutines.CoroutineDispatcher
import sovran.kotlin.Store
import sovran.kotlin.Subscriber
import java.io.File

// Android specific
@Deprecated("Use StorageProvider to create storage for Android instead")
class AndroidStorage(
context: Context,
private val store: Store,
writeKey: String,
private val ioDispatcher: CoroutineDispatcher,
directory: String? = null,
subject: String? = null
) : Subscriber, Storage {
) : StorageImpl(
propertiesFile = AndroidKVS(context.getSharedPreferences("analytics-android-$writeKey", Context.MODE_PRIVATE)),
eventStream = FileEventStream(context.getDir(directory ?: "segment-disk-queue", Context.MODE_PRIVATE)),
store = store,
writeKey = writeKey,
fileIndexKey = if(subject == null) "segment.events.file.index.$writeKey" else "segment.events.file.index.$writeKey.$subject",
ioDispatcher = ioDispatcher
)

private val sharedPreferences: SharedPreferences =
context.getSharedPreferences("analytics-android-$writeKey", Context.MODE_PRIVATE)
override val storageDirectory: File = context.getDir(directory ?: "segment-disk-queue", Context.MODE_PRIVATE)
internal val eventsFile =
EventsFileManager(storageDirectory, writeKey, AndroidKVS(sharedPreferences), subject)

override suspend fun subscribeToStore() {
store.subscribe(
this,
UserInfo::class,
initialState = true,
handler = ::userInfoUpdate,
queue = ioDispatcher
)
store.subscribe(
this,
System::class,
initialState = true,
handler = ::systemUpdate,
queue = ioDispatcher
)
}

override suspend fun write(key: Storage.Constants, value: String) {
when (key) {
Storage.Constants.Events -> {
if (value.length < MAX_PAYLOAD_SIZE) {
// write to disk
eventsFile.storeEvent(value)
} else {
throw Exception("enqueued payload is too large")
}
}
else -> {
sharedPreferences.edit().putString(key.rawVal, value).apply()
}
}
}

/**
* @returns the String value for the associated key
* for Constants.Events it will return a file url that can be used to read the contents of the events
*/
override fun read(key: Storage.Constants): String? {
return when (key) {
Storage.Constants.Events -> {
eventsFile.read().joinToString()
}
Storage.Constants.LegacyAppBuild -> {
// The legacy app build number was stored as an integer so we have to get it
// as an integer and convert it to a String.
val noBuild = -1
val build = sharedPreferences.getInt(key.rawVal, noBuild)
if (build != noBuild) {
return build.toString()
} else {
return null
}
}
else -> {
sharedPreferences.getString(key.rawVal, null)
}
}
}

override fun remove(key: Storage.Constants): Boolean {
return when (key) {
Storage.Constants.Events -> {
true
}
else -> {
sharedPreferences.edit().putString(key.rawVal, null).apply()
true
}
object AndroidStorageProvider : StorageProvider {
override fun createStorage(vararg params: Any): Storage {

if (params.size < 2 || params[0] !is Analytics || params[1] !is Context) {
throw IllegalArgumentException("""
Invalid parameters for AndroidStorageProvider.
AndroidStorageProvider requires at least 2 parameters.
The first argument has to be an instance of Analytics,
an the second argument has to be an instance of Context
""".trimIndent())
}
}

override fun removeFile(filePath: String): Boolean {
return eventsFile.remove(filePath)
}
val analytics = params[0] as Analytics
val context = params[1] as Context
val config = analytics.configuration

override suspend fun rollover() {
eventsFile.rollover()
}
}
val eventDirectory = context.getDir("segment-disk-queue", Context.MODE_PRIVATE)
val fileIndexKey = "segment.events.file.index.${config.writeKey}"
val sharedPreferences: SharedPreferences =
context.getSharedPreferences("analytics-android-${config.writeKey}", Context.MODE_PRIVATE)

object AndroidStorageProvider : StorageProvider {
override fun getStorage(
analytics: Analytics,
store: Store,
writeKey: String,
ioDispatcher: CoroutineDispatcher,
application: Any
): Storage {
return AndroidStorage(
store = store,
writeKey = writeKey,
ioDispatcher = ioDispatcher,
context = application as Context,
)
val propertiesFile = AndroidKVS(sharedPreferences)
val eventStream = FileEventStream(eventDirectory)
return StorageImpl(propertiesFile, eventStream, analytics.store, config.writeKey, fileIndexKey, analytics.fileIODispatcher)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,21 @@ import com.segment.analytics.kotlin.core.utilities.KVS
/**
* A key-value store wrapper for sharedPreferences on Android
*/
class AndroidKVS(val sharedPreferences: SharedPreferences) : KVS {
override fun getInt(key: String, defaultVal: Int): Int =
class AndroidKVS(val sharedPreferences: SharedPreferences): KVS {


override fun get(key: String, defaultVal: Int) =
sharedPreferences.getInt(key, defaultVal)

override fun putInt(key: String, value: Int): Boolean =
override fun get(key: String, defaultVal: String?) =
sharedPreferences.getString(key, defaultVal) ?: defaultVal

override fun put(key: String, value: Int) =
sharedPreferences.edit().putInt(key, value).commit()

override fun put(key: String, value: String) =
sharedPreferences.edit().putString(key, value).commit()

override fun remove(key: String): Boolean =
sharedPreferences.edit().remove(key).commit()
}
Original file line number Diff line number Diff line change
Expand Up @@ -149,24 +149,5 @@ class AndroidContextCollectorTests {
}
}



@Test
fun `storage directory can be customized`() {
val dir = "test"
val androidStorage = AndroidStorage(
appContext,
Store(),
"123",
UnconfinedTestDispatcher(),
dir
)

Assertions.assertTrue(androidStorage.storageDirectory.name.contains(dir))
Assertions.assertTrue(androidStorage.eventsFile.directory.name.contains(dir))
Assertions.assertTrue(androidStorage.storageDirectory.exists())
Assertions.assertTrue(androidStorage.eventsFile.directory.exists())
}

private fun JsonElement?.asString(): String? = this?.jsonPrimitive?.content
}
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@ class StorageTests {
@Nested
inner class Android {
private var store = Store()
private lateinit var androidStorage: AndroidStorage
private lateinit var androidStorage: Storage
private var mockContext: Context = mockContext()

init {
Expand Down Expand Up @@ -208,9 +208,12 @@ class StorageTests {
}
val stringified: String = Json.encodeToString(event)
androidStorage.write(Storage.Constants.Events, stringified)
androidStorage.eventsFile.rollover()
val storagePath = androidStorage.eventsFile.read()[0]
val storageContents = File(storagePath).readText()
androidStorage.rollover()
val storagePath = androidStorage.read(Storage.Constants.Events)?.let{
it.split(',')[0]
}
assertNotNull(storagePath)
val storageContents = File(storagePath!!).readText()
val jsonFormat = Json.decodeFromString(JsonObject.serializer(), storageContents)
assertEquals(1, jsonFormat["batch"]!!.jsonArray.size)
}
Expand All @@ -229,8 +232,8 @@ class StorageTests {
e
}
assertNotNull(exception)
androidStorage.eventsFile.rollover()
assertTrue(androidStorage.eventsFile.read().isEmpty())
androidStorage.rollover()
assertTrue(androidStorage.read(Storage.Constants.Events).isNullOrEmpty())
}

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

androidStorage.eventsFile.rollover()
androidStorage.rollover()
val fileUrl = androidStorage.read(Storage.Constants.Events)
assertNotNull(fileUrl)
fileUrl!!.let {
Expand All @@ -270,7 +273,7 @@ class StorageTests {

@Test
fun `reading events with empty storage return empty list`() = runTest {
androidStorage.eventsFile.rollover()
androidStorage.rollover()
val fileUrls = androidStorage.read(Storage.Constants.Events)
assertTrue(fileUrls!!.isEmpty())
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -44,13 +44,7 @@ open class Analytics protected constructor(

// use lazy to avoid the instance being leak before fully initialized
val storage: Storage by lazy {
configuration.storageProvider.getStorage(
analytics = this,
writeKey = configuration.writeKey,
ioDispatcher = fileIODispatcher,
store = store,
application = configuration.application!!
)
configuration.storageProvider.createStorage(this, configuration.application!!)
}

internal var userInfo: UserInfo = UserInfo.defaultState(storage)
Expand Down
14 changes: 11 additions & 3 deletions core/src/main/java/com/segment/analytics/kotlin/core/Storage.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import kotlinx.serialization.json.Json
import kotlinx.serialization.json.JsonObject
import sovran.kotlin.Store
import java.io.File
import java.io.InputStream

/**
* Storage interface that abstracts storage of
Expand All @@ -28,6 +29,8 @@ interface Storage {
* is not present in payloads themselves, but is added later, such as `sentAt`, `integrations` and other json tokens.
*/
const val MAX_BATCH_SIZE = 475000 // 475KB.

const val MAX_FILE_SIZE = 475_000
}

enum class Constants(val rawVal: String) {
Expand All @@ -42,11 +45,11 @@ interface Storage {
DeviceId("segment.device.id")
}

val storageDirectory: File

suspend fun subscribeToStore()
suspend fun write(key: Constants, value: String)
fun writePrefs(key: Constants, value: String)
fun read(key: Constants): String?
fun readAsStream(source: String): InputStream?
fun remove(key: Constants): Boolean
fun removeFile(filePath: String): Boolean

Expand Down Expand Up @@ -98,11 +101,16 @@ fun parseFilePaths(filePathStr: String?): List<String> {
* provider via this interface
*/
interface StorageProvider {
@Deprecated("Deprecated in favor of the one takes vararg params",
ReplaceWith("createStorage(analytics, store, writeKey, ioDispatcher, application)")
)
fun getStorage(
analytics: Analytics,
store: Store,
writeKey: String,
ioDispatcher: CoroutineDispatcher,
application: Any
): Storage
): Storage = createStorage(analytics, store, writeKey, ioDispatcher, application)

fun createStorage(vararg params: Any): Storage
}
Loading
Loading