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
1 change: 1 addition & 0 deletions nocodes/build.gradle
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ android {
namespace = 'io.qonversion.nocodes'

testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner"
consumerProguardFiles 'consumer-rules.pro'
}

buildTypes {
Expand Down
7 changes: 7 additions & 0 deletions nocodes/consumer-rules.pro
Original file line number Diff line number Diff line change
@@ -0,0 +1,7 @@
# FinishingStubFragment is constructed directly by NoCodesFragmentFactory but may also be
# referenced by AndroidX FragmentManager via reflection (Class.forName) if it is ever
# persisted to a saved Bundle. Keep the no-arg constructor so reflective instantiation
# never fails after R8 shrinking in release builds.
-keep class io.qonversion.nocodes.internal.screen.view.FinishingStubFragment {
<init>();
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,67 @@
package io.qonversion.nocodes.internal.screen.view

import androidx.test.ext.junit.runners.AndroidJUnit4
import io.qonversion.nocodes.internal.di.DependenciesAssembly
import org.junit.After
import org.junit.Assert.assertEquals
import org.junit.Assert.assertFalse
import org.junit.Assert.assertNotEquals
import org.junit.Before
import org.junit.Test
import org.junit.runner.RunWith

/**
* Smoke test for the process-death restoration path. Verifies that
* [NoCodesFragmentFactory] substitutes a [FinishingStubFragment] for [ScreenFragment]
* when [DependenciesAssembly.instance] is uninitialized, and otherwise delegates to the
* default [androidx.fragment.app.FragmentFactory] behavior.
*
* Other instrumentation tests in this module initialize [DependenciesAssembly.instance]
* and the lateinit static persists for the lifetime of the test process. To make this
* test order-independent we forcibly clear the backing field via reflection in
* [Before], and restore it in [After].
*/
@RunWith(AndroidJUnit4::class)
internal class NoCodesFragmentFactoryTest {

private val factory = NoCodesFragmentFactory()
private val classLoader: ClassLoader = NoCodesFragmentFactoryTest::class.java.classLoader!!

private val instanceField =
DependenciesAssembly::class.java.getDeclaredField("instance").apply { isAccessible = true }

private var savedInstance: DependenciesAssembly? = null

@Before
fun clearInstance() {
savedInstance = instanceField.get(null) as DependenciesAssembly?
instanceField.set(null, null)
// Sanity check that our reflection actually changed observable state.
assertFalse(
"Expected DependenciesAssembly.instance to read as uninitialized after reflection clear",
DependenciesAssembly.isInstanceInitialized()
)
}

@After
fun restoreInstance() {
instanceField.set(null, savedInstance)
}

@Test
fun substitutesScreenFragmentWhenDiUninitialized() {
val fragment = factory.instantiate(classLoader, ScreenFragment::class.java.name)

assertEquals(FinishingStubFragment::class.java, fragment.javaClass)
}

@Test
fun delegatesToSuperForUnrelatedFragments() {
val unrelated = androidx.fragment.app.Fragment::class.java.name

val fragment = factory.instantiate(classLoader, unrelated)

assertEquals(androidx.fragment.app.Fragment::class.java, fragment.javaClass)
assertNotEquals(FinishingStubFragment::class.java, fragment.javaClass)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -30,8 +30,17 @@ internal class DependenciesAssembly(
ControllersAssembly by controllersAssembly {

companion object {
// For fragment dependencies
// ScreenFragment reaches its dependencies through this companion because the
// FragmentManager reflectively recreates fragments via their no-arg constructor
// and we cannot inject the assembly through a constructor argument.
internal lateinit var instance: DependenciesAssembly

// Lets ScreenActivity check whether NoCodes.initialize has run in this process
// before relying on `instance`. After Android kills the host process while a
// ScreenActivity is on the back stack and later restores it, the OS recreates
// the activity (and its fragments) before the host app has a chance to call
// NoCodes.initialize again, so `instance` may not yet be set.
internal fun isInstanceInitialized(): Boolean = ::instance.isInitialized
}

class Builder(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,29 @@
package io.qonversion.nocodes.internal.screen.view

import android.os.Bundle
import androidx.fragment.app.Fragment

/**
* Drop-in replacement for [ScreenFragment] used only by [NoCodesFragmentFactory] when
* AndroidX FragmentManager tries to recreate a saved [ScreenFragment] before
* `NoCodes.initialize` has run again in this process (see [NoCodesFragmentFactory] for
* the full scenario).
*
* It does no work other than finishing the host activity in [onCreate]. After the
* activity is gone, Android falls back to the next entry in the task back stack
* (typically the host app's main activity). Once the host re-runs `NoCodes.initialize`,
* the no-code screen can be shown again normally via `NoCodes.showScreen`.
*
* Must be a top-level class with a public no-arg constructor: AndroidX
* FragmentManager instantiates fragments reflectively, and applies its own keep rules
* to discovered Fragment subclasses, but only when reachable from the manifest.
* Because this class is reached through code (the factory) rather than the manifest,
* we add an explicit ProGuard keep rule in nocodes/consumer-rules.pro.
*/
class FinishingStubFragment : Fragment() {

override fun onCreate(savedInstanceState: Bundle?) {
super.onCreate(savedInstanceState)
activity?.finish()
}
}
Original file line number Diff line number Diff line change
@@ -0,0 +1,39 @@
package io.qonversion.nocodes.internal.screen.view

import androidx.fragment.app.Fragment
import androidx.fragment.app.FragmentFactory
import io.qonversion.nocodes.internal.di.DependenciesAssembly

/**
* Intercepts AndroidX FragmentManager's reflective fragment instantiation so we can
* substitute a safe stand-in when [ScreenFragment] would otherwise crash.
*
* The crash scenario:
* 1. The user is on a no-code screen, then backgrounds the app.
* 2. Android kills the host process under memory pressure.
* 3. The user returns. Android creates a new process and restores the task. The top of
* the back stack is [ScreenActivity], so it is created before any other activity.
* 4. Inside [ScreenActivity.onCreate], `super.onCreate(savedInstanceState)` triggers
* FragmentManager.restoreSaveState, which reflectively constructs [ScreenFragment]
* via its no-arg constructor. The fragment's property initializers read
* `DependenciesAssembly.instance`, but the host app has not had a chance to call
* `NoCodes.initialize` yet, so the lateinit throws.
*
* To prevent that, [ScreenActivity] installs this factory before super.onCreate.
* If the assembly is not yet initialized when AndroidX asks for a [ScreenFragment]
* instance, we return a [FinishingStubFragment] that closes the activity instead.
*
* For every other fragment class name we delegate to the default factory so AndroidX's
* own internal fragments (lifecycle dispatchers etc.) are unaffected.
*/
internal class NoCodesFragmentFactory : FragmentFactory() {

override fun instantiate(classLoader: ClassLoader, className: String): Fragment {
if (className == ScreenFragment::class.java.name &&
!DependenciesAssembly.isInstanceInitialized()
) {
return FinishingStubFragment()
}
return super.instantiate(classLoader, className)
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,7 @@ import android.view.WindowManager
import androidx.fragment.app.FragmentActivity
import io.qonversion.nocodes.R
import io.qonversion.nocodes.dto.QScreenPresentationStyle
import io.qonversion.nocodes.internal.di.DependenciesAssembly
import io.qonversion.nocodes.internal.screen.getScreenTransactionAnimations

class ScreenActivity : FragmentActivity(R.layout.nc_activity_screen) {
Expand All @@ -17,7 +18,25 @@ class ScreenActivity : FragmentActivity(R.layout.nc_activity_screen) {
) as? QScreenPresentationStyle

override fun onCreate(savedInstanceState: Bundle?) {
// The factory MUST be installed before super.onCreate. AndroidX's
// FragmentManager replays its saved state inside super.onCreate, calling
// FragmentFactory.instantiate for every saved fragment. By installing our
// factory first, we get to substitute a safe stand-in for ScreenFragment when
// the assembly is not yet initialized in this process - which happens after
// Android kills the host while a ScreenActivity is on the back stack and the
// user later returns to the app, recreating the activity before the host has
// had a chance to call NoCodes.initialize again. See NoCodesFragmentFactory.
supportFragmentManager.fragmentFactory = NoCodesFragmentFactory()
super.onCreate(savedInstanceState)
if (savedInstanceState != null && !DependenciesAssembly.isInstanceInitialized()) {
// Defense in depth: if the saved state happens to contain no fragments
// (e.g., the FragmentContainerView was empty when the state was saved),
// FragmentFactory is never consulted. We still must not stay on a screen
// that depends on an uninitialized assembly, so finish here too. The host
// app can re-show the screen after its own initialization completes.
finish()
return
}
window.setFlags(
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS,
WindowManager.LayoutParams.FLAG_LAYOUT_NO_LIMITS
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -39,15 +39,15 @@ import com.qonversion.android.sdk.dto.QonversionErrorCode

class ScreenFragment : Fragment(), ScreenContract.View {

private val presenter = DependenciesAssembly.instance.screenPresenter(this)
private val logger = DependenciesAssembly.instance.logger()
private val delegateProvider = DependenciesAssembly.instance.noCodesDelegateProvider()
private val purchaseDelegateProvider = DependenciesAssembly.instance.purchaseDelegateProvider()
private val presenter by lazy { DependenciesAssembly.instance.screenPresenter(this) }
private val logger by lazy { DependenciesAssembly.instance.logger() }
private val delegateProvider by lazy { DependenciesAssembly.instance.noCodesDelegateProvider() }
private val purchaseDelegateProvider by lazy { DependenciesAssembly.instance.purchaseDelegateProvider() }
private val themeProvider = { DependenciesAssembly.instance.theme() }

private val screenCustomizationDelegate = DependenciesAssembly.instance.screenCustomizationDelegate()
private val screenCustomizationDelegate by lazy { DependenciesAssembly.instance.screenCustomizationDelegate() }

private val delegate = delegateProvider.noCodesDelegate
private val delegate by lazy { delegateProvider.noCodesDelegate }

private var binding: NcFragmentScreenBinding? = null
private var loadingView: NoCodesLoadingView? = null
Expand Down
Loading