diff --git a/CHANGELOG.md b/CHANGELOG.md index f4a2111..8bababa 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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. diff --git a/app/src/main/java/de/starlightunit/wrapper/MainActivity.java b/app/src/main/java/de/starlightunit/wrapper/MainActivity.java index baa4124..3af703e 100644 --- a/app/src/main/java/de/starlightunit/wrapper/MainActivity.java +++ b/app/src/main/java/de/starlightunit/wrapper/MainActivity.java @@ -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; @@ -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; @@ -64,6 +69,7 @@ public final class MainActivity extends Activity private ValueCallback pendingFileCallback; private boolean mainFrameFailed; private NavigationPolicy navigationPolicy; + private LinkRoutingPolicy linkRoutingPolicy; private NativeNavigationController nativeNavigation; private Map requestHeaders; private QuantumNativeMediaPlayer nativeMediaPlayer; @@ -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, @@ -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(); @@ -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) { @@ -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; @@ -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 callback, 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 1321a22..4cc25d5 100644 --- a/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java +++ b/app/src/main/java/de/starlightunit/wrapper/config/AppConfig.java @@ -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 = ""; + 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; diff --git a/app/src/main/java/de/starlightunit/wrapper/navigation/DeepLinkResolver.java b/app/src/main/java/de/starlightunit/wrapper/navigation/DeepLinkResolver.java new file mode 100644 index 0000000..67dc670 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/navigation/DeepLinkResolver.java @@ -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 : ""; + } + + 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 : ""; + } + + 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 ""; + } + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/navigation/ExternalLinkLauncher.java b/app/src/main/java/de/starlightunit/wrapper/navigation/ExternalLinkLauncher.java new file mode 100644 index 0000000..cca76a9 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/navigation/ExternalLinkLauncher.java @@ -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; + } + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/navigation/LinkRoutingPolicy.java b/app/src/main/java/de/starlightunit/wrapper/navigation/LinkRoutingPolicy.java new file mode 100644 index 0000000..65dc798 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/navigation/LinkRoutingPolicy.java @@ -0,0 +1,161 @@ +package de.starlightunit.wrapper.navigation; + +import org.json.JSONArray; +import org.json.JSONObject; + +import java.net.URI; +import java.net.URISyntaxException; +import java.util.ArrayList; +import java.util.Collections; +import java.util.List; +import java.util.Locale; + +public final class LinkRoutingPolicy { + public enum Action { + INTERNAL, + EXTERNAL, + BLOCK + } + + private static final class Rule { + final String scheme; + final String host; + final String pathPrefix; + final Action action; + + Rule(String scheme, String host, String pathPrefix, Action action) { + this.scheme = normalized(scheme); + this.host = normalized(host); + this.pathPrefix = pathPrefix == null ? "" : pathPrefix; + this.action = action; + } + + boolean matches(URI uri) { + String actualScheme = normalized(uri.getScheme()); + String actualHost = normalized(uri.getHost()); + String actualPath = uri.getPath() == null ? "/" : uri.getPath(); + + if (!scheme.isEmpty() && !scheme.equals(actualScheme)) { + return false; + } + if (!host.isEmpty()) { + if (host.startsWith("*.")) { + String suffix = host.substring(1); + if (!actualHost.endsWith(suffix) || actualHost.equals(host.substring(2))) { + return false; + } + } else if (!host.equals(actualHost)) { + return false; + } + } + return pathPrefix.isEmpty() || actualPath.startsWith(pathPrefix); + } + } + + private final NavigationPolicy navigationPolicy; + private final List rules; + + public LinkRoutingPolicy(NavigationPolicy navigationPolicy, String rulesJson) { + this.navigationPolicy = navigationPolicy; + this.rules = Collections.unmodifiableList(parseRules(rulesJson)); + } + + public Action actionFor(String rawUrl) { + if (rawUrl == null || rawUrl.trim().isEmpty()) { + return Action.BLOCK; + } + + final URI uri; + try { + uri = new URI(rawUrl.trim()); + } catch (URISyntaxException | IllegalArgumentException ignored) { + return Action.BLOCK; + } + + for (Rule rule : rules) { + if (!rule.matches(uri)) { + continue; + } + if (rule.action == Action.INTERNAL) { + return navigationPolicy.shouldStayInWebView(rawUrl) + ? Action.INTERNAL + : Action.BLOCK; + } + if (rule.action == Action.EXTERNAL) { + return isAllowedExternalScheme(rawUrl) + ? Action.EXTERNAL + : Action.BLOCK; + } + return Action.BLOCK; + } + + if (navigationPolicy.shouldStayInWebView(rawUrl)) { + return Action.INTERNAL; + } + return isAllowedExternalScheme(rawUrl) ? Action.EXTERNAL : Action.BLOCK; + } + + public static boolean isAllowedExternalScheme(String url) { + if (url == null) { + return false; + } + try { + URI uri = new URI(url); + String scheme = normalized(uri.getScheme()); + return "https".equals(scheme) + || "http".equals(scheme) + || "mailto".equals(scheme) + || "tel".equals(scheme) + || "geo".equals(scheme) + || "market".equals(scheme) + || "intent".equals(scheme); + } catch (URISyntaxException | IllegalArgumentException ignored) { + return false; + } + } + + private static List parseRules(String rulesJson) { + List parsed = new ArrayList<>(); + try { + JSONArray array = new JSONArray( + rulesJson == null || rulesJson.trim().isEmpty() ? "[]" : rulesJson + ); + for (int i = 0; i < array.length() && parsed.size() < 64; i++) { + JSONObject raw = array.optJSONObject(i); + if (raw == null) { + continue; + } + String actionValue = normalized(raw.optString("action", "")); + Action action; + switch (actionValue) { + case "internal": + action = Action.INTERNAL; + break; + case "external": + action = Action.EXTERNAL; + break; + case "block": + action = Action.BLOCK; + break; + default: + continue; + } + + String scheme = raw.optString("scheme", "").trim(); + String host = raw.optString("host", "").trim(); + String pathPrefix = raw.optString("path_prefix", "").trim(); + if (scheme.isEmpty() && host.isEmpty() && pathPrefix.isEmpty()) { + continue; + } + parsed.add(new Rule(scheme, host, pathPrefix, action)); + } + } catch (Exception ignored) { + parsed.clear(); + } + return parsed; + } + + private static String normalized(String value) { + return value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/navigation/NewWindowPolicy.java b/app/src/main/java/de/starlightunit/wrapper/navigation/NewWindowPolicy.java new file mode 100644 index 0000000..f6691b5 --- /dev/null +++ b/app/src/main/java/de/starlightunit/wrapper/navigation/NewWindowPolicy.java @@ -0,0 +1,28 @@ +package de.starlightunit.wrapper.navigation; + +import java.util.Locale; + +public final class NewWindowPolicy { + public static final String BLOCKED = "blocked"; + public static final String INTERNAL = "internal"; + public static final String EXTERNAL = "external"; + + private NewWindowPolicy() { + } + + public static String normalize(String value) { + String normalized = value == null ? "" : value.trim().toLowerCase(Locale.ROOT); + switch (normalized) { + case INTERNAL: + case EXTERNAL: + case BLOCKED: + return normalized; + default: + return BLOCKED; + } + } + + public static boolean supportsMultipleWindows(String value) { + return !BLOCKED.equals(normalize(value)); + } +} diff --git a/app/src/main/java/de/starlightunit/wrapper/web/GameWebChromeClient.java b/app/src/main/java/de/starlightunit/wrapper/web/GameWebChromeClient.java index 14c9db2..2582722 100644 --- a/app/src/main/java/de/starlightunit/wrapper/web/GameWebChromeClient.java +++ b/app/src/main/java/de/starlightunit/wrapper/web/GameWebChromeClient.java @@ -1,10 +1,13 @@ package de.starlightunit.wrapper.web; +import android.os.Message; import android.view.View; import android.webkit.ConsoleMessage; import android.webkit.ValueCallback; import android.webkit.WebChromeClient; +import android.webkit.WebResourceRequest; import android.webkit.WebView; +import android.webkit.WebViewClient; import android.widget.FrameLayout; import de.starlightunit.wrapper.BuildConfig; @@ -14,6 +17,10 @@ public interface FileChooserHost { void openFileChooser(ValueCallback callback, FileChooserParams params); } + public interface NewWindowHost { + void openNewWindow(String url); + } + public interface Callbacks { void onProgress(int progress); void onFullscreenChanged(boolean fullscreen); @@ -21,6 +28,7 @@ public interface Callbacks { private final FrameLayout fullscreenContainer; private final FileChooserHost fileChooserHost; + private final NewWindowHost newWindowHost; private final Callbacks callbacks; private View customView; private CustomViewCallback customViewCallback; @@ -28,10 +36,12 @@ public interface Callbacks { public GameWebChromeClient( FrameLayout fullscreenContainer, FileChooserHost fileChooserHost, + NewWindowHost newWindowHost, Callbacks callbacks ) { this.fullscreenContainer = fullscreenContainer; this.fileChooserHost = fileChooserHost; + this.newWindowHost = newWindowHost; this.callbacks = callbacks; } @@ -40,6 +50,57 @@ public void onProgressChanged(WebView view, int newProgress) { callbacks.onProgress(newProgress); } + @Override + public boolean onCreateWindow( + WebView view, + boolean isDialog, + boolean isUserGesture, + Message resultMsg + ) { + WebView popup = new WebView(view.getContext()); + boolean[] dispatched = new boolean[]{false}; + popup.setWebViewClient(new WebViewClient() { + private void dispatch(String url) { + if (dispatched[0] || url == null || url.trim().isEmpty()) { + return; + } + dispatched[0] = true; + newWindowHost.openNewWindow(url); + popup.post(popup::destroy); + } + + @Override + public void onPageStarted(WebView view, String url, android.graphics.Bitmap favicon) { + dispatch(url); + } + + @Override + public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { + dispatch(request.getUrl().toString()); + return true; + } + + @SuppressWarnings("deprecation") + @Override + public boolean shouldOverrideUrlLoading(WebView view, String url) { + dispatch(url); + return true; + } + }); + + WebView.WebViewTransport transport = (WebView.WebViewTransport) resultMsg.obj; + transport.setWebView(popup); + resultMsg.sendToTarget(); + return true; + } + + @Override + public void onCloseWindow(WebView window) { + if (window != null) { + window.destroy(); + } + } + @Override public boolean onShowFileChooser( WebView webView, diff --git a/app/src/main/java/de/starlightunit/wrapper/web/GameWebViewClient.java b/app/src/main/java/de/starlightunit/wrapper/web/GameWebViewClient.java index e803978..5360b77 100644 --- a/app/src/main/java/de/starlightunit/wrapper/web/GameWebViewClient.java +++ b/app/src/main/java/de/starlightunit/wrapper/web/GameWebViewClient.java @@ -1,10 +1,7 @@ package de.starlightunit.wrapper.web; -import android.content.ActivityNotFoundException; import android.content.Context; -import android.content.Intent; import android.graphics.Bitmap; -import android.net.Uri; import android.net.http.SslError; import android.webkit.SslErrorHandler; import android.webkit.WebResourceError; @@ -20,6 +17,8 @@ import de.starlightunit.wrapper.assets.QuantumAssetStore; import de.starlightunit.wrapper.assets.QuantumStartupAssetDownloader; import de.starlightunit.wrapper.config.AppConfig; +import de.starlightunit.wrapper.navigation.ExternalLinkLauncher; +import de.starlightunit.wrapper.navigation.LinkRoutingPolicy; import de.starlightunit.wrapper.navigation.NavigationPolicy; public final class GameWebViewClient extends WebViewClient { @@ -31,6 +30,7 @@ public interface Callbacks { private final Context context; private final NavigationPolicy navigationPolicy; + private final LinkRoutingPolicy linkRoutingPolicy; private final Callbacks callbacks; private final Map requestHeaders; private final CampaignAudioHandoff campaignAudioHandoff; @@ -40,11 +40,13 @@ public interface Callbacks { public GameWebViewClient( Context context, NavigationPolicy navigationPolicy, + LinkRoutingPolicy linkRoutingPolicy, Callbacks callbacks, Map requestHeaders ) { this.context = context; this.navigationPolicy = navigationPolicy; + this.linkRoutingPolicy = linkRoutingPolicy; this.callbacks = callbacks; this.requestHeaders = Collections.unmodifiableMap(new LinkedHashMap<>(requestHeaders)); this.campaignAudioHandoff = new CampaignAudioHandoff(context); @@ -119,8 +121,10 @@ public WebResourceResponse shouldInterceptRequest(WebView view, WebResourceReque @Override public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request) { String url = request.getUrl().toString(); - if (navigationPolicy.isTrustedHttps(url)) { - if (request.isForMainFrame() + LinkRoutingPolicy.Action action = linkRoutingPolicy.actionFor(url); + if (action == LinkRoutingPolicy.Action.INTERNAL) { + if (navigationPolicy.isTrustedHttps(url) + && request.isForMainFrame() && "GET".equalsIgnoreCase(request.getMethod()) && !WrapperRequestHeaders.containsConfiguredHeaders(request.getRequestHeaders())) { view.loadUrl(url, requestHeaders); @@ -128,13 +132,27 @@ public boolean shouldOverrideUrlLoading(WebView view, WebResourceRequest request } return false; } - return handleNavigation(url); + if (action == LinkRoutingPolicy.Action.EXTERNAL) { + ExternalLinkLauncher.open(context, url); + } + return true; } @SuppressWarnings("deprecation") @Override public boolean shouldOverrideUrlLoading(WebView view, String url) { - return handleNavigation(url); + LinkRoutingPolicy.Action action = linkRoutingPolicy.actionFor(url); + if (action == LinkRoutingPolicy.Action.INTERNAL) { + if (navigationPolicy.isTrustedHttps(url)) { + view.loadUrl(url, requestHeaders); + return true; + } + return false; + } + if (action == LinkRoutingPolicy.Action.EXTERNAL) { + ExternalLinkLauncher.open(context, url); + } + return true; } @Override @@ -169,33 +187,6 @@ public void close() { } } - private boolean handleNavigation(String url) { - if (navigationPolicy.shouldStayInWebView(url)) { - return false; - } - - if (!isAllowedExternalScheme(url)) { - return true; - } - - 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); - } catch (ActivityNotFoundException | SecurityException | java.net.URISyntaxException ignored) { - // Unsupported external targets are blocked instead of being loaded inside the game WebView. - } - return true; - } - private static boolean hasHeader(Map headers, String expectedName) { if (headers == null || headers.isEmpty()) { return false; @@ -208,19 +199,4 @@ private static boolean hasHeader(Map headers, String expectedNam return false; } - private static boolean isAllowedExternalScheme(String url) { - Uri uri = Uri.parse(url); - String scheme = uri.getScheme(); - if (scheme == null) { - return false; - } - String normalized = scheme.toLowerCase(java.util.Locale.ROOT); - return "https".equals(normalized) - || "http".equals(normalized) - || "mailto".equals(normalized) - || "tel".equals(normalized) - || "geo".equals(normalized) - || "market".equals(normalized) - || "intent".equals(normalized); - } } 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 4f7fddc..e9024ec 100644 --- a/app/src/main/java/de/starlightunit/wrapper/web/WebViewConfigurator.java +++ b/app/src/main/java/de/starlightunit/wrapper/web/WebViewConfigurator.java @@ -8,6 +8,7 @@ import de.starlightunit.wrapper.BuildConfig; import de.starlightunit.wrapper.config.AppConfig; +import de.starlightunit.wrapper.navigation.NewWindowPolicy; public final class WebViewConfigurator { private WebViewConfigurator() { @@ -23,7 +24,9 @@ public static void configure(Context context, WebView webView) { settings.setAllowContentAccess(true); settings.setMixedContentMode(WebSettings.MIXED_CONTENT_NEVER_ALLOW); settings.setMediaPlaybackRequiresUserGesture(!AppConfig.ALLOW_AUTOPLAY_MEDIA); - settings.setSupportMultipleWindows(false); + settings.setSupportMultipleWindows( + NewWindowPolicy.supportsMultipleWindows(AppConfig.NEW_WINDOW_POLICY) + ); settings.setJavaScriptCanOpenWindowsAutomatically(false); settings.setSupportZoom(AppConfig.PINCH_TO_ZOOM_ENABLED); settings.setBuiltInZoomControls(AppConfig.PINCH_TO_ZOOM_ENABLED); diff --git a/app/src/test/java/de/starlightunit/wrapper/navigation/DeepLinkResolverTest.java b/app/src/test/java/de/starlightunit/wrapper/navigation/DeepLinkResolverTest.java new file mode 100644 index 0000000..5595586 --- /dev/null +++ b/app/src/test/java/de/starlightunit/wrapper/navigation/DeepLinkResolverTest.java @@ -0,0 +1,58 @@ +package de.starlightunit.wrapper.navigation; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class DeepLinkResolverTest { + + private final NavigationPolicy navigation = new NavigationPolicy("example.test"); + + @Test + public void resolvesRelativeCustomSchemePath() { + assertEquals( + "https://example.test/news/42", + DeepLinkResolver.resolve( + "quantum:///news/42", + "quantum", + "https://example.test/index.html", + navigation + ) + ); + } + + @Test + public void resolvesTrustedUrlQueryTarget() { + assertEquals( + "https://example.test/account?tab=2", + DeepLinkResolver.resolve( + "quantum://open?url=https%3A%2F%2Fexample.test%2Faccount%3Ftab%3D2", + "quantum", + "https://example.test/", + navigation + ) + ); + } + + @Test + public void rejectsUntrustedQueryTargetAndWrongScheme() { + assertEquals( + "", + DeepLinkResolver.resolve( + "quantum://open?url=https%3A%2F%2Fevil.test%2F", + "quantum", + "https://example.test/", + navigation + ) + ); + assertEquals( + "", + DeepLinkResolver.resolve( + "other:///news", + "quantum", + "https://example.test/", + navigation + ) + ); + } +} diff --git a/app/src/test/java/de/starlightunit/wrapper/navigation/LinkRoutingPolicyTest.java b/app/src/test/java/de/starlightunit/wrapper/navigation/LinkRoutingPolicyTest.java new file mode 100644 index 0000000..45a45e1 --- /dev/null +++ b/app/src/test/java/de/starlightunit/wrapper/navigation/LinkRoutingPolicyTest.java @@ -0,0 +1,64 @@ +package de.starlightunit.wrapper.navigation; + +import org.junit.Test; + +import static org.junit.Assert.assertEquals; + +public final class LinkRoutingPolicyTest { + + private final NavigationPolicy navigation = new NavigationPolicy("example.test"); + + @Test + public void orderedRulesUseFirstMatch() { + LinkRoutingPolicy policy = new LinkRoutingPolicy( + navigation, + "[{\"host\":\"example.test\",\"path_prefix\":\"/blocked\",\"action\":\"block\"}," + + "{\"host\":\"example.test\",\"action\":\"internal\"}]" + ); + assertEquals( + LinkRoutingPolicy.Action.BLOCK, + policy.actionFor("https://example.test/blocked/page") + ); + assertEquals( + LinkRoutingPolicy.Action.INTERNAL, + policy.actionFor("https://example.test/home") + ); + } + + @Test + public void internalRuleCannotForceExternalDomainIntoWebView() { + LinkRoutingPolicy policy = new LinkRoutingPolicy( + navigation, + "[{\"host\":\"outside.test\",\"action\":\"internal\"}]" + ); + assertEquals( + LinkRoutingPolicy.Action.BLOCK, + policy.actionFor("https://outside.test/page") + ); + } + + @Test + public void externalRuleStillRequiresAllowedScheme() { + LinkRoutingPolicy policy = new LinkRoutingPolicy( + navigation, + "[{\"scheme\":\"javascript\",\"action\":\"external\"}]" + ); + assertEquals( + LinkRoutingPolicy.Action.BLOCK, + policy.actionFor("javascript:alert(1)") + ); + } + + @Test + public void defaultBehaviorKeepsTrustedWebInternalAndExternalHttpsOutside() { + LinkRoutingPolicy policy = new LinkRoutingPolicy(navigation, "[]"); + assertEquals( + LinkRoutingPolicy.Action.INTERNAL, + policy.actionFor("https://sub.example.test/page") + ); + assertEquals( + LinkRoutingPolicy.Action.EXTERNAL, + policy.actionFor("https://outside.test/page") + ); + } +} diff --git a/app/src/test/java/de/starlightunit/wrapper/navigation/NewWindowPolicyTest.java b/app/src/test/java/de/starlightunit/wrapper/navigation/NewWindowPolicyTest.java new file mode 100644 index 0000000..7038b0d --- /dev/null +++ b/app/src/test/java/de/starlightunit/wrapper/navigation/NewWindowPolicyTest.java @@ -0,0 +1,25 @@ +package de.starlightunit.wrapper.navigation; + +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 NewWindowPolicyTest { + + @Test + public void supportedModesNormalizeAndUnknownBlocks() { + assertEquals("blocked", NewWindowPolicy.normalize(null)); + assertEquals("blocked", NewWindowPolicy.normalize("unexpected")); + assertEquals("internal", NewWindowPolicy.normalize(" INTERNAL ")); + assertEquals("external", NewWindowPolicy.normalize("external")); + } + + @Test + public void onlyNonBlockedModesEnableTemporaryPopupCapture() { + assertFalse(NewWindowPolicy.supportsMultipleWindows("blocked")); + assertTrue(NewWindowPolicy.supportsMultipleWindows("internal")); + assertTrue(NewWindowPolicy.supportsMultipleWindows("external")); + } +}