Merge pull request #1245 from omnivore-app/android/web-action-data-mutations

Web Action Mutations - Android
This commit is contained in:
Satindar Dhillon 2022-09-28 22:11:08 -07:00 committed by GitHub
commit c6dde4f6f8
No known key found for this signature in database
GPG key ID: 4AEE18F83AFDEB23
12 changed files with 342 additions and 48 deletions

View file

@ -0,0 +1,13 @@
mutation CreateHighlight($input: CreateHighlightInput!) {
createHighlight(input: $input) {
... on CreateHighlightSuccess {
highlight {
...HighlightFields
}
}
... on CreateHighlightError {
errorCodes
}
}
}

View file

@ -0,0 +1,12 @@
mutation DeleteHighlight($highlightId: ID!) {
deleteHighlight(highlightId: $highlightId) {
... on DeleteHighlightSuccess {
highlight {
id
}
}
... on DeleteHighlightError {
errorCodes
}
}
}

View file

@ -0,0 +1,14 @@
mutation SaveArticleReadingProgress($input: SaveArticleReadingProgressInput!) {
saveArticleReadingProgress(input: $input) {
... on SaveArticleReadingProgressSuccess {
updatedArticle {
id
readingProgressPercent
readingProgressAnchorIndex
}
}
... on SaveArticleReadingProgressError {
errorCodes
}
}
}

View file

@ -0,0 +1,13 @@
mutation UpdateHighlight($input: UpdateHighlightInput!) {
updateHighlight(input: $input) {
... on UpdateHighlightSuccess {
highlight {
id
}
}
... on UpdateHighlightError {
errorCodes
}
}
}

View file

@ -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
}

View file

@ -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
}

View file

@ -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)
}

View file

@ -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")
}
}
}
}

View file

@ -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<String?>(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,
)

View file

@ -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<WebReaderParams?>(null)
val annotationLiveData = MutableLiveData<String?>(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
}
}

View file

@ -0,0 +1,15 @@
<?xml version="1.0" encoding="utf-8"?>
<menu xmlns:app="http://schemas.android.com/apk/res-auto"
xmlns:android="http://schemas.android.com/apk/res/android">
<item
android:id="@+id/delete"
android:title="@string/delete_highlight_menu_title"
app:showAsAction="always">
</item>
<item
android:id="@+id/annotate"
android:title="@string/annotate_menu_action"
app:showAsAction="always">
</item>
</menu>

View file

@ -5,4 +5,5 @@
<string name="welcome_subtitle">Save articles and read them later in our distraction-free reader.</string>
<string name="highlight_menu_action">Highlight</string>
<string name="annotate_menu_action">Annotate</string>
<string name="delete_highlight_menu_title">Delete</string>
</resources>