diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 5cb59ae..26689a0 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -16,7 +16,7 @@ coilNetworkOkhttp = "3.3.0" coilTest = "3.3.0" datastorePreferences = "1.1.7" dokka = "2.0.0" -espressoCore = "3.5.0" +espressoCore = "3.6.1" firebaseBom = "34.4.0" google-services-plugin = "4.4.4" io-mockk = "1.14.6" diff --git a/monext/src/androidTest/kotlin/com/monext/sdk/PaymentSheetTest.kt b/monext/src/androidTest/kotlin/com/monext/sdk/PaymentSheetTest.kt index f4d2e03..82bcc57 100644 --- a/monext/src/androidTest/kotlin/com/monext/sdk/PaymentSheetTest.kt +++ b/monext/src/androidTest/kotlin/com/monext/sdk/PaymentSheetTest.kt @@ -1,6 +1,7 @@ package com.monext.sdk import android.os.StrictMode +import android.view.WindowManager import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf import androidx.compose.runtime.setValue @@ -10,6 +11,7 @@ import androidx.compose.ui.test.onNodeWithTag import androidx.compose.ui.test.performTouchInput import androidx.compose.ui.test.swipeDown import org.junit.Assert.assertFalse +import org.junit.Assert.assertTrue import org.junit.Before import org.junit.Rule import org.junit.Test @@ -104,4 +106,70 @@ class PaymentSheetTest { assertFalse(showPaymentSheet) } + + @Test + fun paymentSheet_whenShowing_appliesSecureFlagOnActivity() { + val testContext = MnxtSDKContext(MnxtEnvironment.Sandbox) + + composeTestRule.activity.setTestComposable { + PaymentSheet( + isShowing = true, + sessionToken = "test-token", + sdkContext = testContext, + onResult = {} + ) + } + + composeTestRule.waitForIdle() + + val flags = composeTestRule.activity.window.attributes.flags + assertTrue( + "FLAG_SECURE devrait être actif sur l'Activity", + flags and WindowManager.LayoutParams.FLAG_SECURE != 0 + ) + } + + @Test + fun paymentSheet_whenNotShowing_doesNotApplySecureFlag() { + val testContext = MnxtSDKContext(MnxtEnvironment.Sandbox) + + composeTestRule.activity.setTestComposable { + PaymentSheet( + isShowing = false, + sessionToken = "test-token", + sdkContext = testContext, + onResult = {} + ) + } + + composeTestRule.waitForIdle() + + val flags = composeTestRule.activity.window.attributes.flags + assertTrue( + "FLAG_SECURE ne devrait pas être actif quand le sheet n'est pas affiché", + flags and WindowManager.LayoutParams.FLAG_SECURE == 0 + ) + } + + @Test + fun paymentSheet_inNonSecureEnvironment_doesNotApplySecureFlag() { + val testContext = MnxtSDKContext(MnxtEnvironment.Custom("webpayment.dev.payline.com/payline-widget")) // environnement non-sécurisé + + composeTestRule.activity.setTestComposable { + PaymentSheet( + isShowing = true, + sessionToken = "test-token", + sdkContext = testContext, + onResult = {} + ) + } + + composeTestRule.waitForIdle() + + val flags = composeTestRule.activity.window.attributes.flags + assertTrue( + "FLAG_SECURE ne devrait pas être actif en environnement non-sécurisé", + flags and WindowManager.LayoutParams.FLAG_SECURE == 0 + ) + } } \ 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 1b76413..3609a32 100644 --- a/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt +++ b/monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt @@ -1,7 +1,9 @@ package com.monext.sdk import android.app.Application +import android.os.Environment import android.util.Log +import android.view.WindowManager import androidx.activity.compose.rememberLauncherForActivityResult import androidx.compose.animation.animateContentSize import androidx.compose.foundation.layout.Arrangement @@ -17,6 +19,7 @@ import androidx.compose.material3.Text import androidx.compose.material3.rememberModalBottomSheetState import androidx.compose.runtime.Composable import androidx.compose.runtime.CompositionLocalProvider +import androidx.compose.runtime.DisposableEffect import androidx.compose.runtime.LaunchedEffect import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -27,9 +30,11 @@ import androidx.compose.ui.Alignment import androidx.compose.ui.Modifier import androidx.compose.ui.platform.LocalContext import androidx.compose.ui.platform.LocalInspectionMode +import androidx.compose.ui.platform.LocalView import androidx.compose.ui.platform.testTag import androidx.compose.ui.tooling.preview.Preview import androidx.compose.ui.unit.dp +import androidx.compose.ui.window.DialogWindowProvider import androidx.lifecycle.ViewModelProvider import androidx.lifecycle.compose.collectAsStateWithLifecycle import androidx.lifecycle.viewmodel.MutableCreationExtras @@ -41,12 +46,14 @@ import com.monext.sdk.internal.api.configuration.InternalSDKContext import com.monext.sdk.internal.api.model.SessionInfo import com.monext.sdk.internal.data.LocalSessionStateRepo import com.monext.sdk.internal.data.sessionstate.PaymentMethodsList +import com.monext.sdk.internal.ext.findActivity import com.monext.sdk.internal.presentation.PaymentContainer import com.monext.sdk.internal.presentation.SessionStateViewModel import com.monext.sdk.internal.presentation.common.HeaderSection import com.monext.sdk.internal.presentation.common.PaymentMethodsScreen import com.monext.sdk.internal.presentation.common.PaymentOverlay import com.monext.sdk.internal.preview.PreviewWrapper +import com.monext.sdk.internal.util.SecureWindowManager import kotlinx.coroutines.launch /** @@ -63,6 +70,21 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo val context = LocalContext.current + val isSecureEnvironment = sdkContext.environment in listOf(MnxtEnvironment.Sandbox, MnxtEnvironment.Production) + + // Protège la fenêtre de l'Activity hôte (pour le recents/app switcher) + DisposableEffect(Unit) { + val activity = context.findActivity() + if (isSecureEnvironment) { + activity?.let { SecureWindowManager.acquire(it) } + } + onDispose { + if (isSecureEnvironment) { + activity?.let { SecureWindowManager.release(it) } + } + } + } + val viewModel: SessionStateViewModel = viewModel( factory = SessionStateViewModel.Factory, extras = MutableCreationExtras().apply { @@ -112,6 +134,22 @@ fun PaymentSheet(isShowing: Boolean, sessionToken: String, sdkContext: MnxtSDKCo containerColor = sdkContext.appearance.backgroundColor, dragHandle = {} ) { + val sheetView = LocalView.current + DisposableEffect(Unit) { + val window = (sheetView.parent as? DialogWindowProvider)?.window + if (isSecureEnvironment) { + window?.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } + onDispose { + if (isSecureEnvironment) { + window?.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + } + } + } + LaunchedEffect(sdkContext) { viewModel.updateContext(InternalSDKContext(sdkContext)) diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/util/Context+Activity.kt b/monext/src/main/kotlin/com/monext/sdk/internal/util/Context+Activity.kt new file mode 100644 index 0000000..26bbdf9 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/util/Context+Activity.kt @@ -0,0 +1,14 @@ +package com.monext.sdk.internal.ext + +import android.app.Activity +import android.content.Context +import android.content.ContextWrapper + +internal fun Context.findActivity(): Activity? { + var context = this + while (context is ContextWrapper) { + if (context is Activity) return context + context = context.baseContext + } + return null +} \ No newline at end of file diff --git a/monext/src/main/kotlin/com/monext/sdk/internal/util/SecureWindowManager.kt b/monext/src/main/kotlin/com/monext/sdk/internal/util/SecureWindowManager.kt new file mode 100644 index 0000000..03465b8 --- /dev/null +++ b/monext/src/main/kotlin/com/monext/sdk/internal/util/SecureWindowManager.kt @@ -0,0 +1,57 @@ +package com.monext.sdk.internal.util + +import android.app.Activity +import android.view.WindowManager + +/** + * Gestionnaire centralisé de la protection d'écran via [WindowManager.LayoutParams.FLAG_SECURE]. + * + * Ce singleton maintient un compteur de références par [Activity] afin d'appliquer + * [FLAG_SECURE][WindowManager.LayoutParams.FLAG_SECURE] uniquement lorsqu'au moins un + * consommateur est actif, et de le retirer proprement dès que tous les consommateurs + * ont libéré leur référence. + * + * Ce mécanisme est nécessaire lorsque plusieurs composants (ex: plusieurs champs de + * saisie sensibles) peuvent demander simultanément la protection d'écran sur la même + * [Activity] hôte. Sans comptage de références, le premier composant démonté retirerait + * le flag même si d'autres composants sensibles restent visibles à l'écran. + * + * **Utilisation typique :** + * ```kotlin + * // Lors de l'affichage d'un écran contenant des données sensibles + * SecureWindowManager.acquire(activity) + * + * // Lors de la fermeture ou du démontage du composant + * SecureWindowManager.release(activity) + * ``` + * + * Toutes les méthodes sont thread-safe via [@Synchronized][Synchronized]. + * + * @see WindowManager.LayoutParams.FLAG_SECURE + */ +internal object SecureWindowManager { + private val activeCount = mutableMapOf() + + @Synchronized + fun acquire(activity: Activity) { + val count = activeCount.getOrDefault(activity, 0) + if (count == 0) { + activity.window.setFlags( + WindowManager.LayoutParams.FLAG_SECURE, + WindowManager.LayoutParams.FLAG_SECURE + ) + } + activeCount[activity] = count + 1 + } + + @Synchronized + fun release(activity: Activity) { + val count = activeCount.getOrDefault(activity, 1) - 1 + if (count <= 0) { + activity.window.clearFlags(WindowManager.LayoutParams.FLAG_SECURE) + activeCount.remove(activity) + } else { + activeCount[activity] = count + } + } +} \ No newline at end of file