[1.489.*] Pre-release merge (#1084)

This commit is contained in:
tramline-github[bot]
2026-02-26 14:30:12 +00:00
committed by GitHub
14 changed files with 467 additions and 252 deletions
+1 -4
View File
@@ -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
@@ -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()
}
+3
View File
@@ -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
}
}
+8 -8
View File
@@ -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"