diff --git a/library/src/androidTest/java/com/paypal/messages/ModalExternalLinkTest.kt b/library/src/androidTest/java/com/paypal/messages/ModalExternalLinkTest.kt index a245b8eb..65d2e62f 100644 --- a/library/src/androidTest/java/com/paypal/messages/ModalExternalLinkTest.kt +++ b/library/src/androidTest/java/com/paypal/messages/ModalExternalLinkTest.kt @@ -37,7 +37,7 @@ class ModalExternalLinkTest { val webView = WebView(recordingContext) // Initialize the modal WebView configuration - val fragment = ModalFragment(clientId = "test-client-id") + val fragment = ModalFragment.newInstance(clientId = "test-client-id") fragment.setupWebView(webView) // Load a minimal page that triggers a target=_blank navigation diff --git a/library/src/androidTest/java/com/paypal/messages/ModalFragmentLifecycleTest.kt b/library/src/androidTest/java/com/paypal/messages/ModalFragmentLifecycleTest.kt new file mode 100644 index 00000000..a2cfba07 --- /dev/null +++ b/library/src/androidTest/java/com/paypal/messages/ModalFragmentLifecycleTest.kt @@ -0,0 +1,130 @@ +package com.paypal.messages + +import android.os.Bundle +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.paypal.messages.config.PayPalMessageOfferType +import com.paypal.messages.config.modal.ModalCloseButton +import com.paypal.messages.config.modal.ModalConfig +import com.paypal.messages.config.modal.ModalEvents +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Test +import org.junit.runner.RunWith + +/** + * Tests to verify that ModalFragment properly handles Android lifecycle events, + * including the "Don't Keep Activities" developer option scenario where fragments + * are destroyed and recreated when the app is backgrounded. + * + * This test addresses the crash reported in: + * androidx.fragment.app.Fragment$InstantiationException: Unable to instantiate fragment + * com.paypal.messages.ModalFragment: could not find Fragment constructor + */ +@RunWith(AndroidJUnit4::class) +class ModalFragmentLifecycleTest { + + @Test + fun testFragmentRecreationWithArguments() { + // Create fragment with proper arguments using factory method + val fragment = ModalFragment.newInstance("test-client-id") + + // Verify arguments were set + assertNotNull("Fragment arguments should not be null", fragment.arguments) + assertEquals( + "test-client-id", + fragment.arguments?.getString("client_id"), + ) + } + + @Test + fun testFragmentSurvivesConfigurationChange() { + // Launch fragment in a container + val scenario: FragmentScenario = launchFragmentInContainer( + fragmentArgs = Bundle().apply { + putString("client_id", "test-lifecycle-client") + }, + ) + + // Simulate configuration change (like screen rotation or backgrounding) + scenario.recreate() + + // Verify fragment was recreated successfully + scenario.onFragment { fragment -> + assertNotNull("Fragment should exist after recreation", fragment) + // The fragment should have been recreated using the no-arg constructor + // and arguments should have been restored from the Bundle + } + } + + @Test + fun testFragmentInitializationWithConfig() { + // Create fragment using factory method + val fragment = ModalFragment.newInstance("test-config-client") + + // Initialize with modal configuration + val modalConfig = ModalConfig( + amount = 299.99, + buyerCountry = "US", + offer = PayPalMessageOfferType.PAY_LATER_LONG_TERM, + ignoreCache = false, + devTouchpoint = false, + stageTag = null, + events = ModalEvents( + onClick = {}, + onLoading = {}, + onSuccess = {}, + onError = {}, + onCalculate = {}, + onShow = {}, + onClose = {}, + onApply = {}, + ), + modalCloseButton = ModalCloseButton( + width = 24, + height = 24, + availableWidth = 24, + availableHeight = 24, + color = "#000000", + colorType = "dark", + alternativeText = "Close", + ), + ) + + fragment.init(modalConfig) + + // Verify configuration was applied + assertEquals(299.99, fragment.amount ?: 0.0, 0.01) + assertEquals("US", fragment.buyerCountry) + assertEquals(PayPalMessageOfferType.PAY_LATER_LONG_TERM, fragment.offerType) + } + + @Test + fun testFragmentDoesNotCrashWhenRecreatedBySystem() { + // This test simulates the Android system recreating the fragment + // after process death (like with "Don't Keep Activities" option) + + val scenario: FragmentScenario = launchFragmentInContainer( + fragmentArgs = Bundle().apply { + putString("client_id", "process-death-test-client") + }, + ) + + // Move through lifecycle states + scenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED) + scenario.moveToState(androidx.lifecycle.Lifecycle.State.RESUMED) + + // Simulate backgrounding (what happens when user presses home) + scenario.moveToState(androidx.lifecycle.Lifecycle.State.STARTED) + scenario.moveToState(androidx.lifecycle.Lifecycle.State.CREATED) + + // Simulate recreation (what happens when user returns to app) + scenario.recreate() + + // Verify fragment survived the recreation + scenario.onFragment { fragment -> + assertNotNull("Fragment should be recreated successfully", fragment) + } + } +} diff --git a/library/src/androidTest/java/com/paypal/messages/PayPalMessageViewTest.kt b/library/src/androidTest/java/com/paypal/messages/PayPalMessageViewTest.kt index f4718d7d..91b1b6b2 100644 --- a/library/src/androidTest/java/com/paypal/messages/PayPalMessageViewTest.kt +++ b/library/src/androidTest/java/com/paypal/messages/PayPalMessageViewTest.kt @@ -113,7 +113,7 @@ class PayPalMessageViewTest { // fun dismissAfterFragmentDetached_shouldThrow() { // val scenario: ActivityScenario = ActivityScenario.launch(TestActivity::class.java) // scenario.onActivity { activity: TestActivity -> -// val fragment = ModalFragment("test_client_id") +// val fragment = ModalFragment.newInstance("test_client_id") // fragment.show(activity.supportFragmentManager, "test") // // // Remove the fragment to simulate detachment diff --git a/library/src/androidTest/java/com/paypal/messages/lifecycle/LowTechDeviceLifecycleTest.kt b/library/src/androidTest/java/com/paypal/messages/lifecycle/LowTechDeviceLifecycleTest.kt new file mode 100644 index 00000000..a412f013 --- /dev/null +++ b/library/src/androidTest/java/com/paypal/messages/lifecycle/LowTechDeviceLifecycleTest.kt @@ -0,0 +1,458 @@ +package com.paypal.messages.lifecycle + +import android.content.Context +import android.os.Bundle +import androidx.fragment.app.testing.FragmentScenario +import androidx.fragment.app.testing.launchFragmentInContainer +import androidx.lifecycle.Lifecycle +import androidx.test.core.app.ApplicationProvider +import androidx.test.ext.junit.runners.AndroidJUnit4 +import com.paypal.messages.ModalFragment +import com.paypal.messages.PayPalMessageView +import com.paypal.messages.config.PayPalEnvironment +import com.paypal.messages.config.PayPalMessageOfferType +import com.paypal.messages.config.message.PayPalMessageConfig +import com.paypal.messages.config.message.PayPalMessageData +import com.paypal.messages.config.message.PayPalMessageStyle +import kotlinx.coroutines.delay +import kotlinx.coroutines.runBlocking +import org.junit.Assert.assertEquals +import org.junit.Assert.assertNotNull +import org.junit.Before +import org.junit.Test +import org.junit.runner.RunWith +import kotlin.system.measureTimeMillis + +/** + * Comprehensive lifecycle tests for low-tech devices and "Don't Keep Activities" scenarios. + * + * This test suite specifically addresses: + * 1. WebView state handling during activity recreation + * 2. Network request behavior during activity destruction + * 3. Memory leak detection in rapid lifecycle scenarios + * 4. State persistence across configuration changes + * 5. Performance on resource-constrained devices + * + * These tests ensure the SDK works reliably on budget Android devices globally, + * supporting the affordability and adaptability pillars. + */ +@RunWith(AndroidJUnit4::class) +class LowTechDeviceLifecycleTest { + + private lateinit var context: Context + private lateinit var testConfig: PayPalMessageConfig + + @Before + fun setup() { + context = ApplicationProvider.getApplicationContext() + testConfig = PayPalMessageConfig( + data = PayPalMessageData( + clientID = "low-tech-test-client", + ).apply { + amount = 99.99 + buyerCountry = "US" + environment = PayPalEnvironment.SANDBOX + }, + style = PayPalMessageStyle(), + ) + } + + /** + * Test WebView state handling during rapid activity recreation. + * Simulates "Don't Keep Activities" with modal WebView open. + */ + @Test + fun testWebViewStateHandlingDuringRecreation() { + var scenario: FragmentScenario? = null + var fragmentRecreationTime = 0L + + try { + // Create modal fragment with WebView + val fragmentArgs = Bundle().apply { + putString("client_id", "webview-test-client") + } + + // Launch fragment (this would load a WebView in real scenario) + val launchTime = measureTimeMillis { + scenario = launchFragmentInContainer( + fragmentArgs = fragmentArgs, + themeResId = androidx.appcompat.R.style.Theme_AppCompat, + ) + } + + // Simulate user backgrounding app (fragment moves to STARTED) + scenario?.moveToState(Lifecycle.State.STARTED) + + // Simulate "Don't Keep Activities" destroying the fragment + scenario?.moveToState(Lifecycle.State.CREATED) + + // Recreate the fragment (as would happen when user returns) + fragmentRecreationTime = measureTimeMillis { + scenario?.recreate() + } + + // Verify fragment was recreated successfully + scenario?.onFragment { fragment -> + assertNotNull("Fragment should exist after recreation", fragment) + assertNotNull("Fragment arguments should be preserved", fragment.arguments) + assertEquals( + "webview-test-client", + fragment.arguments?.getString("client_id"), + ) + } + + // WebView recreation should be reasonably fast even on budget devices + assert(fragmentRecreationTime < 1000) { + "Fragment with WebView recreation took ${fragmentRecreationTime}ms, too slow for budget devices" + } + + assert(launchTime < 500) { + "Initial fragment launch took ${launchTime}ms, too slow for low-tech devices" + } + } finally { + scenario?.close() + } + } + + /** + * Test multiple rapid WebView lifecycle events. + * Simulates user rapidly switching between apps with "Don't Keep Activities" enabled. + */ + @Test + fun testRapidWebViewLifecycleEvents() = runBlocking { + val scenarios = mutableListOf>() + val cycleTimes = mutableListOf() + + try { + // Simulate 5 rapid open/close cycles + repeat(5) { cycle -> + val cycleTime = measureTimeMillis { + // Create modal fragment + val scenario = launchFragmentInContainer( + fragmentArgs = Bundle().apply { + putString("client_id", "rapid-webview-$cycle") + }, + themeResId = androidx.appcompat.R.style.Theme_AppCompat, + ) + + // Move through lifecycle states rapidly + scenario.moveToState(Lifecycle.State.STARTED) + scenario.moveToState(Lifecycle.State.RESUMED) + + // Brief delay simulating user interaction + delay(50) + + // Background and destroy (simulating "Don't Keep Activities") + scenario.moveToState(Lifecycle.State.STARTED) + scenario.moveToState(Lifecycle.State.CREATED) + + scenarios.add(scenario) + } + cycleTimes.add(cycleTime) + } + + val averageCycleTime = cycleTimes.average() + val maxCycleTime = cycleTimes.maxOrNull() ?: 0L + + println("WebView lifecycle cycle times: $cycleTimes") + println("Average cycle time: ${averageCycleTime}ms") + println("Max cycle time: ${maxCycleTime}ms") + + // Rapid WebView lifecycle events should not cause performance degradation + assert(averageCycleTime < 1000) { + "Average WebView lifecycle cycle took ${averageCycleTime}ms, too slow for low-tech devices" + } + + assert(maxCycleTime < 1500) { + "Maximum WebView lifecycle cycle took ${maxCycleTime}ms, indicating memory pressure" + } + + // Check for performance degradation over cycles + val firstHalfAverage = cycleTimes.take(cycleTimes.size / 2).average() + val secondHalfAverage = cycleTimes.takeLast(cycleTimes.size / 2).average() + val degradationRatio = secondHalfAverage / firstHalfAverage + + assert(degradationRatio < 1.8) { + "WebView performance degraded by ${(degradationRatio - 1) * 100}% over cycles, indicating leaks" + } + } finally { + // Clean up all scenarios + scenarios.forEach { it.close() } + } + } + + /** + * Test network request handling during activity destruction. + * Ensures in-flight requests don't cause crashes or leaks. + */ + @Test + fun testNetworkRequestDuringActivityDestruction() = runBlocking { + val messageViews = mutableListOf() + + // Create multiple message views that would trigger network requests + repeat(3) { index -> + val view = PayPalMessageView(context) + view.setConfig( + testConfig.clone().apply { + data.clientID = "network-test-$index" + data.amount = (index * 100).toDouble() + 0.99 + }, + ) + messageViews.add(view) + + // Small delay to allow potential network request initiation + delay(100) + } + + // Immediately destroy all views (simulating "Don't Keep Activities") + val destructionTime = measureTimeMillis { + messageViews.forEach { view -> + // Setting empty config simulates cleanup + view.setConfig(PayPalMessageConfig(PayPalMessageData(clientID = ""))) + } + } + + println("Network cleanup during destruction took: ${destructionTime}ms") + + // Destruction should complete quickly despite potential in-flight requests + assert(destructionTime < 200) { + "Destruction with potential network requests took ${destructionTime}ms, too slow" + } + + // Allow time for any background operations to complete + delay(500) + + // Create new view to ensure system is still functional + val recoveryView = PayPalMessageView(context) + recoveryView.setConfig(testConfig) + + assertNotNull("Should be able to create new views after network cleanup", recoveryView.getConfig()) + } + + /** + * Test that rapid network request cycles don't cause memory leaks. + */ + @Test + fun testNetworkRequestMemoryLeakPrevention() = runBlocking { + val requestCycles = 10 + val cycleTimes = mutableListOf() + + repeat(requestCycles) { cycle -> + val cycleTime = measureTimeMillis { + // Create view that would initiate network request + val view = PayPalMessageView(context) + view.setConfig( + testConfig.clone().apply { + data.clientID = "memory-leak-test-$cycle" + data.amount = (cycle * 25).toDouble() + 0.99 + }, + ) + + // Brief delay for potential request initiation + delay(50) + + // Immediate cleanup (simulating rapid activity destruction) + view.setConfig(PayPalMessageConfig(PayPalMessageData(clientID = ""))) + } + cycleTimes.add(cycleTime) + } + + val averageCycleTime = cycleTimes.average() + val firstThirdAverage = cycleTimes.take(requestCycles / 3).average() + val lastThirdAverage = cycleTimes.takeLast(requestCycles / 3).average() + val degradationRatio = lastThirdAverage / firstThirdAverage + + println("Network request cycle times: $cycleTimes") + println("First third average: ${firstThirdAverage}ms") + println("Last third average: ${lastThirdAverage}ms") + println("Degradation ratio: $degradationRatio") + + // Performance should remain consistent (no significant degradation) + assert(degradationRatio < 1.5) { + "Network request performance degraded by ${(degradationRatio - 1) * 100}%, indicating memory leaks" + } + + assert(averageCycleTime < 200) { + "Average network request cycle took ${averageCycleTime}ms, too slow for budget devices" + } + } + + /** + * Test state persistence across configuration changes with complex data. + */ + @Test + fun testCompleteStatePersistence() { + // Test that all configuration aspects are preserved during recreation + val complexConfig = PayPalMessageConfig( + data = PayPalMessageData( + clientID = "state-test-client", + merchantID = "merchant-123", + partnerAttributionID = "partner-456", + ).apply { + amount = 549.99 + buyerCountry = "CA" + offerType = PayPalMessageOfferType.PAY_LATER_LONG_TERM + environment = PayPalEnvironment.SANDBOX + }, + style = PayPalMessageStyle(), + ) + + val originalView = PayPalMessageView(context) + originalView.setConfig(complexConfig) + + // Save state (as would happen in onSaveInstanceState) + val savedConfig = originalView.getConfig() + + // Simulate destruction by clearing view reference + // (in real scenario, view would be destroyed) + + // Recreate view with saved state + val recreatedView = PayPalMessageView(context) + val restorationTime = measureTimeMillis { + recreatedView.setConfig(savedConfig) + } + + // Verify all state was preserved + val restoredConfig = recreatedView.getConfig() + assertEquals("Client ID not preserved", "state-test-client", restoredConfig.data.clientID) + assertEquals("Merchant ID not preserved", "merchant-123", restoredConfig.data.merchantID) + assertEquals("Partner ID not preserved", "partner-456", restoredConfig.data.partnerAttributionID) + assertEquals("Amount not preserved", 549.99, restoredConfig.data.amount ?: 0.0, 0.01) + assertEquals("Country not preserved", "CA", restoredConfig.data.buyerCountry) + assertEquals("Offer type not preserved", PayPalMessageOfferType.PAY_LATER_LONG_TERM, restoredConfig.data.offerType) + + // Restoration should be fast + assert(restorationTime < 100) { + "Complex state restoration took ${restorationTime}ms, too slow for smooth UX" + } + } + + /** + * Test extreme memory pressure scenario with multiple components. + */ + @Test + fun testExtremeMemoryPressureWithMultipleComponents() = runBlocking { + val messageViews = mutableListOf() + val fragmentScenarios = mutableListOf>() + + try { + // Create multiple message views and modal fragments (simulating complex app) + val creationTime = measureTimeMillis { + // Create 5 message views + repeat(5) { index -> + val view = PayPalMessageView(context) + view.setConfig( + testConfig.clone().apply { + data.clientID = "pressure-view-$index" + data.amount = (index * 50).toDouble() + 0.99 + }, + ) + messageViews.add(view) + } + + // Create 3 modal fragments + repeat(3) { index -> + val scenario = launchFragmentInContainer( + fragmentArgs = Bundle().apply { + putString("client_id", "pressure-modal-$index") + }, + themeResId = androidx.appcompat.R.style.Theme_AppCompat, + ) + fragmentScenarios.add(scenario) + } + } + + println("Multiple component creation took: ${creationTime}ms") + + // Simulate system onLowMemory() - aggressive cleanup required + val cleanupTime = measureTimeMillis { + // Cleanup message views + messageViews.forEach { view -> + view.setConfig(PayPalMessageConfig(PayPalMessageData(clientID = ""))) + } + + // Destroy fragments + fragmentScenarios.forEach { scenario -> + scenario.moveToState(Lifecycle.State.DESTROYED) + } + } + + println("Low memory cleanup took: ${cleanupTime}ms") + + // Cleanup should be very fast even with multiple components + assert(cleanupTime < 500) { + "Low memory cleanup took ${cleanupTime}ms, too slow for system onLowMemory()" + } + + assert(creationTime < 2000) { + "Multiple component creation took ${creationTime}ms, too slow for budget devices" + } + + // Test recovery after memory pressure + delay(100) + val recoveryView = PayPalMessageView(context) + recoveryView.setConfig(testConfig) + assertNotNull("Should recover after memory pressure", recoveryView.getConfig()) + } finally { + fragmentScenarios.forEach { it.close() } + } + } + + /** + * Test performance degradation detection over extended lifecycle cycles. + */ + @Test + fun testLongTermPerformanceDegradation() = runBlocking { + val longTermCycles = 25 + val cycleTimes = mutableListOf() + val messageView = PayPalMessageView(context) + + repeat(longTermCycles) { cycle -> + val cycleTime = measureTimeMillis { + // Configure + messageView.setConfig( + testConfig.clone().apply { + data.clientID = "long-term-$cycle" + data.amount = (cycle * 10).toDouble() + 0.99 + }, + ) + + // Simulate brief usage + delay(20) + + // Cleanup (simulating activity destruction) + messageView.setConfig(PayPalMessageConfig(PayPalMessageData(clientID = ""))) + } + cycleTimes.add(cycleTime) + } + + // Analyze performance over time + val segments = 5 + val segmentSize = longTermCycles / segments + val segmentAverages = (0 until segments).map { segment -> + val start = segment * segmentSize + val end = start + segmentSize + cycleTimes.subList(start, end).average() + } + + println("Performance over $longTermCycles cycles ($segments segments):") + segmentAverages.forEachIndexed { index, average -> + println(" Segment $index: ${average}ms") + } + + // Check for linear degradation + val firstSegmentAverage = segmentAverages.first() + val lastSegmentAverage = segmentAverages.last() + val totalDegradationRatio = lastSegmentAverage / firstSegmentAverage + + assert(totalDegradationRatio < 1.8) { + "Performance degraded by ${(totalDegradationRatio - 1) * 100}% over $longTermCycles cycles" + } + + // Overall performance should remain acceptable + val overallAverage = cycleTimes.average() + assert(overallAverage < 150) { + "Overall average cycle time ${overallAverage}ms too slow for long-term usage" + } + } +} diff --git a/library/src/main/java/com/paypal/messages/ModalFragment.kt b/library/src/main/java/com/paypal/messages/ModalFragment.kt index 95f4793a..e939b1d4 100644 --- a/library/src/main/java/com/paypal/messages/ModalFragment.kt +++ b/library/src/main/java/com/paypal/messages/ModalFragment.kt @@ -50,13 +50,28 @@ import java.util.UUID import kotlin.system.measureTimeMillis import com.paypal.messages.config.PayPalMessageOfferType as OfferType -internal class ModalFragment( - private val clientId: String, -) : BottomSheetDialogFragment() { +internal class ModalFragment() : BottomSheetDialogFragment() { + companion object { + private const val ARG_CLIENT_ID = "client_id" + + /** + * Factory method to create a new instance of ModalFragment with the required parameters. + * This ensures proper fragment recreation after configuration changes. + */ + fun newInstance(clientId: String): ModalFragment { + return ModalFragment().apply { + arguments = Bundle().apply { + putString(ARG_CLIENT_ID, clientId) + } + } + } + } + private val TAG = "PayPalMessageModal" private val offsetTop = 50.dp private val gson = GsonBuilder().setPrettyPrinting().create() + private var clientId: String = "" private var modalUrl: String? = null // MODAL CONFIG VALUES @@ -92,7 +107,16 @@ internal class ModalFragment( private var dialog: BottomSheetDialog? = null private var closeButtonData: ModalCloseButton? = null private var instanceId = UUID.randomUUID() - + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + // Retrieve clientId from arguments + clientId = arguments?.getString(ARG_CLIENT_ID) ?: "" + if (clientId.isEmpty()) { + LogCat.error(TAG, "ModalFragment created without clientId. Use ModalFragment.newInstance() factory method.") + } + } + /** * Sets up an external WebView with the modal content. * This method can be used by Compose UI to initialize a WebView. diff --git a/library/src/main/java/com/paypal/messages/PayPalComposableModal.kt b/library/src/main/java/com/paypal/messages/PayPalComposableModal.kt index 2c37be3d..64bcd003 100644 --- a/library/src/main/java/com/paypal/messages/PayPalComposableModal.kt +++ b/library/src/main/java/com/paypal/messages/PayPalComposableModal.kt @@ -59,7 +59,7 @@ fun PayPalComposableModal( var errorMessage by remember { mutableStateOf("") } // Create the ModalFragment instance for WebView setup - val modalFragment = remember { ModalFragment(clientId) } + val modalFragment = remember { ModalFragment.newInstance(clientId) } val offerEnum = offerType?.let { try { PayPalMessageOfferType.valueOf(it) diff --git a/library/src/main/java/com/paypal/messages/PayPalModalActivity.kt b/library/src/main/java/com/paypal/messages/PayPalModalActivity.kt index 490981f6..d6d0ecf0 100644 --- a/library/src/main/java/com/paypal/messages/PayPalModalActivity.kt +++ b/library/src/main/java/com/paypal/messages/PayPalModalActivity.kt @@ -368,7 +368,7 @@ class PayPalModalActivity : ComponentActivity() { } // Create and setup the modal fragment - val modalFragment = ModalFragment(clientId) + val modalFragment = ModalFragment.newInstance(clientId) val offerEnum = offerType?.let { try { com.paypal.messages.config.PayPalMessageOfferType.valueOf(it) diff --git a/library/src/main/java/com/paypal/messages/data/PayPalMessageDataProvider.kt b/library/src/main/java/com/paypal/messages/data/PayPalMessageDataProvider.kt index bd8ee53e..068083c5 100644 --- a/library/src/main/java/com/paypal/messages/data/PayPalMessageDataProvider.kt +++ b/library/src/main/java/com/paypal/messages/data/PayPalMessageDataProvider.kt @@ -311,7 +311,7 @@ class PayPalMessageDataProvider { // For AppCompatActivity contexts, use ModalFragment appCompatContext != null -> { val modal = modalInstances[instanceId] ?: run { - val newModal = ModalFragment(config.data.clientID) + val newModal = ModalFragment.newInstance(config.data.clientID) // Build modal config val modalConfig = ModalConfig( diff --git a/library/src/test/java/com/paypal/messages/performance/PayPalMessagePerformanceTest.kt b/library/src/test/java/com/paypal/messages/performance/PayPalMessagePerformanceTest.kt new file mode 100644 index 00000000..bcc960d3 --- /dev/null +++ b/library/src/test/java/com/paypal/messages/performance/PayPalMessagePerformanceTest.kt @@ -0,0 +1,556 @@ +package com.paypal.messages.performance + +import android.content.Context +import com.paypal.messages.PayPalMessageView +import com.paypal.messages.config.PayPalEnvironment +import com.paypal.messages.config.message.PayPalMessageConfig +import com.paypal.messages.config.message.PayPalMessageData +import com.paypal.messages.config.message.PayPalMessageStyle +import io.mockk.mockk +import org.junit.jupiter.api.BeforeEach +import org.junit.jupiter.api.DisplayName +import org.junit.jupiter.api.Test +import org.junit.jupiter.api.assertTimeout +import java.time.Duration +import kotlin.system.measureTimeMillis + +/** + * Performance tests for PayPal Messages Android SDK aligned with the three pillars: + * 1. Affordability - Tests performance on resource-constrained devices + * 2. Adaptability - Tests performance across different configurations + * 3. Innovation - Tests modern Android performance optimization features + * + * These tests ensure the SDK maintains acceptable performance standards + * that would be expected on budget Android devices globally. + */ +@DisplayName("PayPal Message Performance Tests") +class PayPalMessagePerformanceTest { + + private lateinit var mockContext: Context + private lateinit var baseConfig: PayPalMessageConfig + + @BeforeEach + fun setup() { + mockContext = mockk(relaxed = true) + + // Create a standard configuration for testing + baseConfig = PayPalMessageConfig( + data = PayPalMessageData( + clientID = "test-client-id", + ).apply { + amount = 99.99 + buyerCountry = "US" + environment = PayPalEnvironment.SANDBOX + }, + style = PayPalMessageStyle(), + ) + } + + @Test + @DisplayName("Affordability: Message view creation should complete within budget device constraints") + fun testMessageViewCreationPerformance() { + // Budget devices typically have 2-4GB RAM and slower CPUs + // Message view creation should complete within reasonable time limits + assertTimeout(Duration.ofMillis(500)) { + val creationTime = measureTimeMillis { + PayPalMessageView(mockContext, config = baseConfig) + } + + // Log performance for monitoring (would integrate with actual logging in production) + println("Message view creation time: ${creationTime}ms") + + // Ensure creation time is acceptable for budget devices + assert(creationTime < 300) { + "Message view creation took ${creationTime}ms, exceeding budget device threshold of 300ms" + } + } + } + + @Test + @DisplayName("Affordability: Multiple message configurations should not cause memory pressure") + fun testMultipleConfigurationPerformance() { + val messageView = PayPalMessageView(mockContext) + val configurationTimes = mutableListOf() + + // Test multiple configuration changes as might happen in recycler views + // This simulates real-world usage patterns in e-commerce apps + repeat(10) { iteration -> + val configTime = measureTimeMillis { + messageView.setConfig( + PayPalMessageConfig( + data = PayPalMessageData( + clientID = "test-client-id-$iteration", + ).apply { + amount = (iteration * 10).toDouble() + 0.99 + buyerCountry = "US" + environment = PayPalEnvironment.SANDBOX + }, + style = PayPalMessageStyle(), + ), + ) + } + configurationTimes.add(configTime) + } + + val averageTime = configurationTimes.average() + val maxTime = configurationTimes.maxOrNull() ?: 0L + + println("Average configuration time: ${averageTime}ms") + println("Maximum configuration time: ${maxTime}ms") + + // Configuration should remain fast even after multiple changes + assert(averageTime < 50) { + "Average configuration time ${averageTime}ms exceeds acceptable threshold for budget devices" + } + assert(maxTime < 100) { + "Maximum configuration time ${maxTime}ms indicates potential memory pressure" + } + } + + @Test + @DisplayName("Adaptability: Performance should be consistent across different amount ranges") + fun testAmountRangePerformanceConsistency() { + val messageView = PayPalMessageView(mockContext) + val testAmounts = listOf( + 0.01, // Micro-payment + 99.99, // Standard e-commerce + 999.99, // Higher value + 9999.99, // Enterprise + 99999.99, // Large transaction + ) + + val performanceTimes = testAmounts.map { amount -> + measureTimeMillis { + messageView.setConfig( + baseConfig.clone().apply { + data.amount = amount + }, + ) + } + } + + val variance = calculateVariance(performanceTimes) + val maxTime = performanceTimes.maxOrNull() ?: 0L + + println("Performance times across amounts: $performanceTimes") + println("Performance variance: $variance") + + // Performance should be consistent regardless of amount + assert(variance < 25.0) { + "Performance variance $variance indicates inconsistent behavior across amount ranges" + } + assert(maxTime < 100) { + "Maximum configuration time ${maxTime}ms with large amounts exceeds threshold" + } + } + + @Test + @DisplayName("Adaptability: Environment switching should maintain performance") + fun testEnvironmentSwitchingPerformance() { + val messageView = PayPalMessageView(mockContext) + val environments = listOf( + PayPalEnvironment.SANDBOX, + PayPalEnvironment.LIVE, + PayPalEnvironment.DEVELOP(), + ) + + val switchingTimes = environments.map { environment -> + measureTimeMillis { + messageView.setConfig( + baseConfig.clone().apply { + data.environment = environment + }, + ) + } + } + + val maxSwitchTime = switchingTimes.maxOrNull() ?: 0L + val averageSwitchTime = switchingTimes.average() + + println("Environment switching times: $switchingTimes") + println("Average switching time: ${averageSwitchTime}ms") + + // Environment switching should be fast and consistent + assert(maxSwitchTime < 75) { + "Environment switching took ${maxSwitchTime}ms, too slow for responsive UI" + } + assert(averageSwitchTime < 50) { + "Average environment switching time ${averageSwitchTime}ms exceeds budget device expectations" + } + } + + @Test + @DisplayName("Innovation: Memory usage should be optimized for modern Android lifecycle") + fun testMemoryOptimizationBehavior() { + // This test simulates the "Don't Keep Activities" developer option + // which immediately destroys activities when users leave them + val messageViews = mutableListOf() + + val creationTime = measureTimeMillis { + // Create multiple message views as would happen in rapid activity recreation + repeat(5) { iteration -> + val messageView = PayPalMessageView( + mockContext, + config = baseConfig.clone().apply { + data.clientID = "test-$iteration" + }, + ) + messageViews.add(messageView) + } + } + + val cleanupTime = measureTimeMillis { + // Simulate cleanup during activity destruction + messageViews.forEach { view -> + // In real implementation, this would trigger cleanup callbacks + view.setConfig(PayPalMessageConfig(PayPalMessageData(clientID = ""))) + } + messageViews.clear() + } + + println("Multiple view creation time: ${creationTime}ms") + println("Cleanup time: ${cleanupTime}ms") + + // Creation and cleanup should be efficient for activity lifecycle scenarios + assert(creationTime < 500) { + "Multiple view creation took ${creationTime}ms, indicating potential memory issues" + } + assert(cleanupTime < 100) { + "Cleanup took ${cleanupTime}ms, too slow for activity destruction scenarios" + } + } + + @Test + @DisplayName("Low-Tech: State persistence during rapid activity recreation") + fun testStatePersistenceDuringDontKeepActivities() { + // Simulates the "Don't Keep Activities" scenario where configuration must be preserved + val originalAmount = 199.99 + val originalCountry = "US" + + val messageView = PayPalMessageView(mockContext) + + // Set initial configuration + val initialConfig = baseConfig.clone().apply { + data.amount = originalAmount + data.buyerCountry = originalCountry + } + messageView.setConfig(initialConfig) + + // Simulate activity destruction and recreation by getting and restoring config + val savedConfig = messageView.getConfig() + + // Create new view instance (as Android would do) + val recreatedView = PayPalMessageView(mockContext) + + val restorationTime = measureTimeMillis { + recreatedView.setConfig(savedConfig) + } + + println("State restoration time: ${restorationTime}ms") + + // Verify configuration was preserved + val restoredConfig = recreatedView.getConfig() + assert(restoredConfig.data.amount == originalAmount) { + "Amount not preserved after recreation: expected $originalAmount, got ${restoredConfig.data.amount}" + } + assert(restoredConfig.data.buyerCountry == originalCountry) { + "Buyer country not preserved after recreation: expected $originalCountry, got ${restoredConfig.data.buyerCountry}" + } + + // Restoration should be fast enough for smooth user experience + assert(restorationTime < 100) { + "State restoration took ${restorationTime}ms, too slow for smooth activity recreation" + } + } + + @Test + @DisplayName("Low-Tech: Multiple rapid recreations simulate extreme memory pressure") + fun testExtremeActivityRecreationScenario() { + // Simulates the worst-case scenario: user repeatedly backgrounding/foregrounding + // the app with "Don't Keep Activities" enabled + + val recreationCycles = 10 + val recreationTimes = mutableListOf() + var currentConfig = baseConfig.clone() + + repeat(recreationCycles) { cycle -> + val cycleTime = measureTimeMillis { + // Create view + val view = PayPalMessageView(mockContext) + + // Configure with potentially different data each cycle + currentConfig = currentConfig.clone().apply { + data.amount = (cycle * 50.0) + 0.99 + } + view.setConfig(currentConfig) + + // Get config (simulates saving state) + val savedConfig = view.getConfig() + + // Verify data integrity + assert(savedConfig.data.amount == currentConfig.data.amount) { + "Configuration lost during cycle $cycle" + } + } + recreationTimes.add(cycleTime) + } + + val averageRecreationTime = recreationTimes.average() + val maxRecreationTime = recreationTimes.maxOrNull() ?: 0L + + println("Recreation times over $recreationCycles cycles: $recreationTimes") + println("Average recreation time: ${averageRecreationTime}ms") + println("Maximum recreation time: ${maxRecreationTime}ms") + + // Performance should not degrade over multiple cycles + assert(averageRecreationTime < 100) { + "Average recreation time ${averageRecreationTime}ms indicates memory pressure" + } + assert(maxRecreationTime < 200) { + "Maximum recreation time ${maxRecreationTime}ms indicates severe performance degradation" + } + + // Check for performance degradation over time + val firstHalfAverage = recreationTimes.take(recreationCycles / 2).average() + val secondHalfAverage = recreationTimes.takeLast(recreationCycles / 2).average() + val degradationRatio = secondHalfAverage / firstHalfAverage + + println("First half average: ${firstHalfAverage}ms") + println("Second half average: ${secondHalfAverage}ms") + println("Degradation ratio: $degradationRatio") + + assert(degradationRatio < 1.5) { + "Performance degraded by ${(degradationRatio - 1) * 100}% over time, indicating memory leaks" + } + } + + @Test + @DisplayName("Low-Tech: Configuration changes under memory constraints") + fun testConfigurationChangeUnderMemoryPressure() { + // Simulates configuration changes on devices with limited memory (1-2GB RAM) + // where Android may aggressively kill and recreate activities + + val messageView = PayPalMessageView(mockContext) + val testScenarios = listOf( + Triple(50.0, "US", PayPalEnvironment.SANDBOX), + Triple(100.0, "CA", PayPalEnvironment.LIVE), + Triple(500.0, "GB", PayPalEnvironment.DEVELOP()), + Triple(1000.0, "AU", PayPalEnvironment.SANDBOX), + Triple(2500.0, "DE", PayPalEnvironment.LIVE), + ) + + val configChangeTimes = mutableListOf() + + testScenarios.forEach { (amount, country, environment) -> + // Save current state + val currentConfig = messageView.getConfig() + + // Measure change time + val changeTime = measureTimeMillis { + val newConfig = currentConfig.clone().apply { + data.amount = amount + data.buyerCountry = country + data.environment = environment + } + messageView.setConfig(newConfig) + + // Verify change was applied + val verifiedConfig = messageView.getConfig() + assert(verifiedConfig.data.amount == amount) + assert(verifiedConfig.data.buyerCountry == country) + assert(verifiedConfig.data.environment == environment) + } + + configChangeTimes.add(changeTime) + } + + val averageChangeTime = configChangeTimes.average() + val maxChangeTime = configChangeTimes.maxOrNull() ?: 0L + + println("Configuration change times: $configChangeTimes") + println("Average change time: ${averageChangeTime}ms") + + // Configuration changes should be fast even under memory pressure + assert(averageChangeTime < 50) { + "Average configuration change time ${averageChangeTime}ms too slow for low-memory devices" + } + assert(maxChangeTime < 100) { + "Maximum configuration change time ${maxChangeTime}ms indicates stalls on low-memory devices" + } + } + + @Test + @DisplayName("Low-Tech: Memory allocation patterns during lifecycle events") + fun testMemoryAllocationPatterns() { + // Tests that the SDK doesn't allocate excessive objects during lifecycle transitions + // Important for devices with 1-2GB RAM where GC pauses can cause jank + + val allocationCycles = 20 + val views = mutableListOf() + val configs = mutableListOf() + + val totalAllocationTime = measureTimeMillis { + repeat(allocationCycles) { iteration -> + // Create view and config (allocations) + val view = PayPalMessageView(mockContext) + val config = baseConfig.clone().apply { + data.clientID = "allocation-test-$iteration" + data.amount = iteration.toDouble() + 0.99 + } + + view.setConfig(config) + views.add(view) + configs.add(view.getConfig()) + } + } + + val averageAllocationTime = totalAllocationTime.toDouble() / allocationCycles + + println("Total allocation time for $allocationCycles cycles: ${totalAllocationTime}ms") + println("Average allocation time per cycle: ${averageAllocationTime}ms") + + // Each cycle should allocate efficiently + assert(averageAllocationTime < 25) { + "Average allocation time ${averageAllocationTime}ms indicates excessive object creation" + } + + // Cleanup should also be efficient + val cleanupTime = measureTimeMillis { + views.clear() + configs.clear() + } + + println("Cleanup time: ${cleanupTime}ms") + + assert(cleanupTime < 50) { + "Cleanup took ${cleanupTime}ms, indicating potential finalization issues" + } + } + + @Test + @DisplayName("Low-Tech: Consistent performance across budget device CPU speeds") + fun testPerformanceOnSlowCPU() { + // Budget devices often have slower CPUs (1.0-1.5 GHz quad-core) + // This test ensures operations remain responsive even with slower processing + + val operations = mutableMapOf() + + // Test 1: View creation + operations["view_creation"] = measureTimeMillis { + PayPalMessageView(mockContext, config = baseConfig) + } + + // Test 2: Configuration update + val testView = PayPalMessageView(mockContext) + operations["config_update"] = measureTimeMillis { + testView.setConfig( + baseConfig.clone().apply { + data.amount = 299.99 + }, + ) + } + + // Test 3: Configuration retrieval + operations["config_retrieval"] = measureTimeMillis { + testView.getConfig() + } + + // Test 4: Configuration cloning + operations["config_cloning"] = measureTimeMillis { + repeat(10) { + baseConfig.clone() + } + } + + println("Operation timings on budget CPU:") + operations.forEach { (operation, time) -> + println(" $operation: ${time}ms") + } + + // All operations should complete quickly even on slow CPUs + assert(operations["view_creation"]!! < 300) { + "View creation took ${operations["view_creation"]}ms, too slow for budget devices" + } + assert(operations["config_update"]!! < 100) { + "Config update took ${operations["config_update"]}ms, too slow for budget devices" + } + assert(operations["config_retrieval"]!! < 50) { + "Config retrieval took ${operations["config_retrieval"]}ms, too slow for budget devices" + } + assert(operations["config_cloning"]!! < 100) { + "Config cloning (10x) took ${operations["config_cloning"]}ms, too slow for budget devices" + } + } + + @Test + @DisplayName("Innovation: Concurrent configuration changes should be handled efficiently") + fun testConcurrentConfigurationHandling() { + val messageView = PayPalMessageView(mockContext) + + // Test rapid configuration changes as might happen during user interaction + val rapidConfigurationTime = measureTimeMillis { + repeat(20) { iteration -> + messageView.setConfig( + baseConfig.clone().apply { + data.amount = iteration.toDouble() + 0.99 + data.buyerCountry = if (iteration % 2 == 0) "US" else "CA" + }, + ) + } + } + + println("Rapid configuration changes (20x) took: ${rapidConfigurationTime}ms") + + // Should handle rapid changes without performance degradation + assert(rapidConfigurationTime < 1000) { + "Rapid configuration changes took ${rapidConfigurationTime}ms, indicating poor concurrency handling" + } + + // Average per-change should remain reasonable + val averagePerChange = rapidConfigurationTime / 20.0 + assert(averagePerChange < 30) { + "Average per-change time ${averagePerChange}ms indicates scaling issues" + } + } + + @Test + @DisplayName("Regression: Configuration cloning performance for immutability") + fun testConfigurationCloningPerformance() { + // Test the performance of configuration cloning which ensures immutability + // This is critical for avoiding side effects in multi-threaded scenarios + + val cloningTimes = mutableListOf() + + repeat(100) { + val cloneTime = measureTimeMillis { + baseConfig.clone().apply { + data.amount = it.toDouble() + 0.99 + } + } + cloningTimes.add(cloneTime) + } + + val averageCloneTime = cloningTimes.average() + val maxCloneTime = cloningTimes.maxOrNull() ?: 0L + + println("Average clone time: ${averageCloneTime}ms") + println("Maximum clone time: ${maxCloneTime}ms") + + // Configuration cloning should be extremely fast + assert(averageCloneTime < 5.0) { + "Average configuration cloning time ${averageCloneTime}ms is too slow for frequent operations" + } + assert(maxCloneTime < 20) { + "Maximum clone time ${maxCloneTime}ms indicates potential garbage collection issues" + } + } + + /** + * Calculate variance to measure performance consistency + */ + private fun calculateVariance(values: List): Double { + val mean = values.average() + val squaredDifferences = values.map { (it - mean) * (it - mean) } + return squaredDifferences.average() + } +}