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:
co-authored by
copilot-swe-agent[bot] <198982749+Copilot@users.noreply.github.com>
parent
d4118eaa67
commit
4dd9630e92
@@ -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
|
||||
|
||||
+51
@@ -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"
|
||||
}
|
||||
}
|
||||
+38
@@ -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
|
||||
}
|
||||
}
|
||||
|
||||
+106
@@ -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
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user