From 3f6f20d61de0df8c856f19ea9516f76889139508 Mon Sep 17 00:00:00 2001 From: Fabian Freund Date: Sun, 21 Jun 2026 10:28:38 +0200 Subject: [PATCH] add reader amoled mode; improve reader mode ui; --- .../services/engine_settings_replication.dart | 17 + .../widgets/browser_modules/browser_fab.dart | 66 ++- .../browser_modules/draggable_fab.dart | 59 ++- .../readerview/readerview-background.js | 23 + .../extensions/readerview/readerview.css | 333 ++++++++++++ .../extensions/readerview/readerview.js | 477 ++++++++++++++++++ .../EngineProvider.kt | 13 +- .../GlobalComponents.kt | 20 + .../api/GeckoEngineSettingsApiImpl.kt | 15 + .../api/GeckoViewportApiImpl.kt | 2 + .../components/Events.kt | 37 ++ .../feature/ReaderViewAppearanceFeature.kt | 136 +++++ .../integration/ReaderViewIntegration.kt | 44 +- .../pigeons/Gecko.g.kt | 25 + .../src/main/res/layout/fragment_browser.xml | 4 +- .../services/gecko_engine_settings.dart | 4 + .../lib/src/pigeons/gecko.g.dart | 22 + .../pigeons/gecko.dart | 6 + 18 files changed, 1265 insertions(+), 38 deletions(-) create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview-background.js create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.css create mode 100644 packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.js create mode 100644 packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReaderViewAppearanceFeature.kt diff --git a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart index 1ca32746..d8a47a26 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/domain/services/engine_settings_replication.dart @@ -108,6 +108,23 @@ class EngineSettingsReplicationService }, ); + ref.listen( + fireImmediately: true, + generalSettingsWithDefaultsProvider.select( + (settings) => settings.pureBlack, + ), + (previous, next) async { + await _service.setReaderViewPureBlack(next); + }, + onError: (error, stackTrace) { + logger.e( + 'Error listening to pureBlack', + error: error, + stackTrace: stackTrace, + ); + }, + ); + ref.listen( fireImmediately: true, generalSettingsWithDefaultsProvider.select( diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart index 4e926c97..cb584a36 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/browser_fab.dart @@ -48,9 +48,38 @@ class BrowserFab extends HookConsumerWidget { ), ); - final Widget child; - if (readerabilityState.active && appearanceButtonVisible) { - child = FloatingActionButton( + final showAppearance = readerabilityState.active && appearanceButtonVisible; + final showDock = toolbarState == ToolbarVisibility.dismissed; + + void forceShowToolbar() { + ref + .read(toolbarVisibilityControllerProvider(selectedTabId).notifier) + .forceShow(); + } + + // Re-show the hidden tab bar / toolbar. Kept available even while reading, + // where the reader appearance button would otherwise take the FAB's slot + // and leave no way to bring the toolbar back. Rendered smaller when paired + // with the appearance button to mark it as the secondary action. + Widget buildDockFab({required bool small}) { + const icon = Icon(MdiIcons.dockBottom); + return small + ? FloatingActionButton.small( + key: const ValueKey('dock_fab'), + heroTag: 'dock_fab', + onPressed: forceShowToolbar, + child: icon, + ) + : FloatingActionButton( + key: const ValueKey('dock_fab'), + heroTag: 'dock_fab', + onPressed: forceShowToolbar, + child: icon, + ); + } + + Widget buildAppearanceFab() { + return FloatingActionButton( key: const ValueKey('appearance_fab'), heroTag: 'appearance_fab', onPressed: () async { @@ -58,21 +87,24 @@ class BrowserFab extends HookConsumerWidget { }, child: const Icon(MdiIcons.formatFont), ); - } else if (toolbarState == ToolbarVisibility.dismissed) { - child = FloatingActionButton( - key: const ValueKey('dock_fab'), - heroTag: 'dock_fab', - onPressed: () { - ref - .read(toolbarVisibilityControllerProvider(selectedTabId).notifier) - .forceShow(); - }, - child: const Icon(MdiIcons.dockBottom), - ); - } else { - child = const SizedBox.shrink(key: ValueKey('no_fab')); } - return child; + if (showAppearance && showDock) { + return Column( + mainAxisSize: MainAxisSize.min, + crossAxisAlignment: CrossAxisAlignment.end, + children: [ + buildDockFab(small: true), + const SizedBox(height: 12), + buildAppearanceFab(), + ], + ); + } else if (showAppearance) { + return buildAppearanceFab(); + } else if (showDock) { + return buildDockFab(small: false); + } else { + return const SizedBox.shrink(key: ValueKey('no_fab')); + } } } diff --git a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart index cff3021e..9ded6db6 100644 --- a/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart +++ b/apps/weblibre/lib/features/geckoview/features/browser/presentation/widgets/browser_modules/draggable_fab.dart @@ -50,30 +50,52 @@ class DraggableFab extends HookConsumerWidget { final disableAnimations = mediaQuery.disableAnimations; final isDragging = useState(false); + // Stored as distances from the bottom-right corner (dx = from right edge, + // dy = from bottom edge) rather than a top-left position, so the FAB is + // anchored by its bottom-right corner. This keeps the primary (bottom) FAB + // in a stable slot when extra FABs are stacked above it (e.g. the re-dock + // button while reading), independent of the child's height. final customOffset = useState(null); final dragStartOffset = useRef(null); final dragStartPosition = useRef(null); + // Actual rendered size of the (possibly stacked) FAB child. Used for drag + // clamping so a taller stacked column (e.g. dock + appearance FABs) can't be + // dragged off-screen. Falls back to [fabSize] until first measured. + final fabKey = useMemoized(GlobalKey.new); + final fabRenderSize = useState(null); + + useEffect(() { + WidgetsBinding.instance.addPostFrameCallback((_) { + final size = fabKey.currentContext?.size; + if (size != null && size != fabRenderSize.value) { + fabRenderSize.value = size; + } + }); + return null; + }); + + final fabWidth = fabRenderSize.value?.width ?? fabSize; + final fabHeight = fabRenderSize.value?.height ?? fabSize; + // Calculate default position (bottom-right, respecting toolbar) final defaultBottom = bottomToolbarVisible ? bottomAppBarHeight + _edgePadding : _edgePadding + bottomSafeArea; const defaultRight = _edgePadding; - final defaultLeft = screenSize.width - fabSize - defaultRight; - final defaultTop = screenSize.height - fabSize - defaultBottom; // Current position: custom if set, otherwise default - final currentLeft = customOffset.value?.dx ?? defaultLeft; - final currentTop = customOffset.value?.dy ?? defaultTop; + final currentRight = customOffset.value?.dx ?? defaultRight; + final currentBottom = customOffset.value?.dy ?? defaultBottom; return Positioned( - left: currentLeft, - top: currentTop, + right: currentRight, + bottom: currentBottom, child: GestureDetector( onLongPressStart: (details) { isDragging.value = true; unawaited(HapticFeedback.mediumImpact()); - dragStartOffset.value = Offset(currentLeft, currentTop); + dragStartOffset.value = Offset(currentRight, currentBottom); dragStartPosition.value = details.globalPosition; }, onLongPressMoveUpdate: (details) { @@ -84,13 +106,19 @@ class DraggableFab extends HookConsumerWidget { } final delta = details.globalPosition - dragStartPosition.value!; - final newOffset = dragStartOffset.value! + delta; + // Dragging right/down reduces the distance from the right/bottom edge. + final newOffset = Offset( + dragStartOffset.value!.dx - delta.dx, + dragStartOffset.value!.dy - delta.dy, + ); // Clamp to screen bounds customOffset.value = _clampToBounds( newOffset, screenSize: screenSize, padding: padding, + fabWidth: fabWidth, + fabHeight: fabHeight, ); }, onLongPressEnd: (details) { @@ -103,7 +131,7 @@ class DraggableFab extends HookConsumerWidget { ? Duration.zero : const Duration(milliseconds: 150), scale: isDragging.value ? 1.1 : 1.0, - child: child, + child: KeyedSubtree(key: fabKey, child: child), ), ), ); @@ -113,17 +141,22 @@ class DraggableFab extends HookConsumerWidget { Offset offset, { required Size screenSize, required EdgeInsets padding, + required double fabWidth, + required double fabHeight, }) { const minEdgePadding = 8.0; + // Distances from the bottom-right corner: keep at least the safe-area inset + // plus a margin on the near edge, and leave room for the (possibly stacked) + // FAB on the far edge so it can't be dragged off-screen. return Offset( offset.dx.clamp( - padding.left + minEdgePadding, - screenSize.width - fabSize - padding.right - minEdgePadding, + padding.right + minEdgePadding, + screenSize.width - fabWidth - padding.left - minEdgePadding, ), offset.dy.clamp( - padding.top + minEdgePadding, - screenSize.height - fabSize - padding.bottom - minEdgePadding, + padding.bottom + minEdgePadding, + screenSize.height - fabHeight - padding.top - minEdgePadding, ), ); } diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview-background.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview-background.js new file mode 100644 index 00000000..d95f068b --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview-background.js @@ -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; + } +}); diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.css b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.css new file mode 100644 index 00000000..3e1fec69 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.css @@ -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; +} diff --git a/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.js b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.js new file mode 100644 index 00000000..96a457ba --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/assets/extensions/readerview/readerview.js @@ -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 ` + +
+
+ ${article.url.hostname} +
+

${safeTitle}

+
${safeByline}
+
+
${safeReadingTime}
+
+
+
+ +
+
${article.content}
+
+
+ + `; + } + + /** + * 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, "&") + .replace(/\/g, ">") + .replace(/\"/g, """) + .replace(/\'/g, "'"); + } +} + +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(); +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt index 38478ecb..24c22174 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/EngineProvider.kt @@ -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), + ) } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt index fe0ac669..46981857 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/GlobalComponents.kt @@ -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 diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt index bc806c35..ad948cb2 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoEngineSettingsApiImpl.kt @@ -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, + ) + } } diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt index 81cd1be1..8db0dbaa 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/api/GeckoViewportApiImpl.kt @@ -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) { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt index b4b661ab..60bca6a3 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/components/Events.kt @@ -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 { diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReaderViewAppearanceFeature.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReaderViewAppearanceFeature.kt new file mode 100644 index 00000000..dd1aebe2 --- /dev/null +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/feature/ReaderViewAppearanceFeature.kt @@ -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) { + 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) + } + } +} diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt index a1bc2ac7..df96d623 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/integration/ReaderViewIntegration.kt @@ -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() diff --git a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt index baf514fd..c3e4c34b 100644 --- a/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt +++ b/packages/flutter_mozilla_components/android/src/main/kotlin/eu/weblibre/flutter_mozilla_components/pigeons/Gecko.g.kt @@ -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(binaryMessenger, "dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setReaderViewPureBlack$separatedMessageChannelSuffix", codec) + if (api != null) { + channel.setMessageHandler { message, reply -> + val args = message as List + val enabledArg = args[0] as Boolean + val wrapped: List = try { + api.setReaderViewPureBlack(enabledArg) + listOf(null) + } catch (exception: Throwable) { + GeckoPigeonUtils.wrapError(exception) + } + reply.reply(wrapped) + } + } else { + channel.setMessageHandler(null) + } + } } } } diff --git a/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml b/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml index 0ea9e05d..016c8cda 100644 --- a/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml +++ b/packages/flutter_mozilla_components/android/src/main/res/layout/fragment_browser.xml @@ -29,6 +29,9 @@ + diff --git a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart index f4308379..a34af167 100644 --- a/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart +++ b/packages/flutter_mozilla_components/lib/src/domain/services/gecko_engine_settings.dart @@ -230,4 +230,8 @@ class GeckoEngineSettingsService { }) { return _api.setGlobalDesktopMode(enable, applyToExistingTabs); } + + Future setReaderViewPureBlack(bool enabled) { + return _api.setReaderViewPureBlack(enabled); + } } diff --git a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart index f50d47f3..b1f36097 100644 --- a/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart +++ b/packages/flutter_mozilla_components/lib/src/pigeons/gecko.g.dart @@ -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 setReaderViewPureBlack(bool enabled) async { + final pigeonVar_channelName = 'dev.flutter.pigeon.flutter_mozilla_components.GeckoEngineSettingsApi.setReaderViewPureBlack$pigeonVar_messageChannelSuffix'; + final pigeonVar_channel = BasicMessageChannel( + pigeonVar_channelName, + pigeonChannelCodec, + binaryMessenger: pigeonVar_binaryMessenger, + ); + final Future pigeonVar_sendFuture = pigeonVar_channel.send([enabled]); + final pigeonVar_replyList = await pigeonVar_sendFuture as List?; + + _extractReplyValueOrThrow( + pigeonVar_replyList, + pigeonVar_channelName, + isNullValid: true, + ) + ; + } } class GeckoSessionApi { diff --git a/packages/flutter_mozilla_components/pigeons/gecko.dart b/packages/flutter_mozilla_components/pigeons/gecko.dart index 34a8e1ab..0fbbae61 100644 --- a/packages/flutter_mozilla_components/pigeons/gecko.dart +++ b/packages/flutter_mozilla_components/pigeons/gecko.dart @@ -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()