Beyond DX Components: Extending Pega Mobile UI with Custom Modules

A practitioner’s guide to bridging native iOS/Android features with the React-based Constellation UI.


In the world of enterprise mobility, we are often forced to choose between two competing forces: the speed, consistency, and upgradability of web-based Single Page Applications (SPAs), and the raw, hardware-level performance of native mobile devices.

In the Pega universe, Constellation has revolutionized how we build and maintain unified user experiences on the web. But what happens when your enterprise application needs to scan high-speed barcodes in a warehouse, read a secure Bluetooth sensor on a factory floor, or interact with an offline hardware peripheral?

This is where the Pega Mobile Client (PMC) Custom Modules framework shines. By establishing a robust, asynchronous bridge between the native operating system and the Constellation engine, developers can build components that are beautiful, cross-platform, and fully capable of driving native hardware.

In this guide, we’ll walk through the end-to-end journey of building a native-bridged component. As our hero reference, we’ll use a Barcode Scanner Input component (Pega_Extensions_BarcodeScannerInput) to show you exactly how to hook up Kotlin native logic to a React-based Cosmos DX component.


The Architectural Bridge: Under the Hood of PMC

Before we dive into the code, let’s understand the β€œsecret sauce” that makes this interaction possible.

Pega Mobile Client doesn’t just run your app in a standard web browser. Instead, it hosts the Constellation SPA inside a highly optimized, native WebView container. This container injects a global JavaScript API, exposing native device interfaces directly inside the web app’s execution context.

β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”
β”‚                  Pega Mobile Client                    β”‚
β”‚                                                        β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚                     WebView                      β”‚  β”‚
β”‚  β”‚                                                  β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚   Constellation  β”‚      β”‚  Custom DX       β”‚  β”‚  β”‚
β”‚  β”‚  β”‚   Core Engine    β”‚      β”‚  Component       β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β”‚                                     β”‚            β”‚  β”‚
β”‚  β”‚                       (window.pms)  β”‚ JS API     β”‚  β”‚
β”‚  β”‚                                     β–Ό            β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”Όβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β”‚                                        β”‚ Bridge        β”‚
β”‚                                        β–Ό               β”‚
β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚
β”‚  β”‚                  Native Container                β”‚  β”‚
β”‚  β”‚                                                  β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”      β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚ WebViewPlugin    β”‚      β”‚ ApplicationPluginβ”‚  β”‚  β”‚
β”‚  β”‚  β”‚ (Custom Module)  β”‚      β”‚ (Lifecycle)      β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”¬β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜      β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β”‚           β”‚                                      β”‚  β”‚
β”‚  β”‚           β–Ό Native Android APIs                  β”‚  β”‚
β”‚  β”‚  β”Œβ”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”  β”‚  β”‚
β”‚  β”‚  β”‚ Hardware Camera / Barcode Engine           β”‚  β”‚  β”‚
β”‚  β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚  β”‚
β”‚  β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜  β”‚
β””β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”€β”˜

The system relies on three tightly coupled actors:

  1. The Native Custom Module: A Kotlin (Android) or Swift (iOS) library that implements device-level features and registers them as WebView plugins.
  2. The JavaScript Bridge (window.pms): A global namespace automatically injected by the PMC container, acting as the translator between JavaScript and native calls.
  3. The Custom DX Component: A standard React component that detects the presence of the bridge, adapts its UI gracefully if the bridge is missing, and invokes native APIs asynchronously.

Let’s look at the implementation step-by-step.


Step 1: Scaffolding the Custom Native Module

To begin, we need a native skeleton project that complies with PMC’s build system. Instead of writing Android library descriptors or Gradle configurations from scratch, we leverage the Pega Mobile Client DevKit.

From your DevKit root directory, run the scaffolding Gradle task to generate a pre-configured Android Library project wired with PMC dependencies:

./gradlew createModule \
  --moduleDirectory=<absolute_path_to_modules_folder> \
  --moduleName=BarcodeScanner

The Post-Generation Namespace Fix

When you scaffold a new module, the DevKit defaults to a generic plugin namespace: "samplePlugin". This would expose your native logic to the browser as window.pms.plugins.samplePlugin.

To make your code self-documenting and maintainable, open BarcodeScannerWebViewPlugin.kt in your IDE and update the companion object’s PLUGIN_NAME to match your component’s domain:

// In BarcodeScannerWebViewPlugin.kt
companion object {
    internal const val PLUGIN_NAME = "barcode"
}

This single line changes the JavaScript exposure to window.pms.plugins.barcode, setting up a clean, semantic contract for our React code.


Step 2: Implementing the Native Bridge (Kotlin)

With our scaffold ready, we need to implement the bridge class. This class inherits from WebViewPlugin and exposes native methods using Pega’s annotations.

Asynchronous Contracts: Promises and Types

Since hardware access is inherently blocking (e.g., waiting for a camera to scan or a user to press cancel), all custom module methods must be asynchronous. In PMC, we achieve this by:

  • Annotating our exposed Kotlin methods with @JavaScriptAPI.
  • Ensuring the last parameter of the method signature is a JavaScriptPromise.
  • Wrapping return values in a JavaScriptValue map, which matches the expected JSON structure in our React frontend.

Here is the complete native implementation for our Android-based barcode scanner:

package com.pega.barcodescanner

import com.pega.mobile.core.annotations.JavaScriptAPI
import com.pega.mobile.core.annotations.JavaScriptPlugin
import com.pega.mobile.core.integration.PMCWebView
import com.pega.mobile.core.integration.WebViewPlugin
import com.pega.mobile.core.types.JavaScriptPromise
import com.pega.mobile.core.types.JavaScriptValue

@Suppress("UNUSED")
@JavaScriptPlugin(PLUGIN_NAME)
class BarcodeScannerWebViewPlugin(webView: PMCWebView) : WebViewPlugin(webView) {

    @JavaScriptAPI
    fun scan(options: JavaScriptValue, promise: JavaScriptPromise) {
        try {
            // 1. Launch native scanner activity/camera (implementation omitted for brevity)
            val scannedFormat = "QR_CODE"
            val scannedValue = "https://www.pega.com"
            
            // 2. Build the result map matching the React expectations
            val result = mapOf(
                "format" to scannedFormat,
                "value" to scannedValue
            )
            
            // 3. Resolve the promise back to the WebView's JavaScript context
            promise.resolve(JavaScriptValue(result))
        } catch (e: Exception) {
            // Reject the promise to trigger catch blocks in React
            promise.reject(e)
        }
    }

    companion object {
        internal const val PLUGIN_NAME = "barcode"
    }
}

Step 3: The React Handshake (The DX Component)

Now let’s switch hats and act as the frontend developer. Our custom DX component needs to do three things:

  1. Detect availability: Fail and degrade gracefully if running in a standard web browser (desktop/mobile safari) where no physical barcode hardware interface is present.
  2. Align styling: Maintain strict aesthetic consistency with other Cosmos form fields (like Calendar or search fields).
  3. Execute the native call: Invoke the async native method, handle loading/error states, and update the Pega case field value.

Robust Bridge Detection

Never assume window.pms is ready or available. We perform a safe runtime check before showing any native UI controls:

const isBarcodeApiAvailable = (): boolean =>
  typeof (window as any)?.pms?.plugins?.barcode?.scan === 'function';

Layout Consistency with Pega Cosmos

For native-aligned button inputs, we avoid styling standard HTML tags from scratch. Instead, we compose Pega Cosmos Core layout controls (FormField, FormControl, and Flex).

Crucially, we use a minHeight styling trick on our row container to guarantee the field maintains a consistent height, whether the native scan button is visible or hidden.

Here is the core code for our React component:

import { useEffect, useState, type ChangeEvent } from 'react';
import {
  withConfiguration,
  Button,
  Icon,
  Flex,
  FormField,
  FormControl,
  registerIcon
} from '@pega/cosmos-react-core';
import * as qrIcon from '@pega/cosmos-react-core/lib/components/Icon/icons/qr.icon';

// Register custom icons needed by the component
registerIcon(qrIcon);

export const PegaExtensionsBarcodeScannerInput = (props: any) => {
  const { label, value, getPConnect, testId, disabled, readOnly } = props;
  
  const pConn = getPConnect();
  const actions = pConn.getActionsApi();
  const propName = pConn.getStateProps().value;

  const [inputValue, setInputValue] = useState(value);
  const [scanning, setScanning] = useState(false);
  const [scanError, setScanError] = useState<string | undefined>(undefined);
  
  // Verify if we are running inside PMC with our native module installed
  const barcodeAvailable = isBarcodeApiAvailable();

  const handleScan = () => {
    setScanError(undefined);
    setScanning(true);

    // Call the native method we exposed in Step 2
    (window as any).pms.plugins.barcode.scan({})
      .then((result: { format: string; value: string }) => {
        const scannedValue = result.value;
        setInputValue(scannedValue);
        
        // Update Pega state and trigger validation
        actions.updateFieldValue(propName, scannedValue);
        actions.triggerFieldChange(propName, scannedValue);
      })
      .catch(() => {
        setScanError(pConn.getLocalizedValue('Barcode scanning failed or was cancelled'));
      })
      .finally(() => {
        setScanning(false);
      });
  };

  return (
    <FormField
      label={label}
      info={scanError || props.helperText}
      status={scanError ? 'error' : undefined}
    >
      <FormControl>
        {/* minHeight ensures height matches native Cosmos fields even when the scan button is hidden */}
        <Flex container={{ direction: 'row', alignItems: 'center' }} style={{ minHeight: '2rem' }}>
          <Flex item={{ grow: 1 }}>
            <input
              style={{
                border: 'none',
                outline: 'none',
                background: 'transparent',
                width: '100%',
                padding: 0,
                font: 'inherit',
                color: 'inherit',
              }}
              type="text"
              value={inputValue ?? ''}
              onChange={(e: ChangeEvent<HTMLInputElement>) => {
                setScanError(undefined);
                setInputValue(e.currentTarget.value);
                actions.updateFieldValue(propName, e.currentTarget.value);
              }}
            />
          </Flex>
          
          {barcodeAvailable && (
            <>
              {/* Vertical Separator line matching classic Cosmos form controls */}
              <div
                aria-hidden="true"
                style={{
                  alignSelf: 'stretch',
                  width: '1px',
                  background: 'currentColor',
                  opacity: 0.2,
                  flexShrink: 0,
                }}
              />
              <Button
                variant="simple"
                label={scanning ? 'Scanning…' : 'Scan barcode or QR code'}
                disabled={disabled || readOnly || scanning}
                onClick={handleScan}
                data-testid={testId ? `${testId}-scan-button` : undefined}
                style={{ paddingInline: '0.375rem' }}
              >
                <Icon name="qr" />
              </Button>
            </>
          )}
        </Flex>
      </FormControl>
    </FormField>
  );
};

Step 4: Rapid Feedback – Mocking in Storybook

One of the biggest bottlenecks in mobile development is the edit-compile-test loop. Rebuilding an entire mobile app bundle just to verify a minor UI tweak in React is incredibly slow.

To bypass this loop, we inject a Mock API directly inside our Storybook workspace. This mimics the camera behavior, presents a web-friendly interactive dialog, and returns realistic mock data back into the text field.

// In demo.stories.tsx

const installFakePmsApi = () => {
  (window as any).pms = {
    plugins: {
      barcode: {
        scan: () =>
          new Promise(resolve => {
            // Emulates camera delays and mimics physical user confirmation
            alert(
              'πŸ“· Camera opening… (simulation)\n\n' +
              'In Pega Mobile Client, this would launch the device camera. ' +
              'Click OK to supply a simulated QR/barcode scan result.'
            );
            resolve({
              format: 'QR_CODE',
              value: 'MOCKED-' + Math.random().toString(36).slice(2, 10).toUpperCase(),
            });
          }),
      },
    },
  };
};

const removePmsApi = () => {
  delete (window as any).pms;
};

Using these setup scripts, you can export two separate stories in Storybook:

  1. Default (Simulated PMC): Calls installFakePmsApi() on component mount. This lets you test the full success path, error handlers, and visual alignment during local desktop web development.
  2. NotInPMC (Standard Web Fallback): Calls removePmsApi() on mount. This ensures your component degrades gracefully into a standard, manual-input text box, ensuring safety on desktop browsers.

Step 5: Packaging & Activating the Module

Once your component looks great in Storybook and your native plugin code compiles cleanly in the DevKit, it is time to deploy it to your Pega environment.

1. Uploading the Module via CLI

Execute the Gradle upload command from the root of your DevKit:

./gradlew uploadModule \
  --moduleName=BarcodeScanner \
  -PmobileChannelUrl="https://<pega_server_host>/prweb"

Note: For Pega Infinity 26.1+, this command handles modern browser-based OAuth2 login and redirect authorization out of the box.

2. App Studio Activation

Now, activate the module so it gets compiled into your next mobile app build:

  1. Log in to Pega App Studio.
  2. Navigate to your Mobile Channel.
  3. Under the Configuration tab, select Custom modules.
  4. Check the box next to BarcodeScanner to activate it.
  5. Save the configuration.

The next time your mobile client is built, the native scanner library will be injected, and the React component will automatically detect the plugin presence, rendering the scan button seamlessly.


Why This Architecture Wins: The Meta-Pattern

This approach solves many of the traditional friction points of mobile hybrid apps:

Development Challenge Pega PMC + Custom DX Component Solution
Slow Mobile Feedback Loops Storybook-level mocking of window.pms yields sub-second hot reloading.
Silent Runtime Crashes Strict isBarcodeApiAvailable() detection ensures safety across platforms.
Fragmented Layouts Cosmos Layout wrapper composition preserves alignment and UX consistency.
Brittle Native SDK Upgrades Modular encapsulation using isolated Gradle-scaffolded libraries ensures easy platform updates.

Conclusion

By leveraging Pega Mobile Client Custom Modules alongside Custom DX Components, you aren’t just building web apps on mobile; you’re building cohesive, native-integrated digital products. This strategy empowers solution builders to implement enterprise-grade hardware support while keeping frontend development fast, modern, and aligned with standard React best practices.

Now it’s your turn. What native hardware integrations will you build next? :rocket:


References & Resources

  • Pega Developer Portal Guides
    • Android Authoring Reference: <DevKit_Root>/docs/android/authoring-custom-modules.md
    • WebView Plugin Docs: <DevKit_Root>/docs/android/writing-webview-plugins.md
  • Custom Module Scaffolding Template
    • Scaffolding Reference: <DevKit_Root>/.opencode/skills/create-android-module/SKILL.md
3 Likes

Thanks for sharing your knowledge! I’ve added to our Constellation 101 so it can be found with other useful knowledge shares.

Enjoyed this article?

See suggested articles from our Constellation 101 series and view all our Knowledge Shares from our User Experience Expert Circle.