[1.514.*] Pre-release merge (#1123)

This commit is contained in:
tramline-github[bot]
2026-04-03 14:30:07 +00:00
committed by GitHub
8 changed files with 91 additions and 298 deletions
+1
View File
@@ -248,3 +248,4 @@ distribution/
screenshots/
claw-export.json
.kotlin/
.worktrees/
+4
View File
@@ -7,6 +7,10 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Changed
- The migration path for tag data format changes in 1.61.0 has been removed.
## [1.63.0] - 2026-04-02
### Fixed
-1
View File
@@ -99,7 +99,6 @@ dependencies {
implementation(libs.androidx.collection)
implementation(libs.androidx.core.ktx)
implementation(libs.androidx.core.splashscreen)
implementation(libs.androidx.datastore.preferences.core)
implementation(libs.androidx.lifecycle.common)
implementation(libs.androidx.lifecycle.compose)
implementation(libs.androidx.lifecycle.runtime.compose)
-3
View File
@@ -27,9 +27,6 @@ dependencies {
api(libs.androidx.compose.foundation.layout)
api(libs.androidx.compose.ui)
api(libs.androidx.compose.ui.graphics)
api(libs.androidx.datastore)
api(libs.androidx.datastore.core)
api(libs.androidx.datastore.preferences.core)
api(libs.androidx.lifecycle.viewmodel)
api(projects.api)
api(projects.core)
@@ -1,61 +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.persistence
import android.content.Context
import android.util.Log
import androidx.datastore.core.DataMigration
import androidx.datastore.core.DataStore
import androidx.datastore.core.DataStoreFactory
import androidx.datastore.core.handlers.ReplaceFileCorruptionHandler
import androidx.datastore.preferences.core.Preferences
import androidx.datastore.preferences.core.PreferencesFileSerializer
import androidx.datastore.preferences.core.emptyPreferences
import androidx.datastore.preferences.preferencesDataStoreFile
import dev.zacsweers.metro.AppScope
import dev.zacsweers.metro.BindingContainer
import dev.zacsweers.metro.ContributesTo
import dev.zacsweers.metro.IntoSet
import dev.zacsweers.metro.Provides
import dev.zacsweers.metro.SingleIn
@BindingContainer
@ContributesTo(AppScope::class)
object PreferencesStoreModule {
@Provides
@SingleIn(AppScope::class)
fun providePreferencesDataStore(
context: Context,
migrations: Set<DataMigration<Preferences>>,
): DataStore<Preferences> {
return DataStoreFactory.create(
corruptionHandler =
ReplaceFileCorruptionHandler {
Log.d(
"PreferencesStore",
"Preferences data store corruption detected, returning empty preferences.",
it,
)
emptyPreferences()
},
migrations = migrations.toList(),
produceFile = { context.preferencesDataStoreFile("claw_preferences") },
serializer = PreferencesFileSerializer,
)
}
@Provides
@IntoSet
fun firstMigration(): DataMigration<Preferences> =
object : DataMigration<Preferences> {
override suspend fun shouldMigrate(currentData: Preferences): Boolean = false
override suspend fun migrate(currentData: Preferences): Preferences = currentData
override suspend fun cleanUp() {}
}
}
@@ -6,11 +6,6 @@
*/
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
@@ -18,77 +13,28 @@ 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() }
return tagBlocksQueries.selectActiveTags(now).asFlow().mapToList(readDispatcher).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) } }
return tagBlocksQueries.selectAll().asFlow().mapToList(readDispatcher).map { blocks ->
blocks.map { TagBlock(it.tag, it.expiration_millis) }
}
}
suspend fun saveTagBlock(tag: String, expirationMillis: Long?) {
@@ -6,74 +6,59 @@
*/
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.LobstersDatabase
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.flow.take
import kotlinx.coroutines.flow.toList
import kotlinx.coroutines.launch
import kotlinx.coroutines.test.StandardTestDispatcher
import kotlinx.coroutines.test.resetMain
import kotlinx.coroutines.test.runTest
import kotlinx.coroutines.test.setMain
import kotlinx.serialization.json.Json
import org.junit.jupiter.api.AfterEach
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 driver: JdbcSqliteDriver
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,
)
driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
LobstersDatabase.Schema.create(driver)
tagBlocksQueries = TagBlocksQueries(driver)
}
private fun createTestDataStore(): DataStore<Preferences> {
return PreferenceDataStoreFactory.create { File(tempDir, "test_preferences.preferences_pb") }
@AfterEach
fun teardown() {
Dispatchers.resetMain()
driver.close()
}
@Test
fun `saveTagBlock inserts new tag block successfully`() = runTest {
val dataStore = createTestDataStore()
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, 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)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
repository.saveTagBlock("kotlin", null)
@@ -85,8 +70,9 @@ class TagBlockRepositoryTest {
@Test
fun `saveTagBlock replaces existing tag block`() = runTest {
val dataStore = createTestDataStore()
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
val firstExpiration = System.currentTimeMillis() + 86400000
repository.saveTagBlock("rust", firstExpiration)
@@ -101,8 +87,9 @@ class TagBlockRepositoryTest {
@Test
fun `removeTagBlock deletes tag successfully`() = runTest {
val dataStore = createTestDataStore()
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
repository.saveTagBlock("python", null)
repository.saveTagBlock("javascript", null)
@@ -118,8 +105,9 @@ class TagBlockRepositoryTest {
@Test
fun `getSavedTags filters out expired tags`() = runTest {
val dataStore = createTestDataStore()
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
val pastTime = System.currentTimeMillis() - 1000
val futureTime = System.currentTimeMillis() + 86400000
@@ -134,10 +122,31 @@ class TagBlockRepositoryTest {
assertThat(tags).doesNotContain("expired-tag")
}
@Test
fun `getSavedTags emits updates after mutations`() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
val emissions = mutableListOf<Set<String>>()
val collector =
backgroundScope.launch(testDispatcher) { repository.getSavedTags().take(3).toList(emissions) }
repository.saveTagBlock("android", null)
repository.removeTagBlock("android")
collector.join()
assertThat(emissions)
.containsExactly(emptySet<String>(), setOf("android"), emptySet<String>())
.inOrder()
}
@Test
fun `getTagBlocks returns all blocks including expired`() = runTest {
val dataStore = createTestDataStore()
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
val pastTime = System.currentTimeMillis() - 1000
val futureTime = System.currentTimeMillis() + 86400000
@@ -152,10 +161,36 @@ class TagBlockRepositoryTest {
assertThat(tagBlocks.map { it.tag }).containsExactly("expired", "active", "permanent")
}
@Test
fun `getTagBlocks emits updates after mutations`() = runTest {
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
val emissions = mutableListOf<List<String>>()
val collectedBlocks = mutableListOf<List<dev.msfjarvis.claw.model.TagBlock>>()
val collector =
backgroundScope.launch(testDispatcher) {
repository.getTagBlocks().take(3).toList(collectedBlocks)
}
repository.saveTagBlock("kotlin", null)
repository.removeTagBlock("kotlin")
collector.join()
emissions += collectedBlocks.map { blocks -> blocks.map { it.tag } }
assertThat(emissions)
.containsExactly(emptyList<String>(), listOf("kotlin"), emptyList<String>())
.inOrder()
}
@Test
fun `removeExpiredTags only removes expired blocks`() = runTest {
val dataStore = createTestDataStore()
val repository = TagBlockRepository(tagBlocksQueries, dataStore, testDispatcher, testDispatcher)
val testDispatcher = StandardTestDispatcher(testScheduler)
Dispatchers.setMain(testDispatcher)
val repository = TagBlockRepository(tagBlocksQueries, testDispatcher, testDispatcher)
val pastTime = System.currentTimeMillis() - 1000
val futureTime = System.currentTimeMillis() + 86400000
@@ -171,128 +206,4 @@ class TagBlockRepositoryTest {
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()
}
}
-4
View File
@@ -13,7 +13,6 @@ compose-runtime = "1.11.0-beta02"
benchmark = "1.5.0-alpha05"
compose-ui = "1.11.0-beta02"
coroutines = "1.10.2"
datastore = "1.3.0-alpha07"
eithernet = "2.0.0"
glance = "1.2.0-rc01"
haze = "1.7.2"
@@ -76,9 +75,6 @@ androidx-compose-ui-unit = { module = "androidx.compose.ui:ui-unit", version.ref
androidx-core = "androidx.core:core:1.18.0"
androidx-core-ktx = "androidx.core:core-ktx:1.18.0"
androidx-core-splashscreen = "androidx.core:core-splashscreen:1.2.0"
androidx-datastore = { module = "androidx.datastore:datastore-preferences", version.ref = "datastore" }
androidx-datastore-core = { module = "androidx.datastore:datastore-core", version.ref = "datastore" }
androidx-datastore-preferences-core = { module = "androidx.datastore:datastore-preferences-core", version.ref = "datastore" }
androidx-lifecycle-common = { module = "androidx.lifecycle:lifecycle-common", version.ref = "lifecycle" }
androidx-lifecycle-compose = { module = "androidx.lifecycle:lifecycle-viewmodel-compose", version.ref = "lifecycle" }
androidx-lifecycle-runtime-compose = { module = "androidx.lifecycle:lifecycle-runtime-compose", version.ref = "lifecycle" }