Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 1 addition & 1 deletion gradle/libs.versions.toml
Original file line number Diff line number Diff line change
Expand Up @@ -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"
Expand Down
68 changes: 68 additions & 0 deletions monext/src/androidTest/kotlin/com/monext/sdk/PaymentSheetTest.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand Down Expand Up @@ -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
)
}
}
38 changes: 38 additions & 0 deletions monext/src/main/kotlin/com/monext/sdk/PaymentSheet.kt
Original file line number Diff line number Diff line change
@@ -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
Expand All @@ -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
Expand All @@ -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
Expand All @@ -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

/**
Expand All @@ -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 {
Expand Down Expand Up @@ -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))
Expand Down
Original file line number Diff line number Diff line change
@@ -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
}
Original file line number Diff line number Diff line change
@@ -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 {
Comment thread
lucasbianciotto marked this conversation as resolved.
private val activeCount = mutableMapOf<Activity, Int>()

@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
Comment thread
ThomasGenty marked this conversation as resolved.
)
}
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
}
}
}
Loading