Skip to content

Commit fface0a

Browse files
authored
refactor: update ModalFragment to use newInstance factory (#84)
1 parent 1a87ecc commit fface0a

File tree

12 files changed

+865
-20
lines changed

12 files changed

+865
-20
lines changed

jacoco.gradle

Lines changed: 3 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -70,7 +70,9 @@ def createVariantCoverage(variant) {
7070
description = "Generate Jacoco coverage reports for the ${variantName.capitalize()} build."
7171

7272
reports {
73-
html.enabled = true
73+
html.required = true
74+
xml.required = true
75+
csv.required = false
7476
}
7577

7678
def javaClasses = fileTree(dir: variant.javaCompileProvider.get().destinationDir, excludes: project.excludes)

library/src/androidTest/java/com/paypal/messages/ModalExternalLinkTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -37,7 +37,7 @@ class ModalExternalLinkTest {
3737
val webView = WebView(recordingContext)
3838

3939
// Initialize the modal WebView configuration
40-
val fragment = ModalFragment(clientId = "test-client-id")
40+
val fragment = ModalFragment.newInstance("test-client-id")
4141
fragment.setupWebView(webView)
4242

4343
// Load a minimal page that triggers a target=_blank navigation
Lines changed: 347 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,13 +1,357 @@
1-
package com.paypal.messages.totest
1+
package com.paypal.messages
22

3+
import android.os.Bundle
4+
import androidx.fragment.app.testing.launchFragmentInContainer
5+
import androidx.test.core.app.ActivityScenario
36
import androidx.test.ext.junit.runners.AndroidJUnit4
7+
import androidx.test.platform.app.InstrumentationRegistry
8+
import com.paypal.messages.config.modal.ModalCloseButton
9+
import com.paypal.messages.config.modal.ModalConfig
10+
import com.paypal.messages.config.modal.ModalEvents
11+
import org.junit.Assert.assertEquals
12+
import org.junit.Assert.assertFalse
13+
import org.junit.Assert.assertNotNull
14+
import org.junit.Assert.assertTrue
415
import org.junit.Test
516
import org.junit.runner.RunWith
617

718
@RunWith(AndroidJUnit4::class)
819
class ModalFragmentTest {
20+
21+
@Test
22+
fun newInstance_setsClientIdInArguments() {
23+
val testClientId = "test-client-id-123"
24+
val fragment = ModalFragment.newInstance(testClientId)
25+
26+
assertNotNull("Fragment should not be null", fragment)
27+
assertNotNull("Arguments should not be null", fragment.arguments)
28+
assertEquals(
29+
"ClientId should be stored in arguments",
30+
testClientId,
31+
fragment.arguments?.getString("clientId"),
32+
)
33+
}
34+
35+
@Test
36+
fun fragmentRestoration_preservesClientId() {
37+
val testClientId = "test-client-id-restoration"
38+
39+
// Create fragment using factory method
40+
val originalFragment = ModalFragment.newInstance(testClientId)
41+
42+
// Get the arguments that would be saved (Android automatically saves/restores arguments)
43+
val savedArguments = originalFragment.arguments
44+
45+
// Create a new fragment instance (simulating Android's recreation when "Don't keep activities" is enabled)
46+
// This uses the no-arg constructor, which is required for proper state restoration
47+
val recreatedFragment = ModalFragment()
48+
49+
// Restore the arguments (Android does this automatically during recreation)
50+
recreatedFragment.arguments = savedArguments
51+
52+
// Verify clientId can be accessed (this would fail with the old constructor approach)
53+
// We can't directly access private clientId, but we can verify arguments are set
54+
assertNotNull("Recreated fragment arguments should not be null", recreatedFragment.arguments)
55+
assertEquals(
56+
"ClientId should be preserved in recreated fragment",
57+
testClientId,
58+
recreatedFragment.arguments?.getString("clientId"),
59+
)
60+
}
61+
62+
@Test
63+
fun fragmentRecreation_withActivityScenario() {
64+
val testClientId = "test-client-id-scenario"
65+
66+
// Launch fragment in a container using FragmentScenario
67+
val scenario = launchFragmentInContainer<ModalFragment>(
68+
Bundle().apply {
69+
putString("clientId", testClientId)
70+
},
71+
)
72+
73+
// Verify fragment is created
74+
scenario.onFragment { fragment ->
75+
assertNotNull("Fragment should be created", fragment)
76+
assertNotNull("Fragment arguments should be set", fragment.arguments)
77+
assertEquals(
78+
"ClientId should be accessible",
79+
testClientId,
80+
fragment.arguments?.getString("clientId"),
81+
)
82+
}
83+
84+
// Simulate activity recreation (like "Don't keep activities")
85+
scenario.recreate()
86+
87+
// Verify fragment is recreated with same arguments
88+
scenario.onFragment { recreatedFragment ->
89+
assertNotNull("Recreated fragment should not be null", recreatedFragment)
90+
assertNotNull("Recreated fragment arguments should not be null", recreatedFragment.arguments)
91+
assertEquals(
92+
"ClientId should be preserved after recreation",
93+
testClientId,
94+
recreatedFragment.arguments?.getString("clientId"),
95+
)
96+
}
97+
}
98+
99+
@Test
100+
fun fragmentWithoutArguments_throwsException() {
101+
// Create fragment without using newInstance (simulating old way or error case)
102+
val fragment = ModalFragment()
103+
fragment.arguments = null // No arguments set
104+
105+
// Accessing clientId property should throw IllegalStateException
106+
// We test this indirectly by verifying the fragment can't work without arguments
107+
// Since clientId is private, we test via reflection or by ensuring arguments are required
108+
assertNotNull("Fragment should be created", fragment)
109+
// The actual exception would be thrown when clientId is accessed internally
110+
// This test documents the expected behavior
111+
}
112+
113+
@Test
114+
fun newInstance_differentClientIds() {
115+
val clientId1 = "client-id-1"
116+
val clientId2 = "client-id-2"
117+
118+
val fragment1 = ModalFragment.newInstance(clientId1)
119+
val fragment2 = ModalFragment.newInstance(clientId2)
120+
121+
assertEquals(
122+
"First fragment should have first clientId",
123+
clientId1,
124+
fragment1.arguments?.getString("clientId"),
125+
)
126+
assertEquals(
127+
"Second fragment should have second clientId",
128+
clientId2,
129+
fragment2.arguments?.getString("clientId"),
130+
)
131+
}
132+
133+
@Test
134+
fun fragmentStateRestoration_simulatesDontKeepActivities() {
135+
val testClientId = "test-client-dont-keep-activities"
136+
137+
// Step 1: Create fragment using factory method (normal flow)
138+
val fragment = ModalFragment.newInstance(testClientId)
139+
140+
// Step 2: Simulate what happens when "Don't keep activities" is enabled:
141+
// - Activity is destroyed
142+
// - Fragment arguments are automatically saved by Android
143+
// - Fragment instance is destroyed
144+
val savedArguments = Bundle().apply {
145+
putAll(fragment.arguments ?: Bundle())
146+
}
147+
148+
// Step 3: Simulate Android recreating the fragment (uses no-arg constructor)
149+
// This is the key fix: the fragment must have a no-arg constructor
150+
val recreatedFragment = ModalFragment()
151+
152+
// Step 4: Android restores the arguments Bundle automatically
153+
recreatedFragment.arguments = savedArguments
154+
155+
// Step 5: Verify the fragment can function properly
156+
// The key test: can we access clientId without constructor?
157+
assertEquals(
158+
"Recreated fragment should have same clientId",
159+
testClientId,
160+
recreatedFragment.arguments?.getString("clientId"),
161+
)
162+
163+
// This test verifies the fix: fragment can be recreated using no-arg constructor
164+
// and clientId is accessible via arguments Bundle
165+
}
166+
167+
/**
168+
* Test that dismissing a fragment removes it from the fragment manager
169+
* and marks it as not added
170+
*/
9171
@Test
10-
fun testSomething() {
11-
//
172+
fun dismissFragment_removesFromFragmentManager() {
173+
val testClientId = "test-client-dismiss"
174+
val context = InstrumentationRegistry.getInstrumentation().targetContext
175+
val activityScenario = ActivityScenario.launch(TestActivity::class.java)
176+
177+
activityScenario.onActivity { activity ->
178+
val fragment = ModalFragment.newInstance(testClientId)
179+
180+
// Initialize fragment with minimal config
181+
fragment.init(
182+
ModalConfig(
183+
amount = 100.0,
184+
buyerCountry = "US",
185+
offer = null,
186+
ignoreCache = false,
187+
devTouchpoint = false,
188+
stageTag = null,
189+
events = ModalEvents(),
190+
modalCloseButton = ModalCloseButton(),
191+
),
192+
)
193+
194+
// Show the fragment
195+
fragment.show(activity.supportFragmentManager, "test-modal")
196+
197+
// Wait for fragment to be added
198+
activity.supportFragmentManager.executePendingTransactions()
199+
200+
// Verify fragment is added
201+
assertTrue(
202+
"Fragment should be added to fragment manager",
203+
fragment.isAdded,
204+
)
205+
206+
// Dismiss the fragment
207+
fragment.dismiss()
208+
activity.supportFragmentManager.executePendingTransactions()
209+
210+
// Verify fragment is no longer added
211+
assertFalse(
212+
"Fragment should not be added after dismissal",
213+
fragment.isAdded,
214+
)
215+
216+
// Verify fragment is not in fragment manager
217+
val foundFragment = activity.supportFragmentManager.findFragmentByTag("test-modal")
218+
assertTrue(
219+
"Fragment should be removed from fragment manager after dismissal",
220+
foundFragment == null || !foundFragment.isAdded,
221+
)
222+
}
223+
}
224+
225+
/**
226+
* Test that a dismissed fragment is not restored after activity recreation
227+
* This simulates the scenario: open modal -> dismiss -> press home -> reopen app
228+
*/
229+
@Test
230+
fun dismissedFragment_notRestoredAfterActivityRecreation() {
231+
val testClientId = "test-client-dismiss-restore"
232+
val context = InstrumentationRegistry.getInstrumentation().targetContext
233+
val activityScenario = ActivityScenario.launch(TestActivity::class.java)
234+
235+
activityScenario.onActivity { activity ->
236+
val fragment = ModalFragment.newInstance(testClientId)
237+
238+
// Initialize fragment
239+
fragment.init(
240+
ModalConfig(
241+
amount = 100.0,
242+
buyerCountry = "US",
243+
offer = null,
244+
ignoreCache = false,
245+
devTouchpoint = false,
246+
stageTag = null,
247+
events = ModalEvents(),
248+
modalCloseButton = ModalCloseButton(),
249+
),
250+
)
251+
252+
// Show the fragment
253+
fragment.show(activity.supportFragmentManager, "test-modal-restore")
254+
activity.supportFragmentManager.executePendingTransactions()
255+
256+
// Verify fragment is shown
257+
assertTrue("Fragment should be added", fragment.isAdded)
258+
259+
// Dismiss the fragment (simulating user clicking close button)
260+
fragment.dismiss()
261+
activity.supportFragmentManager.executePendingTransactions()
262+
263+
// Verify fragment is dismissed
264+
assertFalse("Fragment should be dismissed", fragment.isAdded)
265+
}
266+
267+
// Simulate activity recreation (like when app goes to background and comes back)
268+
activityScenario.recreate()
269+
270+
activityScenario.onActivity { recreatedActivity ->
271+
// After recreation, check that the dismissed fragment is not restored
272+
val restoredFragment = recreatedActivity.supportFragmentManager.findFragmentByTag("test-modal-restore")
273+
274+
// The fragment should not exist or should not be added
275+
assertTrue(
276+
"Dismissed fragment should not be restored after activity recreation",
277+
restoredFragment == null || !restoredFragment.isAdded,
278+
)
279+
}
280+
}
281+
282+
/**
283+
* Test that onCreateView handles null closeButtonData gracefully
284+
* (This tests the fix for the NullPointerException during fragment recreation)
285+
*/
286+
@Test
287+
fun onCreateView_withNullCloseButtonData_usesDefaults() {
288+
val testClientId = "test-client-null-button"
289+
val scenario = launchFragmentInContainer<ModalFragment>(
290+
Bundle().apply {
291+
putString("clientId", testClientId)
292+
},
293+
)
294+
295+
// onCreateView should not crash even if closeButtonData is null
296+
// This happens during fragment recreation before init() is called
297+
scenario.onFragment { fragment ->
298+
// Fragment should be created successfully
299+
assertNotNull("Fragment should be created", fragment)
300+
assertNotNull("Fragment view should be created", fragment.view)
301+
302+
// The view should have the close button with default values
303+
val closeButton = fragment.view?.findViewById<android.widget.ImageButton>(
304+
com.paypal.messages.R.id.ModalCloseButton,
305+
)
306+
assertNotNull("Close button should exist", closeButton)
307+
}
308+
}
309+
310+
/**
311+
* Test that onDismiss callback is invoked when fragment is dismissed
312+
*/
313+
@Test
314+
fun dismissFragment_invokesOnDismissCallback() {
315+
val testClientId = "test-client-on-dismiss"
316+
val context = InstrumentationRegistry.getInstrumentation().targetContext
317+
val activityScenario = ActivityScenario.launch(TestActivity::class.java)
318+
var onDismissCalled = false
319+
320+
activityScenario.onActivity { activity ->
321+
val fragment = ModalFragment.newInstance(testClientId)
322+
323+
// Initialize fragment with onClose callback
324+
fragment.init(
325+
ModalConfig(
326+
amount = 100.0,
327+
buyerCountry = "US",
328+
offer = null,
329+
ignoreCache = false,
330+
devTouchpoint = false,
331+
stageTag = null,
332+
events = ModalEvents(
333+
onClose = { onDismissCalled = true },
334+
),
335+
modalCloseButton = ModalCloseButton(),
336+
),
337+
)
338+
339+
// Show the fragment
340+
fragment.show(activity.supportFragmentManager, "test-modal-dismiss")
341+
activity.supportFragmentManager.executePendingTransactions()
342+
343+
// Dismiss the fragment
344+
fragment.dismiss()
345+
activity.supportFragmentManager.executePendingTransactions()
346+
}
347+
348+
// Give time for the dismiss callback to be invoked
349+
Thread.sleep(100)
350+
351+
// Verify onDismiss callback was called
352+
assertTrue(
353+
"onDismiss callback should be invoked when fragment is dismissed",
354+
onDismissCalled,
355+
)
12356
}
13357
}

library/src/androidTest/java/com/paypal/messages/PayPalMessageViewTest.kt

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -113,7 +113,7 @@ class PayPalMessageViewTest {
113113
// fun dismissAfterFragmentDetached_shouldThrow() {
114114
// val scenario: ActivityScenario<TestActivity> = ActivityScenario.launch(TestActivity::class.java)
115115
// scenario.onActivity { activity: TestActivity ->
116-
// val fragment = ModalFragment("test_client_id")
116+
// val fragment = ModalFragment.newInstance("test_client_id")
117117
// fragment.show(activity.supportFragmentManager, "test")
118118
//
119119
// // Remove the fragment to simulate detachment

0 commit comments

Comments
 (0)