Skip to content

Commit

Permalink
Merge pull request #681 from prudrabhat/iam_attachment
Browse files Browse the repository at this point in the history
Detach presentables before re-attaching to top Activity
  • Loading branch information
prudrabhat authored Jun 19, 2024
2 parents d500c8c + acdb82f commit d044b40
Show file tree
Hide file tree
Showing 2 changed files with 214 additions and 0 deletions.
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,14 @@ internal abstract class AEPPresentable<T : Presentation<T>> :

@VisibleForTesting internal val contentIdentifier: Int = Random().nextInt()

/**
* Represents the activity to which the presentable is currently attached.
* This SHOULD always be updated whenever the presentable is attached or detached from an
* activity. For the sake of maintainability, modify this only in [attach] and [detach] and
* limit queries to this field in activity lifecycle methods.
*/
private var attachmentHandle: WeakReference<Activity?> = WeakReference(null)

/**
* @param presentation the [Presentation] to be used by this [Presentable]
* @param presentationUtilityProvider the [PresentationUtilityProvider] to be used to fetch components needed by this [Presentable]
Expand Down Expand Up @@ -241,6 +249,22 @@ internal abstract class AEPPresentable<T : Presentation<T>> :
if (getState() != Presentable.State.VISIBLE) {
return@launch
}

// If the activity that has currently resumed is not the same as the one the presentable
// is attached to, then it means that another activity has launched on top of/beside
// current activity. Detach the presentable from the current activity before attaching
// it to the newly resumed activity.
val currentAttachmentHandle = attachmentHandle.get()
if (currentAttachmentHandle != null && currentAttachmentHandle != activity) {
Log.trace(
ServiceConstants.LOG_TAG,
LOG_SOURCE,
"Detaching from $currentAttachmentHandle before attaching to $activity."
)

detach(currentAttachmentHandle)
}

attach(activity)
}
}
Expand Down Expand Up @@ -327,6 +351,10 @@ internal abstract class AEPPresentable<T : Presentation<T>> :
composeView.id = contentIdentifier
val rootViewGroup = activityToAttach.findViewById<ViewGroup>(android.R.id.content)
rootViewGroup.addView(composeView)

// Update the attachment handle to the currently attached activity.
attachmentHandle = WeakReference(activityToAttach)

Log.trace(
ServiceConstants.LOG_TAG,
LOG_SOURCE,
Expand Down Expand Up @@ -373,6 +401,20 @@ internal abstract class AEPPresentable<T : Presentation<T>> :
}
existingComposeView.removeAllViews()
rootViewGroup.removeView(existingComposeView)

// Clear the attachment handle if the current attachment handle is the same as the activity
// to detach. If not, the handle would have already been cleared when the presentable
// was attached due to another activity being resumed on top of the presentable.
val currentAttachmentHandle = attachmentHandle.get()
if (currentAttachmentHandle == activityToDetach) {
Log.trace(
ServiceConstants.LOG_TAG,
LOG_SOURCE,
"Clearing attachment handle ($activityToDetach)."
)
attachmentHandle.clear()
}

activityCompatOwnerUtils.detachActivityCompatOwner(activityToDetach)
Log.trace(ServiceConstants.LOG_TAG, LOG_SOURCE, "Detached ${contentIdentifier}from $activityToDetach.")
}
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -41,11 +41,16 @@ import org.mockito.Mockito.mock
import org.mockito.Mockito.verifyNoInteractions
import org.mockito.Mockito.`when`
import org.mockito.MockitoAnnotations
import org.mockito.kotlin.KArgumentCaptor
import org.mockito.kotlin.any
import org.mockito.kotlin.argumentCaptor
import org.mockito.kotlin.eq
import org.mockito.kotlin.never
import org.mockito.kotlin.times
import org.mockito.kotlin.verify
import org.mockito.kotlin.verifyNoMoreInteractions
import kotlin.test.assertEquals
import kotlin.test.assertTrue

internal class AEPPresentableTest {
@Mock
Expand Down Expand Up @@ -809,6 +814,169 @@ internal class AEPPresentableTest {
}
}

@Test
fun `Test that presentable is detached and resurfaced when an Activity launches over a visible presentable`() {
// setup
val presentationStateManager = PresentationStateManager()
val aepPresentableWithGatedDisplay = TestAEPPresentableGatedDisplay(
mockPresentation,
mockPresentationUtilityProvider,
mockPresentationDelegate,
mockAppLifecycleProvider,
presentationStateManager,
mockActivityCompatLifecycleUtils,
mockMainScope,
mockPresentationObserver,
conflictLogic = { visible ->
visible.any { it is Alert }
}
)

// simulate that the presentation delegate allows the presentation to be shown
`when`(mockPresentationDelegate.canShow(aepPresentableWithGatedDisplay)).thenReturn(true)
// simulate a valid activity being present
`when`(mockPresentationUtilityProvider.getCurrentActivity()).thenReturn(mockActivity)
// simulate no existing ComposeView being present
`when`(mockActivity.findViewById<ComposeView?>(aepPresentableWithGatedDisplay.contentIdentifier)).thenReturn(
null
)
`when`(mockActivity.findViewById<ViewGroup>(eq(android.R.id.content))).thenReturn(
mockViewGroup
)

`when`(mockPresentationObserver.getVisiblePresentations()).thenReturn(
mutableListOf(mock(FloatingButton::class.java))
)

val mockAnotherActivity = mock(AnotherActivity::class.java)
val anotherViewGroup = mock(ViewGroup::class.java)
`when`(mockAnotherActivity.findViewById<ViewGroup>(eq(android.R.id.content))).thenReturn(
anotherViewGroup
)

runTest {
// Part-1: Simulate presentable shown for the first time
aepPresentableWithGatedDisplay.show()

// verify that the presentation delegate is called because display is gated
verify(mockPresentationDelegate).canShow(aepPresentableWithGatedDisplay)

// verify that the lifecycle provider is called to register the listener
verify(mockAppLifecycleProvider).registerListener(aepPresentableWithGatedDisplay)

// Verify that the compose view is added to the viewgroup
val composeViewCaptor: KArgumentCaptor<ComposeView> = argumentCaptor()
verify(mockViewGroup, times(1)).addView(composeViewCaptor.capture())

// verify that the listener, delegate, and state manager are notified of content show
verify(mockPresentationDelegate).onShow(aepPresentableWithGatedDisplay)
verify(mockPresentationListener).onShow(aepPresentableWithGatedDisplay)
assertTrue { presentationStateManager.presentableState.value == Presentable.State.VISIBLE }

// verify that the presentation observer is notified of the new presentation
verify(mockPresentationObserver).onPresentationVisible(aepPresentableWithGatedDisplay.getPresentation())

// We already asserted above the presentable has been attached. So return the captured ComposeView
`when`(mockActivity.findViewById<ComposeView?>(aepPresentableWithGatedDisplay.contentIdentifier)).thenReturn(
composeViewCaptor.firstValue
)

// Part-2: Simulate another activity resuming when presentable is already shown
// simulate another activity being the current activity
aepPresentableWithGatedDisplay.onActivityResumed(mockAnotherActivity)

// verify that the compose view is removed from the previous Activity view/group
verify(mockViewGroup, times(1)).removeView(composeViewCaptor.firstValue)

// verify that a new compose view is added to the new Activity view/group
verify(anotherViewGroup, times(1)).addView(any())
assertTrue { presentationStateManager.presentableState.value == Presentable.State.VISIBLE }

// verify that the listener, delegate are not re-notified because this is implicit
verifyNoMoreInteractions(mockPresentationDelegate)
verifyNoMoreInteractions(mockPresentationListener)
}
}

@Test
fun `Test that presentable not detached and re-attached when the same Activity instance resumes`() {
// setup
val presentationStateManager = PresentationStateManager()
val aepPresentableWithGatedDisplay = TestAEPPresentableGatedDisplay(
mockPresentation,
mockPresentationUtilityProvider,
mockPresentationDelegate,
mockAppLifecycleProvider,
presentationStateManager,
mockActivityCompatLifecycleUtils,
mockMainScope,
mockPresentationObserver,
conflictLogic = { visible ->
visible.any { it is Alert }
}
)

// simulate that the presentation delegate allows the presentation to be shown
`when`(mockPresentationDelegate.canShow(aepPresentableWithGatedDisplay)).thenReturn(true)
// simulate a valid activity being present
`when`(mockPresentationUtilityProvider.getCurrentActivity()).thenReturn(mockActivity)
// simulate no existing ComposeView being present
`when`(mockActivity.findViewById<ComposeView?>(aepPresentableWithGatedDisplay.contentIdentifier)).thenReturn(
null
)
`when`(mockActivity.findViewById<ViewGroup>(eq(android.R.id.content))).thenReturn(
mockViewGroup
)

`when`(mockPresentationObserver.getVisiblePresentations()).thenReturn(
mutableListOf(mock(FloatingButton::class.java))
)

runTest {
// Part-1: Simulate presentable shown for the first time
aepPresentableWithGatedDisplay.show()

// verify that the presentation delegate is called because display is gated
verify(mockPresentationDelegate).canShow(aepPresentableWithGatedDisplay)

// verify that the lifecycle provider is called to register the listener
verify(mockAppLifecycleProvider).registerListener(aepPresentableWithGatedDisplay)

// Verify that the compose view is added to the viewgroup
val composeViewCaptor: KArgumentCaptor<ComposeView> = argumentCaptor()
verify(mockViewGroup, times(1)).addView(composeViewCaptor.capture())

// verify that the listener, delegate, and state manager are notified of content show
verify(mockPresentationDelegate).onShow(aepPresentableWithGatedDisplay)
verify(mockPresentationListener).onShow(aepPresentableWithGatedDisplay)
assertTrue { presentationStateManager.presentableState.value == Presentable.State.VISIBLE }

// verify that the presentation observer is notified of the new presentation
verify(mockPresentationObserver).onPresentationVisible(aepPresentableWithGatedDisplay.getPresentation())

// Part-2: Simulate same activity resuming when presentable is already shown, like
// going to background and coming back to foreground or a notification drawer being closed

// We already asserted above the presentable has been attached. So return the captured ComposeView
`when`(mockActivity.findViewById<ComposeView?>(aepPresentableWithGatedDisplay.contentIdentifier)).thenReturn(
composeViewCaptor.firstValue
)

// Test the same activity instance resuming
aepPresentableWithGatedDisplay.onActivityResumed(mockActivity)

// verify that the compose view is not removed from the viewgroup since it's the same activity
verify(mockViewGroup, never()).removeView(any())
// verify that no changes are made to the viewgroup
verifyNoMoreInteractions(mockViewGroup)

// verify that the listener, delegate are not re-notified because this is implicit
verifyNoMoreInteractions(mockPresentationDelegate)
verifyNoMoreInteractions(mockPresentationListener)
assertTrue { presentationStateManager.presentableState.value == Presentable.State.VISIBLE }
}
}

@Test
fun `Test that AEPPresentable#onActivityResumed bails when presentable is HIDDEN`() {
// setup
Expand Down Expand Up @@ -963,4 +1131,8 @@ internal class AEPPresentableTest {
return conflictLogic(visiblePresentations)
}
}

private class AnotherActivity : Activity() {
// no-op activity
}
}

0 comments on commit d044b40

Please sign in to comment.