diff --git a/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql b/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql new file mode 100644 index 000000000..d4417067d --- /dev/null +++ b/android/Omnivore/app/src/main/graphql/CreateHighlight.graphql @@ -0,0 +1,13 @@ +mutation CreateHighlight($input: CreateHighlightInput!) { + createHighlight(input: $input) { + ... on CreateHighlightSuccess { + highlight { + ...HighlightFields + } + } + + ... on CreateHighlightError { + errorCodes + } + } +} diff --git a/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql b/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql new file mode 100644 index 000000000..e61011a3e --- /dev/null +++ b/android/Omnivore/app/src/main/graphql/DeleteHighlight.graphql @@ -0,0 +1,12 @@ +mutation DeleteHighlight($highlightId: ID!) { + deleteHighlight(highlightId: $highlightId) { + ... on DeleteHighlightSuccess { + highlight { + id + } + } + ... on DeleteHighlightError { + errorCodes + } + } +} diff --git a/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql b/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql new file mode 100644 index 000000000..80a30eea1 --- /dev/null +++ b/android/Omnivore/app/src/main/graphql/ReadingProgressMutation.graphql @@ -0,0 +1,14 @@ +mutation SaveArticleReadingProgress($input: SaveArticleReadingProgressInput!) { + saveArticleReadingProgress(input: $input) { + ... on SaveArticleReadingProgressSuccess { + updatedArticle { + id + readingProgressPercent + readingProgressAnchorIndex + } + } + ... on SaveArticleReadingProgressError { + errorCodes + } + } +} diff --git a/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql b/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql new file mode 100644 index 000000000..18f93dcea --- /dev/null +++ b/android/Omnivore/app/src/main/graphql/UpdateHighlight.graphql @@ -0,0 +1,13 @@ +mutation UpdateHighlight($input: UpdateHighlightInput!) { + updateHighlight(input: $input) { + ... on UpdateHighlightSuccess { + highlight { + id + } + } + + ... on UpdateHighlightError { + errorCodes + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt new file mode 100644 index 000000000..8c418d63e --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/HighlightMutations.kt @@ -0,0 +1,36 @@ +package app.omnivore.omnivore.networking + +import android.util.Log +import app.omnivore.omnivore.graphql.generated.CreateHighlightMutation +import app.omnivore.omnivore.graphql.generated.type.CreateHighlightInput +import com.apollographql.apollo3.api.Optional +import com.google.gson.Gson + +data class CreateHighlightParams( + val shortId: String?, + val highlightID: String?, + val quote: String?, + val patch: String?, + val articleId: String?, + val `annotation`: String? +) { + fun asCreateHighlightInput() = CreateHighlightInput( + annotation = Optional.presentIfNotNull(`annotation`), + articleId = articleId ?: "", + id = highlightID ?: "", + patch = patch ?: "", + quote = quote ?: "", + shortId = shortId ?: "" + ) +} + +suspend fun Networker.createHighlight(jsonString: String): Boolean { + val input = Gson().fromJson(jsonString, CreateHighlightParams::class.java).asCreateHighlightInput() + + Log.d("Loggo", "created highlight input: $input") + + val result = authenticatedApolloClient().mutation(CreateHighlightMutation(input)).execute() + + val highlight = result.data?.createHighlight?.onCreateHighlightSuccess?.highlight + return highlight != null +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/ReadingProgressMutations.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/ReadingProgressMutations.kt new file mode 100644 index 000000000..f6670efe3 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/ReadingProgressMutations.kt @@ -0,0 +1,36 @@ +package app.omnivore.omnivore.networking + +import app.omnivore.omnivore.graphql.generated.SaveArticleReadingProgressMutation +import app.omnivore.omnivore.graphql.generated.type.SaveArticleReadingProgressInput + + +import android.util.Log +import com.google.gson.Gson + +data class ReadingProgressParams( + val id: String?, + val readingProgressPercent: Double?, + val readingProgressAnchorIndex: Int? +) { + fun asSaveReadingProgressInput() = SaveArticleReadingProgressInput( + id = id ?: "", + readingProgressPercent = readingProgressPercent ?: 0.0, + readingProgressAnchorIndex = readingProgressAnchorIndex ?: 0 + ) +} + +suspend fun Networker.updateReadingProgress(jsonString: String): Boolean { + val input = Gson().fromJson(jsonString, ReadingProgressParams::class.java).asSaveReadingProgressInput() + + Log.d("Loggo", "created reading progress input: $input") + + val result = authenticatedApolloClient() + .mutation(SaveArticleReadingProgressMutation(input)) + .execute() + + val articleID = result.data?.saveArticleReadingProgress?.onSaveArticleReadingProgressSuccess?.updatedArticle?.id + + Log.d("Loggo", "updated article with id: $articleID") + + return articleID != null +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt index a658a3aea..7086c123e 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/networking/SearchQuery.kt @@ -22,7 +22,7 @@ suspend fun Networker.search( ) ).execute() - val cursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor + val newCursor = result.data?.search?.onSearchSuccess?.pageInfo?.endCursor val itemList = result.data?.search?.onSearchSuccess?.edges ?: listOf() val items = itemList.map { @@ -49,5 +49,5 @@ suspend fun Networker.search( ) } - return SearchQueryResponse(cursor, items) + return SearchQueryResponse(newCursor, items) } diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/AnnotationEditView.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/AnnotationEditView.kt new file mode 100644 index 000000000..e308250e2 --- /dev/null +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/AnnotationEditView.kt @@ -0,0 +1,68 @@ +package app.omnivore.omnivore.ui.reader + +import androidx.compose.foundation.background +import androidx.compose.foundation.layout.* +import androidx.compose.foundation.shape.RoundedCornerShape +import androidx.compose.material3.* +import androidx.compose.runtime.Composable +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.ui.Alignment +import androidx.compose.ui.Modifier +import androidx.compose.ui.draw.clip +import androidx.compose.ui.unit.dp + +// TODO: better layout and styling for this view +@OptIn(ExperimentalMaterial3Api::class) +@Composable +fun AnnotationEditView( + initialAnnotation: String, + onSave: (String) -> Unit, + onCancel: () -> Unit, +) { + val annotation = remember { mutableStateOf(initialAnnotation) } + + Column( + modifier = Modifier + .clip(RoundedCornerShape(4.dp)) + .background(MaterialTheme.colorScheme.background) + .padding(8.dp), + ) { + Column( + modifier = Modifier.padding(16.dp), + ) { + Text(text = "Note") + + Spacer(modifier = Modifier.height(8.dp)) + + TextField( + value = annotation.value, + onValueChange = { annotation.value = it } + ) + } + + Spacer(modifier = Modifier.height(8.dp)) + + Row( + modifier = Modifier.align(Alignment.End) + ) { + Button( + onClick = { + onCancel() + } + ) { + Text("Cancel") + } + + Spacer(modifier = Modifier.width(8.dp)) + + Button( + onClick = { + onSave(annotation.value) + } + ) { + Text("Save") + } + } + } +} diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt index 98401f013..c141a78ef 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReader.kt @@ -8,12 +8,17 @@ import android.view.* import android.webkit.JavascriptInterface import android.webkit.WebView import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Box import androidx.compose.material3.Text import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.livedata.observeAsState +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember import androidx.compose.ui.viewinterop.AndroidView import app.omnivore.omnivore.R +import app.omnivore.omnivore.networking.ReadingProgressParams +import com.google.gson.Gson import org.json.JSONObject @@ -36,6 +41,11 @@ fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewMod @SuppressLint("SetJavaScriptEnabled") @Composable fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) { + // TODO: maybe handle cases where js can be queued up? + val javascriptToExecute = remember { mutableStateOf(null) } + + val annotation: String? by webReaderViewModel.annotationLiveData.observeAsState(null) + WebView.setWebContentsDebuggingEnabled(true) val webReaderContent = WebReaderContent( @@ -51,39 +61,81 @@ fun WebReader(params: WebReaderParams, webReaderViewModel: WebReaderViewModel) { val styledContent = webReaderContent.styledContent() - AndroidView(factory = { - OmnivoreWebView(it).apply { - layoutParams = ViewGroup.LayoutParams( - ViewGroup.LayoutParams.MATCH_PARENT, - ViewGroup.LayoutParams.MATCH_PARENT + Box { + AndroidView(factory = { + OmnivoreWebView(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() { + } + + val javascriptInterface = AndroidWebKitMessenger { actionID, json -> + when (actionID) { + "existingHighlightTap" -> { + isExistingHighlightSelected = true + actionTapCoordinates = Gson().fromJson(json, ActionTapCoordinates::class.java) + Log.d("Loggo", "receive existing highlight tap action: $actionTapCoordinates") + startActionMode(null, ActionMode.TYPE_PRIMARY) + } + else -> { + webReaderViewModel.handleIncomingWebMessage(actionID, json) + } + } + } + + addJavascriptInterface(javascriptInterface, "AndroidWebKitMessenger") + + loadDataWithBaseURL( + "file:///android_asset/", + styledContent, + "text/html; charset=utf-8", + "utf-8", + null + ) + } + }, update = { + if (javascriptToExecute.value != null) { + it.evaluateJavascript(javascriptToExecute.value!!, null) + } + }) + + if (annotation != null) { + AnnotationEditView( + initialAnnotation = annotation!!, + onSave = { + val script = "var event = new Event('saveAnnotation');event.annotation = '$it';document.dispatchEvent(event);" + javascriptToExecute.value = script + webReaderViewModel.cancelAnnotationEdit() + }, + onCancel = { + webReaderViewModel.cancelAnnotationEdit() + } ) - - settings.javaScriptEnabled = true - settings.allowContentAccess = true - settings.allowFileAccess = true - settings.domStorageEnabled = true - - webViewClient = object : WebViewClient() { - } - - val javascriptInterface = AndroidWebKitMessenger { actionID, json -> - webReaderViewModel.handleIncomingWebMessage(actionID, json) - } - - addJavascriptInterface(javascriptInterface, "AndroidWebKitMessenger") - loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null); - } - }, update = { - it.loadDataWithBaseURL("file:///android_asset/", styledContent, "text/html; charset=utf-8", "utf-8", null); - }) + } } class OmnivoreWebView(context: Context) : WebView(context) { + var isExistingHighlightSelected = false + var actionTapCoordinates: ActionTapCoordinates? = null + private val actionModeCallback = object : ActionMode.Callback2() { // Called when the action mode is created; startActionMode() was called override fun onCreateActionMode(mode: ActionMode, menu: Menu): Boolean { - mode.menuInflater.inflate(R.menu.text_selection_menu, menu) + if (isExistingHighlightSelected) { + mode.menuInflater.inflate(R.menu.highlight_selection_menu, menu) + isExistingHighlightSelected = false + } else { + mode.menuInflater.inflate(R.menu.text_selection_menu, menu) + } return true } @@ -97,7 +149,8 @@ class OmnivoreWebView(context: Context) : WebView(context) { override fun onActionItemClicked(mode: ActionMode, item: MenuItem): Boolean { return when (item.itemId) { R.id.annotate -> { - Log.d("Loggo", "Annotate action selected") + val script = "var event = new Event('annotate');document.dispatchEvent(event);" + evaluateJavascript(script, null) mode.finish() true } @@ -108,6 +161,13 @@ class OmnivoreWebView(context: Context) : WebView(context) { mode.finish() true } + R.id.delete -> { + val script = "var event = new Event('remove');document.dispatchEvent(event);" + evaluateJavascript(script, null) + clearFocus() + mode.finish() + true + } else -> { Log.d("Loggo", "${item.itemId} selected") false @@ -117,35 +177,44 @@ class OmnivoreWebView(context: Context) : WebView(context) { // Called when the user exits the action mode override fun onDestroyActionMode(mode: ActionMode) { -// actionMode = null + Log.d("Loggo", "destroying menu: $mode") + isExistingHighlightSelected = false + actionTapCoordinates = null } override fun onGetContentRect(mode: ActionMode?, view: View?, outRect: Rect?) { + Log.d("Loggo", "outRect: $outRect, View: $view") outRect?.set(left, top, right, bottom) } } - private var currentActionModeCallback: ActionMode.Callback? = actionModeCallback - override fun startActionMode(callback: ActionMode.Callback?): ActionMode { - return super.startActionMode(currentActionModeCallback) + return super.startActionMode(actionModeCallback) } override fun startActionModeForChild( originalView: View?, callback: ActionMode.Callback? ): ActionMode { - return super.startActionModeForChild(originalView, currentActionModeCallback) + return super.startActionModeForChild(originalView, actionModeCallback) } override fun startActionMode(callback: ActionMode.Callback?, type: Int): ActionMode { - return super.startActionMode(currentActionModeCallback, type) + Log.d("Loggo", "startActionMode:type called") + return super.startActionMode(actionModeCallback, type) } } -class AndroidWebKitMessenger(val messageHandler: (String, JSONObject) -> Unit) { +class AndroidWebKitMessenger(val messageHandler: (String, String) -> Unit) { @JavascriptInterface fun handleIdentifiableMessage(actionID: String, jsonString: String) { - messageHandler(actionID, JSONObject(jsonString)) + messageHandler(actionID, jsonString) } } + +data class ActionTapCoordinates( + val rectX: Double, + val rectY: Double, + val rectWidth: Double, + val rectHeight: Double, +) diff --git a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt index f05943dcb..cb0908ed5 100644 --- a/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt +++ b/android/Omnivore/app/src/main/java/app/omnivore/omnivore/ui/reader/WebReaderViewModel.kt @@ -6,8 +6,7 @@ import androidx.lifecycle.ViewModel import androidx.lifecycle.viewModelScope import app.omnivore.omnivore.DatastoreRepository import app.omnivore.omnivore.models.LinkedItem -import app.omnivore.omnivore.networking.Networker -import app.omnivore.omnivore.networking.linkedItem +import app.omnivore.omnivore.networking.* import com.google.gson.Gson import dagger.hilt.android.lifecycle.HiltViewModel import kotlinx.coroutines.launch @@ -19,12 +18,17 @@ data class WebReaderParams( val articleContent: ArticleContent ) +data class AnnotationWebViewMessage( + val annotation: String? +) + @HiltViewModel class WebReaderViewModel @Inject constructor( private val datastoreRepo: DatastoreRepository, private val networker: Networker ): ViewModel() { val webReaderParamsLiveData = MutableLiveData(null) + val annotationLiveData = MutableLiveData(null) fun loadItem(slug: String) { viewModelScope.launch { @@ -45,37 +49,50 @@ class WebReaderViewModel @Inject constructor( } } - fun handleIncomingWebMessage(actionID: String, json: JSONObject) { + fun handleIncomingWebMessage(actionID: String, jsonString: String) { when (actionID) { "createHighlight" -> { - Log.d("Loggo", "receive create highlight action: $json") + viewModelScope.launch { + val isHighlightSynced = networker.createHighlight(jsonString) + Log.d("Network", "isHighlightSynced = $isHighlightSynced") + } } "deleteHighlight" -> { // { highlightId } - Log.d("Loggo", "receive delete highlight action: $json") + Log.d("Loggo", "receive delete highlight action: $jsonString") } "updateHighlight" -> { - Log.d("Loggo", "receive update highlight action: $json") + Log.d("Loggo", "receive update highlight action: $jsonString") } "articleReadingProgress" -> { - Log.d("Loggo", "received article reading progress action: $json") + viewModelScope.launch { + val isReadingProgressSynced = networker.updateReadingProgress(jsonString) + Log.d("Network", "isReadingProgressSynced = $isReadingProgressSynced") + } } "annotate" -> { - Log.d("Loggo", "received annotate action: $json") - } - "existingHighlightTap" -> { - Log.d("Loggo", "receive existing highlight tap action: $json") + viewModelScope.launch { + val annotation = Gson() + .fromJson(jsonString, AnnotationWebViewMessage::class.java) + .annotation ?: "" + annotationLiveData.value = annotation + } } "shareHighlight" -> { // unimplemented } else -> { - Log.d("Loggo", "receive unrecognized action of $actionID with json: $json") + Log.d("Loggo", "receive unrecognized action of $actionID with json: $jsonString") } } } fun reset() { webReaderParamsLiveData.value = null + annotationLiveData.value = null + } + + fun cancelAnnotationEdit() { + annotationLiveData.value = null } } diff --git a/android/Omnivore/app/src/main/res/menu/highlight_selection_menu.xml b/android/Omnivore/app/src/main/res/menu/highlight_selection_menu.xml new file mode 100644 index 000000000..4cc6d8400 --- /dev/null +++ b/android/Omnivore/app/src/main/res/menu/highlight_selection_menu.xml @@ -0,0 +1,15 @@ + + + + + + + + diff --git a/android/Omnivore/app/src/main/res/values/strings.xml b/android/Omnivore/app/src/main/res/values/strings.xml index 7bb14ba15..b6ed5d009 100644 --- a/android/Omnivore/app/src/main/res/values/strings.xml +++ b/android/Omnivore/app/src/main/res/values/strings.xml @@ -5,4 +5,5 @@ Save articles and read them later in our distraction-free reader. Highlight Annotate + Delete