Skip to content

Stash Pay Checkout

Ondrej Rehacek edited this page Jan 14, 2026 · 3 revisions

Stash Pay

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.


Table of Contents

  1. Architecture Overview
  2. Unity C# Wrapper
  3. iOS Native Implementation
  4. Android Native Implementation
  5. JavaScript Bridge
  6. Card vs Popup Presentation
  7. Gesture and Drag Handling
  8. Purchase State Lock
  9. Device and Orientation Handling
  10. Theming

Architecture Overview

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.

The Three Layers

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").

How Communication Works

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 AndroidJavaClass and AndroidJavaObject.

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.

Unity C# Wrapper

The Unity layer provides a clean, cross-platform API that hides the complexity of native plugins. The key design decisions are:

Singleton Pattern

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.

Event-Based API

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 channel

Game code subscribes before opening checkout:

StashPayCard.Instance.OnPaymentSuccess += () => {
    // Grant items to player, but verify on backend first!
    VerifyPurchaseOnServer();
};

StashPayCard.Instance.OpenCheckout(checkoutUrl);

Platform-Specific Compilation

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.

iOS P/Invoke Declarations

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 JNI Wrapper

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");

Receiving Android Callbacks

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();
}

iOS Native Implementation

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.

File Structure

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.

Window Management Strategy

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:

  1. Save a reference to the current key window
  2. Create a new window with windowLevel = UIWindowLevelAlert
  3. Add a semi-transparent overlay for dimming
  4. Add the card/popup view with the WebView inside
  5. Make the new window key and visible
  6. 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];

Extern C Functions

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.

WKWebView Configuration

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];

Receiving JavaScript Messages

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];
            }];
        });
    }
}

Card Animation

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];
}

Cleanup

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;
}

Android Native Implementation

The Android implementation faces different challenges than iOS. Android's Activity lifecycle and the WebView component have their own quirks that must be handled.

File Structure

Place Java files in Assets/Plugins/Android/. The package name you use in Java must match what Unity calls via JNI.

Plugin Architecture

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;
    }
}

Two Presentation Approaches

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);
        }
    });
}

Launching the Card 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;
}

WebView Setup

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);
}

JavaScript Interface

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");

Unity Bridge

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);
    }
}

JavaScript Bridge

The JavaScript bridge connects the web checkout page to the native app. The native layer injects an SDK that the page can call.

SDK Injection

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

Checkout Page Integration

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 });
    }
}

Card vs Popup Presentation

The system supports two presentation modes optimized for different use cases.

Card Presentation (Checkout)

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);

Popup Presentation (Opt-in)

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);

Size Calculation

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));

Gesture and Drag Handling

The card supports drag gestures for a natural, interactive feel.

Drag Handle

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
}

@end

Drag-to-Dismiss

Dragging 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;
        }
    }
}

Drag-to-Expand

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();
    }

Purchase State Lock

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.

Detecting Processing State

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();
};

Locking Dismissal

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);
}

Unlocking

Processing state is cleared when payment succeeds or fails:

@JavascriptInterface
public void onPaymentSuccess() {
    isPurchaseProcessing = false;  // Clear flag
    // ... send callback and dismiss
}

Device and Orientation Handling

The checkout experience varies based on device type and orientation.

Device Detection

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;
}

Phone Behavior

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]);
}

Tablet Behavior

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;
            }];
        }
    }
}

Theming

The checkout adapts to the system's light/dark mode setting.

Detecting Theme

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;
}

Applying Theme to UI

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);

Passing Theme to Web Content

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}`);

Build Configuration

iOS Framework Linking

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);
    }
}

Android Manifest

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.

Clone this wiki locally