diff --git a/Source/Immutable/Immutable_UPL_Android.xml b/Source/Immutable/Immutable_UPL_Android.xml index ea9a9cf6..816c4246 100644 --- a/Source/Immutable/Immutable_UPL_Android.xml +++ b/Source/Immutable/Immutable_UPL_Android.xml @@ -9,7 +9,9 @@ -dontwarn com.immutable.unreal -keep class com.immutable.unreal.** { *; } -keep interface com.immutable.unreal.** { *; } - -keep public class com.immutable.unreal.ImmutableAndroid.** { public protected *; } + -keep public class com.immutable.unreal.ImmutableActivity { public protected *; } + -keep public class com.immutable.unreal.CustomTabsController { public protected *; } + -keep public class com.immutable.unreal.RedirectActivity { public protected *; } -dontwarn androidx.** -keep class androidx.** { *; } @@ -22,44 +24,27 @@ + + + - - - import com.immutable.unreal.ImmutableAndroid; - - - - - ImmutableAndroid.Callback, - - - - - Uri uri = getIntent().getData(); - if (uri != null) { - String deeplink = uri.toString(); - handleDeepLink(uri.toString()); - } - - - - - Uri uri = getIntent().getData(); - if (uri != null) { - String deeplink = uri.toString(); - handleDeepLink(deeplink); - } - - - public native void handleDeepLink(String Deeplink); + public static native void handleDeepLink(String Deeplink); - public native void handleOnCustomTabsDismissed(String Url); + public static native void handleOnCustomTabsDismissed(String Url); - @Override - public void onCustomTabsDismissed(String Url) { - handleOnCustomTabsDismissed(Url); + public static void onDeeplinkResult(String url) { + handleDeepLink(url); + } + + public static void onCustomTabsDismissed(String url) { + handleOnCustomTabsDismissed(url); } diff --git a/Source/Immutable/Private/Immutable/Android/Java/CustomTabsController.java b/Source/Immutable/Private/Immutable/Android/Java/CustomTabsController.java new file mode 100644 index 00000000..49584a95 --- /dev/null +++ b/Source/Immutable/Private/Immutable/Android/Java/CustomTabsController.java @@ -0,0 +1,153 @@ +package com.immutable.unreal; + +import android.app.Activity; +import android.content.ActivityNotFoundException; +import android.content.ComponentName; +import android.content.Context; +import android.content.Intent; +import android.content.pm.PackageManager; +import android.content.pm.ResolveInfo; +import android.graphics.Insets; +import android.net.Uri; +import android.os.Build; +import android.util.DisplayMetrics; +import android.view.WindowInsets; +import android.view.WindowMetrics; + +import androidx.annotation.NonNull; +import androidx.browser.customtabs.CustomTabsCallback; +import androidx.browser.customtabs.CustomTabsClient; +import androidx.browser.customtabs.CustomTabsIntent; +import androidx.browser.customtabs.CustomTabsService; +import androidx.browser.customtabs.CustomTabsServiceConnection; +import androidx.browser.customtabs.CustomTabsSession; + +import java.lang.ref.WeakReference; +import java.util.ArrayList; +import java.util.List; +import java.util.concurrent.CountDownLatch; +import java.util.concurrent.TimeUnit; +import java.util.concurrent.atomic.AtomicReference; + +public class CustomTabsController extends CustomTabsServiceConnection { + private static final long MAX_WAIT_TIME_SECONDS = 1; + + private final WeakReference context; + private final AtomicReference session; + private final CountDownLatch sessionLatch; + private final String preferredPackage; + private final CustomTabsCallback callback; + + private boolean didTryToBindService; + + public CustomTabsController(@NonNull Activity context, CustomTabsCallback callback) { + this.context = new WeakReference<>(context); + this.session = new AtomicReference<>(); + this.sessionLatch = new CountDownLatch(1); + this.callback = callback; + this.preferredPackage = getPreferredCustomTabsPackage(context); + } + + // Get all apps that can support Custom Tabs Service + // i.e. services that can handle ACTION_CUSTOM_TABS_CONNECTION intents + private String getPreferredCustomTabsPackage(@NonNull Activity context) { + PackageManager packageManager = context.getPackageManager(); + Intent serviceIntent = new Intent(); + serviceIntent.setAction(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); + List resolvedList = packageManager.queryIntentServices(serviceIntent, 0); + List packageNames = new ArrayList<>(); + for (ResolveInfo info : resolvedList) { + if (info.serviceInfo != null) { + packageNames.add(info.serviceInfo.packageName); + } + } + if (packageNames.size() > 0) { + // Get the preferred Custom Tabs package + return CustomTabsClient.getPackageName(context, packageNames); + } else { + return null; + } + } + + @Override + public void onCustomTabsServiceConnected(@NonNull ComponentName componentName, @NonNull CustomTabsClient client) { + client.warmup(0L); + CustomTabsSession customTabsSession = client.newSession(callback); + session.set(customTabsSession); + sessionLatch.countDown(); + } + + @Override + public void onServiceDisconnected(ComponentName componentName) { + session.set(null); + } + + public void bindService() { + Context context = this.context.get(); + didTryToBindService = false; + if (context != null && preferredPackage != null) { + didTryToBindService = true; + CustomTabsClient.bindCustomTabsService(context, preferredPackage, this); + } + } + + public void unbindService() { + Context context = this.context.get(); + if (didTryToBindService && context != null) { + context.unbindService(this); + didTryToBindService = false; + } + } + + public void launch(@NonNull final Uri uri) { + final Activity context = this.context.get(); + if (context == null) { + // Custom tabs context is no longer valid + return; + } + + if (preferredPackage == null) { + // Could not get the preferred Custom Tab browser, so launch URL in any browser + context.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } else { + // Running in a different thread to prevent doing too much work on main thread + new Thread(() -> { + try { + launchCustomTabs(context, uri); + } catch (ActivityNotFoundException ex) { + // Failed to launch Custom Tab browser, so launch in browser + context.startActivity(new Intent(Intent.ACTION_VIEW, uri)); + } + }).start(); + } + } + + private void launchCustomTabs(Activity context, Uri uri) { + bindService(); + try { + boolean ignored = sessionLatch.await(MAX_WAIT_TIME_SECONDS, TimeUnit.SECONDS); + } catch (InterruptedException ignored) { + } + + final CustomTabsIntent.Builder builder = new CustomTabsIntent.Builder(session.get()) + .setInitialActivityHeightPx(getCustomTabsHeight(context)) + .setShareState(CustomTabsIntent.SHARE_STATE_OFF); + final Intent intent = builder.build().intent; + intent.setData(uri); + context.startActivity(intent); + } + + private int getCustomTabsHeight(Activity context) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + WindowMetrics windowMetrics = context.getWindowManager().getCurrentWindowMetrics(); + Insets insets = windowMetrics.getWindowInsets() + .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); + return windowMetrics.getBounds().height() - insets.top - insets.bottom; + } else { + DisplayMetrics displayMetrics = new DisplayMetrics(); + context.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); + return displayMetrics.heightPixels; + } + } + +} diff --git a/Source/Immutable/Private/Immutable/Android/Java/ImmutableActivity.java b/Source/Immutable/Private/Immutable/Android/Java/ImmutableActivity.java new file mode 100644 index 00000000..c81d0c4d --- /dev/null +++ b/Source/Immutable/Private/Immutable/Android/Java/ImmutableActivity.java @@ -0,0 +1,127 @@ +package com.immutable.unreal; + +import android.app.Activity; +import android.content.Context; +import android.content.Intent; +import android.net.Uri; +import android.os.Build; +import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.util.Log; + +import androidx.annotation.NonNull; +import androidx.annotation.Nullable; +import androidx.browser.customtabs.CustomTabsCallback; + +import com.epicgames.unreal.GameActivity; + +public class ImmutableActivity extends Activity { + private static final String EXTRA_URI = "extra_uri"; + private static final String EXTRA_INTENT_LAUNCHED = "extra_intent_launched"; + + private boolean customTabsLaunched = false; + private CustomTabsController customTabsController; + + public static void startActivity(Activity context, String url) { + Intent intent = new Intent(context, ImmutableActivity.class); + intent.putExtra(EXTRA_URI, Uri.parse(url)); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP); + context.startActivity(intent); + } + + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + if (savedInstanceState != null) { + customTabsLaunched = savedInstanceState.getBoolean(EXTRA_INTENT_LAUNCHED, false); + } + } + + @Override + protected void onNewIntent(Intent intent) { + super.onNewIntent(intent); + setIntent(intent); + } + + @Override + protected void onActivityResult(int requestCode, int resultCode, Intent data) { + Intent resultData = resultCode == RESULT_CANCELED ? new Intent() : data; + onDeeplinkResult(resultData); + finish(); + } + + @Override + protected void onSaveInstanceState(@NonNull Bundle outState) { + super.onSaveInstanceState(outState); + outState.putBoolean(EXTRA_INTENT_LAUNCHED, customTabsLaunched); + } + + @Override + protected void onResume() { + super.onResume(); + Intent authenticationIntent = getIntent(); + if (!customTabsLaunched && authenticationIntent.getExtras() == null) { + // This activity was launched in an unexpected way + finish(); + return; + } else if (!customTabsLaunched) { + // Haven't launched custom tabs + customTabsLaunched = true; + launchCustomTabs(); + return; + } + onDeeplinkResult(authenticationIntent); + finish(); + } + + @Override + protected void onDestroy() { + super.onDestroy(); + if (customTabsController != null) { + customTabsController.unbindService(); + customTabsController = null; + } + } + + public native void handleDeepLink(String Deeplink); + + public native void handleOnCustomTabsDismissed(String Url); + + private void launchCustomTabs() { + Bundle extras = getIntent().getExtras(); + final Uri uri = extras.getParcelable(EXTRA_URI); + customTabsController = new CustomTabsController(this, new CustomTabsCallback() { + @Override + public void onNavigationEvent(int navigationEvent, @Nullable Bundle extras) { + if (navigationEvent == CustomTabsCallback.TAB_HIDDEN/* && callbackInstance != null */) { + // Adding some delay before calling onCustomTabsDismissed as sometimes this gets called + // before the PKCE deeplink is triggered (by 100ms). This means PKCEResponseDelegate will be + // set to null before the SDK can use it to notify the consumer of the PKCE result. + // See UImmutablePassport::HandleOnPKCEDismissed and UImmutablePassport::OnDeepLinkActivated + final Handler handler = new Handler(Looper.getMainLooper()); + handler.postDelayed(new Runnable() { + @Override + public void run() { + GameActivity.onCustomTabsDismissed(uri.toString()); + } + }, 1000); + } + } + }); + customTabsController.bindService(); + customTabsController.launch(uri); + } + + private void onDeeplinkResult(@Nullable Intent intent) { + if (intent != null && intent.getData() != null) { + GameActivity.onDeeplinkResult(intent.getData().toString()); + } + } + + public interface Callback { + void onCustomTabsDismissed(String url); + void onDeeplinkResult(String url); + } +} + diff --git a/Source/Immutable/Private/Immutable/Android/Java/ImmutableAndroid.java b/Source/Immutable/Private/Immutable/Android/Java/ImmutableAndroid.java deleted file mode 100644 index 378648e6..00000000 --- a/Source/Immutable/Private/Immutable/Android/Java/ImmutableAndroid.java +++ /dev/null @@ -1,112 +0,0 @@ -package com.immutable.unreal; - -import android.app.Activity; -import android.content.ComponentName; -import android.content.Intent; -import android.content.pm.PackageManager; -import android.content.pm.ResolveInfo; -import android.graphics.Insets; -import android.net.Uri; -import android.os.Build; -import android.os.Bundle; -import android.os.Handler; -import android.os.Looper; -import android.util.DisplayMetrics; -import android.view.WindowInsets; -import android.view.WindowMetrics; - -import androidx.annotation.NonNull; -import androidx.annotation.Nullable; -import androidx.browser.customtabs.CustomTabsCallback; -import androidx.browser.customtabs.CustomTabsClient; -import androidx.browser.customtabs.CustomTabsIntent; -import androidx.browser.customtabs.CustomTabsService; -import androidx.browser.customtabs.CustomTabsServiceConnection; -import androidx.browser.customtabs.CustomTabsSession; - -import java.util.ArrayList; -import java.util.List; - -public class ImmutableAndroid { - private static CustomTabsServiceConnection customTabsServiceConnection; - - private static int getCustomTabsHeight(Activity context) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - WindowMetrics windowMetrics = context.getWindowManager().getCurrentWindowMetrics(); - Insets insets = windowMetrics.getWindowInsets() - .getInsetsIgnoringVisibility(WindowInsets.Type.systemBars()); - return windowMetrics.getBounds().height() - insets.top - insets.bottom; - } else { - DisplayMetrics displayMetrics = new DisplayMetrics(); - context.getWindowManager().getDefaultDisplay().getMetrics(displayMetrics); - return displayMetrics.heightPixels; - } - } - - public static void launchUrl(final Activity context, final String url) { - // Get all apps that can support Custom Tabs Service - // i.e. services that can handle ACTION_CUSTOM_TABS_CONNECTION intents - PackageManager packageManager = context.getPackageManager(); - Intent serviceIntent = new Intent(); - serviceIntent.setAction(CustomTabsService.ACTION_CUSTOM_TABS_CONNECTION); - List resolvedList = packageManager.queryIntentServices(serviceIntent, 0); - List packageNames = new ArrayList<>(); - for (ResolveInfo info : resolvedList) { - if (info.serviceInfo != null) { - packageNames.add(info.serviceInfo.packageName); - } - } - - if (packageNames.size() > 0) { - // Get the preferred Custom Tabs package - String customTabsPackageName = CustomTabsClient.getPackageName(context, packageNames); - if (customTabsPackageName == null) { - // Could not get the preferred Custom Tab browser, so launch URL in any browser - context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); - } else { - customTabsServiceConnection = new CustomTabsServiceConnection() { - @Override - public void onServiceDisconnected(ComponentName name) { - customTabsServiceConnection = null; - } - - @Override - public void onCustomTabsServiceConnected(@NonNull ComponentName name, @NonNull CustomTabsClient client) { - CustomTabsSession session = client.newSession(new CustomTabsCallback() { - @Override - public void onNavigationEvent(int navigationEvent, @Nullable Bundle extras) { - if (context instanceof Callback && navigationEvent == CustomTabsCallback.TAB_HIDDEN) { - // Adding some delay before calling onCustomTabsDismissed as sometimes this gets called - // before the PKCE deeplink is triggered (by 100ms). This means PKCEResponseDelegate will be - // set to null before the SDK can use it to notify the consumer of the PKCE result. - // See UImmutablePassport::HandleOnPKCEDismissed and UImmutablePassport::OnDeepLinkActivated - final Handler handler = new Handler(Looper.getMainLooper()); - handler.postDelayed(new Runnable() { - @Override - public void run() { - ((Callback) context).onCustomTabsDismissed(url); - } - }, 1000); - } - } - }); - // Need to set the session to get custom tabs to show as a bottom sheet - CustomTabsIntent customTabsIntent = new CustomTabsIntent.Builder(session) - .setInitialActivityHeightPx(getCustomTabsHeight(context)) - .setUrlBarHidingEnabled(true) - .build(); - customTabsIntent.launchUrl(context, Uri.parse(url)); - } - }; - CustomTabsClient.bindCustomTabsService(context, customTabsPackageName, customTabsServiceConnection); - } - } else { - // Custom Tabs not supported by any browser on the device so launch URL in any browser - context.startActivity(new Intent(Intent.ACTION_VIEW, Uri.parse(url))); - } - } - - public interface Callback { - void onCustomTabsDismissed(String url); - } -} \ No newline at end of file diff --git a/Source/Immutable/Private/Immutable/Android/Java/RedirectActivity.java b/Source/Immutable/Private/Immutable/Android/Java/RedirectActivity.java new file mode 100644 index 00000000..4e1c6ac3 --- /dev/null +++ b/Source/Immutable/Private/Immutable/Android/Java/RedirectActivity.java @@ -0,0 +1,23 @@ +package com.immutable.unreal; + +import android.app.Activity; +import android.content.Intent; +import android.os.Bundle; + +import androidx.annotation.Nullable; + +// Activity which handles deeplinks +public class RedirectActivity extends Activity { + @Override + protected void onCreate(@Nullable Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + Intent intent = new Intent(this, ImmutableActivity.class); + intent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | Intent.FLAG_ACTIVITY_SINGLE_TOP); + if (getIntent() != null) { + intent.setData(getIntent().getData()); + } + startActivity(intent); + finish(); + } +} + diff --git a/Source/Immutable/Private/Immutable/ImmutablePassport.cpp b/Source/Immutable/Private/Immutable/ImmutablePassport.cpp index 5959be6c..954a3ca0 100644 --- a/Source/Immutable/Private/Immutable/ImmutablePassport.cpp +++ b/Source/Immutable/Private/Immutable/ImmutablePassport.cpp @@ -981,7 +981,7 @@ void UImmutablePassport::HandleOnLoginPKCEDismissed() IMTBL_LOG("Handle On Login PKCE Dismissed"); OnPKCEDismissed = nullptr; - if (!completingPKCE) + if (!completingPKCE && !bIsLoggedIn) { // User hasn't entered all required details (e.g. email address) into // Passport yet @@ -1023,8 +1023,8 @@ void UImmutablePassport::LaunchAndroidUrl(FString Url) if (JNIEnv *Env = FAndroidApplication::GetJavaEnv()) { jstring jurl = Env->NewStringUTF(TCHAR_TO_UTF8(*Url)); - jclass jimmutableAndroidClass = FAndroidApplication::FindJavaClass("com/immutable/unreal/ImmutableAndroid"); - static jmethodID jlaunchUrl = FJavaWrapper::FindStaticMethod(Env, jimmutableAndroidClass, "launchUrl", "(Landroid/app/Activity;Ljava/lang/String;)V", false); + jclass jimmutableAndroidClass = FAndroidApplication::FindJavaClass("com/immutable/unreal/ImmutableActivity"); + static jmethodID jlaunchUrl = FJavaWrapper::FindStaticMethod(Env, jimmutableAndroidClass, "startActivity", "(Landroid/app/Activity;Ljava/lang/String;)V", false); CallJniStaticVoidMethod(Env, jimmutableAndroidClass, jlaunchUrl, FJavaWrapper::GameActivityThis, jurl); }