Skip to content
Closed
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
12 changes: 9 additions & 3 deletions .github/workflows/build+test+deploy.yml
Original file line number Diff line number Diff line change
Expand Up @@ -11,6 +11,11 @@ on:
push:
branches: [ "main" ]
workflow_dispatch:
inputs:
publish:
description: "Publish to Maven Central, tag, and create a GitHub release from this ref. Use for special releases cut from non-main branches (e.g. backport releases)."
type: boolean
default: false

permissions:
contents: write
Expand Down Expand Up @@ -57,9 +62,10 @@ jobs:
path: "superwall/build/version.json"
prop_path: "version"

# Only try to publish if we're on main
# Only try to publish on a push to main, or on a manual dispatch that
# explicitly opted into publishing (special releases off non-main branches).
- name: Check if tag exists
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && inputs.publish == true)
id: check-tag
run: |
EXISTS=$(git tag -l | grep -Fxq "${{steps.version.outputs.prop}}" && echo 'true' || echo 'false')
Expand Down Expand Up @@ -99,7 +105,7 @@ jobs:
fi

- name: Tag
if: github.event_name == 'push' && github.ref == 'refs/heads/main'
if: (github.event_name == 'push' && github.ref == 'refs/heads/main') || (github.event_name == 'workflow_dispatch' && inputs.publish == true)
run: |
if [ "${{ steps.check-tag.outputs.tag-exists }}" == "false" ]; then
sudo git config --global user.name 'Jake'
Expand Down
9 changes: 9 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,15 @@

The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub.

## 2.7.24

This is a special patch release: it is 2.7.23 plus the fixes below, and does not include the changes shipped in 2.8.0 (Google Play Billing Library 9, custom store products, minSdk 23). Use it if you want these fixes but are not ready to take the 2.8.0 upgrade.

## Fixes
- Paywalls with translations now render in the user's language on first paint instead of briefly showing the default language.
- Fix the hashed user ID (`externalAccountId`) attached to Play Store purchases: the SDK was hashing an internal object reference instead of the user ID, producing a hash that didn't match the user and changed between app launches.
- Paywall analytics events (`paywall_open`, `paywall_page_view`, `paywall_close`, etc.) now include a `presentation_id`, a unique identifier minted for each paywall presentation. Previously this field was always empty on Android, which broke dashboard funnels that correlate a paywall's page views into a single session. Also adds the previously-missing `close_reason`, `cache_key`, and `build_id` fields to these events, matching the data already sent by the iOS SDK.

## 2.7.23

## Fixes
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -54,6 +54,7 @@ import com.superwall.sdk.misc.IOScope
import com.superwall.sdk.misc.MainScope
import com.superwall.sdk.misc.primitives.DebugInterceptor
import com.superwall.sdk.misc.primitives.SequentialActor
import com.superwall.sdk.misc.sha256Hex
import com.superwall.sdk.models.config.ComputedPropertyRequest
import com.superwall.sdk.models.config.FeatureFlags
import com.superwall.sdk.models.entitlements.SubscriptionStatus
Expand Down Expand Up @@ -142,7 +143,6 @@ import kotlinx.serialization.modules.SerializersModule
import kotlinx.serialization.modules.contextual
import java.lang.ref.WeakReference
import java.nio.charset.StandardCharsets
import java.security.MessageDigest
import java.util.Date
import com.superwall.sdk.paywall.presentation.internal.dismiss as internalDismiss

Expand Down Expand Up @@ -499,11 +499,8 @@ class DependencyContainer(
storage = storage,
options = { options },
ioScope = ioScope,
stringToSha = {
val bytes = this.toString().toByteArray()
val md = MessageDigest.getInstance("SHA-256")
val digest = md.digest(bytes)
digest.fold("", { str, it -> str + "%02x".format(it) })
stringToSha = { userId ->
checkNotNull(userId.sha256Hex()) { "SHA-256 is unavailable" }
},
notifyUserChange = {
delegate().userAttributesDidChange(it)
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -12,6 +12,11 @@ fun String.sha256(): ByteArray? =
null
}

fun String.sha256Hex(): String? =
sha256()?.joinToString(separator = "") { byte ->
"%02x".format(byte.toInt() and 0xff)
}

fun String.sha256MappedToRange(): Int? {
val hashBytes = this.sha256() ?: return null

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -122,6 +122,12 @@ data class Paywall(
*/
@kotlinx.serialization.Transient()
var state: Map<String, Any> = emptyMap(),
/**
* A unique identifier minted for each distinct presentation of this paywall, used to
* correlate the events tracked during that presentation (e.g. page views).
*/
@kotlinx.serialization.Transient()
var presentationId: String? = null,
@SerialName("url_config")
val urlConfig: PaywallWebviewUrl.Config? = null,
@Serializable
Expand Down Expand Up @@ -272,6 +278,7 @@ data class Paywall(
buildId = buildId,
isScrollEnabled = isScrollEnabled ?: true,
state = state,
presentationId = presentationId,
)

companion object {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -31,3 +31,13 @@ sealed class PaywallCloseReason {
else -> true
}
}

val PaywallCloseReason.description: String
get() =
when (this) {
is PaywallCloseReason.SystemLogic -> "systemLogic"
is PaywallCloseReason.ForNextPaywall -> "forNextPaywall"
is PaywallCloseReason.WebViewFailedToLoad -> "webViewFailedToLoad"
is PaywallCloseReason.ManualClose -> "manualClose"
is PaywallCloseReason.None -> "none"
}
Original file line number Diff line number Diff line change
Expand Up @@ -64,6 +64,7 @@ data class PaywallInfo(
@Serializable(with = AnyMapSerializer::class)
val state: Map<String, Any> = emptyMap(),
val customerInfo: CustomerInfo = CustomerInfo.empty(),
val presentationId: String? = null,
) {
constructor(
databaseId: String,
Expand Down Expand Up @@ -99,6 +100,7 @@ data class PaywallInfo(
isScrollEnabled: Boolean,
state: Map<String, Any> = emptyMap(),
customerInfo: CustomerInfo = CustomerInfo.empty(),
presentationId: String? = null,
) : this(
databaseId = databaseId,
identifier = identifier,
Expand Down Expand Up @@ -187,6 +189,7 @@ data class PaywallInfo(
isScrollEnabled = isScrollEnabled,
state = state,
customerInfo = customerInfo,
presentationId = presentationId,
)

fun eventParams(
Expand Down Expand Up @@ -220,6 +223,10 @@ data class PaywallInfo(
"variant_id" to experiment?.variant?.id,
"is_scroll_enabled" to isScrollEnabled,
"state" to state,
"presentation_id" to presentationId,
"close_reason" to closeReason.description,
"cache_key" to cacheKey,
"build_id" to buildId,
)
val customerParams = customerInfo.toParams()
if (customerParams.isNotEmpty()) {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -180,6 +180,7 @@ class PaywallRequestManager(
return@withContext paywall.copy(
experiment = request.responseIdentifiers.experiment,
presentationSourceType = request.presentationSourceType,
presentationId = java.util.UUID.randomUUID().toString(),
)
}

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -20,6 +20,7 @@ internal open class DefaultWebviewClient(
private val ioScope: CoroutineScope,
private val onWebViewCrash: (view: WebView, RenderProcessGoneDetail) -> Unit = { v, d -> },
private val localResourceHandler: LocalResourceHandler? = null,
private val onPageStartedHook: (WebView) -> Unit = {},
) : WebViewClient() {
val webviewClientEvents: MutableSharedFlow<WebviewClientEvent> =
MutableSharedFlow(extraBufferCapacity = 10, replay = 2)
Expand All @@ -45,6 +46,7 @@ internal open class DefaultWebviewClient(
favicon: Bitmap?,
) {
super.onPageStarted(view, url, favicon)
view?.let(onPageStartedHook)
}

override fun onPageFinished(
Expand Down
Original file line number Diff line number Diff line change
@@ -0,0 +1,32 @@
package com.superwall.sdk.paywall.view.webview

import kotlinx.serialization.json.buildJsonObject
import kotlinx.serialization.json.put

/**
* Builds the JavaScript snippet that seeds the paywall web runtime with device
* data as soon as the page starts loading.
*
* The web runtime reads `window.__SW_DEVICE_PRELOAD__` at boot and uses
* `deviceLocale` to render translations on first paint, instead of waiting for
* the `template_variables` message (which is gated on product/billing loading).
* The locale value must be identical to the `deviceLocale` the SDK later sends
* in `template_variables`, so that message is a visual no-op.
*/
internal object DevicePreloadScript {
/**
* Returns a one-line script of the form:
* `window.__SW_DEVICE_PRELOAD__ = {"deviceLocale":"en_US"};`
*
* The payload is serialized with kotlinx.serialization so hostile locale
* strings (quotes, backslashes, etc.) are escaped and cannot break out of
* the JSON literal.
*/
fun build(deviceLocale: String): String {
val payload =
buildJsonObject {
put("deviceLocale", deviceLocale)
}
return "window.__SW_DEVICE_PRELOAD__ = $payload;"
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -179,6 +179,26 @@ class SWWebView(
private var lastWebViewClient: WebViewClient? = null
private var lastLoadedUrl: String? = null

// The device preload script seeds `window.__SW_DEVICE_PRELOAD__` as soon as
// the page starts loading, so translated paywalls render in the device locale
// on first paint instead of waiting for the `template_variables` message. The
// paywall runtime reads the global when its (network-fetched) bundle boots,
// so an onPageStarted injection lands well before it; if it ever misses, the
// runtime just falls back to waiting for `template_variables` as before.
private fun currentDeviceLocale(): String? =
delegate?.state?.locale
?: if (Superwall.initialized) {
Superwall.instance.dependencyContainer.deviceHelper.locale
} else {
null
}

private val onPageStartedPreloadHook: (WebView) -> Unit = { view ->
currentDeviceLocale()?.let { locale ->
view.evaluateJavascript(DevicePreloadScript.build(locale), null)
}
}

internal fun prepareWebview() {
addJavascriptInterface(messageHandler, "SWAndroid")

Expand Down Expand Up @@ -235,6 +255,7 @@ class SWWebView(
}
},
localResourceHandler = localResourceHandler,
onPageStartedHook = onPageStartedPreloadHook,
)
this.webViewClient = client
if (Build.VERSION.SDK_INT < Build.VERSION_CODES.O) {
Expand Down Expand Up @@ -300,6 +321,7 @@ class SWWebView(
}
},
localResourceHandler = localResourceHandler,
onPageStartedHook = onPageStartedPreloadHook,
)
this.webViewClient = client

Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -27,7 +27,8 @@ internal class WebviewFallbackClient(
private val stopLoading: () -> Unit,
private val onCrashed: (view: WebView, RenderProcessGoneDetail) -> Unit,
localResourceHandler: LocalResourceHandler? = null,
) : DefaultWebviewClient("", ioScope, onCrashed, localResourceHandler) {
onPageStartedHook: (WebView) -> Unit = {},
) : DefaultWebviewClient("", ioScope, onCrashed, localResourceHandler, onPageStartedHook) {
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 @@ -958,6 +958,7 @@ class InternalSuperwallEventTest {
presentation = PaywallPresentationInfo(PaywallPresentationStyle.Modal, 0),
buildId = "build_1",
cacheKey = "cache_1",
presentationId = "presentation_1",
)

private fun stubStoreProduct(
Expand Down Expand Up @@ -1033,6 +1034,7 @@ class InternalSuperwallEventTest {

And("paywall info params are also included") {
assertEquals(paywallInfo.identifier, params["paywall_identifier"])
assertEquals(paywallInfo.presentationId, params["presentation_id"])
}

And("the superwall placement is paywall_page_view") {
Expand Down
Original file line number Diff line number Diff line change
Expand Up @@ -331,6 +331,7 @@ class IdentityManagerTest {
runTest {
Given("passIdentifiersToPlayStore is disabled") {
val testOptions = SuperwallOptions().apply { passIdentifiersToPlayStore = false }
every { storage.read(AppUserId) } returns "user-123"

val manager =
IdentityManager(
Expand All @@ -352,7 +353,7 @@ class IdentityManagerTest {
}

Then("it returns the sha of the userId") {
assertTrue(externalId.startsWith("sha256-of-"))
assertEquals("sha256-of-user-123", externalId)
}
}
}
Expand Down
16 changes: 16 additions & 0 deletions superwall/src/test/java/com/superwall/sdk/misc/StringSHA256Test.kt
Original file line number Diff line number Diff line change
@@ -0,0 +1,16 @@
package com.superwall.sdk.misc

import org.junit.Assert.assertEquals
import org.junit.Assert.assertNotEquals
import org.junit.Test

class StringSHA256Test {
@Test
fun `sha256Hex hashes the string value deterministically`() {
assertEquals(
"fcdec6df4d44dbc637c7c5b58efface52a7f8a88535423430255be0bb89bedd8",
"user-123".sha256Hex(),
)
assertNotEquals("user-123".sha256Hex(), "user-456".sha256Hex())
}
}
Original file line number Diff line number Diff line change
Expand Up @@ -123,6 +123,33 @@ class PaywallInfoTest {
assertNotNull(params["paywall_response_load_start_time"])
}

@Test
fun eventParams_includesPresentationIdCloseReasonCacheKeyAndBuildId() {
val info =
PaywallInfo.empty().copy(
presentationId = "presentation-123",
closeReason = PaywallCloseReason.ManualClose,
cacheKey = "cache-456",
buildId = "build-789",
)

val params = info.eventParams()

assertEquals("presentation-123", params["presentation_id"])
assertEquals("manualClose", params["close_reason"])
assertEquals("cache-456", params["cache_key"])
assertEquals("build-789", params["build_id"])
}

@Test
fun eventParams_omitsPresentationId_whenNull() {
val info = PaywallInfo.empty().copy(presentationId = null)

val params = info.eventParams()

assertFalse(params.containsKey("presentation_id"))
}

private fun createProductItem(
name: String,
productIdentifier: String,
Expand Down
Loading
Loading