Repository navigation
Runtime theme controls and page transitions #22
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
Changes from all commits
f2411c4
28e50c3
fcc25b3
237c1ec
021b998
b6fd0c0
8d6f8f9
496ca53
71bfe99
1f0cecb
3a08e03
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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) | ||
| ); | ||
|
Comment on lines
+174
to
+176
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
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 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); | ||
| } | ||
|
|
@@ -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; | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
On API 23–29 with immersive fullscreen enabled, this return prevents the later 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 | ||
|
|
@@ -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); | ||
|
|
||
| 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); | ||
| } | ||
| } |
| 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; | ||
| } | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
For common mid-light configured colors, this cutoff selects light icons even when they have very poor contrast. For example, 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); | ||
| } | ||
| } | ||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
Useful? React with 👍 / 👎. |
||
|
|
||
| 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';" | ||
| + "}" | ||
|
Comment on lines
+50
to
+52
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
With 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"; | ||
|
|
||
| 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
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more.
When a profile selects 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; | ||
| } | ||
| } | ||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
On a cold launch with a nonblack
SPLASH_BACKGROUND_COLOR, Android displays the activity's starting window beforeonCreate()andsetContentView(), and that window still uses the hardcoded blackandroid:windowBackgroundfromTheme.StarlightUnit. Setting theImageViewbackground 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 👍 / 👎.