move network calls in data layer

This commit is contained in:
Stefano Sansone
2024-04-09 00:20:12 +02:00
parent c76c6e07dd
commit 135295c4cf
18 changed files with 894 additions and 638 deletions

View File

@ -1,160 +1,151 @@
package app.omnivore.omnivore.core.data
import android.util.Log
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.savedItemUpdates
import app.omnivore.omnivore.core.network.search
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
suspend fun DataService.librarySearch(cursor: String?, query: String): SearchResult {
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
val savedItems = searchResult.items.map {
SavedItemWithLabelsAndHighlights(
savedItem = it.item,
labels = it.labels,
highlights = it.highlights,
val savedItems = searchResult.items.map {
SavedItemWithLabelsAndHighlights(
savedItem = it.item,
labels = it.labels,
highlights = it.highlights,
)
}
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d(
"sync",
"found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor"
)
}
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d("sync", "found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor")
return SearchResult(
hasError = false,
hasMoreItems = false,
cursor = searchResult.cursor,
count = searchResult.items.size,
savedItems = savedItems
)
return SearchResult(
hasError = false,
hasMoreItems = false,
cursor = searchResult.cursor,
count = searchResult.items.size,
savedItems = savedItems
)
}
suspend fun DataService.sync(since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult {
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since)
?: return SavedItemSyncResult.errorResult
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since)
?: return SavedItemSyncResult.errorResult
if (syncResult.deletedItemIDs.isNotEmpty()) {
db.savedItemDao().deleteByIds(syncResult.deletedItemIDs)
}
if (syncResult.deletedItemIDs.isNotEmpty()) {
db.savedItemDao().deleteByIds(syncResult.deletedItemIDs)
}
val savedItems = syncResult.items.map {
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
createdAt = it.createdAt as String,
savedAt = it.savedAt as String,
readAt = it.readAt as String?,
updatedAt = it.updatedAt as String?,
readingProgress = it.readingProgressPercent,
readingProgressAnchor = it.readingProgressAnchorIndex,
imageURLString = it.image,
pageURLString = it.url,
descriptionText = it.description,
publisherURLString = it.originalArticleUrl,
siteName = it.siteName,
author = it.author,
publishDate = it.publishedAt as String?,
slug = it.slug,
isArchived = it.isArchived,
contentReader = it.contentReader.rawValue,
content = null,
wordsCount = it.wordsCount
)
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
} ?: listOf()
val highlights = it.highlights?.map { highlight ->
Highlight(
type = highlight.highlightFields.type.toString(),
highlightId = highlight.highlightFields.id,
annotation = highlight.highlightFields.annotation,
createdByMe = highlight.highlightFields.createdByMe,
markedForDeletion = false,
patch = highlight.highlightFields.patch,
prefix = highlight.highlightFields.prefix,
quote = highlight.highlightFields.quote,
serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue,
shortId = highlight.highlightFields.shortId,
suffix = highlight.highlightFields.suffix,
createdAt = null,
updatedAt = highlight.highlightFields.updatedAt as String?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem,
labels = labels,
highlights = highlights
)
}
val savedItems = syncResult.items.map {
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
createdAt = it.createdAt as String,
savedAt = it.savedAt as String,
readAt = it.readAt as String?,
updatedAt = it.updatedAt as String?,
readingProgress = it.readingProgressPercent,
readingProgressAnchor = it.readingProgressAnchorIndex,
imageURLString = it.image,
pageURLString = it.url,
descriptionText = it.description,
publisherURLString = it.originalArticleUrl,
siteName = it.siteName,
author = it.author,
publishDate = it.publishedAt as String?,
slug = it.slug,
isArchived = it.isArchived,
contentReader = it.contentReader.rawValue,
content = null,
wordsCount = it.wordsCount
)
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
} ?: listOf()
val highlights = it.highlights?.map { highlight ->
Highlight(
type = highlight.highlightFields.type.toString(),
highlightId = highlight.highlightFields.id,
annotation = highlight.highlightFields.annotation,
createdByMe = highlight.highlightFields.createdByMe,
markedForDeletion = false,
patch = highlight.highlightFields.patch,
prefix = highlight.highlightFields.prefix,
quote = highlight.highlightFields.quote,
serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue,
shortId = highlight.highlightFields.shortId,
suffix = highlight.highlightFields.suffix,
createdAt = null,
updatedAt = highlight.highlightFields.updatedAt as String?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = labels, highlights = highlights
)
}
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
db.savedItemWithLabelsAndHighlightsDao().insertAll(savedItems)
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
return SavedItemSyncResult(
hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug }
)
return SavedItemSyncResult(hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug })
}
fun DataService.isSavedItemContentStoredInDB(slug: String): Boolean {
val existingItem = db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
val content = existingItem?.savedItem?.content ?: ""
return content.length > 10
suspend fun DataService.isSavedItemContentStoredInDB(slug: String): Boolean {
val existingItem = db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
val content = existingItem?.savedItem?.content ?: ""
return content.length > 10
}
suspend fun DataService.fetchSavedItemContent(slug: String) {
val syncResult = networker.savedItem(slug)
val savedItem = syncResult.item
savedItem?.let {
val item = SavedItemWithLabelsAndHighlights(
savedItem = savedItem,
labels = syncResult.labels,
highlights = syncResult.highlights
)
db.savedItemWithLabelsAndHighlightsDao().insertAll(listOf(item))
}
}
data class SavedItemSyncResult(
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItemSlugs: List<String>,
val cursor: String?
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItemSlugs: List<String>,
val cursor: String?
) {
companion object {
val errorResult = SavedItemSyncResult(hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItemSlugs = listOf())
}
companion object {
val errorResult = SavedItemSyncResult(
hasError = true,
hasMoreItems = true,
cursor = null,
count = 0,
savedItemSlugs = listOf()
)
}
}
data class SearchResult(
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItems: List<SavedItemWithLabelsAndHighlights>,
val cursor: String?
val hasError: Boolean,
val hasMoreItems: Boolean,
val count: Int,
val savedItems: List<SavedItemWithLabelsAndHighlights>,
val cursor: String?
) {
companion object {
val errorResult = SearchResult(hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItems = listOf())
}
companion object {
val errorResult = SearchResult(
hasError = true, hasMoreItems = true, cursor = null, count = 0, savedItems = listOf()
)
}
}

View File

@ -21,198 +21,204 @@ import com.apollographql.apollo3.api.Optional
import kotlinx.coroutines.delay
suspend fun DataService.startSyncChannels() {
Log.d("sync", "Starting sync channels")
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
Log.d("sync", "Starting sync channels")
for (savedItem in savedItemSyncChannel) {
syncSavedItem(savedItem)
}
}
suspend fun DataService.performHighlightChange(highlightChange: HighlightChange) {
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
db.highlightChangesDao().deleteById(highlight.highlightId)
}
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
db.highlightChangesDao().deleteById(highlight.highlightId)
}
}
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightChangesDao().getUnSynced()
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightChangesDao().getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (savedItem in unSyncedSavedItems) {
delay(250)
savedItemSyncChannel.send(savedItem)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
}
private suspend fun DataService.syncSavedItem(item: SavedItem) {
suspend fun updateSyncStatus(status: ServerSyncStatus) {
item.serverSyncStatus = status.rawValue
db.savedItemDao().update(item)
}
when (item.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteSavedItem(item.savedItemId)
if (isDeletedOnServer) {
db.savedItemDao().deleteById(item.savedItemId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
suspend fun updateSyncStatus(status: ServerSyncStatus) {
item.serverSyncStatus = status.rawValue
db.savedItemDao().update(item)
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isArchiveServerSynced = networker.updateArchiveStatusSavedItem(itemID = item.savedItemId, setAsArchived = item.isArchived)
when (item.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isReadingProgressSynced = networker.updateReadingProgress(
ReadingProgressParams(
id = item.savedItemId,
force = item.contentReader == "PDF",
readingProgressPercent = item.readingProgress,
readingProgressAnchorIndex = item.readingProgressAnchor
)
)
val isDeletedOnServer = networker.deleteSavedItem(item.savedItemId)
if (isArchiveServerSynced && isReadingProgressSynced) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
if (isDeletedOnServer) {
db.savedItemDao().deleteById(item.savedItemId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isArchiveServerSynced = networker.updateArchiveStatusSavedItem(
itemID = item.savedItemId, setAsArchived = item.isArchived
)
val isReadingProgressSynced = networker.updateReadingProgress(
ReadingProgressParams(
id = item.savedItemId,
force = item.contentReader == "PDF",
readingProgressPercent = item.readingProgress,
readingProgressAnchorIndex = item.readingProgressAnchor
)
)
if (isArchiveServerSynced && isReadingProgressSynced) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
// TODO: implement when we are able to create content on device
// updateSyncStatus(ServerSyncStatus.IS_SYNCING)
// send update to server
// update db
}
else -> return
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
// TODO: implement when we are able to create content on device
// updateSyncStatus(ServerSyncStatus.IS_SYNCING)
// send update to server
// update db
}
else -> return
}
}
private suspend fun DataService.syncHighlightChange(highlightChange: HighlightChange): Boolean {
val highlight = highlightChangeToHighlight(highlightChange)
val highlight = highlightChangeToHighlight(highlightChange)
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
}
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
db.highlightDao().deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
db.highlightDao().update(highlight)
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isDeletedOnServer) {
db.highlightDao().deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
}
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
return if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
return true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
return false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
}

View File

@ -1,6 +1,10 @@
package app.omnivore.omnivore.core.data.repository
import app.omnivore.omnivore.core.data.SavedItemSyncResult
import app.omnivore.omnivore.core.data.SearchResult
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import kotlinx.coroutines.flow.Flow
@ -8,9 +12,37 @@ interface LibraryRepository {
fun getSavedItems(query: LibraryQuery): Flow<List<SavedItemWithLabelsAndHighlights>>
fun getSavedItemsLabels(): Flow<List<SavedItemLabel>>
suspend fun getLabels(): List<SavedItemLabel>
suspend fun fetchSavedItemContent(slug: String)
suspend fun insertAllLabels(labels: List<SavedItemLabel>)
suspend fun setSavedItemLabels(itemId: String, labels: List<SavedItemLabel>): Boolean
suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
readingProgressAnchorIndex: Int
)
suspend fun createNewSavedItemLabel(labelName: String, hexColorValue: String)
suspend fun librarySearch(cursor: String?, query: String): SearchResult
suspend fun isSavedItemContentStoredInDB(slug: String): Boolean
suspend fun deleteSavedItem(itemID: String)
suspend fun archiveSavedItem(itemID: String)
suspend fun unarchiveSavedItem(itemID: String)
suspend fun syncOfflineItemsWithServerIfNeeded()
suspend fun syncHighlightChange(highlightChange: HighlightChange): Boolean
suspend fun sync(since: String, cursor: String?, limit: Int = 20): SavedItemSyncResult
}

View File

@ -1,20 +1,62 @@
package app.omnivore.omnivore.core.data.repository.impl
import android.util.Log
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.SavedItemSyncResult
import app.omnivore.omnivore.core.data.SearchResult
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import app.omnivore.omnivore.core.database.dao.HighlightDao
import app.omnivore.omnivore.core.database.dao.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.dao.SavedItemLabelDao
import app.omnivore.omnivore.core.database.dao.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.database.entities.highlightChangeToHighlight
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.ReadingProgressParams
import app.omnivore.omnivore.core.network.archiveSavedItem
import app.omnivore.omnivore.core.network.createHighlight
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.core.network.deleteHighlights
import app.omnivore.omnivore.core.network.deleteSavedItem
import app.omnivore.omnivore.core.network.mergeHighlights
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.core.network.savedItemLabels
import app.omnivore.omnivore.core.network.savedItemUpdates
import app.omnivore.omnivore.core.network.search
import app.omnivore.omnivore.core.network.unarchiveSavedItem
import app.omnivore.omnivore.core.network.updateHighlight
import app.omnivore.omnivore.core.network.updateLabelsForSavedItem
import app.omnivore.omnivore.core.network.updateReadingProgress
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.graphql.generated.type.HighlightType
import app.omnivore.omnivore.graphql.generated.type.MergeHighlightInput
import app.omnivore.omnivore.graphql.generated.type.SetLabelsInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import kotlinx.coroutines.delay
import kotlinx.coroutines.flow.Flow
import javax.inject.Inject
class LibraryRepositoryImpl @Inject constructor(
private val savedItemDao: SavedItemDao,
private val networker: Networker
private val savedItemLabelDao: SavedItemLabelDao,
private val savedItemWithLabelsAndHighlightsDao: SavedItemWithLabelsAndHighlightsDao,
private val savedItemAndSavedItemLabelCrossRefDao: SavedItemAndSavedItemLabelCrossRefDao,
private val highlightDao: HighlightDao,
private val highlightChangesDao: HighlightChangesDao,
private val networker: Networker,
private val dataService: DataService
): LibraryRepository {
override fun getSavedItems(query: LibraryQuery): Flow<List<SavedItemWithLabelsAndHighlights>> =
@ -28,6 +70,26 @@ class LibraryRepositoryImpl @Inject constructor(
query.allowedContentReaders
)
override fun getSavedItemsLabels(): Flow<List<SavedItemLabel>> = savedItemLabelDao.getSavedItemLabels()
override suspend fun getLabels(): List<SavedItemLabel> = networker.savedItemLabels()
override suspend fun insertAllLabels(labels: List<SavedItemLabel>) {
savedItemLabelDao.insertAll(labels)
}
override suspend fun fetchSavedItemContent(slug: String) {
val syncResult = networker.savedItem(slug)
val savedItem = syncResult.item
savedItem?.let {
val item = SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = syncResult.labels, highlights = syncResult.highlights
)
savedItemWithLabelsAndHighlightsDao.insertAll(listOf(item))
}
}
override suspend fun updateReadingProgress(
itemId: String,
readingProgressPercentage: Double,
@ -63,4 +125,360 @@ class LibraryRepositoryImpl @Inject constructor(
updatedItem?.let { savedItemDao.update(updatedItem) }
}
}
override suspend fun setSavedItemLabels(
itemId: String,
labels: List<SavedItemLabel>
): Boolean {
val input = SetLabelsInput(
pageId = itemId,
labels = Optional.presentIfNotNull(labels.map { CreateLabelInput(color = Optional.presentIfNotNull(it.color), name = it.name) }),
)
val updatedLabels = networker.updateLabelsForSavedItem(input)
// Figure out which of the labels are new
updatedLabels?.let { updatedLabels ->
val existingNamedLabels = savedItemLabelDao.namedLabels(updatedLabels.map { it.labelFields.name })
val existingNames = existingNamedLabels.map { it.name }
val newNamedLabels = updatedLabels.filter { !existingNames.contains(it.labelFields.name) }
savedItemLabelDao.insertAll(newNamedLabels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = null,
labelDescription = null
)
})
val allNamedLabels = savedItemLabelDao.namedLabels(updatedLabels.map { it.labelFields.name })
val crossRefs = allNamedLabels.map {
SavedItemAndSavedItemLabelCrossRef(
savedItemLabelId = it.savedItemLabelId,
savedItemId = itemId
)
}
savedItemAndSavedItemLabelCrossRefDao.deleteRefsBySavedItemId(itemId)
savedItemAndSavedItemLabelCrossRefDao.insertAll(crossRefs)
return true
} ?: run {
return false
}
}
override suspend fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
val newLabel = networker.createNewLabel(
CreateLabelInput(
color = Optional.presentIfNotNull(hexColorValue), name = labelName
)
)
newLabel?.let {
val savedItemLabel = SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = it.createdAt as String?,
labelDescription = it.description
)
savedItemLabelDao.insertAll(listOf(savedItemLabel))
}
}
override suspend fun librarySearch(cursor: String?, query: String): SearchResult {
val searchResult = networker.search(cursor = cursor, limit = 10, query = query)
val savedItems = searchResult.items.map {
SavedItemWithLabelsAndHighlights(
savedItem = it.item,
labels = it.labels,
highlights = it.highlights,
)
}
savedItemWithLabelsAndHighlightsDao.insertAll(savedItems)
Log.d(
"sync",
"found ${searchResult.items.size} items with search api. Query: $query cursor: $cursor"
)
return SearchResult(
hasError = false,
hasMoreItems = false,
cursor = searchResult.cursor,
count = searchResult.items.size,
savedItems = savedItems
)
}
override suspend fun isSavedItemContentStoredInDB(slug: String): Boolean {
val existingItem = savedItemDao.getSavedItemWithLabelsAndHighlights(slug)
val content = existingItem?.savedItem?.content ?: ""
return content.length > 10
}
override suspend fun deleteSavedItem(itemID: String) {
val savedItem = savedItemDao.findById(itemID = itemID) ?: return
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
savedItemDao.update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
savedItemDao.deleteById(itemID)
}
}
override suspend fun archiveSavedItem(itemID: String) {
val savedItem = savedItemDao.findById(itemID = itemID) ?: return
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
savedItemDao.update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
savedItemDao.update(savedItem)
}
}
override suspend fun unarchiveSavedItem(itemID: String) {
val savedItem = savedItemDao.findById(itemID = itemID) ?: return
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
savedItemDao.update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
savedItemDao.update(savedItem)
}
}
override suspend fun syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = savedItemDao.getUnSynced()
val unSyncedHighlights = highlightChangesDao.getUnSynced()
for (savedItem in unSyncedSavedItems) {
delay(250)
dataService.savedItemSyncChannel.send(savedItem)
}
for (change in unSyncedHighlights) {
performHighlightChange(change)
}
}
override suspend fun syncHighlightChange(highlightChange: HighlightChange): Boolean {
val highlight = highlightChangeToHighlight(highlightChange)
fun updateSyncStatus(status: ServerSyncStatus) {
highlight.serverSyncStatus = status.rawValue
highlightDao.update(highlight)
}
when (highlight.serverSyncStatus) {
ServerSyncStatus.NEEDS_DELETION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isDeletedOnServer = networker.deleteHighlights(listOf(highlight.highlightId))
if (isDeletedOnServer) {
highlightDao.deleteById(highlight.highlightId)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_DELETION)
}
return isDeletedOnServer != null
}
ServerSyncStatus.NEEDS_UPDATE.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val isUpdatedOnServer = networker.updateHighlight(
UpdateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
highlightId = highlight.highlightId,
sharedAt = Optional.absent()
)
)
if (isUpdatedOnServer) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
return isUpdatedOnServer != null
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val input = CreateHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
type = Optional.presentIfNotNull(HighlightType.safeValueOf(highlight.type)),
annotation = Optional.presentIfNotNull(highlight.annotation),
patch = Optional.presentIfNotNull(highlight.patch),
quote = Optional.presentIfNotNull(highlight.quote),
)
Log.d("sync", "Creating highlight from input: ${input}")
val createResult = networker.createHighlight(
input
)
if (createResult.newHighlight != null || createResult.alreadyExists) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
return true
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
return false
}
}
ServerSyncStatus.NEEDS_MERGE.rawValue -> {
Log.d("sync", "NEEDS MERGE: ${highlightChange}")
val mergeHighlightInput = MergeHighlightInput(
id = highlight.highlightId,
shortId = highlight.shortId,
articleId = highlightChange.savedItemId,
annotation = Optional.presentIfNotNull(highlight.annotation),
color = Optional.presentIfNotNull(highlight.color),
highlightPositionAnchorIndex = Optional.presentIfNotNull(highlight.highlightPositionAnchorIndex),
highlightPositionPercent = Optional.presentIfNotNull(highlight.highlightPositionPercent),
html = Optional.presentIfNotNull(highlightChange.html),
overlapHighlightIdList = highlightChange.overlappingIDs ?: emptyList(),
patch = highlight.patch ?: "",
prefix = Optional.presentIfNotNull(highlight.prefix),
quote = highlight.quote ?: "",
suffix = Optional.presentIfNotNull(highlight.suffix)
)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (!isUpdatedOnServer) {
Log.d("sync", "FAILED TO MERGE HIGHLIGHT")
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_MERGE.rawValue
return false
}
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
Log.d("sync", "DELETING MERGED HIGHLIGHT: ${highlightID}")
val deleteChange = HighlightChange(
highlightId = highlightID,
savedItemId = highlightChange.savedItemId,
type = "",
shortId = "",
annotation = null,
createdAt = null,
patch = null,
prefix = null,
quote = null,
serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue,
html = null,
suffix = null,
updatedAt = null,
color = null,
highlightPositionPercent = null,
highlightPositionAnchorIndex = null,
overlappingIDs = null
)
performHighlightChange(deleteChange)
}
return true
}
else -> return false
}
}
private suspend fun performHighlightChange(highlightChange: HighlightChange) {
val highlight = highlightChangeToHighlight(highlightChange)
if (syncHighlightChange(highlightChange)) {
highlightChangesDao.deleteById(highlight.highlightId)
}
}
override suspend fun sync(since: String, cursor: String?, limit: Int): SavedItemSyncResult {
val syncResult = networker.savedItemUpdates(cursor = cursor, limit = limit, since = since)
?: return SavedItemSyncResult.errorResult
if (syncResult.deletedItemIDs.isNotEmpty()) {
savedItemDao.deleteByIds(syncResult.deletedItemIDs)
}
val savedItems = syncResult.items.map {
val savedItem = SavedItem(
savedItemId = it.id,
title = it.title,
createdAt = it.createdAt as String,
savedAt = it.savedAt as String,
readAt = it.readAt as String?,
updatedAt = it.updatedAt as String?,
readingProgress = it.readingProgressPercent,
readingProgressAnchor = it.readingProgressAnchorIndex,
imageURLString = it.image,
pageURLString = it.url,
descriptionText = it.description,
publisherURLString = it.originalArticleUrl,
siteName = it.siteName,
author = it.author,
publishDate = it.publishedAt as String?,
slug = it.slug,
isArchived = it.isArchived,
contentReader = it.contentReader.rawValue,
content = null,
wordsCount = it.wordsCount
)
val labels = it.labels?.map { label ->
SavedItemLabel(
savedItemLabelId = label.labelFields.id,
name = label.labelFields.name,
color = label.labelFields.color,
createdAt = null,
labelDescription = null
)
} ?: listOf()
val highlights = it.highlights?.map { highlight ->
Highlight(
type = highlight.highlightFields.type.toString(),
highlightId = highlight.highlightFields.id,
annotation = highlight.highlightFields.annotation,
createdByMe = highlight.highlightFields.createdByMe,
markedForDeletion = false,
patch = highlight.highlightFields.patch,
prefix = highlight.highlightFields.prefix,
quote = highlight.highlightFields.quote,
serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue,
shortId = highlight.highlightFields.shortId,
suffix = highlight.highlightFields.suffix,
createdAt = null,
updatedAt = highlight.highlightFields.updatedAt as String?,
color = highlight.highlightFields.color,
highlightPositionPercent = highlight.highlightFields.highlightPositionPercent,
highlightPositionAnchorIndex = highlight.highlightFields.highlightPositionAnchorIndex,
)
} ?: listOf()
SavedItemWithLabelsAndHighlights(
savedItem = savedItem, labels = labels, highlights = highlights
)
}
savedItemWithLabelsAndHighlightsDao.insertAll(savedItems)
Log.d("sync", "found ${syncResult.items.size} items with sync api. Since: $since")
return SavedItemSyncResult(hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug })
}
}

View File

@ -2,19 +2,19 @@ package app.omnivore.omnivore.core.database
import androidx.room.Database
import androidx.room.RoomDatabase
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import app.omnivore.omnivore.core.database.dao.HighlightDao
import app.omnivore.omnivore.core.database.dao.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.dao.SavedItemDao
import app.omnivore.omnivore.core.database.dao.SavedItemLabelDao
import app.omnivore.omnivore.core.database.dao.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Highlight
import app.omnivore.omnivore.core.database.entities.HighlightChange
import app.omnivore.omnivore.core.database.entities.HighlightChangesDao
import app.omnivore.omnivore.core.database.entities.HighlightDao
import app.omnivore.omnivore.core.database.entities.SavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndHighlightCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.core.database.entities.SavedItemAndSavedItemLabelCrossRefDao
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemLabelDao
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlightsDao
import app.omnivore.omnivore.core.database.entities.Viewer
import app.omnivore.omnivore.core.database.entities.ViewerDao

View File

@ -23,7 +23,7 @@ interface SavedItemDao {
fun getUnSynced(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE slug = :slug")
fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
suspend fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
@Query("DELETE FROM savedItem WHERE savedItemId = :itemID")
fun deleteById(itemID: String)

View File

@ -1,6 +1,15 @@
package app.omnivore.omnivore.core.database.entities
import androidx.room.*
import androidx.room.Dao
import androidx.room.Embedded
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.Junction
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Relation
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import com.google.gson.annotations.SerializedName
@ -87,25 +96,3 @@ data class SavedItemWithLabelsAndHighlights(
return savedItem.savedItemId.hashCode()
}
}
@Dao
interface HighlightDao {
@Query("SELECT * FROM highlight WHERE serverSyncStatus != 0")
fun getUnSynced(): List<Highlight>
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<Highlight>)
@Query("DELETE FROM highlight WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Query("SELECT * FROM highlight WHERE highlightId = :highlightId")
fun findById(highlightId: String): Highlight?
// Server sync status is passed in here to work around Room compile-time query rules, but should always be NEEDS_UPDATE
@Query("UPDATE highlight SET annotation = :note, serverSyncStatus = :serverSyncStatus WHERE highlightId = :highlightId")
fun updateNote(highlightId: String, note: String, serverSyncStatus: Int = ServerSyncStatus.NEEDS_UPDATE.rawValue)
@Update
fun update(highlight: Highlight)
}

View File

@ -1,15 +1,12 @@
package app.omnivore.omnivore.core.database.entities
import android.util.Log
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.TypeConverter
import androidx.room.TypeConverters
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
import app.omnivore.omnivore.core.database.dao.HighlightChangesDao
import com.google.gson.Gson
import com.google.gson.reflect.TypeToken
@ -110,16 +107,3 @@ fun highlightChangeToHighlight(change: HighlightChange): Highlight {
serverSyncStatus = change.serverSyncStatus
)
}
@Dao
interface HighlightChangesDao {
@Query("SELECT * FROM highlightChange WHERE serverSyncStatus != 0 ORDER BY updatedAt ASC")
fun getUnSynced(): List<HighlightChange>
@Query("DELETE FROM highlightChange WHERE highlightId = :highlightId")
fun deleteById(highlightId: String)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<HighlightChange>)
}

View File

@ -2,12 +2,8 @@ package app.omnivore.omnivore.core.database.entities
import androidx.core.net.toUri
import androidx.room.ColumnInfo
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Transaction
@Entity
data class SavedItem(
@ -74,67 +70,6 @@ data class TypeaheadCardData(
val isArchived: Boolean,
)
@Dao
abstract class SavedItemWithLabelsAndHighlightsDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertSavedItems(items: List<SavedItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabelCrossRefs(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertLabels(items: List<SavedItemLabel>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlights(items: List<Highlight>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
abstract fun insertHighlightCrossRefs(items: List<SavedItemAndHighlightCrossRef>)
@Transaction
open fun insertAll(savedItems: List<SavedItemWithLabelsAndHighlights>) {
insertSavedItems(savedItems.map { it.savedItem })
val labels: MutableList<SavedItemLabel> = mutableListOf()
val highlights: MutableList<Highlight> = mutableListOf()
val labelCrossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
val highlightCrossRefs: MutableList<SavedItemAndHighlightCrossRef> = mutableListOf()
for (searchItem in savedItems) {
labels.addAll(searchItem.labels)
highlights.addAll(searchItem.highlights)
val newLabelCrossRefs = searchItem.labels.map {
SavedItemAndSavedItemLabelCrossRef(
savedItemLabelId = it.savedItemLabelId,
savedItemId = searchItem.savedItem.savedItemId
)
}
val newHighlightCrossRefs = searchItem.highlights.map {
SavedItemAndHighlightCrossRef(
highlightId = it.highlightId,
savedItemId = searchItem.savedItem.savedItemId
)
}
labelCrossRefs.addAll(newLabelCrossRefs)
highlightCrossRefs.addAll(newHighlightCrossRefs)
}
insertLabels(labels)
insertLabelCrossRefs(labelCrossRefs)
insertHighlights(highlights)
insertHighlightCrossRefs(highlightCrossRefs)
}
}
object SavedItemQueryConstants {
const val columns =
"savedItemId, slug, publisherURLString, title, author, descriptionText, imageURLString, isArchived, pageURLString, contentReader, savedAt, readingProgress, wordsCount"

View File

@ -1,15 +1,8 @@
package app.omnivore.omnivore.core.database.entities
import androidx.lifecycle.LiveData
import androidx.room.Dao
import androidx.room.Entity
import androidx.room.ForeignKey
import androidx.room.Insert
import androidx.room.OnConflictStrategy
import androidx.room.PrimaryKey
import androidx.room.Query
import androidx.room.Transaction
import app.omnivore.omnivore.core.data.model.ServerSyncStatus
@Entity
data class SavedItemLabel(
@ -21,26 +14,6 @@ data class SavedItemLabel(
val serverSyncStatus: Int = 0
)
@Dao
interface SavedItemLabelDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemLabel>)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE serverSyncStatus != 2 ORDER BY name ASC")
fun getSavedItemLabelsLiveData(): LiveData<List<SavedItemLabel>>
@Transaction
@Query("UPDATE SavedItemLabel set savedItemLabelId = :permanentId, serverSyncStatus = :status WHERE savedItemLabelId = :tempId")
fun updateTempLabel(
tempId: String, permanentId: String, status: ServerSyncStatus = ServerSyncStatus.IS_SYNCED
)
@Transaction
@Query("SELECT * FROM SavedItemLabel WHERE name in (:names) ORDER BY name ASC")
fun namedLabels(names: List<String>): List<SavedItemLabel>
}
@Entity(
primaryKeys = ["savedItemLabelId", "savedItemId"], foreignKeys = [ForeignKey(
entity = SavedItem::class,
@ -56,16 +29,3 @@ interface SavedItemLabelDao {
data class SavedItemAndSavedItemLabelCrossRef(
val savedItemLabelId: String, val savedItemId: String
)
@Dao
interface SavedItemAndSavedItemLabelCrossRefDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemAndSavedItemLabelCrossRef>)
@Query("DELETE FROM savedItemAndSavedItemLabelCrossRef WHERE savedItemId = :savedItemId")
fun deleteRefsBySavedItemId(savedItemId: String)
}
// has many highlights
// has many savedItems

View File

@ -1,49 +1,41 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation
import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput
import android.util.Log
import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation
import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
data class ReadingProgressParams(
val id: String?,
val readingProgressPercent: Double?,
val readingProgressAnchorIndex: Int?,
val force: Boolean?
val id: String?,
val readingProgressPercent: Double?,
val readingProgressAnchorIndex: Int?,
val force: Boolean?
) {
fun asSaveReadingProgressInput() = SaveArticleReadingProgressInput(
id = id ?: "",
force = Optional.presentIfNotNull(force),
readingProgressPercent = readingProgressPercent ?: 0.0,
readingProgressAnchorIndex = Optional.presentIfNotNull(readingProgressAnchorIndex ?: 0)
)
}
suspend fun Networker.updateWebReadingProgress(jsonString: String): Boolean {
val params = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
return updateReadingProgress(params)
fun asSaveReadingProgressInput() = SaveArticleReadingProgressInput(
id = id ?: "",
force = Optional.presentIfNotNull(force),
readingProgressPercent = readingProgressPercent ?: 0.0,
readingProgressAnchorIndex = Optional.presentIfNotNull(readingProgressAnchorIndex ?: 0)
)
}
suspend fun Networker.updateReadingProgress(params: ReadingProgressParams): Boolean {
try {
val input = params.asSaveReadingProgressInput()
try {
val input = params.asSaveReadingProgressInput()
Log.d("Loggo", "created reading progress input: $input")
Log.d("Loggo", "created reading progress input: $input")
val result = authenticatedApolloClient()
.mutation(SaveArticleReadingProgressMutation(input))
.execute()
val result = authenticatedApolloClient().mutation(SaveArticleReadingProgressMutation(input))
.execute()
val articleID =
result.data?.saveArticleReadingProgress?.onSaveArticleReadingProgressSuccess?.updatedArticle?.id
val articleID =
result.data?.saveArticleReadingProgress?.onSaveArticleReadingProgressSuccess?.updatedArticle?.id
Log.d("Loggo", "updated article with id: $articleID")
Log.d("Loggo", "updated article with id: $articleID")
return articleID != null
} catch (e: java.lang.Exception) {
return false
}
return articleID != null
} catch (e: java.lang.Exception) {
return false
}
}

View File

@ -1,24 +1,24 @@
package app.omnivore.omnivore.core.network
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.graphql.generated.GetLabelsQuery
suspend fun Networker.savedItemLabels(): List<SavedItemLabel> {
try {
val result = authenticatedApolloClient().query(GetLabelsQuery()).execute()
val labels = result.data?.labels?.onLabelsSuccess?.labels ?: listOf()
try {
val result = authenticatedApolloClient().query(GetLabelsQuery()).execute()
val labels = result.data?.labels?.onLabelsSuccess?.labels ?: listOf()
return labels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
labelDescription = it.labelFields.description
)
return labels.map {
SavedItemLabel(
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
labelDescription = it.labelFields.description
)
}
} catch (e: java.lang.Exception) {
return listOf()
}
} catch (e: java.lang.Exception) {
return listOf()
}
}

View File

@ -33,11 +33,13 @@ import app.omnivore.omnivore.feature.components.LabelChipColors
@Composable
fun LibraryFilterBar(
isFollowingScreen: Boolean,
viewModel: LibraryViewModel = hiltViewModel()
) {
var isSavedItemFilterMenuExpanded by remember { mutableStateOf(false) }
val activeSavedItemFilter: SavedItemFilter by viewModel.appliedFilterLiveData.observeAsState(
SavedItemFilter.INBOX
if (isFollowingScreen) SavedItemFilter.FOLLOWING else SavedItemFilter.INBOX
)
val activeLabels: List<SavedItemLabel> by viewModel.activeLabelsLiveData.observeAsState(listOf())
@ -58,7 +60,9 @@ fun LibraryFilterBar(
) {
item {
AssistChip(onClick = { isSavedItemFilterMenuExpanded = true },
label = { Text(activeSavedItemFilter.displayText) },
label = { Text(
activeSavedItemFilter.displayText
) },
trailingIcon = {
Icon(
Icons.Default.ArrowDropDown,
@ -113,9 +117,12 @@ fun LibraryFilterBar(
}
}
SavedItemFilterContextMenu(isExpanded = isSavedItemFilterMenuExpanded,
SavedItemFilterContextMenu(
isFollowingScreen = isFollowingScreen,
isExpanded = isSavedItemFilterMenuExpanded,
onDismiss = { isSavedItemFilterMenuExpanded = false },
actionHandler = { viewModel.updateSavedItemFilter(it) })
actionHandler = { viewModel.updateSavedItemFilter(it) }
)
SavedItemSortFilterContextMenu(isExpanded = isSavedItemSortFilterMenuExpanded,
onDismiss = { isSavedItemSortFilterMenuExpanded = false },

View File

@ -21,7 +21,6 @@ import androidx.compose.material.DismissState
import androidx.compose.material.DismissValue
import androidx.compose.material.ExperimentalMaterialApi
import androidx.compose.material.FractionalThreshold
import androidx.compose.material.Icon
import androidx.compose.material.ScaffoldState
import androidx.compose.material.SwipeToDismiss
import androidx.compose.material.icons.Icons
@ -33,6 +32,7 @@ import androidx.compose.material.rememberScaffoldState
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.HorizontalDivider
import androidx.compose.material3.Icon
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.ModalBottomSheet
import androidx.compose.material3.Scaffold
@ -60,7 +60,6 @@ import androidx.compose.ui.unit.dp
import androidx.hilt.navigation.compose.hiltViewModel
import androidx.lifecycle.compose.collectAsStateWithLifecycle
import androidx.navigation.NavHostController
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.feature.components.AddLinkSheetContent
import app.omnivore.omnivore.feature.components.LabelsSelectionSheetContent
@ -147,6 +146,7 @@ internal fun LibraryView(
when (uiState) {
is LibraryUiState.Success -> {
LibraryViewContent(
isFollowingScreen = currentTopLevelDestination == TopLevelDestination.FOLLOWING,
viewModel,
paddingValues = paddingValues,
uiState = uiState
@ -189,9 +189,9 @@ fun LabelBottomSheet(
) {
val currentSavedItemData = libraryViewModel.currentSavedItemUnderEdit()
val labels: List<SavedItemLabel> by libraryViewModel.savedItemLabelsLiveData.observeAsState(
listOf()
)
val labels by libraryViewModel.labelsState.collectAsStateWithLifecycle()
if (currentSavedItemData != null) {
LabelsSelectionSheetContent(
labels = labels,
@ -302,6 +302,7 @@ fun EditBottomSheet(
@OptIn(ExperimentalMaterialApi::class, ExperimentalMaterial3Api::class)
@Composable
fun LibraryViewContent(
isFollowingScreen: Boolean,
libraryViewModel: LibraryViewModel,
paddingValues: PaddingValues,
uiState: LibraryUiState
@ -328,7 +329,7 @@ fun LibraryViewContent(
.nestedScroll(pullToRefreshState.nestedScrollConnection)
) {
Column {
LibraryFilterBar()
LibraryFilterBar(isFollowingScreen)
HorizontalDivider()
LazyColumn(
state = listState,

View File

@ -1,5 +1,6 @@
package app.omnivore.omnivore.feature.library
import android.content.Context
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.setValue
@ -7,30 +8,14 @@ import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.R
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.core.data.archiveSavedItem
import app.omnivore.omnivore.core.data.deleteSavedItem
import app.omnivore.omnivore.core.data.fetchSavedItemContent
import app.omnivore.omnivore.core.data.isSavedItemContentStoredInDB
import app.omnivore.omnivore.core.data.librarySearch
import app.omnivore.omnivore.core.data.model.LibraryQuery
import app.omnivore.omnivore.core.data.repository.LibraryRepository
import app.omnivore.omnivore.core.data.sync
import app.omnivore.omnivore.core.data.syncLabels
import app.omnivore.omnivore.core.data.syncOfflineItemsWithServerIfNeeded
import app.omnivore.omnivore.core.data.unarchiveSavedItem
import app.omnivore.omnivore.core.database.entities.SavedItemLabel
import app.omnivore.omnivore.core.database.entities.SavedItemWithLabelsAndHighlights
import app.omnivore.omnivore.core.datastore.DatastoreRepository
import app.omnivore.omnivore.core.network.Networker
import app.omnivore.omnivore.core.network.createNewLabel
import app.omnivore.omnivore.feature.ResourceProvider
import app.omnivore.omnivore.feature.setSavedItemLabels
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import dagger.hilt.android.qualifiers.ApplicationContext
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.ExperimentalCoroutinesApi
import kotlinx.coroutines.channels.Channel
@ -50,11 +35,9 @@ import javax.inject.Inject
@OptIn(ExperimentalCoroutinesApi::class)
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository,
private val resourceProvider: ResourceProvider,
private val libraryRepository: LibraryRepository,
@ApplicationContext private val applicationContext: Context
) : ViewModel(), SavedItemViewModel {
private val contentRequestChannel = Channel<String>(capacity = Channel.UNLIMITED)
@ -62,7 +45,7 @@ class LibraryViewModel @Inject constructor(
var snackbarMessage by mutableStateOf<String?>(null)
private set
private val _libraryQuery = MutableStateFlow(
LibraryQuery(
allowedArchiveStates = listOf(0),
@ -75,48 +58,43 @@ class LibraryViewModel @Inject constructor(
val uiState: StateFlow<LibraryUiState> = _libraryQuery.flatMapLatest { query ->
libraryRepository.getSavedItems(query)
}
.map(LibraryUiState::Success)
.stateIn(
}.map(LibraryUiState::Success).stateIn(
scope = viewModelScope,
started = SharingStarted.Lazily,
initialValue = LibraryUiState.Loading
)
val appliedFilterLiveData = MutableLiveData(SavedItemFilter.INBOX)
val appliedFilterLiveData = MutableLiveData<SavedItemFilter>()
val appliedSortFilterLiveData = MutableLiveData(SavedItemSortFilter.NEWEST)
val bottomSheetState = MutableLiveData(LibraryBottomSheetState.HIDDEN)
val currentItem = mutableStateOf<String?>(null)
val savedItemLabelsLiveData = dataService.db.savedItemLabelDao().getSavedItemLabelsLiveData()
val labelsState = libraryRepository.getSavedItemsLabels().stateIn(
scope = viewModelScope, started = SharingStarted.Lazily, initialValue = listOf()
)
val activeLabelsLiveData = MutableLiveData<List<SavedItemLabel>>(listOf())
override val actionsMenuItemLiveData = MutableLiveData<SavedItemWithLabelsAndHighlights?>(null)
var isRefreshing by mutableStateOf(false)
private var hasLoadedInitialFilters = false
private fun loadInitialFilterValues() {
if (hasLoadedInitialFilters) {
return
}
hasLoadedInitialFilters = false
viewModelScope.launch {
withContext(Dispatchers.IO) {
dataService.syncLabels()
}
}
syncLabels()
viewModelScope.launch {
handleFilterChanges()
for (slug in contentRequestChannel) {
CoroutineScope(Dispatchers.IO).launch {
dataService.fetchSavedItemContent(slug)
}
libraryRepository.fetchSavedItemContent(slug)
}
}
updateSavedItemFilter(appliedFilterLiveData.value ?: SavedItemFilter.INBOX)
}
private fun syncLabels() {
viewModelScope.launch {
val labels = libraryRepository.getLabels()
libraryRepository.insertAllLabels(labels)
}
}
fun clearSnackbarMessage() {
@ -125,7 +103,6 @@ class LibraryViewModel @Inject constructor(
fun refresh() {
librarySearchCursor = null
isRefreshing = true
load()
}
@ -141,13 +118,8 @@ class LibraryViewModel @Inject constructor(
fun initialLoad() {
if (getLastSyncTime() == null) {
hasLoadedInitialFilters = false
librarySearchCursor = null
}
if (hasLoadedInitialFilters) {
return
}
load()
}
@ -162,24 +134,18 @@ class LibraryViewModel @Inject constructor(
fun loadUsingSearchAPI() {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = dataService.librarySearch(
cursor = librarySearchCursor, query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
val result = libraryRepository.librarySearch(
cursor = librarySearchCursor, query = searchQueryString()
)
result.cursor?.let {
librarySearchCursor = it
}
result.savedItems.map {
val isSavedInDB = libraryRepository.isSavedItemContentStoredInDB(it.savedItem.slug)
result.savedItems.map {
val isSavedInDB = dataService.isSavedItemContentStoredInDB(it.savedItem.slug)
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
if (!isSavedInDB) {
delay(2000)
contentRequestChannel.send(it.savedItem.slug)
}
}
}
@ -232,6 +198,7 @@ class LibraryViewModel @Inject constructor(
}
var requiredLabels = when (appliedFilterLiveData.value) {
SavedItemFilter.FOLLOWING -> listOf("Newsletter", "RSS")
SavedItemFilter.NEWSLETTERS -> listOf("Newsletter")
SavedItemFilter.FEEDS -> listOf("RSS")
else -> (activeLabelsLiveData.value ?: listOf()).map { it.name }
@ -268,9 +235,6 @@ class LibraryViewModel @Inject constructor(
count = 0,
startTime = syncStart.toString()
)
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
}
}
@ -281,8 +245,8 @@ class LibraryViewModel @Inject constructor(
startTime: String,
isInitialBatch: Boolean = true
) {
dataService.syncOfflineItemsWithServerIfNeeded()
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
libraryRepository.syncOfflineItemsWithServerIfNeeded()
val result = libraryRepository.sync(since = since, cursor = cursor, limit = 20)
// Fetch content for the initial batch only
if (isInitialBatch) {
@ -348,66 +312,39 @@ class LibraryViewModel @Inject constructor(
fun deleteSavedItem(itemID: String) {
viewModelScope.launch {
dataService.deleteSavedItem(itemID)
libraryRepository.deleteSavedItem(itemID)
}
}
fun archiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.archiveSavedItem(itemID)
libraryRepository.archiveSavedItem(itemID)
}
}
fun unarchiveSavedItem(itemID: String) {
viewModelScope.launch {
dataService.unarchiveSavedItem(itemID)
libraryRepository.unarchiveSavedItem(itemID)
}
}
fun updateSavedItemLabels(savedItemID: String, labels: List<SavedItemLabel>) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val result = setSavedItemLabels(
networker = networker,
dataService = dataService,
savedItemID = savedItemID,
labels = labels
)
snackbarMessage = if (result) {
resourceProvider.getString(R.string.library_view_model_snackbar_success)
} else {
resourceProvider.getString(R.string.library_view_model_snackbar_error)
}
CoroutineScope(Dispatchers.Main).launch {
handleFilterChanges()
}
val result = libraryRepository.setSavedItemLabels(
itemId = savedItemID, labels = labels
)
snackbarMessage = if (result) {
applicationContext.getString(R.string.library_view_model_snackbar_success)
} else {
applicationContext.getString(R.string.library_view_model_snackbar_error)
}
handleFilterChanges()
}
}
fun createNewSavedItemLabel(labelName: String, hexColorValue: String) {
viewModelScope.launch {
withContext(Dispatchers.IO) {
val newLabel = networker.createNewLabel(
CreateLabelInput(
color = Optional.presentIfNotNull(hexColorValue), name = labelName
)
)
newLabel?.let {
val savedItemLabel = SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = it.createdAt as String?,
labelDescription = it.description
)
dataService.db.savedItemLabelDao().insertAll(listOf(savedItemLabel))
}
}
libraryRepository.createNewSavedItemLabel(labelName, hexColorValue)
}
}

View File

@ -7,38 +7,45 @@ import androidx.compose.runtime.Composable
import androidx.compose.ui.text.font.FontWeight
enum class SavedItemFilter(val displayText: String, val rawValue: String, val queryString: String) {
INBOX("Inbox", rawValue = "inbox", "in:inbox"), READ_LATER(
"Non-Feed Items", "nonFeed", "no:subscription"
),
FEEDS("Feeds", "feeds", "in:inbox label:RSS"), NEWSLETTERS(
"Newsletters", "newsletters", "in:inbox label:Newsletter"
),
// RECOMMENDED("Recommended", "recommended", "recommendedBy:*"),
ALL("All", "all", "in:all"), ARCHIVED("Archived", "archived", "in:archive"),
FOLLOWING("Following", "following", "in:following use:folders"),
INBOX("Inbox", rawValue = "inbox", "in:inbox"),
READ_LATER("Non-Feed Items", "nonFeed", "no:subscription"),
FEEDS("Feeds", "feeds", "in:inbox label:RSS"),
NEWSLETTERS("Newsletters", "newsletters", "in:inbox label:Newsletter"),
ALL("All", "all", "in:all"),
ARCHIVED("Archived", "archived", "in:archive"),
// HAS_HIGHLIGHTS("Highlighted", "hasHighlights", "has:highlights"),
FILES("Files", "files", "type:file"),
}
@Composable
fun SavedItemFilterContextMenu(
isExpanded: Boolean, onDismiss: () -> Unit, actionHandler: (SavedItemFilter) -> Unit
isFollowingScreen: Boolean,
isExpanded: Boolean, onDismiss: () -> Unit,
actionHandler: (SavedItemFilter) -> Unit
) {
val filters = if (isFollowingScreen) {
listOf(
SavedItemFilter.FOLLOWING,
SavedItemFilter.FEEDS,
SavedItemFilter.NEWSLETTERS
)
} else {
listOf(
SavedItemFilter.INBOX,
SavedItemFilter.READ_LATER,
SavedItemFilter.ALL,
SavedItemFilter.ARCHIVED,
SavedItemFilter.FILES
)
}
DropdownMenu(
expanded = isExpanded, onDismissRequest = onDismiss
) {
// Displaying only a subset of filters until we figure out the Room DB queries (and labels)
// SavedItemFilter.values().forEach {
listOf(
SavedItemFilter.INBOX,
SavedItemFilter.READ_LATER,
SavedItemFilter.NEWSLETTERS,
SavedItemFilter.FEEDS,
SavedItemFilter.ALL,
SavedItemFilter.ARCHIVED,
SavedItemFilter.FILES
).forEach {
filters.forEach {
DropdownMenuItem(text = { Text(text = it.displayText, fontWeight = FontWeight.Normal) },
onClick = {
actionHandler(it)

View File

@ -1,4 +1,4 @@
package app.omnivore.omnivore.feature
package app.omnivore.omnivore.feature.reader
import app.omnivore.omnivore.core.data.DataService
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput

View File

@ -34,7 +34,6 @@ import app.omnivore.omnivore.core.network.saveUrl
import app.omnivore.omnivore.core.network.savedItem
import app.omnivore.omnivore.feature.components.HighlightColor
import app.omnivore.omnivore.feature.library.SavedItemAction
import app.omnivore.omnivore.feature.setSavedItemLabels
import app.omnivore.omnivore.graphql.generated.type.CreateLabelInput
import app.omnivore.omnivore.utils.DatastoreKeys
import com.apollographql.apollo3.api.Optional.Companion.presentIfNotNull
@ -464,7 +463,7 @@ class WebReaderViewModel @Inject constructor(
val storedThemePreference =
datastoreRepo.getString(DatastoreKeys.preferredTheme) ?: "System"
val storedWebFont =
WebFont.values().firstOrNull { it.rawValue == storedFontFamily } ?: WebFont.values()
WebFont.entries.firstOrNull { it.rawValue == storedFontFamily } ?: WebFont.entries
.first()
val prefersHighContrastFont =