diff --git a/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java b/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java index f82dd58d..6e972065 100644 --- a/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java +++ b/modules/browser/src/main/java/com/gluonhq/attach/browser/BrowserService.java @@ -124,11 +124,92 @@ static Optional create() { *

iOS Configuration: none for the custom-scheme form; the HTTPS form requires the * Associated Domains capability and {@code webcredentials} entitlement described above.

* - *

On Android and Desktop 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}.

+ *

On Android this is implemented with an + * Auth Tab, + * 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.

+ * + *

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 + * Custom + * Tab: 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.

+ * + * + * + *

Android Configuration: 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.

+ * + *

On Desktop 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).

* * @param url the authentication URL to load, including the {@code redirect_uri} expected by the * web service. diff --git a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java index d8ad8877..fcc980d5 100644 --- a/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java +++ b/modules/browser/src/main/java/com/gluonhq/attach/browser/impl/AndroidBrowserService.java @@ -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; @@ -39,6 +40,8 @@ public class AndroidBrowserService implements BrowserService { private static final Logger LOG = Logger.getLogger(AndroidBrowserService.class.getName()); + private static Consumer authCallback; + static { System.loadLibrary("browser"); } @@ -63,9 +66,31 @@ public void launchExternalBrowser(String url) throws IOException { @Override public void launchWebAuthentication(String url, String callbackUrlScheme, Consumer 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 callback = authCallback; + authCallback = null; + if (callback == null) { + LOG.warning("No callback registered for web authentication result"); + return; + } + Platform.runLater(() -> callback.accept(callbackUrl)); + } } \ No newline at end of file diff --git a/modules/browser/src/main/native/android/c/browser.c b/modules/browser/src/main/native/android/c/browser.c index e8eceb0e..1f0853d3 100644 --- a/modules/browser/src/main/native/android/c/browser.c +++ b/modules/browser/src/main/native/android/c/browser.c @@ -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 @@ -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, "", "(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); @@ -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 @@ -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); +} diff --git a/modules/browser/src/main/native/android/dalvik/DalvikBrowserService.java b/modules/browser/src/main/native/android/dalvik/DalvikBrowserService.java index 1708433a..662ca268 100644 --- a/modules/browser/src/main/native/android/dalvik/DalvikBrowserService.java +++ b/modules/browser/src/main/native/android/dalvik/DalvikBrowserService.java @@ -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 @@ -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(); @@ -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); } \ No newline at end of file diff --git a/modules/browser/src/main/native/android/dalvik/WebAuthCallbackActivity.java b/modules/browser/src/main/native/android/dalvik/WebAuthCallbackActivity.java new file mode 100644 index 00000000..5b170c78 --- /dev/null +++ b/modules/browser/src/main/native/android/dalvik/WebAuthCallbackActivity.java @@ -0,0 +1,90 @@ +/* + * Copyright (c) 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 + * the Free Software Foundation, either version 3 of the License, or + * (at your option) any later version. + + * This program is distributed in the hope that it will be useful, + * but WITHOUT ANY WARRANTY; without even the implied warranty of + * MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + * GNU General Public License for more details. + * + * You should have received a copy of the GNU General Public License + * along with this program. If not, see . + * + * THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" AND + * ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE IMPLIED + * WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE + * DISCLAIMED. IN NO EVENT SHALL GLUON BE LIABLE FOR ANY + * DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL DAMAGES + * (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR SERVICES; + * LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER CAUSED AND + * ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, OR TORT + * (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE OF THIS + * SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. + */ +package com.gluonhq.helloandroid; + +import android.app.Activity; +import android.content.Intent; +import android.net.Uri; +import android.os.Bundle; +import android.util.Log; + +/** + * Trampoline activity that receives the web authentication redirect when the browser does not + * support Auth Tab and the intent falls back to a regular Custom Tab. In that case the redirect + * is dispatched by the system instead of being captured by the tab, so this activity picks it up, + * delivers the callback URL to the pending {@link DalvikBrowserService} session, and brings the + * application back to the front (dismissing the Custom Tab). + * + *

It requires an intent filter matching the callback URL scheme in the AndroidManifest.xml:

+ *
{@code
+ * 
+ *     
+ *         
+ *         
+ *         
+ *         
+ *     
+ * 
+ * }
+ */ +public class WebAuthCallbackActivity extends Activity { + + private static final String TAG = Util.TAG; + + private final boolean debug; + + public WebAuthCallbackActivity() { + debug = Util.isDebug(); + } + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + Uri uri = getIntent().getData(); + if (debug) { + Log.v(TAG, "WebAuthCallbackActivity :: received uri: " + uri); + } + if (uri != null && DalvikBrowserService.handleWebAuthCallback(uri)) { + // bring the main activity back to front, dismissing the Custom Tab on top of it + try { + Class clazz = Class.forName("com.gluonhq.helloandroid.MainActivity"); + Intent intent = new Intent(this, clazz); + intent.setFlags(Intent.FLAG_ACTIVITY_NEW_TASK | Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + startActivity(intent); + } catch (ClassNotFoundException e) { + Log.e(TAG, "WebAuthCallbackActivity :: error " + e.getMessage()); + } + } else if (debug) { + Log.v(TAG, "WebAuthCallbackActivity :: no pending web authentication session for uri: " + uri); + } + finish(); + } +} diff --git a/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json b/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json new file mode 100644 index 00000000..f121ca07 --- /dev/null +++ b/modules/browser/src/main/resources/META-INF/substrate/config/jniconfig-aarch64-android.json @@ -0,0 +1,6 @@ +[ + { + "name" : "com.gluonhq.attach.browser.impl.AndroidBrowserService", + "methods":[{"name":"setAuthResult","parameterTypes":["java.lang.String"] }] + } +]