Add database backed cache for hottest posts widget (#1041)

Co-authored-by: copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
This commit is contained in:
Copilot
2026-01-15 23:07:06 +00:00
committed by GitHub
co-authored by copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent d4118eaa67
commit 4dd9630e92
10 changed files with 283 additions and 3 deletions
@@ -37,21 +37,55 @@ import com.slack.eithernet.ApiResult
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.android.ClawApplication
import dev.msfjarvis.claw.android.MainActivity
import dev.msfjarvis.claw.database.local.CachedRemotePost
import dev.msfjarvis.claw.model.LobstersPost
import dev.msfjarvis.claw.model.UIPost
import dev.msfjarvis.claw.model.fromCachedRemotePost
import dev.msfjarvis.claw.model.toUIPost
import kotlinx.collections.immutable.ImmutableList
import kotlinx.collections.immutable.persistentListOf
import kotlinx.collections.immutable.toImmutableList
class HottestPostsWidget : GlanceAppWidget() {
override suspend fun provideGlance(context: Context, id: GlanceId) {
val appGraph = (context.applicationContext as ClawApplication).appGraph
val cachedRemotePostsRepository = appGraph.cachedRemotePostsRepository
val posts =
when (val postsResult = appGraph.lobstersApi.getHottestPosts(1)) {
is ApiResult.Success -> postsResult.value.map(LobstersPost::toUIPost).toImmutableList()
else -> persistentListOf()
is ApiResult.Success -> {
val uiPosts = postsResult.value.map(LobstersPost::toUIPost)
// Cache the posts for future use when network is unavailable
// Using try-catch to prevent widget rendering failures if caching fails
try {
val cachedPosts =
uiPosts.mapIndexed { index, post ->
CachedRemotePost(
shortId = post.shortId,
title = post.title,
url = post.url,
createdAt = post.createdAt,
commentCount = post.commentCount,
commentsUrl = post.commentsUrl,
submitterName = post.submitter,
tags = post.tags,
description = post.description,
userIsAuthor = post.userIsAuthor,
insertionOrder = index,
)
}
cachedRemotePostsRepository.savePosts(cachedPosts)
} catch (_: Exception) {
// Silently ignore caching failures - widget should still render
}
uiPosts.toImmutableList()
}
else -> {
// Fall back to cached posts when network fails
cachedRemotePostsRepository
.getCachedPosts()
.map(UIPost::fromCachedRemotePost)
.toImmutableList()
}
}
provideContent { LobstersGlanceTheme { Content(posts) } }
}
@@ -9,6 +9,7 @@ package dev.msfjarvis.claw.android.injection
import android.content.Context
import androidx.work.ListenableWorker
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.core.coroutines.MainDispatcher
@@ -45,6 +46,8 @@ interface AppGraph : MetroAppComponentProviders, ViewModelGraph {
val savedPostsRepository: SavedPostsRepository
val cachedRemotePostsRepository: CachedRemotePostsRepository
val lobstersApi: LobstersApi
@MainDispatcher val mainDispatcher: CoroutineDispatcher
@@ -0,0 +1,51 @@
/*
* 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.android.viewmodel
import android.util.Log
import app.cash.sqldelight.coroutines.asFlow
import app.cash.sqldelight.coroutines.mapToList
import dev.msfjarvis.claw.android.BuildConfig
import dev.msfjarvis.claw.core.coroutines.DatabaseReadDispatcher
import dev.msfjarvis.claw.core.coroutines.DatabaseWriteDispatcher
import dev.msfjarvis.claw.database.local.CachedRemotePost
import dev.msfjarvis.claw.database.local.CachedRemotePostQueries
import dev.zacsweers.metro.Inject
import kotlinx.coroutines.CoroutineDispatcher
import kotlinx.coroutines.withContext
@Inject
class CachedRemotePostsRepository(
private val cachedRemotePostQueries: CachedRemotePostQueries,
@param:DatabaseReadDispatcher private val readDispatcher: CoroutineDispatcher,
@param:DatabaseWriteDispatcher private val writeDispatcher: CoroutineDispatcher,
) {
val cachedPosts = cachedRemotePostQueries.selectAllPosts().asFlow().mapToList(readDispatcher)
fun getRecentPosts(limit: Long) =
cachedRemotePostQueries.selectRecentPosts(limit).asFlow().mapToList(readDispatcher)
suspend fun getCachedPosts(): List<CachedRemotePost> {
return withContext(readDispatcher) { cachedRemotePostQueries.selectAllPosts().executeAsList() }
}
suspend fun savePosts(posts: List<CachedRemotePost>) {
if (BuildConfig.DEBUG) {
Log.d(TAG, "Caching remote posts: ${posts.joinToString(",") { it.shortId }}")
}
withContext(writeDispatcher) {
cachedRemotePostQueries.transaction {
cachedRemotePostQueries.deleteAllPosts()
posts.forEach { post -> cachedRemotePostQueries.insertOrReplacePost(post) }
}
}
}
private companion object {
private const val TAG = "CachedRemotePostsRepository"
}
}
@@ -0,0 +1,38 @@
import kotlin.Int;
import kotlin.String;
import kotlin.collections.List;
import kotlin.Boolean;
CREATE TABLE IF NOT EXISTS CachedRemotePost(
shortId TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
createdAt TEXT NOT NULL,
commentCount INTEGER AS Int,
commentsUrl TEXT NOT NULL,
submitterName TEXT NOT NULL,
tags TEXT AS List<String> NOT NULL,
description TEXT NOT NULL DEFAULT "",
userIsAuthor INTEGER AS Boolean NOT NULL DEFAULT 0,
insertionOrder INTEGER AS Int NOT NULL DEFAULT 0
);
insertOrReplacePost:
INSERT OR REPLACE
INTO CachedRemotePost
VALUES ?;
selectAllPosts:
SELECT *
FROM CachedRemotePost
ORDER BY insertionOrder ASC;
selectRecentPosts:
SELECT *
FROM CachedRemotePost
ORDER BY insertionOrder ASC
LIMIT :limit;
deleteAllPosts:
DELETE
FROM CachedRemotePost;
@@ -0,0 +1,18 @@
import kotlin.Int;
import kotlin.String;
import kotlin.collections.List;
import kotlin.Boolean;
CREATE TABLE IF NOT EXISTS CachedRemotePost(
shortId TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
createdAt TEXT NOT NULL,
commentCount INTEGER AS Int,
commentsUrl TEXT NOT NULL,
submitterName TEXT NOT NULL,
tags TEXT AS List<String> NOT NULL,
description TEXT NOT NULL DEFAULT "",
userIsAuthor INTEGER AS Boolean NOT NULL DEFAULT 0,
insertionOrder INTEGER AS Int NOT NULL DEFAULT 0
);
@@ -13,6 +13,7 @@ import app.cash.sqldelight.adapter.primitive.IntColumnAdapter
import app.cash.sqldelight.driver.android.AndroidSqliteDriver
import app.cash.sqldelight.logs.LogSqliteDriver
import dev.msfjarvis.claw.database.LobstersDatabase
import dev.msfjarvis.claw.database.local.CachedRemotePost
import dev.msfjarvis.claw.database.local.PostComments
import dev.msfjarvis.claw.database.local.SavedPost
import dev.msfjarvis.claw.database.model.CSVAdapter
@@ -59,6 +60,8 @@ object DatabaseModule {
}
return LobstersDatabase(
driver = driver,
CachedRemotePostAdapter =
CachedRemotePost.Adapter(IntColumnAdapter, CSVAdapter(), IntColumnAdapter),
PostCommentsAdapter = PostComments.Adapter(CSVAdapter()),
SavedPostAdapter = SavedPost.Adapter(IntColumnAdapter, CSVAdapter()),
)
@@ -7,6 +7,7 @@
package dev.msfjarvis.claw.database.injection
import dev.msfjarvis.claw.database.LobstersDatabase
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
@@ -39,4 +40,12 @@ object QueriesModule {
fun provideReadPostsQueries(@InternalDatabaseApi database: LobstersDatabase): ReadPostsQueries {
return database.readPostsQueries
}
@Provides
@SingleIn(AppScope::class)
fun provideCachedRemotePostQueries(
@InternalDatabaseApi database: LobstersDatabase
): CachedRemotePostQueries {
return database.cachedRemotePostQueries
}
}
@@ -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.database.local
import com.google.common.truth.Truth.assertThat
import org.junit.jupiter.api.BeforeEach
import org.junit.jupiter.api.Test
class CachedRemotePostQueriesTest {
private lateinit var postQueries: CachedRemotePostQueries
@BeforeEach
fun setup() {
postQueries = setupDatabase().cachedRemotePostQueries
}
@Test
fun `insert and select all posts`() {
val posts = createTestData(5)
posts.forEach { postQueries.insertOrReplacePost(it) }
val postsFromDb = postQueries.selectAllPosts().executeAsList()
assertThat(postsFromDb).hasSize(5)
}
@Test
fun `insert or replace post updates existing post`() {
val post = createTestData(1).first()
postQueries.insertOrReplacePost(post)
val newPost = post.copy(submitterName = "Updated name")
postQueries.insertOrReplacePost(newPost)
val postsFromDb = postQueries.selectAllPosts().executeAsList()
assertThat(postsFromDb).hasSize(1)
assertThat(postsFromDb.first().submitterName).isEqualTo("Updated name")
}
@Test
fun `select recent posts with limit`() {
val posts = createTestData(10)
posts.forEach { postQueries.insertOrReplacePost(it) }
val recentPosts = postQueries.selectRecentPosts(5).executeAsList()
assertThat(recentPosts).hasSize(5)
}
@Test
fun `select recent posts returns all when limit exceeds count`() {
val posts = createTestData(3)
posts.forEach { postQueries.insertOrReplacePost(it) }
val recentPosts = postQueries.selectRecentPosts(10).executeAsList()
assertThat(recentPosts).hasSize(3)
}
@Test
fun `delete all posts`() {
val posts = createTestData(5)
posts.forEach { postQueries.insertOrReplacePost(it) }
postQueries.deleteAllPosts()
val postsFromDb = postQueries.selectAllPosts().executeAsList()
assertThat(postsFromDb).isEmpty()
}
@Test
fun `posts are returned in insertion order`() {
val posts = createTestData(5)
posts.forEach { postQueries.insertOrReplacePost(it) }
val postsFromDb = postQueries.selectAllPosts().executeAsList()
assertThat(postsFromDb.map { it.insertionOrder }).containsExactly(0, 1, 2, 3, 4).inOrder()
}
private fun createTestData(count: Int): List<CachedRemotePost> {
return (1..count).map { i ->
CachedRemotePost(
shortId = "test_id_$i",
createdAt = "2024-01-${i.toString().padStart(2, '0')}T00:00:00+00:00",
title = "test_post_$i",
url = "test_url_$i",
commentCount = i,
commentsUrl = "test_comments_url_$i",
submitterName = "test_user_$i",
tags = listOf("tag1", "tag2"),
description = "description_$i",
userIsAuthor = i % 2 == 0,
insertionOrder = i - 1,
)
}
}
}
@@ -16,6 +16,7 @@ fun setupDatabase(): LobstersDatabase {
LobstersDatabase.Schema.create(driver)
return LobstersDatabase(
driver,
CachedRemotePost.Adapter(IntColumnAdapter, CSVAdapter(), IntColumnAdapter),
PostComments.Adapter(CSVAdapter()),
SavedPost.Adapter(IntColumnAdapter, CSVAdapter()),
)
@@ -6,6 +6,7 @@
*/
package dev.msfjarvis.claw.model
import dev.msfjarvis.claw.database.local.CachedRemotePost
import dev.msfjarvis.claw.database.local.SavedPost
import io.mcarle.konvert.api.KonvertFrom
import io.mcarle.konvert.api.KonvertTo
@@ -16,6 +17,14 @@ import kotlinx.serialization.SerialName
value = SavedPost::class,
mappings = [Mapping(source = "submitter", target = "submitterName")],
)
@KonvertTo(
value = CachedRemotePost::class,
mappings =
[
Mapping(source = "submitter", target = "submitterName"),
Mapping(target = "insertionOrder", constant = "0"),
],
)
data class UIPost(
val shortId: String,
val createdAt: String,
@@ -37,5 +46,13 @@ data class UIPost(
Mapping(target = "commentCount", expression = "it.commentCount ?: 0"),
],
)
@KonvertFrom(
value = CachedRemotePost::class,
mappings =
[
Mapping(source = "submitterName", target = "submitter"),
Mapping(target = "commentCount", expression = "it.commentCount ?: 0"),
],
)
companion object
}