Merge pull request #1637 from omnivore-app/feature/android-library-sync

Library Syncing - Android
This commit is contained in:
Satindar Dhillon 2023-01-24 22:51:11 -08:00 committed by GitHub
commit f36eb0ed32
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
33 changed files with 953 additions and 260 deletions

File diff suppressed because one or more lines are too long

View file

@ -0,0 +1,56 @@
query UpdatesSince($after: String, $first: Int, $since: Date!) {
updatesSince(after: $after, first: $first, since: $since) {
... on UpdatesSinceSuccess {
edges {
cursor
itemID
updateReason
node {
id
title
slug
url
pageType
contentReader
createdAt
isArchived
readingProgressPercent
readingProgressAnchorIndex
author
image
description
publishedAt
ownedByViewer
originalArticleUrl
uploadFileId
labels {
id
name
color
}
pageId
shortId
quote
annotation
state
siteName
subscription
readAt
savedAt
updatedAt
language
}
}
pageInfo {
hasNextPage
hasPreviousPage
startCursor
endCursor
totalCount
}
}
... on UpdatesSinceError {
errorCodes
}
}
}

View file

@ -2,6 +2,7 @@ package app.omnivore.omnivore
import android.content.Context
import androidx.room.Room
import app.omnivore.omnivore.dataService.DataService
import app.omnivore.omnivore.networking.Networker
import app.omnivore.omnivore.persistence.AppDatabase
import dagger.Module
@ -31,5 +32,8 @@ object AppModule {
@Singleton
@Provides
fun provideDataService(@ApplicationContext app: Context) = DataService(app)
fun provideDataService(
@ApplicationContext app: Context,
networker: Networker
) = DataService(app, networker)
}

View file

@ -12,6 +12,7 @@ object DatastoreKeys {
const val omnivoreAuthToken = "omnivoreAuthToken"
const val omnivoreAuthCookieString = "omnivoreAuthCookieString"
const val omnivorePendingUserToken = "omnivorePendingUserToken"
const val libraryLastSyncTimestamp = "libraryLastSyncTimestamp"
const val preferredWebFontSize = "preferredWebFontSize"
const val preferredWebLineHeight = "preferredWebLineHeight"
const val preferredWebMaxWidthPercentage = "preferredWebMaxWidthPercentage"

View file

@ -2,6 +2,5 @@ package app.omnivore.omnivore
sealed class Routes(val route: String) {
object Library : Routes("Library")
object WebAppReader : Routes("WebAppReader")
object Settings: Routes("Settings")
}

View file

@ -1,12 +1,14 @@
package app.omnivore.omnivore
package app.omnivore.omnivore.dataService
import android.content.Context
import androidx.room.Room
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.AppDatabase
import javax.inject.Inject
class DataService @Inject constructor(
context: Context
context: Context,
val networker: Networker
) {
val db = Room.databaseBuilder(
context,

View file

@ -0,0 +1,118 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItemAndHighlightCrossRef
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.createWebHighlight(jsonString: String) {
val createHighlightInput = Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput()
withContext(Dispatchers.IO) {
val highlight = Highlight(
highlightId = createHighlightInput.id,
shortId = createHighlightInput.shortId,
quote = createHighlightInput.quote,
prefix = null,
suffix = null,
patch = createHighlightInput.patch,
annotation = createHighlightInput.annotation.getOrNull(),
createdAt = null,
updatedAt = null,
createdByMe = false
)
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_CREATION.rawValue
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = createHighlightInput.id,
savedItemId = createHighlightInput.articleId
)
db.highlightDao().insertAll(listOf(highlight))
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
val newHighlight = networker.createHighlight(createHighlightInput)
newHighlight?.let {
db.highlightDao().update(it)
}
}
}
suspend fun DataService.mergeWebHighlights(jsonString: String) {
val mergeHighlightInput = Gson().fromJson(jsonString, MergeHighlightsParams::class.java).asMergeHighlightInput()
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = mergeHighlightInput.id) ?: return@withContext
highlight.shortId = mergeHighlightInput.shortId
highlight.quote = mergeHighlightInput.quote
highlight.patch = mergeHighlightInput.patch
highlight.prefix = mergeHighlightInput.prefix.getOrNull()
highlight.annotation = mergeHighlightInput.annotation.getOrNull()
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
for (highlightID in mergeHighlightInput.overlapHighlightIdList) {
deleteHighlight(highlightID)
}
val crossRef = SavedItemAndHighlightCrossRef(
highlightId = mergeHighlightInput.id,
savedItemId = mergeHighlightInput.articleId
)
db.savedItemAndHighlightCrossRefDao().insertAll(listOf(crossRef))
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.mergeHighlights(mergeHighlightInput)
if (isUpdatedOnServer) {
highlight.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.highlightDao().update(highlight)
}
}
}
suspend fun DataService.updateWebHighlight(jsonString: String) {
val updateHighlightParams = Gson().fromJson(jsonString, UpdateHighlightParams::class.java).asUpdateHighlightInput()
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = updateHighlightParams.highlightId) ?: return@withContext
highlight.annotation = updateHighlightParams.annotation.getOrNull()
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.updateHighlight(updateHighlightParams)
if (isUpdatedOnServer) {
highlight.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.highlightDao().update(highlight)
}
}
}
suspend fun DataService.deleteHighlights(jsonString: String) {
val highlightIDs = Gson().fromJson(jsonString, DeleteHighlightParams::class.java).asIdList()
for (highlightID in highlightIDs) {
deleteHighlight(highlightID)
}
}
private suspend fun DataService.deleteHighlight(highlightID: String) {
withContext(Dispatchers.IO) {
val highlight = db.highlightDao().findById(highlightId = highlightID) ?: return@withContext
highlight.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.highlightDao().update(highlight)
val isUpdatedOnServer = networker.deleteHighlights(listOf(highlightID))
if (isUpdatedOnServer) {
db.highlightDao().deleteById(highlightId = highlightID)
}
}
}

View file

@ -0,0 +1,109 @@
package app.omnivore.omnivore.dataService
import android.util.Log
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.*
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 savedItems = syncResult.items.map {
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
)
}
db.savedItemDao().insertAll(savedItems)
val labels: MutableList<SavedItemLabel> = mutableListOf()
val crossRefs: MutableList<SavedItemAndSavedItemLabelCrossRef> = mutableListOf()
// save labels
for (item in syncResult.items) {
val itemLabels = (item.labels ?: listOf()).map {
SavedItemLabel(
savedItemLabelId = it.id,
name = it.name,
color = it.color,
createdAt = null,
labelDescription = null
)
}
labels.addAll(itemLabels)
val newCrossRefs = itemLabels.map {
SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = item.id)
}
crossRefs.addAll(newCrossRefs)
}
db.savedItemLabelDao().insertAll(labels)
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(crossRefs)
return SavedItemSyncResult(
hasError = false,
hasMoreItems = syncResult.hasMoreItems,
cursor = syncResult.cursor,
count = syncResult.items.size,
savedItemSlugs = syncResult.items.map { it.slug }
)
}
suspend fun DataService.syncSavedItemContent(slug: String) {
val syncResult = networker.savedItem(slug)
val savedItem = syncResult.item ?: return
db.savedItemDao().insert(savedItem)
// Persist Labels
db.savedItemLabelDao().insertAll(syncResult.labels)
val labelCrossRefs = syncResult.labels.map {
SavedItemAndSavedItemLabelCrossRef(savedItemLabelId = it.savedItemLabelId, savedItemId = savedItem.savedItemId)
}
db.savedItemAndSavedItemLabelCrossRefDao().insertAll(labelCrossRefs)
// Persist Highlights
db.highlightDao().insertAll(syncResult.highlights)
val highlightCrossRefs = syncResult.highlights.map {
SavedItemAndHighlightCrossRef(highlightId = it.highlightId, savedItemId = savedItem.savedItemId)
}
db.savedItemAndHighlightCrossRefDao().insertAll(highlightCrossRefs)
Log.d("sync", "saved content for item with id: ${savedItem.savedItemId}")
}
data class SavedItemSyncResult(
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())
}
}

View file

@ -0,0 +1,28 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.ReadingProgressParams
import app.omnivore.omnivore.networking.updateReadingProgress
import com.google.gson.Gson
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.updateWebReadingProgress(jsonString: String) {
val readingProgressParams = Gson().fromJson(jsonString, ReadingProgressParams::class.java)
val savedItemId = readingProgressParams.id ?: return
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(savedItemId) ?: return@withContext
savedItem.readingProgress = readingProgressParams.readingProgressPercent ?: 0.0
savedItem.readingProgressAnchor = readingProgressParams.readingProgressAnchorIndex ?: 0
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.updateReadingProgress(readingProgressParams)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View file

@ -0,0 +1,56 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.archiveSavedItem
import app.omnivore.omnivore.networking.deleteSavedItem
import app.omnivore.omnivore.networking.unarchiveSavedItem
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.withContext
suspend fun DataService.deleteSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_DELETION.rawValue
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.deleteSavedItem(itemID)
if (isUpdatedOnServer) {
db.savedItemDao().deleteById(itemID)
}
}
}
suspend fun DataService.archiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = true
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.archiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}
suspend fun DataService.unarchiveSavedItem(itemID: String) {
withContext(Dispatchers.IO) {
val savedItem = db.savedItemDao().findById(itemID = itemID) ?: return@withContext
savedItem.serverSyncStatus = ServerSyncStatus.NEEDS_UPDATE.rawValue
savedItem.isArchived = false
db.savedItemDao().update(savedItem)
val isUpdatedOnServer = networker.unarchiveSavedItem(itemID)
if (isUpdatedOnServer) {
savedItem.serverSyncStatus = ServerSyncStatus.IS_SYNCED.rawValue
db.savedItemDao().update(savedItem)
}
}
}

View file

@ -0,0 +1,131 @@
package app.omnivore.omnivore.dataService
import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput
import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.models.ServerSyncStatus
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.Highlight
import app.omnivore.omnivore.persistence.entities.SavedItem
import com.apollographql.apollo3.api.Optional
suspend fun DataService.syncOfflineItemsWithServerIfNeeded() {
val unSyncedSavedItems = db.savedItemDao().getUnSynced()
val unSyncedHighlights = db.highlightDao().getUnSynced()
for (savedItem in unSyncedSavedItems) {
syncSavedItem(savedItem)
}
for (highlight in unSyncedHighlights) {
syncHighlight(highlight)
}
}
private suspend fun DataService.syncSavedItem(item: SavedItem) {
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)
}
}
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,
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
}
}
private suspend fun DataService.syncHighlight(highlight: Highlight) {
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)
}
}
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)
}
}
ServerSyncStatus.NEEDS_CREATION.rawValue -> {
updateSyncStatus(ServerSyncStatus.IS_SYNCING)
val savedItemID = db.savedItemAndHighlightCrossRefDao()
.associatedSavedItemID(highlightId = highlight.highlightId)
val isCreatedOnServer = networker.createHighlight(
CreateHighlightInput(
annotation = Optional.presentIfNotNull(highlight.annotation),
articleId = savedItemID ?: "",
id = highlight.highlightId,
patch = highlight.patch ?: "",
quote = highlight.quote ?: "",
shortId = highlight.shortId ?: ""
)
)
if (isCreatedOnServer != null) {
updateSyncStatus(ServerSyncStatus.IS_SYNCED)
} else {
updateSyncStatus(ServerSyncStatus.NEEDS_UPDATE)
}
}
else -> return
}
}

View file

@ -0,0 +1,11 @@
package app.omnivore.omnivore.models
public enum class ServerSyncStatus(
public val rawValue: Int,
) {
IS_SYNCED(0),
IS_SYNCING(1),
NEEDS_DELETION(2),
NEEDS_CREATION(3),
NEEDS_UPDATE(4)
}

View file

@ -11,7 +11,6 @@ import app.omnivore.omnivore.graphql.generated.type.UpdateHighlightInput
import app.omnivore.omnivore.persistence.entities.Highlight
import com.apollographql.apollo3.api.Optional
import com.google.gson.Gson
import java.time.LocalDate
data class CreateHighlightParams(
val shortId: String?,
@ -140,7 +139,7 @@ suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
// val updatedAtString = createdHighlight.highlightFields.updatedAt as? String
return Highlight(
id = createdHighlight.highlightFields.id,
highlightId = createdHighlight.highlightFields.id,
shortId = createdHighlight.highlightFields.shortId,
quote = createdHighlight.highlightFields.quote,
prefix = createdHighlight.highlightFields.prefix,
@ -149,9 +148,7 @@ suspend fun Networker.createHighlight(input: CreateHighlightInput): Highlight? {
annotation = createdHighlight.highlightFields.annotation,
createdAt = null, // TODO: update gql query to get this
updatedAt = null, // TODO: fix updatedAtString?.let { LocalDate.parse(it) },
createdByMe = createdHighlight.highlightFields.createdByMe,
markedForDeletion = false,
serverSyncStatus = 1 // TODO: create enum for this
createdByMe = createdHighlight.highlightFields.createdByMe
)
} else {
return null

View file

@ -23,7 +23,7 @@ suspend fun Networker.unarchiveSavedItem(itemID: String): Boolean {
return updateArchiveStatusSavedItem(itemID, false)
}
private suspend fun Networker.updateArchiveStatusSavedItem(itemID: String, setAsArchived: Boolean): Boolean {
suspend fun Networker.updateArchiveStatusSavedItem(itemID: String, setAsArchived: Boolean): Boolean {
return try {
val input = ArchiveLinkInput(setAsArchived, itemID)
val result = authenticatedApolloClient().mutation(SetLinkArchivedMutation(input)).execute()

View file

@ -30,7 +30,7 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
val savedItemLabels = labels.map {
SavedItemLabel(
id = it.labelFields.id,
savedItemLabelId = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt as String?,
@ -42,7 +42,7 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
// val updatedAtString = it.highlightFields.updatedAt as? String
Highlight(
id = it.highlightFields.id,
highlightId = it.highlightFields.id,
shortId = it.highlightFields.shortId,
quote = it.highlightFields.quote,
prefix = it.highlightFields.prefix,
@ -51,16 +51,14 @@ suspend fun Networker.savedItem(slug: String): SavedItemQueryResponse {
annotation = it.highlightFields.annotation,
createdAt = null, // TODO: update gql query to get this
updatedAt = null, //updatedAtString?.let { str -> LocalDate.parse(str) }, TODO: fix date parsing
createdByMe = it.highlightFields.createdByMe,
markedForDeletion = false,
serverSyncStatus = 1 // TODO: create enum for this
createdByMe = it.highlightFields.createdByMe
)
}
// TODO: handle errors
val savedItem = SavedItem(
id = article.articleFields.id,
savedItemId = article.articleFields.id,
title = article.articleFields.title,
createdAt = article.articleFields.createdAt as String,
savedAt = article.articleFields.savedAt as String,

View file

@ -0,0 +1,50 @@
package app.omnivore.omnivore.networking
import app.omnivore.omnivore.graphql.generated.UpdatesSinceQuery
import app.omnivore.omnivore.graphql.generated.type.UpdateReason
import app.omnivore.omnivore.persistence.entities.SavedItem
import com.apollographql.apollo3.api.Optional
data class SavedItemUpdatesQueryResponse(
val cursor: String?,
val hasMoreItems: Boolean,
val deletedItemIDs: List<String>,
val items: List<UpdatesSinceQuery.Node>
)
suspend fun Networker.savedItemUpdates(
cursor: String? = null,
limit: Int = 15,
since: String
): SavedItemUpdatesQueryResponse? {
try {
val result = authenticatedApolloClient().query(
UpdatesSinceQuery(
after = Optional.presentIfNotNull(cursor),
first = Optional.presentIfNotNull(limit),
since = since
)
).execute()
val payload = result.data?.updatesSince?.onUpdatesSinceSuccess ?: return null
val itemNodes: MutableList<UpdatesSinceQuery.Node> = mutableListOf()
val deletedItemIDs: MutableList<String> = mutableListOf()
for (edge in payload.edges) {
if (edge.updateReason == UpdateReason.DELETED) {
deletedItemIDs.add(edge.itemID)
} else if (edge.node != null) {
itemNodes.add(edge.node)
}
}
return SavedItemUpdatesQueryResponse(
cursor = payload.pageInfo.endCursor,
hasMoreItems = payload.pageInfo.hasNextPage,
deletedItemIDs = deletedItemIDs,
items = itemNodes
)
} catch (e: java.lang.Exception) {
return null
}
}

View file

@ -2,7 +2,6 @@ package app.omnivore.omnivore.networking
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.graphql.generated.TypeaheadSearchQuery
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import com.apollographql.apollo3.api.Optional
@ -23,7 +22,7 @@ suspend fun Networker.typeaheadSearch(
val cardsData = itemList.map {
SavedItemCardData(
id = it.id,
savedItemId = it.id,
slug = it.slug,
publisherURLString = "",
title = it.title,
@ -55,13 +54,12 @@ suspend fun Networker.search(
)
).execute()
val newCursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor
val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf()
val cardsData = itemList.map {
SavedItemCardData(
id = it.node.id,
savedItemId = it.node.id,
slug = it.node.slug,
publisherURLString = it.node.originalArticleUrl,
title = it.node.title,

View file

@ -2,19 +2,24 @@ package app.omnivore.omnivore.persistence
import androidx.room.Database
import androidx.room.RoomDatabase
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemDao
import app.omnivore.omnivore.persistence.entities.Viewer
import app.omnivore.omnivore.persistence.entities.ViewerDao
import app.omnivore.omnivore.persistence.entities.*
@Database(
entities = [
Viewer::class,
SavedItem::class
SavedItem::class,
SavedItemLabel::class,
Highlight::class,
SavedItemAndSavedItemLabelCrossRef::class,
SavedItemAndHighlightCrossRef::class
],
version = 1
version = 2
)
abstract class AppDatabase : RoomDatabase() {
abstract fun viewerDao(): ViewerDao
abstract fun savedItemDao(): SavedItemDao
abstract fun highlightDao(): HighlightDao
abstract fun savedItemLabelDao(): SavedItemLabelDao
abstract fun savedItemAndSavedItemLabelCrossRefDao(): SavedItemAndSavedItemLabelCrossRefDao
abstract fun savedItemAndHighlightCrossRefDao(): SavedItemAndHighlightCrossRefDao
}

View file

@ -1,26 +1,92 @@
package app.omnivore.omnivore.persistence.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import java.time.LocalDate
import java.util.Date
import androidx.room.*
import app.omnivore.omnivore.models.ServerSyncStatus
@Entity
data class Highlight(
@PrimaryKey val id: String,
val annotation: String?,
val createdAt: Date?,
@PrimaryKey val highlightId: String,
var annotation: String?,
val createdAt: String?,
val createdByMe: Boolean,
val markedForDeletion: Boolean, // default false
val patch: String,
val prefix: String?,
val quote: String,
val serverSyncStatus: Int, // default 0
val shortId: String,
val markedForDeletion: Boolean = false,
var patch: String,
var prefix: String?,
var quote: String,
var serverSyncStatus: Int = ServerSyncStatus.IS_SYNCED.rawValue,
var shortId: String,
val suffix: String?,
val updatedAt: LocalDate?
val updatedAt: String?
// has many SavedItemLabels (inverse: labels have many highlights)
// has one savedItem (inverse: savedItem has many highlights
// has a UserProfile (no inverse)
)
@Entity(
primaryKeys = ["highlightId", "savedItemId"],
foreignKeys = [
ForeignKey(
entity = Highlight::class,
parentColumns = arrayOf("highlightId"),
childColumns = arrayOf("highlightId"),
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = SavedItem::class,
parentColumns = arrayOf("savedItemId"),
childColumns = arrayOf("savedItemId"),
onDelete = ForeignKey.CASCADE
)
]
)
data class SavedItemAndHighlightCrossRef(
val highlightId: String,
val savedItemId: String
)
@Dao
interface SavedItemAndHighlightCrossRefDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemAndHighlightCrossRef>)
@Query("SELECT savedItemId FROM savedItemAndHighlightCrossRef WHERE highlightId = :highlightId")
fun associatedSavedItemID(highlightId: String): String?
}
data class SavedItemWithLabelsAndHighlights(
@Embedded val savedItem: SavedItem,
@Relation(
parentColumn = "savedItemId",
entityColumn = "savedItemLabelId",
associateBy = Junction(SavedItemAndSavedItemLabelCrossRef::class)
)
val labels: List<SavedItemLabel>,
@Relation(
parentColumn = "savedItemId",
entityColumn = "highlightId",
associateBy = Junction(SavedItemAndHighlightCrossRef::class)
)
val highlights: List<Highlight>
)
@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?
@Update
fun update(highlight: Highlight)
}

View file

@ -5,8 +5,7 @@ import androidx.room.PrimaryKey
@Entity
data class NewsletterEmail(
@PrimaryKey val userID: String,
@PrimaryKey val newsletterEmailId: String?,
val confirmationCode: String?,
val email: String?,
val emailID: String?
val email: String?
)

View file

@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
@Entity
data class RecentSearchItem(
@PrimaryKey val id: String,
@PrimaryKey val recentSearchItemId: String,
val savedAt: String?,
val term: String?
)

View file

@ -5,7 +5,7 @@ import androidx.room.PrimaryKey
@Entity
data class RecommendationGroup(
@PrimaryKey val id: String,
@PrimaryKey val recommendationGroupId: String,
val name: String?,
val canPost: Boolean,
val canSeeMembers: Boolean,

View file

@ -1,19 +1,20 @@
package app.omnivore.omnivore.persistence.entities
import androidx.core.net.toUri
import androidx.lifecycle.LiveData
import androidx.room.*
import app.omnivore.omnivore.persistence.BaseDao
import java.util.*
@Entity
data class SavedItem(
@PrimaryKey val id: String,
@PrimaryKey val savedItemId: String,
val title: String,
val createdAt: String,
val savedAt: String,
val readAt: String?,
val updatedAt: String?,
val readingProgress: Double,
val readingProgressAnchor: Int,
var readingProgress: Double,
var readingProgressAnchor: Int,
val imageURLString: String?,
val pageURLString: String,
val descriptionText: String?,
@ -22,7 +23,7 @@ data class SavedItem(
val author: String?,
val publishDate: String?,
val slug: String,
val isArchived: Boolean,
var isArchived: Boolean,
val contentReader: String? = null,
val content: String? = null,
val createdId: String? = null,
@ -35,7 +36,7 @@ data class SavedItem(
val onDeviceImageURLString: String? = null,
val originalHtml: String? = null,
@ColumnInfo(typeAffinity = ColumnInfo.BLOB) val pdfData: ByteArray? = null,
val serverSyncStatus: Int = 0, // TODO: implement,
var serverSyncStatus: Int = 0,
val tempPDFURL: String? = null
// hasMany highlights
@ -46,43 +47,24 @@ data class SavedItem(
return publisherURLString?.toUri()?.host
}
fun isPDF(): Boolean {
val hasPDFSuffix = pageURLString.endsWith("pdf")
return contentReader == "PDF" || hasPDFSuffix
}
fun asSavedItemCardData(): SavedItemCardData {
return SavedItemCardData(
id = id,
slug = slug,
publisherURLString = publisherURLString,
title = title,
author = author,
imageURLString = imageURLString,
isArchived = isArchived,
pageURLString = pageURLString,
contentReader = contentReader,
)
}
override fun equals(other: Any?): Boolean {
if (this === other) return true
if (javaClass != other?.javaClass) return false
other as SavedItem
if (id != other.id) return false
if (savedItemId != other.savedItemId) return false
return true
}
override fun hashCode(): Int {
return id.hashCode()
return savedItemId.hashCode()
}
}
data class SavedItemCardData(
val id: String,
val savedItemId: String,
val slug: String,
val publisherURLString: String?,
val title: String,
@ -104,12 +86,34 @@ data class SavedItemCardData(
@Dao
interface SavedItemDao {
@Query("SELECT id, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader FROM SavedItem")
fun getLibraryData(): List<SavedItemCardData>
@Query("SELECT savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader FROM SavedItem ORDER BY savedAt DESC")
fun getLibraryLiveData(): LiveData<List<SavedItemCardData>>
@Query("SELECT * FROM savedItem")
fun getAll(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE savedItemId = :itemID")
fun findById(itemID: String): SavedItem?
@Query("SELECT * FROM savedItem WHERE serverSyncStatus != 0")
fun getUnSynced(): List<SavedItem>
@Query("SELECT * FROM savedItem WHERE slug = :slug")
fun getSavedItemWithLabelsAndHighlights(slug: String): SavedItemWithLabelsAndHighlights?
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItem>)
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insert(item: SavedItem)
@Query("DELETE FROM savedItem WHERE savedItemId = :itemID")
fun deleteById(itemID: String)
@Update
fun update(savedItem: SavedItem)
@Transaction
@Query("SELECT savedItemId, slug, publisherURLString, title, author, imageURLString, isArchived, pageURLString, contentReader FROM SavedItem ORDER BY savedAt DESC")
fun getLibraryLiveDataWithLabels(): LiveData<List<SavedItemCardDataWithLabels>>
}

View file

@ -1,11 +1,10 @@
package app.omnivore.omnivore.persistence.entities
import androidx.room.Entity
import androidx.room.PrimaryKey
import androidx.room.*
@Entity
data class SavedItemLabel(
@PrimaryKey val id: String,
@PrimaryKey val savedItemLabelId: String,
val name: String,
val color: String,
val createdAt: String?,
@ -13,5 +12,59 @@ data class SavedItemLabel(
val serverSyncStatus: Int = 0
)
@Dao
interface SavedItemLabelDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemLabel>)
}
@Entity(
primaryKeys = ["savedItemLabelId", "savedItemId"],
foreignKeys = [
ForeignKey(
entity = SavedItem::class,
parentColumns = arrayOf("savedItemId"),
childColumns = arrayOf("savedItemId"),
onDelete = ForeignKey.CASCADE
),
ForeignKey(
entity = SavedItemLabel::class,
parentColumns = arrayOf("savedItemLabelId"),
childColumns = arrayOf("savedItemLabelId"),
onDelete = ForeignKey.CASCADE
)
]
)
data class SavedItemAndSavedItemLabelCrossRef(
val savedItemLabelId: String,
val savedItemId: String
)
data class SavedItemWithLabels(
@Embedded val savedItem: SavedItem,
@Relation(
parentColumn = "savedItemId",
entityColumn = "savedItemLabelId",
associateBy = Junction(SavedItemAndSavedItemLabelCrossRef::class)
)
val labels: List<SavedItemLabel>
)
data class SavedItemCardDataWithLabels(
@Embedded val cardData: SavedItemCardData,
@Relation(
parentColumn = "savedItemId",
entityColumn = "savedItemLabelId",
associateBy = Junction(SavedItemAndSavedItemLabelCrossRef::class)
)
val labels: List<SavedItemLabel>
)
@Dao
interface SavedItemAndSavedItemLabelCrossRefDao {
@Insert(onConflict = OnConflictStrategy.REPLACE)
fun insertAll(items: List<SavedItemAndSavedItemLabelCrossRef>)
}
// has many highlights
// has many savedItems

View file

@ -18,10 +18,12 @@ import androidx.compose.ui.Alignment
import androidx.compose.ui.Modifier
import androidx.compose.ui.platform.LocalContext
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.persistence.entities.SavedItemAndSavedItemLabelCrossRef
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import app.omnivore.omnivore.ui.savedItemViews.SavedItemCard
import app.omnivore.omnivore.ui.reader.PDFReaderActivity
import kotlinx.coroutines.flow.distinctUntilChanged
@ -71,7 +73,9 @@ fun LibraryViewContent(
onRefresh = { libraryViewModel.refresh() }
)
val cardsData: List<SavedItemCardData> by libraryViewModel.itemsLiveData.observeAsState(listOf())
val cardsData: List<SavedItemCardDataWithLabels> by libraryViewModel.itemsLiveData.observeAsState(listOf())
val searchedCardsData: List<SavedItemCardDataWithLabels> by libraryViewModel.searchItemsLiveData.observeAsState(listOf())
val searchText: String by libraryViewModel.searchTextLiveData.observeAsState("")
Box(
modifier = Modifier
@ -87,19 +91,19 @@ fun LibraryViewContent(
.fillMaxSize()
.padding(horizontal = 6.dp)
) {
items(cardsData) { cardData ->
items(if (searchText.isNotEmpty()) searchedCardsData else cardsData) { cardDataWithLabels ->
SavedItemCard(
cardData = cardData,
cardData = cardDataWithLabels.cardData,
onClickHandler = {
if (cardData.isPDF()) {
if (cardDataWithLabels.cardData.isPDF()) {
val intent = Intent(context, PDFReaderActivity::class.java)
intent.putExtra("SAVED_ITEM_SLUG", cardData.slug)
intent.putExtra("SAVED_ITEM_SLUG", cardDataWithLabels.cardData.slug)
context.startActivity(intent)
} else {
navController.navigate("WebReader/${cardData.slug}")
navController.navigate("WebReader/${cardDataWithLabels.cardData.slug}")
}
},
actionHandler = { libraryViewModel.handleSavedItemAction(cardData.id, it) }
actionHandler = { libraryViewModel.handleSavedItemAction(cardDataWithLabels.cardData.savedItemId, it) }
)
}
}

View file

@ -7,24 +7,23 @@ import androidx.compose.runtime.setValue
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.DataService
import app.omnivore.omnivore.*
import app.omnivore.omnivore.dataService.*
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.persistence.entities.SavedItemCardData
import app.omnivore.omnivore.persistence.entities.SavedItemCardDataWithLabels
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.withContext
import kotlinx.coroutines.*
import java.time.LocalDateTime
import javax.inject.Inject
@HiltViewModel
class LibraryViewModel @Inject constructor(
private val networker: Networker,
private val dataService: DataService
private val dataService: DataService,
private val datastoreRepo: DatastoreRepository
): ViewModel() {
private var cursor: String? = null
private var items: List<SavedItemCardData> = listOf()
private var searchedItems: List<SavedItemCardData> = listOf()
// These are used to make sure we handle search result
// responses in the right order
@ -33,14 +32,16 @@ class LibraryViewModel @Inject constructor(
// Live Data
val searchTextLiveData = MutableLiveData("")
val itemsLiveData = MutableLiveData<List<SavedItemCardData>>(listOf())
val searchItemsLiveData = MutableLiveData<List<SavedItemCardDataWithLabels>>(listOf())
val itemsLiveData = dataService.db.savedItemDao().getLibraryLiveDataWithLabels()
var isRefreshing by mutableStateOf(false)
fun updateSearchText(text: String) {
searchTextLiveData.value = text
if (text == "") {
itemsLiveData.value = items
searchItemsLiveData.value = listOf()
} else {
load(clearPreviousSearch = true)
}
@ -51,96 +52,113 @@ class LibraryViewModel @Inject constructor(
load(true)
}
fun getLastSyncTime(): LocalDateTime? = runBlocking {
datastoreRepo.getString(DatastoreKeys.libraryLastSyncTimestamp)?.let {
LocalDateTime.parse(it)
}
}
fun load(clearPreviousSearch: Boolean = false) {
viewModelScope.launch {
if (searchTextLiveData.value != "") {
performSearch(clearPreviousSearch)
} else {
syncItems()
}
}
}
private suspend fun syncItems() {
val syncStart = LocalDateTime.now()
val lastSyncDate = getLastSyncTime() ?: LocalDateTime.MIN
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
withContext(Dispatchers.IO) {
performItemSync(cursor = null, since = lastSyncDate.toString(), count = 0, startTime = syncStart.toString())
}
}
private suspend fun performItemSync(cursor: String?, since: String, count: Int, startTime: String, isInitialBatch: Boolean = true) {
dataService.syncOfflineItemsWithServerIfNeeded()
val result = dataService.sync(since = since, cursor = cursor, limit = 20)
// Fetch content for the initial batch only
if (isInitialBatch) {
for (slug in result.savedItemSlugs) {
dataService.syncSavedItemContent(slug)
}
}
val totalCount = count + result.count
Log.d("sync", "fetched ${result.count} items")
if (!result.hasError && result.hasMoreItems && result.cursor != null) {
performItemSync(
cursor = result.cursor,
since = since,
count = totalCount,
startTime = startTime,
isInitialBatch = false
)
} else {
datastoreRepo.putString(DatastoreKeys.libraryLastSyncTimestamp, startTime)
}
}
private suspend fun performSearch(clearPreviousSearch: Boolean) {
if (clearPreviousSearch) {
cursor = null
}
viewModelScope.launch {
val thisSearchIdx = searchIdx
searchIdx += 1
val thisSearchIdx = searchIdx
searchIdx += 1
// Execute the search
val searchResult =
if (searchTextLiveData.value != "") {
networker.typeaheadSearch(searchTextLiveData.value ?: "")
} else {
networker.search(cursor = cursor, query = searchQuery())
}
// Execute the search
val searchResult = networker.typeaheadSearch(searchTextLiveData.value ?: "")
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
// For example if a user types 'Canucks', often the search results
// for 'C' are returned after 'Canucks' because it takes the backend
// much longer to compute.
if (thisSearchIdx in 1..receivedIdx) {
return@launch
}
// Search results aren't guaranteed to return in order so this
// will discard old results that are returned while a user is typing.
// For example if a user types 'Canucks', often the search results
// for 'C' are returned after 'Canucks' because it takes the backend
// much longer to compute.
if (thisSearchIdx in 1..receivedIdx) {
return
}
receivedIdx = thisSearchIdx
cursor = searchResult.cursor
val cardsDataWithLabels = searchResult.cardsData.map {
SavedItemCardDataWithLabels(cardData = it, labels = listOf())
}
if (searchTextLiveData.value != "" || clearPreviousSearch) {
val previousItems = if (clearPreviousSearch) listOf() else searchedItems
searchedItems = previousItems.plus(searchResult.cardsData)
itemsLiveData.postValue(searchedItems)
} else {
items = items.plus(searchResult.cardsData)
itemsLiveData.postValue(items)
}
searchItemsLiveData.postValue(cardsDataWithLabels)
// withContext(Dispatchers.IO) {
// dataService.db.savedItemDao().insertAll(items)
// val items = dataService.db.savedItemDao().getLibraryData()
// Log.d("appDatabase", "libraryData: $items")
// }
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
CoroutineScope(Dispatchers.Main).launch {
isRefreshing = false
}
}
fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
removeItemFromList(itemID)
viewModelScope.launch {
networker.deleteSavedItem(itemID)
dataService.deleteSavedItem(itemID)
}
}
SavedItemAction.Archive -> {
removeItemFromList(itemID)
viewModelScope.launch {
networker.archiveSavedItem(itemID)
dataService.archiveSavedItem(itemID)
}
}
SavedItemAction.Unarchive -> {
removeItemFromList(itemID)
viewModelScope.launch {
networker.unarchiveSavedItem(itemID)
dataService.unarchiveSavedItem(itemID)
}
}
}
}
private fun removeItemFromList(itemID: String) {
itemsLiveData.value?.let {
val newList = it.filter { item -> item.id != itemID }
itemsLiveData.postValue(newList)
}
}
private fun searchQuery(): String {
var query = "in:inbox sort:saved"
if (searchTextLiveData.value != "") {
query = query.plus(" ${searchTextLiveData.value}")
}
return query
}
}
enum class SavedItemAction {

View file

@ -92,7 +92,7 @@ class PDFReaderViewModel @Inject constructor(
currentReadingProgress = percent
viewModelScope.launch {
val params = ReadingProgressParams(
id = pdfReaderParamsLiveData.value?.item?.id,
id = pdfReaderParamsLiveData.value?.item?.savedItemId,
readingProgressPercent = percent,
readingProgressAnchorIndex = currentPageIndex
)
@ -102,7 +102,7 @@ class PDFReaderViewModel @Inject constructor(
}
fun syncHighlightUpdates(newAnnotation: Annotation, quote: String, overlapIds: List<String>, note: String? = null) {
val itemID = pdfReaderParamsLiveData.value?.item?.id ?: return
val itemID = pdfReaderParamsLiveData.value?.item?.savedItemId ?: return
val highlightID = UUID.randomUUID().toString()
val shortID = UUID.randomUUID().toString().replace("-","").substring(0,8)

View file

@ -1,45 +0,0 @@
package app.omnivore.omnivore.ui.reader
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.CookieManager
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.BuildConfig
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun ArticleWebView(slug: String, authCookieString: String) {
WebView.setWebContentsDebuggingEnabled(true)
val url = BuildConfig.OMNIVORE_WEB_URL + "/app/me/$slug"
AndroidView(factory = {
WebView(it).apply {
layoutParams = ViewGroup.LayoutParams(
ViewGroup.LayoutParams.MATCH_PARENT,
ViewGroup.LayoutParams.MATCH_PARENT
)
settings.javaScriptEnabled = true
settings.allowContentAccess = true
settings.allowFileAccess = true
settings.domStorageEnabled = true
webViewClient = object : WebViewClient() {
}
CookieManager.getInstance().setAcceptThirdPartyCookies(this, true)
CookieManager.getInstance().setAcceptCookie(true)
CookieManager.getInstance().setCookie(BuildConfig.OMNIVORE_API_URL, authCookieString)
CookieManager.getInstance().setCookie(BuildConfig.OMNIVORE_WEB_URL, authCookieString) {
loadUrl(url)
}
}
}, update = {
it.loadUrl(url)
})
}

View file

@ -10,11 +10,14 @@ import android.view.*
import android.webkit.JavascriptInterface
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.activity.compose.LocalOnBackPressedDispatcherOwner
import androidx.compose.foundation.isSystemInDarkTheme
import androidx.compose.foundation.layout.*
import androidx.compose.foundation.verticalScroll
import androidx.compose.material.ModalBottomSheetValue
import androidx.compose.material.TopAppBar
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Menu
import androidx.compose.material.icons.filled.Settings
import androidx.compose.material3.*
import androidx.compose.runtime.*
@ -29,6 +32,7 @@ import androidx.compose.ui.platform.LocalDensity
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import app.omnivore.omnivore.R
import app.omnivore.omnivore.ui.save.SaveSheetActivityBase
import app.omnivore.omnivore.ui.savedItemViews.SavedItemContextMenu
import com.google.gson.Gson
import kotlinx.coroutines.CoroutineScope
@ -40,11 +44,14 @@ import kotlin.math.roundToInt
@Composable
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
val onBackPressedDispatcher = LocalOnBackPressedDispatcherOwner.current?.onBackPressedDispatcher
var isMenuExpanded by remember { mutableStateOf(false) }
var showWebPreferencesDialog by remember { mutableStateOf(false ) }
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null)
val shouldPopView: Boolean by webReaderViewModel.shouldPopViewLiveData.observeAsState(false)
val maxToolbarHeight = 48.dp
val maxToolbarHeightPx = with(LocalDensity.current) { maxToolbarHeight.roundToPx().toFloat() }
@ -97,12 +104,12 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
title = {},
actions = {
// Disabling menu until we implement local persistence
// IconButton(onClick = { isMenuExpanded = true }) {
// Icon(
// imageVector = Icons.Filled.Menu,
// contentDescription = null
// )
// }
IconButton(onClick = { isMenuExpanded = true }) {
Icon(
imageVector = Icons.Filled.Menu,
contentDescription = null
)
}
IconButton(onClick = { showWebPreferencesDialog = true }) {
Icon(
imageVector = Icons.Filled.Settings, // TODO: set a better icon
@ -113,7 +120,7 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
isExpanded = isMenuExpanded,
isArchived = webReaderParams!!.item.isArchived,
onDismiss = { isMenuExpanded = false },
actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.id, it) }
actionHandler = { webReaderViewModel.handleSavedItemAction(webReaderParams!!.item.savedItemId, it) }
)
}
)
@ -139,6 +146,12 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod
)
}
}
LaunchedEffect(shouldPopView) {
if (shouldPopView) {
onBackPressedDispatcher?.onBackPressed()
}
}
} else {
// TODO: add a proper loading view
Text("Loading...")

View file

@ -77,8 +77,8 @@ data class WebReaderContent(
}
window.omnivoreArticle = {
id: "${item.id}",
linkId: "${item.id}",
id: "${item.savedItemId}",
linkId: "${item.savedItemId}",
slug: "${item.slug}",
createdAt: new Date(1662571290735.0).toISOString(),
savedAt: new Date(1662571290981.0).toISOString(),

View file

@ -7,15 +7,13 @@ import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.dataService.*
import app.omnivore.omnivore.persistence.entities.SavedItem
import app.omnivore.omnivore.networking.*
import app.omnivore.omnivore.ui.library.SavedItemAction
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.CoroutineScope
import kotlinx.coroutines.Dispatchers
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import kotlinx.coroutines.*
import java.util.*
import javax.inject.Inject
@ -31,6 +29,7 @@ data class AnnotationWebViewMessage(
@HiltViewModel
class WebReaderViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository,
private val dataService: DataService,
private val networker: Networker
): ViewModel() {
var lastJavascriptActionLoopUUID: UUID = UUID.randomUUID()
@ -41,56 +40,87 @@ class WebReaderViewModel @Inject constructor(
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
val annotationLiveData = MutableLiveData<String?>(null)
val javascriptActionLoopUUIDLiveData = MutableLiveData(lastJavascriptActionLoopUUID)
val shouldPopViewLiveData = MutableLiveData<Boolean>(false)
var hasTappedExistingHighlight = false
var lastTapCoordinates: TapCoordinates? = null
fun loadItem(slug: String) {
viewModelScope.launch {
val articleQueryResult = networker.savedItem(slug)
val webReaderParams = loadItemFromServer(slug)
val article = articleQueryResult.item ?: return@launch
val articleContent = ArticleContent(
title = article.title,
htmlContent = article.content ?: "",
highlights = articleQueryResult.highlights,
contentStatus = "SUCCEEDED",
objectID = "",
labelsJSONString = Gson().toJson(articleQueryResult.labels)
)
webReaderParamsLiveData.value = WebReaderParams(article, articleContent)
if (webReaderParams != null) {
Log.d("sync", "data loaded from server")
webReaderParamsLiveData.postValue(webReaderParams)
} else {
loadItemFromDB(slug)
}
}
}
private suspend fun loadItemFromDB(slug: String) {
withContext(Dispatchers.IO) {
val persistedItem = dataService.db.savedItemDao().getSavedItemWithLabelsAndHighlights(slug)
if (persistedItem?.savedItem?.content != null) {
val articleContent = ArticleContent(
title = persistedItem.savedItem.title,
htmlContent = persistedItem.savedItem.content,
highlights = persistedItem.highlights,
contentStatus = "SUCCEEDED",
objectID = "",
labelsJSONString = Gson().toJson(persistedItem.labels)
)
Log.d("sync", "data loaded from db")
webReaderParamsLiveData.postValue(WebReaderParams(persistedItem.savedItem, articleContent))
}
}
}
private suspend fun loadItemFromServer(slug: String): WebReaderParams? {
val articleQueryResult = networker.savedItem(slug)
val article = articleQueryResult.item ?: return null
val articleContent = ArticleContent(
title = article.title,
htmlContent = article.content ?: "",
highlights = articleQueryResult.highlights,
contentStatus = "SUCCEEDED",
objectID = "",
labelsJSONString = Gson().toJson(articleQueryResult.labels)
)
return WebReaderParams(article, articleContent)
}
fun handleSavedItemAction(itemID: String, action: SavedItemAction) {
when (action) {
SavedItemAction.Delete -> {
viewModelScope.launch {
networker.deleteSavedItem(itemID)
popToLibraryView(itemID)
dataService.deleteSavedItem(itemID)
popToLibraryView()
}
}
SavedItemAction.Archive -> {
viewModelScope.launch {
networker.archiveSavedItem(itemID)
popToLibraryView(itemID)
dataService.archiveSavedItem(itemID)
popToLibraryView()
}
}
SavedItemAction.Unarchive -> {
viewModelScope.launch {
networker.unarchiveSavedItem(itemID)
popToLibraryView(itemID)
dataService.unarchiveSavedItem(itemID)
popToLibraryView()
}
}
}
}
private fun popToLibraryView(itemID: String) {
private fun popToLibraryView() {
CoroutineScope(Dispatchers.Main).launch {
// TODO: pop to library
Log.d("maxx", "should pop to library and remove item with ID: $itemID")
shouldPopViewLiveData.postValue(true)
}
}
@ -98,28 +128,24 @@ class WebReaderViewModel @Inject constructor(
when (actionID) {
"createHighlight" -> {
viewModelScope.launch {
val isHighlightSynced = networker.createWebHighlight(jsonString)
Log.d("Network", "isHighlightSynced = $isHighlightSynced")
dataService.createWebHighlight(jsonString)
}
}
"deleteHighlight" -> {
Log.d("Loggo", "receive delete highlight action: $jsonString")
viewModelScope.launch {
val isHighlightDeletionSynced = networker.deleteHighlight(jsonString)
Log.d("Network", "isHighlightDeletionSynced = $isHighlightDeletionSynced")
dataService.deleteHighlights(jsonString)
}
}
"updateHighlight" -> {
Log.d("Loggo", "receive update highlight action: $jsonString")
viewModelScope.launch {
val isHighlightUpdateSynced = networker.updateWebHighlight(jsonString)
Log.d("Network", "isHighlightUpdateSynced = $isHighlightUpdateSynced")
dataService.updateWebHighlight(jsonString)
}
}
"articleReadingProgress" -> {
viewModelScope.launch {
val isReadingProgressSynced = networker.updateWebReadingProgress(jsonString)
Log.d("Network", "isReadingProgressSynced = $isReadingProgressSynced")
dataService.updateWebReadingProgress(jsonString)
}
}
"annotate" -> {
@ -135,8 +161,7 @@ class WebReaderViewModel @Inject constructor(
}
"mergeHighlight" -> {
viewModelScope.launch {
val isHighlightSynced = networker.mergeWebHighlights(jsonString)
Log.d("Network", "isMergedHighlightSynced = $isHighlightSynced")
dataService.mergeWebHighlights(jsonString)
}
}
else -> {
@ -146,6 +171,7 @@ class WebReaderViewModel @Inject constructor(
}
fun reset() {
shouldPopViewLiveData.postValue(false)
webReaderParamsLiveData.value = null
annotationLiveData.value = null
scrollState = ScrollState(0)

View file

@ -79,14 +79,6 @@ fun PrimaryNavigator(
)
}
// TODO: delete this route and views
composable("WebAppReader/{slug}") {
ArticleWebView(
it.arguments?.getString("slug") ?: "",
authCookieString = loginViewModel.getAuthCookieString() ?: ""
)
}
composable("WebReader/{slug}") {
webReaderViewModel.reset() // clear previously loaded item

File diff suppressed because one or more lines are too long