Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
Binary file added app/src/main/assets/sample.avi
Binary file not shown.
Binary file added app/src/main/assets/sample.m4a
Binary file not shown.
Binary file added app/src/main/assets/sample.mov
Binary file not shown.
Binary file added app/src/main/assets/sample.mp4
Binary file not shown.
Binary file added app/src/main/assets/sample.webm
Binary file not shown.
Binary file added app/src/main/assets/sample.webp
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/src/main/assets/sw_image_1.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
Binary file added app/src/main/res/drawable/sw_image.png
Loading
Sorry, something went wrong. Reload?
Sorry, we cannot display this file.
Sorry, this file is invalid so it cannot be displayed.
20 changes: 20 additions & 0 deletions superwall/src/main/java/com/superwall/sdk/Superwall.kt
Original file line number Diff line number Diff line change
Expand Up @@ -57,6 +57,7 @@ import com.superwall.sdk.paywall.view.PaywallViewState
import com.superwall.sdk.paywall.view.SuperwallPaywallActivity
import com.superwall.sdk.paywall.view.delegate.PaywallLoadingState
import com.superwall.sdk.paywall.view.delegate.PaywallViewEventCallback
import com.superwall.sdk.paywall.view.webview.PaywallResource
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.Closed
import com.superwall.sdk.paywall.view.webview.messaging.PaywallWebEvent.Custom
Expand Down Expand Up @@ -208,6 +209,25 @@ class Superwall(
options.paywalls.overrideProductsByName = value
}

/**
* A mapping of local resource IDs to local paywall resources.
*
* Use this to serve paywall assets (images, videos, Lottie animations) from local files
* instead of fetching them over the network. When a paywall references a `localResourceId`,
* the SDK will look up the corresponding resource in this map.
*
* ```kotlin
* Superwall.instance.localResources = mapOf(
* "hero-video" to PaywallResource.FromUri(Uri.fromFile(File(context.filesDir, "onboarding.mp4"))),
* "hero-image" to PaywallResource.FromResources(R.drawable.hero),
* "bg-animation" to PaywallResource.FromResources(R.raw.lottie_bg),
* )
* ```
*
* Paywall HTML usage: `<video src="swlocal://hero-video" autoplay></video>`
*/
var localResources: Map<String, PaywallResource> = emptyMap()

internal val _customerInfo: MutableStateFlow<CustomerInfo> =
MutableStateFlow(CustomerInfo.empty())

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,7 @@ internal open class DefaultWebviewClient(
private val forUrl: String = "",
private val ioScope: CoroutineScope,
private val onWebViewCrash: (view: WebView, RenderProcessGoneDetail) -> Unit = { v, d -> },
private val localResourceHandler: LocalResourceHandler? = null,
) : WebViewClient() {
val webviewClientEvents: MutableSharedFlow<WebviewClientEvent> =
MutableSharedFlow(extraBufferCapacity = 10, replay = 2)
Expand All @@ -28,6 +29,16 @@ internal open class DefaultWebviewClient(
request: WebResourceRequest?,
): Boolean = true

override fun shouldInterceptRequest(
view: WebView?,
request: WebResourceRequest?,
): WebResourceResponse? {
val url = request?.url ?: return super.shouldInterceptRequest(view, request)
val handler = localResourceHandler ?: return super.shouldInterceptRequest(view, request)
if (!handler.isLocalResourceUrl(url)) return super.shouldInterceptRequest(view, request)
return handler.handleRequest(url)
}

override fun onPageStarted(
view: WebView?,
url: String?,
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,180 @@
package com.superwall.sdk.paywall.view.webview

import android.content.Context
import android.net.Uri
import android.webkit.MimeTypeMap
import android.webkit.WebResourceResponse
import com.superwall.sdk.logger.LogLevel
import com.superwall.sdk.logger.LogScope
import com.superwall.sdk.logger.Logger
import java.io.ByteArrayInputStream
import java.io.InputStream

/**
* Represents a local resource that can be served to paywall WebViews via `swlocal://` URLs.
*/
sealed class PaywallResource {
/**
* A resource backed by an Android [Uri] (`file://`, `content://`, etc.).
*/
data class FromUri(
val uri: Uri,
) : PaywallResource()

/**
* A resource backed by an Android resource ID (e.g. `R.raw.hero_video`, `R.drawable.bg`).
*/
data class FromResources(
val resId: Int,
) : PaywallResource()
}

internal class LocalResourceHandler(
private val context: Context,
private val localResources: () -> Map<String, PaywallResource>,
) {
companion object {
private const val SCHEME = "swlocal"
private const val DEFAULT_MIME_TYPE = "application/octet-stream"
}

fun isLocalResourceUrl(url: Uri): Boolean = url.scheme == SCHEME

fun handleRequest(url: Uri): WebResourceResponse {
val resourceId = url.host
if (resourceId.isNullOrEmpty()) {
Logger.debug(
LogLevel.error,
LogScope.paywallView,
"swlocal:// URL has no resource ID: $url",
)
return errorResponse(400, "Bad Request", "Missing resource ID in swlocal:// URL")
}

val resource = localResources()[resourceId]
if (resource == null) {
Logger.debug(
LogLevel.error,
LogScope.paywallView,
"No local resource found for ID: $resourceId",
)
return errorResponse(404, "Not Found", "No local resource mapped for ID: $resourceId")
}

return when (resource) {
is PaywallResource.FromUri -> handleUri(resourceId, resource.uri)
is PaywallResource.FromResources -> handleAndroidResource(resourceId, resource.resId)
}
}

private fun handleUri(
resourceId: String,
uri: Uri,
): WebResourceResponse {
val mimeType = resolveMimeType(uri)
val inputStream =
openStreamOrError(resourceId, uri) ?: return errorResponse(500, "Internal Error", "Failed to read resource: $resourceId")
return successResponse(mimeType, inputStream)
}

private fun handleAndroidResource(
resourceId: String,
resId: Int,
): WebResourceResponse {
val uri = Uri.parse("android.resource://${context.packageName}/$resId")
val mimeType = resolveResourceMimeType(resId, uri)
val inputStream =
try {
context.resources.openRawResource(resId)
} catch (e: Exception) {
Logger.debug(
LogLevel.error,
LogScope.paywallView,
"Failed to open Android resource '$resourceId' (resId=$resId)",
error = e,
)
return errorResponse(500, "Internal Error", "Failed to read resource: ${e.message}")
}
return successResponse(mimeType, inputStream)
}

private fun openStreamOrError(
resourceId: String,
uri: Uri,
): InputStream? =
try {
context.contentResolver.openInputStream(uri)
?: throw IllegalStateException("ContentResolver returned null InputStream")
} catch (e: Exception) {
Logger.debug(
LogLevel.error,
LogScope.paywallView,
"Failed to open local resource '$resourceId' at $uri",
error = e,
)
null
}

private fun resolveMimeType(uri: Uri): String {
context.contentResolver.getType(uri)?.let { return it }
return mimeTypeFromExtension(uri.toString())
}

private fun resolveResourceMimeType(
resId: Int,
uri: Uri,
): String {
context.contentResolver.getType(uri)?.let { return it }

// Try to extract extension from the resource entry name (e.g. "hero_video" won't have one,
// but the resource type name "raw"/"drawable" gives us a hint)
try {
val entryName = context.resources.getResourceEntryName(resId)
val ext = entryName.substringAfterLast('.', "")
if (ext.isNotEmpty()) {
return mimeTypeFromExtension(entryName)
}
} catch (_: Exception) {
// Resource not found - fall through
}

return DEFAULT_MIME_TYPE
}

private fun mimeTypeFromExtension(path: String): String {
val extension = MimeTypeMap.getFileExtensionFromUrl(path)
if (!extension.isNullOrEmpty()) {
MimeTypeMap.getSingleton().getMimeTypeFromExtension(extension)?.let { return it }
}
return DEFAULT_MIME_TYPE
}

private fun successResponse(
mimeType: String,
inputStream: InputStream,
): WebResourceResponse =
WebResourceResponse(
mimeType,
null,
200,
"OK",
corsHeaders(),
inputStream,
)

private fun errorResponse(
statusCode: Int,
reasonPhrase: String,
body: String,
): WebResourceResponse =
WebResourceResponse(
"text/plain",
"UTF-8",
statusCode,
reasonPhrase,
corsHeaders(),
ByteArrayInputStream(body.toByteArray()),
)

private fun corsHeaders(): Map<String, String> = mapOf("Access-Control-Allow-Origin" to "*")
}
Original file line number Diff line number Diff line change
Expand Up @@ -89,6 +89,10 @@ class SWWebView(
}
override var onScrollChangeListener: PaywallWebUI.OnScrollChangeListener? = null

private val localResourceHandler by lazy {
LocalResourceHandler(context) { Superwall.instance.localResources }
}

override fun detach(fromView: ViewGroup) {
fromView.removeView(this)
}
Expand Down Expand Up @@ -230,6 +234,7 @@ class SWWebView(
}
}
},
localResourceHandler = localResourceHandler,
)
this.webViewClient = client
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Expand Down Expand Up @@ -285,6 +290,7 @@ class SWWebView(
}
}
},
localResourceHandler = localResourceHandler,
)
this.webViewClient = client

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -26,7 +26,8 @@ internal class WebviewFallbackClient(
private val loadUrl: (PaywallWebviewUrl) -> Unit,
private val stopLoading: () -> Unit,
private val onCrashed: (view: WebView, RenderProcessGoneDetail) -> Unit,
) : DefaultWebviewClient("", ioScope, onCrashed) {
localResourceHandler: LocalResourceHandler? = null,
) : DefaultWebviewClient("", ioScope, onCrashed, localResourceHandler) {
private class MaxAttemptsReachedException : Exception("Max attempts reached")

private var failureCount = 0
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -182,20 +182,6 @@ class IdentityManagerTest {
}
}

@Test
fun `init merges generated alias and seed into user attributes`() =
runTest {
Given("no existing aliasId or seed in storage") {
When("the manager is created") {
createManager(this@runTest)
}

Then("user attributes are written to storage") {
verify { storage.write(UserAttributes, any()) }
}
}
}

@Test
fun `init does not merge attributes when alias and seed already exist`() =
runTest {
Expand Down
Loading
Loading