diff --git a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt index f05d152d49..305198053c 100644 --- a/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt +++ b/android/app/src/main/java/com/superproductivity/superproductivity/webview/JavaScriptInterface.kt @@ -85,6 +85,14 @@ class JavaScriptInterface( return "${versionName}_L$launchMode" } + @Suppress("unused") + @JavascriptInterface + fun getTextZoom(): Int { + // Chromium initializes WebView text zoom from this system font scale. + // Reading WebSettings directly here would cross WebView's UI-thread boundary. + return (100 * activity.resources.configuration.fontScale).toInt() + } + // Launch the Play In-App Review flow (play flavor). Delegates to a // flavor-specific InAppReview: the real Play Core implementation in src/play, // and a no-op stub in src/fdroid so the proprietary library stays out of the diff --git a/src/app/app.constants.ts b/src/app/app.constants.ts index d1d3dc0b1b..4ff7d3df7a 100644 --- a/src/app/app.constants.ts +++ b/src/app/app.constants.ts @@ -111,6 +111,7 @@ export enum BodyClass { isFullScreen = 'isFullScreen', isAddTaskBarOpen = 'isAddTaskBarOpen', isMaterialSymbolsLoaded = 'isMaterialSymbolsLoaded', + hasAndroidWebViewTextZoom = 'hasAndroidWebViewTextZoom', // iOS-specific classes isIOS = 'isIOS', diff --git a/src/app/features/android/android-interface.ts b/src/app/features/android/android-interface.ts index 63793dbf4c..8258381fe9 100644 --- a/src/app/features/android/android-interface.ts +++ b/src/app/features/android/android-interface.ts @@ -13,6 +13,7 @@ export interface AndroidShareData { export interface AndroidInterface { getVersion?(): string; + getTextZoom?(): number; // Launches the native Play In-App Review card (play flavor). No-op on fdroid. // The outcome is intentionally opaque (Play policy) — nothing is returned. diff --git a/src/app/ui/material-icons-loader.service.spec.ts b/src/app/ui/material-icons-loader.service.spec.ts index 0d9befa6c5..ae6da6dbf9 100644 --- a/src/app/ui/material-icons-loader.service.spec.ts +++ b/src/app/ui/material-icons-loader.service.spec.ts @@ -8,17 +8,31 @@ const MATERIAL_ICONS_FONT_READY_TIMEOUT_MS = 3000; describe('MaterialIconsLoaderService', () => { let service: MaterialIconsLoaderService; let originalFonts: FontFaceSet | undefined; + let originalAndroidInterfaceDescriptor: PropertyDescriptor | undefined; + let testHost: HTMLDivElement; beforeEach(() => { originalFonts = document.fonts; + originalAndroidInterfaceDescriptor = Object.getOwnPropertyDescriptor( + window, + 'SUPAndroid', + ); document.body.classList.remove(BodyClass.isMaterialSymbolsLoaded); + document.body.classList.remove(BodyClass.hasAndroidWebViewTextZoom); + document.documentElement.style.removeProperty('--android-webview-icon-scale'); + testHost = document.createElement('div'); + document.body.append(testHost); TestBed.configureTestingModule({}); service = TestBed.inject(MaterialIconsLoaderService); }); afterEach(() => { setDocumentFonts(originalFonts); + restoreAndroidInterface(originalAndroidInterfaceDescriptor); document.body.classList.remove(BodyClass.isMaterialSymbolsLoaded); + document.body.classList.remove(BodyClass.hasAndroidWebViewTextZoom); + document.documentElement.style.removeProperty('--android-webview-icon-scale'); + testHost.remove(); }); it('should load icons on first call', async () => { @@ -94,6 +108,76 @@ describe('MaterialIconsLoaderService', () => { document.body.classList.contains(BodyClass.isMaterialSymbolsLoaded), ).toBeTrue(); }); + + it('counter-scales only Android font icons when system text zoom is increased', async () => { + setDocumentFonts(undefined); + setAndroidTextZoom(130); + const fontIcon = document.createElement('mat-icon'); + fontIcon.setAttribute('data-mat-icon-type', 'font'); + fontIcon.style.transform = 'rotate(45deg)'; + const rawFontIcon = document.createElement('span'); + rawFontIcon.classList.add('material-icons'); + const pseudoFontIcon = document.createElement('div'); + pseudoFontIcon.classList.add('sync-warning'); + const inlineFontIcon = document.createElement('mat-icon'); + inlineFontIcon.setAttribute('data-mat-icon-type', 'font'); + inlineFontIcon.classList.add('material-icons', 'mat-icon-inline'); + const svgIcon = document.createElement('mat-icon'); + svgIcon.setAttribute('data-mat-icon-type', 'svg'); + const ordinaryText = document.createElement('span'); + testHost.append( + fontIcon, + rawFontIcon, + pseudoFontIcon, + inlineFontIcon, + svgIcon, + ordinaryText, + ); + + await service.ensureFontReady(); + + expect( + document.body.classList.contains(BodyClass.hasAndroidWebViewTextZoom), + ).toBeTrue(); + expect(parseFloat(getComputedStyle(fontIcon).scale)).toBeCloseTo(100 / 130, 5); + expect(getComputedStyle(fontIcon).transform).not.toBe('none'); + expect(parseFloat(getComputedStyle(rawFontIcon).scale)).toBeCloseTo(100 / 130, 5); + expect(parseFloat(getComputedStyle(pseudoFontIcon, '::before').scale)).toBeCloseTo( + 100 / 130, + 5, + ); + expect(getComputedStyle(inlineFontIcon).scale).toBe('none'); + expect(getComputedStyle(svgIcon).scale).toBe('none'); + expect(getComputedStyle(ordinaryText).scale).toBe('none'); + }); + + it('does not compensate the default Android text zoom', async () => { + setDocumentFonts(undefined); + setAndroidTextZoom(100); + + await service.ensureFontReady(); + + expect( + document.body.classList.contains(BodyClass.hasAndroidWebViewTextZoom), + ).toBeFalse(); + expect( + document.documentElement.style.getPropertyValue('--android-webview-icon-scale'), + ).toBe(''); + }); + + it('keeps icons usable if the Android text zoom bridge throws', async () => { + setDocumentFonts(undefined); + setThrowingAndroidTextZoom(); + + await expectAsync(service.ensureFontReady()).toBeResolved(); + + expect( + document.body.classList.contains(BodyClass.hasAndroidWebViewTextZoom), + ).toBeFalse(); + expect( + document.body.classList.contains(BodyClass.isMaterialSymbolsLoaded), + ).toBeTrue(); + }); }); const setDocumentFonts = (fonts: FontFaceSet | undefined): void => { @@ -102,3 +186,29 @@ const setDocumentFonts = (fonts: FontFaceSet | undefined): void => { configurable: true, }); }; + +const setAndroidTextZoom = (textZoom: number): void => { + Object.defineProperty(window, 'SUPAndroid', { + value: { getTextZoom: (): number => textZoom }, + configurable: true, + }); +}; + +const setThrowingAndroidTextZoom = (): void => { + Object.defineProperty(window, 'SUPAndroid', { + value: { + getTextZoom: (): never => { + throw new Error('bridge unavailable'); + }, + }, + configurable: true, + }); +}; + +const restoreAndroidInterface = (descriptor: PropertyDescriptor | undefined): void => { + if (descriptor) { + Object.defineProperty(window, 'SUPAndroid', descriptor); + } else { + delete (window as Window & { SUPAndroid?: unknown }).SUPAndroid; + } +}; diff --git a/src/app/ui/material-icons-loader.service.ts b/src/app/ui/material-icons-loader.service.ts index c11a19778e..0feb54d785 100644 --- a/src/app/ui/material-icons-loader.service.ts +++ b/src/app/ui/material-icons-loader.service.ts @@ -1,8 +1,11 @@ import { Injectable } from '@angular/core'; import { BodyClass } from '../app.constants'; +import type { AndroidInterface } from '../features/android/android-interface'; const MATERIAL_ICONS_FONT = '24px "Material Symbols Outlined"'; const MATERIAL_ICONS_FONT_READY_TIMEOUT_MS = 3000; +const DEFAULT_ANDROID_TEXT_ZOOM = 100; +const ANDROID_WEBVIEW_ICON_SCALE_PROP = '--android-webview-icon-scale'; @Injectable({ providedIn: 'root', @@ -38,6 +41,8 @@ export class MaterialIconsLoaderService { return Promise.resolve(); } + this._applyAndroidTextZoomCompensation(body); + if (body.classList.contains(BodyClass.isMaterialSymbolsLoaded)) { return Promise.resolve(); } @@ -62,6 +67,36 @@ export class MaterialIconsLoaderService { return MATERIAL_ICONS; } + private _applyAndroidTextZoomCompensation(body: HTMLElement): void { + const rootStyle = document.documentElement.style; + body.classList.remove(BodyClass.hasAndroidWebViewTextZoom); + rootStyle.removeProperty(ANDROID_WEBVIEW_ICON_SCALE_PROP); + + try { + const androidInterface = ( + window as Window & { SUPAndroid?: Pick } + ).SUPAndroid; + const textZoom = androidInterface?.getTextZoom?.(); + + if ( + typeof textZoom !== 'number' || + !Number.isFinite(textZoom) || + textZoom <= 0 || + textZoom === DEFAULT_ANDROID_TEXT_ZOOM + ) { + return; + } + + rootStyle.setProperty( + ANDROID_WEBVIEW_ICON_SCALE_PROP, + `${DEFAULT_ANDROID_TEXT_ZOOM / textZoom}`, + ); + body.classList.add(BodyClass.hasAndroidWebViewTextZoom); + } catch { + // Older native shells may not expose the text zoom bridge yet. + } + } + private async _loadFont(body: HTMLElement, fonts: FontFaceSet): Promise { let timeoutId: ReturnType | undefined; diff --git a/src/styles/font/material-icons.scss b/src/styles/font/material-icons.scss index 0cc2c79706..fc23076e64 100644 --- a/src/styles/font/material-icons.scss +++ b/src/styles/font/material-icons.scss @@ -26,3 +26,10 @@ 'GRAD' 0, 'opsz' 24; } + +// Android WebView applies the system font scale to fixed-size icon-font glyphs. +// Keep inline icons matched to the surrounding text's accessible scale (#5694). +.material-icons:not(.mat-icon-inline), +mat-icon[data-mat-icon-type='font']:not(.mat-icon-inline) { + @include material-icon.androidWebViewTextZoomCompensation(); +} diff --git a/src/styles/mixins/_material-icon.scss b/src/styles/mixins/_material-icon.scss index 845d653c3f..28330ac69f 100644 --- a/src/styles/mixins/_material-icon.scss +++ b/src/styles/mixins/_material-icon.scss @@ -24,7 +24,14 @@ font-feature-settings: 'liga'; } +@mixin androidWebViewTextZoomCompensation() { + body.hasAndroidWebViewTextZoom & { + scale: var(--android-webview-icon-scale, 1); + } +} + @mixin materialIcon($iconName) { @include materialIconBase(); + @include androidWebViewTextZoomCompensation(); content: $iconName; }