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
|
package org.terst.feedreader.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.View
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.material3.AlertDialog
import androidx.compose.material3.Text
import androidx.compose.material3.TextButton
import androidx.compose.runtime.Composable
import androidx.compose.ui.Modifier
import androidx.compose.ui.text.style.TextOverflow
private fun isYouTubeWatchUrl(uri: Uri): Boolean {
val host = uri.host?.lowercase() ?: return false
return host == "youtu.be" || host.endsWith(".youtube.com") || host == "youtube.com"
}
/**
* Shared factory for the read-only, dark-themed article WebView used by both the
* per-article ReaderScreen and LinkReaderScreen (for links tapped inside an article).
* JS is always off -- extracted/feed HTML never needs it, and this keeps articles
* from running arbitrary scripts. Link taps are intercepted rather than left to
* WebView's default behavior (handing off to the system browser) so following a
* link stays inside the app's own reader/extraction pipeline -- except a YouTube
* watch link (what ReaderExtractor turns an embedded video into a thumbnail link
* to), which goes straight to the YouTube app/browser instead: there's nothing for
* our own Readability-based reader pipeline to usefully extract from a video page.
*
* onLongPressLink fires for a long-press on a link (a plain one, or an image
* wrapped in one, e.g. the YouTube thumbnail) with the target URL, instead of
* WebView's own default long-press behavior (a native "open/copy link" popup) --
* callers show their own save/share/open action menu instead.
*/
fun newReaderWebView(context: Context, onOpenLink: (String) -> Unit, onLongPressLink: (String) -> Unit = {}): WebView =
WebView(context).apply {
settings.javaScriptEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
val url = request.url
if (isYouTubeWatchUrl(url)) {
context.startActivity(Intent(Intent.ACTION_VIEW, url))
} else {
onOpenLink(url.toString())
}
return true
}
}
setOnLongClickListener {
val result = hitTestResult
val url = when (result.type) {
WebView.HitTestResult.SRC_ANCHOR_TYPE, WebView.HitTestResult.SRC_IMAGE_ANCHOR_TYPE -> result.extra
else -> null
}
if (url != null) {
onLongPressLink(url)
true
} else {
false
}
}
}
@Composable
fun LinkActionsDialog(
url: String,
onDismiss: () -> Unit,
onOpenInBrowser: () -> Unit,
onShare: () -> Unit,
onSaveToStash: () -> Unit,
onSaveForLater: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(url, maxLines = 2, overflow = TextOverflow.Ellipsis) },
text = {
Column {
TextButton(onClick = { onDismiss(); onOpenInBrowser() }, modifier = Modifier.fillMaxWidth()) {
Text("Open in browser")
}
TextButton(onClick = { onDismiss(); onShare() }, modifier = Modifier.fillMaxWidth()) {
Text("Share")
}
TextButton(onClick = { onDismiss(); onSaveToStash() }, modifier = Modifier.fillMaxWidth()) {
Text("Save to Stash")
}
TextButton(onClick = { onDismiss(); onSaveForLater() }, modifier = Modifier.fillMaxWidth()) {
Text("Save for later")
}
}
},
confirmButton = {},
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
private fun escapeHtml(text: String): String = text
.replace("&", "&")
.replace("<", "<")
.replace(">", ">")
.replace("\"", """)
.replace("'", "'")
// Hardcoded dark palette, not @media (prefers-color-scheme) -- the app itself always
// forces dark colorScheme() regardless of system setting (see MainActivity), so reader
// content should match rather than following a system light/dark the app itself ignores.
//
// title is rendered here, as a real heading at the top of the content, rather than relying
// on whatever heading Readability happened to keep in bodyContent (increasingly nothing --
// see ReaderExtractor's <header> stripping) or on the top app bar's title, which truncates
// to one line and was the only place a long title showed up before this.
fun wrapReaderHtml(bodyContent: String, title: String? = null): String = """
<html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; font-size: 18px; line-height: 1.5; padding: 16px;
background-color: #121212; color: #e1e1e1; }
h1.article-title { font-size: 1.5em; line-height: 1.3; margin: 0 0 0.6em 0; }
a { color: #8ab4f8; }
img { max-width: 100%; height: auto; }
svg { max-width: 100%; }
/* A decorative icon (section badge, share glyph, etc.) that slipped through
extraction relies on the source site's own stylesheet for its real display
size (commonly Tailwind classes like "h-5 w-5") -- which we don't load here.
An <svg> with no explicit width/height falls back to the browser's default
(much larger than any inline icon is meant to be, confirmed live: Ars
Technica's per-article section-badge icon rendering "giant"). One WITH
explicit width/height was deliberately sized by the page itself, so leave
those alone -- just cap the ones that would otherwise size themselves. */
svg:not([width]):not([height]) { max-width: 32px; max-height: 32px; }
/* A tap-to-open thumbnail standing in for a YouTube embed (see ReaderExtractor) --
JS is off here, so the real embedded player could never have worked anyway. */
.yt-embed { position: relative; display: block; margin: 0.5em 0; }
.yt-embed .yt-play { position: absolute; top: 50%; left: 50%; transform: translate(-50%, -50%);
width: 0; height: 0; border-style: solid; border-width: 18px 0 18px 30px;
border-color: transparent transparent transparent rgba(255,255,255,0.85); }
</style></head>
<body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}$bodyContent</body></html>
""".trimIndent()
|