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
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
|
package org.terst.feedreader.ui
import android.content.Context
import android.content.Intent
import android.net.Uri
import android.view.ScaleGestureDetector
import android.view.View
import android.webkit.WebChromeClient
import android.webkit.WebResourceRequest
import android.webkit.WebView
import android.webkit.WebViewClient
import androidx.compose.foundation.layout.Column
import androidx.compose.foundation.layout.aspectRatio
import androidx.compose.foundation.layout.fillMaxWidth
import androidx.compose.foundation.layout.padding
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
import androidx.compose.ui.unit.dp
import androidx.compose.ui.viewinterop.AndroidView
import androidx.compose.ui.window.Dialog
import androidx.compose.ui.window.DialogProperties
import org.terst.feedreader.data.DEFAULT_READER_FONT_SIZE_PX
import org.terst.feedreader.data.UNZOOMED_TEXT_ZOOM_PERCENT
import org.terst.feedreader.data.pinchZoomFontSizePx
import org.terst.feedreader.data.readerTextZoomPercent
// In-memory only (process lifetime, not persisted to disk) -- scoped to "don't lose my
// place if Next/Previous takes me away and back," not to surviving an app restart. A
// process-wide singleton rather than screen-scoped state so it survives an Activity
// recreation (rotation) too, not just Compose recomposition within the same screen instance.
object ReaderScrollPositions {
private val positions = mutableMapOf<String, Int>()
fun get(articleId: String): Int = positions[articleId] ?: 0
fun set(articleId: String, scrollY: Int) {
positions[articleId] = scrollY
}
}
private fun isYouTubeWatchUrl(uri: Uri): Boolean {
val host = uri.host?.lowercase() ?: return false
return host == "youtu.be" || host.endsWith(".youtube.com") || host == "youtube.com"
}
// Pulls the 11-char video ID out of a youtube.com/watch?v=ID or youtu.be/ID URL.
// Returns null for anything it can't confidently resolve to a single video (a
// playlist/channel URL, for one) -- callers fall back to the old external-open
// behavior in that case rather than guessing.
private fun extractYouTubeVideoId(uri: Uri): String? {
val id = if (uri.host?.lowercase() == "youtu.be") {
uri.pathSegments.firstOrNull()
} else {
uri.getQueryParameter("v")
}
return id?.takeIf { it.matches(Regex("^[a-zA-Z0-9_-]{11}$")) }
}
/**
* 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.
*
* onOpenYouTubeExternally fires for a LONG-press on a YouTube link, with the watch URL, instead
* of the ordinary onLongPressLink actions menu -- callers hand it to the system, which is the
* YouTube app on a phone that has one. A tap plays the video in the reader; a long-press is how
* you say you wanted it somewhere else.
*
* onYouTubeVideo fires for a tap on a YouTube watch link (what ReaderExtractor turns
* an embedded video into a thumbnail link to) with just the video ID, instead of the
* old behavior of handing off to the YouTube app/browser -- callers show an in-app
* YouTubePlayerDialog instead, so watching a video doesn't leave the reader. Falls
* back to the external-open behavior when a confident video ID can't be extracted
* (a playlist/channel link, say).
*
* onImageTap fires for a tap on an article image (see ImageZoom.kt for how a tap on an <img>
* is detected at all with JS off) with the image's own URL -- callers show a full-screen
* ImageZoomDialog. Also fires for a link pointing straight at an image file, which used to
* send LinkReaderScreen off to run Readability over a JPEG.
*
* Pinch-to-zoom adjusts the persisted reader font size, not WebView's own page zoom
* (never enabled here -- setSupportZoom/builtInZoomControls are left at their defaults,
* which is effectively off). A real page zoom would scale layout and images along with
* text and wouldn't survive the next article/reload; this app already has a font-size
* concept the rest of the reader respects (Settings.readerFontSizePx, adjustable from
* the Settings screen), so pinching should drive that same value instead of inventing a
* second, WebView-local notion of "how big does this look right now."
*
* Getting this right without breaking ordinary one-finger scrolling/tapping is the whole
* trick: a ScaleGestureDetector is fed every touch via setOnTouchListener, but that
* listener always returns false (never consumes the event), so WebView's own touch
* handling -- scrolling, link taps, the long-press-link listener above -- proceeds
* exactly as before. ScaleGestureDetector only calls back once a second pointer is down,
* so a single-finger drag never triggers any of this.
*
* currentFontSizePx is read fresh (not captured once) at the START of each gesture, since
* the WebView this attaches to is created once but can outlive a Settings-screen-driven
* font size change made while this same article is still open. Everything during the
* gesture is measured against that one starting size, so a pinch is judged against where
* it began rather than against a size it's already changed.
*
* The text reflows live, as the fingers move: each step sets WebSettings.textZoom, which
* re-lays-out the text at the new size right away -- no reload, no JavaScript, and
* images/padding stay exactly where they are. An earlier version instead scaled the whole
* WebView (View.scaleX/scaleY) during the gesture and snapped back to 1x on release, which
* zoomed the canvas like a photo (blurring text, magnifying the page's own margins) and
* then visibly jumped when the real font size took over. Nothing is scaled now, so there's
* nothing to snap back from.
*
* At gesture end onFontSizeChanged reports the new, clamped, rounded font size for the
* caller to persist; ReaderScreen's content-vs-fontSizePx reload logic then re-renders the
* HTML at that size via loadReaderHtml, which is also what returns textZoom to 100% -- the
* live zoom stays applied until the reload it hands over to is on screen, so the two never
* both apply and there's no gap where the text flicks back to its old size.
*/
data class ReaderWebViewCallbacks(
val onOpenLink: (String) -> Unit,
val onLongPressLink: (String) -> Unit = {},
val onOpenYouTubeExternally: (String) -> Unit = {},
val onYouTubeVideo: (String) -> Unit = {},
val onImageTap: (String) -> Unit = {},
val onScrollChanged: (Int) -> Unit = {},
val onPageFinished: (WebView) -> Unit = {},
val currentFontSizePx: () -> Int = { DEFAULT_READER_FONT_SIZE_PX },
val onFontSizeChanged: (Int) -> Unit = {}
)
/**
* The reader's WebView, which knows what font size the HTML it's showing was written at.
*
* That's the whole reason it's a class rather than a plain WebView: a live pinch lifts
* WebSettings.textZoom off 100% and leaves it there until the reload at the new font size
* arrives (see the gesture notes above). Anything that loads HTML into this WebView must put
* textZoom back, or the lifted zoom multiplies the new, already-bigger CSS size -- so loading
* goes through [loadReaderHtml], which does both together and can't be forgotten the way a
* separate "and also reset the zoom" line at each call site could.
*/
class ReaderWebView(context: Context) : WebView(context) {
/** The `font-size` the currently-loaded HTML was written with (see wrapReaderHtml). */
var renderedFontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX
private set
fun loadReaderHtml(baseUrl: String?, html: String, fontSizePx: Int) {
renderedFontSizePx = fontSizePx
settings.textZoom = UNZOOMED_TEXT_ZOOM_PERCENT
loadDataWithBaseURL(baseUrl, html, "text/html", "UTF-8", null)
}
}
fun newReaderWebView(context: Context, callbacks: ReaderWebViewCallbacks): ReaderWebView =
ReaderWebView(context).apply {
settings.javaScriptEnabled = false
overScrollMode = View.OVER_SCROLL_NEVER
isVerticalScrollBarEnabled = false
isHorizontalScrollBarEnabled = false
// Reports the WebView's own scroll offset as the user reads, so a caller (ReaderScreen)
// can remember how far into an article they'd gotten and restore it -- e.g. after
// Next/Previous replaces this screen's back-stack entry with a fresh one for the same
// article, which recreates the WebView from scratch and would otherwise always land
// back at the top.
setOnScrollChangeListener { _, _, scrollY, _, _ -> callbacks.onScrollChanged(scrollY) }
var pinchScale = 1f
var pinchBaseFontSizePx = DEFAULT_READER_FONT_SIZE_PX
val webView = this
val scaleDetector = ScaleGestureDetector(
context,
object : ScaleGestureDetector.SimpleOnScaleGestureListener() {
override fun onScaleBegin(detector: ScaleGestureDetector): Boolean {
pinchScale = 1f
pinchBaseFontSizePx = callbacks.currentFontSizePx()
return true
}
override fun onScale(detector: ScaleGestureDetector): Boolean {
pinchScale *= detector.scaleFactor
// Steps in whole pixels of font size, not in raw gesture scale, so a slow
// pinch relayouts a handful of times over its whole range rather than on
// every touch-move frame -- and lands on exactly the size it will persist.
val targetPx = pinchZoomFontSizePx(pinchBaseFontSizePx, pinchScale)
webView.settings.textZoom = readerTextZoomPercent(webView.renderedFontSizePx, targetPx)
return true
}
override fun onScaleEnd(detector: ScaleGestureDetector) {
callbacks.onFontSizeChanged(pinchZoomFontSizePx(pinchBaseFontSizePx, pinchScale))
}
}
)
setOnTouchListener { _, event ->
scaleDetector.onTouchEvent(event)
false // never consume -- WebView still handles scroll/tap/long-press normally
}
webViewClient = object : WebViewClient() {
override fun onPageFinished(view: WebView, url: String?) {
super.onPageFinished(view, url)
callbacks.onPageFinished(view)
}
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
// A subframe (an <iframe> in the article body, e.g. a non-YouTube oEmbed
// like Twitter/Instagram, or an ad) fires this same callback the instant
// its src resolves -- not a user tap. Without this check, that auto-fired
// navigation was treated exactly like a real link tap: an ad/embed iframe
// could silently auto-navigate to LinkReaderScreen the moment an article
// opened, no interaction required. Block subframe navigation outright
// (return true = don't load it) rather than following it anywhere; JS is
// off in this WebView regardless, so almost nothing embeddable would have
// worked here anyway.
if (!request.isForMainFrame) return true
val url = request.url
// Checked before anything else: an image tap is never a navigation we'd want to
// follow. Covers both the zoom links wrapReaderHtml adds to the article's own
// images and a link the article itself already pointed straight at an image file.
val imageUrl = zoomableImageTarget(url.toString())
if (imageUrl != null) {
callbacks.onImageTap(imageUrl)
} else if (isYouTubeWatchUrl(url)) {
val videoId = extractYouTubeVideoId(url)
if (videoId != null) {
callbacks.onYouTubeVideo(videoId)
} else {
context.startActivity(Intent(Intent.ACTION_VIEW, url))
}
} else {
callbacks.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) {
// Every article image now sits inside a zoom link, so a long-press on one
// reports that link's internal scheme -- strip it back to the image's own URL
// so the actions menu shows, and acts on, something real.
val target = withoutImageZoomScheme(url)
// A long-press on a video reports the thumbnail's URL rather than the link's --
// see youTubeTargetForLongPress, which resolves it back to the video. Straight
// out to whatever handles YouTube on the phone, usually the app: a tap already
// plays the video in the reader, so the only thing a long-press can usefully
// mean is "not in here, out there", and picking that off a menu would be a menu
// with one useful item.
val video = youTubeTargetForLongPress(target)
if (video != null) {
callbacks.onOpenYouTubeExternally(video)
} else {
callbacks.onLongPressLink(target)
}
true
} else {
false
}
}
}
// The generic "confirm before doing something you can't undo" shape -- title, body text,
// a labeled confirm action, and a Cancel that just dismisses -- independently rebuilt from
// scratch at FeedListScreen's unsubscribe confirmation and ArticleListScreen's mark-read-
// from-here confirmation before this. A third such dialog (there's real incentive for one,
// e.g. deleting a saved article) would very likely have been a third from-scratch copy.
@Composable
fun ConfirmDialog(
title: String,
text: String,
confirmLabel: String,
onConfirm: () -> Unit,
onDismiss: () -> Unit
) {
AlertDialog(
onDismissRequest = onDismiss,
title = { Text(title) },
text = { Text(text) },
confirmButton = { TextButton(onClick = onConfirm) { Text(confirmLabel) } },
dismissButton = { TextButton(onClick = onDismiss) { Text("Cancel") } }
)
}
@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") } }
)
}
// A single-video, throwaway playback surface -- the only place in the reader JS is
// ever turned on. Scoped to exactly the video the user tapped "play" on (which is
// also the user gesture that makes mediaPlaybackRequiresUserGesture = false safe
// here specifically: nothing autoplays until the user has already asked for this
// exact video), and destroyed on dismiss so nothing keeps running in the background.
// Loads youtube-nocookie.com, not youtube.com -- YouTube's own privacy-enhanced embed
// domain, deliberately chosen so a casual video view doesn't pull in the viewer's
// signed-in YouTube account/history the way the full site or app would.
@Composable
fun YouTubePlayerDialog(videoId: String, onDismiss: () -> Unit) {
// usePlatformDefaultWidth defaults to true, which caps the dialog at the platform's
// narrow AlertDialog-style width -- fine for text, but it squeezed our own
// fillMaxWidth().aspectRatio(16f/9f) player down to a small, oddly-placed box
// instead of an actual video-sized player (confirmed live: exactly this box, too
// narrow, sitting off-center). Opt out so fillMaxWidth below means the real
// screen width, with our own padding controlling the margin instead.
Dialog(onDismissRequest = onDismiss, properties = DialogProperties(usePlatformDefaultWidth = false)) {
AndroidView(
modifier = Modifier.fillMaxWidth().padding(16.dp).aspectRatio(16f / 9f),
factory = { ctx ->
// A debug build only -- lets `chrome://inspect` on a USB-connected computer
// attach to this exact WebView and show its real JS console output. Blank/
// black-then-white symptoms alone don't say WHY the embed's player script is
// failing; this is how to actually see the error next time instead of
// guessing from a screenshot.
if ((ctx.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0) {
WebView.setWebContentsDebuggingEnabled(true)
}
WebView(ctx).apply {
settings.javaScriptEnabled = true
settings.mediaPlaybackRequiresUserGesture = false
// YouTube's embed player is itself a JS app that reads/writes
// localStorage during init -- without DOM storage enabled, that
// throws and the player never mounts, leaving a blank page (confirmed
// live: exactly the black-flash-then-blank-white reported). Enabling
// it here is scoped to this one throwaway playback WebView, not the
// reader WebView the rest of the app uses.
settings.domStorageEnabled = true
// Without these the embed page can render at the wrong scale (WebView's
// default is to lay out as if for a desktop viewport, then shrink it to
// fit -- the player area ends up small and centered in its own black
// background, which is what the reported "letterboxed a bit" was:
// genuine video area, just laid out far smaller than the WebView itself).
settings.useWideViewPort = true
settings.loadWithOverviewMode = true
settings.setSupportZoom(false)
settings.builtInZoomControls = false
// The embed page sets its own first-party cookies for session/playback
// state even on the nocookie domain (nothing to do with tracking --
// that's what the domain choice already avoids); WebView blocks
// third-party cookies by default but not first-party ones, so this is
// belt-and-suspenders rather than the primary fix.
android.webkit.CookieManager.getInstance().setAcceptCookie(true)
setBackgroundColor(android.graphics.Color.BLACK)
// This dialog has no address bar, no back button, and nothing visually
// marking it as a WebView at all -- it's meant to look like a trusted
// video player, not a browser. Without a WebViewClient restricting
// navigation, an in-page redirect (an end-screen link, a compromised ad
// within the embed, any script-driven top-level navigation) would load
// right in place with JS still on and nothing stopping it going
// anywhere. Confine main-frame navigation to YouTube's own domains;
// anything else is blocked rather than followed.
webViewClient = object : WebViewClient() {
override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean {
if (!request.isForMainFrame) return false
val host = request.url.host?.lowercase() ?: return true
val allowed = host == "youtube-nocookie.com" || host.endsWith(".youtube-nocookie.com") ||
host == "youtube.com" || host.endsWith(".youtube.com") || host == "youtu.be"
return !allowed
}
}
webChromeClient = WebChromeClient()
// A host page with the embed in an iframe, not the embed itself as the
// top-level document. YouTube answers a top-level embed with "Video player
// configuration error -- Error 153", which is the reported "YouTube play is
// misconfigured"; framed, the same URL plays. See YouTubeLinks.
loadDataWithBaseURL(
YOUTUBE_EMBED_BASE_URL,
youTubeEmbedPageHtml(videoId),
"text/html",
"utf-8",
null
)
}
},
onRelease = { it.destroy() }
)
}
}
// Readability-extracted content never has a raw <iframe> left in it -- ReaderExtractor
// already swaps every YouTube iframe for this same thumbnail-link shape before
// Readability ever runs. But a feed's raw feedContentHtml (shown as-is whenever a
// feed's reader mode is off) can itself contain a literal YouTube <iframe> -- standard
// WordPress oEmbed markup does exactly this (confirmed live on a kottke.org article).
// Since the reader WebView always has JS off, that raw iframe hits YouTube's own no-JS
// fallback and renders as a broken "enable JavaScript" error box instead of anything
// useful. Give raw feed content the same treatment extracted content already gets.
// Applying this to EVERY call (not just the raw-feed-content path) is safe -- it's a
// no-op wherever no bare <iframe> survived, i.e. anything Readability already touched.
private val youtubeIframeRegex = Regex(
"""<iframe\b[^>]*\bsrc="[^"]*(?:youtube\.com|youtube-nocookie\.com)/embed/([a-zA-Z0-9_-]{11})[^"]*"[^>]*>.*?</iframe>""",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
// The capture group is already constrained to [a-zA-Z0-9_-]{11} by the pattern above,
// so it can't contain a `"` or `<` -- safe to splice into the replacement markup as-is.
private fun replaceYouTubeIframes(html: String): String =
youtubeIframeRegex.replace(html) { match ->
val id = match.groupValues[1]
"<figure><a href=\"https://www.youtube.com/watch?v=$id\"><img src=\"https://i.ytimg.com/vi/$id/hq720.jpg\" alt=\"YouTube video\"></a><figcaption>▶ Watch on YouTube</figcaption></figure>"
}
// Same root cause as the YouTube case above, just not YouTube: any OTHER embeddable
// platform's iframe (Twitter/X, Instagram, TikTok, Truth Social, Facebook -- all ship
// this way) is equally broken with JS off, since it depends on its own loader script to
// actually render anything. Confirmed live: a Truth Social post embed
// (<iframe class="truthsocial-embed" src="https://truthsocial.com/.../embed">, paired
// with a truthsocial.com/embed.js loader script) rendered as a blank/broken frame in a
// WhoWhatWhy article. Rather than special-case every platform individually, ANY iframe
// still left after the YouTube-specific pass above (which already handles YouTube's case
// better, with a real thumbnail) gets swapped for a plain link to the same src -- always
// a safe, working fallback regardless of which platform it was.
private val genericIframeRegex = Regex(
"""<iframe\b[^>]*\bsrc="([^"]+)"[^>]*>.*?</iframe>""",
setOf(RegexOption.IGNORE_CASE, RegexOption.DOT_MATCHES_ALL)
)
private fun replaceGenericIframes(html: String): String =
genericIframeRegex.replace(html) { match ->
val rawSrc = match.groupValues[1]
// Protocol-relative (//host/path) is common in embed markup; anything else must
// already be a real http(s) URL -- src is attacker/publisher-controlled feed
// content, so a non-http(s) scheme (javascript:, file:, intent:, ...) is dropped
// silently rather than ever becoming a clickable link.
val href = when {
rawSrc.startsWith("//") -> "https:$rawSrc"
rawSrc.startsWith("http://", ignoreCase = true) || rawSrc.startsWith("https://", ignoreCase = true) -> rawSrc
else -> null
}
if (href != null) "<p><a href=\"${escapeHtml(href)}\">View embedded content ↗</a></p>" else ""
}
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,
publishedAt: Long? = null,
imageUrl: String? = null,
fontSizePx: Int = DEFAULT_READER_FONT_SIZE_PX
): String {
val processedBody = replaceGenericIframes(replaceYouTubeIframes(bodyContent))
// NB: linkifyImagesForZoom runs at the very end (below, over hero + body together), after
// the iframe passes -- the YouTube thumbnail one of them synthesizes is an image inside a
// link, and the zoom pass has to see it as one to leave its tap-to-play link alone.
// Some feeds' body HTML never embeds an image at all -- the only image they give us
// is a separate RSS media:content/enclosure field (ArticleEntity.imageUrl), which
// otherwise only ever reaches the list-view thumbnail and never the reader itself
// (confirmed live: a Big Island Now article's <content:encoded> had zero <img> tags,
// its photo living only in a sibling <media:content> the body never referenced). Only
// show it here when the body truly has nothing of its own, so a feed that DOES embed
// its own lead image never ends up with a redundant duplicate above it.
//
// Checked against the ORIGINAL bodyContent, not processedBody: a YouTube <iframe> with
// no <img> of its own gets turned into a thumbnail <img> by replaceYouTubeIframes above,
// and checking the post-replacement string meant that synthesized video thumbnail
// counted as "the body already has an image," silently suppressing the article's real
// featured photo on any article that had both a YouTube embed and a separate hero image.
//
// isNullOrBlank(), not just != null: a feed's media:thumbnail/enclosure can have
// url="" (FeedParser only checks for null, not blank), which produced a broken
// <img src=""> instead of just showing nothing.
val heroHtml = if (!imageUrl.isNullOrBlank() && !bodyContent.contains("<img", ignoreCase = true)) {
"<img class=\"article-hero\" src=\"${escapeHtml(imageUrl)}\" alt=\"\">"
} else ""
return """
<html><head><meta name="viewport" content="width=device-width, initial-scale=1">
<style>
body { font-family: sans-serif; font-size: ${fontSizePx}px; 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; }
p.article-date { font-size: 0.85em; color: #9aa0a6; margin: -0.4em 0 1em 0; }
img.article-hero { border-radius: 8px; margin: 0 0 1em 0; }
a { color: #8ab4f8; }
img { max-width: 100%; height: auto; }
svg { max-width: 100%; }
/* WordPress captioned images (<figure class="wp-caption" style="width: 448px">, or the
older <div class="wp-caption" style="width: ...">) carry a fixed pixel width inline on
the wrapper itself, sized for the source site's wider column. Our img rule above only
clamps the <img>, which then just fills that oversized wrapper -- so the wrapper (and
the image with it) blows past the reader's own column edge on a narrow screen. Clamp
the wrapper too; max-width still wins over the inline width per the CSS box model. */
figure, .wp-caption { max-width: 100%; box-sizing: border-box; }
/* 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; }
/* Maths arrives as MathML, which the WebView renders itself -- no script, no webfont,
no stylesheet of KaTeX's (see ReaderExtractor). It only needs containing: an aligned
multi-line derivation is routinely wider than a phone screen, and <math> obeys
neither the img nor the svg clamp above, so without this one wide equation drags the
whole article into horizontal scrolling. */
math { max-width: 100%; }
math[display="block"] { display: block; overflow-x: auto; overflow-y: hidden; margin: 1em 0; }
</style></head>
<body>${if (!title.isNullOrBlank()) "<h1 class=\"article-title\">${escapeHtml(title)}</h1>" else ""}${
if (publishedAt != null) "<p class=\"article-date\">${escapeHtml(absoluteDateText(publishedAt))}</p>" else ""
}${linkifyImagesForZoom(heroHtml + processedBody)}</body></html>
""".trimIndent()
}
|