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:
- 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. - The result is lost. Launching onto another display requires
FLAG_ACTIVITY_NEW_TASK, and Android cancelsstartActivityForResultimmediately whenNEW_TASKis 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. NoNEW_TASKand nolaunchDisplayIdare 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()
- Detect the secondary display. If there isn't one, or the feature is disabled, use the normal single-display flow.
- Build a
PendingIntentthat targets your activity. - Launch the proxy on the secondary display with
startActivity, notstartActivityForResult. - The proxy launches the payment app and relays its result through the
PendingIntent. - 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
| Extra | Type | Description |
|---|---|---|
WPI_SERVICE_TYPE | String | Subtype of action to execute. |
WPI_REQUEST | String | JSON payload for the service type (may be empty for some services). |
WPI_VERSION | String | WPI version used. |
WPI_SESSION_ID | String | Unique 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
| Extra | Type | Description |
|---|---|---|
WPI_SERVICE_TYPE | String | Subtype of action executed. |
WPI_RESPONSE | String | JSON result for the requested service type. |
WPI_VERSION | String | WPI version used. |
WPI_SESSION_ID | String | Session ID provided in the request. |
Notes & recommendations
- Never use
startActivityForResultwithFLAG_ACTIVITY_NEW_TASK. The result is canceled immediately. Use the proxy and thePendingIntentinstead. - Why not
registerForActivityResult?ActivityOptionsCompatdoesn't exposelaunchDisplayId. The deprecatedstartActivityForResultis used only inside the proxy and in the single-display fallback. NEW_TASK | MULTIPLE_TASKis 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.