diff --git a/CHANGELOG.md b/CHANGELOG.md index 1b44727..5ea2efe 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,16 @@ ## Unreleased +Runtime theme controls and page transitions. + +- Adds Dark / Light / Auto WebView theme modes with system-theme resolution for Auto. +- Exposes the resolved theme to trusted pages through `data-quantum-theme` and CSS `color-scheme`. +- Uses AndroidX WebKit darkening support when available from the installed System WebView. +- Applies profile-controlled status bar, navigation bar and splash background colors. +- Chooses dark or light system-bar icons from configured color luminance. +- Adds optional native page-load fade transitions without changing the mandatory production splash. +- Keeps Android 6 / API 23 compatibility and the existing Android 16 system-bar lifecycle safeguards. + Profile-controlled public downloads. - Adds a compiled Public Downloads runtime flag used by Android DownloadManager. diff --git a/app/src/main/java/de/starlightunit/wrapper/MainActivity.java b/app/src/main/java/de/starlightunit/wrapper/MainActivity.java index 1ef7738..03c1033 100644 --- a/app/src/main/java/de/starlightunit/wrapper/MainActivity.java +++ b/app/src/main/java/de/starlightunit/wrapper/MainActivity.java @@ -33,6 +33,8 @@ import de.starlightunit.wrapper.session.CookiePersistencePolicy; import de.starlightunit.wrapper.session.QuantumSessionCookieStore; import de.starlightunit.wrapper.ui.LoadingIndicatorController; +import de.starlightunit.wrapper.ui.PageTransitionController; +import de.starlightunit.wrapper.ui.SystemBarStyle; import de.starlightunit.wrapper.web.GameWebChromeClient; import de.starlightunit.wrapper.web.GameWebViewClient; import de.starlightunit.wrapper.web.WebViewConfigurator; @@ -49,6 +51,7 @@ public final class MainActivity extends Activity private WebView webView; private LoadingIndicatorController loadingIndicator; + private PageTransitionController pageTransition; private SwipeRefreshLayout refreshLayout; private View errorPanel; private GameWebChromeClient chromeClient; @@ -69,6 +72,7 @@ protected void onCreate(Bundle savedInstanceState) { applyConfiguredSystemUi(false); webView = findViewById(R.id.web_view); + pageTransition = new PageTransitionController(webView, AppConfig.PAGE_TRANSITIONS_ENABLED); refreshLayout = findViewById(R.id.refresh_container); ProgressBar horizontalProgress = findViewById(R.id.progress); FrameLayout loadingOverlay = findViewById(R.id.loading_overlay); @@ -96,6 +100,9 @@ protected void onCreate(Bundle savedInstanceState) { loadTrustedUrl(currentUrl); }); ImageView introOverlay = findViewById(R.id.intro_overlay); + introOverlay.setBackgroundColor( + SystemBarStyle.parseRgb(AppConfig.SPLASH_BACKGROUND_COLOR, Color.BLACK) + ); Button retryButton = findViewById(R.id.retry_button); introController = new QuantumIntroController(this, introOverlay); @@ -164,8 +171,12 @@ private void requestLegacyPublicDownloadPermissionIfNeeded() { private void configureWindow() { Window window = getWindow(); - window.setStatusBarColor(Color.BLACK); - window.setNavigationBarColor(Color.BLACK); + window.setStatusBarColor( + SystemBarStyle.parseRgb(AppConfig.STATUS_BAR_COLOR, Color.BLACK) + ); + window.setNavigationBarColor( + SystemBarStyle.parseRgb(AppConfig.NAVIGATION_BAR_COLOR, Color.BLACK) + ); if (AppConfig.KEEP_SCREEN_ON) { window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON); } @@ -175,8 +186,18 @@ private void applyConfiguredSystemUi(boolean forceFullscreen) { boolean fullscreen = forceFullscreen || AppConfig.IMMERSIVE_FULLSCREEN_ENABLED; View decorView = getWindow().getDecorView(); + int statusColor = SystemBarStyle.parseRgb(AppConfig.STATUS_BAR_COLOR, Color.BLACK); + int navigationColor = SystemBarStyle.parseRgb(AppConfig.NAVIGATION_BAR_COLOR, Color.BLACK); + boolean darkStatusIcons = SystemBarStyle.shouldUseDarkIcons(statusColor); + boolean darkNavigationIcons = SystemBarStyle.shouldUseDarkIcons(navigationColor); + if (Build.VERSION.SDK_INT >= 30) { - Api30WindowHandler.setImmersiveMode(decorView, fullscreen); + Api30WindowHandler.setSystemUi( + decorView, + fullscreen, + darkStatusIcons, + darkNavigationIcons + ); return; } @@ -189,9 +210,17 @@ private void applyConfiguredSystemUi(boolean forceFullscreen) { | View.SYSTEM_UI_FLAG_LAYOUT_HIDE_NAVIGATION | View.SYSTEM_UI_FLAG_LAYOUT_STABLE ); - } else { - decorView.setSystemUiVisibility(View.SYSTEM_UI_FLAG_VISIBLE); + return; + } + + int flags = View.SYSTEM_UI_FLAG_VISIBLE; + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M && darkStatusIcons) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_STATUS_BAR; + } + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O && darkNavigationIcons) { + flags |= View.SYSTEM_UI_FLAG_LIGHT_NAVIGATION_BAR; } + decorView.setSystemUiVisibility(flags); } @Override @@ -230,12 +259,14 @@ protected void onActivityResult(int requestCode, int resultCode, Intent data) { public void onPageLoading() { mainFrameFailed = false; errorPanel.setVisibility(View.GONE); + pageTransition.onPageLoading(); loadingIndicator.show(); } @Override public void onPageReady() { loadingIndicator.hide(); + pageTransition.onPageReady(); refreshLayout.setRefreshing(false); if (!mainFrameFailed) { errorPanel.setVisibility(View.GONE); @@ -249,6 +280,7 @@ public void onPageReady() { public void onMainFrameError() { mainFrameFailed = true; loadingIndicator.hide(); + pageTransition.reset(); refreshLayout.setRefreshing(false); errorPanel.setVisibility(View.VISIBLE); } @@ -320,7 +352,12 @@ private Api30WindowHandler() { } @android.annotation.TargetApi(30) - static void setImmersiveMode(View decorView, boolean enabled) { + static void setSystemUi( + View decorView, + boolean fullscreen, + boolean darkStatusIcons, + boolean darkNavigationIcons + ) { // Android 16 can throw inside PhoneWindow.getInsetsController() when it is // queried before DecorView has been installed. Obtain the controller from // the actual decor view and defer the request until that view is ready. @@ -329,9 +366,21 @@ static void setImmersiveMode(View decorView, boolean enabled) { if (controller == null) { return; } + + int appearance = 0; + int appearanceMask = android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS + | android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS; + if (darkStatusIcons) { + appearance |= android.view.WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS; + } + if (darkNavigationIcons) { + appearance |= android.view.WindowInsetsController.APPEARANCE_LIGHT_NAVIGATION_BARS; + } + controller.setSystemBarsAppearance(appearance, appearanceMask); + int types = android.view.WindowInsets.Type.statusBars() | android.view.WindowInsets.Type.navigationBars(); - if (enabled) { + if (fullscreen) { controller.hide(types); controller.setSystemBarsBehavior( android.view.WindowInsetsController.BEHAVIOR_SHOW_TRANSIENT_BARS_BY_SWIPE @@ -370,6 +419,10 @@ protected void onDestroy() { pendingFileCallback.onReceiveValue(null); pendingFileCallback = null; } + if (pageTransition != null) { + pageTransition.reset(); + pageTransition = null; + } if (refreshLayout != null) { refreshLayout.setOnRefreshListener(null); refreshLayout.setRefreshing(false); diff --git a/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java b/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java index 845e7ad..8338c52 100644 --- a/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java +++ b/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java @@ -29,6 +29,11 @@ public final class AppConfig { public static final String CUSTOM_JAVASCRIPT = ""; public static final String COOKIE_PERSISTENCE_MODE = "persistent"; + public static final String WEB_DARK_MODE = "dark"; + public static final String STATUS_BAR_COLOR = "#020611"; + public static final String NAVIGATION_BAR_COLOR = "#020611"; + public static final String SPLASH_BACKGROUND_COLOR = "#020611"; + public static final String LOADING_INDICATOR_STYLE = "top-bar"; public static final String LOADING_INDICATOR_COLOR = "#6fc7ff"; public static final int LOADING_BAR_THICKNESS_DP = 3; @@ -36,6 +41,7 @@ public final class AppConfig { public static final int LOADING_OVERLAY_DIM_PERCENT = 35; public static final boolean KEEP_SCREEN_ON = true; + public static final boolean PAGE_TRANSITIONS_ENABLED = false; public static final boolean IMMERSIVE_FULLSCREEN_ENABLED = true; public static final boolean PULL_TO_REFRESH_ENABLED = false; public static final boolean PINCH_TO_ZOOM_ENABLED = false; diff --git a/app/src/main/java/de/starlightunit/wrapper/ui/PageTransitionController.java b/app/src/main/java/de/starlightunit/wrapper/ui/PageTransitionController.java new file mode 100644 index 0000000..e4cfec4 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/ui/PageTransitionController.java @@ -0,0 +1,38 @@ +package de.starlightunit.wrapper.ui; + +import android.view.View; + +public final class PageTransitionController { + private static final long DURATION_MS = 140L; + + private final View content; + private final boolean enabled; + + public PageTransitionController(View content, boolean enabled) { + this.content = content; + this.enabled = enabled; + } + + public void onPageLoading() { + content.animate().cancel(); + if (!enabled) { + content.setAlpha(1f); + return; + } + content.setAlpha(0.72f); + } + + public void onPageReady() { + content.animate().cancel(); + if (!enabled) { + content.setAlpha(1f); + return; + } + content.animate().alpha(1f).setDuration(DURATION_MS).start(); + } + + public void reset() { + content.animate().cancel(); + content.setAlpha(1f); + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/ui/SystemBarStyle.java b/app/src/main/java/de/starlightunit/wrapper/ui/SystemBarStyle.java new file mode 100644 index 0000000..2869fd1 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/ui/SystemBarStyle.java @@ -0,0 +1,39 @@ +package de.starlightunit.wrapper.ui; + +import java.util.Locale; + +public final class SystemBarStyle { + private SystemBarStyle() { + } + + public static int parseRgb(String value, int fallback) { + if (value == null) { + return fallback; + } + String normalized = value.trim().toLowerCase(Locale.ROOT); + if (!normalized.matches("^#[0-9a-f]{6}$")) { + return fallback; + } + try { + return 0xff000000 | Integer.parseInt(normalized.substring(1), 16); + } catch (NumberFormatException ignored) { + return fallback; + } + } + + public static boolean shouldUseDarkIcons(int argb) { + double r = ((argb >> 16) & 0xff) / 255.0; + double g = ((argb >> 8) & 0xff) / 255.0; + double b = (argb & 0xff) / 255.0; + double luminance = (0.2126 * linearize(r)) + + (0.7152 * linearize(g)) + + (0.0722 * linearize(b)); + return luminance > 0.55; + } + + private static double linearize(double value) { + return value <= 0.03928 + ? value / 12.92 + : Math.pow((value + 0.055) / 1.055, 2.4); + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/web/WebOverrides.java b/app/src/main/java/de/starlightunit/wrapper/web/WebOverrides.java index 01d911d..47c3d3b 100644 --- a/app/src/main/java/de/starlightunit/wrapper/web/WebOverrides.java +++ b/app/src/main/java/de/starlightunit/wrapper/web/WebOverrides.java @@ -17,6 +17,8 @@ private WebOverrides() { } public static void apply(WebView webView) { + webView.evaluateJavascript(themeInjectionScript(AppConfig.WEB_DARK_MODE), null); + String css = AppConfig.CUSTOM_CSS; if (css != null && !css.trim().isEmpty()) { webView.evaluateJavascript(cssInjectionScript(css), null); @@ -39,6 +41,20 @@ static String cssInjectionScript(String css) { + "})();"; } + static String themeInjectionScript(String mode) { + String normalized = WebThemeController.normalize(mode); + return "(function(){" + + "var root=document.documentElement;" + + "var mode='" + normalized + "';" + + "var resolved=mode;" + + "if(mode==='auto'){" + + "resolved=(window.matchMedia&&window.matchMedia('(prefers-color-scheme: dark)').matches)?'dark':'light';" + + "}" + + "root.dataset.quantumTheme=resolved;" + + "root.style.colorScheme=resolved;" + + "})();"; + } + static String javascriptInjectionScript(String javascript) { String source = javascript == null ? "" : javascript; return source + "\n//# sourceURL=quantum-profile-custom.js"; diff --git a/app/src/main/java/de/starlightunit/wrapper/web/WebThemeController.java b/app/src/main/java/de/starlightunit/wrapper/web/WebThemeController.java new file mode 100644 index 0000000..cb46004 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/web/WebThemeController.java @@ -0,0 +1,63 @@ +package de.starlightunit.wrapper.web; + +import android.content.Context; +import android.content.res.Configuration; +import android.webkit.WebSettings; + +import androidx.webkit.WebSettingsCompat; +import androidx.webkit.WebViewFeature; + +import java.util.Locale; + +import de.starlightunit.wrapper.config.AppConfig; + +public final class WebThemeController { + public static final String MODE_DARK = "dark"; + public static final String MODE_LIGHT = "light"; + public static final String MODE_AUTO = "auto"; + + private WebThemeController() { + } + + public static void configure(Context context, WebSettings settings) { + String mode = normalize(AppConfig.WEB_DARK_MODE); + boolean systemDark = (context.getResources().getConfiguration().uiMode + & Configuration.UI_MODE_NIGHT_MASK) == Configuration.UI_MODE_NIGHT_YES; + boolean dark = resolveDark(mode, systemDark); + + if (WebViewFeature.isFeatureSupported(WebViewFeature.ALGORITHMIC_DARKENING)) { + WebSettingsCompat.setAlgorithmicDarkeningAllowed(settings, dark); + return; + } + + if (WebViewFeature.isFeatureSupported(WebViewFeature.FORCE_DARK)) { + WebSettingsCompat.setForceDark( + settings, + dark ? WebSettingsCompat.FORCE_DARK_ON : WebSettingsCompat.FORCE_DARK_OFF + ); + } + } + + static String normalize(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + switch (normalized) { + case MODE_LIGHT: + case MODE_AUTO: + case MODE_DARK: + return normalized; + default: + return MODE_DARK; + } + } + + static boolean resolveDark(String configuredMode, boolean systemDark) { + String mode = normalize(configuredMode); + if (MODE_LIGHT.equals(mode)) { + return false; + } + if (MODE_AUTO.equals(mode)) { + return systemDark; + } + return true; + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/web/WebViewConfigurator.java b/app/src/main/java/de/starlightunit/wrapper/web/WebViewConfigurator.java index 393d715..4f7fddc 100644 --- a/app/src/main/java/de/starlightunit/wrapper/web/WebViewConfigurator.java +++ b/app/src/main/java/de/starlightunit/wrapper/web/WebViewConfigurator.java @@ -33,6 +33,7 @@ public static void configure(Context context, WebView webView) { settings.setUseWideViewPort(true); settings.setCacheMode(WebSettings.LOAD_DEFAULT); settings.setUserAgentString(settings.getUserAgentString() + AppConfig.USER_AGENT_SUFFIX); + WebThemeController.configure(context, settings); if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) { settings.setSafeBrowsingEnabled(true); diff --git a/app/src/test/java/de/starlightunit/wrapper/ui/SystemBarStyleTest.java b/app/src/test/java/de/starlightunit/wrapper/ui/SystemBarStyleTest.java new file mode 100644 index 0000000..18778df --- /dev/null +++ b/app/src/test/java/de/starlightunit/wrapper/ui/SystemBarStyleTest.java @@ -0,0 +1,26 @@ +package de.starlightunit.wrapper.ui; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class SystemBarStyleTest { + + @Test + public void parsesStrictRgbHexAndFallsBackOtherwise() { + assertEquals(0xff020611, SystemBarStyle.parseRgb("#020611", 0xff000000)); + assertEquals(0xffabcdef, SystemBarStyle.parseRgb("#ABCDEF", 0xff000000)); + assertEquals(0xff123456, SystemBarStyle.parseRgb("nope", 0xff123456)); + assertEquals(0xff123456, SystemBarStyle.parseRgb(null, 0xff123456)); + } + + @Test + public void brightBarsUseDarkIconsAndDarkBarsUseLightIcons() { + assertTrue(SystemBarStyle.shouldUseDarkIcons(0xffffffff)); + assertTrue(SystemBarStyle.shouldUseDarkIcons(0xfff0f0f0)); + assertFalse(SystemBarStyle.shouldUseDarkIcons(0xff020611)); + assertFalse(SystemBarStyle.shouldUseDarkIcons(0xff000000)); + } +} diff --git a/app/src/test/java/de/starlightunit/wrapper/web/WebOverridesTest.java b/app/src/test/java/de/starlightunit/wrapper/web/WebOverridesTest.java index e715360..95ae72d 100644 --- a/app/src/test/java/de/starlightunit/wrapper/web/WebOverridesTest.java +++ b/app/src/test/java/de/starlightunit/wrapper/web/WebOverridesTest.java @@ -15,6 +15,17 @@ public void cssInjectionEscapesContentAndUsesStableStyleElement() { assertTrue(script.contains("s.textContent=")); } + @Test + public void themeInjectionExposesResolvedThemeContract() { + String dark = WebOverrides.themeInjectionScript("dark"); + assertTrue(dark.contains("mode='dark'")); + assertTrue(dark.contains("root.dataset.quantumTheme=resolved")); + assertTrue(dark.contains("root.style.colorScheme=resolved")); + + String auto = WebOverrides.themeInjectionScript("auto"); + assertTrue(auto.contains("prefers-color-scheme: dark")); + } + @Test public void javascriptInjectionPreservesSourceAndAddsDebugSourceName() { String source = "window.quantumTest = (window.quantumTest || 0) + 1;"; diff --git a/app/src/test/java/de/starlightunit/wrapper/web/WebThemeControllerTest.java b/app/src/test/java/de/starlightunit/wrapper/web/WebThemeControllerTest.java new file mode 100644 index 0000000..3f68cc9 --- /dev/null +++ b/app/src/test/java/de/starlightunit/wrapper/web/WebThemeControllerTest.java @@ -0,0 +1,27 @@ +package de.starlightunit.wrapper.web; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; +import static org.junit.Assert.assertFalse; +import static org.junit.Assert.assertTrue; + +public final class WebThemeControllerTest { + + @Test + public void knownModesNormalizeAndUnknownFallsBackToDark() { + assertEquals("dark", WebThemeController.normalize("dark")); + assertEquals("light", WebThemeController.normalize(" LIGHT ")); + assertEquals("auto", WebThemeController.normalize("auto")); + assertEquals("dark", WebThemeController.normalize(null)); + assertEquals("dark", WebThemeController.normalize("unexpected")); + } + + @Test + public void autoFollowsSystemTheme() { + assertTrue(WebThemeController.resolveDark("auto", true)); + assertFalse(WebThemeController.resolveDark("auto", false)); + assertTrue(WebThemeController.resolveDark("dark", false)); + assertFalse(WebThemeController.resolveDark("light", true)); + } +}