diff --git a/.github/settings.yml b/.github/settings.yml index e8e418c..ea82620 100644 --- a/.github/settings.yml +++ b/.github/settings.yml @@ -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 diff --git a/.github/workflows/gradle.yml b/.github/workflows/gradle.yml new file mode 100644 index 0000000..dd5be7d --- /dev/null +++ b/.github/workflows/gradle.yml @@ -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" + + diff --git a/.idea/deploymentTargetSelector.xml b/.idea/deploymentTargetSelector.xml index 0b2afc4..a8238dc 100644 --- a/.idea/deploymentTargetSelector.xml +++ b/.idea/deploymentTargetSelector.xml @@ -8,7 +8,7 @@ - + diff --git a/README.md b/README.md index fd02e7f..1657942 100644 --- a/README.md +++ b/README.md @@ -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. diff --git a/common/src/androidTest/java/xyz/regulad/regulib/FlowCacheInstrumentedTest.kt b/common/src/androidTest/java/xyz/regulad/regulib/FlowCacheInstrumentedTest.kt index d683be8..30eb8da 100644 --- a/common/src/androidTest/java/xyz/regulad/regulib/FlowCacheInstrumentedTest.kt +++ b/common/src/androidTest/java/xyz/regulad/regulib/FlowCacheInstrumentedTest.kt @@ -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 { @@ -72,4 +75,41 @@ class FlowCacheInstrumentedTest { return@runBlocking // assure the test completes with a void value } + + @Test + fun testFlowCachingWithoutKeys() = runBlocking { + val testFlow: Flow = 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().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().asCached(appContext) + val result4 = secondNewCachedFlow.toList() + assertEquals(result1, result4) + Log.d("FlowCacheInstrumentedTest", "result4: $result4") + + return@runBlocking // assure the test completes with a void value + } } diff --git a/common/src/main/java/xyz/regulad/regulib/FlowCache.kt b/common/src/main/java/xyz/regulad/regulib/FlowCache.kt index a8ea909..c28c26f 100644 --- a/common/src/main/java/xyz/regulad/regulib/FlowCache.kt +++ b/common/src/main/java/xyz/regulad/regulib/FlowCache.kt @@ -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 @@ -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 @@ -50,17 +49,20 @@ class FlowCache @PublishedApi internal constructor(context: Context) : internal const val COLUMN_DATA = "data" @PublishedApi - internal val flowCaches: MutableMap = Collections.synchronizedMap(WeakHashMap(MAP_SIZE)) + internal val flowCaches: MutableMap = 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 Flow.asCached(context: Context, cacheKey: String): Flow = + inline fun Flow.asCached( + context: Context, + cacheKey: String = "${Build.BOARD}+${T::class.java.name}" + ): Flow = flowCaches.versionAgnosticComputeIfAbsent(context) { FlowCache(context) }.cacheFlow(this, cacheKey) } @@ -71,14 +73,15 @@ class FlowCache @PublishedApi internal constructor(context: Context) : internal val dbLock = ReentrantReadWriteLock() @PublishedApi - internal val flowCache = ConcurrentHashMap>(MAP_SIZE) + internal val flowCache = ConcurrentHashMap>() @PublishedApi - internal val flowTypeMap: MutableMap, 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, KClass<*>> = Collections.synchronizedMap(WeakHashMap()) @PublishedApi - internal inline fun cacheFlow(flow: Flow, cacheId: String): Flow { - val map = flowCache.versionAgnosticComputeIfAbsent(cacheId) { + internal inline fun cacheFlow(flow: Flow, cacheKey: String): Flow { + 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 @@ -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(cacheId) + val cachedItems = readListOfId(cacheKey) if (cachedItems != null) { cachedItems.forEach { newItemFlow.emit(it) } @@ -104,7 +107,7 @@ class FlowCache @PublishedApi internal constructor(context: Context) : streamCompletedFlow.value = true // store in db - writeListOfId(cacheId, itemsReceived) + writeListOfId(cacheKey, itemsReceived) } } @@ -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