1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
|
package org.terst.feedreader.ui
import android.annotation.SuppressLint
import android.content.Intent
import android.net.Uri
import android.webkit.WebView
import androidx.compose.foundation.layout.Box
import androidx.compose.foundation.layout.fillMaxSize
import androidx.compose.foundation.layout.padding
import androidx.compose.material.icons.Icons
import androidx.compose.material.icons.filled.Article
import androidx.compose.material.icons.filled.ArrowBack
import androidx.compose.material.icons.filled.Bookmark
import androidx.compose.material.icons.filled.BookmarkBorder
import androidx.compose.material.icons.filled.CloudDone
import androidx.compose.material.icons.filled.CloudUpload
import androidx.compose.material.icons.filled.MoreVert
import androidx.compose.material.icons.filled.OpenInBrowser
import androidx.compose.material.icons.outlined.Article
import androidx.compose.material3.CircularProgressIndicator
import androidx.compose.material3.DropdownMenu
import androidx.compose.material3.DropdownMenuItem
import androidx.compose.material3.ExperimentalMaterial3Api
import androidx.compose.material3.Icon
import androidx.compose.material3.IconButton
import androidx.compose.material3.MaterialTheme
import androidx.compose.material3.Scaffold
import androidx.compose.material3.SnackbarHost
import androidx.compose.material3.SnackbarHostState
import androidx.compose.material3.Text
import androidx.compose.material3.TopAppBar
import androidx.compose.runtime.Composable
import androidx.compose.runtime.LaunchedEffect
import androidx.compose.runtime.getValue
import androidx.compose.runtime.mutableStateOf
import androidx.compose.runtime.remember
import androidx.compose.runtime.rememberCoroutineScope
import androidx.compose.runtime.setValue
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.text.style.TextOverflow
import androidx.compose.ui.viewinterop.AndroidView
import kotlinx.coroutines.launch
import org.terst.feedreader.data.AppDatabase
import org.terst.feedreader.data.ArticleEntity
import org.terst.feedreader.data.ensureStandaloneArticle
import org.terst.feedreader.data.saveArticleToStash
import org.terst.feedreader.data.urlToArticleId
import org.terst.feedreader.readability.ReaderExtractor
import java.net.URLEncoder
/**
* A reader view for links tapped inside an article's WebView -- extracts the linked
* page with the same Readability pipeline as the main reader, and recursively re-opens
* itself for further links, so link-hopping stays inside the app's reader instead of
* dropping into the system browser. Brought to parity with the main article reader:
* save locally / save to Stash (lazily creates a standalone ArticleEntity, feedId=null,
* keyed by a hash of the URL -- same scheme ShareReceiverActivity uses, so sharing the
* same link later lands on the same row instead of duplicating it), a reader-mode
* toggle (falls back to the live page, JS on, when the user wants the original instead
* of the extracted view or extraction failed), and the same login/search-alternate menu.
*/
@SuppressLint("SetJavaScriptEnabled")
@OptIn(ExperimentalMaterial3Api::class)
@Composable
fun LinkReaderScreen(url: String, onBack: () -> Unit, onOpenLink: (String) -> Unit, onOpenBrowser: (String) -> Unit) {
val context = LocalContext.current
val db = remember { AppDatabase.get(context) }
val scope = rememberCoroutineScope()
val snackbarHostState = remember { SnackbarHostState() }
val articleId = remember(url) { urlToArticleId(url) }
var article by remember(url) { mutableStateOf<ArticleEntity?>(null) }
var extractedTitle by remember(url) { mutableStateOf<String?>(null) }
var extractedContent by remember(url) { mutableStateOf<String?>(null) }
var extractionFailed by remember(url) { mutableStateOf(false) }
var isReaderMode by remember(url) { mutableStateOf(true) }
var isSaving by remember(url) { mutableStateOf(false) }
var isReextracting by remember(url) { mutableStateOf(false) }
var moreMenuExpanded by remember(url) { mutableStateOf(false) }
var longPressedLink by remember(url) { mutableStateOf<String?>(null) }
var youtubeVideoId by remember(url) { mutableStateOf<String?>(null) }
var zoomedImageUrl by remember(url) { mutableStateOf<String?>(null) }
LaunchedEffect(url) {
val existing = db.articleDao().getById(articleId)
article = existing
if (existing?.extractedContent != null) {
extractedTitle = existing.title
extractedContent = existing.extractedContent
} else {
val extracted = ReaderExtractor.extract(context, url)
val content = extracted?.content
if (content != null) {
extractedTitle = extracted.title
extractedContent = content
} else {
extractionFailed = true
isReaderMode = false
}
}
}
// Both save actions create the standalone row on first use (there's nothing to save
// until the user asks) -- subsequent taps just update the existing one.
suspend fun ensureArticleRow(): ArticleEntity {
article?.let { return it }
val a = ArticleEntity(
id = articleId,
feedId = null,
title = extractedTitle ?: url,
link = url,
extractedContent = extractedContent,
createdAt = System.currentTimeMillis()
)
db.articleDao().upsert(a)
article = a
return a
}
Scaffold(
topBar = {
TopAppBar(
title = {
Text(extractedTitle ?: Uri.parse(url).host ?: "Article", maxLines = 1, overflow = TextOverflow.Ellipsis)
},
navigationIcon = {
IconButton(onClick = onBack) {
Icon(Icons.Default.ArrowBack, contentDescription = "Back")
}
},
actions = {
val currentArticle = article
if (extractedContent != null) {
IconButton(onClick = { isReaderMode = !isReaderMode }) {
Icon(
if (isReaderMode) Icons.Filled.Article else Icons.Outlined.Article,
contentDescription = "Toggle reader view"
)
}
}
IconButton(onClick = {
context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(url)))
}) {
Icon(Icons.Default.OpenInBrowser, contentDescription = "Open in browser")
}
IconButton(onClick = {
scope.launch {
val a = ensureArticleRow()
val newSaved = !a.isSaved
article = a.copy(isSaved = newSaved)
db.articleDao().setSaved(a.id, newSaved)
}
}) {
Icon(
if (currentArticle?.isSaved == true) Icons.Default.Bookmark else Icons.Default.BookmarkBorder,
contentDescription = "Save locally"
)
}
IconButton(
enabled = !isSaving,
onClick = {
isSaving = true
scope.launch {
val a = ensureArticleRow()
val outcome = saveArticleToStash(context, db, a.id, a.link, a.title)
outcome.artifactId?.let { article = a.copy(stashArtifactId = it) }
snackbarHostState.showSnackbar(outcome.message)
isSaving = false
}
}
) {
Icon(
if (currentArticle?.stashArtifactId != null) Icons.Default.CloudDone else Icons.Default.CloudUpload,
contentDescription = "Save to Stash"
)
}
IconButton(onClick = { moreMenuExpanded = true }) {
Icon(Icons.Default.MoreVert, contentDescription = "More")
}
DropdownMenu(expanded = moreMenuExpanded, onDismissRequest = { moreMenuExpanded = false }) {
DropdownMenuItem(
enabled = !isReextracting,
text = { Text("Re-download and re-extract") },
onClick = {
moreMenuExpanded = false
scope.launch {
isReextracting = true
val extracted = ReaderExtractor.extract(context, url)
isReextracting = false
val content = extracted?.content
if (content != null) {
extractedTitle = extracted.title
extractedContent = content
extractionFailed = false
isReaderMode = true
db.articleDao().setExtractedContent(articleId, content)
snackbarHostState.showSnackbar("Re-extracted")
} else {
snackbarHostState.showSnackbar("Re-extraction failed")
}
}
}
)
DropdownMenuItem(
text = { Text("Find another source") },
onClick = {
moreMenuExpanded = false
val query = URLEncoder.encode(extractedTitle ?: url, "UTF-8")
onOpenBrowser("https://www.google.com/search?q=$query")
}
)
DropdownMenuItem(
text = { Text("Log in to this site") },
onClick = {
moreMenuExpanded = false
onOpenBrowser(url)
}
)
}
}
)
},
snackbarHost = { SnackbarHost(snackbarHostState) }
) { padding ->
Box(modifier = Modifier.fillMaxSize().padding(padding)) {
val currentExtractedContent = extractedContent
when {
isReaderMode && currentExtractedContent != null -> {
val loadedContent = remember(url) { mutableStateOf<String?>(null) }
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
newReaderWebView(
ctx,
ReaderWebViewCallbacks(
onOpenLink = onOpenLink,
onLongPressLink = { longPressedLink = it },
onYouTubeVideo = { youtubeVideoId = it },
onImageTap = { zoomedImageUrl = it }
)
).apply {
loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
loadedContent.value = currentExtractedContent
}
},
update = { webView ->
if (loadedContent.value != currentExtractedContent) {
webView.loadDataWithBaseURL(url, wrapReaderHtml(currentExtractedContent, extractedTitle), "text/html", "UTF-8", null)
loadedContent.value = currentExtractedContent
}
},
onRelease = { webView -> webView.destroy() }
)
}
// Reader mode off, or extraction failed -- fall back to the live page
// itself (JS on) rather than leaving the screen blank.
!isReaderMode || extractionFailed -> {
if (!url.startsWith("http://", ignoreCase = true) && !url.startsWith("https://", ignoreCase = true)) {
// `url` isn't necessarily a link the user themselves tapped: it can
// also be whatever a WebView's shouldOverrideUrlLoading reported for
// ANY navigation attempt inside an article (see ReaderWebView's
// onOpenLink), including one triggered by the article's own HTML
// linking to a non-http scheme (intent://, tel:, etc.). Refusing to
// load anything but http(s) here means this JS-enabled WebView can't
// become a sink for whatever scheme a crafted article handed it.
Text(
"Can't open this link: $url",
modifier = Modifier.fillMaxSize().padding(16.dp),
color = MaterialTheme.colorScheme.onSurfaceVariant
)
} else {
AndroidView(
modifier = Modifier.fillMaxSize(),
factory = { ctx ->
WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.domStorageEnabled = true
loadUrl(url)
}
},
onRelease = { webView -> webView.destroy() }
)
}
}
else -> CircularProgressIndicator(modifier = Modifier.align(Alignment.Center))
}
}
youtubeVideoId?.let { videoId ->
YouTubePlayerDialog(videoId = videoId, onDismiss = { youtubeVideoId = null })
}
zoomedImageUrl?.let { imageUrl ->
ImageZoomDialog(imageUrl = imageUrl, onDismiss = { zoomedImageUrl = null })
}
longPressedLink?.let { linkUrl ->
LinkActionsDialog(
url = linkUrl,
onDismiss = { longPressedLink = null },
onOpenInBrowser = { context.startActivity(Intent(Intent.ACTION_VIEW, Uri.parse(linkUrl))) },
onShare = {
val send = Intent(Intent.ACTION_SEND).apply {
type = "text/plain"
putExtra(Intent.EXTRA_TEXT, linkUrl)
}
context.startActivity(Intent.createChooser(send, null))
},
onSaveToStash = {
scope.launch {
val entity = ensureStandaloneArticle(context, db, linkUrl)
val outcome = saveArticleToStash(context, db, entity.id, linkUrl, entity.title)
snackbarHostState.showSnackbar(outcome.message)
}
},
onSaveForLater = {
scope.launch {
val entity = ensureStandaloneArticle(context, db, linkUrl)
if (!entity.isSaved) db.articleDao().setSaved(entity.id, true)
snackbarHostState.showSnackbar("Saved for later")
}
}
)
}
}
}
|