-
Notifications
You must be signed in to change notification settings - Fork 0
Stash Pay Checkout
This document provides a detailed guide on how to build an in-game native checkout popup that displays web-based payment pages within a mobile game, similar to how Apple Pay or Google Pay dialogs appear. This approach allows you to present web checkout flows without leaving the game context.
- Architecture Overview
- Unity C# Wrapper
- iOS Native Implementation
- Android Native Implementation
- JavaScript Bridge
- Card vs Popup Presentation
- Gesture and Drag Handling
- Purchase State Lock
- Device and Orientation Handling
- Theming
The checkout popup system is built on a three-layer architecture that enables communication between your Unity game, native platform code, and web-based checkout pages.
Layer 1: Unity (C#) - This is where your game logic lives. A singleton MonoBehaviour manages the checkout lifecycle, exposes a simple API for opening/closing the checkout, and dispatches events when payments succeed or fail.
Layer 2: Native (iOS/Android) - Platform-specific code that creates the actual UI. On iOS, this uses WKWebView inside a custom UIWindow. On Android, this uses a WebView inside either a Dialog or a dedicated Activity. The native layer handles all the visual presentation, animations, and gesture recognition.
Layer 3: Web Content (JavaScript) - The checkout page itself, typically hosted by a payment provider. The native layer injects a JavaScript SDK into this page, allowing the web content to communicate back to the game (e.g., "payment succeeded").
The challenge is enabling bidirectional communication across these layers. Each direction uses a different mechanism:
Unity calling Native code:
- On iOS, Unity uses P/Invoke (Platform Invoke) - a mechanism that lets C# call C functions. Your native Objective-C++ code exposes extern "C" functions that Unity can call directly.
- On Android, Unity uses JNI (Java Native Interface) through wrapper classes. You call Java methods using
AndroidJavaClassandAndroidJavaObject.
Native code calling Unity:
- Both platforms use
UnitySendMessage, a Unity-provided function that invokes a method on a MonoBehaviour by name. You specify the GameObject name, method name, and a string parameter.
JavaScript calling Native code:
- On iOS, WKWebView provides "message handlers" - you register handlers by name, and JavaScript calls them via
window.webkit.messageHandlers.handlerName.postMessage(data). - On Android, you add a "JavaScript interface" - an object whose methods are callable from JavaScript via a global variable you define (e.g.,
StashAndroid.methodName()).
Native code calling JavaScript:
- Both platforms can inject JavaScript into the WebView using
evaluateJavaScript. This is used to inject the SDK on page load.
The Unity layer provides a clean, cross-platform API that hides the complexity of native plugins. The key design decisions are:
The wrapper uses a singleton pattern because only one checkout can be active at a time, and the instance needs to persist across scene loads to receive callbacks from native code.
public class StashPayCard : MonoBehaviour
{
private static StashPayCard _instance;
public static StashPayCard Instance
{
get
{
if (_instance == null)
{
// Create a new GameObject that won't be destroyed
GameObject go = new GameObject("StashPayCard");
_instance = go.AddComponent<StashPayCard>();
DontDestroyOnLoad(go);
}
return _instance;
}
}
}The GameObject name ("StashPayCard") is critical - native code uses this exact string when calling UnitySendMessage. If you change this name, native callbacks will fail silently.
Rather than using callbacks with every method call, the wrapper exposes events that game code subscribes to. This is cleaner because multiple systems might care about payment results.
public event Action OnSafariViewDismissed; // User closed the checkout
public event Action OnPaymentSuccess; // Payment completed successfully
public event Action OnPaymentFailure; // Payment failed
public event Action<string> OnOptinResponse; // User selected payment channelGame code subscribes before opening checkout:
StashPayCard.Instance.OnPaymentSuccess += () => {
// Grant items to player, but verify on backend first!
VerifyPurchaseOnServer();
};
StashPayCard.Instance.OpenCheckout(checkoutUrl);The wrapper uses preprocessor directives to compile different code per platform. This keeps the public API identical while the internal implementation varies.
public void OpenCheckout(string url)
{
#if UNITY_IOS && !UNITY_EDITOR
// iOS implementation
_StashPayCardOpenCheckoutInSafariVC(url);
#elif UNITY_ANDROID && !UNITY_EDITOR
// Android implementation
androidPluginInstance.Call("openCheckout", url);
#elif UNITY_EDITOR
// Editor simulation for testing
Debug.Log("Would open checkout: " + url);
#endif
}The !UNITY_EDITOR check is important - P/Invoke and JNI don't work in the editor, so you need fallback behavior for development.
To call native iOS functions from C#, you declare them using DllImport. The "__Internal" library name tells Unity to look in the statically linked native code.
[DllImport("__Internal")]
private static extern void _StashPayCardOpenCheckoutInSafariVC(string url);
[DllImport("__Internal")]
private static extern void _StashPayCardSetPaymentSuccessCallback(PaymentSuccessCallback callback);For callbacks from native to C#, you define delegate types and use the MonoPInvokeCallback attribute. This attribute is required because callbacks cross the managed/unmanaged boundary.
private delegate void PaymentSuccessCallback();
[MonoPInvokeCallback(typeof(PaymentSuccessCallback))]
private static void OnIOSPaymentSuccess()
{
// Must be static! Instance access goes through singleton
Instance?.OnPaymentSuccess?.Invoke();
}The callback must be static because the native code stores a raw function pointer - it can't handle instance methods.
Android uses a different approach. You create wrapper objects for Java classes and call methods by name.
private AndroidJavaClass androidPlugin;
private AndroidJavaObject androidPluginInstance;
private void InitializeAndroidPlugin()
{
if (androidPlugin == null)
{
// The string must exactly match the Java package and class name
androidPlugin = new AndroidJavaClass("com.stash.popup.StashPayCardPlugin");
androidPluginInstance = androidPlugin.CallStatic<AndroidJavaObject>("getInstance");
}
}Method calls use reflection-like syntax:
// Calling void openCheckout(String url)
androidPluginInstance.Call("openCheckout", url);
// Calling boolean isCurrentlyPresented()
bool isOpen = androidPluginInstance.Call<bool>("isCurrentlyPresented");Android can't use function pointer callbacks like iOS. Instead, it calls methods on your MonoBehaviour using UnitySendMessage. You implement public methods that match the names the native code expects:
// Called by: UnityPlayer.UnitySendMessage("StashPayCard", "OnAndroidPaymentSuccess", "")
public void OnAndroidPaymentSuccess(string message)
{
OnPaymentSuccess?.Invoke();
}
public void OnAndroidPaymentFailure(string message)
{
OnPaymentFailure?.Invoke();
}
public void OnAndroidDialogDismissed(string message)
{
OnSafariViewDismissed?.Invoke();
}The iOS implementation creates a native checkout experience using WKWebView. The key challenge is presenting the checkout UI above Unity's rendering without disrupting it.
The iOS plugin is a single Objective-C++ file (.mm extension) placed in Assets/Plugins/iOS/. Unity automatically includes it in the Xcode project when building.
Unity owns the main UIWindow. To present the checkout, we create a secondary UIWindow at a higher window level. This overlays the game without modifying Unity's view hierarchy.
The approach is:
- Save a reference to the current key window
- Create a new window with
windowLevel = UIWindowLevelAlert - Add a semi-transparent overlay for dimming
- Add the card/popup view with the WebView inside
- Make the new window key and visible
- On dismiss, hide our window and restore the previous key window
// Save current state
self.previousKeyWindow = [UIApplication sharedApplication].keyWindow;
// Create new window above Unity
CGRect screenBounds = [UIScreen mainScreen].bounds;
self.portraitWindow = [[UIWindow alloc] initWithFrame:screenBounds];
self.portraitWindow.windowLevel = UIWindowLevelAlert;
self.portraitWindow.backgroundColor = [UIColor clearColor];The interface between Unity and native code uses plain C functions. These are exposed in an extern "C" block to prevent C++ name mangling.
extern "C" {
void _StashPayCardOpenCheckoutInSafariVC(const char* url) {
NSString* urlString = [NSString stringWithUTF8String:url];
// Must dispatch to main thread - UI work requires it
dispatch_async(dispatch_get_main_queue(), ^{
[[StashPayCardSafariDelegate sharedInstance] openCardWithURL:urlString];
});
}
void _StashPayCardSetPaymentSuccessCallback(PaymentSuccessCallback callback) {
// Store the function pointer for later use
_paymentSuccessCallback = callback;
}
bool _StashPayCardIsCurrentlyPresented() {
return _isCardCurrentlyPresented;
}
} // extern "C"Each function the Unity wrapper declares with DllImport must have a matching implementation here. The function names must match exactly.
The WebView needs special configuration to support the JavaScript bridge and payment flows.
WKWebViewConfiguration *config = [[WKWebViewConfiguration alloc] init];
WKUserContentController *contentController = [[WKUserContentController alloc] init];
// Register message handlers for JavaScript->Native communication
// Each name becomes callable via window.webkit.messageHandlers.NAME.postMessage()
[contentController addScriptMessageHandler:self name:@"stashPaymentSuccess"];
[contentController addScriptMessageHandler:self name:@"stashPaymentFailure"];
[contentController addScriptMessageHandler:self name:@"stashPurchaseProcessing"];
[contentController addScriptMessageHandler:self name:@"stashOptin"];
// Inject JavaScript SDK at document start (before page scripts run)
NSString *sdkScript = @"window.stash_sdk = { onPaymentSuccess: function() { ... } };";
WKUserScript *script = [[WKUserScript alloc]
initWithSource:sdkScript
injectionTime:WKUserScriptInjectionTimeAtDocumentStart
forMainFrameOnly:YES];
[contentController addUserScript:script];
config.userContentController = contentController;
// Create WebView with configuration
WKWebView *webView = [[WKWebView alloc] initWithFrame:frame configuration:config];When JavaScript calls window.webkit.messageHandlers.stashPaymentSuccess.postMessage('data'), the native delegate receives it:
- (void)userContentController:(WKUserContentController *)userContentController
didReceiveScriptMessage:(WKScriptMessage *)message {
NSString *name = message.name; // "stashPaymentSuccess"
id body = message.body; // The data passed to postMessage()
if ([name isEqualToString:@"stashPaymentSuccess"]) {
// Prevent duplicate callbacks
if (_paymentSuccessHandled) return;
_paymentSuccessHandled = YES;
// Call Unity on main thread
dispatch_async(dispatch_get_main_queue(), ^{
if (_paymentSuccessCallback != NULL) {
_paymentSuccessCallback();
}
// Dismiss the checkout UI
[self dismissWithAnimation:^{
[self cleanupCardInstance];
}];
});
}
}The checkout card slides up from the bottom of the screen. Spring animations create a natural, physics-based feel.
- (void)animateCardIn:(UIViewController *)containerVC overlayView:(UIView *)overlayView {
// Start position: below the screen
CGRect startFrame = containerVC.view.frame;
startFrame.origin.y = [UIScreen mainScreen].bounds.size.height;
containerVC.view.frame = startFrame;
// Animate to final position with spring damping
[UIView animateWithDuration:0.4
delay:0
usingSpringWithDamping:0.85 // Controls bounciness
initialSpringVelocity:0
options:UIViewAnimationOptionCurveEaseOut
animations:^{
// Move to configured position
containerVC.view.frame = self.originalFrame;
// Fade in overlay
overlayView.backgroundColor = [UIColor colorWithWhite:0.0 alpha:0.3];
} completion:nil];
}When the checkout is dismissed, all resources must be properly released to prevent memory leaks and ensure future checkouts work correctly.
- (void)cleanupCardInstance {
// Remove JavaScript message handlers first
WKWebView *webView = [self findWebView];
if (webView) {
[webView stopLoading];
WKUserContentController *controller = webView.configuration.userContentController;
[controller removeScriptMessageHandlerForName:@"stashPaymentSuccess"];
[controller removeScriptMessageHandlerForName:@"stashPaymentFailure"];
// ... remove all handlers
[controller removeAllUserScripts];
// Load blank page to clear any pending operations
[webView loadHTMLString:@"" baseURL:nil];
[webView removeFromSuperview];
}
// Hide and release our window
if (self.portraitWindow) {
self.portraitWindow.hidden = YES;
self.portraitWindow.rootViewController = nil;
// Restore previous key window
if (self.previousKeyWindow) {
[self.previousKeyWindow makeKeyAndVisible];
self.previousKeyWindow = nil;
}
self.portraitWindow = nil;
}
// Reset all state flags
_isCardCurrentlyPresented = NO;
_paymentSuccessHandled = NO;
_callbackWasCalled = NO;
}The Android implementation faces different challenges than iOS. Android's Activity lifecycle and the WebView component have their own quirks that must be handled.
Place Java files in Assets/Plugins/Android/. The package name you use in Java must match what Unity calls via JNI.
The main plugin class is a singleton that manages the checkout state. It's separate from the Activity that displays the UI.
public class StashPayCardPlugin {
private static StashPayCardPlugin instance;
// UI components
private Dialog currentDialog;
private WebView webView;
// State
private boolean isCurrentlyPresented;
private boolean paymentSuccessHandled;
private boolean isPurchaseProcessing;
public static StashPayCardPlugin getInstance() {
if (instance == null) {
instance = new StashPayCardPlugin();
}
return instance;
}
}Android uses different presentation strategies for cards vs popups:
Activity-based (for card/checkout): A separate transparent Activity overlays the Unity player. This gives better control over orientation locking and back button handling.
Dialog-based (for popup/opt-in): A fullscreen Dialog is simpler for modal interactions that don't need orientation control.
private void openURLInternal(String url) {
Activity activity = UnityPlayer.currentActivity;
// Must run on UI thread for any view operations
activity.runOnUiThread(() -> {
if (usePopupPresentation) {
createAndShowPopupDialog(url, activity);
} else {
launchCheckoutActivity(url, activity);
}
});
}For the checkout card, we launch a separate Activity that can lock its orientation to portrait.
private void launchCheckoutActivity(String url, Activity activity) {
// Check current orientation to handle landscape->portrait transition
int rotation = activity.getWindowManager().getDefaultDisplay().getRotation();
boolean isLandscape = (rotation == Surface.ROTATION_90 || rotation == Surface.ROTATION_270);
Intent intent = new Intent(activity, StashPayCardPortraitActivity.class);
intent.putExtra("url", url);
intent.putExtra("cardHeightRatio", cardHeightRatio);
intent.putExtra("wasLandscape", isLandscape);
// Suppress default animation - we'll animate ourselves
intent.addFlags(Intent.FLAG_ACTIVITY_NO_ANIMATION);
activity.startActivity(intent);
activity.overridePendingTransition(0, 0);
isCurrentlyPresented = true;
}The WebView needs careful configuration to support payment flows. Third-party cookies, JavaScript, and DOM storage are all required.
private void setupWebView(WebView webView, String url) {
WebSettings settings = webView.getSettings();
// Security: disable local file access
settings.setAllowFileAccess(false);
settings.setAllowContentAccess(false);
// Enable features required for payments
settings.setJavaScriptEnabled(true);
settings.setDomStorageEnabled(true);
// Disable zoom controls
settings.setBuiltInZoomControls(false);
settings.setSupportZoom(false);
// Enable third-party cookies for payment providers
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.LOLLIPOP) {
CookieManager.getInstance().setAcceptThirdPartyCookies(webView, true);
}
// Add JavaScript interface for native communication
webView.addJavascriptInterface(new StashJavaScriptInterface(), "StashAndroid");
webView.loadUrl(url);
}The JavaScript interface exposes Java methods to JavaScript. The @JavascriptInterface annotation is required for security (prevents arbitrary method exposure).
private class StashJavaScriptInterface {
@JavascriptInterface
public void onPaymentSuccess() {
// Prevent duplicate handling
if (paymentSuccessHandled) return;
paymentSuccessHandled = true;
isPurchaseProcessing = false;
// Must switch to main thread for UI and Unity communication
new Handler(Looper.getMainLooper()).post(() -> {
// Send message to Unity
StashUnityBridge.sendPaymentSuccess();
// Dismiss the UI
dismissCurrentDialog();
});
}
@JavascriptInterface
public void onPurchaseProcessing() {
isPurchaseProcessing = true;
// Disable dismissal while processing
new Handler(Looper.getMainLooper()).post(() -> {
if (currentDialog != null && currentDialog.isShowing()) {
currentDialog.setCanceledOnTouchOutside(false);
currentDialog.setCancelable(false);
}
});
}
@JavascriptInterface
public void setPaymentChannel(String channel) {
// Opt-in response - user selected a payment method
new Handler(Looper.getMainLooper()).post(() -> {
StashUnityBridge.sendOptInResponse(channel != null ? channel : "");
dismissCurrentDialog();
});
}
}JavaScript calls these methods directly:
// This works because we called addJavascriptInterface(obj, "StashAndroid")
StashAndroid.onPaymentSuccess();
StashAndroid.setPaymentChannel("stash_pay");A separate class handles communication to Unity. This centralizes the UnitySendMessage calls and defines the method names.
public class StashUnityBridge {
// The GameObject name must match Unity's singleton
private static final String UNITY_GAME_OBJECT = "StashPayCard";
// Method names must match Unity's public method names
public static final String MSG_ON_PAYMENT_SUCCESS = "OnAndroidPaymentSuccess";
public static final String MSG_ON_PAYMENT_FAILURE = "OnAndroidPaymentFailure";
public static final String MSG_ON_DIALOG_DISMISSED = "OnAndroidDialogDismissed";
public static final String MSG_ON_OPTIN_RESPONSE = "OnAndroidOptinResponse";
public static void sendPaymentSuccess() {
try {
UnityPlayer.UnitySendMessage(UNITY_GAME_OBJECT, MSG_ON_PAYMENT_SUCCESS, "");
} catch (Exception e) {
Log.e("StashUnityBridge", "Failed to send message: " + e.getMessage());
}
}
public static void sendOptInResponse(String channel) {
UnityPlayer.UnitySendMessage(UNITY_GAME_OBJECT, MSG_ON_OPTIN_RESPONSE, channel);
}
}The JavaScript bridge connects the web checkout page to the native app. The native layer injects an SDK that the page can call.
When the page loads, native code injects JavaScript that creates the window.stash_sdk object. This happens before the page's own scripts run.
iOS injection (via WKUserScript):
(function() {
window.stash_sdk = window.stash_sdk || {};
window.stash_sdk.onPaymentSuccess = function(data) {
window.webkit.messageHandlers.stashPaymentSuccess.postMessage(data || '');
};
window.stash_sdk.onPaymentFailure = function(data) {
window.webkit.messageHandlers.stashPaymentFailure.postMessage(data || '');
};
window.stash_sdk.onPurchaseProcessing = function(data) {
window.webkit.messageHandlers.stashPurchaseProcessing.postMessage(data || '');
};
window.stash_sdk.setPaymentChannel = function(channel) {
window.webkit.messageHandlers.stashOptin.postMessage(channel || '');
};
})();Android injection (via evaluateJavascript):
(function() {
window.stash_sdk = window.stash_sdk || {};
window.stash_sdk.onPaymentSuccess = function(data) {
try { StashAndroid.onPaymentSuccess(); } catch(e) {}
};
window.stash_sdk.onPaymentFailure = function(data) {
try { StashAndroid.onPaymentFailure(); } catch(e) {}
};
window.stash_sdk.onPurchaseProcessing = function(data) {
try { StashAndroid.onPurchaseProcessing(); } catch(e) {}
};
window.stash_sdk.setPaymentChannel = function(channel) {
try { StashAndroid.setPaymentChannel(channel || ''); } catch(e) {}
};
})();The try/catch blocks prevent errors if the interface isn't available (e.g., testing in a browser).
The checkout page calls the SDK when appropriate events occur:
// When payment is being processed - lock dismissal
function startProcessing() {
if (window.stash_sdk) {
window.stash_sdk.onPurchaseProcessing();
}
}
// When payment succeeds
function paymentCompleted(orderId) {
if (window.stash_sdk) {
window.stash_sdk.onPaymentSuccess({ orderId: orderId });
}
}
// When payment fails
function paymentFailed(error) {
if (window.stash_sdk) {
window.stash_sdk.onPaymentFailure({ error: error });
}
}The system supports two presentation modes optimized for different use cases.
The card is designed for payment flows. It slides up from the bottom, taking about 60-70% of the screen height. Key characteristics:
- Bottom-aligned on phones - familiar interaction pattern
- Centered on tablets - works better with larger screens
- Includes drag handle - enables drag-to-dismiss gesture
- Expandable - can grow taller for complex forms
- Portrait-locked on phones - payment forms work best in portrait
The card height is configurable:
// Set before opening
StashPayCard.Instance.CardHeightRatio = 0.7f; // 70% of screen
StashPayCard.Instance.OpenCheckout(url);The popup is designed for quick decisions like payment method selection. It's a centered modal dialog. Key characteristics:
- Centered on all devices - focused attention
- Smaller than card - feels like a quick decision
- No drag handle - dismissed by tap outside or selection
- Supports rotation - adapts to orientation changes
- Custom sizing available - can specify size multipliers
var customSize = new PopupSizeConfig
{
portraitWidthMultiplier = 0.9f,
portraitHeightMultiplier = 0.7f,
landscapeWidthMultiplier = 0.6f,
landscapeHeightMultiplier = 0.85f
};
StashPayCard.Instance.OpenPopup(url, customSize: customSize);Popup dimensions are calculated from a base size that adapts to the screen:
// Calculate base size from screen dimensions
int smallerDimension = Math.min(screenWidth, screenHeight);
boolean isTablet = smallerDimension / density >= 600; // 600dp threshold
float percentage = isTablet ? 0.5f : 0.75f;
int baseSize = Math.max(
isTablet ? 400 : 300, // minimum
Math.min(500, smallerDimension * percentage) // maximum 500, scaled
);
// Apply orientation-specific multipliers
int width = (int)(baseSize * (isLandscape ? 1.23f : 1.03f));
int height = (int)(baseSize * (isLandscape ? 1.14f : 1.49f));The card supports drag gestures for a natural, interactive feel.
A small pill-shaped view at the top of the card serves as the drag handle. Only touches on this handle initiate drag gestures - touches on the WebView below pass through normally.
// Custom view that only intercepts touches on the handle
@implementation DragTrayView
- (UIView *)hitTest:(CGPoint)point withEvent:(UIEvent *)event {
UIView *handleView = [self viewWithTag:8889]; // The pill shape
if (handleView) {
CGPoint pointInHandle = [self convertPoint:point toView:handleView];
CGRect expandedBounds = CGRectInset(handleView.bounds, -15, -15); // Extra padding
if (CGRectContainsPoint(expandedBounds, pointInHandle)) {
return [super hitTest:point withEvent:event]; // Handle drag
}
}
return nil; // Pass through to WebView
}
@endDragging down on the handle moves the card and fades it out. If the drag distance exceeds a threshold, the card dismisses. Otherwise, it snaps back.
- (void)handleDragTrayPanGesture:(UIPanGestureRecognizer *)gesture {
// Block gestures during payment processing
if (self.isPurchaseProcessing) return;
UIView *cardView = self.currentPresentedVC.view;
CGPoint translation = [gesture translationInView:self.portraitWindow];
switch (gesture.state) {
case UIGestureRecognizerStateBegan:
self.initialY = cardView.frame.origin.y;
break;
case UIGestureRecognizerStateChanged: {
// Move card with finger
CGFloat newY = self.initialY + translation.y;
CGRect frame = cardView.frame;
frame.origin.y = MAX(newY, -50); // Allow slight overdrag at top
cardView.frame = frame;
// Fade overlay as card moves down
CGFloat progress = MAX(0, MIN(1, (newY - self.initialY) / 200.0));
overlayView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.3 * (1 - progress)];
break;
}
case UIGestureRecognizerStateEnded: {
CGFloat dragDistance = cardView.frame.origin.y - self.initialY;
CGFloat threshold = screenHeight * 0.25; // 25% of screen
if (dragDistance > threshold || velocity.y > 1000) {
// Dismiss
[self dismissWithAnimation:nil];
} else {
// Snap back
[UIView animateWithDuration:0.3 animations:^{
cardView.frame = self.originalFrame;
overlayView.backgroundColor = [UIColor colorWithWhite:0 alpha:0.3];
}];
}
break;
}
}
}Dragging up can expand the card to near-fullscreen (on phones, not tablets).
// In Android touch handler
case MotionEvent.ACTION_UP:
float deltaY = event.getRawY() - initialY;
if (deltaY < 0 && Math.abs(deltaY) > dpToPx(80) && !isExpanded) {
// Dragged up enough - expand the card
animateExpand();
}When payment processing begins, the checkout must not be dismissible. A user accidentally closing the checkout mid-payment would cause confusion about whether they were charged.
The web page calls onPurchaseProcessing() when the user submits payment. This signals the native layer to lock the UI.
// On the checkout page
submitButton.onclick = function() {
// Lock the UI first
if (window.stash_sdk) {
window.stash_sdk.onPurchaseProcessing();
}
// Then submit payment
processPayment();
};When processing starts, all dismissal mechanisms are disabled:
Drag gestures:
- (void)handleDragTrayPanGesture:(UIPanGestureRecognizer *)gesture {
if (self.isPurchaseProcessing) return; // Ignore all drags
// ...
}Tap outside:
- (void)overlayTapped:(UITapGestureRecognizer *)gesture {
if (self.isPurchaseProcessing) return; // Ignore taps
// ...
}Back button (Android):
@Override
public void onBackPressed() {
if (isPurchaseProcessing) {
return; // Block back button
}
dismissWithAnimation();
}Dialog properties (Android):
@JavascriptInterface
public void onPurchaseProcessing() {
isPurchaseProcessing = true;
// Also update dialog properties
currentDialog.setCanceledOnTouchOutside(false);
currentDialog.setCancelable(false);
}Processing state is cleared when payment succeeds or fails:
@JavascriptInterface
public void onPaymentSuccess() {
isPurchaseProcessing = false; // Clear flag
// ... send callback and dismiss
}The checkout experience varies based on device type and orientation.
iOS:
BOOL isRunningOniPad() {
return [UIDevice currentDevice].userInterfaceIdiom == UIUserInterfaceIdiomPad;
}Android:
public static boolean isTablet(Activity activity) {
DisplayMetrics metrics = activity.getResources().getDisplayMetrics();
float smallerDimension = Math.min(metrics.widthPixels, metrics.heightPixels);
float smallerDp = smallerDimension / metrics.density;
// 600dp is the standard tablet threshold
return smallerDp >= 600;
}On phones, the card presentation locks to portrait orientation. This is because:
- Payment forms are designed for portrait
- Rotating mid-payment is confusing
- Portrait provides more vertical space for forms
// Android: Lock to portrait for card (not popup)
if (!usePopup && !isTablet) {
setRequestedOrientation(ActivityInfo.SCREEN_ORIENTATION_PORTRAIT);
}// iOS: Lock to current or portrait
- (UIInterfaceOrientationMask)supportedInterfaceOrientations {
if (isRunningOniPad()) {
return UIInterfaceOrientationMaskAll;
}
if (_usePopupPresentation) {
return UIInterfaceOrientationMaskAll; // Popup can rotate
}
// Card locks to current orientation
return (1 << [[UIApplication sharedApplication] statusBarOrientation]);
}Tablets allow rotation for all presentation modes. The card is centered and maintains consistent proportions regardless of orientation.
When rotation occurs, the layout updates smoothly:
- (void)viewWillLayoutSubviews {
[super viewWillLayoutSubviews];
if (isRunningOniPad()) {
CGRect screenBounds = [UIScreen mainScreen].bounds;
// Recalculate centered position
CGSize cardSize = calculateiPadCardSize(screenBounds);
CGFloat x = (screenBounds.size.width - cardSize.width) / 2;
CGFloat y = (screenBounds.size.height - cardSize.height) / 2;
CGRect newFrame = CGRectMake(x, y, cardSize.width, cardSize.height);
// Animate if significant change
if (frameDifference > 50) {
[UIView animateWithDuration:0.3 animations:^{
self.view.frame = newFrame;
}];
}
}
}The checkout adapts to the system's light/dark mode setting.
iOS:
BOOL isDarkMode = NO;
if (@available(iOS 13.0, *)) {
UIUserInterfaceStyle style = [UITraitCollection currentTraitCollection].userInterfaceStyle;
isDarkMode = (style == UIUserInterfaceStyleDark);
}Android:
public static boolean isDarkTheme(Context context) {
if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) {
int nightModeFlags = context.getResources().getConfiguration().uiMode
& Configuration.UI_MODE_NIGHT_MASK;
return nightModeFlags == Configuration.UI_MODE_NIGHT_YES;
}
return false;
}The card background, loading indicators, and drag handle adapt to the theme:
int backgroundColor = isDarkTheme ?
Color.parseColor("#1C1C1E") : // Dark gray for dark mode
Color.WHITE;
cardContainer.setBackgroundColor(backgroundColor);The checkout URL gets a query parameter so the web page can also adapt:
public static String appendThemeQueryParameter(String url, boolean isDarkTheme) {
Uri uri = Uri.parse(url);
Uri.Builder builder = uri.buildUpon();
builder.appendQueryParameter("theme", isDarkTheme ? "dark" : "light");
return builder.build().toString();
}The web page reads this parameter:
const params = new URLSearchParams(window.location.search);
const theme = params.get('theme') || 'light';
document.body.classList.add(`theme-${theme}`);WKWebView requires the WebKit framework. Create a post-processor script to automatically add it during builds:
// Assets/Editor/AddWebKitFramework.cs
using UnityEditor;
using UnityEditor.Callbacks;
using UnityEditor.iOS.Xcode;
public class AddWebKitFramework
{
[PostProcessBuild(1)]
public static void OnPostProcessBuild(BuildTarget target, string path)
{
if (target != BuildTarget.iOS) return;
string projectPath = PBXProject.GetPBXProjectPath(path);
PBXProject project = new PBXProject();
project.ReadFromFile(projectPath);
string targetGuid = project.GetUnityMainTargetGuid();
project.AddFrameworkToProject(targetGuid, "WebKit.framework", false);
project.WriteToFile(projectPath);
}
}Ensure internet permission and register the checkout Activity:
<manifest xmlns:android="http://schemas.android.com/apk/res/android">
<uses-permission android:name="android.permission.INTERNET" />
<application>
<activity
android:name="com.stash.popup.StashPayCardPortraitActivity"
android:theme="@android:style/Theme.Translucent.NoTitleBar.Fullscreen"
android:configChanges="orientation|screenSize|keyboardHidden"
android:windowSoftInputMode="adjustResize" />
</application>
</manifest>The configChanges attribute prevents Activity recreation on rotation - we handle layout changes ourselves.