add reader amoled mode; improve reader mode ui;

This commit is contained in:
Fabian Freund
2026-06-21 10:28:38 +02:00
parent 2060275c0e
commit 3f6f20d61d
18 changed files with 1265 additions and 38 deletions
@@ -0,0 +1,23 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
// This background script is needed to update the current tab
// and activate reader view.
browser.runtime.onMessage.addListener(message => {
switch (message.action) {
case "addSerializedDoc":
browser.storage.session.set({ [message.id]: message.doc });
return Promise.resolve();
case "getSerializedDoc":
return (async () => {
let doc = await browser.storage.session.get(message.id);
browser.storage.session.remove(message.id);
return doc[message.id];
})();
default:
console.error(`Received unsupported action ${message.action}`);
return false;
}
});
@@ -0,0 +1,333 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* WebLibre: this file overrides the upstream Mozilla Android Components reader
* view stylesheet (org.mozilla.components:feature-readerview). It is identical
* to upstream except for the AMOLED / pure-black additions, which are gated on
* the `amoled` body class. That class is toggled by readerview.js based on the
* WebLibre "pure black" setting (see ReaderViewAppearanceFeature on the native
* side). When upgrading mozilla-components, re-sync this file with upstream and
* re-apply the AMOLED rules below. */
.mozac-readerview-body {
padding: 20px;
transition-property: background-color, color;
transition-duration: 0.4s;
max-width: 35em;
margin-left: auto;
margin-right: auto;
}
.mozac-readerview-body.light {
background-color: #ffffff;
color: #222222;
}
.mozac-readerview-body.sepia {
color: #5b4636;
background-color: #f4ecd8;
}
.mozac-readerview-body.dark {
background-color: #1c1b22;
color: #eeeeee;
}
/* WebLibre AMOLED / pure-black: only applies on top of the dark scheme when the
* WebLibre "pure black" setting is enabled. */
.mozac-readerview-body.dark.amoled {
background-color: #000000;
}
.mozac-readerview-body.light * {
color: #222222;
}
.mozac-readerview-body.sepia * {
color: #5b4636;
}
.mozac-readerview-body.dark * {
color: #eeeeee;
}
.mozac-readerview-body.sans-serif * {
font-family: sans-serif !important;
}
.mozac-readerview-body.serif * {
font-family: serif !important;
}
/* Override some controls and content styles based on color scheme */
.mozac-readerview-body.light > .container > .header > .domain {
color: #ee7600;
border-bottom-color: #d0d0d0;
}
.mozac-readerview-body.light > .container > .header > h1 {
color: #222222;
}
.mozac-readerview-body.light > .container > .header > .credits {
color: #898989;
}
.mozac-readerview-body.dark > .container > .header > .domain {
color: #ff9400;
border-bottom-color: #777777;
}
.mozac-readerview-body.dark > .container > .header > h1 {
color: #eeeeee;
}
.mozac-readerview-body.dark > .container > .header > .credits {
color: #aaaaaa;
}
.mozac-readerview-body.sepia > .container > .header > .domain {
border-bottom-color: #5b4636 !important;
}
.mozac-readerview-body.sepia > .container > .footer {
background-color: #dedad4 !important;
}
.mozac-readerview-body.light > .container > .content .caption,
.mozac-readerview-body.light > .container > .content .wp-caption-text,
.mozac-readerview-body.light > .container > .content figcaption {
color: #898989;
}
.mozac-readerview-body.dark > .container > .content .caption,
.mozac-readerview-body.dark > .container > .content .wp-caption-text,
.mozac-readerview-body.dark > .container > .content figcaption {
color: #aaaaaa;
}
.mozac-readerview-body.light > .container > .content blockquote {
color: #898989 !important;
border-left-color: #d0d0d0 !important;
}
.mozac-readerview-body.sepia blockquote {
border-inline-start: 2px solid #5b4636 !important;
}
.mozac-readerview-body.dark > .container > .content blockquote {
color: #aaaaaa !important;
border-left-color: #777777 !important;
}
.mozac-readerview-body > .container > hr {
margin: 0px;
}
.mozac-readerview-body > .container > .header {
text-align: start;
padding-bottom: 10px;
}
.mozac-readerview-body > .container > .header > .credits {
font-size: 0.9em;
}
.mozac-readerview-body > .container > .header > .domain {
margin-top: 10px;
padding-bottom: 10px;
color: #00acff !important;
text-decoration: none;
}
.mozac-readerview-body > .container > .header > .domain-border {
margin-top: 15px;
border-bottom: 1.5px solid #777777;
width: 50%;
}
.mozac-readerview-body > .container > .header > h1 {
font-size: 1.33em;
font-weight: 700;
line-height: 1.1em;
width: 100%;
margin: 0px;
margin-top: 32px;
margin-bottom: 16px;
padding: 0px;
}
.mozac-readerview-body > .container > .header > .credits {
padding: 0px;
margin: 0px;
margin-bottom: 32px;
}
.mozac-readerview-body > .container > .header > .meta-data {
font-size: 0.65em;
margin: 0 0 15px 0;
}
.mozac-readerview-body > .container > .content {
padding-top: 10px;
padding-left: 0px;
padding-right: 0px;
}
/*======= Article content =======*/
.mozac-readerview-content {
font-size: 1em;
}
.mozac-readerview-content a {
text-decoration: underline !important;
font-weight: normal;
}
.mozac-readerview-body.dark :is(
.mozac-readerview-content a,
.mozac-readerview-content a:hover,
.mozac-readerview-content a:active
):not(.mozac-readerview-content a:visited) {
color: #45a1ff !important;
}
.mozac-readerview-content a,
.mozac-readerview-content a:hover,
.mozac-readerview-content a:active
:not(.mozac-readerview-content a:visited) {
color: #0060df !important;
}
.mozac-readerview-content a:visited {
color: #b5007f !important;
}
.mozac-readerview-content h1 {
margin-top: 16px;
margin-bottom: 16px;
font-weight: 700;
font-size: 1.6em;
}
.mozac-readerview-content h2 {
margin-top: 16px;
margin-bottom: 16px;
font-weight: 700;
font-size: 1.2em;
}
.mozac-readerview-content h3 {
margin-top: 16px;
margin-bottom: 16px;
font-weight: 700;
font-size: 1em;
}
.mozac-readerview-content * {
max-width: 100% !important;
height: auto !important;
}
.mozac-readerview-content p {
font-size: 1em !important;
line-height: 1.4em !important;
margin: 0px !important;
margin-bottom: 20px !important;
}
/* Covers all images showing edge-to-edge using a
an optional caption text */
.mozac-readerview-content .wp-caption,
.mozac-readerview-content figure {
display: block !important;
width: 100% !important;
margin: 0px !important;
margin-bottom: 32px !important;
}
/* Images marked to be shown edge-to-edge with an
optional captio ntext */
.mozac-readerview-content p > img:only-child,
.mozac-readerview-content p > a:only-child > img:only-child,
.mozac-readerview-content .wp-caption img,
.mozac-readerview-content figure img {
display: block;
margin-left: auto;
margin-right: auto;
}
/* Account for body padding to make image full width */
.mozac-readerview-content img[moz-reader-full-width] {
width: calc(100% + 40px);
margin-left: -20px;
margin-right: -20px;
max-width: none !important;
}
/* Image caption text */
.mozac-readerview-content .caption,
.mozac-readerview-content .wp-caption-text,
.mozac-readerview-content figcaption {
font-size: 0.9em;
font-family: sans-serif;
margin: 0px !important;
padding-top: 4px !important;
}
/* Ensure all pre-formatted code inside the reader content
are properly wrapped inside content width */
.mozac-readerview-content code,
.mozac-readerview-content pre {
white-space: pre-wrap !important;
margin-bottom: 20px !important;
}
.mozac-readerview-content blockquote {
margin: 0px !important;
margin-bottom: 20px !important;
padding: 0px !important;
padding-inline-start: 16px !important;
border: 0px !important;
border-left: 2px solid !important;
}
.mozac-readerview-content ul,
.mozac-readerview-content ol {
margin: 0px !important;
margin-bottom: 20px !important;
padding: 0px !important;
line-height: 1.5em;
}
.mozac-readerview-content ul {
padding-inline-start: 30px !important;
list-style: disc !important;
}
.mozac-readerview-content ol {
padding-inline-start: 35px !important;
list-style: decimal !important;
}
/* Hide elements with common "hidden" class names */
.mozac-readerview-content .visually-hidden,
.mozac-readerview-content .visuallyhidden,
.mozac-readerview-content .hidden,
.mozac-readerview-content .invisible,
.mozac-readerview-content .sr-only {
}
/* Enforce wordpress and similar emoji/smileys aren't sized to be full-width,
* see bug 1399616 for context. */
.mozac-readerview-content img.wp-smiley,
.mozac-readerview-content img.emoji {
display: inline-block;
border-width: 0;
/* height: auto is implied from `.mozac-readerview-content *` rule. */
width: 1em;
margin: 0 .07em;
padding: 0;
}
@@ -0,0 +1,477 @@
/* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this file,
* You can obtain one at http://mozilla.org/MPL/2.0/. */
/* eslint-disable no-unsanitized/property */ /* bug 1903144 */
/* import-globals-from readability/readability-0.4.2.js */
/* import-globals-from readability/JSDOMParser-0.4.2.js */
/* WebLibre: this file overrides the upstream Mozilla Android Components reader
* view script (org.mozilla.components:feature-readerview). It is identical to
* upstream except for the AMOLED / pure-black additions (search for "WebLibre").
* The native side (ReaderViewAppearanceFeature) pushes the WebLibre "pure black"
* flag over a dedicated content port ("weblibreReaderviewActive") whenever a
* reader page connects and whenever the user toggles the setting. The flag is
* reflected as the `amoled` body class, which readerview.css uses to render the
* dark scheme as pure black. We also cache it in browser.storage.local so a
* freshly opened reader page can apply the last known value before the native
* push arrives (avoiding a flash). When upgrading mozilla-components, re-sync
* with upstream and re-apply the WebLibre additions. */
// Class names to preserve in the readerized output. We preserve these class
// names so that rules in readerview.css can match them. This list is taken from Fennec:
// https://dxr.mozilla.org/mozilla-central/rev/7d47e7fa2489550ffa83aae67715c5497048923f/toolkit/components/reader/ReaderMode.jsm#21
const preservedClasses = [
"caption",
"emoji",
"hidden",
"invisible",
"sr-only",
"visually-hidden",
"visuallyhidden",
"wp-caption",
"wp-caption-text",
"wp-smiley",
];
// WebLibre: key in browser.storage.local caching the last known "pure black"
// flag, and the native content port the flag is pushed over.
const WEBLIBRE_AMOLED_STORAGE_KEY = "amoled";
const WEBLIBRE_APPEARANCE_PORT = "weblibreReaderviewActive";
// WebLibre: last known "pure black" flag. Updated by the native push and the
// storage cache; re-applied whenever the body element is (re)created.
let weblibreAmoled = false;
class ReaderView {
static get MIN_FONT_SIZE() {
return 1;
}
static get MAX_FONT_SIZE() {
return 9;
}
/**
* Shows a reader view for the provided document. This method is used when activating
* reader view on the original page. In this case, we already have the DOM (passed
* through in the message from the background script) and can parse it directly.
*
* @param doc the document to make readerable.
* @param url the url of the article.
* @param options the fontSize, fontType and colorScheme to use.
*/
show(
doc,
url,
options = { fontSize: 4, fontType: "sans-serif", colorScheme: "light" }
) {
let result = new Readability(doc, {
classesToPreserve: preservedClasses,
}).parse();
result.language = doc.documentElement.lang;
document.title = result.title;
let article = Object.assign(
result,
{ url: new URL(url) },
{ readingTime: this.getReadingTime(result.length, result.language) },
{ byline: this.getByline(result) },
{ dir: this.getTextDirection(result) },
{ title: this.getTitle(result) }
);
document.body.outerHTML = this.createHtmlBody(article);
this.setFontSize(options.fontSize);
this.setFontType(options.fontType);
this.setColorScheme(options.colorScheme);
// WebLibre: re-apply the pure-black flag. createHtmlBody() replaced the body
// element (dropping any class set in prepareBody), so re-apply it here.
weblibreApplyAmoled(weblibreAmoled);
if (options.scrollY) {
window.scrollTo({ top: options.scrollY, left: 0, behavior: "instant" });
}
}
/**
* Allows adjusting the font size in discrete steps between ReaderView.MIN_FONT_SIZE
* and ReaderView.MAX_FONT_SIZE.
*
* @param changeAmount e.g. +1, or -1.
*/
changeFontSize(changeAmount) {
var size = Math.max(
ReaderView.MIN_FONT_SIZE,
Math.min(ReaderView.MAX_FONT_SIZE, this.fontSize + changeAmount)
);
this.setFontSize(size);
}
/**
* Sets the font size.
*
* @param fontSize must be value between ReaderView.MIN_FONT_SIZE
* and ReaderView.MAX_FONT_SIZE.
*/
setFontSize(fontSize) {
let size = 10 + 2 * fontSize + "px";
let readerView = document.getElementById("mozac-readerview-container");
readerView.style.setProperty("font-size", size);
this.fontSize = fontSize;
}
/**
* Sets the font type.
*
* @param fontType the font type to use.
*/
setFontType(fontType) {
let bodyClasses = document.body.classList;
if (this.fontType) {
bodyClasses.remove(this.fontType);
}
this.fontType = fontType;
bodyClasses.add(this.fontType);
}
/**
* Sets the color scheme.
*
* @param colorScheme the color scheme to use, must be either light, dark
* or sepia.
*/
setColorScheme(colorScheme) {
if (!["light", "sepia", "dark"].includes(colorScheme)) {
console.error(`Invalid color scheme specified: ${colorScheme}`);
return;
}
let bodyClasses = document.body.classList;
if (this.colorScheme) {
bodyClasses.remove(this.colorScheme);
}
this.colorScheme = colorScheme;
bodyClasses.add(this.colorScheme);
}
/**
* Create the reader view HTML body.
*
* @param article a JSONObject representing the article to show.
*/
createHtmlBody(article) {
const safeDir = this.escapeHTML(article.dir);
const safeTitle = this.escapeHTML(article.title);
const safeByline = this.escapeHTML(article.byline);
const safeReadingTime = this.escapeHTML(article.readingTime);
return `
<body class="mozac-readerview-body">
<div id="mozac-readerview-container" class="container" dir="${safeDir}">
<div class="header">
<a class="domain" href="${article.url.href}">${article.url.hostname}</a>
<div class="domain-border"></div>
<h1>${safeTitle}</h1>
<div class="credits">${safeByline}</div>
<div>
<div>${safeReadingTime}</div>
</div>
</div>
<hr>
<div class="content">
<div class="mozac-readerview-content">${article.content}</div>
</div>
</div>
</body>
`;
}
/**
* Returns the estimated reading time as localized string.
*
* @param length of the article (number of chars).
* @param optional language of the article, defaults to en.
*/
getReadingTime(length, lang = "en") {
const [readingSpeed, readingSpeedLang] =
this.getReadingSpeedForLanguage(lang);
const charactersPerMinuteLow = readingSpeed.cpm - readingSpeed.variance;
const charactersPerMinuteHigh = readingSpeed.cpm + readingSpeed.variance;
const readingTimeMinsSlow = Math.ceil(length / charactersPerMinuteLow);
const readingTimeMinsFast = Math.ceil(length / charactersPerMinuteHigh);
// Construct a localized and "humanized" reading time in minutes.
// If we have both a fast and slow reading time we'll show both e.g.
// "2 - 4 minutes", otherwise we'll just show "4 minutes".
try {
var parts = new Intl.RelativeTimeFormat(readingSpeedLang).formatToParts(
readingTimeMinsSlow,
"minute"
);
if (parts.length == 3) {
// No need to use part[0] which represents the literal "in".
var readingTime = parts[1].value; // reading time in minutes
var minutesLiteral = parts[2].value; // localized singular or plural literal of 'minute'
var readingTimeString = `${readingTime} ${minutesLiteral}`;
if (readingTimeMinsSlow != readingTimeMinsFast) {
readingTimeString = `${readingTimeMinsFast} - ${readingTimeString}`;
}
return readingTimeString;
}
} catch (error) {
console.error(`Failed to format reading time: ${error}`);
}
return "";
}
/**
* Returns the reading speed of a selection of languages with likely variance.
*
* Reading speed estimated from a study done on reading speeds in various languages.
* study can be found here: http://iovs.arvojournals.org/article.aspx?articleid=2166061
*
* @return object with characters per minute and variance. Defaults to English
* if no suitable language is found in the collection.
*/
getReadingSpeedForLanguage(lang) {
const readingSpeed = new Map([
["en", { cpm: 987, variance: 118 }],
["ar", { cpm: 612, variance: 88 }],
["de", { cpm: 920, variance: 86 }],
["es", { cpm: 1025, variance: 127 }],
["fi", { cpm: 1078, variance: 121 }],
["fr", { cpm: 998, variance: 126 }],
["he", { cpm: 833, variance: 130 }],
["it", { cpm: 950, variance: 140 }],
["ja", { cpm: 357, variance: 56 }],
["nl", { cpm: 978, variance: 143 }],
["pl", { cpm: 916, variance: 126 }],
["pt", { cpm: 913, variance: 145 }],
["ru", { cpm: 986, variance: 175 }],
["sl", { cpm: 885, variance: 145 }],
["sv", { cpm: 917, variance: 156 }],
["tr", { cpm: 1054, variance: 156 }],
["zh", { cpm: 255, variance: 29 }],
]);
return readingSpeed.has(lang)
? [readingSpeed.get(lang), lang]
: [readingSpeed.get("en"), "en"];
}
getByline(article) {
return article.byline || "";
}
/**
* Attempts to read the optional text direction from the article and uses
* language mapping to detect rtl, if missing.
*/
getTextDirection(article) {
if (article.dir) {
return article.dir;
}
if (["ar", "fa", "he", "ug", "ur"].includes(article.language)) {
return "rtl";
}
return "ltr";
}
getTitle(article) {
return article.title || "";
}
escapeHTML(text) {
return text
.replace(/\&/g, "&amp;")
.replace(/\</g, "&lt;")
.replace(/\>/g, "&gt;")
.replace(/\"/g, "&quot;")
.replace(/\'/g, "&#039;");
}
}
function fetchDocument(url) {
return new Promise((resolve, reject) => {
let xhr = new XMLHttpRequest();
xhr.open("GET", url, true);
xhr.onerror = evt => reject(evt.error);
xhr.responseType = "document";
xhr.onload = _evt => {
if (xhr.status !== 200) {
reject("Reader mode XHR failed with status: " + xhr.status);
return;
}
let doc = xhr.responseXML;
if (!doc) {
reject("Reader mode XHR didn't return a document");
return;
}
resolve(doc);
};
xhr.send();
});
}
function getPreparedDocument(id, url) {
return new Promise((resolve, reject) => {
browser.runtime
.sendMessage({ action: "getSerializedDoc", id })
.then(serializedDoc => {
if (serializedDoc) {
// eslint-disable-next-line no-undef
let doc = new JSDOMParser().parse(serializedDoc, url);
resolve(doc);
} else {
reject();
}
});
});
}
/**
* WebLibre: applies the given pure-black flag to the body as the `amoled` class.
* readerview.css only acts on it when combined with the dark scheme. The latest
* value is remembered so it can be re-applied after the body is recreated.
*/
function weblibreApplyAmoled(enabled) {
weblibreAmoled = !!enabled;
if (document.body) {
document.body.classList.toggle("amoled", weblibreAmoled);
}
}
/**
* WebLibre: applies the cached pure-black flag from extension storage. This is a
* fast path so a freshly opened reader page can paint with the right background
* before the authoritative native push arrives over the appearance port.
*/
function weblibreApplyCachedAmoled() {
try {
browser.storage.local
.get(WEBLIBRE_AMOLED_STORAGE_KEY)
.then(result => weblibreApplyAmoled(result[WEBLIBRE_AMOLED_STORAGE_KEY]))
.catch(() => {});
} catch (e) {
// browser.storage may be unavailable in some contexts; ignore.
}
}
/**
* WebLibre: connects the appearance port. The native side pushes the current
* pure-black flag on connect and whenever the user toggles the setting, so this
* works both for newly opened reader pages and for live toggles while a reader
* page is already on screen. The value is cached for the next page load.
*/
function weblibreConnectAppearancePort() {
try {
let port = browser.runtime.connectNative(WEBLIBRE_APPEARANCE_PORT);
port.onMessage.addListener(message => {
if (message && typeof message.amoled !== "undefined") {
weblibreApplyAmoled(message.amoled);
try {
browser.storage.local.set({
[WEBLIBRE_AMOLED_STORAGE_KEY]: weblibreAmoled,
});
} catch (e) {
// ignore cache write failures
}
}
});
} catch (e) {
console.error(`WebLibre reader appearance port failed: ${e}`);
}
}
let readerView = new ReaderView();
connectNativePort();
weblibreConnectAppearancePort();
prepareBody();
function connectNativePort() {
let url = new URL(window.location.href);
let articleUrl = url.searchParams.get("url");
let id = url.searchParams.get("id");
let baseUrl = browser.runtime.getURL("/");
let port = browser.runtime.connectNative("mozacReaderviewActive");
port.onMessage.addListener(message => {
switch (message.action) {
case "show": {
async function showAsync(options) {
try {
let doc;
if (typeof Promise.any === "function") {
doc = await Promise.any([
fetchDocument(articleUrl),
getPreparedDocument(id, articleUrl),
]);
} else {
try {
doc = await getPreparedDocument(id, articleUrl);
} catch (e) {
doc = await fetchDocument(articleUrl);
}
}
readerView.show(doc, articleUrl, options);
} catch (e) {
// eslint-disable-next-line no-console
console.log(e);
// We weren't able to find the prepared document and also
// failed to fetch it. Let's load the original page which
// will make sure we show an appropriate error page.
window.location.href = articleUrl;
}
}
showAsync(message.value);
break;
}
case "hide":
window.location.href = articleUrl;
break;
case "setColorScheme":
readerView.setColorScheme(message.value.toLowerCase());
break;
case "changeFontSize":
readerView.changeFontSize(message.value);
break;
case "setFontType":
readerView.setFontType(message.value.toLowerCase());
break;
case "checkReaderState":
port.postMessage({
baseUrl,
activeUrl: articleUrl,
readerable: true,
});
break;
default:
console.error(`Received invalid action ${message.action}`);
}
});
}
/**
* Applies the configured color scheme to the HTML body while reader view is loading. This is to
* prevent "flashes" caused by having to change the color later.
*/
function prepareBody() {
let url = new URL(window.location.href);
let colorScheme = url.searchParams.get("colorScheme");
let body = document.createElement("body");
body.classList.add("mozac-readerview-body");
body.classList.add(colorScheme);
document.body = body;
// WebLibre: apply the cached pure-black flag as early as possible to avoid a
// flash from the regular dark background to pure black once the article loads.
// The native push over the appearance port corrects it if the cache is stale.
weblibreApplyCachedAmoled();
}
@@ -5,10 +5,12 @@
package eu.weblibre.flutter_mozilla_components
import android.content.Context
import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.feature.ContainerProxyFeature
import eu.weblibre.flutter_mozilla_components.feature.CookieManagerFeature
import eu.weblibre.flutter_mozilla_components.feature.BrowserExtensionFeature
import eu.weblibre.flutter_mozilla_components.feature.MLEngineFeature
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
import eu.weblibre.flutter_mozilla_components.feature.SandboxCaptureFeature
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode
import eu.weblibre.flutter_mozilla_components.pigeons.BrowserExtensionEvents
@@ -135,11 +137,12 @@ object EngineProvider {
SandboxCaptureFeature.install(it)
BuiltInWebExtensionController(
"readerview@mozac.org",
"resource://android/assets/extensions/readerview/",
"mozacReaderview",
).install(it)
// Installs Mozilla's reader view extension early and wires the
// WebLibre "pure black" (AMOLED) appearance bridge into it.
ReaderViewAppearanceFeature.install(
it,
PreferenceManager.getDefaultSharedPreferences(context),
)
}
}
@@ -132,6 +132,26 @@ object GlobalComponents {
val bottomViewportInsetPx: Int
get() = (dynamicToolbarMaxHeightPx + verticalClippingPx).coerceAtLeast(0)
// Listeners notified when [bottomViewportInsetPx] may have changed (i.e. when
// the dynamic toolbar height or vertical clipping is updated). Lets native
// views that align to the bottom chrome (e.g. the reader view controls bar)
// re-apply their inset while visible, not only when first shown.
private val bottomViewportInsetListeners =
java.util.concurrent.CopyOnWriteArraySet<(Int) -> Unit>()
fun addBottomViewportInsetListener(listener: (Int) -> Unit) {
bottomViewportInsetListeners.add(listener)
}
fun removeBottomViewportInsetListener(listener: (Int) -> Unit) {
bottomViewportInsetListeners.remove(listener)
}
fun notifyBottomViewportInsetChanged() {
val inset = bottomViewportInsetPx
bottomViewportInsetListeners.forEach { it(inset) }
}
// External download manager setting
var useExternalDownloadManager: Boolean = false
@@ -11,6 +11,7 @@ import androidx.preference.PreferenceManager
import eu.weblibre.flutter_mozilla_components.ColorSchemePreference
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.R
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
import eu.weblibre.flutter_mozilla_components.pigeons.AppLinksMode
import eu.weblibre.flutter_mozilla_components.pigeons.BounceTrackingProtectionMode as PigeonBounceTrackingProtectionMode
import eu.weblibre.flutter_mozilla_components.pigeons.ColorScheme
@@ -501,4 +502,18 @@ class GeckoEngineSettingsApiImpl : GeckoEngineSettingsApi {
}
}
}
override fun setReaderViewPureBlack(enabled: Boolean) {
// Push to every tab whose reader view is currently active so the change
// applies live, without depending on a single tracked session.
val activeReaderSessions = components.core.store.state.tabs
.filter { it.readerState.active }
.mapNotNull { it.engineState.engineSession }
ReaderViewAppearanceFeature.setPureBlack(
enabled,
components.core.prefs,
activeReaderSessions,
)
}
}
@@ -37,6 +37,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
override fun setDynamicToolbarMaxHeight(heightPx: Long) {
val height = heightPx.toInt()
GlobalComponents.dynamicToolbarMaxHeightPx = height
GlobalComponents.notifyBottomViewportInsetChanged()
val engineView = components.mainBrowserEngineView
if (engineView == null) {
@@ -67,6 +68,7 @@ class GeckoViewportApiImpl : GeckoViewportApi {
override fun setVerticalClipping(clippingPx: Long) {
val clipping = clippingPx.toInt()
GlobalComponents.verticalClippingPx = clipping
GlobalComponents.notifyBottomViewportInsetChanged()
val engineView = components.mainBrowserEngineView
if (engineView == null) {
@@ -9,6 +9,7 @@ package eu.weblibre.flutter_mozilla_components.components
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.feature.ReaderViewAppearanceFeature
import eu.weblibre.flutter_mozilla_components.ext.toWebPBytes
import eu.weblibre.flutter_mozilla_components.pigeons.ExternalApplicationResource
import eu.weblibre.flutter_mozilla_components.pigeons.FindResultState
@@ -160,6 +161,42 @@ class Events(
}
}
// Register the WebLibre "pure black" appearance content port whenever a
// tab's reader view becomes active. Store-driven (rather than the user's
// reader toggle) so it also covers reader views restored on app start.
//
// Keyed on both readerState.active AND the engine session: a restored
// reader tab can already be active before its engine session is linked,
// and the active flag never changes afterwards — so we must also react to
// the session becoming available to register on the right session.
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
flow.mapNotNull { state -> state.tabs }
.filterChanged { it.readerState.active to it.engineState.engineSession }
.collect { tab ->
if (tab.readerState.active) {
tab.engineState.engineSession?.let { session ->
ReaderViewAppearanceFeature.registerSession(session)
}
}
}
}
// Keep the reader appearance (font/settings) button in sync with the
// selected tab's actual reader-active state. Driven by the store rather
// than the user's reader toggle (ReaderViewIntegration) so the button
// also appears for reader views restored on app start ("resume last tab"),
// which never go through an explicit toggle.
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
flow.map { state -> state.selectedTab?.readerState?.active ?: false }
.distinctUntilChanged()
.collect { active ->
GlobalComponents.components?.readerViewController?.appearanceButtonVisibility(
EventSequence.next(),
active,
) { _ -> }
}
}
stateFlow.flowScoped(dispatcher = Dispatchers.Main) { flow ->
flow.mapNotNull { state -> state.tabs }
.filterChanged {
@@ -0,0 +1,136 @@
/*
* This Source Code Form is subject to the terms of the Mozilla Public
* License, v. 2.0. If a copy of the MPL was not distributed with this
* file, You can obtain one at https://mozilla.org/MPL/2.0/.
*/
package eu.weblibre.flutter_mozilla_components.feature
import android.content.SharedPreferences
import androidx.annotation.VisibleForTesting
import androidx.core.content.edit
import mozilla.components.concept.engine.EngineSession
import mozilla.components.concept.engine.webextension.MessageHandler
import mozilla.components.concept.engine.webextension.Port
import mozilla.components.concept.engine.webextension.WebExtensionRuntime
import mozilla.components.support.base.log.logger.Logger
import mozilla.components.support.webextensions.BuiltInWebExtensionController
import org.json.JSONObject
/**
* Bridges WebLibre's "pure black" setting into Mozilla's reader view extension so
* the dark color scheme can be rendered as pure black (AMOLED).
*
* Flutter remains the source of truth for the setting (see
* general_settings.dart `pureBlack` and engine_settings_replication.dart).
* Mozilla's reader view extension (`readerview@mozac.org`) is a prebuilt part of
* `org.mozilla.components:feature-readerview`; we override its bundled assets
* (readerview.css / readerview.js) to honor an `amoled` body class.
*
* The flag is delivered into the reader page over a dedicated **content** port
* ([APPEARANCE_PORT]) registered on the reader tab's engine session. We use a
* content port (registered whenever a tab's reader view becomes active, see
* [registerSession]) rather than the extension's background script because the
* reader extension's background is a non-persistent event page that gets
* suspended when idle, dropping pushes; the reader page (and therefore its
* content port) is alive exactly while reader view is on screen. The value is
* pushed when the page connects ([MessageHandler.onPortConnected]) and again
* whenever the user toggles the setting ([setPureBlack]).
*
* Registration is driven by the BrowserStore reader-active state (see
* Events.kt), not the user's reader toggle, so it also covers reader views that
* were restored on app start ("resume last tab") without an explicit toggle.
*
* The value is also persisted in [SharedPreferences] so it survives process
* restarts (e.g. a cold-started Custom Tab / PWA reader view before Flutter has
* pushed the setting).
*/
object ReaderViewAppearanceFeature {
private val logger = Logger("reader-view-appearance")
private const val EXTENSION_ID = "readerview@mozac.org"
private const val EXTENSION_URL = "resource://android/assets/extensions/readerview/"
private const val APPEARANCE_PORT = "weblibreReaderviewActive"
private const val PREF_KEY = "weblibre_reader_pure_black"
private const val MESSAGE_KEY_AMOLED = "amoled"
@Volatile
private var pureBlack: Boolean = false
@VisibleForTesting
// Internal var to make it mutable for unit testing purposes only.
internal var extensionController = BuiltInWebExtensionController(
EXTENSION_ID,
EXTENSION_URL,
APPEARANCE_PORT,
)
private val messageHandler = object : MessageHandler {
override fun onPortConnected(port: Port) {
// Push the current value as soon as the reader page connects, so it
// can correct the cached value it applied on load.
port.postMessage(currentMessage())
}
}
private fun currentMessage(): JSONObject =
JSONObject().put(MESSAGE_KEY_AMOLED, pureBlack)
/**
* Installs Mozilla's reader view extension early and seeds the persisted
* value. The reader view feature itself (from android-components) reuses the
* already-installed extension via the shared built-in extension registry, so
* installing it here does not interfere with its lifecycle.
*/
fun install(runtime: WebExtensionRuntime, prefs: SharedPreferences) {
pureBlack = prefs.getBoolean(PREF_KEY, false)
extensionController.install(
runtime,
onSuccess = {
logger.debug("Installed reader view extension: ${it.id}")
},
onError = { throwable ->
logger.error("Failed to install reader view extension", throwable)
},
)
}
/**
* Registers the appearance content port on an engine session whose reader
* view has become active. Idempotent (safe to call again for the same
* session). Pushes the current value immediately if the port is already
* connected (e.g. a restored reader page that connected before this ran);
* otherwise [MessageHandler.onPortConnected] pushes it once the page connects.
*/
fun registerSession(session: EngineSession) {
extensionController.registerContentMessageHandler(
session,
messageHandler,
APPEARANCE_PORT,
)
pushTo(session)
}
/**
* Updates the pure-black flag, persists it, and pushes it to every currently
* active reader page so the change is reflected immediately without having to
* re-enter reader view.
*
* @param sessions the engine sessions of all tabs whose reader view is active.
*/
fun setPureBlack(enabled: Boolean, prefs: SharedPreferences, sessions: List<EngineSession>) {
pureBlack = enabled
prefs.edit { putBoolean(PREF_KEY, enabled) }
sessions.forEach { pushTo(it) }
}
private fun pushTo(session: EngineSession) {
if (extensionController.portConnected(session, APPEARANCE_PORT)) {
extensionController.sendContentMessage(currentMessage(), session, APPEARANCE_PORT)
}
}
}
@@ -8,7 +8,11 @@ package eu.weblibre.flutter_mozilla_components.integration
import android.content.Context
import android.graphics.drawable.Drawable
import android.view.View
import androidx.core.content.ContextCompat
import androidx.core.view.ViewCompat
import androidx.core.view.WindowInsetsCompat
import androidx.core.view.updatePadding
import eu.weblibre.flutter_mozilla_components.GlobalComponents
import eu.weblibre.flutter_mozilla_components.ext.EventSequence
import eu.weblibre.flutter_mozilla_components.api.ReaderViewEventsImpl
@@ -27,12 +31,18 @@ class ReaderViewIntegration(
context: Context,
engine: Engine,
store: BrowserStore,
view: ReaderViewControlsView,
private val view: ReaderViewControlsView,
private val readerViewEvents: ReaderViewEventsImpl,
readerViewController: ReaderViewController
) : LifecycleAwareFeature, UserInteractionHandler {
private var listenerRegistered = false
// Re-applies the controls bar inset whenever the bottom chrome changes (tab
// bar shown/hidden, stacking mode, etc.) while the controls are on screen.
private val bottomInsetListener: (Int) -> Unit = {
applyControlsBarBottomInset(onlyIfVisible = true)
}
private val controllerListener = object : ReaderViewControllerListener {
override fun onReaderViewToggled(enabled: Boolean) {
if (enabled) {
@@ -48,10 +58,38 @@ class ReaderViewIntegration(
}
override fun onAppearanceButtonTap() {
feature.showControls()
// Toggle: tapping the appearance button while the controls are open
// closes them again, as the user expects.
if ((view as? View)?.visibility == View.VISIBLE) {
feature.hideControls()
} else {
applyControlsBarBottomInset(onlyIfVisible = false)
feature.showControls()
}
}
}
/**
* Lifts the reader controls bar above the current bottom chrome (Flutter
* bottom app bar + system navigation inset) instead of a hardcoded padding,
* which under edge-to-edge left the bar overlapped by the bottom app bar.
* [GlobalComponents.bottomViewportInsetPx] already includes the nav inset
* when the toolbar is visible; fall back to the nav inset alone otherwise.
*
* @param onlyIfVisible when true, skips bars that aren't currently shown
* (used by the live inset listener; the bar is re-padded when next shown).
*/
private fun applyControlsBarBottomInset(onlyIfVisible: Boolean) {
val barView = view as? View ?: return
if (onlyIfVisible && barView.visibility != View.VISIBLE) return
val navInset = ViewCompat.getRootWindowInsets(barView)
?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
barView.updatePadding(
bottom = maxOf(GlobalComponents.bottomViewportInsetPx, navInset),
)
}
private val feature = ReaderViewFeature(context, engine, store, view)
// Will be event based in flutter
// { available, active ->
@@ -65,6 +103,7 @@ class ReaderViewIntegration(
override fun start() {
if (!listenerRegistered) {
readerViewEvents.addListener(controllerListener)
GlobalComponents.addBottomViewportInsetListener(bottomInsetListener)
listenerRegistered = true
}
feature.start()
@@ -73,6 +112,7 @@ class ReaderViewIntegration(
override fun stop() {
if (listenerRegistered) {
readerViewEvents.removeListener(controllerListener)
GlobalComponents.removeBottomViewportInsetListener(bottomInsetListener)
listenerRegistered = false
}
feature.stop()
@@ -7285,6 +7285,13 @@ interface GeckoEngineSettingsApi {
* during startup/replication restore, to avoid clobbering per-tab overrides.
*/
fun setGlobalDesktopMode(enable: Boolean, applyToExistingTabs: Boolean)
/**
* Sets whether the reader view dark color scheme should be rendered as pure
* black (AMOLED). Mirrors WebLibre's "pure black" theme setting into
* Mozilla's reader view extension. Persisted in SharedPreferences so a
* cold-started reader view resolves the right value before Flutter runs.
*/
fun setReaderViewPureBlack(enabled: Boolean)
companion object {
/** The codec used by GeckoEngineSettingsApi. */
@@ -7452,6 +7459,24 @@ interface GeckoEngineSettingsApi {
channel.setMessageHandler(null)
}
}
run {
val channel = BasicMessageChannel<Any?>(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setReaderViewPureBlack$separatedMessageChannelSuffix", codec)
if (api != null) {
channel.setMessageHandler { message, reply ->
val args = message as List<Any?>
val enabledArg = args[0] as Boolean
val wrapped: List<Any?> = try {
api.setReaderViewPureBlack(enabledArg)
listOf(null)
} catch (exception: Throwable) {
GeckoPigeonUtils.wrapError(exception)
}
reply.reply(wrapped)
}
} else {
channel.setMessageHandler(null)
}
}
}
}
}
@@ -29,6 +29,9 @@
<!-- Inject engine here -->
</mozilla.components.ui.widgets.VerticalSwipeRefreshLayout>
<!-- paddingBottom is applied dynamically in ReaderViewIntegration to
clear the Flutter bottom app bar + system navigation inset under
edge-to-edge; do not hardcode it here. -->
<mozilla.components.feature.readerview.view.ReaderViewControlsBar
android:id="@+id/readerViewBar"
android:layout_width="match_parent"
@@ -36,7 +39,6 @@
android:layout_gravity="bottom"
android:background="#FFFFFFFF"
android:elevation="10dp"
android:paddingBottom="55dp"
android:visibility="gone" />
</FrameLayout>
@@ -230,4 +230,8 @@ class GeckoEngineSettingsService {
}) {
return _api.setGlobalDesktopMode(enable, applyToExistingTabs);
}
Future<void> setReaderViewPureBlack(bool enabled) {
return _api.setReaderViewPureBlack(enabled);
}
}
@@ -7386,6 +7386,28 @@ class GeckoEngineSettingsApi {
)
;
}
/// Sets whether the reader view dark color scheme should be rendered as pure
/// black (AMOLED). Mirrors WebLibre's "pure black" theme setting into
/// Mozilla's reader view extension. Persisted in SharedPreferences so a
/// cold-started reader view resolves the right value before Flutter runs.
Future<void> setReaderViewPureBlack(bool enabled) async {
final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setReaderViewPureBlack$pigeonVar_messageChannelSuffix';
final pigeonVar_channel = BasicMessageChannel<Object?>(
pigeonVar_channelName,
pigeonChannelCodec,
binaryMessenger: pigeonVar_binaryMessenger,
);
final Future<Object?> pigeonVar_sendFuture = pigeonVar_channel.send(<Object?>[enabled]);
final pigeonVar_replyList = await pigeonVar_sendFuture as List<Object?>?;
_extractReplyValueOrThrow(
pigeonVar_replyList,
pigeonVar_channelName,
isNullValid: true,
)
;
}
}
class GeckoSessionApi {
@@ -1571,6 +1571,12 @@ abstract class GeckoEngineSettingsApi {
/// in place). This should only be requested for an explicit user toggle, not
/// during startup/replication restore, to avoid clobbering per-tab overrides.
void setGlobalDesktopMode(bool enable, bool applyToExistingTabs);
/// Sets whether the reader view dark color scheme should be rendered as pure
/// black (AMOLED). Mirrors WebLibre's "pure black" theme setting into
/// Mozilla's reader view extension. Persisted in SharedPreferences so a
/// cold-started reader view resolves the right value before Flutter runs.
void setReaderViewPureBlack(bool enabled);
}
@HostApi()