[1.489.*] Pre-release merge (#1084)
This commit is contained in:
+1
-4
@@ -7,10 +7,6 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
|
||||
## [Unreleased]
|
||||
|
||||
### Added
|
||||
|
||||
- Tag blocks can now be time bound
|
||||
|
||||
### Fixed
|
||||
|
||||
- Collapsed comments state are now persisted across navigation events
|
||||
@@ -18,6 +14,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
|
||||
### Changed
|
||||
|
||||
- Tags use a more muted background color to avoid being distracting.
|
||||
- Tag blocks can now be time-bound
|
||||
|
||||
## [1.60.0] - 2026-02-14
|
||||
|
||||
|
||||
@@ -51,10 +51,10 @@ class HottestPostsWidget : GlanceAppWidget() {
|
||||
override suspend fun provideGlance(context: Context, id: GlanceId) {
|
||||
val appGraph = (context.applicationContext as ClawApplication).appGraph
|
||||
val cachedRemotePostsRepository = appGraph.cachedRemotePostsRepository
|
||||
val tagFilterRepository = appGraph.tagFilterRepository
|
||||
val tagBlockRepository = appGraph.tagBlockRepository
|
||||
val filteredTags =
|
||||
try {
|
||||
tagFilterRepository.getSavedTags().first()
|
||||
tagBlockRepository.getSavedTags().first()
|
||||
} catch (_: Exception) {
|
||||
emptySet()
|
||||
}
|
||||
|
||||
@@ -12,7 +12,7 @@ import androidx.work.WorkManager
|
||||
import dev.msfjarvis.claw.android.viewmodel.CachedRemotePostsRepository
|
||||
import dev.msfjarvis.claw.android.viewmodel.SavedPostsRepository
|
||||
import dev.msfjarvis.claw.api.LobstersApi
|
||||
import dev.msfjarvis.claw.common.tags.TagFilterRepository
|
||||
import dev.msfjarvis.claw.common.tags.TagBlockRepository
|
||||
import dev.msfjarvis.claw.core.coroutines.MainDispatcher
|
||||
import dev.msfjarvis.claw.core.injection.AppPlugin
|
||||
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
|
||||
@@ -51,7 +51,7 @@ interface AppGraph : MetroAppComponentProviders, ViewModelGraph {
|
||||
|
||||
val lobstersApi: LobstersApi
|
||||
|
||||
val tagFilterRepository: TagFilterRepository
|
||||
val tagBlockRepository: TagBlockRepository
|
||||
|
||||
@MainDispatcher val mainDispatcher: CoroutineDispatcher
|
||||
|
||||
|
||||
+3
-3
@@ -9,7 +9,7 @@ package dev.msfjarvis.claw.android.work
|
||||
import android.content.Context
|
||||
import androidx.work.CoroutineWorker
|
||||
import androidx.work.WorkerParameters
|
||||
import dev.msfjarvis.claw.common.tags.TagFilterRepository
|
||||
import dev.msfjarvis.claw.common.tags.TagBlockRepository
|
||||
import dev.msfjarvis.claw.core.injection.InjectedWorkerFactory
|
||||
import dev.msfjarvis.claw.core.injection.WorkerKey
|
||||
import dev.zacsweers.metro.AppScope
|
||||
@@ -23,10 +23,10 @@ import dev.zacsweers.metro.binding
|
||||
class TagExpirationCleanupWorker(
|
||||
context: Context,
|
||||
@Assisted params: WorkerParameters,
|
||||
private val tagFilterRepository: TagFilterRepository,
|
||||
private val tagBlockRepository: TagBlockRepository,
|
||||
) : CoroutineWorker(context, params) {
|
||||
override suspend fun doWork(): Result {
|
||||
tagFilterRepository.removeExpiredTags()
|
||||
tagBlockRepository.removeExpiredTags()
|
||||
return Result.success()
|
||||
}
|
||||
|
||||
|
||||
@@ -66,13 +66,16 @@ dependencies {
|
||||
implementation(libs.kotlinx.coroutines.core)
|
||||
implementation(libs.metrox.viewmodel)
|
||||
implementation(libs.metrox.viewmodel.compose)
|
||||
implementation(libs.sentry.android.core)
|
||||
implementation(libs.sqldelight.runtime)
|
||||
implementation(libs.sqldelight.extensions.coroutines)
|
||||
|
||||
compileOnly(libs.androidx.compose.ui.tooling.preview)
|
||||
|
||||
runtimeOnly(libs.androidx.compose.ui.tooling)
|
||||
|
||||
testImplementation(libs.kotlinx.coroutines.test)
|
||||
testImplementation(libs.sqldelight.jvmDriver)
|
||||
|
||||
addTestDependencies(project)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,106 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.common.tags
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import app.cash.sqldelight.coroutines.asFlow
|
||||
import app.cash.sqldelight.coroutines.mapToList
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
|
||||
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
|
||||
import dev.msfjarvis.claw.database.local.TagBlocksQueries
|
||||
import dev.msfjarvis.claw.model.TagBlock
|
||||
import dev.zacsweers.metro.Inject
|
||||
import io.sentry.Sentry
|
||||
import kotlinx.coroutines.CoroutineDispatcher
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.coroutines.withContext
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Inject
|
||||
class TagBlockRepository(
|
||||
private val tagBlocksQueries: TagBlocksQueries,
|
||||
private val preferences: DataStore<Preferences>,
|
||||
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
|
||||
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
|
||||
) {
|
||||
private val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
private val timedTagsKey = stringPreferencesKey("tags_with_expiration")
|
||||
|
||||
private var migrationCompleted = false
|
||||
|
||||
private suspend fun migrateFromDataStore() {
|
||||
if (migrationCompleted) return
|
||||
|
||||
val prefs = preferences.data.first()
|
||||
val timedTags = prefs[timedTagsKey]
|
||||
val legacyTags = prefs[legacyTagsKey]
|
||||
|
||||
if (timedTags == null && legacyTags == null) {
|
||||
migrationCompleted = true
|
||||
Sentry.metrics().count("tag_sqlite_migration_not_required")
|
||||
return
|
||||
} else {
|
||||
Sentry.metrics().count("tag_sqlite_migration_required")
|
||||
}
|
||||
|
||||
withContext(writeDispatcher) {
|
||||
if (timedTags != null) {
|
||||
val tagMap = Json.decodeFromString<Map<String, Long?>>(timedTags)
|
||||
tagMap.forEach { (tag, expiration) -> tagBlocksQueries.insertOrReplace(tag, expiration) }
|
||||
} else {
|
||||
legacyTags?.forEach { tag -> tagBlocksQueries.insertOrReplace(tag, null) }
|
||||
}
|
||||
|
||||
// Clean up DataStore after successful migration
|
||||
preferences.edit { prefs ->
|
||||
prefs.remove(timedTagsKey)
|
||||
prefs.remove(legacyTagsKey)
|
||||
}
|
||||
|
||||
migrationCompleted = true
|
||||
}
|
||||
}
|
||||
|
||||
fun getSavedTags(): Flow<Set<String>> {
|
||||
val now = System.currentTimeMillis()
|
||||
return tagBlocksQueries
|
||||
.selectActiveTags(now)
|
||||
.asFlow()
|
||||
.mapToList(readDispatcher)
|
||||
.onStart { migrateFromDataStore() }
|
||||
.map { it.toSet() }
|
||||
}
|
||||
|
||||
fun getTagBlocks(): Flow<List<TagBlock>> {
|
||||
return tagBlocksQueries
|
||||
.selectAll()
|
||||
.asFlow()
|
||||
.mapToList(readDispatcher)
|
||||
.onStart { migrateFromDataStore() }
|
||||
.map { blocks -> blocks.map { TagBlock(it.tag, it.expiration_millis) } }
|
||||
}
|
||||
|
||||
suspend fun saveTagBlock(tag: String, expirationMillis: Long?) {
|
||||
withContext(writeDispatcher) { tagBlocksQueries.insertOrReplace(tag, expirationMillis) }
|
||||
}
|
||||
|
||||
suspend fun removeTagBlock(tag: String) {
|
||||
withContext(writeDispatcher) { tagBlocksQueries.deleteByTag(tag) }
|
||||
}
|
||||
|
||||
suspend fun removeExpiredTags() {
|
||||
val now = System.currentTimeMillis()
|
||||
withContext(writeDispatcher) { tagBlocksQueries.deleteExpired(now) }
|
||||
}
|
||||
}
|
||||
@@ -1,102 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.common.tags
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import dev.msfjarvis.claw.model.TagBlock
|
||||
import dev.zacsweers.metro.Inject
|
||||
import kotlinx.coroutines.flow.Flow
|
||||
import kotlinx.coroutines.flow.map
|
||||
import kotlinx.coroutines.flow.onStart
|
||||
import kotlinx.serialization.json.Json
|
||||
|
||||
@Inject
|
||||
class TagFilterRepository(private val preferences: DataStore<Preferences>) {
|
||||
private val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
private val tagsKey = stringPreferencesKey("tags_with_expiration")
|
||||
|
||||
private suspend fun migrateIfNeeded() {
|
||||
preferences.edit { prefs ->
|
||||
val legacyTags = prefs[legacyTagsKey]
|
||||
val newKeyJson = prefs[tagsKey]
|
||||
|
||||
if (legacyTags != null) {
|
||||
val existingTagMap =
|
||||
if (newKeyJson != null) {
|
||||
Json.decodeFromString<Map<String, Long?>>(newKeyJson)
|
||||
} else {
|
||||
emptyMap()
|
||||
}
|
||||
|
||||
val migratedTags = legacyTags.associateWith { null as Long? }
|
||||
val mergedMap = existingTagMap + migratedTags
|
||||
|
||||
prefs[tagsKey] = Json.encodeToString(mergedMap)
|
||||
prefs.remove(legacyTagsKey)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fun getSavedTags(): Flow<Set<String>> {
|
||||
return preferences.data
|
||||
.onStart { migrateIfNeeded() }
|
||||
.map { prefs ->
|
||||
val json = prefs[tagsKey] ?: return@map emptySet()
|
||||
val tagBlocks = Json.decodeFromString<Map<String, Long?>>(json)
|
||||
val now = System.currentTimeMillis()
|
||||
tagBlocks.filterValues { expiration -> expiration == null || expiration > now }.keys
|
||||
}
|
||||
}
|
||||
|
||||
fun getTagBlocks(): Flow<List<TagBlock>> {
|
||||
return preferences.data
|
||||
.onStart { migrateIfNeeded() }
|
||||
.map { prefs ->
|
||||
val json = prefs[tagsKey] ?: return@map emptyList()
|
||||
val tagMap = Json.decodeFromString<Map<String, Long?>>(json)
|
||||
tagMap.map { (tag, expiration) -> TagBlock(tag, expiration) }
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun saveTagBlock(tag: String, expirationMillis: Long?) {
|
||||
preferences.edit { prefs ->
|
||||
val json = prefs[tagsKey]
|
||||
val tagMap =
|
||||
if (json != null) {
|
||||
Json.decodeFromString<Map<String, Long?>>(json).toMutableMap()
|
||||
} else {
|
||||
mutableMapOf()
|
||||
}
|
||||
tagMap[tag] = expirationMillis
|
||||
prefs[tagsKey] = Json.encodeToString(tagMap)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeTagBlock(tag: String) {
|
||||
preferences.edit { prefs ->
|
||||
val json = prefs[tagsKey] ?: return@edit
|
||||
val tagMap = Json.decodeFromString<Map<String, Long?>>(json).toMutableMap()
|
||||
tagMap.remove(tag)
|
||||
prefs[tagsKey] = Json.encodeToString(tagMap)
|
||||
}
|
||||
}
|
||||
|
||||
suspend fun removeExpiredTags() {
|
||||
preferences.edit { prefs ->
|
||||
val json = prefs[tagsKey] ?: return@edit
|
||||
val tagMap = Json.decodeFromString<Map<String, Long?>>(json)
|
||||
val now = System.currentTimeMillis()
|
||||
val activeTagMap =
|
||||
tagMap.filterValues { expiration -> expiration == null || expiration > now }
|
||||
prefs[tagsKey] = Json.encodeToString(activeTagMap)
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -38,12 +38,12 @@ import kotlinx.coroutines.withContext
|
||||
@ContributesIntoMap(AppScope::class)
|
||||
class TagFilterViewModel(
|
||||
private val api: LobstersApi,
|
||||
private val tagFilterRepository: TagFilterRepository,
|
||||
private val tagBlockRepository: TagBlockRepository,
|
||||
@param:IODispatcher private val ioDispatcher: CoroutineDispatcher,
|
||||
) : ViewModel() {
|
||||
|
||||
val filteredTags = tagFilterRepository.getSavedTags().map(Set<String>::toPersistentSet)
|
||||
val tagBlocks = tagFilterRepository.getTagBlocks()
|
||||
val filteredTags = tagBlockRepository.getSavedTags().map(Set<String>::toPersistentSet)
|
||||
val tagBlocks = tagBlockRepository.getTagBlocks()
|
||||
|
||||
var allTags by mutableStateOf<NetworkState>(NetworkState.Loading)
|
||||
private set
|
||||
@@ -70,14 +70,14 @@ class TagFilterViewModel(
|
||||
}
|
||||
|
||||
fun saveTagBlock(tag: String, expirationMillis: Long?) {
|
||||
viewModelScope.launch { tagFilterRepository.saveTagBlock(tag, expirationMillis) }
|
||||
viewModelScope.launch { tagBlockRepository.saveTagBlock(tag, expirationMillis) }
|
||||
}
|
||||
|
||||
fun removeTagBlock(tag: String) {
|
||||
viewModelScope.launch { tagFilterRepository.removeTagBlock(tag) }
|
||||
viewModelScope.launch { tagBlockRepository.removeTagBlock(tag) }
|
||||
}
|
||||
|
||||
fun triggerCleanupNow() {
|
||||
viewModelScope.launch { tagFilterRepository.removeExpiredTags() }
|
||||
viewModelScope.launch { tagBlockRepository.removeExpiredTags() }
|
||||
}
|
||||
}
|
||||
|
||||
@@ -0,0 +1,298 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.common.tags
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringPreferencesKey
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import app.cash.sqldelight.driver.jdbc.sqlite.JdbcSqliteDriver
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import dev.msfjarvis.claw.database.local.TagBlocksQueries
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import kotlinx.serialization.json.Json
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TagBlockRepositoryTest {
|
||||
@TempDir lateinit var tempDir: File
|
||||
|
||||
private lateinit var tagBlocksQueries: TagBlocksQueries
|
||||
private val testDispatcher = UnconfinedTestDispatcher()
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
Dispatchers.setMain(testDispatcher)
|
||||
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
|
||||
|
||||
driver.execute(
|
||||
null,
|
||||
"""
|
||||
CREATE TABLE IF NOT EXISTS TagBlocks (
|
||||
tag TEXT PRIMARY KEY NOT NULL,
|
||||
expiration_millis INTEGER
|
||||
)
|
||||
"""
|
||||
.trimIndent(),
|
||||
0,
|
||||
)
|
||||
|
||||
tagBlocksQueries = TagBlocksQueries(driver)
|
||||
}
|
||||
|
||||
private fun createTestDataStore(): DataStore<Preferences> {
|
||||
return PreferenceDataStoreFactory.create { File(tempDir, "test_preferences.preferences_pb") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveTagBlock inserts new tag block successfully`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val expirationTime = System.currentTimeMillis() + 86400000
|
||||
repository.saveTagBlock("android", expirationTime)
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
assertThat(tags).containsExactly("android")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveTagBlock with null expiration creates permanent block`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
repository.saveTagBlock("kotlin", null)
|
||||
|
||||
val tagBlocks = repository.getTagBlocks().first()
|
||||
assertThat(tagBlocks).hasSize(1)
|
||||
assertThat(tagBlocks[0].tag).isEqualTo("kotlin")
|
||||
assertThat(tagBlocks[0].isPermanent).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `saveTagBlock replaces existing tag block`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val firstExpiration = System.currentTimeMillis() + 86400000
|
||||
repository.saveTagBlock("rust", firstExpiration)
|
||||
|
||||
val secondExpiration = System.currentTimeMillis() + 172800000
|
||||
repository.saveTagBlock("rust", secondExpiration)
|
||||
|
||||
val tagBlocks = repository.getTagBlocks().first()
|
||||
assertThat(tagBlocks).hasSize(1)
|
||||
assertThat(tagBlocks[0].expirationMillis).isEqualTo(secondExpiration)
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeTagBlock deletes tag successfully`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
repository.saveTagBlock("python", null)
|
||||
repository.saveTagBlock("javascript", null)
|
||||
|
||||
var tags = repository.getSavedTags().first()
|
||||
assertThat(tags).containsExactly("python", "javascript")
|
||||
|
||||
repository.removeTagBlock("python")
|
||||
|
||||
tags = repository.getSavedTags().first()
|
||||
assertThat(tags).containsExactly("javascript")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getSavedTags filters out expired tags`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val pastTime = System.currentTimeMillis() - 1000
|
||||
val futureTime = System.currentTimeMillis() + 86400000
|
||||
|
||||
repository.saveTagBlock("expired-tag", pastTime)
|
||||
repository.saveTagBlock("active-tag", futureTime)
|
||||
repository.saveTagBlock("permanent-tag", null)
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
|
||||
assertThat(tags).containsExactly("active-tag", "permanent-tag")
|
||||
assertThat(tags).doesNotContain("expired-tag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `getTagBlocks returns all blocks including expired`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val pastTime = System.currentTimeMillis() - 1000
|
||||
val futureTime = System.currentTimeMillis() + 86400000
|
||||
|
||||
repository.saveTagBlock("expired", pastTime)
|
||||
repository.saveTagBlock("active", futureTime)
|
||||
repository.saveTagBlock("permanent", null)
|
||||
|
||||
val tagBlocks = repository.getTagBlocks().first()
|
||||
|
||||
assertThat(tagBlocks).hasSize(3)
|
||||
assertThat(tagBlocks.map { it.tag }).containsExactly("expired", "active", "permanent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeExpiredTags only removes expired blocks`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val pastTime = System.currentTimeMillis() - 1000
|
||||
val futureTime = System.currentTimeMillis() + 86400000
|
||||
|
||||
repository.saveTagBlock("expired1", pastTime)
|
||||
repository.saveTagBlock("expired2", pastTime - 5000)
|
||||
repository.saveTagBlock("future", futureTime)
|
||||
repository.saveTagBlock("permanent", null)
|
||||
|
||||
repository.removeExpiredTags()
|
||||
|
||||
val remaining = repository.getTagBlocks().first()
|
||||
assertThat(remaining).hasSize(2)
|
||||
assertThat(remaining.map { it.tag }).containsExactly("future", "permanent")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrates legacy DataStore tags to SQLite`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("android", "kotlin", "java") }
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
|
||||
assertThat(tags).containsExactly("android", "kotlin", "java")
|
||||
|
||||
val tagBlocks = repository.getTagBlocks().first()
|
||||
assertThat(tagBlocks.all { it.isPermanent }).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrates JSON DataStore format to SQLite`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val datastoreTagsKey = stringPreferencesKey("tags_with_expiration")
|
||||
|
||||
val expirationTime = System.currentTimeMillis() + 86400000
|
||||
val tagMap = mapOf("rust" to expirationTime, "go" to null)
|
||||
|
||||
dataStore.edit { prefs -> prefs[datastoreTagsKey] = Json.encodeToString(tagMap) }
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
assertThat(tags).containsExactly("rust", "go")
|
||||
|
||||
val tagBlocks = repository.getTagBlocks().first()
|
||||
val rustBlock = tagBlocks.find { it.tag == "rust" }
|
||||
val goBlock = tagBlocks.find { it.tag == "go" }
|
||||
|
||||
assertThat(rustBlock?.expirationMillis).isEqualTo(expirationTime)
|
||||
assertThat(goBlock?.isPermanent).isTrue()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration only runs once`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("security") }
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
repository.getSavedTags().first()
|
||||
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("privacy") }
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
assertThat(tags).containsExactly("security")
|
||||
assertThat(tags).doesNotContain("privacy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `prefers JSON format over legacy format during migration`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
val datastoreTagsKey = stringPreferencesKey("tags_with_expiration")
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
prefs[legacyTagsKey] = setOf("old-tag")
|
||||
prefs[datastoreTagsKey] = Json.encodeToString(mapOf("new-tag" to null))
|
||||
}
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
|
||||
assertThat(tags).containsExactly("new-tag")
|
||||
assertThat(tags).doesNotContain("old-tag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration cleans up legacy DataStore after migrating`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("android", "kotlin") }
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
repository.getSavedTags().first()
|
||||
|
||||
val prefs = dataStore.data.first()
|
||||
assertThat(prefs[legacyTagsKey]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration cleans up JSON DataStore after migrating`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val datastoreTagsKey = stringPreferencesKey("tags_with_expiration")
|
||||
|
||||
dataStore.edit { prefs -> prefs[datastoreTagsKey] = Json.encodeToString(mapOf("rust" to null)) }
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
repository.getSavedTags().first()
|
||||
|
||||
val prefs = dataStore.data.first()
|
||||
assertThat(prefs[datastoreTagsKey]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration cleans up both DataStore formats when both present`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
val datastoreTagsKey = stringPreferencesKey("tags_with_expiration")
|
||||
|
||||
dataStore.edit { prefs ->
|
||||
prefs[legacyTagsKey] = setOf("old-tag")
|
||||
prefs[datastoreTagsKey] = Json.encodeToString(mapOf("new-tag" to null))
|
||||
}
|
||||
|
||||
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
|
||||
repository.getSavedTags().first()
|
||||
|
||||
val prefs = dataStore.data.first()
|
||||
assertThat(prefs[legacyTagsKey]).isNull()
|
||||
assertThat(prefs[datastoreTagsKey]).isNull()
|
||||
}
|
||||
}
|
||||
@@ -1,125 +0,0 @@
|
||||
/*
|
||||
* Copyright © Harsh Shandilya.
|
||||
* Use of this source code is governed by an MIT-style
|
||||
* license that can be found in the LICENSE file or at
|
||||
* https://opensource.org/licenses/MIT.
|
||||
*/
|
||||
package dev.msfjarvis.claw.common.tags
|
||||
|
||||
import androidx.datastore.core.DataStore
|
||||
import androidx.datastore.preferences.core.PreferenceDataStoreFactory
|
||||
import androidx.datastore.preferences.core.Preferences
|
||||
import androidx.datastore.preferences.core.edit
|
||||
import androidx.datastore.preferences.core.stringSetPreferencesKey
|
||||
import com.google.common.truth.Truth.assertThat
|
||||
import java.io.File
|
||||
import kotlinx.coroutines.Dispatchers
|
||||
import kotlinx.coroutines.ExperimentalCoroutinesApi
|
||||
import kotlinx.coroutines.flow.first
|
||||
import kotlinx.coroutines.test.UnconfinedTestDispatcher
|
||||
import kotlinx.coroutines.test.runTest
|
||||
import kotlinx.coroutines.test.setMain
|
||||
import org.junit.jupiter.api.BeforeEach
|
||||
import org.junit.jupiter.api.Test
|
||||
import org.junit.jupiter.api.io.TempDir
|
||||
|
||||
@OptIn(ExperimentalCoroutinesApi::class)
|
||||
class TagFilterRepositoryTest {
|
||||
@TempDir lateinit var tempDir: File
|
||||
|
||||
@BeforeEach
|
||||
fun setup() {
|
||||
Dispatchers.setMain(UnconfinedTestDispatcher())
|
||||
}
|
||||
|
||||
private fun createTestDataStore(): DataStore<Preferences> {
|
||||
return PreferenceDataStoreFactory.create { File(tempDir, "test_preferences.preferences_pb") }
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migrates legacy tags to new format with null expiration`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("android", "kotlin", "java") }
|
||||
|
||||
val repository = TagFilterRepository(dataStore)
|
||||
val tags = repository.getSavedTags().first()
|
||||
|
||||
assertThat(tags).containsExactly("android", "kotlin", "java")
|
||||
|
||||
val dataStoreSnapshot = dataStore.data.first()
|
||||
assertThat(dataStoreSnapshot[legacyTagsKey]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `migration preserves tags as permanent blocks`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("security", "privacy") }
|
||||
|
||||
val repository = TagFilterRepository(dataStore)
|
||||
val tagBlocks = repository.getTagBlocks().first()
|
||||
|
||||
assertThat(tagBlocks).hasSize(2)
|
||||
assertThat(tagBlocks.all { it.isPermanent }).isTrue()
|
||||
assertThat(tagBlocks.map { it.tag }).containsExactly("security", "privacy")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `merges legacy tags with existing new format tags`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagFilterRepository(dataStore)
|
||||
|
||||
val expirationTime = System.currentTimeMillis() + 86400000
|
||||
repository.saveTagBlock("rust", expirationTime)
|
||||
|
||||
val legacyTagsKey = stringSetPreferencesKey("tags")
|
||||
dataStore.edit { prefs -> prefs[legacyTagsKey] = setOf("python", "javascript") }
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
assertThat(tags).containsExactly("rust", "python", "javascript")
|
||||
|
||||
val dataStoreSnapshot = dataStore.data.first()
|
||||
assertThat(dataStoreSnapshot[legacyTagsKey]).isNull()
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `filters out expired tags from getSavedTags`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagFilterRepository(dataStore)
|
||||
|
||||
val pastTime = System.currentTimeMillis() - 1000
|
||||
val futureTime = System.currentTimeMillis() + 86400000
|
||||
|
||||
repository.saveTagBlock("expired-tag", pastTime)
|
||||
repository.saveTagBlock("active-tag", futureTime)
|
||||
repository.saveTagBlock("permanent-tag", null)
|
||||
|
||||
val tags = repository.getSavedTags().first()
|
||||
|
||||
assertThat(tags).containsExactly("active-tag", "permanent-tag")
|
||||
assertThat(tags).doesNotContain("expired-tag")
|
||||
}
|
||||
|
||||
@Test
|
||||
fun `removeExpiredTags only removes expired blocks`() = runTest {
|
||||
val dataStore = createTestDataStore()
|
||||
val repository = TagFilterRepository(dataStore)
|
||||
|
||||
val pastTime = System.currentTimeMillis() - 1000
|
||||
val futureTime = System.currentTimeMillis() + 86400000
|
||||
|
||||
repository.saveTagBlock("expired1", pastTime)
|
||||
repository.saveTagBlock("expired2", pastTime - 5000)
|
||||
repository.saveTagBlock("future", futureTime)
|
||||
repository.saveTagBlock("permanent", null)
|
||||
|
||||
repository.removeExpiredTags()
|
||||
|
||||
val remaining = repository.getTagBlocks().first()
|
||||
assertThat(remaining).hasSize(2)
|
||||
assertThat(remaining.map { it.tag }).containsExactly("future", "permanent")
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import kotlin.Long;
|
||||
|
||||
CREATE TABLE TagBlocks (
|
||||
tag TEXT PRIMARY KEY NOT NULL,
|
||||
expiration_millis INTEGER AS Long
|
||||
);
|
||||
|
||||
selectAll:
|
||||
SELECT * FROM TagBlocks;
|
||||
|
||||
selectActiveTags:
|
||||
SELECT tag FROM TagBlocks
|
||||
WHERE expiration_millis IS NULL OR expiration_millis > :currentTimeMillis;
|
||||
|
||||
insertOrReplace:
|
||||
INSERT OR REPLACE INTO TagBlocks(tag, expiration_millis)
|
||||
VALUES (?, ?);
|
||||
|
||||
deleteByTag:
|
||||
DELETE FROM TagBlocks WHERE tag = ?;
|
||||
|
||||
deleteExpired:
|
||||
DELETE FROM TagBlocks
|
||||
WHERE expiration_millis IS NOT NULL AND expiration_millis <= :currentTimeMillis;
|
||||
|
||||
deleteAll:
|
||||
DELETE FROM TagBlocks;
|
||||
@@ -0,0 +1,4 @@
|
||||
CREATE TABLE IF NOT EXISTS TagBlocks (
|
||||
tag TEXT PRIMARY KEY NOT NULL,
|
||||
expiration_millis INTEGER
|
||||
);
|
||||
@@ -11,6 +11,7 @@ import dev.msfjarvis.claw.database.local.CachedRemotePostQueries
|
||||
import dev.msfjarvis.claw.database.local.PostCommentsQueries
|
||||
import dev.msfjarvis.claw.database.local.ReadPostsQueries
|
||||
import dev.msfjarvis.claw.database.local.SavedPostQueries
|
||||
import dev.msfjarvis.claw.database.local.TagBlocksQueries
|
||||
import dev.zacsweers.metro.AppScope
|
||||
import dev.zacsweers.metro.BindingContainer
|
||||
import dev.zacsweers.metro.ContributesTo
|
||||
@@ -48,4 +49,10 @@ object QueriesModule {
|
||||
): CachedRemotePostQueries {
|
||||
return database.cachedRemotePostQueries
|
||||
}
|
||||
|
||||
@Provides
|
||||
@SingleIn(AppScope::class)
|
||||
fun provideTagBlocksQueries(@InternalDatabaseApi database: LobstersDatabase): TagBlocksQueries {
|
||||
return database.tagBlocksQueries
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,13 +5,13 @@ androidx-test = "1.7.0"
|
||||
annotation = "1.9.1"
|
||||
coil3 = "3.4.0"
|
||||
collection = "1.5.0"
|
||||
compose-animation = "1.11.0-alpha05"
|
||||
compose-foundation = "1.11.0-alpha05"
|
||||
compose-animation = "1.11.0-alpha06"
|
||||
compose-foundation = "1.11.0-alpha06"
|
||||
compose-icons = "1.7.8"
|
||||
compose-material3 = "1.5.0-alpha14"
|
||||
compose-runtime = "1.11.0-alpha05"
|
||||
compose-material3 = "1.5.0-alpha15"
|
||||
compose-runtime = "1.11.0-alpha06"
|
||||
benchmark = "1.5.0-alpha03"
|
||||
compose-ui = "1.11.0-alpha05"
|
||||
compose-ui = "1.11.0-alpha06"
|
||||
coroutines = "1.10.2"
|
||||
datastore = "1.2.0"
|
||||
eithernet = "2.0.0"
|
||||
@@ -22,13 +22,13 @@ konvert = "4.4.0"
|
||||
kotlin = "2.3.10"
|
||||
kotlinResult = "2.1.0"
|
||||
lifecycle = "2.10.0"
|
||||
metro = "0.11.0"
|
||||
metro = "0.11.1"
|
||||
monitor = "1.8.0"
|
||||
navigation3 = "1.0.1"
|
||||
navigation3-material = "1.3.0-alpha08"
|
||||
navigation3-material = "1.3.0-alpha09"
|
||||
paging = "3.4.1"
|
||||
retrofit = "3.0.0"
|
||||
runtimeSaveable = "1.11.0-alpha05"
|
||||
runtimeSaveable = "1.11.0-alpha06"
|
||||
sentry = "8.33.0"
|
||||
serialization = "1.10.0"
|
||||
sqldelight = "2.2.1"
|
||||
|
||||
Reference in New Issue
Block a user