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
Original file line number Diff line number Diff line change
Expand Up @@ -124,11 +124,92 @@ static Optional<BrowserService> create() {
* <p><b>iOS Configuration</b>: none for the custom-scheme form; the HTTPS form requires the
* Associated Domains capability and {@code webcredentials} entitlement described above.</p>
*
* <p>On <b>Android</b> and <b>Desktop</b> the default implementation simply opens the URL in the
* external browser (see {@link #launchExternalBrowser(String)}). On Android the redirect is
* caught by the system through an HTTPS or custom-scheme intent filter declared in the
* {@code AndroidManifest.xml}, and the resulting URL can be read with the
* {@code RuntimeArgsService}.</p>
* <p>On <b>Android</b> this is implemented with an
* <a href="https://developer.chrome.com/docs/android/custom-tabs/guide-auth-tab">Auth Tab</a>,
* a specialized Custom Tab designed for authentication flows, launched on top of the app.
* When the web service redirects to a URL that matches {@code callbackUrlScheme}, the tab
* is automatically dismissed and the full callback URL is passed to {@code callback},
* without going through the system URL dispatch.</p>
*
* <p>Auth Tab requires the user's default browser to support it (e.g. Chrome 132+). Since that
* cannot be known in advance, on devices where it is not supported the same intent
* automatically falls back to a regular
* <a href="https://developer.android.com/develop/ui/views/layout/webapps/overview-of-android-custom-tabs">Custom
* Tab</a>: in that case the redirect is not captured by the tab but dispatched by the system
* to the activity {@code com.gluonhq.helloandroid.WebAuthCallbackActivity}, provided by this
* service, which delivers it to {@code callback} exactly as in the Auth Tab case. The system
* dispatch requires an intent filter for that activity in the {@code AndroidManifest.xml},
* with the requirements below for each of the two {@code callbackUrlScheme} forms. The
* intent filter is harmless when Auth Tab is available (the tab intercepts the redirect
* before it reaches the system dispatch), so apps distributed to arbitrary devices should
* always declare it.</p>
*
* <ul>
* <li><b>The custom URL scheme form</b> (e.g. {@code "myapp"}), with a redirect like
* {@code myapp://callback}:
* <ul>
* <li>Auth Tab: no setup required.</li>
* <li>Custom Tab fallback: an intent filter for the scheme:
* <pre>{@code
* <activity android:name="com.gluonhq.helloandroid.WebAuthCallbackActivity"
* android:exported="true"
* android:configChanges="keyboardHidden|orientation|screenSize">
* <intent-filter>
* <action android:name="android.intent.action.VIEW"/>
* <category android:name="android.intent.category.DEFAULT"/>
* <category android:name="android.intent.category.BROWSABLE"/>
* <data android:scheme="myapp"/>
* </intent-filter>
* </activity>
* }</pre>
* </li>
* </ul>
* </li>
* <li><b>The full HTTPS URL form</b> (e.g. {@code "https://example.com/callback"}), with a
* verified HTTPS redirect. In both cases this requires
* <a href="https://developers.google.com/digital-asset-links">Digital Asset Links</a>
* verification: an {@code assetlinks.json} file hosted at
* {@code https://example.com/.well-known/assetlinks.json} (served as
* {@code application/json}, no redirects), listing the app's package name and signing
* certificate SHA-256 fingerprint with the
* {@code delegate_permission/common.handle_all_urls} relation.
* <ul>
* <li>Auth Tab: no manifest setup required; the browser verifies the calling app
* against the hosted {@code assetlinks.json} on its own.</li>
* <li>Custom Tab fallback: the redirect must additionally be a verified
* <a href="https://developer.android.com/training/app-links">App Link</a>, which
* requires an {@code android:autoVerify} intent filter for the domain (verification
* runs at install time against the same {@code assetlinks.json}):
* <pre>{@code
* <activity android:name="com.gluonhq.helloandroid.WebAuthCallbackActivity"
* android:exported="true"
* android:configChanges="keyboardHidden|orientation|screenSize">
* <intent-filter android:autoVerify="true">
* <action android:name="android.intent.action.VIEW"/>
* <category android:name="android.intent.category.DEFAULT"/>
* <category android:name="android.intent.category.BROWSABLE"/>
* <data android:scheme="https" android:host="example.com" android:path="/callback"/>
* </intent-filter>
* </activity>
* }</pre>
* The verification state can be checked with
* {@code adb shell pm get-app-links <package.name>}. Note that with the fallback the
* redirect travels through the browser as a regular navigation, so the web service
* should use the authorization-code flow ({@code response_type=code}): a redirect
* carrying parameters in the URL fragment (e.g. the implicit flow
* {@code #access_token=...}) is not reliably preserved across the system dispatch.</li>
* </ul>
* </li>
* </ul>
*
* <p><b>Android Configuration</b>: none for Auth Tab browsers with the custom-scheme form;
* the intent filters described above for the Custom Tab fallback, and the hosted
* {@code assetlinks.json} for the HTTPS form.</p>
*
* <p>On <b>Desktop</b> the default implementation simply opens the URL in the
* external browser (see {@link #launchExternalBrowser(String)}), and the redirect has to be
* handled by the application itself (for instance with a local HTTP server listening for a
* {@code http://localhost} redirect).</p>
*
* @param url the authentication URL to load, including the {@code redirect_uri} expected by the
* web service.
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -29,6 +29,7 @@

import com.gluonhq.attach.browser.BrowserService;
import com.gluonhq.attach.util.Util;
import javafx.application.Platform;

import java.io.IOException;
import java.net.URISyntaxException;
Expand All @@ -39,6 +40,8 @@ public class AndroidBrowserService implements BrowserService {

private static final Logger LOG = Logger.getLogger(AndroidBrowserService.class.getName());

private static Consumer<String> authCallback;

static {
System.loadLibrary("browser");
}
Expand All @@ -63,9 +66,31 @@ public void launchExternalBrowser(String url) throws IOException {
@Override
public void launchWebAuthentication(String url, String callbackUrlScheme, Consumer<String> callback)
throws IOException, URISyntaxException {
launchExternalBrowser(url);
if (url == null || url.isEmpty()) {
throw new IOException("Authentication url cannot be null or empty");
}
if (callbackUrlScheme == null || callbackUrlScheme.isEmpty()) {
throw new IOException("Callback url scheme cannot be null or empty");
}
if (Util.DEBUG) {
LOG.info("Launch web authentication URL: " + url + ", callback scheme: " + callbackUrlScheme);
}
authCallback = callback;
startWebAuthentication(url, callbackUrlScheme);
}

// native
private native boolean launchURL(String url);
private native void startWebAuthentication(String url, String callbackUrlScheme);

// callback
public static void setAuthResult(String callbackUrl) {
final Consumer<String> callback = authCallback;
authCallback = null;
if (callback == null) {
LOG.warning("No callback registered for web authentication result");
return;
}
Platform.runLater(() -> callback.accept(callbackUrl));
}
}
47 changes: 46 additions & 1 deletion modules/browser/src/main/native/android/c/browser.c
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, 2021, Gluon
* Copyright (c) 2020, 2026, Gluon
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -27,16 +27,26 @@
*/
#include "util.h"

// Graal handles
static jclass jGraalBrowserClass;
static jmethodID jGraalSetAuthResultMethod;

static jclass jBrowserServiceClass;
static jobject jDalvikBrowserService;
static jmethodID jBrowserServiceLaunchMethod;
static jmethodID jBrowserServiceStartWebAuthMethod;

static void initializeGraalHandles(JNIEnv* env) {
jGraalBrowserClass = (*env)->NewGlobalRef(env, (*env)->FindClass(env, "com/gluonhq/attach/browser/impl/AndroidBrowserService"));
jGraalSetAuthResultMethod = (*env)->GetStaticMethodID(env, jGraalBrowserClass, "setAuthResult", "(Ljava/lang/String;)V");
}

static void initializeDalvikHandles() {
jBrowserServiceClass = GET_REGISTER_DALVIK_CLASS(jBrowserServiceClass, "com/gluonhq/helloandroid/DalvikBrowserService");
ATTACH_DALVIK();
jmethodID jBrowserServiceInitMethod = (*dalvikEnv)->GetMethodID(dalvikEnv, jBrowserServiceClass, "<init>", "(Landroid/app/Activity;)V");
jBrowserServiceLaunchMethod = (*dalvikEnv)->GetMethodID(dalvikEnv, jBrowserServiceClass, "launchURL", "(Ljava/lang/String;)Z");
jBrowserServiceStartWebAuthMethod = (*dalvikEnv)->GetMethodID(dalvikEnv, jBrowserServiceClass, "startWebAuthentication", "(Ljava/lang/String;Ljava/lang/String;)V");

jobject jActivity = substrateGetActivity();
jobject jtmpobj = (*dalvikEnv)->NewObject(dalvikEnv, jBrowserServiceClass, jBrowserServiceInitMethod, jActivity);
Expand All @@ -60,6 +70,7 @@ JNI_OnLoad_browser(JavaVM *vm, void *reserved)
return JNI_FALSE;
}
ATTACH_LOG_FINE("[Browser Service] Initializing native Browser from OnLoad");
initializeGraalHandles(graalEnv);
initializeDalvikHandles();
return JNI_VERSION_1_8;
#else
Expand All @@ -80,3 +91,37 @@ JNIEXPORT jboolean JNICALL Java_com_gluonhq_attach_browser_impl_AndroidBrowserSe
// (*env)->ReleaseStringUTFChars(env, jurl, urlChars);
return result;
}

JNIEXPORT void JNICALL Java_com_gluonhq_attach_browser_impl_AndroidBrowserService_startWebAuthentication
(JNIEnv *env, jclass jClass, jstring jurl, jstring jcallbackUrlScheme)
{
const char *urlChars = (*env)->GetStringUTFChars(env, jurl, NULL);
const char *schemeChars = (*env)->GetStringUTFChars(env, jcallbackUrlScheme, NULL);
if (isDebugAttach()) {
ATTACH_LOG_FINE("Browser start web authentication for url %s, callback scheme %s\n", urlChars, schemeChars);
}
ATTACH_DALVIK();
jstring durl = (*dalvikEnv)->NewStringUTF(dalvikEnv, urlChars);
jstring dscheme = (*dalvikEnv)->NewStringUTF(dalvikEnv, schemeChars);
(*dalvikEnv)->CallVoidMethod(dalvikEnv, jDalvikBrowserService, jBrowserServiceStartWebAuthMethod, durl, dscheme);
DETACH_DALVIK();
// (*env)->ReleaseStringUTFChars(env, jurl, urlChars);
// (*env)->ReleaseStringUTFChars(env, jcallbackUrlScheme, schemeChars);
}

///////////////////////////
// From Dalvik to native //
///////////////////////////

JNIEXPORT void JNICALL Java_com_gluonhq_helloandroid_DalvikBrowserService_nativeWebAuthResult(
JNIEnv *env, jobject service, jstring jcallbackUrl) {
const char *callbackUrlChars = (jcallbackUrl == NULL) ? NULL : (*env)->GetStringUTFChars(env, jcallbackUrl, NULL);
if (isDebugAttach()) {
ATTACH_LOG_FINE("Web authentication result %s\n", (callbackUrlChars == NULL) ? "null" : callbackUrlChars);
}
ATTACH_GRAAL();
jstring jresult = (callbackUrlChars == NULL) ? NULL : (*graalEnv)->NewStringUTF(graalEnv, callbackUrlChars);
(*graalEnv)->CallStaticVoidMethod(graalEnv, jGraalBrowserClass, jGraalSetAuthResultMethod, jresult);
DETACH_GRAAL();
// if (callbackUrlChars != NULL) (*env)->ReleaseStringUTFChars(env, jcallbackUrl, callbackUrlChars);
}
Original file line number Diff line number Diff line change
@@ -1,5 +1,5 @@
/*
* Copyright (c) 2020, 2023, Gluon
* Copyright (c) 2020, 2026, Gluon
*
* This program is free software: you can redistribute it and/or modify
* it under the terms of the GNU General Public License as published by
Expand Down Expand Up @@ -30,15 +30,30 @@
import android.app.Activity;
import android.content.Intent;
import android.net.Uri;
import android.os.Bundle;
import android.util.Log;

public class DalvikBrowserService {

private static final String TAG = Util.TAG;

private static final int WEB_AUTH_REQUEST_CODE = 20126;

// Public intent extras from androidx.browser Auth Tab
private static final String EXTRA_SESSION = "android.support.customtabs.extra.SESSION";
private static final String EXTRA_LAUNCH_AUTH_TAB = "androidx.browser.auth.extra.LAUNCH_AUTH_TAB";
private static final String EXTRA_REDIRECT_SCHEME = "androidx.browser.auth.extra.REDIRECT_SCHEME";
private static final String EXTRA_HTTPS_REDIRECT_HOST = "androidx.browser.auth.extra.HTTPS_REDIRECT_HOST";
private static final String EXTRA_HTTPS_REDIRECT_PATH = "androidx.browser.auth.extra.HTTPS_REDIRECT_PATH";

private final Activity activity;
private final boolean debug;

/** The pending web authentication session, if any, so the redirect can be delivered by
* {@link WebAuthCallbackActivity} when the browser falls back to a regular Custom Tab. */
private static volatile DalvikBrowserService pendingService;
private static volatile String pendingCallbackUrlScheme;

public DalvikBrowserService(Activity activity) {
this.activity = activity;
this.debug = Util.isDebug();
Expand Down Expand Up @@ -68,4 +83,117 @@ private boolean launchURL(String url) {
activity.startActivity(browserIntent);
return true;
}

private void startWebAuthentication(String url, String callbackUrlScheme) {
if (url == null || url.isEmpty() || callbackUrlScheme == null || callbackUrlScheme.isEmpty()) {
Log.e(TAG, "Invalid web authentication parameters: url and callbackUrlScheme are required");
nativeWebAuthResult(null);
return;
}

Intent authIntent = new Intent(Intent.ACTION_VIEW, Uri.parse(url));
authIntent.putExtra(EXTRA_LAUNCH_AUTH_TAB, true);
// null session so browsers without Auth Tab support treat this as a Custom Tab
Bundle sessionBundle = new Bundle();
sessionBundle.putBinder(EXTRA_SESSION, null);
authIntent.putExtras(sessionBundle);

if (callbackUrlScheme.startsWith("https://")) {
Uri redirectUri = Uri.parse(callbackUrlScheme);
String host = redirectUri.getHost();
if (host == null || host.isEmpty()) {
Log.e(TAG, "Invalid https callback url: " + callbackUrlScheme);
nativeWebAuthResult(null);
return;
}
authIntent.putExtra(EXTRA_HTTPS_REDIRECT_HOST, host);
String path = redirectUri.getPath();
authIntent.putExtra(EXTRA_HTTPS_REDIRECT_PATH, (path == null || path.isEmpty()) ? "/" : path);
} else {
authIntent.putExtra(EXTRA_REDIRECT_SCHEME, callbackUrlScheme);
}

if (authIntent.resolveActivity(activity.getPackageManager()) == null) {
Log.e(TAG, "There is no activity to handle the web authentication intent");
nativeWebAuthResult(null);
return;
}

Util.setOnActivityResultHandler(new IntentHandler() {
@Override
public void gotActivityResult(int requestCode, int resultCode, Intent intent) {
if (requestCode != WEB_AUTH_REQUEST_CODE) {
return;
}
Util.setOnActivityResultHandler(null);
if (pendingService == null) {
// result already delivered through WebAuthCallbackActivity (Custom Tab fallback)
return;
}
clearPendingSession();
String callbackUrl = null;
if (resultCode == Activity.RESULT_OK && intent != null && intent.getData() != null) {
callbackUrl = intent.getData().toString();
}
if (debug) {
Log.v(TAG, "Web authentication result, code: " + resultCode + ", url: " + callbackUrl);
}
nativeWebAuthResult(callbackUrl);
}
});
pendingService = this;
pendingCallbackUrlScheme = callbackUrlScheme;

if (debug) {
Log.v(TAG, "Launching web authentication with URL: " + url + ", callback scheme: " + callbackUrlScheme);
}
activity.runOnUiThread(new Runnable() {
@Override
public void run() {
activity.startActivityForResult(authIntent, WEB_AUTH_REQUEST_CODE);
}
});
}

/**
* Called by {@link WebAuthCallbackActivity} when the redirect is dispatched by the system
* instead of being captured by the tab (Custom Tab fallback for browsers without Auth Tab
* support). Delivers the callback URL to the pending session, if it matches.
*
* @param uri the redirect URI received by the intent filter
* @return true if there was a pending session matching the URI and the result was delivered
*/
static boolean handleWebAuthCallback(Uri uri) {
DalvikBrowserService service = pendingService;
String scheme = pendingCallbackUrlScheme;
if (service == null || scheme == null || uri == null || !matchesCallback(uri, scheme)) {
return false;
}
clearPendingSession();
Util.setOnActivityResultHandler(null);
if (service.debug) {
Log.v(TAG, "Web authentication result from callback activity, url: " + uri);
}
service.nativeWebAuthResult(uri.toString());
return true;
}

private static boolean matchesCallback(Uri uri, String callbackUrlScheme) {
if (callbackUrlScheme.startsWith("https://")) {
Uri redirectUri = Uri.parse(callbackUrlScheme);
String path = redirectUri.getPath();
return "https".equals(uri.getScheme())
&& redirectUri.getHost() != null && redirectUri.getHost().equals(uri.getHost())
&& (path == null || path.isEmpty() || "/".equals(path)
|| (uri.getPath() != null && uri.getPath().startsWith(path)));
}
return callbackUrlScheme.equals(uri.getScheme());
}

private static void clearPendingSession() {
pendingService = null;
pendingCallbackUrlScheme = null;
}

private native void nativeWebAuthResult(String callbackUrl);
}
Loading
Loading