Skip to main content

Dual-Screen Payment Integration Guide

Overview​

How to launch the payment app (WPI PROCESS_TRANSACTION intent) on the secondary display of a dual-screen Android device and get its result back.

Requirements: API 26+ (ActivityOptions.launchDisplayId) and a device with a secondary display.

Devices​

The below description has been tested on

  • Landi C20 Pro
  • Swan Pro 1
  • Sunmi D3 Mini
  • M10
  • Telpo C9/C12 (two separate screens with two different processors; inconclusive result, as the screens are not linked

Why a proxy activity?​

Launching the payment app directly from your activity with launchDisplayId doesn't work reliably:

  1. The payment app's own screens jump back to the primary display. Its internal navigation follows the display of the app that launched it, not launchDisplayId.
  2. The result is lost. Launching onto another display requires FLAG_ACTIVITY_NEW_TASK, and Android cancels startActivityForResult immediately when NEW_TASK is set:
   ActivityTaskManager: Activity is launching as a new task, so cancelling activity result.

onActivityResult is never called with the real result. The app stays on "waiting for card" forever.

Solution:

  • Launch a small, transparent proxy activity onto the secondary display.
  • The proxy, which is already on that display, launches the payment app with a plain startActivityForResult. No NEW_TASK and no launchDisplayId are needed, and the payment app stays on the secondary display.
  • The proxy sends the result back to your activity through a PendingIntent, which isn't affected by the display change.

Flow​

 SalesActivity (Display 1)
│ startActivity(proxy, launchDisplayId = 2) + NEW_TASK | MULTIPLE_TASK
▼
SecondaryDisplayProxyActivity (Display 2)
│ startActivityForResult(WPI intent)
▼
Payment app (Display 2)
│ onActivityResult → proxy
▼
proxy: resultPendingIntent.send(...) → SalesActivity.onNewIntent()
  1. Detect the secondary display. If there isn't one, or the feature is disabled, use the normal single-display flow.
  2. Build a PendingIntent that targets your activity.
  3. Launch the proxy on the secondary display with startActivity, not startActivityForResult.
  4. The proxy launches the payment app and relays its result through the PendingIntent.
  5. Your activity gets the result in onNewIntent().

1. Proxy activity​

/**
* Transparent trampoline launched on the secondary display. Starts the wrapped intent from there
* so the target app stays on that display, then relays the result via a PendingIntent.
* Generic: works for any cross-display launch, not only WPI.
*/
class SecondaryDisplayProxyActivity : Activity() {

private var resultPendingIntent: PendingIntent? = null

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
resultPendingIntent = IntentCompat.getParcelableExtra(
intent, EXTRA_RESULT_PENDING_INTENT, PendingIntent::class.java,
)
// Recreated after a config change: the pending result is preserved, don't relaunch.
if (savedInstanceState != null) return

val targetIntent = IntentCompat.getParcelableExtra(intent, EXTRA_TARGET_INTENT, Intent::class.java)
if (targetIntent == null || resultPendingIntent == null) {
finish()
return
}

// REORDER_TO_FRONT would pull an existing payment-app task back to its previous display.
targetIntent.removeFlags(Intent.FLAG_ACTIVITY_REORDER_TO_FRONT)

try {
@Suppress("DEPRECATION")
startActivityForResult(targetIntent, REQUEST_TARGET)
} catch (e: ActivityNotFoundException) {
relayAndFinish(RESULT_CANCELED, null) // payment app not installed
}
}

@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_TARGET) relayAndFinish(resultCode, data)
}

private fun relayAndFinish(resultCode: Int, data: Intent?) {
try {
resultPendingIntent?.send(this, resultCode, data ?: Intent())
} catch (e: PendingIntent.CanceledException) {
Log.w("SecondaryDisplayProxy", "Result PendingIntent was cancelled", e)
}
finish()
}

companion object {
const val EXTRA_TARGET_INTENT = "com.aruba.ipago.EXTRA_TARGET_INTENT"
const val EXTRA_RESULT_PENDING_INTENT = "com.aruba.ipago.EXTRA_RESULT_PENDING_INTENT"
private const val REQUEST_TARGET = 1
}
}

Manifest:

<activity
android:name=".SecondaryDisplayProxyActivity"
android:exported="false"
android:excludeFromRecents="true"
android:theme="@android:style/Theme.Translucent.NoTitleBar" />

<!-- Host activity: singleTop so the relayed result arrives in onNewIntent() -->
<activity
android:name=".SalesActivity"
android:launchMode="singleTop" />

2. Host activity​

class SalesActivity : ComponentActivity() {

companion object {
private const val REQUEST_PAYMENT = 1000
private const val ACTION_WPI_RESPONSE = "com.aruba.ipago.ACTION_WPI_RESPONSE"
}

private fun startTransaction(transactionJson: String, serviceType: String, sessionId: String) {
val wpiIntent = Intent("com.worldline.payment.action.PROCESS_TRANSACTION").apply {
putExtra("WPI_SERVICE_TYPE", serviceType)
putExtra("WPI_REQUEST", transactionJson)
putExtra("WPI_VERSION", "2.1")
putExtra("WPI_SESSION_ID", sessionId)
}

val secondaryDisplay = getSystemService(DisplayManager::class.java)
.displays.firstOrNull { it.displayId != Display.DEFAULT_DISPLAY }

if (secondaryDisplay == null) {
// Single display: normal launch, result in onActivityResult()
@Suppress("DEPRECATION")
startActivityForResult(wpiIntent, REQUEST_PAYMENT)
return
}

// Dual screen: result comes back via this PendingIntent → onNewIntent()
val resultPendingIntent = PendingIntent.getActivity(
this,
REQUEST_PAYMENT,
Intent(this, SalesActivity::class.java).setAction(ACTION_WPI_RESPONSE),
PendingIntent.FLAG_UPDATE_CURRENT or PendingIntent.FLAG_MUTABLE,
)

val proxyIntent = Intent(this, SecondaryDisplayProxyActivity::class.java)
.putExtra(SecondaryDisplayProxyActivity.EXTRA_TARGET_INTENT, wpiIntent)
.putExtra(SecondaryDisplayProxyActivity.EXTRA_RESULT_PENDING_INTENT, resultPendingIntent)
.addFlags(Intent.FLAG_ACTIVITY_NEW_TASK or Intent.FLAG_ACTIVITY_MULTIPLE_TASK)

val options = ActivityOptions.makeBasic().apply { launchDisplayId = secondaryDisplay.displayId }
startActivity(proxyIntent, options.toBundle()) // NOT startActivityForResult
}

// Dual-screen result
override fun onNewIntent(intent: Intent) {
super.onNewIntent(intent)
if (intent.action == ACTION_WPI_RESPONSE) handleTransactionResponse(intent)
}

// Single-screen result
@Deprecated("Deprecated in Java")
@Suppress("DEPRECATION")
override fun onActivityResult(requestCode: Int, resultCode: Int, data: Intent?) {
super.onActivityResult(requestCode, resultCode, data)
if (requestCode == REQUEST_PAYMENT) data?.let(::handleTransactionResponse)
}

private fun handleTransactionResponse(intent: Intent) {
val rawJsonResponse = intent.getStringExtra("WPI_RESPONSE")
Log.d("SalesActivity", "Received transaction response: $rawJsonResponse")
}
}

send(context, resultCode, data) merges the extras of data into the PendingIntent's intent (Intent.fillIn()), so WPI_RESPONSE and the other response extras can be read directly in onNewIntent().


Intent extras​

Request​

ExtraTypeDescription
WPI_SERVICE_TYPEStringSubtype of action to execute.
WPI_REQUESTStringJSON payload for the service type
(may be empty for some services).
WPI_VERSIONStringWPI version used.
WPI_SESSION_IDStringUnique ID of the request/response exchange.
Use a new random value for each request.
Returned in the response,
and used to recover the exchange status after an unexpected
failure.

All request extras are mandatory.

Response​

ExtraTypeDescription
WPI_SERVICE_TYPEStringSubtype of action executed.
WPI_RESPONSEStringJSON result for the requested service type.
WPI_VERSIONStringWPI version used.
WPI_SESSION_IDStringSession ID provided in the request.

Notes & recommendations​

  • Never use startActivityForResult with FLAG_ACTIVITY_NEW_TASK. The result is canceled immediately. Use the proxy and the PendingIntent instead.
  • Why not registerForActivityResult? ActivityOptionsCompat doesn't expose launchDisplayId. The deprecated startActivityForResult is used only inside the proxy and in the single-display fallback.
  • NEW_TASK | MULTIPLE_TASK is required only for the outer launch (host → proxy), because an activity can only be placed on another display in a new task.
  • Feature toggle: put dual-screen mode behind a runtime capability check plus a setting that defaults to off, so it can be enabled per device or per rollout.
  • Timeouts: the payment app can still crash or freeze on the secondary display. Keep the same timeout backstop you use for the single-display flow.