130: Split API and database models r=msfjarvis a=msfjarvis

- Introduces a new `SavedPost` table stripped down to only the necessary fields for persistence
- Deletes the old `LobstersPost` table
- Switches `LobstersItem` to take in a `SavedPost` for rendering
- Replaces moshi-metadata-reflect with moshi-ksp

Fixes #118 

bors r+


Co-authored-by: Harsh Shandilya <me@msfjarvis.dev>
This commit is contained in:
bors[bot] 2021-02-28 15:29:33 +00:00 committed by GitHub
commit f10e2b79ac
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
27 changed files with 195 additions and 200 deletions

1
.github/ci-gradle.properties vendored Normal file
View file

@ -0,0 +1 @@
org.gradle.jvmargs=-Xmx2048m -XX:MaxPermSize=512m -XX:+HeapDumpOnOutOfMemoryError -Dfile.encoding=UTF-8

View file

@ -10,12 +10,17 @@ jobs:
test-pr: test-pr:
runs-on: macOS-latest runs-on: macOS-latest
steps: steps:
- uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2 - uses: actions/setup-java@d202f5dbf7256730fb690ec59f6381650114feb2
with: with:
java-version: '11' java-version: '11'
- name: Checkout repository - name: Checkout repository
uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f uses: actions/checkout@5a4ac9002d0be2fb38bd78e4b4dbde5606d7042f
- name: Copy CI gradle.properties
run: mkdir -p ~/.gradle ; cp .github/ci-gradle.properties ~/.gradle/gradle.properties
- uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6 - uses: burrunan/gradle-cache-action@03c71a8ba93d670980695505f48f49daf43704a6
name: Run unit tests name: Run unit tests
with: with:

6
.idea/kotlinScripting.xml generated Normal file
View file

@ -0,0 +1,6 @@
<?xml version="1.0" encoding="UTF-8"?>
<project version="4">
<component name="KotlinScriptingSettings">
<option name="suppressDefinitionsCheck" value="true" />
</component>
</project>

View file

@ -1,12 +1,12 @@
plugins { plugins {
kotlin("jvm") kotlin("jvm")
id("com.google.devtools.ksp") version "1.4.30-1.0.0-alpha04"
`lobsters-plugin` `lobsters-plugin`
} }
dependencies { dependencies {
api(Dependencies.ThirdParty.Retrofit.lib) api(Dependencies.ThirdParty.Retrofit.lib)
implementation(project(":database")) ksp(Dependencies.ThirdParty.Moshi.ksp)
implementation(Dependencies.ThirdParty.Moshi.moshiMetadataReflect)
implementation(Dependencies.ThirdParty.Retrofit.moshi) implementation(Dependencies.ThirdParty.Retrofit.moshi)
testImplementation(Dependencies.Kotlin.Coroutines.core) testImplementation(Dependencies.Kotlin.Coroutines.core)
testImplementation(Dependencies.Testing.junit) testImplementation(Dependencies.Testing.junit)

View file

@ -1,6 +1,6 @@
package dev.msfjarvis.lobsters.data.api package dev.msfjarvis.lobsters.data.api
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.model.LobstersPost
import retrofit2.http.GET import retrofit2.http.GET
import retrofit2.http.Query import retrofit2.http.Query

View file

@ -1,10 +1,12 @@
package dev.msfjarvis.lobsters.model package dev.msfjarvis.lobsters.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class KeybaseSignature( class KeybaseSignature(
@Json(name = "kb_username") @Json(name = "kb_username")
val kbUsername: String, val kbUsername: String,
@Json(name = "sig_hash") @Json(name = "sig_hash")
val sigHash: String val sigHash: String,
) )

View file

@ -0,0 +1,26 @@
package dev.msfjarvis.lobsters.model
import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class LobstersPost(
@Json(name = "short_id")
val shortId: String,
@Json(name = "short_id_url")
val shortIdUrl: String,
@Json(name = "created_at")
val createdAt: String,
val title: String,
val url: String,
val score: Long,
val flags: Long,
@Json(name = "comment_count")
val commentCount: Long,
val description: String,
@Json(name = "comments_url")
val commentsUrl: String,
@Json(name = "submitter_user")
val submitterUser: Submitter,
val tags: List<String>,
)

View file

@ -1,7 +1,9 @@
package dev.msfjarvis.lobsters.model package dev.msfjarvis.lobsters.model
import com.squareup.moshi.Json import com.squareup.moshi.Json
import com.squareup.moshi.JsonClass
@JsonClass(generateAdapter = true)
class Submitter( class Submitter(
val username: String, val username: String,
@Json(name = "created_at") @Json(name = "created_at")
@ -21,5 +23,5 @@ class Submitter(
@Json(name = "twitter_username") @Json(name = "twitter_username")
val twitterUsername: String? = null, val twitterUsername: String? = null,
@Json(name = "keybase_signatures") @Json(name = "keybase_signatures")
val keybaseSignatures: List<KeybaseSignature> = emptyList() val keybaseSignatures: List<KeybaseSignature> = emptyList(),
) )

View file

@ -2,7 +2,6 @@ package dev.msfjarvis.lobsters.data.api
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import dev.msfjarvis.lobsters.util.TestUtils import dev.msfjarvis.lobsters.util.TestUtils
import dev.zacsweers.moshix.reflect.MetadataKotlinJsonAdapterFactory
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import mockwebserver3.Dispatcher import mockwebserver3.Dispatcher
import mockwebserver3.MockResponse import mockwebserver3.MockResponse
@ -24,7 +23,6 @@ class LobstersApiTest {
private val webServer = MockWebServer() private val webServer = MockWebServer()
private val apiData = TestUtils.getJson("hottest.json") private val apiData = TestUtils.getJson("hottest.json")
private val moshi = Moshi.Builder() private val moshi = Moshi.Builder()
.add(MetadataKotlinJsonAdapterFactory())
.build() .build()
private val okHttp = OkHttpClient.Builder() private val okHttp = OkHttpClient.Builder()
.build() .build()
@ -63,7 +61,7 @@ class LobstersApiTest {
fun `no moderator posts in test data`() = runBlocking { fun `no moderator posts in test data`() = runBlocking {
val posts = apiClient.getHottestPosts(1) val posts = apiClient.getHottestPosts(1)
val moderatorPosts = posts.asSequence() val moderatorPosts = posts.asSequence()
.filter { it.submitter_user.isModerator } .filter { it.submitterUser.isModerator }
.toSet() .toSet()
assertTrue(moderatorPosts.isEmpty()) assertTrue(moderatorPosts.isEmpty())
} }

View file

@ -54,7 +54,6 @@ dependencies {
implementation(Dependencies.Kotlin.Coroutines.android) implementation(Dependencies.Kotlin.Coroutines.android)
implementation(Dependencies.ThirdParty.accompanist) implementation(Dependencies.ThirdParty.accompanist)
implementation(Dependencies.ThirdParty.Moshi.lib) implementation(Dependencies.ThirdParty.Moshi.lib)
implementation(Dependencies.ThirdParty.Moshi.moshiMetadataReflect)
implementation(Dependencies.ThirdParty.Retrofit.moshi) implementation(Dependencies.ThirdParty.Retrofit.moshi)
implementation(Dependencies.ThirdParty.SQLDelight.androidDriver) implementation(Dependencies.ThirdParty.SQLDelight.androidDriver)
testImplementation(Dependencies.Testing.junit) testImplementation(Dependencies.Testing.junit)

View file

@ -2,8 +2,8 @@ package dev.msfjarvis.lobsters.data.remote
import androidx.paging.PagingSource import androidx.paging.PagingSource
import androidx.paging.PagingState import androidx.paging.PagingState
import dev.msfjarvis.lobsters.data.local.LobstersPost
import dev.msfjarvis.lobsters.data.repo.LobstersRepository import dev.msfjarvis.lobsters.data.repo.LobstersRepository
import dev.msfjarvis.lobsters.model.LobstersPost
import javax.inject.Inject import javax.inject.Inject
class LobstersPagingSource @Inject constructor( class LobstersPagingSource @Inject constructor(

View file

@ -1,7 +1,8 @@
package dev.msfjarvis.lobsters.data.repo package dev.msfjarvis.lobsters.data.repo
import dev.msfjarvis.lobsters.data.api.LobstersApi import dev.msfjarvis.lobsters.data.api.LobstersApi
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.model.LobstersPost
import dev.msfjarvis.lobsters.database.LobstersDatabase import dev.msfjarvis.lobsters.database.LobstersDatabase
import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.MutableStateFlow
@ -13,7 +14,7 @@ class LobstersRepository constructor(
private val lobstersDatabase: LobstersDatabase, private val lobstersDatabase: LobstersDatabase,
) { ) {
private val savedPostsCache: MutableMap<String, LobstersPost> = mutableMapOf() private val savedPostsCache: MutableMap<String, SavedPost> = mutableMapOf()
private val _isCacheReady = MutableStateFlow(false) private val _isCacheReady = MutableStateFlow(false)
val isCacheReady = _isCacheReady.asStateFlow() val isCacheReady = _isCacheReady.asStateFlow()
@ -21,7 +22,7 @@ class LobstersRepository constructor(
return savedPostsCache.containsKey(postId) return savedPostsCache.containsKey(postId)
} }
fun getAllPostsFromCache(): List<LobstersPost> { fun getAllPostsFromCache(): List<SavedPost> {
return savedPostsCache.values.toList() return savedPostsCache.values.toList()
} }
@ -31,29 +32,29 @@ class LobstersRepository constructor(
suspend fun updateCache() { suspend fun updateCache() {
if (_isCacheReady.value) return if (_isCacheReady.value) return
val posts = getAllPosts() val posts = getSavedPosts()
posts.forEach { posts.forEach {
savedPostsCache[it.short_id] = it savedPostsCache[it.shortId] = it
} }
_isCacheReady.value = true _isCacheReady.value = true
} }
private suspend fun getAllPosts(): List<LobstersPost> = withContext(Dispatchers.IO) { private suspend fun getSavedPosts(): List<SavedPost> = withContext(Dispatchers.IO) {
return@withContext lobstersDatabase.postQueries.selectAllPosts().executeAsList() return@withContext lobstersDatabase.savedPostQueries.selectAllPosts().executeAsList()
} }
suspend fun addPost(post: LobstersPost) = withContext(Dispatchers.IO) { suspend fun addPost(post: SavedPost) = withContext(Dispatchers.IO) {
if (!savedPostsCache.containsKey(post.short_id)) { if (!savedPostsCache.containsKey(post.shortId)) {
savedPostsCache.putIfAbsent(post.short_id, post) savedPostsCache.putIfAbsent(post.shortId, post)
lobstersDatabase.postQueries.insertOrReplacePost(post) lobstersDatabase.savedPostQueries.insertOrReplacePost(post)
} }
} }
suspend fun removePost(post: LobstersPost) = withContext(Dispatchers.IO) { suspend fun removePost(post: SavedPost) = withContext(Dispatchers.IO) {
if (savedPostsCache.containsKey(post.short_id)) { if (savedPostsCache.containsKey(post.shortId)) {
savedPostsCache.remove(post.short_id) savedPostsCache.remove(post.shortId)
lobstersDatabase.postQueries.deletePost(post.short_id) lobstersDatabase.savedPostQueries.deletePost(post.shortId)
} }
} }
} }

View file

@ -1,7 +1,6 @@
package dev.msfjarvis.lobsters.injection package dev.msfjarvis.lobsters.injection
import android.content.Context import android.content.Context
import com.squareup.moshi.JsonAdapter
import com.squareup.sqldelight.android.AndroidSqliteDriver import com.squareup.sqldelight.android.AndroidSqliteDriver
import com.squareup.sqldelight.db.SqlDriver import com.squareup.sqldelight.db.SqlDriver
import dagger.Module import dagger.Module
@ -10,10 +9,8 @@ import dagger.Reusable
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.android.qualifiers.ApplicationContext import dagger.hilt.android.qualifiers.ApplicationContext
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.database.LobstersDatabase import dev.msfjarvis.lobsters.database.LobstersDatabase
import dev.msfjarvis.lobsters.model.Submitter
import dev.msfjarvis.lobsters.model.SubmitterAdapter
import dev.msfjarvis.lobsters.model.TagsAdapter import dev.msfjarvis.lobsters.model.TagsAdapter
import javax.inject.Singleton import javax.inject.Singleton
@ -21,12 +18,6 @@ import javax.inject.Singleton
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
object DatabaseModule { object DatabaseModule {
@Provides
@Reusable
fun providesSubmitterAdapter(jsonAdapter: JsonAdapter<Submitter>): SubmitterAdapter {
return SubmitterAdapter(jsonAdapter)
}
@Provides @Provides
@Reusable @Reusable
fun providesTagsAdapter(): TagsAdapter { fun providesTagsAdapter(): TagsAdapter {
@ -43,12 +34,11 @@ object DatabaseModule {
@Singleton @Singleton
fun providesLobstersDatabase( fun providesLobstersDatabase(
sqlDriver: SqlDriver, sqlDriver: SqlDriver,
submitterAdapter: SubmitterAdapter,
tagsAdapter: TagsAdapter tagsAdapter: TagsAdapter
): LobstersDatabase { ): LobstersDatabase {
return LobstersDatabase( return LobstersDatabase(
sqlDriver, sqlDriver,
LobstersPost.Adapter(submitterAdapter, tagsAdapter) SavedPost.Adapter(tagsAdapter),
) )
} }
} }

View file

@ -1,15 +1,11 @@
package dev.msfjarvis.lobsters.injection package dev.msfjarvis.lobsters.injection
import com.squareup.moshi.JsonAdapter
import com.squareup.moshi.Moshi import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import dagger.Module import dagger.Module
import dagger.Provides import dagger.Provides
import dagger.Reusable import dagger.Reusable
import dagger.hilt.InstallIn import dagger.hilt.InstallIn
import dagger.hilt.components.SingletonComponent import dagger.hilt.components.SingletonComponent
import dev.msfjarvis.lobsters.model.Submitter
import dev.zacsweers.moshix.reflect.MetadataKotlinJsonAdapterFactory
@Module @Module
@InstallIn(SingletonComponent::class) @InstallIn(SingletonComponent::class)
@ -18,14 +14,6 @@ object MoshiModule {
@Reusable @Reusable
fun provideMoshi(): Moshi { fun provideMoshi(): Moshi {
return Moshi.Builder() return Moshi.Builder()
.add(MetadataKotlinJsonAdapterFactory())
.build() .build()
} }
@OptIn(ExperimentalStdlibApi::class)
@Provides
@Reusable
fun provideSubmitterJsonAdapter(moshi: Moshi): JsonAdapter<Submitter> {
return moshi.adapter()
}
} }

View file

@ -11,8 +11,10 @@ import androidx.compose.ui.Modifier
import androidx.paging.LoadState import androidx.paging.LoadState
import androidx.paging.compose.LazyPagingItems import androidx.paging.compose.LazyPagingItems
import androidx.paging.compose.items import androidx.paging.compose.items
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.model.LobstersPost
import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher
import dev.msfjarvis.lobsters.util.toDbModel
@Composable @Composable
fun HottestPosts( fun HottestPosts(
@ -20,7 +22,7 @@ fun HottestPosts(
listState: LazyListState, listState: LazyListState,
isPostSaved: (String) -> Boolean, isPostSaved: (String) -> Boolean,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
saveAction: (LobstersPost) -> Unit, saveAction: (SavedPost) -> Unit,
) { ) {
val urlLauncher = LocalUrlLauncher.current val urlLauncher = LocalUrlLauncher.current
@ -33,13 +35,15 @@ fun HottestPosts(
) { ) {
items(posts) { item -> items(posts) { item ->
if (item != null) { if (item != null) {
var isSaved by remember(item.short_id) { mutableStateOf(isPostSaved(item.short_id)) } @Suppress("NAME_SHADOWING")
val item = item.toDbModel()
var isSaved by remember(item.shortId) { mutableStateOf(isPostSaved(item.shortId)) }
LobstersItem( LobstersItem(
post = item, post = item,
isSaved = isSaved, isSaved = isSaved,
onClick = { urlLauncher.launch(item.url.ifEmpty { item.comments_url }) }, onClick = { urlLauncher.launch(item.url.ifEmpty { item.commentsUrl }) },
onLongClick = { urlLauncher.launch(item.comments_url) }, onLongClick = { urlLauncher.launch(item.commentsUrl) },
onSaveButtonClick = { onSaveButtonClick = {
isSaved = isSaved.not() isSaved = isSaved.not()
saveAction.invoke(item) saveAction.invoke(item)

View file

@ -29,43 +29,26 @@ import coil.transform.CircleCropTransformation
import dev.chrisbanes.accompanist.coil.CoilImage import dev.chrisbanes.accompanist.coil.CoilImage
import dev.msfjarvis.lobsters.R import dev.msfjarvis.lobsters.R
import dev.msfjarvis.lobsters.data.api.LobstersApi import dev.msfjarvis.lobsters.data.api.LobstersApi
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.model.Submitter
import dev.msfjarvis.lobsters.ui.theme.LobstersTheme import dev.msfjarvis.lobsters.ui.theme.LobstersTheme
import dev.msfjarvis.lobsters.ui.theme.titleColor import dev.msfjarvis.lobsters.ui.theme.titleColor
import dev.msfjarvis.lobsters.util.IconResource import dev.msfjarvis.lobsters.util.IconResource
val TEST_POST = LobstersPost( val TEST_POST = SavedPost(
"zqyydb", shortId = "zqyydb",
"https://lobste.rs/s/zqyydb", title = "k2k20 hackathon report: Bob Beck on LibreSSL progress",
"2020-09-21T07:11:14.000-05:00", url = "https://undeadly.org/cgi?action=article;sid=20200921105847",
"k2k20 hackathon report: Bob Beck on LibreSSL progress", createdAt = "2020-09-21T07:11:14.000-05:00",
"https://undeadly.org/cgi?action=article;sid=20200921105847", commentsUrl = "https://lobste.rs/s/zqyydb/k2k20_hackathon_report_bob_beck_on",
4, submitterName = "Vigdis",
0, submitterAvatarUrl = "/avatars/Vigdis-100.png",
0, tags = listOf("openbsd", "linux", "containers", "hack the planet", "no thanks"),
"",
"https://lobste.rs/s/zqyydb/k2k20_hackathon_report_bob_beck_on",
Submitter(
"Vigdis",
"2017-02-27T21:08:14.000-06:00",
false,
"Alleycat for the fun, sys/net admin for a living and OpenBSD contributions for the pleasure. (Not so) French dude in Montreal\r\n\r\nhttps://chown.me",
false,
76,
"/avatars/Vigdis-100.png",
"sevan",
null,
null,
emptyList(),
),
listOf("openbsd", "linux", "containers", "hack the planet", "no thanks"),
) )
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun LobstersItem( fun LobstersItem(
post: LobstersPost, post: SavedPost,
isSaved: Boolean, isSaved: Boolean,
onClick: () -> Unit, onClick: () -> Unit,
onLongClick: () -> Unit, onLongClick: () -> Unit,
@ -99,10 +82,10 @@ fun LobstersItem(
) )
Row { Row {
CoilImage( CoilImage(
data = "${LobstersApi.BASE_URL}/${post.submitter_user.avatarUrl}", data = "${LobstersApi.BASE_URL}/${post.submitterAvatarUrl}",
contentDescription = stringResource( contentDescription = stringResource(
R.string.avatar_content_description, R.string.avatar_content_description,
post.submitter_user.username post.submitterName
), ),
fadeIn = true, fadeIn = true,
requestBuilder = { requestBuilder = {
@ -113,7 +96,7 @@ fun LobstersItem(
.padding(4.dp), .padding(4.dp),
) )
Text( Text(
text = stringResource(id = R.string.submitted_by, post.submitter_user.username), text = stringResource(id = R.string.submitted_by, post.submitterName),
modifier = Modifier modifier = Modifier
.padding(4.dp), .padding(4.dp),
) )

View file

@ -6,16 +6,16 @@ import androidx.compose.foundation.lazy.items
import androidx.compose.foundation.lazy.rememberLazyListState import androidx.compose.foundation.lazy.rememberLazyListState
import androidx.compose.runtime.Composable import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier import androidx.compose.ui.Modifier
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher import dev.msfjarvis.lobsters.ui.urllauncher.LocalUrlLauncher
import dev.msfjarvis.lobsters.util.asZonedDateTime import dev.msfjarvis.lobsters.util.asZonedDateTime
@OptIn(ExperimentalFoundationApi::class) @OptIn(ExperimentalFoundationApi::class)
@Composable @Composable
fun SavedPosts( fun SavedPosts(
posts: List<LobstersPost>, posts: List<SavedPost>,
modifier: Modifier = Modifier, modifier: Modifier = Modifier,
saveAction: (LobstersPost) -> Unit, saveAction: (SavedPost) -> Unit,
) { ) {
val listState = rememberLazyListState() val listState = rememberLazyListState()
val urlLauncher = LocalUrlLauncher.current val urlLauncher = LocalUrlLauncher.current
@ -27,7 +27,7 @@ fun SavedPosts(
state = listState, state = listState,
modifier = Modifier.then(modifier), modifier = Modifier.then(modifier),
) { ) {
val grouped = posts.groupBy { it.created_at.asZonedDateTime().month } val grouped = posts.groupBy { it.createdAt.asZonedDateTime().month }
grouped.forEach { (month, posts) -> grouped.forEach { (month, posts) ->
stickyHeader { stickyHeader {
MonthHeader(month = month) MonthHeader(month = month)
@ -36,8 +36,8 @@ fun SavedPosts(
LobstersItem( LobstersItem(
post = item, post = item,
isSaved = true, isSaved = true,
onClick = { urlLauncher.launch(item.url.ifEmpty { item.comments_url }) }, onClick = { urlLauncher.launch(item.url.ifEmpty { item.commentsUrl }) },
onLongClick = { urlLauncher.launch(item.comments_url) }, onLongClick = { urlLauncher.launch(item.commentsUrl) },
onSaveButtonClick = { saveAction.invoke(item) }, onSaveButtonClick = { saveAction.invoke(item) },
) )
} }

View file

@ -6,7 +6,7 @@ import androidx.paging.Pager
import androidx.paging.PagingConfig import androidx.paging.PagingConfig
import androidx.paging.cachedIn import androidx.paging.cachedIn
import dagger.hilt.android.lifecycle.HiltViewModel import dagger.hilt.android.lifecycle.HiltViewModel
import dev.msfjarvis.lobsters.data.local.LobstersPost import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource import dev.msfjarvis.lobsters.data.remote.LobstersPagingSource
import dev.msfjarvis.lobsters.data.repo.LobstersRepository import dev.msfjarvis.lobsters.data.repo.LobstersRepository
import javax.inject.Inject import javax.inject.Inject
@ -21,7 +21,7 @@ class LobstersViewModel @Inject constructor(
private val lobstersRepository: LobstersRepository, private val lobstersRepository: LobstersRepository,
private val pagingSource: LobstersPagingSource, private val pagingSource: LobstersPagingSource,
) : ViewModel() { ) : ViewModel() {
private val _savedPosts = MutableStateFlow<List<LobstersPost>>(emptyList()) private val _savedPosts = MutableStateFlow<List<SavedPost>>(emptyList())
val savedPosts = _savedPosts.asStateFlow() val savedPosts = _savedPosts.asStateFlow()
val posts = Pager(PagingConfig(25)) { val posts = Pager(PagingConfig(25)) {
pagingSource pagingSource
@ -35,9 +35,9 @@ class LobstersViewModel @Inject constructor(
}.launchIn(viewModelScope) }.launchIn(viewModelScope)
} }
fun toggleSave(post: LobstersPost) { fun toggleSave(post: SavedPost) {
viewModelScope.launch { viewModelScope.launch {
val isSaved = lobstersRepository.isPostSaved(post.short_id) val isSaved = lobstersRepository.isPostSaved(post.shortId)
if (isSaved) removeSavedPost(post) else savePost(post) if (isSaved) removeSavedPost(post) else savePost(post)
} }
} }
@ -46,14 +46,14 @@ class LobstersViewModel @Inject constructor(
return lobstersRepository.isPostSaved(postId) return lobstersRepository.isPostSaved(postId)
} }
private fun savePost(post: LobstersPost) { private fun savePost(post: SavedPost) {
viewModelScope.launch { viewModelScope.launch {
lobstersRepository.addPost(post) lobstersRepository.addPost(post)
_savedPosts.value = lobstersRepository.getAllPostsFromCache() _savedPosts.value = lobstersRepository.getAllPostsFromCache()
} }
} }
private fun removeSavedPost(post: LobstersPost) { private fun removeSavedPost(post: SavedPost) {
viewModelScope.launch { viewModelScope.launch {
lobstersRepository.removePost(post) lobstersRepository.removePost(post)
_savedPosts.value = lobstersRepository.getAllPostsFromCache() _savedPosts.value = lobstersRepository.getAllPostsFromCache()

View file

@ -0,0 +1,20 @@
package dev.msfjarvis.lobsters.util
import dev.msfjarvis.lobsters.data.local.SavedPost
import dev.msfjarvis.lobsters.model.LobstersPost
/**
* Convert a [LobstersPost] object returned by the API into a [SavedPost] for persistence.
*/
fun LobstersPost.toDbModel(): SavedPost {
return SavedPost(
shortId = shortId,
title = title,
url = url,
createdAt = createdAt,
commentsUrl = commentsUrl,
submitterName = submitterUser.username,
submitterAvatarUrl = submitterUser.avatarUrl,
tags = tags,
)
}

View file

@ -1,3 +1,12 @@
plugins { plugins {
`lobsters-plugin` `lobsters-plugin`
} }
subprojects {
configurations.configureEach {
resolutionStrategy {
// Retrofit depends on a very old version of Moshi that causes moshi-ksp to fail
force(Dependencies.ThirdParty.Moshi.lib)
}
}
}

View file

@ -71,7 +71,7 @@ object Dependencies {
private const val version = "1.11.0" private const val version = "1.11.0"
const val lib = "com.squareup.moshi:moshi:$version" const val lib = "com.squareup.moshi:moshi:$version"
const val moshiMetadataReflect = "dev.zacsweers.moshix:moshi-metadata-reflect:0.9.1" const val ksp = "dev.zacsweers.moshix:moshi-ksp:0.9.1"
} }
object Retrofit { object Retrofit {

View file

@ -5,8 +5,6 @@ plugins {
} }
dependencies { dependencies {
implementation(Dependencies.ThirdParty.Moshi.lib)
implementation(Dependencies.ThirdParty.Moshi.moshiMetadataReflect)
testImplementation(Dependencies.Kotlin.Coroutines.core) testImplementation(Dependencies.Kotlin.Coroutines.core)
testImplementation(Dependencies.ThirdParty.SQLDelight.jvmDriver) testImplementation(Dependencies.ThirdParty.SQLDelight.jvmDriver)
testImplementation(Dependencies.Testing.junit) testImplementation(Dependencies.Testing.junit)

View file

@ -1,16 +0,0 @@
package dev.msfjarvis.lobsters.model
import com.squareup.moshi.JsonAdapter
import com.squareup.sqldelight.ColumnAdapter
class SubmitterAdapter(private val submitterJsonAdapter: JsonAdapter<Submitter>) :
ColumnAdapter<Submitter, String> {
override fun decode(databaseValue: String): Submitter {
return submitterJsonAdapter.fromJson(databaseValue)!!
}
override fun encode(value: Submitter): String {
return submitterJsonAdapter.toJson(value)
}
}

View file

@ -1,45 +0,0 @@
import dev.msfjarvis.lobsters.model.Submitter;
import java.lang.Boolean;
import kotlin.collections.List;
CREATE TABLE IF NOT EXISTS LobstersPost(
short_id TEXT NOT NULL PRIMARY KEY,
short_id_url TEXT NOT NULL,
created_at TEXT NOT NULL,
title TEXT NOT NULL,
url TEXT NOT NULL,
score INTEGER NOT NULL,
flags INTEGER NOT NULL,
comment_count INTEGER NOT NULL,
description TEXT NOT NULL,
comments_url TEXT NOT NULL,
submitter_user TEXT as Submitter NOT NULL,
tags TEXT as List<String> NOT NULL
);
selectPost:
SELECT *
FROM LobstersPost
WHERE short_id = ?;
selectAllPosts:
SELECT *
FROM LobstersPost;
insertOrReplacePost:
INSERT OR REPLACE
INTO LobstersPost
VALUES ?;
deletePost:
DELETE
FROM LobstersPost
WHERE short_id = ?;
deleteAllPosts:
DELETE
FROM LobstersPost;
selectCount:
SELECT COUNT(*)
FROM LobstersPost;

View file

@ -0,0 +1,39 @@
import kotlin.collections.List;
CREATE TABLE IF NOT EXISTS SavedPost(
shortId TEXT NOT NULL PRIMARY KEY,
title TEXT NOT NULL,
url TEXT NOT NULL,
createdAt TEXT NOT NULL,
commentsUrl TEXT NOT NULL,
submitterName TEXT NOT NULL,
submitterAvatarUrl TEXT NOT NULL,
tags TEXT as List<String> NOT NULL
);
insertOrReplacePost:
INSERT OR REPLACE
INTO SavedPost
VALUES ?;
selectAllPosts:
SELECT *
FROM SavedPost;
selectCount:
SELECT COUNT(*)
FROM SavedPost;
deleteAllPosts:
DELETE
FROM SavedPost;
deletePost:
DELETE
FROM SavedPost
WHERE shortId = ?;
selectPost:
SELECT *
FROM SavedPost
WHERE shortId = ?;

View file

@ -1,13 +1,8 @@
package dev.msfjarvis.lobsters.data.local package dev.msfjarvis.lobsters.data.local
import com.squareup.moshi.Moshi
import com.squareup.moshi.adapter
import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver import com.squareup.sqldelight.sqlite.driver.JdbcSqliteDriver
import dev.msfjarvis.lobsters.database.LobstersDatabase import dev.msfjarvis.lobsters.database.LobstersDatabase
import dev.msfjarvis.lobsters.model.Submitter
import dev.msfjarvis.lobsters.model.SubmitterAdapter
import dev.msfjarvis.lobsters.model.TagsAdapter import dev.msfjarvis.lobsters.model.TagsAdapter
import dev.zacsweers.moshix.reflect.MetadataKotlinJsonAdapterFactory
import kotlinx.coroutines.runBlocking import kotlinx.coroutines.runBlocking
import org.junit.Assert.assertEquals import org.junit.Assert.assertEquals
import org.junit.Before import org.junit.Before
@ -16,19 +11,17 @@ import org.junit.Test
@OptIn(ExperimentalStdlibApi::class) @OptIn(ExperimentalStdlibApi::class)
class SqlDelightQueriesTest { class SqlDelightQueriesTest {
private lateinit var postQueries: PostQueries private lateinit var postQueries: SavedPostQueries
@Before @Before
fun setUp() { fun setUp() {
val moshi = Moshi.Builder().add(MetadataKotlinJsonAdapterFactory()).build()
val submitterJsonAdapter = moshi.adapter<Submitter>()
val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY) val driver = JdbcSqliteDriver(JdbcSqliteDriver.IN_MEMORY)
LobstersDatabase.Schema.create(driver) LobstersDatabase.Schema.create(driver)
val database = LobstersDatabase( val database = LobstersDatabase(
driver, driver,
LobstersPost.Adapter(SubmitterAdapter(submitterJsonAdapter), TagsAdapter()) SavedPost.Adapter(TagsAdapter()),
) )
postQueries = database.postQueries postQueries = database.savedPostQueries
} }
@Test @Test
@ -63,7 +56,7 @@ class SqlDelightQueriesTest {
postQueries.insertOrReplacePost(post) postQueries.insertOrReplacePost(post)
// Create a new post and try replacing it // Create a new post and try replacing it
val newPost = post.copy(comment_count = 100) val newPost = post.copy(submitterName = "Fake name")
postQueries.insertOrReplacePost(newPost) postQueries.insertOrReplacePost(newPost)
// Check post count // Check post count
@ -71,8 +64,8 @@ class SqlDelightQueriesTest {
assertEquals(1, postsCount) assertEquals(1, postsCount)
// Check if post is updated // Check if post is updated
val postFromDb = postQueries.selectPost(post.short_id).executeAsOne() val postFromDb = postQueries.selectPost(post.shortId).executeAsOne()
assertEquals(100, postFromDb.comment_count) assertEquals("Fake name", postFromDb.submitterName)
} }
@Test @Test
@ -84,7 +77,7 @@ class SqlDelightQueriesTest {
postQueries.insertOrReplacePost(post) postQueries.insertOrReplacePost(post)
val postFromDb = postQueries.selectAllPosts().executeAsOne() val postFromDb = postQueries.selectAllPosts().executeAsOne()
assertEquals("test_id_1", postFromDb.short_id) assertEquals("test_id_1", postFromDb.shortId)
} }
@Test @Test
@ -97,9 +90,9 @@ class SqlDelightQueriesTest {
val postsFromDb = postQueries.selectAllPosts().executeAsList() val postsFromDb = postQueries.selectAllPosts().executeAsList()
// Check if all posts have correct short_id // Check if all posts have correct shortId
for (i in 1..5) { for (i in 1..5) {
assertEquals("test_id_$i", postsFromDb[i - 1].short_id) assertEquals("test_id_$i", postsFromDb[i - 1].shortId)
} }
} }
@ -116,8 +109,8 @@ class SqlDelightQueriesTest {
// Check if size is 2, and only the correct post is deleted // Check if size is 2, and only the correct post is deleted
assertEquals(2, postsFromDB.size) assertEquals(2, postsFromDB.size)
assertEquals("test_id_1", postsFromDB[0].short_id) assertEquals("test_id_1", postsFromDB[0].shortId)
assertEquals("test_id_3", postsFromDB[1].short_id) assertEquals("test_id_3", postsFromDB[1].shortId)
} }
@Test @Test
@ -136,32 +129,18 @@ class SqlDelightQueriesTest {
} }
private fun createTestData(count: Int): ArrayList<LobstersPost> { private fun createTestData(count: Int): ArrayList<SavedPost> {
val posts = arrayListOf<LobstersPost>() val posts = arrayListOf<SavedPost>()
for (i in 1..count) { for (i in 1..count) {
val submitter = Submitter( val post = SavedPost(
username = "test_user_$i", shortId = "test_id_$i",
createdAt = "0", createdAt = "0",
about = "test",
avatarUrl = "test_avatar_url",
invitedByUser = "test_user",
isAdmin = false,
isModerator = false
)
val post = LobstersPost(
short_id = "test_id_$i",
short_id_url = "test_id_url",
created_at = "0",
title = "test", title = "test",
url = "test_url", url = "test_url",
score = 0, commentsUrl = "test_comments_url",
flags = 0, submitterName = "test_user_$i",
comment_count = 0, submitterAvatarUrl = "test_avatar_url",
description = "test",
comments_url = "test_comments_url",
submitter_user = submitter,
tags = listOf(), tags = listOf(),
) )

View file

@ -1,3 +1,9 @@
rootProject.name = "Claw" rootProject.name = "Claw"
include(":app", ":api", ":database") include(":app", ":api", ":database")
enableFeaturePreview("GRADLE_METADATA") enableFeaturePreview("GRADLE_METADATA")
pluginManagement {
repositories {
google()
gradlePluginPortal()
}
}