Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
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

Native link handling runtime.

- Adds first-match-wins link rules for scheme, host and path-prefix matching.
- Link rules can keep trusted targets internal, delegate allowed targets externally, or block them.
- Adds profile-controlled target-blank/window.open policy for Blocked, Internal and External behavior.
- Internal new windows are folded into the existing WebView; temporary popup WebViews are destroyed after URL capture.
- Adds trusted custom-scheme deep links and resolves incoming intents only to trusted app HTTPS URLs.
- Centralizes Android external-link launching behind the same allowed-scheme policy.
- Keeps Android 6 / API 23 compatibility and the existing trusted-domain boundary.

Native Navigation runtime foundation.

- Adds native Top Bar, Sidebar, Bottom Tabs and Contextual Toolbar surfaces controlled by compiled app profiles.
Expand Down
57 changes: 54 additions & 3 deletions app/src/main/java/de/starlightunit/wrapper/MainActivity.java
Original file line number Diff line number Diff line change
Expand Up @@ -33,7 +33,11 @@
import de.starlightunit.wrapper.download.DownloadDestinationPolicy;
import de.starlightunit.wrapper.launch.QuantumIntroController;
import de.starlightunit.wrapper.media.QuantumNativeMediaPlayer;
import de.starlightunit.wrapper.navigation.DeepLinkResolver;
import de.starlightunit.wrapper.navigation.ExternalLinkLauncher;
import de.starlightunit.wrapper.navigation.LinkRoutingPolicy;
import de.starlightunit.wrapper.navigation.NativeNavigationController;
import de.starlightunit.wrapper.navigation.NewWindowPolicy;
import de.starlightunit.wrapper.navigation.NavigationPolicy;
import de.starlightunit.wrapper.session.CookiePersistencePolicy;
import de.starlightunit.wrapper.session.QuantumSessionCookieStore;
Expand All @@ -49,6 +53,7 @@
public final class MainActivity extends Activity
implements GameWebChromeClient.FileChooserHost,
GameWebViewClient.Callbacks,
GameWebChromeClient.NewWindowHost,
GameWebChromeClient.Callbacks {

private static final int FILE_CHOOSER_REQUEST = 7001;
Expand All @@ -64,6 +69,7 @@ public final class MainActivity extends Activity
private ValueCallback<android.net.Uri[]> pendingFileCallback;
private boolean mainFrameFailed;
private NavigationPolicy navigationPolicy;
private LinkRoutingPolicy linkRoutingPolicy;
private NativeNavigationController nativeNavigation;
private Map<String, String> requestHeaders;
private QuantumNativeMediaPlayer nativeMediaPlayer;
Expand Down Expand Up @@ -124,6 +130,7 @@ protected void onCreate(Bundle savedInstanceState) {
}

navigationPolicy = new NavigationPolicy(AppConfig.TRUSTED_DOMAIN);
linkRoutingPolicy = new LinkRoutingPolicy(navigationPolicy, AppConfig.LINK_RULES_JSON);
nativeNavigation = new NativeNavigationController(
this,
refreshLayout,
Expand Down Expand Up @@ -193,9 +200,9 @@ public boolean canGoForward() {
new QuantumNativeMediaBridge(webView, navigationPolicy, nativeMediaPlayer),
AppConfig.NATIVE_MEDIA_BRIDGE_NAME
);
webViewClient = new GameWebViewClient(this, navigationPolicy, this, requestHeaders);
webViewClient = new GameWebViewClient(this, navigationPolicy, linkRoutingPolicy, this, requestHeaders);
webView.setWebViewClient(webViewClient);
chromeClient = new GameWebChromeClient(fullscreenContainer, this, this);
chromeClient = new GameWebChromeClient(fullscreenContainer, this, this, this);
webView.setWebChromeClient(chromeClient);
webView.setDownloadListener(new AppDownloadListener(this, requestHeaders));
requestLegacyPublicDownloadPermissionIfNeeded();
Expand All @@ -207,7 +214,8 @@ public boolean canGoForward() {
});

if (savedInstanceState == null || webView.restoreState(savedInstanceState) == null) {
loadTrustedUrl(AppConfig.START_URL);
String deepLinkTarget = resolveDeepLinkTarget(getIntent());
loadTrustedUrl(deepLinkTarget.isEmpty() ? AppConfig.START_URL : deepLinkTarget);
}

if (Build.VERSION.SDK_INT >= 33) {
Expand All @@ -222,6 +230,28 @@ private void loadTrustedUrl(String requestedUrl) {
webView.loadUrl(targetUrl, requestHeaders);
}

private String resolveDeepLinkTarget(Intent intent) {
if (intent == null || intent.getData() == null) {
return "";
}
return DeepLinkResolver.resolve(
intent.getData().toString(),
AppConfig.DEEP_LINK_SCHEME,
AppConfig.START_URL,
navigationPolicy
);
}

@Override
protected void onNewIntent(Intent intent) {
super.onNewIntent(intent);
setIntent(intent);
String target = resolveDeepLinkTarget(intent);
if (!target.isEmpty()) {
loadTrustedUrl(target);
}
}

private void requestLegacyPublicDownloadPermissionIfNeeded() {
boolean granted = checkSelfPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)
== PackageManager.PERMISSION_GRANTED;
Expand Down Expand Up @@ -291,6 +321,27 @@ private void applyConfiguredSystemUi(boolean forceFullscreen) {
decorView.setSystemUiVisibility(flags);
}

@Override
public void openNewWindow(String url) {
String policy = NewWindowPolicy.normalize(AppConfig.NEW_WINDOW_POLICY);
if (NewWindowPolicy.BLOCKED.equals(policy)) {
return;
}

LinkRoutingPolicy.Action action = linkRoutingPolicy.actionFor(url);
if (NewWindowPolicy.INTERNAL.equals(policy)) {
if (action == LinkRoutingPolicy.Action.INTERNAL && navigationPolicy.isTrustedHttps(url)) {
loadTrustedUrl(url);
}
return;
}

if (NewWindowPolicy.EXTERNAL.equals(policy)
&& action != LinkRoutingPolicy.Action.BLOCK) {
ExternalLinkLauncher.open(this, url);
}
}

@Override
public void openFileChooser(
ValueCallback<android.net.Uri[]> callback,
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -40,6 +40,10 @@ public final class AppConfig {
public static final String NATIVE_NAVIGATION_FOREGROUND_COLOR = "#ffffff";
public static final String NATIVE_NAVIGATION_ACCENT_COLOR = "#6fc7ff";

public static final String NEW_WINDOW_POLICY = "blocked";
public static final String DEEP_LINK_SCHEME = "";

Copy link
Copy Markdown

Choose a reason for hiding this comment

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

P1 Badge Register the configured deep-link scheme

When a generated profile sets DEEP_LINK_SCHEME, Android still cannot deliver matching custom-scheme URLs to this activity: the repository-wide manifest contains only the MAIN/LAUNCHER intent filter and no VIEW filter with the configured scheme. Consequently, launching a URI such as quantum:///news/42 finds no matching activity, so the new resolveDeepLinkTarget() and onNewIntent() paths are never reached. The profile/build output must also add a browsable custom-scheme intent filter.

Useful? React with 👍 / 👎.

public static final String LINK_RULES_JSON = "[]";

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;
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,97 @@
package de.starlightunit.wrapper.navigation;

import java.net.URI;
import java.net.URISyntaxException;
import java.net.URLDecoder;
import java.nio.charset.StandardCharsets;
import java.util.Locale;

public final class DeepLinkResolver {
private DeepLinkResolver() {
}

public static String resolve(
String rawUri,
String configuredScheme,
String startUrl,
NavigationPolicy navigationPolicy
) {
String scheme = normalizeScheme(configuredScheme);
if (scheme.isEmpty() || rawUri == null || rawUri.trim().isEmpty()) {
return "";
}

final URI incoming;
final URI base;
try {
incoming = new URI(rawUri.trim());
base = new URI(startUrl);
} catch (URISyntaxException | IllegalArgumentException ignored) {
return "";
}

if (!scheme.equals(normalizeScheme(incoming.getScheme()))) {
return "";
}

String queryTarget = queryParameter(incoming.getRawQuery(), "url");
if (!queryTarget.isEmpty()) {
return navigationPolicy.isTrustedHttps(queryTarget) ? queryTarget : "";
}
Comment on lines +37 to +40

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 Limit the special url parameter to the open form

Any custom deep link containing a nonempty query parameter named url is treated as an absolute override before the path is resolved, even when the URI is not the documented scheme://open?url=... form. Thus a valid trusted route such as quantum:///share?url=%2Farticle%2F42 is rejected because /article/42 is not an absolute trusted HTTPS URL, and a page can never receive its ordinary url parameter. Only interpret this parameter as the target when the authority denotes the open form; otherwise preserve it as part of the trusted relative route.

Useful? React with 👍 / 👎.


String path = incoming.getRawPath();
if (path == null || path.isEmpty()) {
path = "/";
}

String host = incoming.getHost();
if (host != null && !host.isEmpty() && !"open".equalsIgnoreCase(host)) {
path = "/" + host + (path.startsWith("/") ? path : "/" + path);
}

String query = incoming.getRawQuery();
String fragment = incoming.getRawFragment();
StringBuilder relative = new StringBuilder(path.startsWith("/") ? path : "/" + path);
if (query != null && !query.isEmpty()) {
relative.append('?').append(query);
}
if (fragment != null && !fragment.isEmpty()) {
relative.append('#').append(fragment);
}

try {
String resolved = base.resolve(relative.toString()).toString();
return navigationPolicy.isTrustedHttps(resolved) ? resolved : "";
} catch (IllegalArgumentException ignored) {
return "";
}
}

public static String normalizeScheme(String value) {
String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT);
return normalized.matches("^[a-z][a-z0-9+.-]{1,31}$") ? normalized : "";
Comment on lines +70 to +72

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 Accept single-character custom URI schemes

A profile using a valid one-character URI scheme such as q: is silently disabled because the regular expression requires at least one character after the initial letter. resolve() consequently sees an empty configured scheme and rejects every incoming deep link, even though the URI scheme grammar permits a single alphabetic character. Change the repeated suffix from one-or-more to zero-or-more while retaining any desired maximum length.

Useful? React with 👍 / 👎.

}

private static String queryParameter(String rawQuery, String key) {
if (rawQuery == null || rawQuery.isEmpty()) {
return "";
}
for (String pair : rawQuery.split("&")) {
int split = pair.indexOf('=');
String rawKey = split >= 0 ? pair.substring(0, split) : pair;
if (!key.equals(decode(rawKey))) {
continue;
}
return decode(split >= 0 ? pair.substring(split + 1) : "");
}
return "";
}

private static String decode(String value) {
try {
return URLDecoder.decode(value, StandardCharsets.UTF_8.name());
} catch (Exception ignored) {
return "";
}
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,33 @@
package de.starlightunit.wrapper.navigation;

import android.content.ActivityNotFoundException;
import android.content.Context;
import android.content.Intent;
import android.net.Uri;

public final class ExternalLinkLauncher {
private ExternalLinkLauncher() {
}

public static boolean open(Context context, String url) {
if (!LinkRoutingPolicy.isAllowedExternalScheme(url)) {
return false;
}
try {
Intent intent;
String scheme = Uri.parse(url).getScheme();
if ("intent".equalsIgnoreCase(scheme)) {
intent = Intent.parseUri(url, Intent.URI_INTENT_SCHEME);
intent.setComponent(null);
intent.setSelector(null);
} else {
intent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
}
intent.addCategory(Intent.CATEGORY_BROWSABLE);
context.startActivity(intent);
return true;
} catch (ActivityNotFoundException | SecurityException | java.net.URISyntaxException ignored) {
return false;
}
}
}
Loading
Loading