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 9158a883..035ea812 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -2,7 +2,6 @@ package com.superwall.sdk.config import android.content.Context import android.webkit.WebView -import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.config.options.SuperwallOptions @@ -34,7 +33,10 @@ import kotlinx.coroutines.GlobalScope import kotlinx.coroutines.Job import kotlinx.coroutines.async 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 @@ -47,7 +49,8 @@ open class ConfigManager( private val paywallManager: PaywallManager, private val factory: Factory ) { - interface Factory: RequestFactory, DeviceInfoFactory, RuleAttributesFactory {} + interface Factory : RequestFactory, DeviceInfoFactory, RuleAttributesFactory {} + var options = SuperwallOptions() // The configuration of the Superwall dashboard @@ -289,15 +292,19 @@ open class ConfigManager( presentationSourceType = null, retryCount = 6 ) - try { - paywallManager.getPaywallViewController( - request = request, - isForPresentation = true, - isPreloading = true, - delegate = null - ) - } catch (e: Exception) { - // Handle exception + val shouldSkip = paywallManager + .preloadViaPaywallArchivalAndShouldSkipViewControllerCache(request = request) + if (!shouldSkip) { + try { + paywallManager.getPaywallViewController( + request = request, + isForPresentation = true, + isPreloading = true, + delegate = null + ) + } catch (e: Exception) { + // Handle exception + } } } tasks.add(task) 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 5f490f45..b2cabc2b 100644 --- a/superwall/src/main/java/com/superwall/sdk/debug/DebugViewController.kt +++ b/superwall/src/main/java/com/superwall/sdk/debug/DebugViewController.kt @@ -440,7 +440,8 @@ class DebugViewController( val paywallVc = factory.makePaywallViewController( paywall = paywall, cache = null, - delegate = null + delegate = null, + paywallArchivalManager = null ) previewContainerView.addView(paywallVc) previewViewContent = paywallVc diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt index da45f7da..8cc9f502 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -24,9 +24,9 @@ import com.superwall.sdk.delegate.SuperwallDelegateAdapter import com.superwall.sdk.delegate.subscription_controller.PurchaseController import com.superwall.sdk.identity.IdentityInfo import com.superwall.sdk.identity.IdentityManager -import com.superwall.sdk.misc.CurrentActivityTracker import com.superwall.sdk.misc.ActivityProvider import com.superwall.sdk.misc.AppLifecycleObserver +import com.superwall.sdk.misc.CurrentActivityTracker import com.superwall.sdk.models.config.FeatureFlags import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall @@ -35,6 +35,7 @@ import com.superwall.sdk.network.Api import com.superwall.sdk.network.Network import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.network.device.DeviceInfo +import com.superwall.sdk.paywall.archival.PaywallArchivalManager import com.superwall.sdk.paywall.manager.PaywallManager import com.superwall.sdk.paywall.manager.PaywallViewControllerCache import com.superwall.sdk.paywall.presentation.internal.PresentationRequest @@ -52,6 +53,7 @@ import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.vc.web_view.templating.models.JsonVariables import com.superwall.sdk.paywall.vc.web_view.templating.models.Variables import com.superwall.sdk.storage.EventsQueue +import com.superwall.sdk.storage.SearchPathDirectory import com.superwall.sdk.storage.Storage import com.superwall.sdk.store.InternalPurchaseController import com.superwall.sdk.store.StoreKitManager @@ -76,7 +78,8 @@ class DependencyContainer( StoreTransactionFactory, Storage.Factory, InternalSuperwallEvent.PresentationRequest.Factory, ViewControllerFactory, PaywallManager.Factory, OptionsFactory, TriggerFactory, TransactionVerifierFactory, TransactionManager.Factory, PaywallViewController.Factory, - ConfigManager.Factory, AppSessionManager.Factory, DebugViewController.Factory { + ConfigManager.Factory, AppSessionManager.Factory, DebugViewController.Factory, + PaywallArchivalManagerFactory { var network: Network override lateinit var api: Api @@ -95,7 +98,8 @@ class DependencyContainer( var storeKitManager: StoreKitManager val transactionManager: TransactionManager val googleBillingWrapper: GoogleBillingWrapper - + val paywallArchivalManager: PaywallArchivalManager = + PaywallArchivalManager(baseDirectory = SearchPathDirectory.USER_SPECIFIC_DOCUMENTS.fileDirectory(context)) init { // TODO: Add delegate adapter @@ -247,6 +251,7 @@ class DependencyContainer( override suspend fun makePaywallViewController( paywall: Paywall, cache: PaywallViewControllerCache?, + paywallArchivalManager: PaywallArchivalManager?, delegate: PaywallViewControllerDelegateAdapter? ): PaywallViewController { return withContext(Dispatchers.Main) { @@ -278,7 +283,8 @@ class DependencyContainer( paywallManager = paywallManager, storage = storage, webView = webView, - eventDelegate = Superwall.instance + eventDelegate = Superwall.instance, + paywallArchivalManager = paywallArchivalManager ) webView.delegate = paywallViewController messageHandler.delegate = paywallViewController @@ -500,4 +506,9 @@ class DependencyContainer( override suspend fun makeTriggers(): Set<String> { return configManager.triggersByEventName.keys } + + override fun makePaywallArchivalManager(): PaywallArchivalManager { + return this.paywallArchivalManager + } + } \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt index 21e1d814..a9dbc0d6 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -20,6 +20,7 @@ import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.network.Api import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.network.device.DeviceInfo +import com.superwall.sdk.paywall.archival.PaywallArchivalManager import com.superwall.sdk.paywall.manager.PaywallViewControllerCache import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType @@ -133,6 +134,7 @@ interface ViewControllerFactory { suspend fun makePaywallViewController( paywall: Paywall, cache: PaywallViewControllerCache?, + paywallArchivalManager: PaywallArchivalManager?, delegate: PaywallViewControllerDelegateAdapter? ): PaywallViewController @@ -157,6 +159,9 @@ interface ViewControllerFactory { // fun makeCache(): PaywallViewControllerCache //} +interface PaywallArchivalManagerFactory { + fun makePaywallArchivalManager(): PaywallArchivalManager +} interface CacheFactory { fun makeCache(): PaywallViewControllerCache diff --git a/superwall/src/main/java/com/superwall/sdk/misc/RequestCoalescence.kt b/superwall/src/main/java/com/superwall/sdk/misc/RequestCoalescence.kt new file mode 100644 index 00000000..79de0198 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/RequestCoalescence.kt @@ -0,0 +1,24 @@ +package com.superwall.sdk.misc + +import kotlinx.coroutines.CompletableDeferred +import java.util.concurrent.ConcurrentHashMap + +class RequestCoalescence<Input : Any, Output> { + private val tasks = ConcurrentHashMap<Input, CompletableDeferred<Output>>() + + suspend fun get(input: Input, request: suspend (Input) -> Output): Output { + val existingTask = tasks[input] + return if (existingTask != null) { + // If there's already a task in progress, wait for it to finish + existingTask.await() + } else { + // Start a new task if one isn't already in progress + val newTask = CompletableDeferred<Output>() + tasks[input] = newTask + val output = request(input) + newTask.complete(output) + tasks.remove(input) + output + } + } +} \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt index 22b4dc2e..2adbd40c 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt @@ -100,7 +100,11 @@ data class Paywall( /** Surveys to potentially show when an action happens in the paywall. */ - var surveys: List<Survey> = emptyList() + var surveys: List<Survey> = emptyList(), + + // A listing of all the files referenced in a paywall + // to be able to preload the whole paywall into a web archive + val manifest: ArchivalManifest? = null ) : SerializableEntity { // Public getter for productItems @@ -244,7 +248,8 @@ data class Paywall( paywalljsVersion = "", isFreeTrialAvailable = false, featureGating = FeatureGatingBehavior.NonGated, - localNotifications = arrayListOf() + localNotifications = arrayListOf(), + manifest = null ) } diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallManifest.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallManifest.kt new file mode 100644 index 00000000..07cc2503 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/PaywallManifest.kt @@ -0,0 +1,23 @@ +package com.superwall.sdk.models.paywall + +import com.superwall.sdk.models.serialization.URLSerializer +import kotlinx.serialization.Serializable +import java.net.URL + +@Serializable +enum class ArchivalManifestUsage { + ALWAYS, NEVER, IF_AVAILABLE_ON_PAYWALL_OPEN +} + +@Serializable +data class ArchivalManifest( + val use: ArchivalManifestUsage, + val document: ArchivalManifestItem, + val resources: List<ArchivalManifestItem> +) + +@Serializable +data class ArchivalManifestItem( + val url: @Serializable(with = URLSerializer::class) URL, + val mimeType: String +) diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/WebArchive.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/WebArchive.kt new file mode 100644 index 00000000..81c00c38 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/WebArchive.kt @@ -0,0 +1,14 @@ +package com.superwall.sdk.models.paywall + +import java.net.URL + +data class WebArchive( + val mainResource: WebArchiveResource, + val subResources: List<WebArchiveResource> +) + +class WebArchiveResource( + val url: URL, + val data: ByteArray, + val mimeType: String +) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/archival/PaywallArchivalManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/archival/PaywallArchivalManager.kt new file mode 100644 index 00000000..cd3a0aa1 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/archival/PaywallArchivalManager.kt @@ -0,0 +1,77 @@ +package com.superwall.sdk.paywall.archival + +import com.superwall.sdk.misc.Result +import com.superwall.sdk.models.paywall.ArchivalManifestUsage +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.paywall.WebArchive +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import java.io.File + +class PaywallArchivalManager( + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val ioCoroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, + private var webArchiveManager: WebArchiveManager? = null, + baseDirectory: File? = null, +) { + + init { + if (webArchiveManager == null && baseDirectory != null) { + webArchiveManager = WebArchiveManager(baseDirectory = baseDirectory.resolve("paywalls")) + } + } + + fun preloadArchiveAndShouldSkipViewControllerCache(paywall: Paywall): Boolean { + webArchiveManager?.let { webArchiveManager -> + if (paywall.manifest != null) { + if (paywall.manifest.use == ArchivalManifestUsage.NEVER) { + return false + } + coroutineScope.launch(ioCoroutineDispatcher) { + webArchiveManager.archiveForManifest(manifest = paywall.manifest) + } + return true + } + } + return false + } + + // If we should be really aggressive and wait for the archival to finish + // before we load + fun shouldWaitForWebArchiveToLoad(paywall: Paywall): Boolean { + return webArchiveManager != null && paywall.manifest?.use == ArchivalManifestUsage.ALWAYS + } + + // We'll try to see if it's cached, if not we'll just + // skip it and fall back to the normal method of loading + fun cachedArchiveForPaywallImmediately(paywall: Paywall): WebArchive? { + webArchiveManager?.let { webArchiveManager -> + if (paywall.manifest != null) { + if (paywall.manifest.use == ArchivalManifestUsage.NEVER) { + return null + } + return webArchiveManager.archiveForManifestImmediately(manifest = paywall.manifest) + } + } + return null + } + + suspend fun cachedArchiveForPaywall(paywall: Paywall): WebArchive? { + webArchiveManager?.let { webArchiveManager -> + if (paywall.manifest != null) { + if (paywall.manifest.use == ArchivalManifestUsage.NEVER) { + return null + } + val result = webArchiveManager.archiveForManifest(manifest = paywall.manifest) + return when (result) { + is Result.Success -> result.value + is Result.Failure -> null + } + } + } + return null + } + +} \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/archival/WebArchiveFile.kt b/superwall/src/main/java/com/superwall/sdk/paywall/archival/WebArchiveFile.kt new file mode 100644 index 00000000..25f0e222 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/archival/WebArchiveFile.kt @@ -0,0 +1,64 @@ +package com.superwall.sdk.paywall.archival + +import com.superwall.sdk.models.paywall.WebArchive +import com.superwall.sdk.models.paywall.WebArchiveResource +import java.io.BufferedReader +import java.io.BufferedWriter +import java.io.File +import java.io.FileReader +import java.io.FileWriter +import java.net.URL +import java.util.Base64 + +fun WebArchive.writeToFile(file: File) { + BufferedWriter(FileWriter(file)).use { writer -> + mainResource.write(writer) + subResources.forEach { resource -> + resource.write(writer) + } + } +} + +private fun WebArchiveResource.write(bufferedWriter: BufferedWriter) { + with(bufferedWriter) { + appendLine(url.toString()) + appendLine(mimeType) + appendLine(Base64.getEncoder().encodeToString(data)) + } +} + +fun File.readWebArchive(): WebArchive? { + return BufferedReader(FileReader(this)).use { reader -> + val mainResource = reader.readWebArchiveResource() + if (mainResource != null) { + val subResources = mutableListOf<WebArchiveResource>() + var resource = reader.readWebArchiveResource() + while (resource != null) { + subResources.add(resource) + resource = reader.readWebArchiveResource() + } + WebArchive( + mainResource = mainResource, + subResources = subResources + ) + } else { + null + } + } +} + +private fun BufferedReader.readWebArchiveResource(): WebArchiveResource? { + val url = readLine() + val mimeType = readLine() + val data = readLine() + return if (url != null && mimeType != null && data != null) { + WebArchiveResource( + url = URL(url), + mimeType = mimeType, + data = Base64.getDecoder().decode(data) + + ) + } else { + null + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/archival/WebArchiveManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/archival/WebArchiveManager.kt new file mode 100644 index 00000000..90bf3eaa --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/archival/WebArchiveManager.kt @@ -0,0 +1,127 @@ +package com.superwall.sdk.paywall.archival + +import com.superwall.sdk.misc.RequestCoalescence +import com.superwall.sdk.misc.Result +import com.superwall.sdk.models.paywall.ArchivalManifest +import com.superwall.sdk.models.paywall.ArchivalManifestItem +import com.superwall.sdk.models.paywall.WebArchive +import com.superwall.sdk.models.paywall.WebArchiveResource +import kotlinx.coroutines.CoroutineDispatcher +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async +import kotlinx.coroutines.withContext +import java.io.File +import java.net.HttpURLConnection +import java.net.URL + +class WebArchiveManager( + private val baseDirectory: File, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val ioCoroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val requestCoalescence: RequestCoalescence<ArchivalManifestItem, WebArchiveResource> = RequestCoalescence(), + private val archivalCoalescence: RequestCoalescence<ArchivalManifest, Result<WebArchive>> = RequestCoalescence() +) { + + fun archiveForManifestImmediately(manifest: ArchivalManifest): WebArchive? { + val archiveFile = fsPath(forUrl = manifest.document.url) + return if (archiveFile.exists()) { + readArchiveFromFile(archiveFile) + } else { + null + } + } + + suspend fun archiveForManifest(manifest: ArchivalManifest): Result<WebArchive> = + withContext(ioCoroutineDispatcher) { + val archiveFile = fsPath(forUrl = manifest.document.url) + if (archiveFile.exists()) { + val archive = readArchiveFromFile(archiveFile) + if (archive != null) { + Result.Success(archive) + } else { + Result.Failure(Exception("Reading failed")) + } + } else { + archivalCoalescence.get(input = manifest) { + createArchiveForManifest(manifest = it) + } + } + } + + private suspend fun createArchiveForManifest(manifest: ArchivalManifest): Result<WebArchive> { + return try { + val archive = downloadManifest(manifest = manifest) + val targetFile = fsPath(forUrl = manifest.document.url) + writeArchiveToFile(archive = archive, file = targetFile) + Result.Success(archive) + } catch (exception: Exception) { + Result.Failure(exception) + } + } + + // Consistent way to look up the appropriate directory for a given url + private fun fsPath(forUrl: URL): File { + val hostDashed = forUrl.host?.split(".")?.joinToString(separator = "-") ?: "unknown" + var path = baseDirectory.resolve(hostDashed.replace(oldValue = "/", newValue = "")) + forUrl.path.split("/").filter { it.isNotEmpty() }.forEach { item -> + path = path.resolve(item) + } + path = File("${path.path}/cached.custom_web_archive") + + return path + } + + private suspend fun downloadManifest(manifest: ArchivalManifest): WebArchive { + val mainResourceDeferred = coroutineScope.async { + requestCoalescence.get(manifest.document) { + fetchDataForManifest(manifestItem = it) + } + } + val subResourcesDeferred = manifest.resources.map { resource -> + coroutineScope.async { + requestCoalescence.get(resource) { + fetchDataForManifest(manifestItem = it) + } + } + } + val mainResource = mainResourceDeferred.await() + val subResources = subResourcesDeferred.map { it.await() } + return WebArchive(mainResource = mainResource, subResources = subResources) + } + + private fun writeArchiveToFile(archive: WebArchive, file: File) { + if (file.parentFile?.mkdirs() != false) { + archive.writeToFile(file) + } else { + throw Exception("Cannot create directory") + } + } + + private fun readArchiveFromFile(file: File): WebArchive? { + return try { + file.readWebArchive() + } catch (exception: Exception) { + null + } + } + + private fun fetchDataForManifest(manifestItem: ArchivalManifestItem): WebArchiveResource { + val connection = manifestItem.url.openConnection() as HttpURLConnection + connection.requestMethod = "GET" + connection.useCaches = true + val responseCode = connection.responseCode + return if (responseCode == HttpURLConnection.HTTP_OK) { + val data = connection.inputStream.readBytes() + connection.disconnect() + WebArchiveResource( + url = manifestItem.url, + mimeType = manifestItem.mimeType, + data = data, + ) + } else { + connection.disconnect() + throw Exception("Invalid response for resource: ${manifestItem.url}") + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt index 436e0bcb..614dccd1 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallManager.kt @@ -2,7 +2,9 @@ package com.superwall.sdk.paywall.manager import com.superwall.sdk.dependencies.CacheFactory import com.superwall.sdk.dependencies.DeviceHelperFactory +import com.superwall.sdk.dependencies.PaywallArchivalManagerFactory import com.superwall.sdk.dependencies.ViewControllerFactory +import com.superwall.sdk.paywall.archival.PaywallArchivalManager import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.paywall.request.PaywallRequestManager import com.superwall.sdk.paywall.vc.PaywallViewController @@ -14,10 +16,11 @@ import kotlinx.coroutines.launch import kotlinx.coroutines.withContext class PaywallManager( - private val factory: PaywallManager.Factory, + private val factory: Factory, private val paywallRequestManager: PaywallRequestManager ) { - interface Factory: ViewControllerFactory, CacheFactory, DeviceHelperFactory {} + interface Factory : ViewControllerFactory, CacheFactory, DeviceHelperFactory, + PaywallArchivalManagerFactory var presentedViewController: PaywallViewController? = null get() = cache.activePaywallViewController @@ -32,6 +35,9 @@ class PaywallManager( return _cache!! } + private val paywallArchivalManager: PaywallArchivalManager = + factory.makePaywallArchivalManager() + private fun createCache(): PaywallViewControllerCache { val cache: PaywallViewControllerCache = factory.makeCache() _cache = cache @@ -52,6 +58,19 @@ class PaywallManager( } } + /// First, this gets the paywall response for a specified paywall identifier or trigger event. + /// It then checks with the archival manager to tell us if we should still eagerly create the + /// view controller or not. + /// + /// - Parameters: + /// - request: The request to get the paywall. + suspend fun preloadViaPaywallArchivalAndShouldSkipViewControllerCache( + request: PaywallRequest + ): Boolean { + val paywall = paywallRequestManager.getPaywall(request = request) + return paywallArchivalManager.preloadArchiveAndShouldSkipViewControllerCache(paywall = paywall) + } + suspend fun getPaywallViewController( request: PaywallRequest, isForPresentation: Boolean, @@ -79,7 +98,8 @@ class PaywallManager( val paywallViewController = factory.makePaywallViewController( paywall = paywall, cache = cache, - delegate = delegate + delegate = delegate, + paywallArchivalManager = paywallArchivalManager ) cache.save(paywallViewController, cacheKey) 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 02b480eb..4906f6d8 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 @@ -52,6 +52,7 @@ import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.paywall.PaywallPresentationStyle import com.superwall.sdk.models.triggers.TriggerRuleOccurrence import com.superwall.sdk.network.device.DeviceHelper +import com.superwall.sdk.paywall.archival.PaywallArchivalManager import com.superwall.sdk.paywall.manager.PaywallCacheLogic import com.superwall.sdk.paywall.manager.PaywallManager import com.superwall.sdk.paywall.manager.PaywallViewControllerCache @@ -75,13 +76,16 @@ import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandlerDele 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.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow import kotlinx.coroutines.launch +import kotlinx.coroutines.withContext import java.net.MalformedURLException import java.net.URL -import java.util.* +import java.util.Date +import java.util.UUID import kotlin.coroutines.resume import kotlin.coroutines.suspendCoroutine @@ -97,9 +101,14 @@ class PaywallViewController( val paywallManager: PaywallManager, override val webView: SWWebView, val cache: PaywallViewControllerCache?, - private val loadingViewController: LoadingViewController = LoadingViewController(context) -) : FrameLayout(context), PaywallMessageHandlerDelegate, SWWebViewDelegate, ActivityEncapsulatable, GameControllerDelegate { - interface Factory: TriggerSessionManagerFactory, TriggerFactory {} + private val loadingViewController: LoadingViewController = LoadingViewController(context), + val paywallArchivalManager: PaywallArchivalManager?, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main), + private val ioCoroutineDispatcher: CoroutineDispatcher = Dispatchers.IO, + private val mainCoroutineDispatcher: CoroutineDispatcher = Dispatchers.Main +) : FrameLayout(context), PaywallMessageHandlerDelegate, SWWebViewDelegate, ActivityEncapsulatable, + GameControllerDelegate { + interface Factory : TriggerSessionManagerFactory, TriggerFactory {} //region Public properties // MUST be set prior to presentation @@ -663,23 +672,51 @@ class PaywallViewController( paywall.webviewLoadingInfo.startAt = Date() } - CoroutineScope(Dispatchers.IO).launch { + coroutineScope.launch { val trackedEvent = InternalSuperwallEvent.PaywallWebviewLoad( state = InternalSuperwallEvent.PaywallWebviewLoad.State.Start(), paywallInfo = this@PaywallViewController.info ) Superwall.instance.track(trackedEvent) } + coroutineScope.launch(ioCoroutineDispatcher) { + if (paywallArchivalManager?.shouldWaitForWebArchiveToLoad(paywall = paywall) == true) { + // + // There is still a chance something goes wrong so we can fall back to the + // other loading method if we really need to + // + val webArchive = paywallArchivalManager.cachedArchiveForPaywall(paywall = paywall) + withContext(mainCoroutineDispatcher) { + if (webArchive != null) { + webView.loadWebArchive(webArchive = webArchive) + } else { + loadWebViewFromUrl(url = url) + } + } - if (paywall.onDeviceCache is OnDeviceCaching.Enabled) { - webView.settings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK - } else { - webView.settings.cacheMode = WebSettings.LOAD_DEFAULT + } else { + val webArchive = + paywallArchivalManager?.cachedArchiveForPaywallImmediately(paywall = paywall) + if (webArchive != null) { + webView.loadWebArchive(webArchive = webArchive) + } else { + loadWebViewFromUrl(url = url) + } + } } + loadingState = PaywallLoadingState.LoadingURL() + } - webView.loadUrl(url.toString()) + private suspend fun loadWebViewFromUrl(url: URL) { + withContext(mainCoroutineDispatcher) { + if (paywall.onDeviceCache is OnDeviceCaching.Enabled) { + webView.settings.cacheMode = WebSettings.LOAD_CACHE_ELSE_NETWORK + } else { + webView.settings.cacheMode = WebSettings.LOAD_DEFAULT + } - loadingState = PaywallLoadingState.LoadingURL() + webView.loadUrl(url.toString()) + } } //endregion diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt index 1855d8b3..bff095f1 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/SWWebView.kt @@ -9,21 +9,28 @@ import android.view.MotionEvent import android.view.inputmethod.BaseInputConnection import android.view.inputmethod.EditorInfo import android.view.inputmethod.InputConnection -import android.webkit.* +import android.webkit.ConsoleMessage +import android.webkit.WebChromeClient +import android.webkit.WebResourceError +import android.webkit.WebResourceRequest +import android.webkit.WebView +import android.webkit.WebViewClient import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.SessionEventsManager import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.analytics.trigger_session.LoadState import com.superwall.sdk.game.dispatchKeyEvent import com.superwall.sdk.game.dispatchMotionEvent +import com.superwall.sdk.models.paywall.WebArchive import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandler import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallMessageHandlerDelegate +import kotlinx.coroutines.CoroutineDispatcher import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.launch -import java.util.* +import kotlinx.coroutines.withContext +import java.util.Date interface _SWWebViewDelegate { @@ -35,7 +42,9 @@ interface SWWebViewDelegate : _SWWebViewDelegate, PaywallMessageHandlerDelegate class SWWebView( context: Context, private val sessionEventsManager: SessionEventsManager, - val messageHandler: PaywallMessageHandler + val messageHandler: PaywallMessageHandler, + private val coroutineScope: CoroutineScope = CoroutineScope(Dispatchers.Main), + private val mainCoroutineDispatcher: CoroutineDispatcher = Dispatchers.Main ) : WebView(context) { var delegate: SWWebViewDelegate? = null @@ -83,7 +92,7 @@ class SWWebView( request: WebResourceRequest?, error: WebResourceError? ) { - CoroutineScope(Dispatchers.Main).launch { + coroutineScope.launch { trackPaywallError() } } @@ -131,6 +140,27 @@ class SWWebView( super.loadUrl(urlString) } + suspend fun loadWebArchive(webArchive: WebArchive) { + withContext(mainCoroutineDispatcher) { + webViewClient = object : WebArchiveWebViewClient(webArchive = webArchive) { + + override fun onReceivedError( + view: WebView?, + request: WebResourceRequest?, + error: WebResourceError? + ) { + coroutineScope.launch { + trackPaywallError() + } + } + + } + //use the url of the main resource so the relative paths of references are prepended + //with the same base url WebViewClient's shouldInterceptRequest() + loadUrl(webArchive.mainResource.url.toString()) + } + } + private suspend fun trackPaywallError() { delegate?.paywall?.webviewLoadingInfo?.failAt = Date() diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebArchiveWebViewClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebArchiveWebViewClient.kt new file mode 100644 index 00000000..272264d5 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebArchiveWebViewClient.kt @@ -0,0 +1,34 @@ +package com.superwall.sdk.paywall.vc.web_view + +import android.webkit.WebResourceRequest +import android.webkit.WebResourceResponse +import android.webkit.WebView +import android.webkit.WebViewClient +import com.superwall.sdk.models.paywall.WebArchive +import com.superwall.sdk.models.paywall.WebArchiveResource + +open class WebArchiveWebViewClient( + val webArchive: WebArchive +) : WebViewClient() { + + override fun shouldInterceptRequest( + view: WebView, + request: WebResourceRequest + ): WebResourceResponse? { + val url = request.url.toString() + return if (url.contains(webArchive.mainResource.url.toString())) { + webArchive.mainResource.toWebResourceResponse() + } else { + //if no subresource for url exists the method returns null and the subresource is loaded from network + webArchive.subResources.find { it.url.toString() == url }?.toWebResourceResponse() + } + } + + override fun shouldOverrideUrlLoading(view: WebView, request: WebResourceRequest): Boolean { + return true + } + + private fun WebArchiveResource.toWebResourceResponse() = + WebResourceResponse(mimeType, Charsets.UTF_8.name(), data.inputStream()) + +} \ No newline at end of file