Skip to content

Commit

Permalink
keying
Browse files Browse the repository at this point in the history
  • Loading branch information
regulad committed Oct 15, 2024
1 parent ab5f89a commit e1a6d40
Show file tree
Hide file tree
Showing 6 changed files with 93 additions and 27 deletions.
12 changes: 2 additions & 10 deletions .github/settings.yml
Original file line number Diff line number Diff line change
@@ -1,12 +1,4 @@
repository:
name: regulib
description: "📚 Library for common Android utilities"
topics:
- android
- android-library
- gradle
- maven
- maven-central
- kotlin
- library
- utilities
description: "📚 Library of common Android utilities, including data manipulation & async utils."
topics: android, android-library, gradle, maven, maven-central, kotlin, library, utilities
29 changes: 29 additions & 0 deletions .github/workflows/gradle.yml
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
name: "Gradle Tasks"

on:
push:
branches:
- master
pull_request:
branches:
- master
schedule:
# ensures that workflow runs AT LEAST every 60 days to keep artifacts downloadable
- cron: "0 3 15 */2 *"
workflow_dispatch:

jobs:
gradle:
# - setup java
# - setup gradle cache
# - setup android sdk
# - ci: gradle "check" task
# - ci: assembleDebug and upload artifacts to github
# - doc: upload javadoc to github pages

runs-on: "ubuntu-latest"
steps:
- name: TODO
run: echo "TODO"


2 changes: 1 addition & 1 deletion .idea/deploymentTargetSelector.xml

Some generated files are not rendered by default. Learn more about how customized files appear on GitHub.

2 changes: 2 additions & 0 deletions README.md
Original file line number Diff line number Diff line change
Expand Up @@ -15,6 +15,8 @@ It provides commonly-reused utilities, including:
* Version-agnostic support for Java 1.8 Collection & Map features under Kotlin
* And more!

For more info, check out the JavaDoc at https://regulad.github.io/regulib/

## Installation

ReguLib is available on Maven Central.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,9 @@ import xyz.regulad.regulib.FlowCache.Companion.asCached
@Serializable
data class TestData(val value: Int)

@Serializable
data class AnotherTestData(val value: Int)

@RunWith(AndroidJUnit4::class)
class FlowCacheInstrumentedTest {

Expand Down Expand Up @@ -72,4 +75,41 @@ class FlowCacheInstrumentedTest {

return@runBlocking // assure the test completes with a void value
}

@Test
fun testFlowCachingWithoutKeys() = runBlocking {
val testFlow: Flow<AnotherTestData> = flow {
Log.d("FlowCacheInstrumentedTest", "Emitting test data! This should only happen once.")
emit(AnotherTestData(1))
emit(AnotherTestData(2))
emit(AnotherTestData(3))
}

val cachedFlow = testFlow.asCached(appContext)

// Collect from the cached flow
val result1 = cachedFlow.toList()
assertEquals(listOf(AnotherTestData(1), AnotherTestData(2), AnotherTestData(3)), result1)
Log.d("FlowCacheInstrumentedTest", "result1: $result1")

// Collect again to verify that the cache is working
val result2 = cachedFlow.toList()
assertEquals(result1, result2)
Log.d("FlowCacheInstrumentedTest", "result2: $result2")

// Create a new cached flow with the same key to test persistence
val newCachedFlow = flowOf<AnotherTestData>().asCached(appContext)
val result3 = newCachedFlow.toList()
assertEquals(result1, result3)
Log.d("FlowCacheInstrumentedTest", "result3: $result3")

// last step: check serialization by clearing out the internal cache and re-fetching
FlowCache.Companion.flowCaches[appContext]!!.flowCache.clear()
val secondNewCachedFlow = flowOf<AnotherTestData>().asCached(appContext)
val result4 = secondNewCachedFlow.toList()
assertEquals(result1, result4)
Log.d("FlowCacheInstrumentedTest", "result4: $result4")

return@runBlocking // assure the test completes with a void value
}
}
35 changes: 19 additions & 16 deletions common/src/main/java/xyz/regulad/regulib/FlowCache.kt
Original file line number Diff line number Diff line change
Expand Up @@ -5,6 +5,7 @@ import android.content.ContentValues
import android.content.Context
import android.database.sqlite.SQLiteDatabase
import android.database.sqlite.SQLiteOpenHelper
import android.os.Build
import kotlinx.coroutines.*
import kotlinx.coroutines.flow.Flow
import kotlinx.coroutines.flow.MutableSharedFlow
Expand Down Expand Up @@ -33,9 +34,7 @@ import kotlin.reflect.KClass
class FlowCache @PublishedApi internal constructor(context: Context) :
SQLiteOpenHelper(context, File(context.cacheDir, DATABASE_NAME).absolutePath, null, DATABASE_VERSION) {
companion object {
@PublishedApi
// i would like for this to be infinite, but java forces my hand
internal const val MAP_SIZE = 1_000
// no need to declare a custom initial size of a hashmap: it will grow as needed efficiently

const val DATABASE_NAME = "regulib_flow_cache.db"
private const val DATABASE_VERSION = 1
Expand All @@ -50,17 +49,20 @@ class FlowCache @PublishedApi internal constructor(context: Context) :
internal const val COLUMN_DATA = "data"

@PublishedApi
internal val flowCaches: MutableMap<Context, FlowCache> = Collections.synchronizedMap(WeakHashMap(MAP_SIZE))
internal val flowCaches: MutableMap<Context, FlowCache> = Collections.synchronizedMap(WeakHashMap())

/**
* Caches the flow in the context, persisting between application restarts.
*
* @param context the context to cache the flow in
* @param cacheKey the key to cache the flow under;
* make sure that this key is unique for each flow! if the key binds to flows of two different types,
* a serialization error will occur
* @param context the context to cache the flow in, typically an activity context. Data will be stored in the context's app's cache directory.
* @param cacheKey The key of the cache. Make sure that this value is unique for each flow you cache. If the value is not unique for two flows of the same type, both flows will share the same cache, and it is undefined behavior which underlying flow will be collected. For two flows of two different types, an `IllegalArgumentException` will be thrown. The default value is `"${Build.BOARD}+${T::class.java.name}"`, which should be fine for most uses including apps that sync data between devices provided that the class is only used in a flow once and the class is not anonymous. In these cases, define a custom cache key.
* @return a cached version of the flow, which is "hot" yet has infinite replay meaning that all items will be replayed to any new collectors
* @throws IllegalArgumentException if the flow is not of the correct type
*/
inline fun <reified T : @Serializable Any> Flow<T>.asCached(context: Context, cacheKey: String): Flow<T> =
inline fun <reified T : @Serializable Any> Flow<T>.asCached(
context: Context,
cacheKey: String = "${Build.BOARD}+${T::class.java.name}"
): Flow<T> =
flowCaches.versionAgnosticComputeIfAbsent(context) { FlowCache(context) }.cacheFlow(this, cacheKey)
}

Expand All @@ -71,14 +73,15 @@ class FlowCache @PublishedApi internal constructor(context: Context) :
internal val dbLock = ReentrantReadWriteLock()

@PublishedApi
internal val flowCache = ConcurrentHashMap<String, Flow<*>>(MAP_SIZE)
internal val flowCache = ConcurrentHashMap<String, Flow<*>>()

@PublishedApi
internal val flowTypeMap: MutableMap<Flow<*>, KClass<*>> = Collections.synchronizedMap(WeakHashMap(MAP_SIZE))
// this should never be used with the default key since we include the reified type in the key, but if the user provides a custom key, we need to ensure that the flow is of the correct type to avoid undefined behavior
internal val flowTypeMap: MutableMap<Flow<*>, KClass<*>> = Collections.synchronizedMap(WeakHashMap())

@PublishedApi
internal inline fun <reified T : @Serializable Any> cacheFlow(flow: Flow<T>, cacheId: String): Flow<T> {
val map = flowCache.versionAgnosticComputeIfAbsent(cacheId) {
internal inline fun <reified T : @Serializable Any> cacheFlow(flow: Flow<T>, cacheKey: String): Flow<T> {
val map = flowCache.versionAgnosticComputeIfAbsent(cacheKey) {
// we can't use a hot flow because hot flows don't have the ability to "finish" a stream
// thus, we must implement a custom solution that sends in all the values from the flow once they are

Expand All @@ -88,7 +91,7 @@ class FlowCache @PublishedApi internal constructor(context: Context) :
val streamCompletedFlow = MutableStateFlow(false) // beware: no replay of this state

collectionCoroutineScope.launch {
val cachedItems = readListOfId<T>(cacheId)
val cachedItems = readListOfId<T>(cacheKey)

if (cachedItems != null) {
cachedItems.forEach { newItemFlow.emit(it) }
Expand All @@ -104,7 +107,7 @@ class FlowCache @PublishedApi internal constructor(context: Context) :
streamCompletedFlow.value = true

// store in db
writeListOfId(cacheId, itemsReceived)
writeListOfId(cacheKey, itemsReceived)
}
}

Expand Down Expand Up @@ -143,7 +146,7 @@ class FlowCache @PublishedApi internal constructor(context: Context) :

// before returning, verify that the flow is the correct type
if (flowTypeMap[map] != T::class) {
throw IllegalArgumentException("The flow with cacheId $cacheId is not of type ${T::class}")
throw IllegalArgumentException("The flow with key $cacheKey is not of type ${T::class}")
}

@Suppress("UNCHECKED_CAST") // we just manually verified that the flow is of the correct type
Expand Down

0 comments on commit e1a6d40

Please sign in to comment.