Skip to content
Merged
11 changes: 11 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,17 @@

## Unreleased

Custom splash replacement and post-load STU attribution.

- Generated apps may replace the visible STU production splash with one profile-provided custom splash; both splash images are never shown in sequence.
- The production splash stays packaged as a safe fallback when no valid custom resource exists.
- Custom-splash builds show the canonical STU attribution banner once after both intro completion and the first successful main-page load.
- Attribution display duration is bounded to 2 through 4 seconds and defaults to 3 seconds.
- The floating banner automatically clears enabled Bottom Tabs and Contextual Toolbar surfaces.
- Factory-splash builds and failed first-page loads never show the footer attribution banner.
- Splash and attribution lifecycles are controlled by separate components with a small tested coordination policy.
- Keeps Android 6 / API 23 compatibility and the existing Android 16 startup safeguards.

Native link handling runtime.

- Adds first-match-wins link rules for scheme, host and path-prefix matching.
Expand Down
32 changes: 31 additions & 1 deletion app/src/main/java/de/starlightunit/wrapper/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -31,7 +31,9 @@
import de.starlightunit.wrapper.config.AppConfig;
import de.starlightunit.wrapper.download.AppDownloadListener;
import de.starlightunit.wrapper.download.DownloadDestinationPolicy;
import de.starlightunit.wrapper.launch.AttributionBannerController;
import de.starlightunit.wrapper.launch.QuantumIntroController;
import de.starlightunit.wrapper.launch.SplashAttributionPolicy;
import de.starlightunit.wrapper.media.QuantumNativeMediaPlayer;
import de.starlightunit.wrapper.navigation.DeepLinkResolver;
import de.starlightunit.wrapper.navigation.ExternalLinkLauncher;
Expand Down Expand Up @@ -74,6 +76,7 @@ public final class MainActivity extends Activity
private Map<String, String> requestHeaders;
private QuantumNativeMediaPlayer nativeMediaPlayer;
private QuantumIntroController introController;
private AttributionBannerController attributionBanner;
private QuantumSessionCookieStore sessionCookieStore;

@Override
Expand Down Expand Up @@ -115,9 +118,22 @@ protected void onCreate(Bundle savedInstanceState) {
introOverlay.setBackgroundColor(
SystemBarStyle.parseRgb(AppConfig.SPLASH_BACKGROUND_COLOR, Color.BLACK)
);
ImageView attributionView = findViewById(R.id.attribution_banner);
attributionBanner = new AttributionBannerController(
this,
attributionView,
AppConfig.ATTRIBUTION_BANNER_ENABLED,
AppConfig.ATTRIBUTION_BANNER_DURATION_MS,
attributionBottomInsetDp()
);
Button retryButton = findViewById(R.id.retry_button);

introController = new QuantumIntroController(this, introOverlay);
introController = new QuantumIntroController(
this,
introOverlay,
AppConfig.CUSTOM_SPLASH_ENABLED,
customSplashUsed -> attributionBanner.onIntroFinished(customSplashUsed)
);
introController.start(savedInstanceState != null);

WebViewConfigurator.configure(this, webView);
Expand Down Expand Up @@ -267,6 +283,13 @@ private void requestLegacyPublicDownloadPermissionIfNeeded() {
}
}

private int attributionBottomInsetDp() {
return SplashAttributionPolicy.footerInsetDp(
AppConfig.BOTTOM_TABS_ENABLED,
AppConfig.CONTEXTUAL_TOOLBAR_ENABLED
);
}

private void configureWindow() {
Window window = getWindow();
window.setStatusBarColor(
Expand Down Expand Up @@ -389,6 +412,9 @@ public void onPageReady() {
refreshLayout.setRefreshing(false);
if (!mainFrameFailed) {
errorPanel.setVisibility(View.GONE);
if (attributionBanner != null) {
attributionBanner.onFirstPageReady();

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 Require a successful HTTP status before showing attribution

When the initial main-frame request returns a 4xx response such as 401, 403, or 404, GameWebViewClient.onReceivedHttpError() does not set mainFrameFailed because it only treats statuses of 500 or higher as errors; onPageFinished() then reaches this call and permanently marks the first page as ready. A custom-splash build will therefore show the attribution banner after a failed first-page load, contrary to the documented behavior. Track main-frame 4xx responses as unsuccessful for this coordination path, or pass an explicit successful-load signal.

Useful? React with 👍 / 👎.

}
}
if (sessionCookieStore != null) {
sessionCookieStore.capture();
Expand Down Expand Up @@ -540,6 +566,10 @@ protected void onDestroy() {
introController.cancel();
introController = null;
}
if (attributionBanner != null) {
attributionBanner.cancel();
attributionBanner = null;
}
if (pendingFileCallback != null) {
pendingFileCallback.onReceiveValue(null);
pendingFileCallback = null;
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -50,6 +50,10 @@ public final class AppConfig {
public static final int LOADING_SPINNER_SIZE_DP = 56;
public static final int LOADING_OVERLAY_DIM_PERCENT = 35;

public static final boolean CUSTOM_SPLASH_ENABLED = false;
public static final boolean ATTRIBUTION_BANNER_ENABLED = false;
public static final int ATTRIBUTION_BANNER_DURATION_MS = 3000;

public static final boolean KEEP_SCREEN_ON = true;
public static final boolean PAGE_TRANSITIONS_ENABLED = false;
public static final boolean TOP_NAVIGATION_ENABLED = false;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,124 @@
package de.starlightunit.wrapper.launch;

import android.app.Activity;
import android.os.Handler;
import android.os.Looper;
import android.view.View;
import android.widget.FrameLayout;
import android.widget.ImageView;

public final class AttributionBannerController {
private static final long FADE_IN_MS = 180L;
private static final long FADE_OUT_MS = 220L;

private final Activity activity;
private final ImageView banner;
private final boolean enabled;
private final long durationMs;
private final Handler handler = new Handler(Looper.getMainLooper());

private boolean introFinished;
private boolean customSplashUsed;
private boolean firstPageReady;
private boolean shown;
private boolean cancelled;

public AttributionBannerController(
Activity activity,
ImageView banner,
boolean enabled,
int durationMs,
int bottomInsetDp
) {
this.activity = activity;
this.banner = banner;
this.enabled = enabled;
this.durationMs = SplashAttributionPolicy.clampDurationMs(durationMs);
applyBottomInset(bottomInsetDp);
banner.setVisibility(View.GONE);
banner.setAlpha(0f);
}

public void onIntroFinished(boolean customSplashUsed) {
this.introFinished = true;
this.customSplashUsed = customSplashUsed;
maybeShow();
}

public void onFirstPageReady() {
if (firstPageReady) {
return;
}
firstPageReady = true;
maybeShow();
}

public void cancel() {
cancelled = true;
handler.removeCallbacksAndMessages(null);
banner.animate().cancel();
banner.setVisibility(View.GONE);
}

private void maybeShow() {
if (!SplashAttributionPolicy.shouldShow(
enabled,
introFinished,
customSplashUsed,
firstPageReady,
shown,
cancelled
)) {
return;
}

int resource = activity.getResources().getIdentifier(
"quantum_studio_attribution_banner",
"drawable",
activity.getPackageName()
);
if (resource == 0) {
return;
Comment on lines +75 to +81

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 Package the attribution drawable used by enabled builds

A repo-wide search of the reviewed tree and its resource history finds no drawable named quantum_studio_attribution_banner, while the change documents only quantum_custom_splash as a profile-provided resource. Consequently, a generated profile that enables the new flags and supplies the documented custom splash reaches resource == 0 here and silently omits the advertised STU attribution. Bundle the canonical banner with the wrapper, or explicitly make it a required generated resource and validate its presence instead of silently returning.

Useful? React with 👍 / 👎.

}

shown = true;
banner.setImageResource(resource);
banner.setVisibility(View.VISIBLE);
banner.setAlpha(0f);
banner.setTranslationY(dp(12));
banner.bringToFront();

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 Keep the banner below modal UI surfaces

During the banner's display window, bringToFront() permanently places it above every sibling in the root FrameLayout. The sidebar and HTML5 fullscreen paths only change their containers to VISIBLE and never bring them forward, so opening the sidebar or entering fullscreen during those seconds leaves the attribution image drawn over the menu or video. Reorder the overlay layers or explicitly keep modal containers above this nonmodal banner.

Useful? React with 👍 / 👎.

banner.animate()
.alpha(1f)
.translationY(0f)
.setDuration(FADE_IN_MS)
.withEndAction(() -> handler.postDelayed(this::hide, durationMs))
Comment on lines +93 to +94

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 Include fade time in the four-second display limit

When ATTRIBUTION_BANNER_DURATION_MS is configured at the supported 4000 ms maximum, this delay begins only after the 180 ms fade-in completes, and hide() then performs another 220 ms fade-out. The banner is consequently displayed for about 4.4 seconds, exceeding the advertised two-to-four-second bound; schedule the fade-out early enough that the complete visible lifecycle fits within the clamped duration.

Useful? React with 👍 / 👎.

.start();
}

private void hide() {
if (cancelled) {
return;
}
banner.animate().cancel();
banner.animate()
.alpha(0f)
.translationY(dp(8))
.setDuration(FADE_OUT_MS)
.withEndAction(() -> {
if (!cancelled) {
banner.setVisibility(View.GONE);
}
})
.start();
}

private void applyBottomInset(int bottomInsetDp) {
FrameLayout.LayoutParams params = (FrameLayout.LayoutParams) banner.getLayoutParams();
params.bottomMargin = dp(Math.max(16, bottomInsetDp));
banner.setLayoutParams(params);
}

private int dp(int value) {
return Math.round(value * activity.getResources().getDisplayMetrics().density);
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -10,44 +10,67 @@
import de.starlightunit.wrapper.R;

/**
* Controls the mandatory Starlight Quantum production splash and the optional
* app-owner intro. The production splash is always first. A generated wrapper
* may optionally provide a drawable named {@code quantum_user_intro}; when it
* is absent the app proceeds directly to the WebView.
* Shows exactly one startup splash. Generated apps may replace the canonical
* Starlight production splash with a profile-provided drawable named
* quantum_custom_splash. The two splash variants are mutually exclusive.
*/
public final class QuantumIntroController {
private static final long PRODUCTION_SPLASH_MS = 2500L;
private static final long USER_INTRO_MS = 2000L;
public interface Completion {
void onIntroFinished(boolean customSplashUsed);
}

private static final long SPLASH_MS = 2500L;
private static final long FADE_MS = 180L;

private final Activity activity;
private final ImageView overlay;
private final boolean customSplashEnabled;
private final Completion completion;
private final Handler handler = new Handler(Looper.getMainLooper());
private boolean cancelled;

public QuantumIntroController(Activity activity, ImageView overlay) {
private boolean customSplashUsed;
private boolean completionDelivered;

public QuantumIntroController(
Activity activity,
ImageView overlay,
boolean customSplashEnabled,
Completion completion
) {
this.activity = activity;
this.overlay = overlay;
this.customSplashEnabled = customSplashEnabled;
this.completion = completion;
}

public void start(boolean restoredActivityState) {
cancelPending();
cancelled = false;
completionDelivered = false;
customSplashUsed = false;

int splashResource = R.drawable.quantum_production_splash;
if (customSplashEnabled) {
int custom = activity.getResources().getIdentifier(
"quantum_custom_splash",
"drawable",
activity.getPackageName()
);
if (custom != 0) {
splashResource = custom;
customSplashUsed = true;
}
}

// The production attribution is mandatory for every newly created
// MainActivity. Do not suppress it just because Android supplied a
// restored state after an update/process recreation.
overlay.animate().cancel();
overlay.setImageResource(R.drawable.quantum_production_splash);
overlay.setImageResource(splashResource);
overlay.setAlpha(1f);
overlay.setVisibility(View.VISIBLE);
overlay.bringToFront();
overlay.invalidate();

// Start the 2.5 s display window only after Android is about to render
// the overlay for the first time. Previously the timer started inside
// onCreate; WebView startup could consume that interval before a frame
// reached the display, making the splash appear to be skipped.
// Start the display interval only after Android is about to render the
// chosen splash. WebView startup therefore cannot consume the interval.
overlay.getViewTreeObserver().addOnPreDrawListener(new ViewTreeObserver.OnPreDrawListener() {
private boolean armed;

Expand All @@ -63,8 +86,8 @@ public boolean onPreDraw() {
}
if (!cancelled) {
handler.postDelayed(
QuantumIntroController.this::showOptionalUserIntroOrFinish,
PRODUCTION_SPLASH_MS
QuantumIntroController.this::fadeOut,
SPLASH_MS
);
}
return true;
Expand All @@ -78,22 +101,10 @@ public void cancel() {
overlay.animate().cancel();
}

private void showOptionalUserIntroOrFinish() {
private void fadeOut() {
if (cancelled) {
return;
}

int userIntro = activity.getResources().getIdentifier(
"quantum_user_intro",
"drawable",
activity.getPackageName()
);

if (userIntro == 0) {
fadeOut();
return;
}

overlay.animate().cancel();
overlay.animate()
.alpha(0f)
Expand All @@ -102,31 +113,18 @@ private void showOptionalUserIntroOrFinish() {
if (cancelled) {
return;
}
overlay.setImageResource(userIntro);
overlay.setAlpha(0f);
overlay.animate()
.alpha(1f)
.setDuration(FADE_MS)
.withEndAction(() -> handler.postDelayed(this::fadeOut, USER_INTRO_MS))
.start();
overlay.setVisibility(View.GONE);
deliverCompletion();
})
.start();
}

private void fadeOut() {
if (cancelled) {
private void deliverCompletion() {
if (completionDelivered || completion == null) {
return;
}
overlay.animate().cancel();
overlay.animate()
.alpha(0f)
.setDuration(FADE_MS)
.withEndAction(() -> {
if (!cancelled) {
overlay.setVisibility(View.GONE);
}
})
.start();
completionDelivered = true;
completion.onIntroFinished(customSplashUsed);
}

private void cancelPending() {
Expand Down
Loading
Loading