Merge pull request #1216 from omnivore-app/feature/android-bundled-web-reader

Bundled Web Reader - Android
This commit is contained in:
Satindar Dhillon
2022-09-21 08:34:40 -07:00
committed by GitHub
53 changed files with 466 additions and 37 deletions

View File

@ -153,7 +153,9 @@ dependencies {
implementation 'com.google.android.gms:play-services-auth:20.2.0'
implementation "com.google.accompanist:accompanist-systemuicontroller:0.25.1"
implementation("io.coil-kt:coil-compose:2.2.0")
implementation 'io.coil-kt:coil-compose:2.2.0'
implementation 'com.google.code.gson:gson:2.8.6'
}
apollo {

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

Binary file not shown.

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{color:#ddd;background:#303030}.hljs-keyword,.hljs-link,.hljs-literal,.hljs-section,.hljs-selector-tag{color:#fff}.hljs-addition,.hljs-attribute,.hljs-built_in,.hljs-bullet,.hljs-name,.hljs-string,.hljs-symbol,.hljs-template-tag,.hljs-template-variable,.hljs-title,.hljs-type,.hljs-variable{color:#d88}.hljs-comment,.hljs-deletion,.hljs-meta,.hljs-quote{color:#979797}.hljs-doctag,.hljs-keyword,.hljs-literal,.hljs-name,.hljs-section,.hljs-selector-tag,.hljs-strong,.hljs-title,.hljs-type{font-weight:700}.hljs-emphasis{font-style:italic}

View File

@ -0,0 +1 @@
pre code.hljs{display:block;overflow-x:auto;padding:1em}code.hljs{padding:3px 5px}.hljs{background:#f3f3f3;color:#444}.hljs-comment{color:#697070}.hljs-punctuation,.hljs-tag{color:#444a}.hljs-tag .hljs-attr,.hljs-tag .hljs-name{color:#444}.hljs-attribute,.hljs-doctag,.hljs-keyword,.hljs-meta .hljs-keyword,.hljs-name,.hljs-selector-tag{font-weight:700}.hljs-deletion,.hljs-number,.hljs-quote,.hljs-selector-class,.hljs-selector-id,.hljs-string,.hljs-template-tag,.hljs-type{color:#800}.hljs-section,.hljs-title{color:#800;font-weight:700}.hljs-link,.hljs-operator,.hljs-regexp,.hljs-selector-attr,.hljs-selector-pseudo,.hljs-symbol,.hljs-template-variable,.hljs-variable{color:#ab5656}.hljs-literal{color:#695}.hljs-addition,.hljs-built_in,.hljs-bullet,.hljs-code{color:#397300}.hljs-meta{color:#1f7199}.hljs-meta .hljs-string{color:#38a}.hljs-emphasis{font-style:italic}.hljs-strong{font-weight:700}

View File

@ -0,0 +1,8 @@
MathJax = {
tex: {
inlineMath: [
['$latex', '$'],
['\\(', '\\)'],
],
},
}

File diff suppressed because one or more lines are too long

View File

@ -0,0 +1,64 @@
query GetArticle($slug: String!) {
article(username: "me", slug: $slug) {
... on ArticleSuccess {
article {
...ArticleFields
content
highlights(input: { includeFriends: false }) {
...HighlightFields
}
labels {
...LabelFields
}
}
}
... on ArticleError {
errorCodes
}
}
}
fragment ArticleFields on Article {
id
title
url
author
image
savedAt
createdAt
publishedAt
contentReader
originalArticleUrl
readingProgressPercent
readingProgressAnchorIndex
slug
isArchived
description
linkId
siteName
state
readAt
updatedAt
content
}
fragment HighlightFields on Highlight {
id
shortId
quote
prefix
suffix
patch
annotation
createdByMe
updatedAt
sharedAt
}
fragment LabelFields on Label {
id
name
color
description
createdAt
}

View File

@ -34,6 +34,8 @@ query Search($after: String, $first: Int, $query: String) {
siteName
subscription
readAt
savedAt
updatedAt
}
}
pageInfo {

View File

@ -16,6 +16,7 @@ import androidx.core.view.WindowCompat
import androidx.core.view.WindowInsetsCompat
import app.omnivore.omnivore.ui.auth.LoginViewModel
import app.omnivore.omnivore.ui.home.HomeViewModel
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
import app.omnivore.omnivore.ui.root.RootView
import dagger.hilt.android.AndroidEntryPoint
@ -26,6 +27,7 @@ class MainActivity : ComponentActivity() {
val loginViewModel: LoginViewModel by viewModels()
val homeViewModel: HomeViewModel by viewModels()
val webReaderViewModel: WebReaderViewModel by viewModels()
setContent {
OmnivoreTheme {
@ -34,7 +36,7 @@ class MainActivity : ComponentActivity() {
.fillMaxSize()
.background(color = Color.Black)
) {
RootView(loginViewModel, homeViewModel)
RootView(loginViewModel, homeViewModel, webReaderViewModel)
}
}
}

View File

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

View File

@ -0,0 +1,14 @@
package app.omnivore.omnivore.models
data class Highlight(
val id: String,
val shortId: String,
val quote: String,
val prefix: String?,
val suffix: String?,
val patch: String,
val annotation: String?,
val createdAt: Any?,
val updatedAt: Any?,
val createdByMe : Boolean,
)

View File

@ -0,0 +1,29 @@
package app.omnivore.omnivore.models
import androidx.core.net.toUri
data class LinkedItem(
val id: String,
val title: String,
val createdAt: Any,
val savedAt: Any,
val readAt: Any?,
val updatedAt: Any?,
val readingProgress: Double,
val readingProgressAnchor: Int,
val imageURLString: String?,
val pageURLString: String,
val descriptionText: String?,
val publisherURLString: String?,
val siteName: String?,
val author: String?,
val publishDate: Any?,
val slug: String,
val isArchived: Boolean,
val contentReader: String?,
val content: String?
) {
fun publisherDisplayName(): String? {
return publisherURLString?.toUri()?.host
}
}

View File

@ -0,0 +1,9 @@
package app.omnivore.omnivore.models
data class LinkedItemLabel(
val id: String,
val name: String,
val color: String,
val createdAt: Any?,
val labelDescription: String?,
)

View File

@ -14,6 +14,7 @@ import androidx.compose.ui.Modifier
import androidx.compose.ui.unit.dp
import androidx.navigation.NavHostController
import app.omnivore.omnivore.Routes
import app.omnivore.omnivore.models.LinkedItem
import kotlinx.coroutines.flow.distinctUntilChanged

View File

@ -1,6 +1,5 @@
package app.omnivore.omnivore.ui.home
import androidx.core.net.toUri
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
@ -8,6 +7,7 @@ import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.SearchQuery
import app.omnivore.omnivore.models.LinkedItem
import com.apollographql.apollo3.ApolloClient
import com.apollographql.apollo3.api.Optional
import dagger.hilt.android.lifecycle.HiltViewModel
@ -88,14 +88,22 @@ class HomeViewModel @Inject constructor(
id = it.node.id,
title = it.node.title,
createdAt = it.node.createdAt,
savedAt = it.node.savedAt,
readAt = it.node.readAt,
updatedAt = it.node.updatedAt,
readingProgress = it.node.readingProgressPercent,
readingProgressAnchor = it.node.readingProgressAnchorIndex,
imageURLString = it.node.image,
pageURLString = it.node.url,
descriptionText = it.node.description,
publisherURLString = it.node.originalArticleUrl,
siteName = it.node.siteName,
author = it.node.author,
slug = it.node.slug
publishDate = it.node.publishedAt,
slug = it.node.slug,
isArchived = it.node.isArchived,
contentReader = it.node.contentReader.rawValue,
content = null
)
}
@ -121,30 +129,3 @@ class HomeViewModel @Inject constructor(
}
}
public data class LinkedItem(
public val id: String,
public val title: String,
public val createdAt: Any,
// public val savedAt: Any,
public val readAt: Any?,
// public val updatedAt: Any,
public val readingProgress: Double,
public val readingProgressAnchor: Int,
public val imageURLString: String?,
// public val onDeviceImageURLString: String?,
// public val documentDirectoryPath: String?,
// public val pageURLString: String,
public val descriptionText: String?,
public val publisherURLString: String?,
// public val siteName: String?,
public val author: String?,
// public val publishDate: Any?,
public val slug: String,
// public val isArchived: Boolean,
// public val contentReader: String?,
// public val originalHtml: String?,
) {
fun publisherDisplayName(): String? {
return publisherURLString?.toUri()?.host
}
}

View File

@ -15,6 +15,7 @@ import androidx.compose.ui.graphics.Color
import androidx.compose.ui.text.style.TextOverflow
import androidx.compose.ui.unit.dp
import androidx.compose.ui.unit.sp
import app.omnivore.omnivore.models.LinkedItem
import coil.compose.rememberAsyncImagePainter
@Composable

View File

@ -0,0 +1,68 @@
package app.omnivore.omnivore.ui.reader
import android.annotation.SuppressLint
import android.view.ViewGroup
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.material3.Text
import androidx.compose.runtime.Composable
import androidx.compose.runtime.getValue
import androidx.compose.runtime.livedata.observeAsState
import androidx.compose.ui.viewinterop.AndroidView
@Composable
fun WebReaderLoadingContainer(slug: String, webReaderViewModel: WebReaderViewModel) {
val webReaderParams: WebReaderParams? by webReaderViewModel.webReaderParamsLiveData.observeAsState(null)
if (webReaderParams == null) {
webReaderViewModel.loadItem(slug = slug)
}
if (webReaderParams != null) {
WebReader(webReaderParams!!)
} else {
// TODO: add a proper loading view
Text("Loading...")
}
}
@SuppressLint("SetJavaScriptEnabled")
@Composable
fun WebReader(params: WebReaderParams) {
WebView.setWebContentsDebuggingEnabled(true)
val webReaderContent = WebReaderContent(
textFontSize = 12,
lineHeight = 150,
maxWidthPercentage = 100,
item = params.item,
themeKey = "LightGray",
fontFamily = WebFont.SYSTEM ,
articleContent = params.articleContent,
prefersHighContrastText = false,
)
val styledContent = webReaderContent.styledContent()
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() {
}
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);
})
}

View File

@ -0,0 +1,111 @@
package app.omnivore.omnivore.ui.reader
import android.util.Log
import app.omnivore.omnivore.models.LinkedItem
enum class WebFont(val displayText: String, val rawValue: String) {
INTER("Inter", "Inter"),
SYSTEM("System Default", "unset"),
OPEN_DYSLEXIC("Open Dyslexic", "OpenDyslexic"),
MERRIWEATHER("Merriweather", "Merriweather"),
LORA("Lora", "Lora"),
OPEN_SANS("Open Sans", "Open Sans"),
ROBOTO("Roboto", "Roboto"),
CRIMSON_TEXT("Crimson Text", "Crimson Text"),
SOURCE_SERIF_PRO("Source Serif Pro", "Source Serif Pro"),
Inter("Inter", "Inter"),
}
enum class ArticleContentStatus(val rawValue: String) {
FAILED("FAILED"),
PROCESSING("PROCESSING"),
SUCCEEDED("SUCCEEDED"),
UNKNOWN("UNKNOWN")
}
data class ArticleContent(
val title: String,
val htmlContent: String,
val highlightsJSONString: String,
val contentStatus: String, // ArticleContentStatus,
val objectID: String?, // whatever the Room Equivalent of objectID is
val labelsJSONString: String
)
data class WebReaderContent(
val textFontSize: Int,
val lineHeight: Int,
val maxWidthPercentage: Int,
val item: LinkedItem,
val themeKey: String,
val fontFamily: WebFont,
val articleContent: ArticleContent,
val prefersHighContrastText: Boolean
) {
fun styledContent(): String {
// TODO: Kotlinize these three values (pasted from Swift)
val savedAt = "new Date(1662571290735.0).toISOString()"
val createdAt = "new Date().toISOString()"
val publishedAt = "new Date().toISOString()" //if (item.publishDate != null) "new Date((item.publishDate!.timeIntervalSince1970 * 1000)).toISOString()" else "undefined"
val content = """
<!DOCTYPE html>
<html>
<head>
<meta charset="utf-8" />
<meta name='viewport' content='width=device-width, initial-scale=1.0, maximum-scale=1.0, minimum-scale=1.0, user-scalable=no' />
<style>
@import url("highlight${if (themeKey == "Gray") "-dark" else ""}.css");
</style>
</head>
<body>
<div id="root" />
<div id='_omnivore-htmlContent'>
${articleContent.htmlContent}
</div>
<script type="text/javascript">
window.omnivoreEnv = {
"NEXT_PUBLIC_APP_ENV": "prod",
"NEXT_PUBLIC_BASE_URL": "unset",
"NEXT_PUBLIC_SERVER_BASE_URL": "unset",
"NEXT_PUBLIC_HIGHLIGHTS_BASE_URL": "unset"
}
window.omnivoreArticle = {
id: "${item.id}",
linkId: "${item.id}",
slug: "${item.slug}",
createdAt: new Date(1662571290735.0).toISOString(),
savedAt: new Date(1662571290981.0).toISOString(),
publishedAt: new Date(1662454816000.0).toISOString(),
url: `${item.pageURLString}`,
title: `${articleContent.title.replace("`", "\\`")}`,
content: document.getElementById('_omnivore-htmlContent').innerHTML,
originalArticleUrl: "${item.pageURLString}",
contentReader: "WEB",
readingProgressPercent: ${item.readingProgress},
readingProgressAnchorIndex: ${item.readingProgressAnchor},
labels: ${articleContent.labelsJSONString},
highlights: ${articleContent.highlightsJSONString},
}
window.fontSize = $textFontSize
window.fontFamily = "${fontFamily.rawValue}"
window.maxWidthPercentage = $maxWidthPercentage
window.lineHeight = $lineHeight
window.localStorage.setItem("theme", "$themeKey")
window.prefersHighContrastFont = $prefersHighContrastText
window.enableHighlightBar = false
</script>
<script src="bundle.js"></script>
<script src="mathJaxConfiguration.js" id="MathJax-script"></script>
<script src="mathjax.js" id="MathJax-script"></script>
</body>
</html>
"""
Log.d("Loggo", content)
return content
}
}

View File

@ -0,0 +1,117 @@
package app.omnivore.omnivore.ui.reader
import androidx.lifecycle.MutableLiveData
import androidx.lifecycle.ViewModel
import androidx.lifecycle.viewModelScope
import app.omnivore.omnivore.Constants
import app.omnivore.omnivore.DatastoreKeys
import app.omnivore.omnivore.DatastoreRepository
import app.omnivore.omnivore.graphql.generated.GetArticleQuery
import app.omnivore.omnivore.models.Highlight
import app.omnivore.omnivore.models.LinkedItem
import app.omnivore.omnivore.models.LinkedItemLabel
import com.apollographql.apollo3.ApolloClient
import com.google.gson.Gson
import dagger.hilt.android.lifecycle.HiltViewModel
import kotlinx.coroutines.launch
import kotlinx.coroutines.runBlocking
import javax.inject.Inject
data class WebReaderParams(
val item: LinkedItem,
val articleContent: ArticleContent
)
@HiltViewModel
class WebReaderViewModel @Inject constructor(
private val datastoreRepo: DatastoreRepository
): ViewModel() {
val webReaderParamsLiveData = MutableLiveData<WebReaderParams?>(null)
private fun getAuthToken(): String? = runBlocking {
datastoreRepo.getString(DatastoreKeys.omnivoreAuthToken)
}
fun loadItem(slug: String) {
viewModelScope.launch {
val authToken = getAuthToken()
val apolloClient = ApolloClient.Builder()
.serverUrl("${Constants.apiURL}/api/graphql")
.addHttpHeader("Authorization", value = authToken ?: "")
.build()
val response = apolloClient.query(
GetArticleQuery(slug = slug)
).execute()
val article = response.data?.article?.onArticleSuccess?.article ?: return@launch
val labels = article.labels ?: listOf()
val linkedItemLabels = labels.map {
LinkedItemLabel(
id = it.labelFields.id,
name = it.labelFields.name,
color = it.labelFields.color,
createdAt = it.labelFields.createdAt,
labelDescription = it.labelFields.description
)
}
val highlights = article.highlights.map {
Highlight(
id = it.highlightFields.id,
shortId = it.highlightFields.shortId,
quote = it.highlightFields.quote,
prefix = it.highlightFields.prefix,
suffix = it.highlightFields.suffix,
patch = it.highlightFields.patch,
annotation = it.highlightFields.annotation,
createdAt = null,
updatedAt = it.highlightFields.updatedAt,
createdByMe = it.highlightFields.createdByMe,
)
}
// TODO: handle errors
val linkedItem = LinkedItem(
id = article.articleFields.id,
title = article.articleFields.title,
createdAt = article.articleFields.createdAt,
savedAt = article.articleFields.savedAt,
readAt = article.articleFields.readAt,
updatedAt = article.articleFields.updatedAt,
readingProgress = article.articleFields.readingProgressPercent,
readingProgressAnchor = article.articleFields.readingProgressAnchorIndex,
imageURLString = article.articleFields.image,
pageURLString = article.articleFields.url,
descriptionText = article.articleFields.description,
publisherURLString = article.articleFields.originalArticleUrl,
siteName = article.articleFields.siteName,
author = article.articleFields.author,
publishDate = article.articleFields.publishedAt,
slug = article.articleFields.slug,
isArchived = article.articleFields.isArchived,
contentReader = article.articleFields.contentReader.rawValue,
content = article.articleFields.content
)
val articleContent = ArticleContent(
title = article.articleFields.title,
htmlContent = article.articleFields.content ?: "",
highlightsJSONString = Gson().toJson(highlights),
contentStatus = "SUCCEEDED",
objectID = "",
labelsJSONString = Gson().toJson(linkedItemLabels)
)
webReaderParamsLiveData.value = WebReaderParams(linkedItem, articleContent)
}
}
fun reset() {
webReaderParamsLiveData.value = null
}
}

View File

@ -19,12 +19,16 @@ import app.omnivore.omnivore.ui.auth.WelcomeScreen
import app.omnivore.omnivore.ui.home.HomeView
import app.omnivore.omnivore.ui.home.HomeViewModel
import app.omnivore.omnivore.ui.reader.ArticleWebView
import app.omnivore.omnivore.ui.reader.WebReader
import app.omnivore.omnivore.ui.reader.WebReaderLoadingContainer
import app.omnivore.omnivore.ui.reader.WebReaderViewModel
import com.google.accompanist.systemuicontroller.rememberSystemUiController
@Composable
fun RootView(
loginViewModel: LoginViewModel,
homeViewModel: HomeViewModel
homeViewModel: HomeViewModel,
webReaderViewModel: WebReaderViewModel
) {
val hasAuthToken: Boolean by loginViewModel.hasAuthTokenLiveData.observeAsState(false)
val systemUiController = rememberSystemUiController()
@ -46,7 +50,8 @@ fun RootView(
if (hasAuthToken) {
PrimaryNavigator(
loginViewModel = loginViewModel,
homeViewModel = homeViewModel
homeViewModel = homeViewModel,
webReaderViewModel = webReaderViewModel
)
} else {
WelcomeScreen(viewModel = loginViewModel)
@ -57,7 +62,8 @@ fun RootView(
@Composable
fun PrimaryNavigator(
loginViewModel: LoginViewModel,
homeViewModel: HomeViewModel
homeViewModel: HomeViewModel,
webReaderViewModel: WebReaderViewModel
) {
val navController = rememberNavController()
@ -69,13 +75,23 @@ fun PrimaryNavigator(
)
}
composable("WebReader/{slug}") {
// 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
WebReaderLoadingContainer(
it.arguments?.getString("slug") ?: "",
webReaderViewModel = webReaderViewModel
)
}
composable(Routes.Settings.route) {
SettingsView(loginViewModel = loginViewModel, navController = navController)
}

View File

@ -51,7 +51,6 @@ struct WebReaderContent {
</head>
<body>
<div id="root" />
<div>HIIIIII</div>
<div id='_omnivore-htmlContent' style="display: none;">
\(articleContent.htmlContent)
</div>