diff --git a/.github/CONTRIBUTING.md b/.github/CONTRIBUTING.md new file mode 100644 index 00000000..bf491ba5 --- /dev/null +++ b/.github/CONTRIBUTING.md @@ -0,0 +1,74 @@ +# Contributing to Superwall + +We want to make contributing to this project as easy and transparent as +possible, and actively welcome your pull requests. If you run into problems, +please open an issue on GitHub. + +## Pull Requests + +1. Fork the repo and create your branch from `develop`. +2. Run the setup script `./scripts/setup.sh` +3. If you've added code that should be tested, add tests. +4. If you've changed APIs, update the documentation. +5. Ensure the test suite passes. +6. Make sure your code lints (see below). +7. Tag @ianrumac or @yusuftor in the pull request. +8. Add an entry to the [CHANGELOG.md](../CHANGELOG.md) for any breaking changes, enhancements, or bug fixes. +9. After the PR is merged, delete the branch. + +## Coding Style + + +### Ktlint + +To maintain readability and achieve code consistency, we follow the [Kotlin coding conventions](https://kotlinlang.org/docs/coding-conventions.html) +and [Android's Kotlin style guide](https://developer.android.com/kotlin/style-guide). +We use [Ktlint](https://github.com/pinterest/ktlint) to check for style errors. + +To do this locally, you can either run the setup script or install ktlint using `brew install ktlint` and either: +* Run `ktlint` from the root folder to detect style issues immediately. +* Use `ktlint installGitPreCommitHook` to install a pre-commit hook that will run ktlint before every commit. + +### IntelliJ IDEA + +To configure IntelliJ IDEA to work with ktlint, you can use one of the following approaches: + +* Install the [ktlint plugin](https://plugins.jetbrains.com/plugin/15057-ktlint) and follow the instructions. +* Use the IntelliJ configuration and follow the official [Ktlint Guide](https://pinterest.github.io/ktlint/latest/rules/configuration-intellij-idea/) to avoid conflicts with IntelliJ IDEA's built-in formatting. + +### Documentation + +Public classes and methods must contain detailed documentation. + +## Editing the code + +Open the module: `./superwall/` containing the SDK itself. + +For the example app's and test app's, open either the: +* `./app/` for the testing application +* `./example/` for the example application + + +## Git Workflow + +We have two branches: `master` and `develop`. + +All pull requests are set to merge into `develop`, with the exception of a hotfix on `master`. + +Name your branch `author/feature/` for consistency. + +When we're ready to cut a new release, we update the `version` in [build.gradle](/superwall/build.gradle.kts) and merge `develop` into `master`. + +## Testing + +If you add new code, please make sure it gets tested! When fixing bugs, try to reproduce the bug in a unit test and then fix the test. This makes sure we never regress that issue again. +Before creating a pull request, run all unit and integration tests. Make sure they all pass and fix any broken tests. +We also have a GitHub Action that runs the tests on push. + +## Issues + +We use GitHub issues to track public bugs. Please ensure your description is clear and has sufficient instructions to be able to reproduce the issue. + +## License + +By contributing to `Superwall`, you agree that your contributions will be licensed under the LICENSE file in the root directory of this source tree. diff --git a/.github/PULL_REQUEST_TEMPLATE.md b/.github/PULL_REQUEST_TEMPLATE.md new file mode 100644 index 00000000..e716fac7 --- /dev/null +++ b/.github/PULL_REQUEST_TEMPLATE.md @@ -0,0 +1,14 @@ +## Changes in this pull request + +- + +### Checklist + +- [ ] All unit tests pass. +- [ ] All UI tests pass. +- [ ] Demo project builds and runs. +- [ ] I added/updated tests or detailed why my change isn't tested. +- [ ] I added an entry to the `CHANGELOG.md` for any breaking changes, enhancements, or bug fixes. +- [ ] I have run `ktlint` in the main directory and fixed any issues. +- [ ] I have updated the SDK documentation as well as the online docs. +- [ ] I have reviewed the [contributing guide](https://github.com/superwall/Superwall-Android/tree/master/.github/CONTRIBUTING.md) \ No newline at end of file diff --git a/CHANGELOG.md b/CHANGELOG.md index 8e6e87ed..8d75f34f 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,6 +2,18 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 1.1.9 + +### Deprecations + +- Deprecated configuration method `Superwall.configure(applicationContext: Context, ...)` in favor of `Superwall.configure(applicationContext: Application, ...)` to enforce type safety. The rest of the method signature remains the same. + +### Fixes + +- SW-2878: and it's related leaks. The `PaywallViewController` was not being properly detached when activity was stopped, causing memory leaks. +- SW-2872: Fixes issue where `deviceAttributes` event and fetching would not await for IP geo to complete. +- Fixes issues on tablet devices where the paywall would close after rotation/configuration change. + ## 1.1.8 ### Enhancements diff --git a/app/build.gradle.kts b/app/build.gradle.kts index d0c16a7a..f54ad104 100644 --- a/app/build.gradle.kts +++ b/app/build.gradle.kts @@ -82,6 +82,7 @@ dependencies { androidTestImplementation(libs.test.rules) androidTestImplementation(platform(libs.compose.bom)) androidTestImplementation(libs.ui.test.junit4) + debugImplementation(libs.leakcanary.android) // Debug debugImplementation(libs.ui.tooling) diff --git a/gradle/libs.versions.toml b/gradle/libs.versions.toml index 1d989dd4..234b7063 100644 --- a/gradle/libs.versions.toml +++ b/gradle/libs.versions.toml @@ -4,6 +4,7 @@ browser_version = "1.5.0" gradle_plugin_version = "8.4.1" javascriptengineVersion = "1.0.0-beta01" kotlinxCoroutinesGuavaVersion = "1.8.1" +leakcanaryAndroidVersion = "2.14" lifecycleProcessVersion = "2.8.1" mockk = "1.13.8" revenue_cat_version = "7.7.1" @@ -37,6 +38,7 @@ serialization_version = "1.6.0" # SQL javascriptengine = { module = "androidx.javascriptengine:javascriptengine", version.ref = "javascriptengineVersion" } +leakcanary-android = { module = "com.squareup.leakcanary:leakcanary-android", version.ref = "leakcanaryAndroidVersion" } room-compiler = { module = "androidx.room:room-compiler", version.ref = "room_runtime_version" } room-ktx = { module = "androidx.room:room-ktx", version.ref = "room_runtime_version" } room-runtime = { module = "androidx.room:room-runtime", version.ref = "room_runtime_version" } diff --git a/scripts/setup.sh b/scripts/setup.sh new file mode 100755 index 00000000..a2123684 --- /dev/null +++ b/scripts/setup.sh @@ -0,0 +1,25 @@ +#!/bin/bash + +cd "$(dirname "$0")/.." + + +echo 'Checking for Ktlint...' +if which ktlint >/dev/null; then + echo 'KtLint already installed ✅' +else + if which brew >/dev/null; then + echo 'Installing Ktlint...' + brew install ktlint + else + echo " + Error: Ktlint could not be installed! ❌ + Check installation instructions from https://pinterest.github.io/ktlint/latest/ for manual + install, or brew install ktlint. Then run this script again." + exit 1 + fi +fi + +echo 'Installing githooks...' +ktlint installGitPreCommitHook +echo 'Done! ✅' + diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index 2e556e5c..504562cd 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -20,7 +20,7 @@ plugins { id("signing") } -version = "1.1.8" +version = "1.1.9" android { compileSdk = 34 diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 16899e0f..b4d15f0e 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -1,7 +1,9 @@ package com.superwall.sdk +import android.app.Application import android.content.Context import android.net.Uri +import androidx.lifecycle.ViewModelProvider import androidx.work.WorkManager import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent @@ -24,12 +26,23 @@ import com.superwall.sdk.paywall.presentation.internal.dismiss import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult import com.superwall.sdk.paywall.vc.PaywallViewController import com.superwall.sdk.paywall.vc.SuperwallPaywallActivity +import com.superwall.sdk.paywall.vc.SuperwallStoreOwner +import com.superwall.sdk.paywall.vc.ViewModelFactory +import com.superwall.sdk.paywall.vc.ViewStorageViewModel import com.superwall.sdk.paywall.vc.delegate.PaywallViewControllerEventDelegate import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent -import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.* +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.Closed +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.Custom +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.InitiatePurchase +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.InitiateRestore +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.OpenedDeepLink +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.OpenedURL +import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.OpenedUrlInSafari import com.superwall.sdk.storage.ActiveSubscriptionStatus import com.superwall.sdk.store.ExternalNativePurchaseController -import kotlinx.coroutines.* +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job import kotlinx.coroutines.flow.Flow import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow @@ -37,10 +50,11 @@ import kotlinx.coroutines.flow.drop import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update -import java.util.* +import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext class Superwall( - internal var context: Context, + context: Context, private var apiKey: String, private var purchaseController: PurchaseController?, options: SuperwallOptions?, @@ -49,10 +63,19 @@ class Superwall( ) : PaywallViewControllerEventDelegate { private var _options: SuperwallOptions? = options private val ioScope = CoroutineScope(Dispatchers.IO) + internal var context: Context = context.applicationContext // Add a private variable for the purchase task private var purchaseTask: Job? = null + private val viewStorageViewModel = + ViewModelProvider( + SuperwallStoreOwner(), + ViewModelFactory(), + ).get(ViewStorageViewModel::class.java) + + internal fun viewStore(): ViewStorageViewModel = viewStorageViewModel + internal val presentationItems: PresentationItems = PresentationItems() /** @@ -163,7 +186,8 @@ class Superwall( */ val latestPaywallInfo: PaywallInfo? get() { - val presentedPaywallInfo = dependencyContainer.paywallManager.presentedViewController?.info + val presentedPaywallInfo = + dependencyContainer.paywallManager.presentedViewController?.info return presentedPaywallInfo ?: presentationItems.paywallInfo } @@ -217,7 +241,7 @@ class Superwall( * @return The configured [Superwall] instance. */ fun configure( - applicationContext: Context, + applicationContext: Application, apiKey: String, purchaseController: PurchaseController? = null, options: SuperwallOptions? = null, @@ -259,6 +283,25 @@ class Superwall( true } } + + @Deprecated( + "This constructor is too ambiguous and will be removed in upcoming versions. Use Superwall.configure(Application, ...) instead.", + ) + fun configure( + applicationContext: Context, + apiKey: String, + purchaseController: PurchaseController? = null, + options: SuperwallOptions? = null, + activityProvider: ActivityProvider? = null, + completion: (() -> Unit)? = null, + ) = configure( + applicationContext.applicationContext as Application, + apiKey, + purchaseController, + options, + activityProvider, + completion, + ) } private lateinit var _dependencyContainer: DependencyContainer @@ -284,7 +327,8 @@ class Superwall( ) } - val cachedSubsStatus = dependencyContainer.storage.get(ActiveSubscriptionStatus) ?: SubscriptionStatus.UNKNOWN + val cachedSubsStatus = + dependencyContainer.storage.get(ActiveSubscriptionStatus) ?: SubscriptionStatus.UNKNOWN setSubscriptionStatus(cachedSubsStatus) addListeners() @@ -329,7 +373,8 @@ class Superwall( */ fun togglePaywallSpinner(isHidden: Boolean) { ioScope.launch { - val paywallViewController = dependencyContainer.paywallManager.presentedViewController ?: return@launch + val paywallViewController = + dependencyContainer.paywallManager.presentedViewController ?: return@launch paywallViewController.togglePaywallSpinner(isHidden) } } @@ -353,7 +398,9 @@ class Superwall( * Removes all of Superwall's pending local notifications. */ fun cancelAllScheduledNotifications() { - WorkManager.getInstance(context).cancelAllWorkByTag(SuperwallPaywallActivity.NOTIFICATION_CHANNEL_ID) + WorkManager + .getInstance(context) + .cancelAllWorkByTag(SuperwallPaywallActivity.NOTIFICATION_CHANNEL_ID) } // MARK: - Reset @@ -457,6 +504,7 @@ class Superwall( closeReason = PaywallCloseReason.ManualClose, ) } + is InitiatePurchase -> { if (purchaseTask != null) { // If a purchase is already in progress, do not start another @@ -475,18 +523,23 @@ class Superwall( } } } + is InitiateRestore -> { dependencyContainer.transactionManager.tryToRestore(paywallViewController) } + is OpenedURL -> { dependencyContainer.delegateAdapter.paywallWillOpenURL(url = paywallEvent.url) } + is OpenedUrlInSafari -> { dependencyContainer.delegateAdapter.paywallWillOpenURL(url = paywallEvent.url) } + is OpenedDeepLink -> { dependencyContainer.delegateAdapter.paywallWillOpenDeepLink(url = paywallEvent.url) } + is Custom -> { dependencyContainer.delegateAdapter.handleCustomPaywallAction(name = paywallEvent.string) } diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt b/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt index 266cc267..34d51e0c 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/session/AppSessionManager.kt @@ -15,7 +15,7 @@ import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.launch -import java.util.* +import java.util.Date interface AppManagerDelegate { suspend fun didUpdateAppSession(appSession: AppSession) @@ -110,7 +110,13 @@ class AppSessionManager( val userAttributes = delegate.makeUserAttributesEvent() Superwall.instance.track(InternalSuperwallEvent.SessionStart()) - Superwall.instance.track(InternalSuperwallEvent.DeviceAttributes(deviceAttributes)) + if (didTrackAppLaunch) { + Superwall.instance.track( + InternalSuperwallEvent.DeviceAttributes( + deviceAttributes, + ), + ) + } Superwall.instance.track(userAttributes) } } else { diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt index 3c67b512..66e827db 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -2,9 +2,13 @@ package com.superwall.sdk.config import android.content.Context import android.webkit.WebView +import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.internal.track +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.config.options.SuperwallOptions +import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.DeviceInfoFactory import com.superwall.sdk.dependencies.RequestFactory import com.superwall.sdk.dependencies.RuleAttributesFactory @@ -32,8 +36,12 @@ import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.Job import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll import kotlinx.coroutines.coroutineScope -import kotlinx.coroutines.flow.* +import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.mapNotNull +import kotlinx.coroutines.flow.take import kotlinx.coroutines.launch // TODO: Re-enable those params @@ -50,7 +58,8 @@ open class ConfigManager( interface Factory : RequestFactory, DeviceInfoFactory, - RuleAttributesFactory + RuleAttributesFactory, + DeviceHelperFactory val ioScope = CoroutineScope(Dispatchers.IO) @@ -103,11 +112,15 @@ open class ConfigManager( ioScope.async { deviceHelper.getGeoInfo() } + val attributesDeferred = ioScope.async { factory.makeSessionDeviceAttributes() } // Await results from both operations - val config = configDeferred.await() - geoDeferred.await() - + val (result, _, attributes) = listOf(configDeferred, geoDeferred, attributesDeferred).awaitAll() + ioScope.launch { + @Suppress("UNCHECKED_CAST") + Superwall.instance.track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap)) + } + val config = result as Config ioScope.launch { sendProductsBack(config) } Logger.debug( diff --git a/superwall/src/main/java/com/superwall/sdk/debug/DebugViewController.kt b/superwall/src/main/java/com/superwall/sdk/debug/DebugViewController.kt index e8588746..41891300 100644 --- a/superwall/src/main/java/com/superwall/sdk/debug/DebugViewController.kt +++ b/superwall/src/main/java/com/superwall/sdk/debug/DebugViewController.kt @@ -49,7 +49,6 @@ import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.request.PaywallRequestManager import com.superwall.sdk.paywall.request.ResponseIdentifiers import com.superwall.sdk.paywall.vc.ActivityEncapsulatable -import com.superwall.sdk.paywall.vc.ViewStorage import com.superwall.sdk.store.StoreKitManager import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -775,7 +774,7 @@ internal class DebugViewControllerActivity : AppCompatActivity() { view: View, ) { val key = UUID.randomUUID().toString() - ViewStorage.storeView(key, view) + Superwall.instance.viewStore().storeView(key, view) val intent = Intent(context, DebugViewControllerActivity::class.java).apply { @@ -804,7 +803,7 @@ internal class DebugViewControllerActivity : AppCompatActivity() { return } val view = - ViewStorage.retrieveView(key) ?: run { + Superwall.instance.viewStore().retrieveView(key) ?: run { finish() // Close the activity if the view associated with the key is not found return } diff --git a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt index 159e4501..351bc875 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/device/DeviceHelper.kt @@ -17,6 +17,9 @@ import com.superwall.sdk.BuildConfig import com.superwall.sdk.Superwall import com.superwall.sdk.dependencies.IdentityInfoFactory import com.superwall.sdk.dependencies.LocaleIdentifierFactory +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.network.Network @@ -24,9 +27,16 @@ import com.superwall.sdk.paywall.vc.web_view.templating.models.DeviceTemplate import com.superwall.sdk.storage.LastPaywallView import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.TotalPaywallViews +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.first +import kotlinx.coroutines.withTimeout import java.text.SimpleDateFormat import java.time.Duration -import java.util.* +import java.util.Currency +import java.util.Date +import java.util.Locale +import java.util.TimeZone +import kotlin.time.Duration.Companion.minutes enum class InterfaceStyle( val rawValue: String, @@ -95,7 +105,7 @@ class DeviceHelper( return storage.get(TotalPaywallViews) ?: 0 } - private var geoInfo: GeoInfo? = null + private val geoInfo: MutableStateFlow = MutableStateFlow(null) val locale: String get() { @@ -183,7 +193,8 @@ class DeviceHelper( val isSandbox: Boolean get() { // Not exactly the same as iOS, but similar - val isDebuggable: Boolean = (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 + val isDebuggable: Boolean = + (context.applicationInfo.flags and ApplicationInfo.FLAG_DEBUGGABLE) != 0 return isDebuggable } @@ -308,7 +319,8 @@ class DeviceHelper( // Pad beta number and add to appendix if (appendixComponents.size > 1) { - appendixVersion = String.format("%03d", appendixComponents[1].toIntOrNull() ?: 0) + appendixVersion = + String.format("%03d", appendixComponents[1].toIntOrNull() ?: 0) appendix += ".$appendixVersion" } } @@ -386,7 +398,21 @@ class DeviceHelper( suspend fun getTemplateDevice(): Map { val identityInfo = factory.makeIdentityInfo() val aliases = listOf(identityInfo.aliasId) - + val geo = + try { + withTimeout(1.minutes) { + geoInfo.first { it != null } + } + } catch (e: Throwable) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.device, + message = "Failed to get geo info - timeout", + info = emptyMap(), + error = e, + ) + null + } val deviceTemplate = DeviceTemplate( publicApiKey = storage.apiKey, @@ -433,18 +459,20 @@ class DeviceHelper( appBuildString = appBuildString, appBuildStringNumber = appBuildString.toInt(), interfaceStyleMode = if (interfaceStyleOverride == null) "automatic" else "manual", - ipRegion = geoInfo?.region, - ipRegionCode = geoInfo?.regionCode, - ipCountry = geoInfo?.country, - ipCity = geoInfo?.city, - ipContinent = geoInfo?.continent, - ipTimezone = geoInfo?.timezone, + ipRegion = geo?.region, + ipRegionCode = geo?.regionCode, + ipCountry = geo?.country, + ipCity = geo?.city, + ipContinent = geo?.continent, + ipTimezone = geo?.timezone, ) return deviceTemplate.toDictionary() } suspend fun getGeoInfo() { - geoInfo = network.getGeoInfo() + network.getGeoInfo().let { + geoInfo.value = it + } } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt index e6c64aed..47be64e8 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallViewController.kt @@ -1,41 +1,24 @@ package com.superwall.sdk.paywall.vc -import android.Manifest -import android.R import android.app.Activity -import android.app.NotificationChannel -import android.app.NotificationManager import android.content.Context import android.content.Intent -import android.content.pm.PackageManager import android.content.res.Configuration -import android.graphics.Color import android.net.Uri import android.os.Build -import android.os.Bundle import android.view.MotionEvent import android.view.View import android.view.ViewGroup import android.view.ViewTreeObserver -import android.view.Window -import android.view.WindowInsetsController -import android.view.WindowManager import android.webkit.WebSettings import android.widget.FrameLayout -import androidx.activity.OnBackPressedCallback -import androidx.appcompat.app.AppCompatActivity -import androidx.appcompat.app.AppCompatDelegate import androidx.browser.customtabs.CustomTabsIntent -import androidx.core.app.ActivityCompat -import androidx.core.app.NotificationManagerCompat -import androidx.core.content.ContextCompat import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEventObjc import com.superwall.sdk.config.models.OnDeviceCaching import com.superwall.sdk.config.options.PaywallOptions -import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.TriggerFactory import com.superwall.sdk.dependencies.TriggerSessionManagerFactory import com.superwall.sdk.game.GameControllerDelegate @@ -46,9 +29,7 @@ import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.AlertControllerFactory import com.superwall.sdk.misc.isDarkColor -import com.superwall.sdk.misc.isLightColor import com.superwall.sdk.misc.readableOverlayColor -import com.superwall.sdk.models.paywall.LocalNotification import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallPresentationStyle import com.superwall.sdk.models.triggers.TriggerRuleOccurrence @@ -75,7 +56,6 @@ import com.superwall.sdk.paywall.vc.web_view.SWWebViewDelegate import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandlerDelegate import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent import com.superwall.sdk.storage.Storage -import com.superwall.sdk.store.transactions.notifications.NotificationScheduler import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -83,8 +63,6 @@ import kotlinx.coroutines.launch import java.net.MalformedURLException import java.net.URL import java.util.* -import kotlin.coroutines.resume -import kotlin.coroutines.suspendCoroutine class PaywallViewController( context: Context, @@ -269,6 +247,7 @@ class PaywallViewController( SuperwallPaywallActivity.startWithView( presenter, this, + cacheKey, presentationStyleOverride, ) viewDidAppearCompletion = completion @@ -587,7 +566,7 @@ class PaywallViewController( action: (() -> Unit)? = null, onClose: (() -> Unit)? = null, ) { - val activity = encapsulatingActivity.let { it } ?: return + val activity = encapsulatingActivity ?: return val alertController = AlertControllerFactory.make( @@ -796,284 +775,3 @@ class PaywallViewController( interface ActivityEncapsulatable { var encapsulatingActivity: Activity? } - -class SuperwallPaywallActivity : AppCompatActivity() { - companion object { - private const val REQUEST_CODE_NOTIFICATION_PERMISSION = 1001 - const val NOTIFICATION_CHANNEL_ID = "com.superwall.android.notifications" - private const val NOTIFICATION_CHANNEL_NAME = "Trial Reminder Notifications" - private const val NOTIFICATION_CHANNEL_DESCRIPTION = "Notifications sent when a free trial is about to end." - private const val VIEW_KEY = "viewKey" - private const val PRESENTATION_STYLE_KEY = "presentationStyleKey" - private const val IS_LIGHT_BACKGROUND_KEY = "isLightBackgroundKey" - - fun startWithView( - context: Context, - view: PaywallViewController, - presentationStyleOverride: PaywallPresentationStyle? = null, - ) { - val key = UUID.randomUUID().toString() - ViewStorage.storeView(key, view) - - val intent = - Intent(context, SuperwallPaywallActivity::class.java).apply { - putExtra(VIEW_KEY, key) - putExtra(PRESENTATION_STYLE_KEY, presentationStyleOverride) - putExtra(IS_LIGHT_BACKGROUND_KEY, view.paywall.backgroundColor.isLightColor()) - flags = Intent.FLAG_ACTIVITY_SINGLE_TOP - } - - context.startActivity(intent) - } - } - - private var contentView: View? = null - private var notificationPermissionCallback: NotificationPermissionCallback? = null - - override fun setContentView(view: View) { - super.setContentView(view) - contentView = view - } - - override fun onCreate(savedInstanceState: Bundle?) { - super.onCreate(savedInstanceState) - - AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) - - // Show content behind the status bar - window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or - View.SYSTEM_UI_FLAG_LAYOUT_STABLE - window.statusBarColor = Color.TRANSPARENT - window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) - window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) - - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - val isLightBackground = intent.getBooleanExtra(IS_LIGHT_BACKGROUND_KEY, false) - if (isLightBackground) { - window.insetsController?.let { - it.setSystemBarsAppearance( - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, - ) - } - } - } - - requestWindowFeature(Window.FEATURE_NO_TITLE) - - val key = intent.getStringExtra(VIEW_KEY) - if (key == null) { - finish() // Close the activity if there's no key - return - } - - val view = - ViewStorage.retrieveView(key) as? PaywallViewController ?: run { - finish() // Close the activity if the view associated with the key is not found - return - } - - (view.parent as? ViewGroup)?.removeView(view) - view.encapsulatingActivity = this - - setContentView(view) - - onBackPressedDispatcher.addCallback( - this, - object : OnBackPressedCallback(true) { - override fun handleOnBackPressed() { - view.dismiss( - result = PaywallResult.Declined(), - closeReason = PaywallCloseReason.ManualClose, - ) - } - }, - ) - - try { - supportActionBar?.hide() - } catch (e: Throwable) { - } - - // TODO: handle animation and style from `presentationStyleOverride` - when (intent.getSerializableExtra(PRESENTATION_STYLE_KEY) as? PaywallPresentationStyle) { - PaywallPresentationStyle.PUSH -> { - overridePendingTransition(R.anim.slide_in_left, R.anim.slide_in_left) - } - PaywallPresentationStyle.DRAWER -> { - } - PaywallPresentationStyle.FULLSCREEN -> { - } - PaywallPresentationStyle.FULLSCREEN_NO_ANIMATION -> { - } - PaywallPresentationStyle.MODAL -> { - } - PaywallPresentationStyle.NONE -> { - // Do nothing - } - null -> { - // Do nothing - } - } - } - - override fun onStart() { - super.onStart() - val paywallVc = contentView as? PaywallViewController ?: return - - if (paywallVc.isSafariVCPresented) { - paywallVc.isSafariVCPresented = false - } - - paywallVc.viewWillAppear() - } - - override fun onResume() { - super.onResume() - val paywallVc = contentView as? PaywallViewController ?: return - - paywallVc.viewDidAppear() - } - - override fun onPause() { - super.onPause() - - val paywallVc = contentView as? PaywallViewController ?: return - - CoroutineScope(Dispatchers.Main).launch { - paywallVc.viewWillDisappear() - } - } - - override fun onStop() { - super.onStop() - - val paywallVc = contentView as? PaywallViewController ?: return - - CoroutineScope(Dispatchers.Main).launch { - paywallVc.viewDidDisappear() - } - } - - override fun onDestroy() { - super.onDestroy() - - // Clear reference to activity in the view - (contentView as? ActivityEncapsulatable)?.encapsulatingActivity = null - - // Clear the reference to the contentView - contentView = null - } - - //region Notifications - interface NotificationPermissionCallback { - fun onPermissionResult(granted: Boolean) - } - - suspend fun attemptToScheduleNotifications( - notifications: List, - factory: DeviceHelperFactory, - context: Context, - ) = suspendCoroutine { continuation -> - if (notifications.isEmpty()) { - continuation.resume(Unit) // Resume immediately as there's nothing to schedule - return@suspendCoroutine - } - - createNotificationChannel() - - notificationPermissionCallback = - object : NotificationPermissionCallback { - override fun onPermissionResult(granted: Boolean) { - if (granted) { - NotificationScheduler.scheduleNotifications( - notifications = notifications, - factory = factory, - context = context, - ) - } - continuation.resume(Unit) // Resume coroutine after processing - } - } - - checkAndRequestNotificationPermissions(this, notificationPermissionCallback!!) - } - - private fun createNotificationChannel() { - val importance = NotificationManager.IMPORTANCE_DEFAULT - val channel = - NotificationChannel( - NOTIFICATION_CHANNEL_ID, - NOTIFICATION_CHANNEL_NAME, - importance, - ).apply { - description = NOTIFICATION_CHANNEL_DESCRIPTION - } - channel.setShowBadge(false) - // Register the channel with the system - val notificationManager: NotificationManager = - getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - notificationManager.createNotificationChannel(channel) - } - - private fun checkAndRequestNotificationPermissions( - context: Context, - callback: NotificationPermissionCallback, - ) { - if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { - if (!ActivityCompat.shouldShowRequestPermissionRationale(context as Activity, Manifest.permission.POST_NOTIFICATIONS)) { - // First time asking or user previously denied without 'Don't ask again' - ActivityCompat.requestPermissions( - context, - arrayOf(Manifest.permission.POST_NOTIFICATIONS), - REQUEST_CODE_NOTIFICATION_PERMISSION, - ) - } else { - // Permission previously denied with 'Don't ask again' - callback.onPermissionResult(false) - } - } else { - callback.onPermissionResult(true) - } - } else { - callback.onPermissionResult(areNotificationsEnabled(context)) - } - } - - private fun areNotificationsEnabled(context: Context): Boolean { - val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager - val channel = notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) - if (channel?.importance == NotificationManager.IMPORTANCE_NONE) { - return false - } - return NotificationManagerCompat.from(context).areNotificationsEnabled() - } - - override fun onRequestPermissionsResult( - requestCode: Int, - permissions: Array, - grantResults: IntArray, - ) { - super.onRequestPermissionsResult(requestCode, permissions, grantResults) - if (requestCode == REQUEST_CODE_NOTIFICATION_PERMISSION && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { - val isGranted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED - // Invoke the callback here - notificationPermissionCallback?.onPermissionResult(isGranted) - } - } - //endregion -} - -object ViewStorage { - private val views: MutableMap = mutableMapOf() - - fun storeView( - key: String, - view: View, - ) { - views[key] = view - } - - fun retrieveView(key: String): View? = views.remove(key) -} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt new file mode 100644 index 00000000..b0d63503 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt @@ -0,0 +1,309 @@ +package com.superwall.sdk.paywall.vc + +import android.Manifest +import android.R +import android.app.Activity +import android.app.NotificationChannel +import android.app.NotificationManager +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Color +import android.os.Build +import android.os.Bundle +import android.view.View +import android.view.ViewGroup +import android.view.Window +import android.view.WindowInsetsController +import android.view.WindowManager +import androidx.activity.OnBackPressedCallback +import androidx.appcompat.app.AppCompatActivity +import androidx.appcompat.app.AppCompatDelegate +import androidx.core.app.ActivityCompat +import androidx.core.app.NotificationManagerCompat +import androidx.core.content.ContextCompat +import com.superwall.sdk.Superwall +import com.superwall.sdk.dependencies.DeviceHelperFactory +import com.superwall.sdk.misc.isLightColor +import com.superwall.sdk.models.paywall.LocalNotification +import com.superwall.sdk.models.paywall.PaywallPresentationStyle +import com.superwall.sdk.paywall.presentation.PaywallCloseReason +import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult +import com.superwall.sdk.store.transactions.notifications.NotificationScheduler +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.util.UUID +import kotlin.coroutines.resume +import kotlin.coroutines.suspendCoroutine + +class SuperwallPaywallActivity : AppCompatActivity() { + companion object { + private const val REQUEST_CODE_NOTIFICATION_PERMISSION = 1001 + const val NOTIFICATION_CHANNEL_ID = "com.superwall.android.notifications" + private const val NOTIFICATION_CHANNEL_NAME = "Trial Reminder Notifications" + private const val NOTIFICATION_CHANNEL_DESCRIPTION = "Notifications sent when a free trial is about to end." + private const val VIEW_KEY = "viewKey" + private const val PRESENTATION_STYLE_KEY = "presentationStyleKey" + private const val IS_LIGHT_BACKGROUND_KEY = "isLightBackgroundKey" + + fun startWithView( + context: Context, + view: PaywallViewController, + key: String = UUID.randomUUID().toString(), + presentationStyleOverride: PaywallPresentationStyle? = null, + ) { + val viewStorageViewModel = Superwall.instance.viewStore() + viewStorageViewModel.storeView(key, view) + + val intent = + Intent(context, SuperwallPaywallActivity::class.java).apply { + putExtra(VIEW_KEY, key) + putExtra(PRESENTATION_STYLE_KEY, presentationStyleOverride) + putExtra(IS_LIGHT_BACKGROUND_KEY, view.paywall.backgroundColor.isLightColor()) + flags = Intent.FLAG_ACTIVITY_SINGLE_TOP + } + + context.startActivity(intent) + } + } + + private var contentView: View? = null + private var notificationPermissionCallback: NotificationPermissionCallback? = null + + override fun setContentView(view: View) { + super.setContentView(view) + contentView = view + } + + override fun onCreate(savedInstanceState: Bundle?) { + super.onCreate(savedInstanceState) + + AppCompatDelegate.setCompatVectorFromResourcesEnabled(true) + + // Show content behind the status bar + window.decorView.systemUiVisibility = View.SYSTEM_UI_FLAG_LAYOUT_FULLSCREEN or + View.SYSTEM_UI_FLAG_LAYOUT_STABLE + window.statusBarColor = Color.TRANSPARENT + window.clearFlags(WindowManager.LayoutParams.FLAG_TRANSLUCENT_STATUS) + window.addFlags(WindowManager.LayoutParams.FLAG_DRAWS_SYSTEM_BAR_BACKGROUNDS) + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { + val isLightBackground = intent.getBooleanExtra(IS_LIGHT_BACKGROUND_KEY, false) + if (isLightBackground) { + window.insetsController?.let { + it.setSystemBarsAppearance( + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + WindowInsetsController.APPEARANCE_LIGHT_STATUS_BARS, + ) + } + } + } + + requestWindowFeature(Window.FEATURE_NO_TITLE) + + val key = intent.getStringExtra(VIEW_KEY) + if (key == null) { + finish() // Close the activity if there's no key + return + } + + val viewStorageViewModel = Superwall.instance.viewStore() + + val view = + viewStorageViewModel.retrieveView(key) as? PaywallViewController ?: run { + finish() // Close the activity if the view associated with the key is not found + return + } + + (view.parent as? ViewGroup)?.removeView(view) + view.encapsulatingActivity = this + + setContentView(view) + + onBackPressedDispatcher.addCallback( + this, + object : OnBackPressedCallback(true) { + override fun handleOnBackPressed() { + view.dismiss( + result = PaywallResult.Declined(), + closeReason = PaywallCloseReason.ManualClose, + ) + } + }, + ) + + try { + supportActionBar?.hide() + } catch (e: Throwable) { + } + + // TODO: handle animation and style from `presentationStyleOverride` + when (intent.getSerializableExtra(PRESENTATION_STYLE_KEY) as? PaywallPresentationStyle) { + PaywallPresentationStyle.PUSH -> { + overridePendingTransition(R.anim.slide_in_left, R.anim.slide_in_left) + } + PaywallPresentationStyle.DRAWER -> { + } + PaywallPresentationStyle.FULLSCREEN -> { + } + PaywallPresentationStyle.FULLSCREEN_NO_ANIMATION -> { + } + PaywallPresentationStyle.MODAL -> { + } + PaywallPresentationStyle.NONE -> { + // Do nothing + } + null -> { + // Do nothing + } + } + } + + override fun onStart() { + super.onStart() + val paywallVc = contentView as? PaywallViewController ?: return + + if (paywallVc.isSafariVCPresented) { + paywallVc.isSafariVCPresented = false + } + + paywallVc.viewWillAppear() + } + + override fun onResume() { + super.onResume() + val paywallVc = contentView as? PaywallViewController ?: return + + paywallVc.viewDidAppear() + } + + override fun onPause() { + super.onPause() + + val paywallVc = contentView as? PaywallViewController ?: return + + CoroutineScope(Dispatchers.Main).launch { + paywallVc.viewWillDisappear() + } + } + + override fun onStop() { + super.onStop() + + val paywallVc = contentView as? PaywallViewController ?: return + + CoroutineScope(Dispatchers.Main).launch { + paywallVc.viewDidDisappear() + } + } + + override fun onDestroy() { + super.onDestroy() + (contentView?.parent as? ViewGroup)?.removeView(contentView) + // Clear reference to activity in the view + (contentView as? ActivityEncapsulatable)?.encapsulatingActivity = null + + // Clear the reference to the contentView + contentView = null + } + + //region Notifications + interface NotificationPermissionCallback { + fun onPermissionResult(granted: Boolean) + } + + suspend fun attemptToScheduleNotifications( + notifications: List, + factory: DeviceHelperFactory, + context: Context, + ) = suspendCoroutine { continuation -> + if (notifications.isEmpty()) { + continuation.resume(Unit) // Resume immediately as there's nothing to schedule + return@suspendCoroutine + } + + createNotificationChannel() + + notificationPermissionCallback = + object : NotificationPermissionCallback { + override fun onPermissionResult(granted: Boolean) { + if (granted) { + NotificationScheduler.scheduleNotifications( + notifications = notifications, + factory = factory, + context = context, + ) + } + continuation.resume(Unit) // Resume coroutine after processing + } + } + + checkAndRequestNotificationPermissions(this, notificationPermissionCallback!!) + } + + private fun createNotificationChannel() { + val importance = NotificationManager.IMPORTANCE_DEFAULT + val channel = + NotificationChannel( + NOTIFICATION_CHANNEL_ID, + NOTIFICATION_CHANNEL_NAME, + importance, + ).apply { + description = NOTIFICATION_CHANNEL_DESCRIPTION + } + channel.setShowBadge(false) + // Register the channel with the system + val notificationManager: NotificationManager = + getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + notificationManager.createNotificationChannel(channel) + } + + private fun checkAndRequestNotificationPermissions( + context: Context, + callback: NotificationPermissionCallback, + ) { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + if (ContextCompat.checkSelfPermission(context, Manifest.permission.POST_NOTIFICATIONS) != PackageManager.PERMISSION_GRANTED) { + if (!ActivityCompat.shouldShowRequestPermissionRationale(context as Activity, Manifest.permission.POST_NOTIFICATIONS)) { + // First time asking or user previously denied without 'Don't ask again' + ActivityCompat.requestPermissions( + context, + arrayOf(Manifest.permission.POST_NOTIFICATIONS), + REQUEST_CODE_NOTIFICATION_PERMISSION, + ) + } else { + // Permission previously denied with 'Don't ask again' + callback.onPermissionResult(false) + } + } else { + callback.onPermissionResult(true) + } + } else { + callback.onPermissionResult(areNotificationsEnabled(context)) + } + } + + private fun areNotificationsEnabled(context: Context): Boolean { + val notificationManager = getSystemService(Context.NOTIFICATION_SERVICE) as NotificationManager + val channel = notificationManager.getNotificationChannel(NOTIFICATION_CHANNEL_ID) + if (channel?.importance == NotificationManager.IMPORTANCE_NONE) { + return false + } + return NotificationManagerCompat.from(context).areNotificationsEnabled() + } + + override fun onRequestPermissionsResult( + requestCode: Int, + permissions: Array, + grantResults: IntArray, + ) { + super.onRequestPermissionsResult(requestCode, permissions, grantResults) + if (requestCode == REQUEST_CODE_NOTIFICATION_PERMISSION && Build.VERSION.SDK_INT >= Build.VERSION_CODES.TIRAMISU) { + val isGranted = grantResults.isNotEmpty() && grantResults[0] == PackageManager.PERMISSION_GRANTED + // Invoke the callback here + notificationPermissionCallback?.onPermissionResult(isGranted) + } + } + //endregion +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt new file mode 100644 index 00000000..4315d50c --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt @@ -0,0 +1,20 @@ +package com.superwall.sdk.paywall.vc + +import androidx.lifecycle.ViewModel +import androidx.lifecycle.ViewModelProvider +import androidx.lifecycle.ViewModelStore +import androidx.lifecycle.ViewModelStoreOwner + +internal class SuperwallStoreOwner : ViewModelStoreOwner { + override val viewModelStore: ViewModelStore = ViewModelStore() +} + +internal class ViewModelFactory : ViewModelProvider.Factory { + override fun create(modelClass: Class): T { + if (modelClass.isAssignableFrom(ViewStorageViewModel::class.java)) { + @Suppress("UNCHECKED_CAST") + return ViewStorageViewModel() as T + } + throw IllegalArgumentException("Unknown ViewModel class") + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorage.kt new file mode 100644 index 00000000..b81d0d13 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorage.kt @@ -0,0 +1,16 @@ +package com.superwall.sdk.paywall.vc + +import android.view.View + +interface ViewStorage { + val views: MutableMap + + fun storeView( + key: String, + view: View, + ) { + views[key] = view + } + + fun retrieveView(key: String): View? = views.get(key) +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt new file mode 100644 index 00000000..9db6f8af --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt @@ -0,0 +1,22 @@ +package com.superwall.sdk.paywall.vc + +import android.view.View +import androidx.lifecycle.ViewModel + +/* +* Stores already loaded or preloaded paywalls +* */ +internal class ViewStorageViewModel : + ViewModel(), + ViewStorage { + override val views = mutableMapOf() + + override fun storeView( + key: String, + view: View, + ) { + views[key] = view + } + + override fun retrieveView(key: String): View? = views[key] +}