fix(ios): release screen brightness on background so auto-brightness resumes (#4885) (#4896)

On iOS `UIScreen.main.brightness` is a global device setting, not a
per-window one like Android. Once Readest overrode it (brightness slider
or left-edge swipe gesture) the override survived backgrounding, so
swiping to the home screen left the system stuck at an extreme level and
ambient auto-brightness appeared locked. The only cleanup lived in the
reader's unmount effect, which never runs when the app is merely sent to
the background, and the native `brightness < 0` "release" branch was a
no-op stub.

Native (NativeBridgePlugin.swift): capture the system brightness before
the first override, restore it on `appDidEnterBackground` so iOS resumes
auto-brightness, and re-apply the app's value on `appWillEnterForeground`.
Implement the negative-value release path (restore + forget state),
mirroring Android's BRIGHTNESS_OVERRIDE_NONE.

JS (useScreenBrightness hook, replacing the racy inline Reader effect):
apply the manual brightness while reading, release via
setScreenBrightness(-1) on unmount and when "System Screen Brightness" is
toggled back on. Excludes screenBrightness from deps so live slider/gesture
drags don't flash release-then-reapply.

Co-authored-by: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
Huang Xin
2026-07-03 01:23:26 +09:00
committed by GitHub
parent 9f65e3d415
commit 849f151166
4 changed files with 181 additions and 30 deletions
@@ -470,6 +470,16 @@ class NativeBridgePlugin: Plugin {
private var webViewLifecycleManager: WebViewLifecycleManager?
private var traitChangeRegistered = false
// Screen-brightness management. `UIScreen.main.brightness` is a *global*
// device setting, not a per-window one: once the app writes to it, iOS
// suppresses ambient auto-brightness and the override survives backgrounding,
// leaving the system stuck at the app's level until the user nudges it
// manually (issue #4885). We remember the value that was there before the
// first override so we can hand it back whenever the app leaves the
// foreground, and re-assert the app's value when it returns.
private var appDesiredBrightness: CGFloat?
private var systemBrightnessBeforeOverride: CGFloat?
@objc public override func load(webview: WKWebView) {
self.webView = webview
logger.log("NativeBridgePlugin loaded")
@@ -536,6 +546,10 @@ class NativeBridgePlugin: Plugin {
@objc func appWillEnterForeground() {
logger.log("NativeBridgePlugin: App will enter foreground")
// Re-assert the app's brightness that was released on background (#4885).
if let desired = appDesiredBrightness {
UIScreen.main.brightness = desired
}
webViewLifecycleManager?.handleAppWillEnterForeground()
}
@@ -662,6 +676,11 @@ class NativeBridgePlugin: Plugin {
if let handler = volumeKeyHandler, handler.isIntercepting {
handler.stopInterception()
}
// Hand screen brightness back to iOS so ambient auto-brightness resumes
// while backgrounded; the override is re-applied on foreground (#4885).
if appDesiredBrightness != nil, let original = systemBrightnessBeforeOverride {
UIScreen.main.brightness = original
}
webViewLifecycleManager?.handleAppDidEnterBackground()
}
@@ -1050,22 +1069,39 @@ class NativeBridgePlugin: Plugin {
let brightness = args.brightness ?? 0.5
if brightness < 0.0 {
// Revert to system brightness - iOS doesn't have a direct "system brightness" setting
// We will restore the brightness that was set before the app modified it
return invoke.resolve(["success": true])
}
if brightness > 1.0 {
return invoke.reject("Brightness must be between 0.0 and 1.0")
}
DispatchQueue.main.async {
UIScreen.main.brightness = CGFloat(brightness)
DispatchQueue.main.async { [weak self] in
guard let self = self else { return }
if brightness < 0.0 {
// A negative value means "release control back to the system", mirroring
// Android's BRIGHTNESS_OVERRIDE_NONE. Restore the pre-override brightness
// so iOS resumes ambient auto-brightness.
self.releaseBrightnessControl()
} else {
if self.systemBrightnessBeforeOverride == nil {
self.systemBrightnessBeforeOverride = UIScreen.main.brightness
}
self.appDesiredBrightness = CGFloat(brightness)
UIScreen.main.brightness = CGFloat(brightness)
}
}
invoke.resolve(["success": true])
}
/// Restore the brightness captured before the app first overrode it so iOS
/// resumes ambient auto-brightness, then forget our managed state. Must run
/// on the main thread.
private func releaseBrightnessControl() {
if let original = systemBrightnessBeforeOverride {
UIScreen.main.brightness = original
}
appDesiredBrightness = nil
systemBrightnessBeforeOverride = nil
}
@objc public func copy_uri_to_path(_ invoke: Invoke) {
guard let args = try? invoke.parseArgs(CopyUriToPathRequestArgs.self) else {
return invoke.reject("Failed to parse arguments")
@@ -0,0 +1,90 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { render, cleanup } from '@testing-library/react';
const h = vi.hoisted(() => ({
hasScreenBrightness: true,
autoScreenBrightness: false,
screenBrightness: -1,
setScreenBrightness: vi.fn(),
}));
vi.mock('@/context/EnvContext', () => ({
useEnv: () => ({ appService: { hasScreenBrightness: h.hasScreenBrightness } }),
}));
vi.mock('@/store/settingsStore', () => ({
useSettingsStore: () => ({
settings: {
autoScreenBrightness: h.autoScreenBrightness,
screenBrightness: h.screenBrightness,
},
}),
}));
vi.mock('@/store/deviceStore', () => ({
useDeviceControlStore: () => ({ setScreenBrightness: h.setScreenBrightness }),
}));
import { useScreenBrightness } from '@/app/reader/hooks/useScreenBrightness';
function Wrapper() {
useScreenBrightness();
return null;
}
const setup = () => render(<Wrapper />);
describe('useScreenBrightness', () => {
beforeEach(() => {
h.hasScreenBrightness = true;
h.autoScreenBrightness = false;
h.screenBrightness = -1;
h.setScreenBrightness.mockReset();
});
afterEach(() => cleanup());
it('applies the saved manual brightness when auto is off', () => {
h.autoScreenBrightness = false;
h.screenBrightness = 40;
setup();
expect(h.setScreenBrightness).toHaveBeenCalledWith(0.4);
});
it('releases control to the system when auto brightness is on', () => {
h.autoScreenBrightness = true;
h.screenBrightness = 40;
setup();
expect(h.setScreenBrightness).toHaveBeenCalledWith(-1);
});
it('releases control when no manual brightness has been set', () => {
h.autoScreenBrightness = false;
h.screenBrightness = -1;
setup();
expect(h.setScreenBrightness).toHaveBeenCalledWith(-1);
});
it('releases control on unmount', () => {
h.autoScreenBrightness = false;
h.screenBrightness = 40;
const utils = setup();
h.setScreenBrightness.mockClear();
utils.unmount();
expect(h.setScreenBrightness).toHaveBeenCalledWith(-1);
});
it('re-applies the manual brightness when switching off auto brightness', () => {
h.autoScreenBrightness = true;
h.screenBrightness = 40;
const utils = setup();
h.setScreenBrightness.mockClear();
h.autoScreenBrightness = false;
utils.rerender(<Wrapper />);
expect(h.setScreenBrightness).toHaveBeenLastCalledWith(0.4);
});
it('is inert when the platform lacks screen brightness control', () => {
h.hasScreenBrightness = false;
h.screenBrightness = 40;
setup();
expect(h.setScreenBrightness).not.toHaveBeenCalled();
});
});
@@ -15,6 +15,7 @@ import { useNotebookStore } from '@/store/notebookStore';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
import { useScreenWakeLock } from '@/hooks/useScreenWakeLock';
import { useScreenBrightness } from '@/app/reader/hooks/useScreenBrightness';
import { useTransferQueue } from '@/hooks/useTransferQueue';
import { useReplicaPull } from '@/hooks/useReplicaPull';
import { eventDispatcher } from '@/utils/event';
@@ -60,7 +61,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
const { sideBarBookKey } = useSidebarStore();
const { hoveredBookKey } = useReaderStore();
const { showSystemUI, dismissSystemUI } = useThemeStore();
const { getScreenBrightness, setScreenBrightness } = useDeviceControlStore();
const { acquireBackKeyInterception, releaseBackKeyInterception } = useDeviceControlStore();
const { isSideBarVisible, isSideBarPinned } = useSidebarStore();
const { getIsSideBarVisible, setSideBarVisible } = useSidebarStore();
@@ -70,6 +70,7 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
useTheme({ systemUIVisible: settings.alwaysShowStatusBar, appThemeColor: 'base-100' });
useScreenWakeLock(settings.screenWakeLock);
useScreenBrightness();
useTransferQueue(libraryLoaded, 5000);
// Reader needs dictionaries for word-lookup, fonts for rendering, and
// textures for the page background. Mounted here (not in the app-
@@ -87,27 +88,6 @@ const Reader: React.FC<{ ids?: string }> = ({ ids }) => {
initDayjs(getLocale());
}, []);
useEffect(() => {
const brightness = settings.screenBrightness;
const autoBrightness = settings.autoScreenBrightness;
if (appService?.hasScreenBrightness && !autoBrightness && brightness >= 0) {
setScreenBrightness(brightness / 100);
}
let previousBrightness = -1;
if (appService?.isIOSApp) {
getScreenBrightness().then((b) => {
previousBrightness = b;
});
}
return () => {
if (appService?.hasScreenBrightness && !autoBrightness) {
setScreenBrightness(previousBrightness);
}
};
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [appService]);
const handleKeyDown = (event: CustomEvent) => {
if (event.detail.keyName === 'Back') {
const { hoveredBookKey, setHoveredBookKey } = useReaderStore.getState();
@@ -0,0 +1,45 @@
import { useEffect } from 'react';
import { useEnv } from '@/context/EnvContext';
import { useSettingsStore } from '@/store/settingsStore';
import { useDeviceControlStore } from '@/store/deviceStore';
// Sentinel handed to the native bridge to release any app override and give
// screen brightness back to the system: iOS restores the value captured before
// the override, Android clears BRIGHTNESS_OVERRIDE_NONE. See issue #4885.
const RELEASE_BRIGHTNESS = -1;
/**
* Applies the user's manual reading brightness while the reader is open and
* releases control back to the system when the reader closes or the user
* switches "System Screen Brightness" back on.
*
* The native iOS bridge additionally restores the system brightness whenever
* the app backgrounds (and re-applies on foreground), so ambient auto-brightness
* never stays locked after leaving the app — the reader component does not
* unmount when the app is merely sent to the home screen.
*/
export const useScreenBrightness = () => {
const { appService } = useEnv();
const { settings } = useSettingsStore();
const { setScreenBrightness } = useDeviceControlStore();
const hasScreenBrightness = !!appService?.hasScreenBrightness;
const { screenBrightness, autoScreenBrightness } = settings;
useEffect(() => {
if (!hasScreenBrightness) return;
if (!autoScreenBrightness && screenBrightness >= 0) {
setScreenBrightness(screenBrightness / 100);
} else {
setScreenBrightness(RELEASE_BRIGHTNESS);
}
return () => {
setScreenBrightness(RELEASE_BRIGHTNESS);
};
// Deliberately not depending on `screenBrightness`: live slider/gesture
// updates are pushed by their own handlers, so re-running here would flash
// the screen (release then re-apply) mid-drag. We only (re)apply on mount
// and when the auto/manual mode flips.
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [hasScreenBrightness, autoScreenBrightness, setScreenBrightness]);
};