diff --git a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift index 06b8d0be..c5c1e2e4 100644 --- a/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift +++ b/apps/readest-app/src-tauri/plugins/tauri-plugin-native-bridge/ios/Sources/NativeBridgePlugin.swift @@ -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") diff --git a/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx b/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx new file mode 100644 index 00000000..7def34f1 --- /dev/null +++ b/apps/readest-app/src/__tests__/hooks/useScreenBrightness.test.tsx @@ -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(); + +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(); + 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(); + }); +}); diff --git a/apps/readest-app/src/app/reader/components/Reader.tsx b/apps/readest-app/src/app/reader/components/Reader.tsx index 95185cce..273e62f8 100644 --- a/apps/readest-app/src/app/reader/components/Reader.tsx +++ b/apps/readest-app/src/app/reader/components/Reader.tsx @@ -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(); diff --git a/apps/readest-app/src/app/reader/hooks/useScreenBrightness.ts b/apps/readest-app/src/app/reader/hooks/useScreenBrightness.ts new file mode 100644 index 00000000..c614f08a --- /dev/null +++ b/apps/readest-app/src/app/reader/hooks/useScreenBrightness.ts @@ -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]); +};