diff --git a/nocodes/build.gradle b/nocodes/build.gradle index 5a4db673..981305c4 100644 --- a/nocodes/build.gradle +++ b/nocodes/build.gradle @@ -17,6 +17,7 @@ android { namespace = 'io.qonversion.nocodes' testInstrumentationRunner "androidx.test.runner.AndroidJUnitRunner" + consumerProguardFiles 'consumer-rules.pro' } buildTypes { diff --git a/nocodes/consumer-rules.pro b/nocodes/consumer-rules.pro new file mode 100644 index 00000000..f2e2da32 --- /dev/null +++ b/nocodes/consumer-rules.pro @@ -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 { + (); +} diff --git a/nocodes/src/androidTest/java/io/qonversion/nocodes/internal/screen/view/NoCodesFragmentFactoryTest.kt b/nocodes/src/androidTest/java/io/qonversion/nocodes/internal/screen/view/NoCodesFragmentFactoryTest.kt new file mode 100644 index 00000000..cde379f7 --- /dev/null +++ b/nocodes/src/androidTest/java/io/qonversion/nocodes/internal/screen/view/NoCodesFragmentFactoryTest.kt @@ -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) + } +} diff --git a/nocodes/src/main/java/io/qonversion/nocodes/internal/di/DependenciesAssembly.kt b/nocodes/src/main/java/io/qonversion/nocodes/internal/di/DependenciesAssembly.kt index ba28b6fe..47797575 100644 --- a/nocodes/src/main/java/io/qonversion/nocodes/internal/di/DependenciesAssembly.kt +++ b/nocodes/src/main/java/io/qonversion/nocodes/internal/di/DependenciesAssembly.kt @@ -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( diff --git a/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/FinishingStubFragment.kt b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/FinishingStubFragment.kt new file mode 100644 index 00000000..e32d22e4 --- /dev/null +++ b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/FinishingStubFragment.kt @@ -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() + } +} diff --git a/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/NoCodesFragmentFactory.kt b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/NoCodesFragmentFactory.kt new file mode 100644 index 00000000..ea7030b9 --- /dev/null +++ b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/NoCodesFragmentFactory.kt @@ -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) + } +} diff --git a/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenActivity.kt b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenActivity.kt index 457100a6..d0ccaddb 100644 --- a/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenActivity.kt +++ b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenActivity.kt @@ -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) { @@ -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 diff --git a/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenFragment.kt b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenFragment.kt index 092b479e..53ee6301 100644 --- a/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenFragment.kt +++ b/nocodes/src/main/java/io/qonversion/nocodes/internal/screen/view/ScreenFragment.kt @@ -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