mirror of
https://github.com/johannesjo/super-productivity.git
synced 2026-07-18 00:46:45 +00:00
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:
parent
ce8ed47328
commit
519ea02167
7 changed files with 169 additions and 0 deletions
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -111,6 +111,7 @@ export enum BodyClass {
|
|||
isFullScreen = 'isFullScreen',
|
||||
isAddTaskBarOpen = 'isAddTaskBarOpen',
|
||||
isMaterialSymbolsLoaded = 'isMaterialSymbolsLoaded',
|
||||
hasAndroidWebViewTextZoom = 'hasAndroidWebViewTextZoom',
|
||||
|
||||
// iOS-specific classes
|
||||
isIOS = 'isIOS',
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue