Skip to content
Merged
10 changes: 10 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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.
Expand Down
67 changes: 60 additions & 7 deletions app/src/main/java/de/starlightunit/wrapper/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand All @@ -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;
Expand All @@ -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);
Expand Down Expand Up @@ -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)
);
Comment on lines +103 to +105

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Apply the splash color to the starting window

On a cold launch with a nonblack SPLASH_BACKGROUND_COLOR, Android displays the activity's starting window before onCreate() and setContentView(), and that window still uses the hardcoded black android:windowBackground from Theme.StarlightUnit. Setting the ImageView background here happens too late to prevent the black launch frame, so the configured splash color should also be emitted into the launch/window theme.

Useful? React with 👍 / 👎.

Button retryButton = findViewById(R.id.retry_button);

introController = new QuantumIntroController(this, introOverlay);
Expand Down Expand Up @@ -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)
);
Comment on lines +174 to +176

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Draw the status-bar background on edge-to-edge Android

When the status bar is visible on Android 15 or newer, this target-SDK-36 app is subject to enforced edge-to-edge behavior, where the status bar is transparent and setStatusBarColor no longer paints the requested background. Profiles therefore lose STATUS_BAR_COLOR on the Android versions this change explicitly supports, and the icon appearance is calculated against a color that is not actually displayed. Draw the configured color behind the status-bar inset instead of relying solely on this window setter.

Useful? React with 👍 / 👎.

window.setNavigationBarColor(
SystemBarStyle.parseRgb(AppConfig.NAVIGATION_BAR_COLOR, Color.BLACK)
);
if (AppConfig.KEEP_SCREEN_ON) {
window.addFlags(WindowManager.LayoutParams.FLAG_KEEP_SCREEN_ON);
}
Expand All @@ -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;
}

Expand All @@ -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;

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Preserve dark icon flags in legacy immersive mode

On API 23–29 with immersive fullscreen enabled, this return prevents the later LIGHT_STATUS_BAR and LIGHT_NAVIGATION_BAR flags from ever being applied. Even for a pure-white configured bar, swiping to reveal the transient system bars therefore shows the platform's default light icons against the bright background. Include the applicable light-appearance bits in the immersive visibility mask before returning.

Useful? React with 👍 / 👎.

}

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
Expand Down Expand Up @@ -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);
Expand All @@ -249,6 +280,7 @@ public void onPageReady() {
public void onMainFrameError() {
mainFrameFailed = true;
loadingIndicator.hide();
pageTransition.reset();
refreshLayout.setRefreshing(false);
errorPanel.setVisibility(View.VISIBLE);
}
Expand Down Expand Up @@ -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.
Expand All @@ -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
Expand Down Expand Up @@ -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);
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,13 +29,19 @@ 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;
public static final int LOADING_SPINNER_SIZE_DP = 56;
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;
Expand Down
Original file line number Diff line number Diff line change
@@ -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);
}
}
39 changes: 39 additions & 0 deletions app/src/main/java/de/starlightunit/wrapper/ui/SystemBarStyle.java
Original file line number Diff line number Diff line change
@@ -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;
}

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Choose system-bar icons using actual contrast

For common mid-light configured colors, this cutoff selects light icons even when they have very poor contrast. For example, #BBBBBB has linear luminance about 0.497, so this returns false and uses white icons at roughly 1.9:1 contrast, whereas black icons exceed 10:1. Compare black-versus-white contrast directly (their crossover is near luminance 0.179) so configured bars do not make the icons unreadable.

Useful? React with 👍 / 👎.


private static double linearize(double value) {
return value <= 0.03928
? value / 12.92
: Math.pow((value + 0.055) / 1.055, 2.4);
}
}
16 changes: 16 additions & 0 deletions app/src/main/java/de/starlightunit/wrapper/web/WebOverrides.java
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,8 @@ private WebOverrides() {
}

public static void apply(WebView webView) {
webView.evaluateJavascript(themeInjectionScript(AppConfig.WEB_DARK_MODE), null);
Comment on lines 19 to +20

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Install the theme contract before page scripts run

WebOverrides.apply() is called only from GameWebViewClient.onPageFinished(), so this evaluation occurs after the trusted page's inline, deferred, and module initialization has already run. A page that reads data-quantum-theme once during startup sees it as absent and can permanently select its default assets or state, while CSS using the attribute also renders an initial unthemed frame. Install this script at document start with the trusted-origin restriction rather than adding the contract after load completion.

Useful? React with 👍 / 👎.


String css = AppConfig.CUSTOM_CSS;
if (css != null && !css.trim().isEmpty()) {
webView.evaluateJavascript(cssInjectionScript(css), null);
Expand All @@ -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';"
+ "}"
Comment on lines +50 to +52

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Resolve Auto from the system configuration

With WEB_DARK_MODE="auto" on a system-light device, this resolves the exposed theme from WebView's prefers-color-scheme, which follows the activity theme on the targeted modern WebViews. Since this app uses the fixed dark Theme.Material.NoActionBar, the script can publish data-quantum-theme="dark" despite the native configuration being light. Pass the native Configuration result into the injection instead, and refresh it when the handled uiMode changes.

Useful? React with 👍 / 👎.

+ "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";
Expand Down
Original file line number Diff line number Diff line change
@@ -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;
Comment on lines +28 to +30

Copy link
Copy Markdown

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

P2 Badge Make the selected mode drive the WebView color scheme

When a profile selects light on a current WebView, this branch only disables algorithmic darkening; it does not make CSS prefers-color-scheme resolve to light. Because the activity uses the fixed dark Theme.Material.NoActionBar, pages with their own dark media rules can continue rendering dark even while data-quantum-theme reports light. The selected mode needs to drive the WebView/app theme rather than treating the algorithmic-darkening permission as a mode switch.

Useful? React with 👍 / 👎.

}

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;
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down
Loading
Loading