Skip to content
Open
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
5 changes: 3 additions & 2 deletions app/build.gradle.kts
Original file line number Diff line number Diff line change
@@ -1,4 +1,3 @@

plugins {
alias(libs.plugins.android.application)
alias(libs.plugins.kotlin.android)
Expand Down Expand Up @@ -84,7 +83,10 @@ android {
}
}

@Suppress("UNCHECKED_CAST")
fun getVersionName(): String = (project.extra["getVersionName"] as () -> String)()

@Suppress("UNCHECKED_CAST")
fun getVersionCode(): Int = (project.extra["getVersionCode"] as () -> Int)()

dependencies {
Expand Down Expand Up @@ -114,7 +116,6 @@ dependencies {

androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.ui.test.junit4.android)
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.ui.test.junit4)
debugImplementation(libs.androidx.ui.tooling)
Expand Down
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.6.1"
espressoCore = "3.7.0"
firebaseBom = "34.4.0"
google-services-plugin = "4.4.4"
io-mockk = "1.14.6"
Expand Down
19 changes: 15 additions & 4 deletions monext/build.gradle.kts
Original file line number Diff line number Diff line change
Expand Up @@ -133,7 +133,14 @@ android {
}
}

// Fonction pour récupérer la clé API de manière sécurisée
// Force la résolution d'espresso-core en 3.7.0 même si une lib transitive
// tente de le tirer en 3.6.x (incompatible avec Android 15+).
configurations.all {
resolutionStrategy {
force("androidx.test.espresso:espresso-core:3.7.0")
}
}

fun getApiKey(): String {
// 1. Variable d'environnement (CI/CD)
System.getenv("THREEDS_API_ACCESS_KEY")?.let {
Expand Down Expand Up @@ -469,18 +476,22 @@ dependencies {
// Voir la documentation : https://3dss.netcetera.com/3dssdk/doc/2.25.0/android-integration
implementation(files("libs/netcetera-3ds-sdk-2.5.3.2-classes.jar"))

// Tests
// Tests JVM
testImplementation(libs.junit.jupiter)
testImplementation(kotlin("test"))
testImplementation(libs.io.mockk)
testImplementation(libs.jetbrains.kotlinx.test)
testImplementation(libs.slf4j.api)

debugImplementation(libs.ui.tooling)
// Tooling Compose (Preview en debug)
debugImplementation(libs.androidx.ui.tooling)
debugImplementation(libs.androidx.ui.test.manifest)

// Tests instrumentés
androidTestImplementation(platform(libs.androidx.compose.bom))
androidTestImplementation(libs.androidx.junit)
androidTestImplementation(libs.androidx.espresso.core)
androidTestImplementation(libs.androidx.ui.test.junit4.android)
androidTestImplementation(libs.androidx.ui.test.junit4)
androidTestImplementation(libs.io.mockk)
androidTestImplementation(libs.io.mockk.agent)
androidTestImplementation(libs.io.mockk.android)
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,50 @@
package com.monext.sdk.internal.security

import androidx.test.ext.junit.runners.AndroidJUnit4
import androidx.test.platform.app.InstrumentationRegistry
import com.monext.sdk.internal.security.check.BinaryCheck
import com.monext.sdk.internal.security.check.BuildCheck
import com.monext.sdk.internal.security.check.DebuggerCheck
import com.monext.sdk.internal.security.check.FridaCheck
import com.monext.sdk.internal.security.check.PackageCheck
import com.monext.sdk.internal.security.check.ProcessCheck
import junit.framework.TestCase.assertFalse
import junit.framework.TestCase.assertTrue
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class RootDetectionRealDeviceTest {

private val context = InstrumentationRegistry.getInstrumentation().targetContext

@Test
fun buildCheck_shouldTriggerOnEmulator() {
assertTrue(BuildCheck().check())
}

@Test
fun binaryCheck_shouldNotTriggerOnCleanEmulator() {
assertTrue(BinaryCheck().check())
}

@Test
fun packageCheck_shouldNotTriggerOnCleanEmulator() {
assertFalse(PackageCheck(context.packageManager).check())
}

@Test
fun processCheck_shouldNotTriggerOnCleanEmulator() {
assertFalse(ProcessCheck(context).check())
}

@Test
fun fridaCheck_shouldNotTriggerOnCleanEmulator() {
assertFalse(FridaCheck().check())
}

@Test
fun debuggerCheck_jdwpOnly_shouldNotTrigger() {
assertFalse(DebuggerCheck().detectJavaDebugger())
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,86 @@
package com.monext.sdk.internal.security.ui

import android.os.StrictMode
import androidx.activity.compose.LocalActivity
import androidx.compose.runtime.CompositionLocalProvider
import androidx.compose.ui.test.assertIsDisplayed
import androidx.compose.ui.test.junit4.createAndroidComposeRule
import androidx.compose.ui.test.onNodeWithText
import androidx.compose.ui.test.performClick
import androidx.test.ext.junit.runners.AndroidJUnit4
import com.monext.sdk.FakeTestActivity
import io.mockk.MockKAnnotations
import org.junit.Assert.assertTrue
import org.junit.Before
import org.junit.Rule
import org.junit.Test
import org.junit.runner.RunWith

@RunWith(AndroidJUnit4::class)
class SecurityAlertDialogTest {

@get:Rule
val composeTestRule = createAndroidComposeRule<FakeTestActivity>()

@Before
fun setup() {
MockKAnnotations.init(this)
StrictMode.setThreadPolicy(StrictMode.ThreadPolicy.LAX)
StrictMode.setVmPolicy(StrictMode.VmPolicy.LAX)
}

private fun setDialog(onDismiss: () -> Unit) {
composeTestRule.activity.setTestComposable {
CompositionLocalProvider(
LocalActivity provides composeTestRule.activity
) {
SecurityAlertDialog(onDismiss = onDismiss)
}
}
composeTestRule.waitForIdle()
}

@Test
fun securityDialog_whenShowing_displaysTitle() {
setDialog(onDismiss = {})

composeTestRule
.onNodeWithText("Appareil non sécurisé")
.assertIsDisplayed()
}

@Test
fun securityDialog_whenShowing_displaysMessage() {
setDialog(onDismiss = {})

composeTestRule
.onNodeWithText(
"Votre appareil ne répond pas aux exigences de sécurité " +
"requises pour effectuer un paiement."
)
.assertIsDisplayed()
}

@Test
fun securityDialog_whenShowing_displaysCloseButton() {
setDialog(onDismiss = {})

composeTestRule
.onNodeWithText("Fermer")
.assertIsDisplayed()
}

@Test
fun securityDialog_whenCloseButtonClicked_callsOnDismiss() {
var dismissed = false

setDialog(onDismiss = { dismissed = true })

composeTestRule
.onNodeWithText("Fermer")
.performClick()

composeTestRule.waitUntil(5000) { dismissed }
assertTrue(dismissed)
}
}
16 changes: 16 additions & 0 deletions monext/src/main/AndroidManifest.xml
Original file line number Diff line number Diff line change
Expand Up @@ -8,5 +8,21 @@
android:name=".internal.service.PaymentForegroundService"
android:foregroundServiceType="dataSync"
android:exported="false" />

<!--
ContentProvider auto-init de RootDetector.

Fusionné automatiquement dans le manifest de l'app intégratrice.
L'authority utilise ${applicationId} pour garantir l'unicité
entre plusieurs apps qui intègrent le SDK.

android:exported="false" → jamais accessible depuis l'extérieur.
android:multiprocess="false" → instancié une seule fois dans le process principal.
-->
<provider
android:name="com.monext.sdk.internal.security.RootDetectorInitializer"
android:authorities="${applicationId}.monext.security.init"
android:exported="false"
android:multiprocess="false" />
</application>
</manifest>
16 changes: 15 additions & 1 deletion monext/src/main/kotlin/com/monext/sdk/PaymentBox.kt
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,8 @@ import androidx.compose.runtime.saveable.rememberSaveable
import androidx.compose.runtime.setValue
import androidx.compose.ui.Modifier
import androidx.compose.ui.tooling.preview.Preview
import com.monext.sdk.internal.security.RootDetector
import com.monext.sdk.internal.security.ui.SecurityAlertDialog

/**
* This composable handles payment sheet presentation upon user interaction.
Expand All @@ -28,10 +30,15 @@ fun PaymentBox(
) {

var showPaymentSheet by rememberSaveable { mutableStateOf(false) }
var showSecurityAlert by rememberSaveable { mutableStateOf(false) }

val onClick: () -> Unit = {
if (sessionToken != null) {
showPaymentSheet = true
if (RootDetector.isCompromised()) {
showSecurityAlert = true
} else {
showPaymentSheet = true
}
}
}

Expand All @@ -44,6 +51,13 @@ fun PaymentBox(
sdkContext = sdkContext,
onResult = onResult,
onIsShowingChange = { showPaymentSheet = it })


if (showSecurityAlert) {
SecurityAlertDialog(
onDismiss = { showSecurityAlert = false }
)
}
}
}

Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,100 @@
package com.monext.sdk.internal.security

import android.content.Context
import android.util.Log

/**
* Point d'entrée public pour la détection de root du SDK Monext.
*
* Cette classe est un singleton initialisé automatiquement au démarrage
* de l'application via [RootDetectorInitializer] (ContentProvider).
* Aucun appel manuel à [init] n'est nécessaire côté application intégratrice.
*
* ### Usage
* ```kotlin
* if (RootDetector.isCompromised()) {
* // Bloquer l'action sensible
* }
* ```
*
* ### Cycle de vie
* - **Init** : déclenché automatiquement par [RootDetectorInitializer] au boot de l'app.
* - **Résultat** : mis en cache, recalculé toutes les [CACHE_TTL_MS] millisecondes.
* - **Thread-safety** : le champ [compromised] est marqué `@Volatile`.
*/
object RootDetector {

private const val TAG = "RootDetector"

/**
* Durée de validité du cache en millisecondes (30 secondes).
* Évite de relancer tous les checks à chaque appel.
*/
private const val CACHE_TTL_MS = 30_000L

@Volatile private var initialized = false
@Volatile private var compromised = false
@Volatile private var lastCheckTime = 0L

private var detectorImpl: RootDetectorImpl? = null

/**
* Initialise le détecteur avec le contexte de l'application.
*
* Appelé automatiquement par [RootDetectorInitializer].
* Un second appel est ignoré (idempotent).
*
* @param context Le contexte de l'application (ApplicationContext).
*/
internal fun init(context: Context) {
if (initialized) return

detectorImpl = RootDetectorImpl(context.applicationContext)
refreshCheck()
initialized = true

Log.d(TAG, "RootDetector initialized — compromised=$compromised")
}

/**
* Indique si l'appareil est considéré comme compromis.
*
* Le résultat est mis en cache pendant [CACHE_TTL_MS] ms.
* Si [init] n'a pas encore été appelé, retourne `false` par défaut (fail-open).
*
* @return `true` si un indicateur de root ou de compromission a été détecté.
*/
fun isCompromised(): Boolean {
if (!initialized) {
Log.w(TAG, "isCompromised() called before init()")
return false
}

val now = System.currentTimeMillis()
if (now - lastCheckTime > CACHE_TTL_MS) {
refreshCheck()
}

return compromised
}

/**
* Force un recalcul immédiat de l'état de sécurité, en ignorant le cache.
*
* À utiliser avec précaution car les checks natifs peuvent être coûteux.
*
* @return `true` si l'appareil est compromis après recalcul.
*/
fun forceRefresh(): Boolean {
refreshCheck()
return compromised
}

/**
* Recalcule l'état de sécurité et met à jour le cache.
*/
private fun refreshCheck() {
compromised = detectorImpl?.isDeviceCompromised() ?: false
lastCheckTime = System.currentTimeMillis()
}
}
Loading
Loading