fix(android): keep material icons aligned with system font scaling (#8992)

Counter-scale fixed-size font icons by the Android WebView text zoom while preserving accessible text and inline icon scaling. Covers Angular icons, raw Material Symbols, and shared pseudo-element icons.

Fixes #5694
This commit is contained in:
Johannes Millan 2026-07-14 12:38:12 +02:00 committed by GitHub
parent ce8ed47328
commit 519ea02167
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
7 changed files with 169 additions and 0 deletions

View file

@ -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

View file

@ -111,6 +111,7 @@ export enum BodyClass {
isFullScreen = 'isFullScreen',
isAddTaskBarOpen = 'isAddTaskBarOpen',
isMaterialSymbolsLoaded = 'isMaterialSymbolsLoaded',
hasAndroidWebViewTextZoom = 'hasAndroidWebViewTextZoom',
// iOS-specific classes
isIOS = 'isIOS',

View file

@ -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.

View file

@ -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;
}
};

View file

@ -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<AndroidInterface, 'getTextZoom'> }
).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<void> {
let timeoutId: ReturnType<typeof setTimeout> | undefined;

View file

@ -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();
}

View file

@ -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;
}