From 1b3364e069713c44f71ed7f6aefd6e259e3ed858 Mon Sep 17 00:00:00 2001 From: thomasmonext <48686647+thomasmonext@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:25:39 +0200 Subject: [PATCH 1/2] =?UTF-8?q?Feature=20:=20Pr=C3=A9paration=20du=20Foreg?= =?UTF-8?q?round=20context=20pour=20=C3=A9viter=20que=20Android=20tue=20l'?= =?UTF-8?q?app=20pendant=20le=20paiement=20en=20cas=20de=20switch=20d'appl?= =?UTF-8?q?ication.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- monext/src/main/AndroidManifest.xml | 8 ++ .../kotlin/com/monext/sdk/PaymentSheet.kt | 3 + .../internal/presentation/PaymentContainer.kt | 5 + .../service/PaymentForegroundService.kt | 107 ++++++++++++++++++ 4 files changed, 123 insertions(+) create mode 100644 monext/src/main/kotlin/com/monext/sdk/internal/service/PaymentForegroundService.kt diff --git a/monext/src/main/AndroidManifest.xml b/monext/src/main/AndroidManifest.xml index a5918e6..4b57a18 100644 --- a/monext/src/main/AndroidManifest.xml +++ b/monext/src/main/AndroidManifest.xml @@ -1,4 +1,12 @@ + + + + + \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt b/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt index 8a25636..f9eaf5f 100644 --- a/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt +++ b/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt @@ -121,6 +121,9 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo viewModel.initializeSessionState(sessionToken) } + // On démarre "ForegroundService" afin d'éviter à l'OS de kill l'app en background si jamais il y a changement d'application + PaymentForegroundService.start(context) + val sessionLoading by viewModel.sessionLoading.collectAsStateWithLifecycle() val canPayGooglePay by viewModel.canPayGooglePay.collectAsStateWithLifecycle() var showingOverlay by remember { mutableStateOf(PaymentOverlayToggle.off()) } diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt b/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt index 91aa748..a66def0 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt @@ -1,6 +1,7 @@ package com.monext.sdk.internal.presentation import androidx.compose.runtime.Composable +import androidx.compose.ui.platform.LocalContext import com.monext.sdk.PaymentOverlayToggle import com.monext.sdk.PaymentResult import com.monext.sdk.PaymentResult.PaymentCompleted @@ -20,6 +21,7 @@ import com.monext.sdk.internal.presentation.status.PaymentPendingScreen import com.monext.sdk.internal.presentation.status.PaymentRedirectionScreen import com.monext.sdk.internal.presentation.status.PaymentSuccessScreen import com.monext.sdk.internal.presentation.status.TokenExpiredScreen +import com.monext.sdk.internal.service.PaymentForegroundService import com.monext.sdk.internal.threeds.view.PaymentSdkChallengeScreen internal data class PaymentAttempt( @@ -44,7 +46,10 @@ internal fun PaymentContainer( showOverlay: (PaymentOverlayToggle) -> Unit ) { + val context = LocalContext.current + if (sessionState?.type?.isFinalState() == true) { + PaymentForegroundService.stop(context) when(sessionState.type) { SessionStateType.PAYMENT_SUCCESS -> onResult( PaymentCompleted( diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/service/PaymentForegroundService.kt b/monext/src/main/kotlin/com/monext/sdk/internal/service/PaymentForegroundService.kt new file mode 100644 index 0000000..b162d0f --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/service/PaymentForegroundService.kt @@ -0,0 +1,107 @@ +package com.monext.sdk.internal.service + +import android.app.NotificationChannel +import android.app.NotificationManager +import android.app.Service +import android.content.Context +import android.content.Intent +import android.content.pm.ServiceInfo +import android.os.Build +import android.os.IBinder +import android.util.Log +import androidx.core.app.NotificationCompat +import androidx.core.app.ServiceCompat +import androidx.core.content.ContextCompat + +class PaymentForegroundService : Service() { + + companion object { + const val ACTION_START = "ACTION_START" + const val ACTION_STOP = "ACTION_STOP" + + private const val NOTIFICATION_ID = 1001 + private const val CHANNEL_ID = "payment_channel" + + // Helper pour démarrer le service depuis n'importe quel contexte + fun start(context: Context) { + val intent = Intent(context, PaymentForegroundService::class.java) + .apply { action = ACTION_START } + ContextCompat.startForegroundService(context, intent) + } + + // Helper pour arrêter le service + fun stop(context: Context) { + val intent = Intent(context, PaymentForegroundService::class.java) + .apply { action = ACTION_STOP } + context.startService(intent) + } + } + + // Evite les appels multiples à makeForeground() + private var isStarted = false + + override fun onCreate() { + super.onCreate() + createNotificationChannel() + } + + override fun onStartCommand(intent: Intent?, flags: Int, startId: Int): Int { + Log.d("PaymentFGS", "onStartCommand called with action: ${intent?.action}") + when (intent?.action) { + ACTION_START -> { + if (!isStarted) { + makeForeground() + isStarted = true + } + } + ACTION_STOP -> { + stopForeground(STOP_FOREGROUND_REMOVE) + stopSelf() + } + } + // START_NOT_STICKY : ne pas redémarrer automatiquement si le process est tué + return START_NOT_STICKY + } + + private fun makeForeground() { + val notification = NotificationCompat.Builder(this, CHANNEL_ID) + .setContentTitle("Paiement en cours") + .setContentText("Veuillez finaliser votre authentification bancaire") + .setSmallIcon(android.R.drawable.ic_lock_lock) + .setOngoing(true) + .setShowWhen(false) + .setPriority(NotificationCompat.PRIORITY_LOW) + .build() + + // ServiceCompat gère automatiquement les différences de versions + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.Q) { + ServiceCompat.startForeground( + this, + NOTIFICATION_ID, + notification, + ServiceInfo.FOREGROUND_SERVICE_TYPE_DATA_SYNC + ) + } + } + + private fun createNotificationChannel() { + val channel = NotificationChannel( + CHANNEL_ID, + "Paiement", + NotificationManager.IMPORTANCE_LOW // Pas de son, pas de popup + ).apply { + description = "Maintient le paiement actif pendant l'authentification 3DS" + setShowBadge(false) + } + val manager = getSystemService(NotificationManager::class.java) + manager.createNotificationChannel(channel) + } + + override fun onDestroy() { + super.onDestroy() + isStarted = false + } + + // Pas de binding nécessaire pour ce cas d'usage + override fun onBind(intent: Intent?): IBinder? = null +} \ No newline at end of file From 28b26f029fdcc11eb323a51619814940897437b6 Mon Sep 17 00:00:00 2001 From: thomasmonext <48686647+thomasmonext@users.noreply.github.com> Date: Thu, 18 Jun 2026 11:29:01 +0200 Subject: [PATCH 2/2] PAYLANO-12422 : [SDK_ANDROID][PAYPAL_APIREST] - FraudNet & Redirection automatique - Prise en compte du nouveau contextType 1. Show only a loading spinner (no user interaction) 2. Load JavaScript code in a hidden WebView 3. Auto-trigger the payment - Added JVM unit tests covering the pure-logic parts of the new `PAYMENT_REDIRECT_WITH_JAVASCRIPT` feature. --- .../sdk/presentation/PaymentContainerTest.kt | 92 +++++++++++++++--- .../kotlin/com/monext/sdk/PaymentSheet.kt | 10 +- .../com/monext/sdk/internal/api/PaymentAPI.kt | 16 +++- .../model/response/SessionStateResponse.kt | 3 +- .../internal/data/SessionStateRepository.kt | 2 +- .../data/sessionstate/PaymentMethodsList.kt | 23 +++++ .../internal/presentation/PaymentContainer.kt | 26 +++++ .../PaymentJavascriptRedirectionScreen.kt | 94 +++++++++++++++++++ .../sdk/internal/preview/PaymentAPIPreview.kt | 2 +- .../sdk/internal/api/PaymentAPIImplTest.kt | 13 +-- .../model/response/SessionStateTypeTest.kt | 80 ++++++++++++++++ .../data/SessionStateRepositoryTest.kt | 12 +-- .../data/sessionstate/FormScriptTest.kt | 73 ++++++++++++++ .../preview/PaymentAPIPreviewSuccessTest.kt | 3 +- 14 files changed, 409 insertions(+), 40 deletions(-) create mode 100644 monext/src/main/kotlin/com/monext/sdk/internal/presentation/status/PaymentJavascriptRedirectionScreen.kt create mode 100644 monext/src/test/kotlin/com/monext/sdk/internal/api/model/response/SessionStateTypeTest.kt create mode 100644 monext/src/test/kotlin/com/monext/sdk/internal/data/sessionstate/FormScriptTest.kt diff --git a/monext/src/androidTest/kotlin/com/monext/sdk/presentation/PaymentContainerTest.kt b/monext/src/androidTest/kotlin/com/monext/sdk/presentation/PaymentContainerTest.kt index a7917f3..17eb94b 100644 --- a/monext/src/androidTest/kotlin/com/monext/sdk/presentation/PaymentContainerTest.kt +++ b/monext/src/androidTest/kotlin/com/monext/sdk/presentation/PaymentContainerTest.kt @@ -7,24 +7,15 @@ import androidx.compose.ui.test.assertTextEquals import androidx.compose.ui.test.junit4.createAndroidComposeRule import androidx.compose.ui.test.onNodeWithTag import androidx.test.ext.junit.runners.AndroidJUnit4 -import com.monext.sdk.Appearance -import com.monext.sdk.FakeTestActivity -import com.monext.sdk.LocalAppearance -import com.monext.sdk.PaymentOverlayToggle -import com.monext.sdk.PaymentResult +import com.monext.sdk.* import com.monext.sdk.internal.api.model.response.SessionState import com.monext.sdk.internal.api.model.response.SessionStateType -import com.monext.sdk.internal.data.LocalSessionStateRepo -import com.monext.sdk.internal.data.SessionStateRepository -import com.monext.sdk.internal.data.sessionstate.ActiveWaiting -import com.monext.sdk.internal.data.sessionstate.CustomMessage -import com.monext.sdk.internal.data.sessionstate.PaymentOnholdPartner +import com.monext.sdk.internal.data.FormData +import com.monext.sdk.internal.data.sessionstate.* +import com.monext.sdk.internal.presentation.PaymentAttempt import com.monext.sdk.internal.presentation.PaymentContainer import com.monext.sdk.internal.preview.PreviewSamples.Companion.buildSessionState -import io.mockk.impl.annotations.RelaxedMockK -import org.junit.Assert.assertEquals -import org.junit.Assert.assertFalse -import org.junit.Assert.assertTrue +import org.junit.Assert.* import org.junit.Before import org.junit.Rule import org.junit.Test @@ -157,6 +148,78 @@ class PaymentContainerTest { "back_button" to null)) } + @Test + fun withPaymentRedirectWithJavascript_triggersMakePaymentForLoadedMethod() { + // Given une réponse PAYMENT_REDIRECT_WITH_JAVASCRIPT contenant un moyen de paiement + // avec un script à exécuter (ex : empreinte device PayPal). + val paymentMethodData = PaymentMethodData( + cardCode = "PAYPAL_APIREST", + contractNumber = "PAYPAL_APIREST", + disabled = false, + hasForm = true, + form = PaymentForm( + displayButton = true, + formScript = FormScript( + content = "console.log('fingerprint');", + wrapIntoScriptTag = true, + formScriptEnum = "CUSTOM" + ), + formType = "CUSTOM" + ), + hasLogo = false, + logo = null, + isIsolated = false, + options = emptyList(), + paymentMethodAction = 0, + additionalData = null, + requestContext = null, + shouldBeInTopPosition = false, + state = "AVAILABLE" + ) + val sessionState = SessionState( + token = "fake_token", + type = SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT, + creationDate = "Tue Mar 25 12:33:22 CET 2025", + cancelUrl = "https://www.payline.com", + pointOfSale = "POS_Fake", + language = "fr", + returnUrl = "https://www.monext.fr", + automaticRedirectAtSessionsEnd = false, + isSandbox = true, + paymentMethodsList = PaymentMethodsList( + isOriginalCreditTransfer = false, + needsDeviceFingerprint = true, + paymentMethodsData = listOf(paymentMethodData), + scoringNeeded = null, + sensitiveInputContentMasked = false, + shouldChangePaymentMethodPosition = false, + wallets = emptyList() + ) + ) + + var capturedAttempt: PaymentAttempt? = null + + // When + composeTestRule.activity.setTestComposable { + CompositionLocalProvider(LocalAppearance provides appearance) { + PaymentContainer( + sessionState, + { paymentMethodList, sessionInfo -> }, + {}, + { attempt -> capturedAttempt = attempt }, + {}, + { }, + { } + ) { state -> stateHistory.add(state) } + } + } + + // Then : le paiement est déclenché automatiquement pour le moyen de paiement chargé + composeTestRule.waitUntil(timeoutMillis = 10_000) { capturedAttempt != null } + assertEquals("PAYPAL_APIREST", capturedAttempt?.selectedPaymentMethod?.cardCode) + assertTrue(capturedAttempt?.paymentFormData is FormData.AlternativePaymentMethodForm) + } + private fun executeSessionStateTest( sessionState: SessionState, expectedTransactionState: PaymentResult.TransactionState, @@ -172,6 +235,7 @@ class PaymentContainerTest { { paymentMethodList, sessionInfo -> }, {}, {}, + {}, { result -> paymentResult = result }, diff --git a/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt b/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt index f9eaf5f..1b76413 100644 --- a/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt +++ b/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt @@ -96,8 +96,8 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo ) ) if (!sheetState.isVisible) { - onIsShowingChange?.invoke(false) - } + onIsShowingChange?.invoke(false) + } }, modifier = Modifier.statusBarsPadding().testTag("payment_bottom_sheet"), sheetState = sheetState, @@ -121,9 +121,6 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo viewModel.initializeSessionState(sessionToken) } - // On démarre "ForegroundService" afin d'éviter à l'OS de kill l'app en background si jamais il y a changement d'application - PaymentForegroundService.start(context) - val sessionLoading by viewModel.sessionLoading.collectAsStateWithLifecycle() val canPayGooglePay by viewModel.canPayGooglePay.collectAsStateWithLifecycle() var showingOverlay by remember { mutableStateOf(PaymentOverlayToggle.off()) } @@ -195,6 +192,9 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo ) }, onRedirectionComplete = { viewModel.updateSessionState(sessionToken) }, + onMakePayment = { attempt -> + viewModel.makePayment(attempt, context) { showingOverlay = it } + }, onRetry = { /* TODO: implement RETRY */ }, onResult = onResult, onIsShowingChange = isShowingChangeWrapper, diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/api/PaymentAPI.kt b/monext/src/main/kotlin/com/monext/sdk/internal/api/PaymentAPI.kt index 5bfed3a..1b716e1 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/api/PaymentAPI.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/api/PaymentAPI.kt @@ -21,7 +21,7 @@ import java.net.URL internal interface PaymentAPI { @Throws(NetworkError::class) - suspend fun stateCurrent(sessionToken: String): SessionState + suspend fun stateCurrent(sessionToken: String, merchantReturnUrl: String): SessionState @Throws(NetworkError::class) suspend fun payment(sessionToken: String, params: PaymentRequest): SessionState @@ -78,10 +78,20 @@ internal class PaymentAPIImpl( * GET /token/{token}/state/current */ @Throws(NetworkError::class) - override suspend fun stateCurrent(sessionToken: String): SessionState { + override suspend fun stateCurrent(sessionToken: String, merchantReturnUrl: String): SessionState { val baseUrl = buildBaseUrl() val url = appendPath(baseUrl, sessionToken, "state", "current") - val httpRequest = buildHttpRequest(url, HttpMethod.GET) + + val uriWithQuery = URI( + url.protocol, + url.authority, + url.path, + "merchantReturnUrl=$merchantReturnUrl", + null + ) + val finalUrl = uriWithQuery.toURL() + + val httpRequest = buildHttpRequest(finalUrl, HttpMethod.GET) val sessionStateResponse = makeRequest(httpRequest) // on récupère la configuration pour le remoteLogger diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/api/model/response/SessionStateResponse.kt b/monext/src/main/kotlin/com/monext/sdk/internal/api/model/response/SessionStateResponse.kt index 78c97b2..faee27c 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/api/model/response/SessionStateResponse.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/api/model/response/SessionStateResponse.kt @@ -46,6 +46,7 @@ internal enum class SessionStateType { PAYMENT_METHODS_LIST, PAYMENT_REDIRECT_NO_RESPONSE, + PAYMENT_REDIRECT_WITH_JAVASCRIPT, // Redirection après avoir executé le Javascript PAYMENT_SUCCESS, PAYMENT_FAILURE, PAYMENT_ONHOLD_PARTNER, @@ -59,7 +60,7 @@ internal enum class SessionStateType { fun toTransactionState(): PaymentResult.TransactionState? = when (this) { - PAYMENT_METHODS_LIST, PAYMENT_REDIRECT_NO_RESPONSE, SDK_CHALLENGE, ACTIVE_WAITING -> PaymentResult.TransactionState.PAYMENT_INCOMPLETE + PAYMENT_METHODS_LIST, PAYMENT_REDIRECT_NO_RESPONSE, PAYMENT_REDIRECT_WITH_JAVASCRIPT, SDK_CHALLENGE, ACTIVE_WAITING -> PaymentResult.TransactionState.PAYMENT_INCOMPLETE PAYMENT_SUCCESS -> PaymentResult.TransactionState.PAYMENT_SUCCESS PAYMENT_FAILURE, PAYMENT_FAILURE_WITH_RETRY -> PaymentResult.TransactionState.PAYMENT_FAILURE PAYMENT_CANCELED -> PaymentResult.TransactionState.PAYMENT_CANCELED diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/data/SessionStateRepository.kt b/monext/src/main/kotlin/com/monext/sdk/internal/data/SessionStateRepository.kt index 274b17b..df3e4f6 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/data/SessionStateRepository.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/data/SessionStateRepository.kt @@ -55,7 +55,7 @@ internal class SessionStateRepository( suspend fun updateSessionState(token: String) { makeRequest { - val sState = paymentAPI.stateCurrent(token) + val sState = paymentAPI.stateCurrent(sessionToken = token, merchantReturnUrl = this.returnURLString) this.token = sState.token sState } diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/data/sessionstate/PaymentMethodsList.kt b/monext/src/main/kotlin/com/monext/sdk/internal/data/sessionstate/PaymentMethodsList.kt index 1f2757c..dd3e318 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/data/sessionstate/PaymentMethodsList.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/data/sessionstate/PaymentMethodsList.kt @@ -96,9 +96,32 @@ internal data class PaymentForm( val description: String? = null, val buttonText: String? = null, val formFields: List = emptyList(), + val formScript: FormScript? = null, val formType: String? = null ) : Parcelable +@Parcelize +@Serializable +internal data class FormScript( + val content: String? = null, + val wrapIntoScriptTag: Boolean? = null, + val formScriptEnum: String? = null +) : Parcelable { + + /** + * Retourne le contenu HTML prêt à être chargé dans la WebView. + * Si [wrapIntoScriptTag] vaut true, le contenu est entouré d'une balise . + */ + fun htmlContent(): String? { + val script = content ?: return null + return if (wrapIntoScriptTag == true) { + "" + } else { + script + } + } +} + @Parcelize @Serializable internal data class PaymentMethodFormField( diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt b/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt index a66def0..b19f2b4 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/presentation/PaymentContainer.kt @@ -18,6 +18,7 @@ import com.monext.sdk.internal.presentation.status.LoadingSection import com.monext.sdk.internal.presentation.status.PaymentCanceledScreen import com.monext.sdk.internal.presentation.status.PaymentFailureScreen import com.monext.sdk.internal.presentation.status.PaymentPendingScreen +import com.monext.sdk.internal.presentation.status.PaymentJavascriptRedirectionScreen import com.monext.sdk.internal.presentation.status.PaymentRedirectionScreen import com.monext.sdk.internal.presentation.status.PaymentSuccessScreen import com.monext.sdk.internal.presentation.status.TokenExpiredScreen @@ -40,6 +41,7 @@ internal fun PaymentContainer( sessionState: SessionState?, paymentMethodsScreen: @Composable (PaymentMethodsList, SessionInfo) -> Unit, onRedirectionComplete: () -> Unit, + onMakePayment: (PaymentAttempt) -> Unit, onRetry: () -> Unit, onResult: (PaymentResult) -> Unit, onIsShowingChange: ((Boolean) -> Unit)? = null, @@ -97,6 +99,30 @@ internal fun PaymentContainer( } ?: LoadingSection() } + SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT -> { + // On a les memes data que "paymentMethodsList". + // On exécute le Javascript dans une WebView invisible (seul un spinner est affiché), + // puis on déclenche automatiquement le paiement du moyen de paiement chargé, + // sans afficher le bouton ni demander d'interaction à l'acheteur. + val paymentMethod = sessionState.paymentMethodsList?.paymentMethods?.firstOrNull() + val formScript = paymentMethod?.data?.form?.formScript + if (paymentMethod != null && formScript != null) { + PaymentJavascriptRedirectionScreen(formScript) { + onMakePayment( + PaymentAttempt( + selectedPaymentMethod = paymentMethod, + paymentFormData = FormData.AlternativePaymentMethodForm(saveCard = false), + selectedWallet = null, + walletFormData = null + ) + ) + } + } else { + // Ne peut normalement pas arriver car si le backend renvoit ce type de réponse, c'est qu'il a controlé les données. + LoadingSection() + } + } + SessionStateType.SDK_CHALLENGE -> { sessionState.paymentSdkChallenge?.sdkChallengeData?.let { data -> PaymentSdkChallengeScreen(sdkChallengeData = data, showOverlay) diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/presentation/status/PaymentJavascriptRedirectionScreen.kt b/monext/src/main/kotlin/com/monext/sdk/internal/presentation/status/PaymentJavascriptRedirectionScreen.kt new file mode 100644 index 0000000..155490c --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/presentation/status/PaymentJavascriptRedirectionScreen.kt @@ -0,0 +1,94 @@ +package com.monext.sdk.internal.presentation.status + +import android.annotation.SuppressLint +import android.view.ViewGroup +import android.webkit.WebView +import android.webkit.WebViewClient +import androidx.compose.foundation.layout.Box +import androidx.compose.foundation.layout.fillMaxSize +import androidx.compose.ui.Alignment +import androidx.compose.runtime.Composable +import androidx.compose.runtime.LaunchedEffect +import androidx.compose.runtime.getValue +import androidx.compose.runtime.mutableStateOf +import androidx.compose.runtime.remember +import androidx.compose.runtime.rememberUpdatedState +import androidx.compose.runtime.setValue +import androidx.compose.ui.Modifier +import androidx.compose.ui.viewinterop.AndroidView +import com.monext.sdk.internal.data.sessionstate.FormScript +import kotlinx.coroutines.delay +import kotlin.time.Duration.Companion.milliseconds + +/** + * Écran utilisé pour l'état [com.monext.sdk.internal.api.model.response.SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT]. + * + * Cas d'usage : exécuter un script Javascript fourni par le backend (ex : empreinte device PayPal) + * SANS aucune interaction de l'acheteur. On affiche uniquement un spinner, on charge le script dans + * une WebView invisible, on attend un court délai, puis on déclenche automatiquement le paiement. + */ +const val JAVASCRIPT_EXECUTION_DELAY_MS = 500L + +@SuppressLint("SetJavaScriptEnabled") +@Composable +internal fun PaymentJavascriptRedirectionScreen( + formScript: FormScript, + onScriptExecuted: () -> Unit +) { + val htmlContent = remember(formScript) { formScript.htmlContent() } + var pageFinished by remember { mutableStateOf(false) } + val currentOnScriptExecuted by rememberUpdatedState(onScriptExecuted) + + // Si le script est absent, on déclenche directement le paiement + LaunchedEffect(htmlContent) { + if (htmlContent == null) { + currentOnScriptExecuted() + } + } + + // Une fois la page chargée, on attend un court délai pour laisser le JS s'exécuter, + // puis on déclenche le paiement programmatiquement. + LaunchedEffect(pageFinished) { + if (pageFinished) { + delay(JAVASCRIPT_EXECUTION_DELAY_MS.milliseconds) + currentOnScriptExecuted() + } + } + + // Spinner affiché à l'acheteur, centré au-dessus de la WebView (invisible) pendant + // l'exécution du Javascript (seul élément visible pour l'acheteur). + Box( + modifier = Modifier.fillMaxSize(), + contentAlignment = Alignment.Center + ) { + if (htmlContent != null) { + // WebView invisible (taille 0) : on ne veut rien afficher à l'acheteur. + AndroidView( + factory = { context -> + WebView(context).apply { + layoutParams = ViewGroup.LayoutParams(0, 0) + settings.javaScriptEnabled = true + webViewClient = object : WebViewClient() { + override fun onPageFinished(view: WebView?, url: String?) { + super.onPageFinished(view, url) + pageFinished = true + } + } + } + }, + update = { webView -> + webView.loadDataWithBaseURL( + null, + htmlContent, + "text/html", + "UTF-8", + null + ) + } + ) + } + + // Loader circulaire affiché pendant l'exécution du Javascript. + LoadingSection() + } +} diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreview.kt b/monext/src/main/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreview.kt index f24033d..d0fd248 100644 --- a/monext/src/main/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreview.kt +++ b/monext/src/main/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreview.kt @@ -13,7 +13,7 @@ import com.monext.sdk.internal.threeds.response.DirectoryServerSdkKey import com.monext.sdk.internal.threeds.response.DirectoryServerSdkKeyResponse internal object PaymentAPIPreviewSuccess: PaymentAPI { - override suspend fun stateCurrent(sessionToken: String): SessionState = PreviewSamples.sessionStatePaymentMethodsList + override suspend fun stateCurrent(sessionToken: String, merchantReturnUrl: String): SessionState = PreviewSamples.sessionStatePaymentMethodsList override suspend fun payment(sessionToken: String, params: PaymentRequest): SessionState = PreviewSamples.sessionStateSuccess override suspend fun securedPayment(sessionToken: String, params: SecuredPaymentRequest): SessionState = PreviewSamples.sessionStateSuccess override suspend fun walletPayment(sessionToken: String, params: WalletPaymentRequest): SessionState = PreviewSamples.sessionStateSuccess diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/api/PaymentAPIImplTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/api/PaymentAPIImplTest.kt index ce43988..d47f09a 100644 --- a/monext/src/test/kotlin/com/monext/sdk/internal/api/PaymentAPIImplTest.kt +++ b/monext/src/test/kotlin/com/monext/sdk/internal/api/PaymentAPIImplTest.kt @@ -38,6 +38,7 @@ class PaymentAPIImplTest { private val testDispatcher = StandardTestDispatcher() private val testEnvironment = MnxtEnvironment.Custom("test.example.com/api/v1") private val sessionToken = "test-session-token-123" + private val merchantReturnUrl = "https://www.monext.fr" // allow to capture parameter with non nullable type `Double` val captureHttpRequest = slot() @@ -73,14 +74,14 @@ class PaymentAPIImplTest { mockHttpClientResponse(responseBody = RESPONSE_CONTEXT_SUCCESS) // When - val result = paymentApi.stateCurrent(sessionToken) + val result = paymentApi.stateCurrent(sessionToken, merchantReturnUrl) // Then checkResponseSuccess(expectedSessionState, result) // On check aussi la request envoyée checkHttpRequest( - url = "https://test.example.com/api/v1/services/token/test-session-token-123/state/current", + url = "https://test.example.com/api/v1/services/token/test-session-token-123/state/current?merchantReturnUrl=$merchantReturnUrl", method = "GET" ) } @@ -100,14 +101,14 @@ class PaymentAPIImplTest { mockHttpClientResponse(responseBody = RESPONSE_CONTEXT_SUCCESS) // When - val result = paymentApi.stateCurrent(sessionToken) + val result = paymentApi.stateCurrent(sessionToken, merchantReturnUrl) // Then checkResponseSuccess(expectedSessionState, result) // On check aussi la request envoyée checkHttpRequest( - url = "https://homologation-payment.payline.com/services/token/test-session-token-123/state/current", + url = "https://homologation-payment.payline.com/services/token/test-session-token-123/state/current?merchantReturnUrl=$merchantReturnUrl", method = "GET", origin = "homologation-payment.payline.com" ) @@ -128,14 +129,14 @@ class PaymentAPIImplTest { mockHttpClientResponse(responseBody = RESPONSE_CONTEXT_SUCCESS) // When - val result = paymentApi.stateCurrent(sessionToken) + val result = paymentApi.stateCurrent(sessionToken, merchantReturnUrl) // Then checkResponseSuccess(expectedSessionState, result) // On check aussi la request envoyée checkHttpRequest( - url = "https://payment.payline.com/services/token/test-session-token-123/state/current", + url = "https://payment.payline.com/services/token/test-session-token-123/state/current?merchantReturnUrl=$merchantReturnUrl", method = "GET", origin = "payment.payline.com" ) diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/api/model/response/SessionStateTypeTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/api/model/response/SessionStateTypeTest.kt new file mode 100644 index 0000000..9181525 --- /dev/null +++ b/monext/src/test/kotlin/com/monext/sdk/internal/api/model/response/SessionStateTypeTest.kt @@ -0,0 +1,80 @@ +package com.monext.sdk.internal.api.model.response + +import com.monext.sdk.PaymentResult +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class SessionStateTypeTest { + + @Test + fun toTransactionState_paymentRedirectWithJavascript_returnsPaymentIncomplete() { + // Given + val type = SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT + + // When + val result = type.toTransactionState() + + // Then + assertEquals(PaymentResult.TransactionState.PAYMENT_INCOMPLETE, result) + } + + @Test + fun toTransactionState_paymentMethodsList_returnsPaymentIncomplete() { + assertEquals( + PaymentResult.TransactionState.PAYMENT_INCOMPLETE, + SessionStateType.PAYMENT_METHODS_LIST.toTransactionState() + ) + } + + @Test + fun toTransactionState_paymentRedirectNoResponse_returnsPaymentIncomplete() { + assertEquals( + PaymentResult.TransactionState.PAYMENT_INCOMPLETE, + SessionStateType.PAYMENT_REDIRECT_NO_RESPONSE.toTransactionState() + ) + } + + @Test + fun toTransactionState_paymentSuccess_returnsPaymentSuccess() { + assertEquals( + PaymentResult.TransactionState.PAYMENT_SUCCESS, + SessionStateType.PAYMENT_SUCCESS.toTransactionState() + ) + } + + @Test + fun toTransactionState_paymentFailure_returnsPaymentFailure() { + assertEquals( + PaymentResult.TransactionState.PAYMENT_FAILURE, + SessionStateType.PAYMENT_FAILURE.toTransactionState() + ) + } + + @Test + fun toTransactionState_paymentCanceled_returnsPaymentCanceled() { + assertEquals( + PaymentResult.TransactionState.PAYMENT_CANCELED, + SessionStateType.PAYMENT_CANCELED.toTransactionState() + ) + } + + @Test + fun toTransactionState_tokenExpired_returnsTokenExpired() { + assertEquals( + PaymentResult.TransactionState.TOKEN_EXPIRED, + SessionStateType.TOKEN_EXPIRED.toTransactionState() + ) + } + + @Test + fun toTransactionState_unknown_returnsNull() { + assertNull(SessionStateType.UNKNOWN.toTransactionState()) + } + + @Test + fun isFinalState_paymentRedirectWithJavascript_returnsFalse() { + // Given le flux Javascript est un état intermédiaire (spinner + exécution du JS) + assertEquals(false, SessionStateType.PAYMENT_REDIRECT_WITH_JAVASCRIPT.isFinalState()) + } +} diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/data/SessionStateRepositoryTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/data/SessionStateRepositoryTest.kt index 7ef8c76..0031949 100644 --- a/monext/src/test/kotlin/com/monext/sdk/internal/data/SessionStateRepositoryTest.kt +++ b/monext/src/test/kotlin/com/monext/sdk/internal/data/SessionStateRepositoryTest.kt @@ -7,14 +7,12 @@ import com.monext.sdk.MnxtSDKContext import com.monext.sdk.SdkTestHelper import com.monext.sdk.internal.api.PaymentAPI import com.monext.sdk.internal.api.configuration.InternalSDKContext -import com.monext.sdk.internal.api.model.response.SessionState import com.monext.sdk.internal.preview.PreviewSamples import com.monext.sdk.internal.preview.PreviewSamples.Companion.sessionStateActiveWaiting import com.monext.sdk.internal.threeds.ThreeDSManager import com.monext.sdk.internal.threeds.model.ChallengeUseCaseCallback import io.mockk.coEvery import io.mockk.coVerify -import io.mockk.every import io.mockk.impl.annotations.RelaxedMockK import io.mockk.junit5.MockKExtension import io.mockk.mockk @@ -52,7 +50,7 @@ class SessionStateRepositoryTest { underTest = spyk(SessionStateRepository(paymentAPI, internalSDKContext, threeDSManager)) // Mock - coEvery { paymentAPI.stateCurrent(any()) } returns sessionStateMethodList + coEvery { paymentAPI.stateCurrent(any(), any()) } returns sessionStateMethodList } @Test @@ -60,7 +58,7 @@ class SessionStateRepositoryTest { underTest.updateSessionState(token) - coVerify(exactly = 1) { paymentAPI.stateCurrent(token) } + coVerify(exactly = 1) { paymentAPI.stateCurrent(token, any()) } assertEquals(sessionStateMethodList, underTest.sessionState.value) } @@ -86,7 +84,7 @@ class SessionStateRepositoryTest { fun clearSession() = runTest(testDispatcher) { // Init data underTest.updateSessionState(token) - coVerify(exactly = 1) { paymentAPI.stateCurrent(token) } + coVerify(exactly = 1) { paymentAPI.stateCurrent(token, any()) } assertEquals(sessionStateMethodList, underTest.sessionState.value) // Test @@ -180,7 +178,7 @@ class SessionStateRepositoryTest { // On met à jour underTest.updateSessionState(token) - coVerify(exactly = 1) { paymentAPI.stateCurrent(token) } + coVerify(exactly = 1) { paymentAPI.stateCurrent(token, any()) } // Il ne doit plus etre null assertEquals(sessionStateMethodList, underTest.sessionState.value) } @@ -264,4 +262,4 @@ class SessionStateRepositoryTest { coVerify { underTest.updateSessionState(token) } } -} \ No newline at end of file +} diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/data/sessionstate/FormScriptTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/data/sessionstate/FormScriptTest.kt new file mode 100644 index 0000000..1f588be --- /dev/null +++ b/monext/src/test/kotlin/com/monext/sdk/internal/data/sessionstate/FormScriptTest.kt @@ -0,0 +1,73 @@ +package com.monext.sdk.internal.data.sessionstate + +import kotlin.test.Test +import kotlin.test.assertEquals +import kotlin.test.assertNull + +class FormScriptTest { + + @Test + fun htmlContent_whenWrapIntoScriptTagTrue_wrapsContentInScriptTag() { + // Given + val formScript = FormScript( + content = "console.log('hello');", + wrapIntoScriptTag = true, + formScriptEnum = "CUSTOM" + ) + + // When + val result = formScript.htmlContent() + + // Then + assertEquals("", result) + } + + @Test + fun htmlContent_whenWrapIntoScriptTagFalse_returnsContentAsIs() { + // Given + val rawContent = "" + val formScript = FormScript( + content = rawContent, + wrapIntoScriptTag = false, + formScriptEnum = "CUSTOM" + ) + + // When + val result = formScript.htmlContent() + + // Then + assertEquals(rawContent, result) + } + + @Test + fun htmlContent_whenWrapIntoScriptTagNull_returnsContentAsIs() { + // Given + val formScript = FormScript( + content = "alert('hi');", + wrapIntoScriptTag = null, + formScriptEnum = "CUSTOM" + ) + + // When + val result = formScript.htmlContent() + + // Then + assertEquals("alert('hi');", result) + } + + @Test + fun htmlContent_whenContentNull_returnsNull() { + // Given + val formScript = FormScript( + content = null, + wrapIntoScriptTag = true, + formScriptEnum = "CUSTOM" + ) + + // When + val result = formScript.htmlContent() + + // Then + assertNull(result) + } +} diff --git a/monext/src/test/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreviewSuccessTest.kt b/monext/src/test/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreviewSuccessTest.kt index d92e8c3..4a37589 100644 --- a/monext/src/test/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreviewSuccessTest.kt +++ b/monext/src/test/kotlin/com/monext/sdk/internal/preview/PaymentAPIPreviewSuccessTest.kt @@ -3,7 +3,6 @@ package com.monext.sdk.internal.preview import com.monext.sdk.MnxtEnvironment import com.monext.sdk.MnxtSDKContext import com.monext.sdk.SdkTestHelper -import com.monext.sdk.internal.api.AvailableCardNetworksRequest import com.monext.sdk.internal.api.configuration.InternalSDKContext import kotlinx.coroutines.runBlocking import org.junit.jupiter.api.Assertions.* @@ -28,7 +27,7 @@ class PaymentAPIPreviewSuccessTest { @Test fun stateCurrent() = runBlocking { - assertEquals(PreviewSamples.sessionStatePaymentMethodsList, underTest.stateCurrent("aa")) + assertEquals(PreviewSamples.sessionStatePaymentMethodsList, underTest.stateCurrent("aa", "https://www.monext.fr")) } @Test