diff --git a/.github/workflows/build+test+deploy.yml b/.github/workflows/build+test+deploy.yml index 733391bb..d1278525 100644 --- a/.github/workflows/build+test+deploy.yml +++ b/.github/workflows/build+test+deploy.yml @@ -74,25 +74,20 @@ jobs: EXISTS=$(git tag -l | grep -Fxq "${{steps.version.outputs.prop}}" && echo 'true' || echo 'false') echo "::set-output name=tag-exists::$EXISTS" - - name: Configure GPG Key - run: | - export GPG_TTY=$(tty) - echo $GPG_SIGNING_KEY | base64 --decode | gpg --batch --import --passphrase $GPG_SIGNING_KEY_PASSPHRASE - env: - GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} - GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} - - name: Deploy if: github.event_name == 'push' && github.ref == 'refs/heads/main' run: | if [ "${{ steps.check-tag.outputs.tag-exists }}" == "false" ]; then - ./gradlew publish -Paws_access_key_id=$AWS_ACCESS_KEY_ID -Paws_secret_access_key=$AWS_SECRET_ACCESS_KEY -PsonatypeUsername=$SONATYPE_USERNAME -PsonatypePassword=$SONATYPE_PASSWORD + ./gradlew publish -Paws_access_key_id=$AWS_ACCESS_KEY_ID -Paws_secret_access_key=$AWS_SECRET_ACCESS_KEY -PsonatypeUsername=$SONATYPE_USERNAME -PsonatypePassword=$SONATYPE_PASSWORD -pgpg_signing_key=$GPG_SIGNING_KEY -Pgpg_signing_key_passphrase=$GPG_SIGNING_KEY_PASSPHRASE -Pgpg_signing_key_id=$GPG_SIGNING_KEY_ID fi env: AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} + GPG_SIGNING_KEY_ID: ${{ secrets.GPG_SIGNING_KEY_ID }} - name: Determine prerelease status id: prerelease @@ -120,6 +115,28 @@ jobs: sudo git push -u origin release/${{steps.version.outputs.prop}} fi + - name: Run Autodowngrade Task + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + if [ "${{ steps.check-tag.outputs.tag-exists }}" == "false" ]; then + ./gradlew downgradeAgpTask + fi + + - name: Deploy + if: github.event_name == 'push' && github.ref == 'refs/heads/main' + run: | + if [ "${{ steps.check-tag.outputs.tag-exists }}" == "false" ]; then + ./gradlew publish -Paws_access_key_id=$AWS_ACCESS_KEY_ID -Paws_secret_access_key=$AWS_SECRET_ACCESS_KEY -PsonatypeUsername=$SONATYPE_USERNAME -PsonatypePassword=$SONATYPE_PASSWORD -pgpg_signing_key=$GPG_SIGNING_KEY -Pgpg_signing_key_passphrase=$GPG_SIGNING_KEY_PASSPHRASE -Pgpg_signing_key_id=$GPG_SIGNING_KEY_ID + fi + env: + AWS_ACCESS_KEY_ID: ${{ secrets.AWS_ACCESS_KEY_ID }} + AWS_SECRET_ACCESS_KEY: ${{ secrets.AWS_SECRET_ACCESS_KEY }} + SONATYPE_USERNAME: ${{ secrets.SONATYPE_USERNAME }} + SONATYPE_PASSWORD: ${{ secrets.SONATYPE_PASSWORD }} + GPG_SIGNING_KEY: ${{ secrets.GPG_SIGNING_KEY }} + GPG_SIGNING_KEY_PASSPHRASE: ${{ secrets.GPG_SIGNING_KEY_PASSPHRASE }} + GPG_SIGNING_KEY_ID: ${{ secrets.GPG_SIGNING_KEY_ID }} + - name: slack-send # Only notify on a new tag if: steps.check-tag.outputs.tag-exists == 'false' diff --git a/CHANGELOG.md b/CHANGELOG.md index 8b36c5fb..36d38114 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -2,8 +2,22 @@ The changelog for `Superwall`. Also see the [releases](https://github.com/superwall/Superwall-Android/releases) on GitHub. +## 1.2.5 + +### Enhancements +- Adds a `Modifier` to `PaywallComposable` to allow for more control +- Adds a `PaywallView.setup(...)` method to allow for easy setup when using `PaywallView` directly +- Adds support for `MODAL` presentation style + +### Fixes +- Fixes issue with displaying `PaywallComposable` +- Resolves issue where users would get `UninitializedPropertyAccessException` when calling `Superwall.instance` + ## 1.2.4 +### Enhancements +- For users who are not able to upgrade their AGP or Gradle versions, we have added a new artifact `superwall-android-agp-7` which keeps compatibility. + ### Enhancements - Fixes issue with decoding custom placements from paywalls. diff --git a/app/src/main/java/com/superwall/superapp/MainActivity.kt b/app/src/main/java/com/superwall/superapp/MainActivity.kt index d6bd5732..7cea3431 100644 --- a/app/src/main/java/com/superwall/superapp/MainActivity.kt +++ b/app/src/main/java/com/superwall/superapp/MainActivity.kt @@ -6,6 +6,7 @@ import android.widget.Button import androidx.appcompat.app.AppCompatActivity import com.superwall.sdk.Superwall import com.superwall.superapp.test.UITestActivity +import java.lang.ref.WeakReference class MainActivity : AppCompatActivity() { val events by lazy { @@ -15,6 +16,7 @@ class MainActivity : AppCompatActivity() { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) setContentView(R.layout.activity_main) + (application as MainApplication).activity = WeakReference(this) // Setup deep linking handling respondToDeepLinks() diff --git a/app/src/main/java/com/superwall/superapp/MainApplication.kt b/app/src/main/java/com/superwall/superapp/MainApplication.kt index 021ca3b0..8cfdcfd9 100644 --- a/app/src/main/java/com/superwall/superapp/MainApplication.kt +++ b/app/src/main/java/com/superwall/superapp/MainApplication.kt @@ -1,12 +1,17 @@ package com.superwall.superapp +import android.app.Activity +import androidx.appcompat.app.AlertDialog import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.config.options.PaywallOptions import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.delegate.SuperwallDelegate +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope import com.superwall.sdk.paywall.presentation.register import kotlinx.coroutines.flow.MutableSharedFlow +import java.lang.ref.WeakReference class MainApplication : android.app.Application(), @@ -30,6 +35,8 @@ class MainApplication : */ } + var activity: WeakReference? = null + override fun onCreate() { super.onCreate() @@ -80,6 +87,28 @@ class MainApplication : Superwall.instance.register(event, params) } + override fun handleLog( + level: String, + scope: String, + message: String?, + info: Map?, + error: Throwable?, + ) { + val ctx = activity?.get() ?: return + if (level == LogLevel.error.toString() && + scope == LogScope.productsManager.toString() + ) { + AlertDialog + .Builder(ctx) + .apply { + setTitle("Error") + setMessage(message) + setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + }.show() + } + super.handleLog(level, scope, message, info, error) + } + override fun handleSuperwallEvent(withInfo: SuperwallEventInfo) { println( "\n!! SuperwallDelegate !! \n" + diff --git a/build.gradle.kts b/build.gradle.kts index 5723045a..6628d878 100644 --- a/build.gradle.kts +++ b/build.gradle.kts @@ -7,3 +7,7 @@ plugins { alias(libs.plugins.serialization) apply false } true + +buildscript { + apply(from = "./scripts/old-agp-auto-downgrade.gradle.kts") +} diff --git a/example/app/src/main/java/com/superwall/exampleapp/MainActivity.kt b/example/app/src/main/java/com/superwall/exampleapp/MainActivity.kt index eea21fe2..61cbe7a3 100644 --- a/example/app/src/main/java/com/superwall/exampleapp/MainActivity.kt +++ b/example/app/src/main/java/com/superwall/exampleapp/MainActivity.kt @@ -19,10 +19,12 @@ import androidx.compose.foundation.shape.RoundedCornerShape import androidx.compose.foundation.text.BasicTextField import androidx.compose.foundation.text.KeyboardActions import androidx.compose.foundation.text.KeyboardOptions +import androidx.compose.material3.AlertDialog import androidx.compose.material3.Button import androidx.compose.material3.MaterialTheme import androidx.compose.material3.Surface import androidx.compose.material3.Text +import androidx.compose.material3.TextButton import androidx.compose.runtime.Composable import androidx.compose.runtime.getValue import androidx.compose.runtime.mutableStateOf @@ -44,21 +46,60 @@ import androidx.compose.ui.unit.sp import androidx.core.view.WindowCompat import com.superwall.exampleapp.ui.theme.SuperwallExampleAppTheme import com.superwall.sdk.Superwall +import com.superwall.sdk.delegate.SuperwallDelegate import com.superwall.sdk.identity.identify import com.superwall.sdk.identity.setUserAttributes +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope -class MainActivity : ComponentActivity() { +class MainActivity : + ComponentActivity(), + SuperwallDelegate { override fun onCreate(savedInstanceState: Bundle?) { super.onCreate(savedInstanceState) Superwall.configure( - applicationContext = this, + applicationContext = application, apiKey = "pk_3b18882b1683318b710c741f371f40f54e357c6f0baff1f4", ) WindowCompat.setDecorFitsSystemWindows(window, false) setContent { SuperwallExampleAppTheme { + var errorMessage = + remember { + mutableStateOf(null) + } + Superwall.instance.delegate = + object : SuperwallDelegate { + override fun handleLog( + level: String, + scope: String, + message: String?, + info: Map?, + error: Throwable?, + ) { + if (level == LogLevel.error.toString() && + scope == LogScope.productsManager.toString() + ) { + errorMessage.value = message + } + super.handleLog(level, scope, message, info, error) + } + } + if (errorMessage.value != null) { + AlertDialog( + onDismissRequest = { errorMessage.value = null }, + title = { Text("Error") }, + text = { Text(errorMessage.value ?: "") }, + confirmButton = { + TextButton(onClick = { errorMessage.value = null }) { + Text("OK") + } + }, + ) + } + WelcomeScreen() } } @@ -84,7 +125,10 @@ fun WelcomeScreen() { .padding(bottom = 20.dp), contentAlignment = Alignment.Center, ) { - Column(horizontalAlignment = Alignment.CenterHorizontally, modifier = Modifier.padding(16.dp)) { + Column( + horizontalAlignment = Alignment.CenterHorizontally, + modifier = Modifier.padding(16.dp), + ) { Spacer(modifier = Modifier.weight(1f)) Text( diff --git a/scripts/old-agp-auto-downgrade.gradle.kts b/scripts/old-agp-auto-downgrade.gradle.kts new file mode 100644 index 00000000..8e65ee3f --- /dev/null +++ b/scripts/old-agp-auto-downgrade.gradle.kts @@ -0,0 +1,153 @@ +import java.io.File + + +fun updateLibsVersions( + filePath: String, + oldAgpVersion: String, +) { + val file = File(filePath) + val lines = file.readLines().toMutableList() + + val versionUpdates = + mapOf( + "gradle_plugin_version" to oldAgpVersion, + "lifecycleProcessVersion" to "2.3.1", + "compose_version" to "2022.10.00", + "kotlinx_serialization_json_version" to "1.5.1", + "activity_compose_version" to "1.5.1", + "core_version" to "1.6.0", + "appcompat_version" to "1.6.1", + "material_version" to "1.8.0", + "core_ktx_version" to "1.6.0", + "lifecycle_runtime_ktx_version" to "2.3.1", + "kotlinx_coroutines_test_version" to "1.7.1", + "kotlin" to "1.8.21", + "kotlinx_coroutines_core_version" to "1.7.1", + ) + + val updatedLines = + lines.map { line -> + val trimmedLine = line.trim() + versionUpdates.entries + .find { (key, _) -> + trimmedLine.matches(Regex("^$key\\s*=.*")) + }?.let { (key, value) -> + "$key = \"$value\"" + } ?: line + } + + file.writeText(updatedLines.joinToString("\n")) +} + +fun removeEdgeToEdgeReference() { + val file = + File("superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt") + val lines = + file + .readLines() + .map { line -> + if (line.contains("enableEdgeToEdge")) { + null + } else { + line + } + }.filterNotNull() + .toMutableList() + file.writeText(lines.joinToString("\n")) +} + +fun fixViewModelStoreReference() { + val toWrite = "override fun getViewModelStore(): ViewModelStore" + val text = + File("superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallStoreOwner.kt").let { + val newText = + it.readText().replace("override val viewModelStore: ViewModelStore", toWrite) + it.writeText(newText) + } +} + +fun replaceEntriesCall() { + val file = File("superwall/src/main/java/com/superwall/sdk/models/paywall/Paywall.kt") + val text = file.readText().replace(".entries.", ".values().") + file.writeText(text) +} + +fun updateGradleWrapper( + filePath: String, + oldGradleVersion: String, +) { + val file = File(filePath) + val content = file.readText() + val updatedContent = + content.replace( + Regex("distributionUrl=.*"), + "distributionUrl=https\\://services.gradle.org/distributions/gradle-$oldGradleVersion-bin.zip", + ) + file.writeText(updatedContent) +} + +fun updateBuildGradle( + filePath: String, + oldKotlinCompilerVersion: String, +) { + val file = File(filePath) + val lines = file.readLines().toMutableList() + + for (i in lines.indices) { + if (lines[i].contains("kotlinCompilerExtensionVersion = ")) { + lines[i] = " kotlinCompilerExtensionVersion = \"$oldKotlinCompilerVersion\"" + } + } + + // Replace packaging block with tasks.withType + val packagingBlockStart = lines.indexOfFirst { it.contains("packaging {") } + if (packagingBlockStart != -1) { + val packagingBlockEnd = + lines + .subList(packagingBlockStart, lines.size) + .indexOfFirst { it.contains("}") } + packagingBlockStart + val newBlock = + """ + tasks.withType { + exclude("META-INF/LICENSE.md") + exclude("META-INF/LICENSE-notice.md") + } + """.trimIndent() + lines.subList(packagingBlockStart, packagingBlockEnd + 1).clear() + lines.add(packagingBlockStart, newBlock) + } + + file.writeText(lines.joinToString("\n")) +} + +fun updateArtifactId( + filePath: String, + newArtifactId: String, +) { + val file = File(filePath) + val txt = file.readText() + val updatedTxt = + txt.replace( + "artifactId = \"superwall-android\"", + "artifactId = \"$newArtifactId\"", + ) + file.writeText(updatedTxt) +} + +fun action() { + val projectDir = projectDir.toString() + updateLibsVersions("$projectDir/gradle/libs.versions.toml", "7.4.2") + updateGradleWrapper("$projectDir/gradle/wrapper/gradle-wrapper.properties", "7.5") + updateBuildGradle("$projectDir/superwall/build.gradle.kts", "1.4.7") + updateArtifactId("$projectDir/superwall/build.gradle.kts", "superwall-android-agp-7") + removeEdgeToEdgeReference() + fixViewModelStoreReference() + replaceEntriesCall() + println("AGP version update and artifactId change completed successfully.") +} + +tasks.register("downgradeAgpTask") { + group = "custom" + description = "Downgrade AGP version and change artifactId" + action() +} diff --git a/superwall/build.gradle.kts b/superwall/build.gradle.kts index 478a8ce9..b455a622 100644 --- a/superwall/build.gradle.kts +++ b/superwall/build.gradle.kts @@ -9,6 +9,9 @@ buildscript { extra["awsSecretAccessKey"] = System.getenv("AWS_SECRET_ACCESS_KEY") ?: findProperty("aws_secret_access_key") extra["sonatypeUsername"] = System.getenv("SONATYPE_USERNAME") ?: findProperty("sonatype_username") extra["sonatypePassword"] = System.getenv("SONATYPE_PASSWORD") ?: findProperty("sonatype_password") + extra["signingKeyId"] = System.getenv("GPG_SIGNING_KEY_ID") ?: findProperty("gpg_signing_key_id") + extra["signingPassword"] = System.getenv("GPG_SIGNING_KEY_PASSPHRASE") ?: findProperty("gpg_signing_key_passphrase") + extra["signingKey"] = System.getenv("GPG_SIGNING_KEY") ?: findProperty("gpg_signing_key") } plugins { @@ -20,7 +23,7 @@ plugins { id("signing") } -version = "1.2.4" +version = "1.2.5" android { compileSdk = 34 @@ -161,6 +164,12 @@ publishing { } signing { + val signingKeyId: String? by extra + val signingPassword: String? by extra + val signingKey: String? by extra + if (signingKey != null && signingKeyId != null && signingPassword != null) { + useInMemoryPgpKeys(signingKeyId, signingKey, signingPassword) + } sign(publishing.publications["release"]) } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt index 08755c0e..a72d8589 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/analytics/internal/TrackingLogicTest.kt @@ -1,14 +1,15 @@ package com.superwall.sdk.analytics.internal -import android.util.Log import androidx.test.platform.app.InstrumentationRegistry import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.identity.IdentityInfo +import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.network.Network import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.storage.LastPaywallView -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LatestGeoInfo +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.TotalPaywallViews import io.mockk.every import io.mockk.mockk @@ -18,12 +19,13 @@ import org.junit.Test class TrackingLogicTest { val store = - mockk { + mockk { every { apiKey } returns "pk_test_1234" every { didTrackFirstSession } returns true every { didTrackFirstSeen } returns true - every { get(LastPaywallView) } returns null - every { get(TotalPaywallViews) } returns 0 + every { read(LastPaywallView) } returns null + every { read(TotalPaywallViews) } returns 0 + every { read(LatestGeoInfo) } returns GeoInfo.stub() } val network = mockk() @@ -51,7 +53,17 @@ class TrackingLogicTest { val attributes = deviceHelper.getTemplateDevice() val event = InternalSuperwallEvent.DeviceAttributes(HashMap(attributes)) val res = TrackingLogic.processParameters(event, "appSessionId") - Log.e("res", res.toString()) - assert(res.eventParams.isEmpty()) + assert( + lazyMessage = { "Lists should be cleaned" }, + value = res.audienceFilterParams.none { it.value is List<*> }, + ) + assert( + lazyMessage = { "Booleans should be serialized as booleans" }, + value = res.audienceFilterParams["\$is_standard_event"] == true, + ) + assert( + lazyMessage = { "Double should be serialized as double" }, + value = res.audienceFilterParams["\$totalPaywallViews"] == 0.0, + ) } } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/AssignmentsTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/AssignmentsTest.kt new file mode 100644 index 00000000..dfb3ae7f --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/AssignmentsTest.kt @@ -0,0 +1,145 @@ +package com.superwall.sdk.config + +import Assignments +import Given +import Then +import When +import androidx.test.platform.app.InstrumentationRegistry +import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.assignment.Assignment +import com.superwall.sdk.models.assignment.ConfirmableAssignment +import com.superwall.sdk.models.triggers.Experiment +import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.network.Network +import com.superwall.sdk.storage.LocalStorage +import io.mockk.coEvery +import io.mockk.coVerify +import io.mockk.mockk +import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.test.runTest +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class AssignmentsTest { + private lateinit var storage: LocalStorage + private lateinit var network: Network + + @Before + fun setup() { + val context = InstrumentationRegistry.getInstrumentation().targetContext + storage = mockk(relaxed = true) + network = mockk(relaxed = true) + } + + @Test + fun test_choosePaywallVariants() = + runTest { + val assignments = Assignments(storage, network, ioScope = this) + coEvery { network.confirmAssignments(any()) } returns Either.Success(Unit) + Given("We have a set of triggers") { + val triggers = + setOf( + Trigger.stub(), + Trigger.stub(), + ) + + When("We choose paywall variants") { + assignments.choosePaywallVariants(triggers) + + Then("The assignments should be updated") { + verify { storage.getConfirmedAssignments() } + verify { storage.saveConfirmedAssignments(any()) } + } + } + } + } + + @Test + fun test_getAssignments() = + runTest { + val assignments = Assignments(storage, network, ioScope = this) + Given("We have a set of triggers and server assignments") { + val triggers = + setOf( + Trigger.stub(), + Trigger.stub(), + ) + val serverAssignments = + listOf( + Assignment("exp1", "var1"), + Assignment("exp2", "var2"), + ) + coEvery { network.getAssignments() } returns Either.Success(serverAssignments) + + When("We get assignments") { + assignments.getAssignments(triggers) + + Then("The assignments should be updated with server data") { + verify { storage.getConfirmedAssignments() } + verify { storage.saveConfirmedAssignments(any()) } + } + } + } + } + + @Test + fun test_confirmAssignment() = + runTest { + val assignments = Assignments(storage, network, ioScope = CoroutineScope(Dispatchers.IO)) + coEvery { network.confirmAssignments(any()) } returns Either.Success(Unit) + + Given("We have a confirmable assignment") { + val assignment = + ConfirmableAssignment( + experimentId = "exp1", + variant = + Experiment.Variant( + id = "var1", + type = Experiment.Variant.VariantType.TREATMENT, + paywallId = "pw1", + ), + ) + + When("We confirm the assignment") { + assignments.confirmAssignment(assignment) + + Then("The assignment should be confirmed and saved") { + verify { storage.getConfirmedAssignments() } + verify { storage.saveConfirmedAssignments(any()) } + coVerify { network.confirmAssignments(any()) } + } + } + } + } + + @Test + fun test_reset() = + runTest { + Given("We have some unconfirmed assignments") { + val assignments = + Assignments( + storage, + network, + ioScope = this@runTest, + mapOf( + "exp1" to + Experiment.Variant( + id = "var1", + type = Experiment.Variant.VariantType.TREATMENT, + paywallId = "pw1", + ), + ), + ) + When("We reset the assignments") { + assignments.reset() + + Then("The unconfirmed assignments should be cleared") { + assertTrue(assignments.unconfirmedAssignments.isEmpty()) + } + } + } + } +} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt index f6d885e3..022f9afc 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/config/ConfigManagerInstrumentedTest.kt @@ -1,13 +1,20 @@ package com.superwall.sdk.config +import And +import Assignments +import Given +import Then +import When +import android.app.Application import android.content.Context import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry +import com.superwall.sdk.Superwall 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.DependencyContainer -import com.superwall.sdk.identity.IdentityInfo -import com.superwall.sdk.misc.Result +import com.superwall.sdk.misc.Either import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.config.Config @@ -18,9 +25,14 @@ import com.superwall.sdk.models.triggers.Trigger import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.VariantOption import com.superwall.sdk.network.Network +import com.superwall.sdk.network.NetworkError import com.superwall.sdk.network.NetworkMock +import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.paywall.manager.PaywallManager +import com.superwall.sdk.storage.LatestConfig +import com.superwall.sdk.storage.LatestGeoInfo +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.StorageMock import com.superwall.sdk.store.StoreKitManager @@ -32,8 +44,13 @@ import io.mockk.just import io.mockk.mockk import io.mockk.spyk import io.mockk.verify +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.async import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive +import kotlinx.coroutines.flow.drop +import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch import kotlinx.coroutines.test.runTest import org.junit.Assert.assertEquals @@ -41,17 +58,19 @@ import org.junit.Assert.assertNull import org.junit.Assert.assertTrue import org.junit.Test import org.junit.runner.RunWith -import java.util.Locale import kotlin.time.Duration class ConfigManagerUnderTest( private val context: Context, private val storage: Storage, - private val network: Network, + private val network: SuperwallAPI, private val paywallManager: PaywallManager, private val storeKitManager: StoreKitManager, private val factory: Factory, private val deviceHelper: DeviceHelper, + private val assignments: Assignments, + private val paywallPreload: PaywallPreload, + private val ioScope: CoroutineScope, ) : ConfigManager( context = context, storage = storage, @@ -61,331 +80,784 @@ class ConfigManagerUnderTest( factory = factory, deviceHelper = deviceHelper, options = SuperwallOptions(), + assignments = assignments, + paywallPreload = paywallPreload, + ioScope = ioScope, + track = {}, ) { suspend fun setConfig(config: Config) { - configState.emit(Result.Success(ConfigState.Retrieved(config))) + configState.emit(ConfigState.Retrieved(config)) } } @RunWith(AndroidJUnit4::class) class ConfigManagerTests { - private val helperFactory = - object : DeviceHelper.Factory { - override fun makeLocaleIdentifier() = Locale.US.toLanguageTag() - - override suspend fun makeIdentityInfo() = IdentityInfo("test", "test") + val mockDeviceHelper = + mockk { + every { appVersion } returns "1.0" + every { locale } returns "en-US" + coEvery { getTemplateDevice() } returns emptyMap() } @Test fun test_confirmAssignment() = runTest { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val experimentId = "abc" - val variantId = "def" - val variant = - Experiment.Variant( - id = variantId, - type = Experiment.Variant.VariantType.TREATMENT, - paywallId = "jkl", - ) - val assignment = ConfirmableAssignment(experimentId = experimentId, variant = variant) - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null) - val network = NetworkMock(factory = dependencyContainer) - val storage = StorageMock(context = context) - val configManager = - ConfigManager( - context = context, - options = SuperwallOptions(), -// storeKitManager = dependencyContainer.storeKitManager, - storage = storage, - network = network, - paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, - factory = dependencyContainer, - deviceHelper = DeviceHelper(context, storage, network, helperFactory), - ) - configManager.confirmAssignment(assignment) - - // Adding a delay because confirming assignments is on a queue - delay(500) - - try { - assertTrue(network.assignmentsConfirmed) - assertEquals(storage.getConfirmedAssignments()[experimentId], variant) - assertNull(configManager.unconfirmedAssignments[experimentId]) - } catch (e: Throwable) { - throw e - } finally { - dependencyContainer.provideJavascriptEvaluator(context).teardown() + Given("we have a ConfigManager with a mock assignment") { + // get context + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val experimentId = "abc" + val variantId = "def" + val variant = + Experiment.Variant( + id = variantId, + type = Experiment.Variant.VariantType.TREATMENT, + paywallId = "jkl", + ) + val assignment = + ConfirmableAssignment(experimentId = experimentId, variant = variant) + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val network = NetworkMock() + val storage = StorageMock(context = context) + val assignments = Assignments(storage, network, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + storage, + assignments, + dependencyContainer.paywallManager, + ) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = network, + paywallManager = dependencyContainer.paywallManager, + storeKitManager = dependencyContainer.storeKitManager, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignments, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we confirm the assignment") { + assignments.confirmAssignment(assignment) + delay(500) + } + + Then("the assignment should be confirmed and stored correctly") { + assertTrue(network.assignmentsConfirmed) + assertEquals(storage.getConfirmedAssignments()[experimentId], variant) + assertNull(configManager.unconfirmedAssignments[experimentId]) + } } } @Test fun test_loadAssignments_noConfig() = runTest { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null) - val evaluator = dependencyContainer.provideJavascriptEvaluator(context) - val network = NetworkMock(factory = dependencyContainer) - val storage = StorageMock(context = context) - val configManager = - ConfigManager( - context = context, - options = SuperwallOptions(), - storage = storage, - network = network, - paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, - factory = dependencyContainer, - deviceHelper = DeviceHelper(context, storage, network, helperFactory), - ) - - val job = - launch { - configManager.getAssignments() - ensureActive() - // Make sure we never get here... - assert(false) + Given("we have a ConfigManager with no config") { + // get context + val context = InstrumentationRegistry.getInstrumentation().targetContext + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val network = NetworkMock() + val storage = StorageMock(context = context) + val assignments = Assignments(storage, network, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + storage, + assignments, + dependencyContainer.paywallManager, + ) + + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = network, + paywallManager = dependencyContainer.paywallManager, + storeKitManager = dependencyContainer.storeKitManager, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignments, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we try to get assignments") { + val job = + launch { + configManager.getAssignments() + ensureActive() + assert(false) // Make sure we never get here... + } + delay(1000) + job.cancel() } - delay(1000) - - job.cancel() - - try { - assertTrue(storage.getConfirmedAssignments().isEmpty()) - assertTrue(configManager.unconfirmedAssignments.isEmpty()) - } catch (e: Throwable) { - throw e - } finally { - evaluator.teardown() + Then("no assignments should be stored") { + assertTrue(storage.getConfirmedAssignments().isEmpty()) + assertTrue(configManager.unconfirmedAssignments.isEmpty()) + } } } @Test fun test_loadAssignments_noTriggers() = runTest { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null) - val network = NetworkMock(factory = dependencyContainer) - val storage = StorageMock(context = context) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = network, - paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, - factory = dependencyContainer, - deviceHelper = DeviceHelper(context, storage, network, helperFactory), + Given("we have a ConfigManager with a config that has no triggers") { + // get context + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val network = NetworkMock() + val storage = StorageMock(context = context) + val assignments = Assignments(storage, network, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + storage, + assignments, + dependencyContainer.paywallManager, + ) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = network, + paywallManager = dependencyContainer.paywallManager, + storeKitManager = dependencyContainer.storeKitManager, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignments, + paywallPreload = preload, + ioScope = this@runTest, + ) + configManager.setConfig( + Config.stub().apply { this.triggers = emptySet() }, ) - configManager.setConfig( - Config.stub().apply { this.triggers = emptySet() }, - ) - - configManager.getAssignments() - - try { - assertTrue(storage.getConfirmedAssignments().isEmpty()) - assertTrue(configManager.unconfirmedAssignments.isEmpty()) - } catch (e: Throwable) { - throw e - } finally { - dependencyContainer.provideJavascriptEvaluator(context).teardown() + + When("we get assignments") { + configManager.getAssignments() + } + + Then("no assignments should be stored") { + assertTrue(storage.getConfirmedAssignments().isEmpty()) + assertTrue(configManager.unconfirmedAssignments.isEmpty()) + } } } @Test fun test_loadAssignments_saveAssignmentsFromServer() = runTest { - // get context - val context = InstrumentationRegistry.getInstrumentation().targetContext - - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null) - val network = NetworkMock(factory = dependencyContainer) - val storage = StorageMock(context = context) - val configManager = - ConfigManagerUnderTest( - context = context, - storage = storage, - network = network, - paywallManager = dependencyContainer.paywallManager, - storeKitManager = dependencyContainer.storeKitManager, - factory = dependencyContainer, - deviceHelper = DeviceHelper(context, storage, network, helperFactory), + Given("we have a ConfigManager with assignments from the server") { + // get context + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val network = NetworkMock() + val storage = StorageMock(context = context) + val assignmentStore = Assignments(storage, network, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + storage, + assignmentStore, + dependencyContainer.paywallManager, + ) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = network, + paywallManager = dependencyContainer.paywallManager, + storeKitManager = dependencyContainer.storeKitManager, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignmentStore, + paywallPreload = preload, + ioScope = this@runTest, + ) + + val variantId = "variantId" + val experimentId = "experimentId" + + val assignments: List = + listOf( + Assignment(experimentId = experimentId, variantId = variantId), + ) + network.assignments = assignments.toMutableList() + + val variantOption = VariantOption.stub().apply { id = variantId } + configManager.setConfig( + Config.stub().apply { + triggers = + setOf( + Trigger.stub().apply { + rules = + listOf( + TriggerRule.stub().apply { + this.experimentId = experimentId + this.variants = listOf(variantOption) + }, + ) + }, + ) + }, ) - val variantId = "variantId" - val experimentId = "experimentId" + When("we get assignments") { + configManager.getAssignments() + delay(1) + } - val assignments: List = - listOf( - Assignment(experimentId = experimentId, variantId = variantId), - ) - network.assignments = assignments.toMutableList() - - val variantOption = VariantOption.stub().apply { id = variantId } - configManager.setConfig( - Config.stub().apply { - triggers = - setOf( - Trigger.stub().apply { - rules = - listOf( - TriggerRule.stub().apply { - this.experimentId = experimentId - this.variants = listOf(variantOption) - }, - ) - }, - ) - }, - ) - - configManager.getAssignments() - - delay(1) - - try { - assertEquals( - storage.getConfirmedAssignments()[experimentId], - variantOption.toVariant(), - ) - assertTrue(configManager.unconfirmedAssignments.isEmpty()) - } catch (e: Throwable) { - throw e - } finally { - dependencyContainer.provideJavascriptEvaluator(context).teardown() + Then("the assignments should be stored correctly") { + assertEquals( + storage.getConfirmedAssignments()[experimentId], + variantOption.toVariant(), + ) + assertTrue(configManager.unconfirmedAssignments.isEmpty()) + } } } - val mockDeviceHelper = - mockk { - every { appVersion } returns "1.0" - every { locale } returns "en-US" - coEvery { getTemplateDevice() } returns emptyMap() - } - @Test fun should_refresh_config_successfully() = runTest(timeout = Duration.INFINITE) { - val mockNetwork = - mockk { - coEvery { getConfig(any()) } returns Config.stub() - coEvery { getGeoInfo() } returns mockk() + Given("we have a ConfigManager with an old config") { + val mockNetwork = + mockk { + coEvery { getConfig(any()) } returns + Either.Success( + Config.stub(), + ) + coEvery { getGeoInfo() } returns Either.Success(mockk()) + } + val context = InstrumentationRegistry.getInstrumentation().targetContext + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val storage = StorageMock(context = context) + val oldConfig = + Config.stub().copy( + rawFeatureFlags = + listOf( + RawFeatureFlag("enable_config_refresh", true), + ), + ) + + val mockPaywallManager = + mockk { + every { resetPaywallRequestCache() } just Runs + every { currentView } returns null + } + + val mockContainer = + spyk(dependencyContainer) { + every { deviceHelper } returns mockDeviceHelper + every { paywallManager } returns mockPaywallManager + } + val assignments = Assignments(storage, mockNetwork, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + storage, + assignments, + dependencyContainer.paywallManager, + ) + + val testId = "123" + val configManager = + spyk( + ConfigManagerUnderTest( + context, + storage, + mockNetwork, + mockPaywallManager, + dependencyContainer.storeKitManager, + mockContainer, + mockDeviceHelper, + assignments = assignments, + paywallPreload = preload, + ioScope = this@runTest, + ), + ) { + every { config } returns oldConfig.copy(requestId = testId) + } + + When("we refresh the configuration") { + Superwall.configure( + context.applicationContext as Application, + "pk_test_1234", + null, + null, + null, + null, + ) + configManager.refreshConfiguration() } - val context = InstrumentationRegistry.getInstrumentation().targetContext - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null) - val storage = StorageMock(context = context) - val oldConfig = - Config.stub().copy( - rawFeatureFlags = - listOf( - RawFeatureFlag("enable_config_refresh", true), + + Then("the config should be refreshed and the paywall cache reset") { + coVerify { mockNetwork.getConfig(any()) } + verify { mockPaywallManager.resetPaywallRequestCache() } + assertTrue(configManager.config?.requestId === testId) + } + } + } + + @Test + fun should_fail_refreshing_config_and_keep_old_config() = + runTest(timeout = Duration.INFINITE) { + Given("we have a ConfigManager with an old config and a network that fails") { + val context = InstrumentationRegistry.getInstrumentation().targetContext + val mockNetwork = + mockk { + coEvery { getConfig(any()) } returns Either.Failure(NetworkError.Unknown()) + coEvery { getGeoInfo() } returns Either.Success(mockk()) + } + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val storage = StorageMock(context = context) + val oldConfig = + Config.stub().copy( + rawFeatureFlags = + listOf( + RawFeatureFlag("enable_config_refresh", true), + ), + ) + + val mockPaywallManager = + mockk { + every { resetPaywallRequestCache() } just Runs + every { currentView } returns null + } + + val mockContainer = + spyk(dependencyContainer) { + every { deviceHelper } returns mockDeviceHelper + every { paywallManager } returns mockPaywallManager + } + val assignments = Assignments(storage, mockNetwork, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + storage, + assignments, + dependencyContainer.paywallManager, + ) + + val testId = "123" + val configManager = + spyk( + ConfigManagerUnderTest( + context, + storage, + mockNetwork, + mockPaywallManager, + dependencyContainer.storeKitManager, + mockContainer, + mockDeviceHelper, + assignments = assignments, + paywallPreload = preload, + ioScope = this@runTest, ), - ) + ) { + every { config } returns oldConfig.copy(requestId = testId) + } + + When("we try to refresh the configuration") { + configManager.refreshConfiguration() - val mockPaywallManager = - mockk { - every { resetPaywallRequestCache() } just Runs - every { currentView } returns null + Then("the old config should be kept") { + coVerify { mockNetwork.getConfig(any()) } + assertTrue(configManager.config?.requestId === testId) + } } + } + } + + private val storage = + mockk { + coEvery { write(any(), any()) } just Runs + } + private val dependencyContainer = + mockk { + coEvery { makeSessionDeviceAttributes() } returns hashMapOf() + coEvery { provideJavascriptEvaluator(any()) } returns mockk() + } + + private val manager = + mockk { + every { resetPaywallRequestCache() } just Runs + every { resetCache() } just Runs + } + private val storeKit = + mockk { + coEvery { products(any()) } returns emptySet() + } + private val preload = + mockk { + coEvery { preloadAllPaywalls(any(), any()) } just Runs + coEvery { preloadPaywallsByNames(any(), any()) } just Runs + coEvery { removeUnusedPaywallVCsFromCache(any(), any()) } just Runs + } + + private val localStorage = + mockk { + every { getConfirmedAssignments() } returns emptyMap() + every { saveConfirmedAssignments(any()) } just Runs + } + private val mockNetwork = mockk() - val mockContainer = - spyk(dependencyContainer) { - every { deviceHelper } returns mockDeviceHelper - every { paywallManager } returns mockPaywallManager + @Test + fun test_network_delay_with_cached_version() = + runTest { + Given("we have a cached config and a delayed network response") { + val cachedConfig = + Config.stub().copy( + buildId = "cached", + rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh", true)), + ) + val newConfig = Config.stub().copy(buildId = "not") + + coEvery { storage.read(LatestConfig) } returns cachedConfig + coEvery { storage.write(any(), any()) } just Runs + coEvery { storage.read(LatestGeoInfo) } returns GeoInfo.stub() + coEvery { mockNetwork.getConfig(any()) } coAnswers { + delay(400) + Either.Success(newConfig) + } + coEvery { mockNetwork.getGeoInfo() } coAnswers { + delay(200) + Either.Success(GeoInfo.stub()) } - val testId = "123" - val configManager = - spyk( - ConfigManager( - context, - dependencyContainer.storeKitManager, - storage, - mockNetwork, - mockDeviceHelper, - SuperwallOptions(), - mockPaywallManager, - mockContainer, - this, - ), - ) { - every { config } returns - oldConfig.copy( - requestId = testId, - ) + coEvery { + mockDeviceHelper.getGeoInfo() + } coAnswers { + delay(200) + Either.Success(GeoInfo.stub()) } - configManager.refreshConfiguration() - coVerify { mockNetwork.getConfig(any()) } - verify { mockPaywallManager.resetPaywallRequestCache() } - assertTrue(configManager.config?.requestId === testId) + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) + val mockContainer = + spyk(dependencyContainer) { + every { deviceHelper } returns mockDeviceHelper + every { paywallManager } returns manager + } + + val assignmentStore = Assignments(localStorage, mockNetwork, this@runTest) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = mockNetwork, + paywallManager = mockContainer.paywallManager, + storeKitManager = mockContainer.storeKitManager, + factory = mockContainer, + deviceHelper = mockDeviceHelper, + assignments = assignmentStore, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we fetch the configuration") { + configManager.fetchConfiguration() + + Then("the cached config should be used initially") { + coVerify(exactly = 1) { storage.read(LatestConfig) } + configManager.configState.first { it is ConfigState.Retrieved } + assertEquals("cached", configManager.config?.buildId) + + And("we wait for new config to be retrieved") { + configManager.configState.drop(1).first { it is ConfigState.Retrieved } + + Then("the new config should be fetched and used") { + assertEquals("not", configManager.config?.buildId) + } + } + } + } + } } @Test - fun should_fail_refreshing_config_and_keep_old_config() = - runTest(timeout = Duration.INFINITE) { - val context = InstrumentationRegistry.getInstrumentation().targetContext - val mockNetwork = - mockk { - coEvery { getConfig(any()) } throws IllegalStateException() - coEvery { getGeoInfo() } returns mockk() + fun test_network_delay_without_cached_version() = + runTest { + Given("we have no cached config and a delayed network response") { + coEvery { storage.read(LatestConfig) } returns null + coEvery { localStorage.read(LatestGeoInfo) } returns null + coEvery { storage.read(LatestGeoInfo) } returns null + coEvery { + mockDeviceHelper.getGeoInfo() + } returns Either.Failure(NetworkError.Unknown()) + coEvery { + mockNetwork.getGeoInfo() + } returns Either.Failure(NetworkError.Unknown()) + coEvery { mockNetwork.getConfig(any()) } coAnswers { + delay(500) + Either.Success(Config.stub().copy(buildId = "not")) } - val dependencyContainer = - DependencyContainer(context, null, null, activityProvider = null) - val storage = StorageMock(context = context) - val oldConfig = - Config.stub().copy( - rawFeatureFlags = - listOf( - RawFeatureFlag("enable_config_refresh", true), - ), - ) + coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val assignmentStore = Assignments(localStorage, mockNetwork, this@runTest) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = mockNetwork, + paywallManager = manager, + storeKitManager = storeKit, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignmentStore, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we fetch the configuration") { + configManager.fetchConfiguration() + + And("we wait for it to be retrieved") { + configManager.configState.first { it is ConfigState.Retrieved } + + Then("the new config should be fetched exactly once and used") { + coVerify(exactly = 1) { mockNetwork.getConfig(any()) } + assertEquals("not", configManager.config?.buildId) + } + } + } + } + } - val mockPaywallManager = - mockk { - every { resetPaywallRequestCache() } just Runs - every { currentView } returns null + @Test + fun test_network_failure_with_cached_version() = + runTest { + Given("we have a cached config and a network failure") { + val cachedConfig = + Config.stub().copy( + buildId = "cached", + rawFeatureFlags = + listOf( + RawFeatureFlag("enable_config_refresh", true), + ), + ) + + coEvery { storage.read(LatestConfig) } returns cachedConfig + coEvery { mockNetwork.getConfig(any()) } returns Either.Failure(NetworkError.Unknown()) + coEvery { localStorage.read(LatestGeoInfo) } returns null + coEvery { storage.read(LatestGeoInfo) } returns null + coEvery { + mockNetwork.getGeoInfo() + } returns Either.Failure(NetworkError.Unknown()) + + coEvery { + mockDeviceHelper.getGeoInfo() + } returns Either.Failure(NetworkError.Unknown()) + + coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val assignmentStore = Assignments(localStorage, mockNetwork, this@runTest) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = mockNetwork, + paywallManager = manager, + storeKitManager = storeKit, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignmentStore, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we fetch the configuration") { + configManager.fetchConfiguration() + + Then("the cached config should be used") { + configManager.configState.first { it is ConfigState.Retrieved } + coEvery { mockNetwork.getConfig(any()) } returns + Either.Success( + Config.stub().copy(buildId = "not"), + ) + assertEquals("cached", configManager.config?.buildId) + + And("the network becomes available and we fetch again") { + coEvery { mockNetwork.getConfig(any()) } returns + Either.Success( + Config.stub().copy(buildId = "not"), + ) + + Then("the new config should be set and used") { + configManager.configState + .drop(1) + .first { it is ConfigState.Retrieved } + assertEquals("not", configManager.config?.buildId) + } + } + } } + } + } + + @Test + fun test_quick_network_success() = + runTest { + Given("we have a quick network response") { + val newConfig = Config.stub().copy(buildId = "not") - val mockContainer = - spyk(dependencyContainer) { - every { deviceHelper } returns mockDeviceHelper - every { paywallManager } returns mockPaywallManager + coEvery { storage.read(LatestConfig) } returns null + coEvery { mockNetwork.getConfig(any()) } coAnswers { + delay(200) + Either.Success(newConfig) } - val testId = "123" - val configManager = - spyk( - ConfigManager( - context, - dependencyContainer.storeKitManager, - storage, - mockNetwork, - mockDeviceHelper, - SuperwallOptions(), - mockPaywallManager, - mockContainer, - this, - ), - ) { - every { config } returns oldConfig.copy(requestId = testId) + coEvery { localStorage.read(LatestGeoInfo) } returns null + coEvery { storage.read(LatestGeoInfo) } returns null + coEvery { + mockNetwork.getGeoInfo() + } returns Either.Success(GeoInfo.stub()) + + coEvery { + mockDeviceHelper.getGeoInfo() + } returns Either.Success(GeoInfo.stub()) + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val assignmentStore = Assignments(localStorage, mockNetwork, this@runTest) + val preload = + PaywallPreload( + dependencyContainer, + this@runTest, + localStorage, + assignmentStore, + manager, + ) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = mockNetwork, + paywallManager = manager, + storeKitManager = storeKit, + factory = dependencyContainer, + deviceHelper = mockDeviceHelper, + assignments = assignmentStore, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we fetch the configuration") { + configManager.fetchConfiguration() + + Then("the new config should be used immediately") { + assertEquals("not", configManager.config?.buildId) + } } + } + } + + @Test + fun test_config_and_geo_calls_both_cached() = + runTest { + Given("we have cached config and geo info, and delayed network responses") { + val cachedConfig = + Config.stub().copy( + buildId = "cached", + rawFeatureFlags = listOf(RawFeatureFlag("enable_config_refresh", true)), + ) + val newConfig = Config.stub().copy(buildId = "not") + val cachedGeo = GeoInfo.stub().copy(country = "cachedCountry") + val newGeo = GeoInfo.stub().copy(country = "newCountry") + + coEvery { storage.read(LatestConfig) } returns cachedConfig + coEvery { storage.read(LatestGeoInfo) } returns cachedGeo + coEvery { localStorage.read(LatestGeoInfo) } returns cachedGeo + + coEvery { mockNetwork.getConfig(any()) } coAnswers { + async(Dispatchers.IO) { + delay(400) + }.await() + Either.Success(newConfig) + } + coEvery { mockDeviceHelper.getGeoInfo() } coAnswers { + async(Dispatchers.IO) { + delay(400) + }.await() + Either.Success(newGeo) + } + coEvery { mockDeviceHelper.getTemplateDevice() } returns emptyMap() + + val context = InstrumentationRegistry.getInstrumentation().targetContext + + val dependencyContainer = + DependencyContainer(context, null, null, activityProvider = null) - configManager.refreshConfiguration() - coVerify { mockNetwork.getConfig(any()) } - assertTrue(configManager.config?.requestId === testId) + val mockContainer = + spyk(dependencyContainer) { + every { deviceHelper } returns mockDeviceHelper + every { paywallManager } returns manager + } + val assignmentStore = Assignments(localStorage, mockNetwork, this@runTest) + val preload = + PaywallPreload( + mockContainer, + this@runTest, + localStorage, + assignmentStore, + dependencyContainer.paywallManager, + ) + val configManager = + ConfigManagerUnderTest( + context = context, + storage = storage, + network = mockNetwork, + paywallManager = mockContainer.paywallManager, + storeKitManager = mockContainer.storeKitManager, + factory = mockContainer, + deviceHelper = mockDeviceHelper, + assignments = assignmentStore, + paywallPreload = preload, + ioScope = this@runTest, + ) + + When("we fetch the configuration") { + configManager.fetchConfiguration() + + Then("the cached config and geo info should be used initially") { + configManager.configState.first { it is ConfigState.Retrieved }.also { + assertEquals("cached", it.getConfig()?.buildId) + } + + And("we wait until new config is available") { + configManager.configState.drop(1).first { it is ConfigState.Retrieved } + + Then("the new config and geo info should be fetched and used") { + assertEquals("not", configManager.config?.buildId) + } + } + } + } + } } } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/network/BaseHostServiceTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/network/BaseHostServiceTest.kt new file mode 100644 index 00000000..c97293d6 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/network/BaseHostServiceTest.kt @@ -0,0 +1,249 @@ +package com.superwall.sdk.network + +import BaseHostService +import Given +import Then +import When +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.assignment.Assignment +import com.superwall.sdk.models.assignment.ConfirmedAssignmentResponse +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.paywall.Paywalls +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import io.mockk.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class BaseHostServiceTest { + private lateinit var customHttpUrlConnection: CustomHttpUrlConnection + private lateinit var apiFactory: ApiFactory + private lateinit var service: BaseHostService + private lateinit var executor: RequestExecutor + + @Before + fun setup() { + customHttpUrlConnection = mockk() + apiFactory = mockk() + executor = mockk() + service = + BaseHostService( + host = "test.com", + version = "/v1/", + factory = apiFactory, + json = Json { ignoreUnknownKeys = true }, + customHttpUrlConnection = + CustomHttpUrlConnection( + Json { ignoreUnknownKeys = true }, + executor, + ), + ) + } + + @Test + fun test_config_success() = + runTest { + Given("a valid API key and request ID") { + val apiKey = "test_api_key" + val requestId = "test_request_id" + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(Config.stub()), + duration = 10.0, + headers = mapOf("Authorization" to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + coEvery { apiFactory.storage.apiKey } returns apiKey + + When("requesting the config") { + val result = service.config(requestId) + + Then("the result should be a success with the expected config") { + assertTrue(result is Either.Success) + assertEquals(Config.stub(), (result as Either.Success).value) + } + } + } + } + + @Test + fun test_config_failure() = + runTest { + val error = NetworkError.Unknown() + Given("an invalid API key or network error") { + val requestId = "test_request_id" + coEvery { executor.execute(any()) } returns Either.Failure(error) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + coEvery { apiFactory.storage.apiKey } returns "invalid_api_key" + + When("requesting the config") { + val result = service.config(requestId) + + Then("the result should be a failure") { + assertTrue(result is Either.Failure) + assertEquals(error, (result as Either.Failure).error) + } + } + } + } + + @Test + fun test_assignments_success() = + runTest { + Given("a valid request for assignments") { + val mockResponse = ConfirmedAssignmentResponse(mutableListOf(Assignment("exp1", "var1"))) + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(mockResponse), + duration = 10.0, + headers = mapOf("Authorization" to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("requesting assignments") { + val result = service.assignments() + + Then("the result should be a success with the expected assignments") { + assertTrue(result is Either.Success) + assertEquals(mockResponse, (result as Either.Success).value) + } + } + } + } + + @Test + fun test_assignments_failure() = + runTest { + val error = NetworkError.Unknown() + Given("a network error occurs") { + coEvery { executor.execute(any()) } returns Either.Failure(error) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("requesting assignments") { + val result = service.assignments() + + Then("the result should be a failure") { + assertTrue(result is Either.Failure) + assertEquals(error, (result as Either.Failure).error) + } + } + } + } + + @Test + fun test_paywalls_success() = + runTest { + Given("a valid request for paywalls") { + val mockResponse = Paywalls(listOf(Paywall.stub())) + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(mockResponse), + duration = 10.0, + headers = mapOf("Authorization" to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("requesting paywalls") { + val result = service.paywalls() + + Then("the result should be a success with the expected paywalls") { + assertTrue(result is Either.Success) + assertEquals(mockResponse, (result as Either.Success).value) + } + } + } + } + + @Test + fun test_paywalls_failure() = + runTest { + val error = NetworkError.Unknown() + + Given("a network error occurs") { + coEvery { executor.execute(any()) } returns Either.Failure(error) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("requesting paywalls") { + val result = service.paywalls() + + Then("the result should be a failure") { + assertTrue(result is Either.Failure) + assertEquals(error, (result as Either.Failure).error) + } + } + } + } + + @Test + fun test_paywall_success() = + runTest { + Given("a valid request for a specific paywall") { + val paywallId = "test_paywall_id" + val mockResponse = Paywall.stub() + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(mockResponse), + duration = 10.0, + headers = mapOf("Authorization" to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + coEvery { apiFactory.storage.apiKey } returns "test_api_key" + coEvery { apiFactory.configManager.config } returns Config.stub() + coEvery { apiFactory.deviceHelper.locale } returns "en_US" + + When("requesting a specific paywall") { + val result = service.paywall(paywallId) + + Then("the result should be a success with the expected paywall") { + assertTrue(result is Either.Success) + assertEquals(mockResponse, (result as Either.Success).value) + } + } + } + } + + @Test + fun test_paywall_failure() = + runTest { + Given("an invalid paywall ID or network error") { + val paywallId = "invalid_paywall_id" + val error = NetworkError.Unknown() + coEvery { executor.execute(any()) } returns Either.Failure(error) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + coEvery { apiFactory.storage.apiKey } returns "test_api_key" + coEvery { apiFactory.configManager.config } returns Config.stub() + coEvery { apiFactory.deviceHelper.locale } returns "en_US" + + When("requesting a specific paywall") { + val result = service.paywall(paywallId) + + Then("the result should be a failure") { + assertTrue(result is Either.Failure) + assertEquals(error, (result as Either.Failure).error) + } + } + } + } +} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/network/CollectorServiceTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/network/CollectorServiceTest.kt new file mode 100644 index 00000000..db2d3d58 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/network/CollectorServiceTest.kt @@ -0,0 +1,139 @@ +package com.superwall.sdk.network + +import Given +import Then +import When +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.events.EventsRequest +import com.superwall.sdk.models.events.EventsResponse +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import io.mockk.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test +import java.time.Instant +import java.util.Date + +class CollectorServiceTest { + private lateinit var customHttpUrlConnection: CustomHttpUrlConnection + private lateinit var apiFactory: ApiFactory + private lateinit var service: CollectorService + private lateinit var executor: RequestExecutor + + @Before + fun setup() { + customHttpUrlConnection = mockk() + apiFactory = mockk() + executor = mockk() + service = + CollectorService( + host = "test.com", + version = "/v1/", + factory = apiFactory, + json = Json { ignoreUnknownKeys = true }, + customHttpUrlConnection = + CustomHttpUrlConnection( + Json { ignoreUnknownKeys = true }, + executor, + ), + ) + } + + @Test + fun test_events_success() = + runTest { + Given("a valid events request") { + val eventsRequest = + EventsRequest( + listOf( + EventData( + name = "test_event", + parameters = emptyMap(), + createdAt = + Date.from( + Instant.now(), + ), + ), + ), + ) + val mockResponse = EventsResponse(EventsResponse.Status.OK) + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(mockResponse), + duration = 10.0, + headers = mapOf("Authorization " to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("sending events") { + val result = service.events(eventsRequest) + + Then("the result should be a success with the expected response") { + assertTrue(result is Either.Success) + assertEquals(mockResponse, (result as Either.Success).value) + } + } + } + } + + @Test + fun test_events_partial_success() = + runTest { + Given("a partially valid events request") { + val eventsRequest = + EventsRequest( + listOf( + EventData( + name = "test_event", + parameters = emptyMap(), + createdAt = + Date.from( + Instant.now(), + ), + ), + EventData( + name = "invalid_event", + parameters = emptyMap(), + createdAt = + Date.from( + Instant.now(), + ), + ), + ), + ) + val mockResponse = EventsResponse(EventsResponse.Status.PARTIAL_SUCCESS, listOf(1)) + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(mockResponse), + duration = 10.0, + headers = mapOf("Authorization" to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("sending events") { + val result = service.events(eventsRequest) + + Then("the result should be a success with partial success status") { + assertTrue(result is Either.Success) + val response = (result as Either.Success).value + assertEquals(EventsResponse.Status.PARTIAL_SUCCESS, response.status) + assertEquals(listOf(1), response.invalidIndexes) + } + } + } + } +} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/network/GeoServiceTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/network/GeoServiceTest.kt new file mode 100644 index 00000000..04fc6304 --- /dev/null +++ b/superwall/src/androidTest/java/com/superwall/sdk/network/GeoServiceTest.kt @@ -0,0 +1,105 @@ +package com.superwall.sdk.network + +import GeoService +import Given +import Then +import When +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.geo.GeoWrapper +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import io.mockk.* +import kotlinx.coroutines.test.runTest +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json +import org.junit.Assert.assertEquals +import org.junit.Assert.assertTrue +import org.junit.Before +import org.junit.Test + +class GeoServiceTest { + private lateinit var customHttpUrlConnection: CustomHttpUrlConnection + private lateinit var apiFactory: ApiFactory + private lateinit var service: GeoService + private lateinit var executor: RequestExecutor + + @Before + fun setup() { + customHttpUrlConnection = mockk() + apiFactory = mockk() + executor = mockk() + service = + GeoService( + host = "test.com", + version = "/v1/", + factory = apiFactory, + customHttpUrlConnection = + CustomHttpUrlConnection( + Json { ignoreUnknownKeys = true }, + executor, + ), + ) + } + + @Test + fun test_geo_success() = + runTest { + Given("a valid request for geo information") { + val mockResponse = + GeoWrapper( + GeoInfo( + country = "US", + region = "CA", + city = "San Francisco", + postalCode = "94105", + longitude = -122.3959, + latitude = 37.7911, + regionCode = "CA", + continent = "NA", + timezone = "America/Los_Angeles", + metroCode = "USD", + ), + ) + coEvery { executor.execute(any()) } returns + Either.Success( + RequestResult( + requestId = "1", + responseCode = 200, + responseMessage = Json.encodeToString(mockResponse), + duration = 10.0, + headers = mapOf("Authorization" to "Bearer token"), + ), + ) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("requesting geo information") { + val result = service.geo() + + Then("the result should be a success with the expected geo information") { + assertTrue(result is Either.Success) + assertEquals(mockResponse, (result as Either.Success).value) + } + } + } + } + + @Test + fun test_geo_failure() = + runTest { + Given("a network error occurs") { + val error = NetworkError.Unknown() + coEvery { executor.execute(any()) } returns Either.Failure(error) + coEvery { apiFactory.makeHeaders(any(), any()) } returns mapOf("Authorization" to "Bearer token") + + When("requesting geo information") { + val result = service.geo() + + Then("the result should be a failure") { + assertTrue(result is Either.Failure) + assertEquals(error, (result as Either.Failure).error) + } + } + } + } +} diff --git a/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt b/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt index 1158e13f..30c1e56a 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/network/NetworkMock.kt @@ -1,15 +1,15 @@ package com.superwall.sdk.network -import androidx.lifecycle.Lifecycle -import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.misc.Either import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.AssignmentPostback import com.superwall.sdk.models.config.Config -import kotlinx.coroutines.flow.SharedFlow +import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.events.EventsRequest +import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.paywall.Paywall -class NetworkMock( - factory: ApiFactory, -) : Network(factory = factory) { +class NetworkMock : SuperwallAPI { // var sentSessionEvents: SessionEventsRequest? = null var getConfigCalled = false var assignmentsConfirmed = false @@ -17,23 +17,43 @@ class NetworkMock( var configReturnValue: Config? = Config.stub() var configError: Exception? = null + override suspend fun sendEvents(events: EventsRequest): Either { + TODO("Not yet implemented") + } + // suspend fun sendSessionEvents(session: SessionEventsRequest) { // sentSessionEvents = session // } @Throws(Exception::class) - suspend fun getConfig(injectedApplicationStatePublisher: SharedFlow? = null): Config { + override suspend fun getConfig(isRetryingCallback: suspend () -> Unit): Either { getConfigCalled = true configReturnValue?.let { - return it + return Either.Success(it) } ?: throw configError ?: Exception("Config Error") } - override suspend fun confirmAssignments(confirmableAssignments: AssignmentPostback) { + override suspend fun confirmAssignments(confirmableAssignments: AssignmentPostback): Either { assignmentsConfirmed = true + return Either.Success(Unit) + } + + override suspend fun getPaywall( + identifier: String?, + event: EventData?, + ): Either { + TODO("Not yet implemented") + } + + override suspend fun getPaywalls(): Either, NetworkError> { + TODO("Not yet implemented") + } + + override suspend fun getGeoInfo(): Either { + TODO("Not yet implemented") } @Throws(Exception::class) - override suspend fun getAssignments(): List = assignments + override suspend fun getAssignments(): Either, NetworkError> = Either.Success(assignments) } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt index 2370bcf8..dd138c4b 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorInstrumentedTest.kt @@ -13,7 +13,7 @@ import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.models.triggers.VariantOption import com.superwall.sdk.paywall.presentation.rule_logic.javascript.SandboxJavascriptEvaluator -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.StorageMock import junit.framework.TestCase.assertEquals import kotlinx.coroutines.CoroutineScope @@ -57,7 +57,7 @@ class ExpressionEvaluatorInstrumentedTest { sandbox = null } - private fun CoroutineScope.evaluatorFor(storage: Storage) = + private fun CoroutineScope.evaluatorFor(storage: LocalStorage) = SandboxJavascriptEvaluator( sandbox ?: error("Sandbox not initialized"), storage = storage, diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt index 51bf2925..3396b786 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvaluatorTest.kt @@ -5,7 +5,7 @@ import androidx.javascriptengine.JavaScriptSandbox import androidx.javascriptengine.SandboxDeadException import androidx.test.platform.app.InstrumentationRegistry import com.superwall.sdk.models.triggers.TriggerRule -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import io.mockk.every import io.mockk.mockk import io.mockk.mockkStatic @@ -24,7 +24,7 @@ class DefaultJavascriptEvaluatorTest { @Test fun evaulate_succesfully_with_sandbox() = runTest { - val storage = mockk() + val storage = mockk() mockkStatic(WebView::class) { every { WebView.getCurrentWebViewPackage() } returns null } @@ -42,7 +42,7 @@ class DefaultJavascriptEvaluatorTest { @Test fun fail_evaluating_with_sandbox_and_fallback_is_used() = runTest { - val storage = mockk() + val storage = mockk() val sandbox = JavaScriptSandbox.createConnectedInstanceAsync(ctx()).await() diff --git a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt index 43320084..ebbc63bf 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/paywall/presentation/rule_logic/vc/webview/WebviewFallbackClientTest.kt @@ -108,11 +108,15 @@ class WebviewFallbackClientTest { ) val paywall = createPaywallConfig( - PaywallWebviewUrl( - url = "https://www.google.com", - score = 10, - timeout = 0, - ), + configs = + arrayOf( + PaywallWebviewUrl( + url = "https://www.google.com", + score = 10, + timeout = 0, + ), + ), + maxAttemps = 3, ) val context = InstrumentationRegistry.getInstrumentation().context val webview = @@ -150,13 +154,13 @@ class WebviewFallbackClientTest { createPaywallConfig( PaywallWebviewUrl( url = "https://www.this-url-doesnt-exist-so-test-fails.com", - timeout = 100, + timeout = 10, score = 100, ), PaywallWebviewUrl( url = "https://www.wikipedia.org", timeout = 500, - score = 10, + score = 1, ), ) val context = InstrumentationRegistry.getInstrumentation().context @@ -175,9 +179,9 @@ class WebviewFallbackClientTest { val events = webview .clientEvents(mainScope) - .take(4) + .take(5) .toList() - assert(events[0] is WebviewClientEvent.OnError) + assert(events.any { it is WebviewClientEvent.OnError }) assert(events.count { it is OnPageFinished && it.url.contains("wiki") } == 1) } } @@ -197,12 +201,12 @@ class WebviewFallbackClientTest { createPaywallConfig( PaywallWebviewUrl( url = "https://www.this-url-doesnt-exist-so-test-fails.com", - timeout = 1, + timeout = 0, score = 100, ), PaywallWebviewUrl( url = "https://www.this-url-doesnt-exist-so-test-fails-too.com", - timeout = 500, + timeout = 10, score = 10, ), PaywallWebviewUrl( @@ -226,10 +230,11 @@ class WebviewFallbackClientTest { val events = webview .clientEvents(mainScope) - .take(7) + .take(8) .toList() And("the last one is the one with score 0") { val last = events.filterIsInstance().last() + Log.e("WebviewFallbackClientTest", "last.url: ${last.url}") assert(last.url.contains("wiki")) } } @@ -292,10 +297,11 @@ class WebviewFallbackClientTest { private fun failingUrl( index: Int = 0, score: Int = 10, + timeout: Long = 0, ) = PaywallWebviewUrl( url = "https://www.this-url-doesnt-exist-$index.com/", score = score, - timeout = 0, + timeout = timeout, ) @Test @@ -332,7 +338,6 @@ class WebviewFallbackClientTest { it is WebviewClientEvent.OnError && it.webviewError is WebviewError.MaxAttemptsReached } as WebviewClientEvent.OnError val error = event.webviewError as WebviewError.MaxAttemptsReached - Log.e("WebviewFallbackClientTest", "errorUrls: ${error.urls}") assert(error.urls.size == paywall.urlConfig?.maxAttempts) } } diff --git a/superwall/src/androidTest/java/com/superwall/sdk/storage/CacheInstrumentedTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/storage/CacheInstrumentedTest.kt index 71b9f197..7e9feb29 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/storage/CacheInstrumentedTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/storage/CacheInstrumentedTest.kt @@ -4,6 +4,8 @@ import androidx.test.ext.junit.runners.AndroidJUnit4 import androidx.test.platform.app.InstrumentationRegistry import kotlinx.coroutines.runBlocking import kotlinx.coroutines.test.runTest +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy import org.junit.Before import org.junit.Test import org.junit.runner.RunWith @@ -11,13 +13,19 @@ import java.util.* @RunWith(AndroidJUnit4::class) class CacheInstrumentedTest { + val json = + Json { + ignoreUnknownKeys = true + namingStrategy = JsonNamingStrategy.SnakeCase + } + @Before fun setUp() = runBlocking { println("!!setUp - start") // Clear all caches val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val cache = Cache(appContext) + val cache = Cache(appContext, json = json) cache.delete(AppUserId) cache.delete(AliasId) cache.delete(UserAttributes) @@ -28,7 +36,7 @@ class CacheInstrumentedTest { fun test_alias_id() = runTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val cache = Cache(appContext) + val cache = Cache(appContext, json = json) // Test first read val aliasId = cache.read(AppUserId) @@ -49,7 +57,7 @@ class CacheInstrumentedTest { fun test_app_user_id() = runTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val cache = Cache(appContext) + val cache = Cache(appContext, json = json) // Test first read val appUserId = cache.read(AppUserId) @@ -70,7 +78,7 @@ class CacheInstrumentedTest { fun test_user_attributes() = runTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val cache = Cache(appContext) + val cache = Cache(appContext, json = json) // Test first read val userAttributes = cache.read(UserAttributes) @@ -92,7 +100,7 @@ class CacheInstrumentedTest { fun test_last_paywall_view() = runTest { val appContext = InstrumentationRegistry.getInstrumentation().targetContext - val cache = Cache(appContext) + val cache = Cache(appContext, json = json) // Test first read val lastPaywallView = cache.read(LastPaywallView) diff --git a/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt b/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt index 40f4558d..7eb00c97 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/storage/StorageMock.kt @@ -4,8 +4,10 @@ import android.content.Context import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.network.device.DeviceInfo +import kotlinx.serialization.json.Json +import kotlinx.serialization.json.JsonNamingStrategy -class StorageFactoryMock : Storage.Factory { +class StorageFactoryMock : LocalStorage.Factory { override fun makeDeviceInfo(): DeviceInfo = DeviceInfo(appInstalledAtString = "a", locale = "b") override fun makeIsSandbox(): Boolean = true @@ -22,7 +24,15 @@ class StorageMock( // coreDataManager: CoreDataManagerFakeDataMock = CoreDataManagerFakeDataMock(), private var confirmedAssignments: Map = mapOf(), // cache: Cache = Cache(context) -) : Storage(context = context, factory = StorageFactoryMock()) { +) : LocalStorage( + context = context, + factory = StorageFactoryMock(), + json = + Json { + namingStrategy = JsonNamingStrategy.SnakeCase + ignoreUnknownKeys = true + }, + ) { var didClearCachedSessionEvents = false override fun clearCachedSessionEvents() { diff --git a/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt b/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt index 61e24d30..5d733eae 100644 --- a/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt +++ b/superwall/src/androidTest/java/com/superwall/sdk/utilities/ErrorTrackingTest.kt @@ -1,7 +1,7 @@ package com.superwall.sdk.utilities import com.superwall.sdk.storage.ErrorLog -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import io.mockk.Runs import io.mockk.coVerify import io.mockk.every @@ -20,9 +20,9 @@ class ErrorTrackingTest { fun should_save_error_when_occured() = runTest { val storage = - mockk { - every { save(any(), ErrorLog) } just Runs - every { get(ErrorLog) } returns null + mockk { + every { write(any(), ErrorLog) } just Runs + every { read(ErrorLog) } returns null } val errorTracker: ErrorTracking = ErrorTracker(this, storage, { @@ -30,7 +30,7 @@ class ErrorTrackingTest { }) errorTracker.trackError(Exception("Test Error")) - coVerify { storage.save(any(), ErrorLog) } + coVerify { storage.write(any(), ErrorLog) } } @Test @@ -38,10 +38,10 @@ class ErrorTrackingTest { runTest { val error = ErrorTracking.ErrorOccurence("Test Error", "Test Stacktrace", System.currentTimeMillis()) val storage = - mockk { - every { save(any(), ErrorLog) } just Runs - every { get(ErrorLog) } returns error - every { remove(ErrorLog) } just Runs + mockk { + every { write(any(), ErrorLog) } just Runs + every { read(ErrorLog) } returns error + every { delete(ErrorLog) } just Runs } var tracked = MutableStateFlow(false) @@ -54,6 +54,6 @@ class ErrorTrackingTest { tracked.update { true } }) - coVerify { storage.get(ErrorLog) } + coVerify { storage.read(ErrorLog) } } } diff --git a/superwall/src/main/AndroidManifest.xml b/superwall/src/main/AndroidManifest.xml index 972f3b97..a3e594af 100644 --- a/superwall/src/main/AndroidManifest.xml +++ b/superwall/src/main/AndroidManifest.xml @@ -1,4 +1,6 @@ + + \ No newline at end of file diff --git a/superwall/src/main/java/com/superwall/sdk/Superwall.kt b/superwall/src/main/java/com/superwall/sdk/Superwall.kt index 78c08a55..60b6e038 100644 --- a/superwall/src/main/java/com/superwall/sdk/Superwall.kt +++ b/superwall/src/main/java/com/superwall/sdk/Superwall.kt @@ -3,7 +3,6 @@ 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 @@ -26,9 +25,6 @@ import com.superwall.sdk.paywall.presentation.internal.dismiss import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult import com.superwall.sdk.paywall.vc.PaywallView 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.PaywallViewEventCallback import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent import com.superwall.sdk.paywall.vc.web_view.messaging.PaywallWebEvent.Closed @@ -64,20 +60,12 @@ class Superwall( private val completion: (() -> Unit)?, ) : PaywallViewEventCallback { private var _options: SuperwallOptions? = options - private val ioScope = CoroutineScope(Dispatchers.IO) + internal 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() var localeIdentifier: String? @@ -239,7 +227,9 @@ class Superwall( .filter { it } .take(1) - lateinit var instance: Superwall + private var _instance: Superwall? = null + val instance: Superwall + get() = _instance ?: throw IllegalStateException("Superwall has not been initialized or configured.") /** * Configures a shared instance of [Superwall] for use throughout your app. @@ -270,7 +260,10 @@ class Superwall( activityProvider: ActivityProvider? = null, completion: (() -> Unit)? = null, ) { - if (::instance.isInitialized) { + if (_hasInitialized.value && _instance == null) { + _hasInitialized.update { false } + } + if (_instance != null) { Logger.debug( logLevel = LogLevel.warn, scope = LogScope.superwallCore, @@ -282,7 +275,7 @@ class Superwall( val purchaseController = purchaseController ?: ExternalNativePurchaseController(context = applicationContext) - instance = + _instance = Superwall( context = applicationContext, apiKey = apiKey, @@ -340,8 +333,8 @@ class Superwall( internal val serialTaskManager = SerialTaskManager() internal fun setup() { - withErrorTracking { - synchronized(this) { + synchronized(this) { + try { _dependencyContainer = DependencyContainer( context = context, @@ -349,15 +342,18 @@ class Superwall( options = _options, activityProvider = activityProvider, ) + } catch (e: Exception) { + e.printStackTrace() + throw e } + } - val cachedSubsStatus = - dependencyContainer.storage.get(ActiveSubscriptionStatus) - ?: SubscriptionStatus.UNKNOWN - setSubscriptionStatus(cachedSubsStatus) + val cachedSubsStatus = + dependencyContainer.storage.read(ActiveSubscriptionStatus) + ?: SubscriptionStatus.UNKNOWN + setSubscriptionStatus(cachedSubsStatus) - addListeners() - } + addListeners() ioScope.launch { withErrorTrackingAsync { @@ -384,7 +380,7 @@ class Superwall( .drop(1) // Drops the first item .collect { newValue -> // Save and handle the new value - dependencyContainer.storage.save(newValue, ActiveSubscriptionStatus) + dependencyContainer.storage.write(ActiveSubscriptionStatus, newValue) dependencyContainer.delegateAdapter.subscriptionStatusDidChange(newValue) val event = InternalSuperwallEvent.SubscriptionStatusDidChange(newValue) track(event) diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/SessionEventsManager.kt b/superwall/src/main/java/com/superwall/sdk/analytics/SessionEventsManager.kt index c3056a3e..60a65ff3 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/SessionEventsManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/SessionEventsManager.kt @@ -3,7 +3,7 @@ package com.superwall.sdk.analytics import com.superwall.sdk.analytics.session.AppSession import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.network.Network -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.store.abstractions.transactions.StoreTransactionType import kotlinx.coroutines.* import kotlin.coroutines.CoroutineContext @@ -16,7 +16,7 @@ fun interface SessionEventsDelegate { class SessionEventsManager( // private val queue: SessionEnqueuable, - private val storage: Storage, + private val storage: LocalStorage, private val network: Network, private val configManager: ConfigManager, ) : CoroutineScope, diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt index cf1d3345..9f8a76f2 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/Tracking.kt @@ -71,7 +71,7 @@ suspend fun Superwall.track(event: Trackable): TrackingResult { dependencyContainer.configManager.config ?.featureFlags ?.disableVerboseEvents - val previousDisableVerboseEvents = dependencyContainer.storage.get(DisableVerboseEvents) + val previousDisableVerboseEvents = dependencyContainer.storage.read(DisableVerboseEvents) val verboseEvents = existingDisableVerboseEvents ?: previousDisableVerboseEvents diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt index 2cfcf0a7..4b26e45c 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/TrackingLogic.kt @@ -4,6 +4,9 @@ import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.analytics.internal.trackable.Trackable import com.superwall.sdk.analytics.internal.trackable.TrackableSuperwallEvent import com.superwall.sdk.analytics.superwall.SuperwallEvents +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import com.superwall.sdk.paywall.vc.PaywallView import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.withContext @@ -133,7 +136,7 @@ sealed class TrackingLogic { is List<*> -> null is Map<*, *> -> value.mapValues { clean(it.value) }.filterValues { it != null } is String -> value - is Int, is Float, is Double, is Long, is Boolean -> value.toString() + is Int, is Float, is Double, is Long, is Boolean -> value else -> { try { Json.encodeToString(value) @@ -172,7 +175,11 @@ sealed class TrackingLogic { } if (!triggers.contains(event.rawName)) { - println("!! canTriggerPaywall: triggers.contains(event.rawName) ${event.rawName} $triggers") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! canTriggerPaywall: triggers.contains(event.rawName) ${event.rawName} $triggers", + ) return ImplicitTriggerOutcome.DontTriggerPaywall } @@ -187,7 +194,11 @@ sealed class TrackingLogic { val referringEventName = paywallView?.info?.presentedByEventWithName if (referringEventName != null) { if (notAllowedReferringEventNames.contains(referringEventName)) { - println("!! canTriggerPaywall: notAllowedReferringEventNames.contains(referringEventName) $referringEventName") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! canTriggerPaywall: notAllowedReferringEventNames.contains(referringEventName) $referringEventName", + ) return ImplicitTriggerOutcome.DontTriggerPaywall } } @@ -206,7 +217,11 @@ sealed class TrackingLogic { } if (paywallView != null) { - println("!! canTriggerPaywall: paywallViewController != null") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! canTriggerPaywall: paywallViewController != null", + ) return ImplicitTriggerOutcome.DontTriggerPaywall } diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt index ee3fae76..26b35f6f 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/internal/trackable/TrackableSuperwallEvent.kt @@ -753,10 +753,29 @@ sealed class InternalSuperwallEvent( } } - object ConfigRefresh : InternalSuperwallEvent(SuperwallEvent.ConfigRefresh) { + data class ConfigRefresh( + val isCached: Boolean, + val buildId: String, + val retryCount: Int, + val fetchDuration: Long, + ) : InternalSuperwallEvent(SuperwallEvent.ConfigRefresh) { override val audienceFilterParams: Map = emptyMap() - override suspend fun getSuperwallParameters(): Map = emptyMap() + override suspend fun getSuperwallParameters(): Map = + mapOf( + "cache_status" to if (isCached) "CACHED" else "NOT_CACHED", + "config_build_id" to buildId, + "retry_count" to retryCount, + "fetch_duration" to fetchDuration, + ) + } + + data class ConfigFail( + val errorMessage: String, + ) : InternalSuperwallEvent(SuperwallEvent.ConfigFail) { + override val audienceFilterParams: Map = emptyMap() + + override suspend fun getSuperwallParameters(): Map = mapOf("error_message" to errorMessage) } object Reset : InternalSuperwallEvent(SuperwallEvent.Reset) { 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 c4520801..0a672691 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 @@ -9,7 +9,7 @@ import com.superwall.sdk.config.ConfigManager import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.dependencies.DeviceHelperFactory import com.superwall.sdk.dependencies.UserAttributesEventFactory -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.mapNotNull @@ -23,7 +23,7 @@ interface AppManagerDelegate { // class AppSessionManager( private val configManager: ConfigManager, - private val storage: Storage, + private val storage: LocalStorage, private val delegate: Factory, private val backgroundScope: CoroutineScope, ) : DefaultLifecycleObserver { @@ -74,7 +74,7 @@ class AppSessionManager( fun listenForAppSessionTimeout() { backgroundScope.launch { configManager.configState - .mapNotNull { it.getSuccess()?.getConfig() } + .mapNotNull { it.getConfig() } .collect { config -> appSessionTimeout = config.appSessionTimeout // Account for fact that dev may have delayed the init of Superwall diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt index 475828aa..53a4111c 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvent.kt @@ -378,6 +378,12 @@ sealed class SuperwallEvent { get() = "config_refresh" } + // When a configuration fails to load + object ConfigFail : SuperwallEvent() { + override val rawName: String + get() = "config_fail" + } + // When Superwall.instance.reset is called object Reset : SuperwallEvent() { override val rawName: String diff --git a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt index 28358b85..2fa77c45 100644 --- a/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt +++ b/superwall/src/main/java/com/superwall/sdk/analytics/superwall/SuperwallEvents.kt @@ -48,4 +48,5 @@ enum class SuperwallEvents( RestoreComplete("restore_complete"), CustomPlacement("custom_placement"), ConfigAttributes("config_attributes"), + ConfigFail("config_fail"), } diff --git a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt index f01df2c7..fddd134f 100644 --- a/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt +++ b/superwall/src/main/java/com/superwall/sdk/billing/GoogleBillingWrapper.kt @@ -15,7 +15,7 @@ import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.AppLifecycleObserver -import com.superwall.sdk.misc.Result +import com.superwall.sdk.misc.Either import com.superwall.sdk.store.abstractions.product.StoreProduct import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import kotlinx.coroutines.CoroutineScope @@ -42,7 +42,7 @@ class GoogleBillingWrapper( ) : PurchasesUpdatedListener, BillingClientStateListener { companion object { - private val productsCache = ConcurrentHashMap>() + private val productsCache = ConcurrentHashMap>() } @get:Synchronized @@ -148,8 +148,8 @@ class GoogleBillingWrapper( .mapNotNull { fullProductId -> productsCache[fullProductId]?.let { result -> when (result) { - is Result.Success -> result.value - is Result.Failure -> throw result.error + is Either.Success -> result.value + is Either.Failure -> throw result.error } } }.toSet() @@ -170,13 +170,13 @@ class GoogleBillingWrapper( // Update cache with fetched products and collect their identifiers val foundProductIds = storeProducts.map { product -> - productsCache[product.fullIdentifier] = Result.Success(product) + productsCache[product.fullIdentifier] = Either.Success(product) product.fullIdentifier } // Identify and handle missing products missingFullProductIds.filterNot { it in foundProductIds }.forEach { fullProductId -> - productsCache[fullProductId] = Result.Failure(Exception("Failed to query product details for $fullProductId")) + productsCache[fullProductId] = Either.Failure(Exception("Failed to query product details for $fullProductId")) } // Combine cached products (now including the newly fetched ones) with the fetched products @@ -187,7 +187,7 @@ class GoogleBillingWrapper( override fun onError(error: BillingError) { // Identify and handle missing products missingFullProductIds.forEach { fullProductId -> - productsCache[fullProductId] = Result.Failure(error) + productsCache[fullProductId] = Either.Failure(error) } continuation.resumeWithException(error) } @@ -469,10 +469,18 @@ class GoogleBillingWrapper( result: BillingResult, purchases: MutableList?, ) { - println("onPurchasesUpdated: $result") + Logger.debug( + LogLevel.debug, + LogScope.storeKitManager, + "onPurchasesUpdated: $result", + ) if (result.responseCode == BillingClient.BillingResponseCode.OK && purchases != null) { for (purchase in purchases) { - println("Purchase: $purchase") + Logger.debug( + LogLevel.debug, + LogScope.storeKitManager, + "Purchase: $purchase", + ) CoroutineScope(Dispatchers.IO).launch { purchaseResults.emit( InternalPurchaseResult.Purchased(purchase), @@ -484,12 +492,20 @@ class GoogleBillingWrapper( purchaseResults.emit(InternalPurchaseResult.Cancelled) } - println("User cancelled purchase") + Logger.debug( + LogLevel.debug, + LogScope.storeKitManager, + "User cancelled purchase", + ) } else { CoroutineScope(Dispatchers.IO).launch { purchaseResults.emit(InternalPurchaseResult.Failed(Exception(result.responseCode.toString()))) } - println("Purchase failed") + Logger.debug( + LogLevel.debug, + LogScope.storeKitManager, + "Purchase failed", + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt b/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt index 00918a04..9a2d05ee 100644 --- a/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt +++ b/superwall/src/main/java/com/superwall/sdk/composable/PaywallComposable.kt @@ -19,7 +19,9 @@ import androidx.compose.ui.viewinterop.AndroidView import com.superwall.sdk.Superwall import com.superwall.sdk.paywall.presentation.get_paywall.getPaywall import com.superwall.sdk.paywall.presentation.internal.request.PaywallOverrides +import com.superwall.sdk.paywall.vc.LoadingView import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.vc.ShimmerView import com.superwall.sdk.paywall.vc.delegate.PaywallViewCallback import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -28,6 +30,7 @@ import java.lang.ref.WeakReference @Composable fun PaywallComposable( + modifier: Modifier = Modifier.fillMaxSize(), event: String, params: Map? = null, paywallOverrides: PaywallOverrides? = null, @@ -57,6 +60,7 @@ fun PaywallComposable( try { val newView = Superwall.instance.getPaywall(event, params, paywallOverrides, delegate) newView.encapsulatingActivity = WeakReference(context as? Activity) + newView.setupWith(ShimmerView(context), LoadingView(context)) newView.beforeViewCreated() viewState.value = newView } catch (e: Throwable) { @@ -80,6 +84,7 @@ fun PaywallComposable( } } AndroidView( + modifier = modifier, factory = { context -> viewToRender }, diff --git a/superwall/src/main/java/com/superwall/sdk/config/Assignments.kt b/superwall/src/main/java/com/superwall/sdk/config/Assignments.kt new file mode 100644 index 00000000..d742c3e5 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/config/Assignments.kt @@ -0,0 +1,76 @@ +import com.superwall.sdk.config.ConfigLogic +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.then +import com.superwall.sdk.models.assignment.Assignment +import com.superwall.sdk.models.assignment.AssignmentPostback +import com.superwall.sdk.models.assignment.ConfirmableAssignment +import com.superwall.sdk.models.triggers.Experiment +import com.superwall.sdk.models.triggers.ExperimentID +import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.network.SuperwallAPI +import com.superwall.sdk.storage.LocalStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.launch + +class Assignments( + private val storage: LocalStorage, + private val network: SuperwallAPI, + private val ioScope: CoroutineScope, + unconfirmedAssignments: Map = emptyMap(), +) { + // A memory store of assignments that are yet to be confirmed. + private var _unconfirmedAssignments = unconfirmedAssignments.toMutableMap() + val unconfirmedAssignments: Map + get() = _unconfirmedAssignments + + fun choosePaywallVariants(triggers: Set) { + updateAssignments { confirmedAssignments -> + ConfigLogic.chooseAssignments( + fromTriggers = triggers, + confirmedAssignments = confirmedAssignments, + ) + } + } + + suspend fun getAssignments(triggers: Set): Either, NetworkError> = + network + .getAssignments() + .then { + updateAssignments { confirmedAssignments -> + ConfigLogic.transferAssignmentsFromServerToDisk( + assignments = it, + triggers = triggers, + confirmedAssignments = confirmedAssignments, + unconfirmedAssignments = unconfirmedAssignments, + ) + } + } + + fun confirmAssignment(assignment: ConfirmableAssignment) { + val postback: AssignmentPostback = AssignmentPostback.create(assignment) + ioScope.launch { network.confirmAssignments(postback) } + + updateAssignments { confirmedAssignments -> + ConfigLogic.move( + assignment, + unconfirmedAssignments, + confirmedAssignments, + ) + } + } + + private fun updateAssignments(operation: (Map) -> ConfigLogic.AssignmentOutcome) { + var confirmedAssignments = storage.getConfirmedAssignments() + + val updatedAssignments = operation(confirmedAssignments) + _unconfirmedAssignments = updatedAssignments.unconfirmed.toMutableMap() + confirmedAssignments = updatedAssignments.confirmed.toMutableMap() + + storage.saveConfirmedAssignments(confirmedAssignments) + } + + fun reset() { + _unconfirmedAssignments.clear() + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt index bfb722dd..c1bddda0 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigLogic.kt @@ -1,5 +1,8 @@ package com.superwall.sdk.config +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.config.Config @@ -86,8 +89,16 @@ object ConfigLogic { groupIds.add(groupId) groupedTriggerRules.add(trigger.rules) } - println("!!! groupedTriggerRules") - println(groupedTriggerRules) + Logger.debug( + LogLevel.debug, + LogScope.configManager, + "!!! groupedTriggerRules", + ) + Logger.debug( + LogLevel.debug, + LogScope.configManager, + groupedTriggerRules.toString(), + ) return groupedTriggerRules } 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 43afabac..1be53f7d 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/ConfigManager.kt @@ -1,9 +1,8 @@ package com.superwall.sdk.config +import Assignments import android.content.Context -import android.webkit.WebView -import com.superwall.sdk.Superwall -import com.superwall.sdk.analytics.internal.track +import awaitUntilNetworkExists import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.config.models.ConfigState import com.superwall.sdk.config.models.getConfig @@ -15,29 +14,28 @@ import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger -import com.superwall.sdk.misc.Result +import com.superwall.sdk.misc.Either import com.superwall.sdk.misc.awaitFirstValidConfig -import com.superwall.sdk.models.assignment.AssignmentPostback -import com.superwall.sdk.models.assignment.ConfirmableAssignment +import com.superwall.sdk.misc.fold +import com.superwall.sdk.misc.into +import com.superwall.sdk.misc.onError +import com.superwall.sdk.misc.then import com.superwall.sdk.models.config.Config -import com.superwall.sdk.models.paywall.CacheKey -import com.superwall.sdk.models.paywall.PaywallIdentifier import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID import com.superwall.sdk.models.triggers.Trigger -import com.superwall.sdk.network.Network +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.network.SuperwallAPI import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.paywall.manager.PaywallManager -import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator -import com.superwall.sdk.paywall.request.ResponseIdentifiers import com.superwall.sdk.storage.DisableVerboseEvents +import com.superwall.sdk.storage.LatestConfig +import com.superwall.sdk.storage.LatestGeoInfo import com.superwall.sdk.storage.Storage import com.superwall.sdk.store.StoreKitManager import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Deferred import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job import kotlinx.coroutines.async import kotlinx.coroutines.awaitAll import kotlinx.coroutines.flow.Flow @@ -46,19 +44,26 @@ import kotlinx.coroutines.flow.mapNotNull import kotlinx.coroutines.flow.take import kotlinx.coroutines.flow.update import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import java.util.concurrent.atomic.AtomicInteger // TODO: Re-enable those params open class ConfigManager( private val context: Context, private val storeKitManager: StoreKitManager, private val storage: Storage, - private val network: Network, + private val network: SuperwallAPI, private val deviceHelper: DeviceHelper, var options: SuperwallOptions, private val paywallManager: PaywallManager, private val factory: Factory, + private val assignments: Assignments, + private val paywallPreload: PaywallPreload, private val ioScope: CoroutineScope = CoroutineScope(Dispatchers.IO), + private val track: suspend (InternalSuperwallEvent) -> Unit, ) { + private val CACHE_LIMIT = 1000L + interface Factory : RequestFactory, DeviceInfoFactory, @@ -67,16 +72,24 @@ open class ConfigManager( JavascriptEvaluator.Factory // The configuration of the Superwall dashboard - val configState = MutableStateFlow>(Result.Success(ConfigState.Retrieving)) + val configState = MutableStateFlow(ConfigState.None) // Convenience variable to access config val config: Config? - get() = configState.value.getSuccess()?.getConfig() + get() = + configState.value + .also { + if (it is ConfigState.Failed) { + ioScope.launch { + fetchConfiguration() + } + } + }.getConfig() // A flow that emits just once only when `config` is non-`nil`. val hasConfig: Flow = configState - .mapNotNull { it.getSuccess()?.getConfig() } + .mapNotNull { it.getConfig() } .take(1) // A dictionary of triggers by their event name. @@ -88,120 +101,194 @@ open class ConfigManager( } // A memory store of assignments that are yet to be confirmed. - private var _unconfirmedAssignments = mutableMapOf() - private var currentPreloadingTask: Job? = null - var unconfirmedAssignments: Map - get() = _unconfirmedAssignments - set(value) { - _unconfirmedAssignments = value.toMutableMap() - } + val unconfirmedAssignments: Map + get() = assignments.unconfirmedAssignments suspend fun fetchConfiguration() { - try { - val configDeferred = - ioScope.async { - network.getConfig { - // Emit retrying state - configState.update { Result.Success(ConfigState.Retrying) } + if (configState.value != ConfigState.Retrieving) { + fetchConfig() + } + } + + private suspend fun fetchConfig() { + configState.update { ConfigState.Retrieving } + val oldConfig = storage.read(LatestConfig) + var isConfigFromCache = false + var isGeoFromCache = false + + // If config is cached, get config from the network but timeout after 300ms + // and default to the cached version. Then, refresh in the background. + val configRetryCount: AtomicInteger = AtomicInteger(0) + var configDuration = 0L + val configDeferred = + ioScope.async { + val start = System.currentTimeMillis() + ( + if (oldConfig?.featureFlags?.enableConfigRefresh == true) { + try { + // If config refresh is enabled, try loading with a timeout + withTimeout(CACHE_LIMIT) { + network + .getConfig { + // Emit retrying state + configState.update { ConfigState.Retrying } + configRetryCount.incrementAndGet() + context.awaitUntilNetworkExists() + }.into { + if (it is Either.Failure) { + isConfigFromCache = true + Either.Success(oldConfig) + } else { + it + } + } + } + } catch (e: Throwable) { + // If fetching config fails, default to the cached version + // Note: Only a timeout exception is possible here + oldConfig?.let { + isConfigFromCache = true + Either.Success(it) + } ?: Either.Failure(e) + } + } else { + // If config refresh is disabled or there is no cache + // just fetch with a normal retry + network + .getConfig { + configState.update { ConfigState.Retrying } + configRetryCount.incrementAndGet() + context.awaitUntilNetworkExists() + } } + ).also { + configDuration = System.currentTimeMillis() - start } + } - val geoDeferred = - ioScope.async { + val geoDeferred = + ioScope.async { + if (config?.featureFlags?.enableConfigRefresh == true) { + try { + // If we have a cached config and refresh was enabled, try loading with + // a timeout or load from cache + withTimeout(CACHE_LIMIT) { + deviceHelper + .getGeoInfo() + .then { + storage.write(LatestGeoInfo, it) + } + } + } catch (e: Throwable) { + // Loading timed out, we default to cached version + storage.read(LatestGeoInfo)?.let { + isGeoFromCache = true + Either.Success(it) + } ?: Either.Failure(e) + } + } else { deviceHelper.getGeoInfo() } - val attributesDeferred = ioScope.async { factory.makeSessionDeviceAttributes() } - - // Await results from both operations - 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( - logLevel = LogLevel.debug, - scope = LogScope.superwallCore, - message = "Fetched Configuration: $config", - ) - - processConfig(config) - // Preload all products - if (options.paywalls.shouldPreload) { - val productIds = config.paywalls.flatMap { it.productIds }.toSet() - try { - storeKitManager.products(productIds) - } catch (e: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.productsManager, - message = "Failed to preload products", - error = e, + val attributesDeferred = ioScope.async { factory.makeSessionDeviceAttributes() } + + // Await results from both operations + val (result, _, attributes) = + listOf( + configDeferred, + geoDeferred, + attributesDeferred, + ).awaitAll() + ioScope.launch { + @Suppress("UNCHECKED_CAST") + track(InternalSuperwallEvent.DeviceAttributes(attributes as HashMap)) + } + val configResult = result as Either + configResult + .then { + ioScope.launch { + track( + InternalSuperwallEvent.ConfigRefresh( + isCached = isConfigFromCache, + buildId = it.buildId, + fetchDuration = configDuration, + retryCount = configRetryCount.get(), + ), ) } - } - - configState.emit(Result.Success(ConfigState.Retrieved(config))) - - // TODO: Re-enable those params -// storeKitManager.loadPurchasedProducts() - ioScope.launch { preloadPaywalls() } - } catch (e: Throwable) { - configState.emit(Result.Failure(e)) - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.superwallCore, - message = "Failed to Fetch Configuration", - error = e, + }.then(::processConfig) + .then { + if (options.paywalls.shouldPreload) { + val productIds = it.paywalls.flatMap { it.productIds }.toSet() + try { + storeKitManager.products(productIds) + } catch (e: Throwable) { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.productsManager, + message = "Failed to preload products", + error = e, + ) + } + } + }.then { + configState.update { _ -> ConfigState.Retrieved(it) } + }.then { + if (isConfigFromCache) { + ioScope.launch { refreshConfiguration() } + } + if (isGeoFromCache) { + ioScope.launch { network.getGeoInfo() } + } + }.fold( + onSuccess = + { + ioScope.launch { preloadPaywalls() } + }, + onFailure = + { e -> + configState.update { ConfigState.Failed(e) } + if (!isConfigFromCache) { + refreshConfiguration() + } + track(InternalSuperwallEvent.ConfigFail(e.message ?: "Unknown error")) + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.superwallCore, + message = "Failed to Fetch Configuration", + error = e, + ) + }, ) - } } fun reset() { - val config = configState.value.getSuccess()?.getConfig() ?: return + val config = configState.value.getConfig() ?: return - unconfirmedAssignments = mutableMapOf() - choosePaywallVariants(config.triggers) + assignments.reset() + assignments.choosePaywallVariants(config.triggers) ioScope.launch { preloadPaywalls() } } - private fun choosePaywallVariants(triggers: Set) { - updateAssignments { confirmedAssignments -> - ConfigLogic.chooseAssignments( - fromTriggers = triggers, - confirmedAssignments = confirmedAssignments, - ) - } - } - suspend fun getAssignments() { val config = configState.awaitFirstValidConfig() ?: return config.triggers.takeUnless { it.isEmpty() }?.let { triggers -> try { - val assignments = network.getAssignments() - - updateAssignments { confirmedAssignments -> - ConfigLogic.transferAssignmentsFromServerToDisk( - assignments = assignments, - triggers = triggers, - confirmedAssignments = confirmedAssignments, - unconfirmedAssignments = unconfirmedAssignments, - ) - } - - if (options.paywalls.shouldPreload) { - ioScope.launch { preloadAllPaywalls() } - } + assignments + .getAssignments(triggers) + .then { + ioScope.launch { preloadPaywalls() } + }.onError { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.configManager, + message = "Error retrieving assignments.", + error = it, + ) + } } catch (e: Throwable) { Logger.debug( logLevel = LogLevel.error, @@ -214,46 +301,15 @@ open class ConfigManager( } private fun processConfig(config: Config) { - storage.save(config.featureFlags.disableVerboseEvents, DisableVerboseEvents) - triggersByEventName = ConfigLogic.getTriggersByEventName(config.triggers) - choosePaywallVariants(config.triggers) - } - - fun confirmAssignment(assignment: ConfirmableAssignment) { - val postback: AssignmentPostback = AssignmentPostback.create(assignment) - ioScope.launch(Dispatchers.IO) { network.confirmAssignments(postback) } - - updateAssignments { confirmedAssignments -> - ConfigLogic.move( - assignment, - unconfirmedAssignments, - confirmedAssignments, - ) + storage.write(DisableVerboseEvents, config.featureFlags.disableVerboseEvents) + if (config.featureFlags.enableConfigRefresh) { + storage.write(LatestConfig, config) } + triggersByEventName = ConfigLogic.getTriggersByEventName(config.triggers) + assignments.choosePaywallVariants(config.triggers) } - private fun updateAssignments(operation: (Map) -> ConfigLogic.AssignmentOutcome) { - var confirmedAssignments = storage.getConfirmedAssignments() - - val updatedAssignments = operation(confirmedAssignments) - unconfirmedAssignments = updatedAssignments.unconfirmed.toMutableMap() - confirmedAssignments = updatedAssignments.confirmed.toMutableMap() - - storage.saveConfirmedAssignments(confirmedAssignments) - } - - // Preloading Paywalls - private fun getTreatmentPaywallIds(triggers: Set): Set { - val config = configState.value.getSuccess()?.getConfig() ?: return emptySet() - val preloadableTriggers = ConfigLogic.filterTriggers(triggers, config.preloadingDisabled) - if (preloadableTriggers.isEmpty()) return emptySet() - val confirmedAssignments = storage.getConfirmedAssignments() - return ConfigLogic.getActiveTreatmentPaywallIds( - preloadableTriggers, - confirmedAssignments, - unconfirmedAssignments, - ) - } +// Preloading Paywalls // Preloads paywalls. private suspend fun preloadPaywalls() { @@ -262,172 +318,72 @@ open class ConfigManager( } // Preloads paywalls referenced by triggers. - suspend fun preloadAllPaywalls() { - if (currentPreloadingTask != null) { - return - } - - currentPreloadingTask = - ioScope.launch { - val config = configState.awaitFirstValidConfig() ?: return@launch - val js = factory.provideJavascriptEvaluator(context) - val expressionEvaluator = - ExpressionEvaluator( - evaluator = js, - storage = storage, - factory = factory, - ) - val triggers = - ConfigLogic.filterTriggers( - config.triggers, - preloadingDisabled = config.preloadingDisabled, - ) - val confirmedAssignments = storage.getConfirmedAssignments() - val paywallIds = - ConfigLogic.getAllActiveTreatmentPaywallIds( - triggers = triggers, - confirmedAssignments = confirmedAssignments, - unconfirmedAssignments = unconfirmedAssignments, - expressionEvaluator = expressionEvaluator, - ) - preloadPaywalls(paywallIdentifiers = paywallIds) - - currentPreloadingTask = null - } - } + suspend fun preloadAllPaywalls() = + paywallPreload.preloadAllPaywalls( + configState.awaitFirstValidConfig(), + context, + ) // Preloads paywalls referenced by the provided triggers. - suspend fun preloadPaywallsByNames(eventNames: Set) { - val config = configState.awaitFirstValidConfig() ?: return - val triggersToPreload = config.triggers.filter { eventNames.contains(it.eventName) } - val triggerPaywallIdentifiers = getTreatmentPaywallIds(triggersToPreload.toSet()) - preloadPaywalls(triggerPaywallIdentifiers) - } - - // Preloads paywalls referenced by triggers. - private suspend fun preloadPaywalls(paywallIdentifiers: Set) { - val webviewExists = - WebView.getCurrentWebViewPackage() != null - - if (webviewExists) { - ioScope.launch { - // List to hold all the Deferred objects - val tasks = mutableListOf>() - - for (identifier in paywallIdentifiers) { - val task = - async { - // Your asynchronous operation - val request = - factory.makePaywallRequest( - eventData = null, - responseIdentifiers = - ResponseIdentifiers( - paywallId = identifier, - experiment = null, - ), - overrides = null, - isDebuggerLaunched = false, - presentationSourceType = null, - retryCount = 6, - ) - try { - paywallManager.getPaywallView( - request = request, - isForPresentation = true, - isPreloading = true, - delegate = null, - ) - } catch (e: Exception) { - // Handle exception - } - } - tasks.add(task) - } - // Await all tasks - tasks.awaitAll() - } - } - } - - internal suspend fun refreshConfiguration() { - // Make sure config already exists - val oldConfig = config ?: return + suspend fun preloadPaywallsByNames(eventNames: Set) = + paywallPreload.preloadPaywallsByNames( + configState.awaitFirstValidConfig(), + eventNames, + ) - // Ensure the config refresh feature flag is enabled - if (!oldConfig.featureFlags.enableConfigRefresh) { - return + private suspend fun Either.handleConfigUpdate( + fetchDuration: Long, + retryCount: Int, + ) = then { + paywallManager.resetPaywallRequestCache() + val oldConfig = config + if (oldConfig != null) { + paywallPreload.removeUnusedPaywallVCsFromCache(oldConfig, it) } - - try { - val newConfig = - network.getConfig {} - paywallManager.resetPaywallRequestCache() - removeUnusedPaywallVCsFromCache(oldConfig, newConfig) - processConfig(newConfig) - configState.update { Result.Success(ConfigState.Retrieved(newConfig)) } - Superwall.instance.track(InternalSuperwallEvent.ConfigRefresh) + }.then { config -> + processConfig(config) + configState.update { ConfigState.Retrieved(config) } + track( + InternalSuperwallEvent.ConfigRefresh( + isCached = false, + buildId = config.buildId, + fetchDuration = fetchDuration, + retryCount = retryCount, + ), + ) + }.fold( + onSuccess = { newConfig -> ioScope.launch { preloadPaywalls() } - } catch (e: Exception) { + }, + onFailure = { Logger.debug( logLevel = LogLevel.warn, scope = LogScope.superwallCore, message = "Failed to refresh configuration.", info = null, - error = e, + error = it, ) - } - } + }, + ) - private suspend fun removeUnusedPaywallVCsFromCache( - oldConfig: Config, - newConfig: Config, - ) { - val oldPaywalls = oldConfig.paywalls - val newPaywalls = newConfig.paywalls - - val presentedPaywallId = paywallManager.currentView?.paywall?.identifier - val oldPaywallCacheIds: Map = - oldPaywalls - .map { it.identifier to it.cacheKey } - .toMap() - val newPaywallCacheIds: Map = newPaywalls.map { it.identifier to it.cacheKey }.toMap() - - val removedIds: Set = - (oldPaywallCacheIds.keys - newPaywallCacheIds.keys).toSet() - - val changedIds = - removedIds + - newPaywalls - .filter { - val oldCacheKey = oldPaywallCacheIds[it.identifier] - val keyChanged = oldCacheKey != newPaywallCacheIds[it.identifier] - oldCacheKey != null && keyChanged - }.map { it.identifier } - presentedPaywallId + internal suspend fun refreshConfiguration() { + // Make sure config already exists + val oldConfig = config ?: return - changedIds.toSet().filterNotNull().forEach { - paywallManager.removePaywallView(it) + // Ensure the config refresh feature flag is enabled + if (!oldConfig.featureFlags.enableConfigRefresh) { + return } - } - -// Assuming other necessary classes and objects are defined elsewhere - // This sends product data back to the dashboard. - private suspend fun sendProductsBack(config: Config) { -// if (!config.featureFlags.enablePostback) return@coroutineScope -// val milliseconds = 1000L -// val nanoseconds = milliseconds * 1_000_000L -// val duration = config.postback.postbackDelay * nanoseconds -// -// delay(duration) -// try { -// val productIds = config.postback.productsToPostBack.map { it.identifier } -// val products = storeKitManager.getProducts(productIds) -// val postbackProducts = products.productsById.values.map(::PostbackProduct) -// val postback = Postback(postbackProducts) -// network.sendPostback(postback) -// } catch (e: Exception) { -// Logger.debug(LogLevel.ERROR, DebugViewController, "No Paywall Response", null, e) -// } + var retryCount: AtomicInteger = AtomicInteger(0) + val startTime = System.currentTimeMillis() + network + .getConfig { + retryCount.incrementAndGet() + context.awaitUntilNetworkExists() + }.handleConfigUpdate( + retryCount = retryCount.get(), + fetchDuration = System.currentTimeMillis() - startTime, + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt new file mode 100644 index 00000000..0df58526 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/config/PaywallPreload.kt @@ -0,0 +1,180 @@ +package com.superwall.sdk.config + +import Assignments +import android.content.Context +import android.webkit.WebView +import com.superwall.sdk.dependencies.RequestFactory +import com.superwall.sdk.dependencies.RuleAttributesFactory +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.paywall.CacheKey +import com.superwall.sdk.models.paywall.PaywallIdentifier +import com.superwall.sdk.models.triggers.Trigger +import com.superwall.sdk.paywall.manager.PaywallManager +import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluator +import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator +import com.superwall.sdk.paywall.request.ResponseIdentifiers +import com.superwall.sdk.storage.LocalStorage +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Deferred +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.Job +import kotlinx.coroutines.async +import kotlinx.coroutines.awaitAll +import kotlinx.coroutines.launch + +class PaywallPreload( + val factory: Factory, + val scope: CoroutineScope = CoroutineScope(Dispatchers.IO), + val storage: LocalStorage, + val assignments: Assignments, + val paywallManager: PaywallManager, +) { + interface Factory : + RequestFactory, + RuleAttributesFactory, + JavascriptEvaluator.Factory + + private var currentPreloadingTask: Job? = null + + suspend fun preloadAllPaywalls( + config: Config, + context: Context, + ) { + if (currentPreloadingTask != null) { + return + } + + currentPreloadingTask = + scope.launch { + val js = factory.provideJavascriptEvaluator(context) + val expressionEvaluator = + ExpressionEvaluator( + evaluator = js, + storage = storage, + factory = factory, + ) + val triggers = + ConfigLogic.filterTriggers( + config.triggers, + preloadingDisabled = config.preloadingDisabled, + ) + val confirmedAssignments = storage.getConfirmedAssignments() + val paywallIds = + ConfigLogic.getAllActiveTreatmentPaywallIds( + triggers = triggers, + confirmedAssignments = confirmedAssignments, + unconfirmedAssignments = assignments.unconfirmedAssignments, + expressionEvaluator = expressionEvaluator, + ) + preloadPaywalls(paywallIdentifiers = paywallIds) + + currentPreloadingTask = null + } + } + + // Preloads paywalls referenced by the provided triggers. + suspend fun preloadPaywallsByNames( + config: Config, + eventNames: Set, + ) { + val triggersToPreload = config.triggers.filter { eventNames.contains(it.eventName) } + val triggerPaywallIdentifiers = + getTreatmentPaywallIds( + config, + triggersToPreload.toSet(), + ) + preloadPaywalls(triggerPaywallIdentifiers) + } + + // Preloads paywalls referenced by triggers. + private suspend fun preloadPaywalls(paywallIdentifiers: Set) { + val webviewExists = + WebView.getCurrentWebViewPackage() != null + + if (webviewExists) { + scope.launch { + // List to hold all the Deferred objects + val tasks = mutableListOf>() + + for (identifier in paywallIdentifiers) { + val task = + async { + // Your asynchronous operation + val request = + factory.makePaywallRequest( + eventData = null, + responseIdentifiers = + ResponseIdentifiers( + paywallId = identifier, + experiment = null, + ), + overrides = null, + isDebuggerLaunched = false, + presentationSourceType = null, + retryCount = 6, + ) + try { + paywallManager.getPaywallView( + request = request, + isForPresentation = true, + isPreloading = true, + delegate = null, + ) + } catch (e: Exception) { + // Handle exception + } + } + tasks.add(task) + } + // Await all tasks + tasks.awaitAll() + } + } + } + + private fun getTreatmentPaywallIds( + config: Config, + triggers: Set, + ): Set { + val preloadableTriggers = ConfigLogic.filterTriggers(triggers, config.preloadingDisabled) + if (preloadableTriggers.isEmpty()) return emptySet() + val confirmedAssignments = storage.getConfirmedAssignments() + return ConfigLogic.getActiveTreatmentPaywallIds( + preloadableTriggers, + confirmedAssignments, + assignments.unconfirmedAssignments, + ) + } + + internal suspend fun removeUnusedPaywallVCsFromCache( + oldConfig: Config, + newConfig: Config, + ) { + val oldPaywalls = oldConfig.paywalls + val newPaywalls = newConfig.paywalls + + val presentedPaywallId = paywallManager.currentView?.paywall?.identifier + val oldPaywallCacheIds: Map = + oldPaywalls + .map { it.identifier to it.cacheKey } + .toMap() + val newPaywallCacheIds: Map = + newPaywalls.map { it.identifier to it.cacheKey }.toMap() + + val removedIds: Set = + (oldPaywallCacheIds.keys - newPaywallCacheIds.keys).toSet() + + val changedIds = + removedIds + + newPaywalls + .filter { + val oldCacheKey = oldPaywallCacheIds[it.identifier] + val keyChanged = oldCacheKey != newPaywallCacheIds[it.identifier] + oldCacheKey != null && keyChanged + }.map { it.identifier } - presentedPaywallId + + changedIds.toSet().filterNotNull().forEach { + paywallManager.removePaywallView(it) + } + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt index f1d79607..b6b981a4 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/models/ConfigState.kt @@ -3,6 +3,8 @@ package com.superwall.sdk.config.models import com.superwall.sdk.models.config.Config sealed class ConfigState { + object None : ConfigState() + object Retrieving : ConfigState() object Retrying : ConfigState() @@ -11,7 +13,9 @@ sealed class ConfigState { val config: Config, ) : ConfigState() - object Failed : ConfigState() + data class Failed( + val throwable: Throwable, + ) : ConfigState() } fun ConfigState.getConfig(): Config? = diff --git a/superwall/src/main/java/com/superwall/sdk/config/models/Survey.kt b/superwall/src/main/java/com/superwall/sdk/config/models/Survey.kt index b11a0e7e..370a5909 100644 --- a/superwall/src/main/java/com/superwall/sdk/config/models/Survey.kt +++ b/superwall/src/main/java/com/superwall/sdk/config/models/Survey.kt @@ -1,6 +1,6 @@ package com.superwall.sdk.config.models -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.SurveyAssignmentKey import kotlinx.serialization.Serializable @@ -31,8 +31,8 @@ data class Survey( return randomNumber >= presentationProbability } - fun hasSeenSurvey(storage: Storage): Boolean { - val existingAssignmentKey = storage.get(SurveyAssignmentKey) ?: return false + fun hasSeenSurvey(storage: LocalStorage): Boolean { + val existingAssignmentKey = storage.read(SurveyAssignmentKey) ?: return false return existingAssignmentKey == assignmentKey } diff --git a/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt b/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt index a73ad2fa..0dea405f 100644 --- a/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/debug/DebugManager.kt @@ -6,7 +6,7 @@ import androidx.annotation.MainThread import com.superwall.sdk.Superwall import com.superwall.sdk.dependencies.ViewFactory import com.superwall.sdk.paywall.presentation.dismiss -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.delay @@ -14,7 +14,7 @@ import kotlinx.coroutines.launch class DebugManager( private val context: Context, - private val storage: Storage, + private val storage: LocalStorage, private val factory: ViewFactory, ) { var view: DebugView? = null diff --git a/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt b/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt index ae7c4571..ee0fcb5b 100644 --- a/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt +++ b/superwall/src/main/java/com/superwall/sdk/debug/DebugView.kt @@ -37,6 +37,8 @@ import com.superwall.sdk.dependencies.ViewFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.onError +import com.superwall.sdk.misc.then import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.network.Network import com.superwall.sdk.paywall.manager.PaywallManager @@ -118,8 +120,10 @@ class DebugView( val imageView = ImageView(context).apply { // Apply a color filter with full opacity to test visibility - val debuggerImage = ContextCompat.getDrawable(context, R.drawable.exit)?.mutate() - debuggerImage?.colorFilter = PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP) + val debuggerImage = + ContextCompat.getDrawable(context, R.drawable.exit)?.mutate() + debuggerImage?.colorFilter = + PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP) setImageDrawable(debuggerImage) scaleType = ImageView.ScaleType.FIT_CENTER @@ -152,8 +156,10 @@ class DebugView( val imageView = ImageView(context).apply { // Apply a color filter with full opacity to test visibility - val debuggerImage = ContextCompat.getDrawable(context, R.drawable.debugger)?.mutate() - debuggerImage?.colorFilter = PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP) + val debuggerImage = + ContextCompat.getDrawable(context, R.drawable.debugger)?.mutate() + debuggerImage?.colorFilter = + PorterDuffColorFilter(Color.WHITE, PorterDuff.Mode.SRC_ATOP) setImageDrawable(debuggerImage) scaleType = ImageView.ScaleType.FIT_CENTER @@ -210,7 +216,8 @@ class DebugView( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT, ).apply { - rightMargin = 8 // You can adjust the margin to control the space between the image and the text + rightMargin = + 8 // You can adjust the margin to control the space between the image and the text } } addView(playImageView) @@ -279,7 +286,8 @@ class DebugView( LinearLayout.LayoutParams.WRAP_CONTENT, LinearLayout.LayoutParams.WRAP_CONTENT, ).apply { - leftMargin = 8 // You can adjust the margin to control the space between the text and the image + leftMargin = + 8 // You can adjust the margin to control the space between the text and the image } } addView(arrowImageView) @@ -294,7 +302,8 @@ class DebugView( ConstraintLayout(context).apply { id = View.generateViewId() // shouldAnimateLightly = true - isFocusable = true // Depending on the view's properties, you might need to set focusability + isFocusable = + true // Depending on the view's properties, you might need to set focusability layoutParams = LayoutParams( LayoutParams.MATCH_CONSTRAINT, @@ -319,7 +328,11 @@ class DebugView( } fun addSubviews() { - val layoutParams = FrameLayout.LayoutParams(FrameLayout.LayoutParams.MATCH_PARENT, FrameLayout.LayoutParams.WRAP_CONTENT) + val layoutParams = + FrameLayout.LayoutParams( + FrameLayout.LayoutParams.MATCH_PARENT, + FrameLayout.LayoutParams.WRAP_CONTENT, + ) // Adding views to the layout addView(activityIndicator, layoutParams) @@ -339,46 +352,164 @@ class DebugView( constraintSet.clone(this) // Applying constraints to previewContainerView - constraintSet.connect(previewContainerView.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START) - constraintSet.connect(previewContainerView.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END) - constraintSet.connect(previewContainerView.id, ConstraintSet.TOP, logoImageView.id, ConstraintSet.BOTTOM, dpToPx(5)) - constraintSet.connect(previewContainerView.id, ConstraintSet.BOTTOM, bottomButton.id, ConstraintSet.TOP, dpToPx(5)) + constraintSet.connect( + previewContainerView.id, + ConstraintSet.START, + ConstraintSet.PARENT_ID, + ConstraintSet.START, + ) + constraintSet.connect( + previewContainerView.id, + ConstraintSet.END, + ConstraintSet.PARENT_ID, + ConstraintSet.END, + ) + constraintSet.connect( + previewContainerView.id, + ConstraintSet.TOP, + logoImageView.id, + ConstraintSet.BOTTOM, + dpToPx(5), + ) + constraintSet.connect( + previewContainerView.id, + ConstraintSet.BOTTOM, + bottomButton.id, + ConstraintSet.TOP, + dpToPx(5), + ) constraintSet.constrainHeight(previewContainerView.id, 0) // Constraints for logoImageView (Centered horizontally at the top) constraintSet.constrainWidth(logoImageView.id, ConstraintSet.MATCH_CONSTRAINT) constraintSet.constrainHeight(logoImageView.id, dpToPx(20)) - constraintSet.connect(logoImageView.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, dpToPx(30)) - constraintSet.connect(logoImageView.id, ConstraintSet.START, consoleButton.id, ConstraintSet.END) - constraintSet.connect(logoImageView.id, ConstraintSet.END, exitButton.id, ConstraintSet.START) + constraintSet.connect( + logoImageView.id, + ConstraintSet.TOP, + ConstraintSet.PARENT_ID, + ConstraintSet.TOP, + dpToPx(30), + ) + constraintSet.connect( + logoImageView.id, + ConstraintSet.START, + consoleButton.id, + ConstraintSet.END, + ) + constraintSet.connect( + logoImageView.id, + ConstraintSet.END, + exitButton.id, + ConstraintSet.START, + ) // Constraints for consoleButton (Top Left) - constraintSet.connect(consoleButton.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START, dpToPx(25)) - constraintSet.connect(consoleButton.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, dpToPx(30)) + constraintSet.connect( + consoleButton.id, + ConstraintSet.START, + ConstraintSet.PARENT_ID, + ConstraintSet.START, + dpToPx(25), + ) + constraintSet.connect( + consoleButton.id, + ConstraintSet.TOP, + ConstraintSet.PARENT_ID, + ConstraintSet.TOP, + dpToPx(30), + ) constraintSet.constrainWidth(consoleButton.id, dpToPx(44)) // Constraints for exitButton (Top Right) - constraintSet.connect(exitButton.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, dpToPx(25)) - constraintSet.connect(exitButton.id, ConstraintSet.TOP, ConstraintSet.PARENT_ID, ConstraintSet.TOP, dpToPx(30)) + constraintSet.connect( + exitButton.id, + ConstraintSet.END, + ConstraintSet.PARENT_ID, + ConstraintSet.END, + dpToPx(25), + ) + constraintSet.connect( + exitButton.id, + ConstraintSet.TOP, + ConstraintSet.PARENT_ID, + ConstraintSet.TOP, + dpToPx(30), + ) constraintSet.constrainWidth(exitButton.id, dpToPx(44)) // Constraints for activityIndicator - constraintSet.connect(activityIndicator.id, ConstraintSet.START, previewContainerView.id, ConstraintSet.START) - constraintSet.connect(activityIndicator.id, ConstraintSet.END, previewContainerView.id, ConstraintSet.END) - constraintSet.connect(activityIndicator.id, ConstraintSet.TOP, previewContainerView.id, ConstraintSet.TOP) - constraintSet.connect(activityIndicator.id, ConstraintSet.BOTTOM, previewContainerView.id, ConstraintSet.BOTTOM) + constraintSet.connect( + activityIndicator.id, + ConstraintSet.START, + previewContainerView.id, + ConstraintSet.START, + ) + constraintSet.connect( + activityIndicator.id, + ConstraintSet.END, + previewContainerView.id, + ConstraintSet.END, + ) + constraintSet.connect( + activityIndicator.id, + ConstraintSet.TOP, + previewContainerView.id, + ConstraintSet.TOP, + ) + constraintSet.connect( + activityIndicator.id, + ConstraintSet.BOTTOM, + previewContainerView.id, + ConstraintSet.BOTTOM, + ) // Constraints for bottomButton - constraintSet.connect(bottomButton.id, ConstraintSet.START, ConstraintSet.PARENT_ID, ConstraintSet.START, dpToPx(25)) - constraintSet.connect(bottomButton.id, ConstraintSet.END, ConstraintSet.PARENT_ID, ConstraintSet.END, dpToPx(25)) + constraintSet.connect( + bottomButton.id, + ConstraintSet.START, + ConstraintSet.PARENT_ID, + ConstraintSet.START, + dpToPx(25), + ) + constraintSet.connect( + bottomButton.id, + ConstraintSet.END, + ConstraintSet.PARENT_ID, + ConstraintSet.END, + dpToPx(25), + ) constraintSet.constrainHeight(bottomButton.id, dpToPx(60)) - constraintSet.connect(bottomButton.id, ConstraintSet.BOTTOM, ConstraintSet.PARENT_ID, ConstraintSet.BOTTOM, dpToPx(30)) + constraintSet.connect( + bottomButton.id, + ConstraintSet.BOTTOM, + ConstraintSet.PARENT_ID, + ConstraintSet.BOTTOM, + dpToPx(30), + ) // Constraints for previewPickerButton - constraintSet.connect(previewPickerButton.id, ConstraintSet.START, previewContainerView.id, ConstraintSet.START, dpToPx(25)) - constraintSet.connect(previewPickerButton.id, ConstraintSet.END, previewContainerView.id, ConstraintSet.END, dpToPx(25)) + constraintSet.connect( + previewPickerButton.id, + ConstraintSet.START, + previewContainerView.id, + ConstraintSet.START, + dpToPx(25), + ) + constraintSet.connect( + previewPickerButton.id, + ConstraintSet.END, + previewContainerView.id, + ConstraintSet.END, + dpToPx(25), + ) constraintSet.constrainHeight(previewPickerButton.id, dpToPx(30)) - constraintSet.connect(previewPickerButton.id, ConstraintSet.BOTTOM, bottomButton.id, ConstraintSet.TOP, dpToPx(10)) + constraintSet.connect( + previewPickerButton.id, + ConstraintSet.BOTTOM, + bottomButton.id, + ConstraintSet.TOP, + dpToPx(10), + ) // Apply all the constraints constraintSet.applyTo(this) @@ -396,17 +527,19 @@ class DebugView( } if (paywalls.isEmpty()) { - try { - paywalls = network.getPaywalls() - finishLoadingPreview() - } catch (error: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.debugView, - message = "Failed to Fetch Paywalls", - error = error, - ) - } + network + .getPaywalls() + .onError { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.debugView, + message = "Failed to Fetch Paywalls", + error = it, + ) + }.then { + paywalls = it + finishLoadingPreview() + } } else { finishLoadingPreview() } @@ -480,10 +613,20 @@ class DebugView( val constraints = ConstraintSet().apply { clone(previewContainerView) - connect(paywallVc.id, ConstraintSet.START, previewContainerView.id, ConstraintSet.START) + connect( + paywallVc.id, + ConstraintSet.START, + previewContainerView.id, + ConstraintSet.START, + ) connect(paywallVc.id, ConstraintSet.END, previewContainerView.id, ConstraintSet.END) connect(paywallVc.id, ConstraintSet.TOP, previewContainerView.id, ConstraintSet.TOP) - connect(paywallVc.id, ConstraintSet.BOTTOM, previewContainerView.id, ConstraintSet.BOTTOM) + connect( + paywallVc.id, + ConstraintSet.BOTTOM, + previewContainerView.id, + ConstraintSet.BOTTOM, + ) } constraints.applyTo(previewContainerView) @@ -680,7 +823,8 @@ class DebugView( dialog.show() dialog.window?.findViewById(androidx.appcompat.R.id.contentPanel)?.post { - val contentPanel = dialog.window?.findViewById(androidx.appcompat.R.id.contentPanel) + val contentPanel = + dialog.window?.findViewById(androidx.appcompat.R.id.contentPanel) // remove dialog's default background to make it look more like an action sheet contentPanel?.background = null } @@ -715,9 +859,11 @@ class DebugView( when (state) { is PaywallState.Presented -> { // bottomButton.showLoading = false - val playButton = ResourcesCompat.getDrawable(resources, R.drawable.play_button, null) + val playButton = + ResourcesCompat.getDrawable(resources, R.drawable.play_button, null) // bottomButton.setImageDrawable(playButton) } + is PaywallState.Skipped -> { val errorMessage = when (state.paywallSkippedReason) { @@ -731,12 +877,15 @@ class DebugView( message = errorMessage, ) // bottomButton.showLoading = false - val playButton = ResourcesCompat.getDrawable(resources, R.drawable.play_button, null) + val playButton = + ResourcesCompat.getDrawable(resources, R.drawable.play_button, null) // bottomButton.setImageDrawable(playButton) } + is PaywallState.Dismissed -> { // Handle dismissed state if needed } + is PaywallState.PresentationError -> { Logger.debug( logLevel = LogLevel.error, @@ -748,7 +897,8 @@ class DebugView( message = state.error.localizedMessage, ) // bottomButton.showLoading = false - val playButton = ResourcesCompat.getDrawable(resources, R.drawable.play_button, null) + val playButton = + ResourcesCompat.getDrawable(resources, R.drawable.play_button, null) // bottomButton.setImageDrawable(playButton) } } @@ -770,7 +920,9 @@ internal class DebugViewActivity : AppCompatActivity() { view: View, ) { val key = UUID.randomUUID().toString() - Superwall.instance.viewStore().storeView(key, view) + Superwall.instance.dependencyContainer + .makeViewStore() + .storeView(key, view) val intent = Intent(context, DebugViewActivity::class.java).apply { @@ -799,7 +951,9 @@ internal class DebugViewActivity : AppCompatActivity() { return } val view = - Superwall.instance.viewStore().retrieveView(key) ?: run { + Superwall.instance.dependencyContainer + .makeViewStore() + .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/delegate/SuperwallDelegate.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt index 4e533d68..d62ad8bb 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegate.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.delegate +import android.net.Uri import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URL @@ -21,7 +22,7 @@ interface SuperwallDelegate { fun paywallWillOpenURL(url: URL) {} - fun paywallWillOpenDeepLink(url: URL) {} + fun paywallWillOpenDeepLink(url: Uri) {} fun handleLog( level: String, diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt index ca775a36..e3ccbad5 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateAdapter.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.delegate +import android.net.Uri import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URL @@ -38,7 +39,7 @@ class SuperwallDelegateAdapter { ?: javaDelegate?.paywallWillOpenURL(url) } - fun paywallWillOpenDeepLink(url: URL) { + fun paywallWillOpenDeepLink(url: Uri) { kotlinDelegate?.paywallWillOpenDeepLink(url) ?: javaDelegate?.paywallWillOpenDeepLink(url) } diff --git a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt index 63678bd4..53e9541b 100644 --- a/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt +++ b/superwall/src/main/java/com/superwall/sdk/delegate/SuperwallDelegateJava.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.delegate +import android.net.Uri import com.superwall.sdk.analytics.superwall.SuperwallEventInfo import com.superwall.sdk.paywall.presentation.PaywallInfo import java.net.URL @@ -17,7 +18,7 @@ interface SuperwallDelegateJava { fun paywallWillOpenURL(url: URL) {} - fun paywallWillOpenDeepLink(url: URL) {} + fun paywallWillOpenDeepLink(url: Uri) {} fun handleSuperwallEvent(eventInfo: SuperwallEventInfo) {} 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 730ea34e..5144a327 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/DependencyContainer.kt @@ -1,13 +1,19 @@ package com.superwall.sdk.dependencies +import Assignments +import BaseHostService import ComputedPropertyRequest +import GeoService import android.app.Activity import android.app.Application import android.content.Context +import android.webkit.WebSettings import androidx.lifecycle.ProcessLifecycleOwner +import androidx.lifecycle.ViewModelProvider import com.android.billingclient.api.Purchase 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.session.AppManagerDelegate import com.superwall.sdk.analytics.session.AppSession @@ -15,6 +21,7 @@ import com.superwall.sdk.analytics.session.AppSessionManager import com.superwall.sdk.billing.GoogleBillingWrapper import com.superwall.sdk.config.ConfigLogic import com.superwall.sdk.config.ConfigManager +import com.superwall.sdk.config.PaywallPreload import com.superwall.sdk.config.options.SuperwallOptions import com.superwall.sdk.debug.DebugManager import com.superwall.sdk.debug.DebugView @@ -31,10 +38,13 @@ import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.models.product.ProductVariable import com.superwall.sdk.network.Api +import com.superwall.sdk.network.CollectorService import com.superwall.sdk.network.JsonFactory import com.superwall.sdk.network.Network +import com.superwall.sdk.network.RequestExecutor import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.network.device.DeviceInfo +import com.superwall.sdk.network.session.CustomHttpUrlConnection import com.superwall.sdk.paywall.manager.PaywallManager import com.superwall.sdk.paywall.manager.PaywallViewCache import com.superwall.sdk.paywall.presentation.internal.PresentationRequest @@ -48,13 +58,16 @@ import com.superwall.sdk.paywall.request.PaywallRequestManager import com.superwall.sdk.paywall.request.PaywallRequestManagerDepFactory import com.superwall.sdk.paywall.request.ResponseIdentifiers import com.superwall.sdk.paywall.vc.PaywallView +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.PaywallViewDelegateAdapter import com.superwall.sdk.paywall.vc.web_view.SWWebView 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.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.store.InternalPurchaseController import com.superwall.sdk.store.StoreKitManager import com.superwall.sdk.store.abstractions.transactions.GoogleBillingPurchaseTransaction @@ -85,7 +98,7 @@ class DependencyContainer( PaywallRequestManagerDepFactory, VariablesFactory, StoreTransactionFactory, - Storage.Factory, + LocalStorage.Factory, InternalSuperwallEvent.PresentationRequest.Factory, ViewFactory, PaywallManager.Factory, @@ -99,13 +112,15 @@ class DependencyContainer( DebugView.Factory, JavascriptEvaluator.Factory, JsonFactory, - ConfigAttributesFactory { + ConfigAttributesFactory, + PaywallPreload.Factory, + ViewStoreFactory { var network: Network - override lateinit var api: Api - override lateinit var deviceHelper: DeviceHelper - override lateinit var storage: Storage - override lateinit var configManager: ConfigManager - override lateinit var identityManager: IdentityManager + override var api: Api + override var deviceHelper: DeviceHelper + override lateinit var storage: LocalStorage + override var configManager: ConfigManager + override var identityManager: IdentityManager override var appLifecycleObserver: AppLifecycleObserver = AppLifecycleObserver() var appSessionManager: AppSessionManager var sessionEventsManager: SessionEventsManager @@ -128,6 +143,9 @@ class DependencyContainer( ) } + internal val assignments: Assignments + private val paywallPreload: PaywallPreload + internal val errorTracker: ErrorTracker init { @@ -165,8 +183,45 @@ class DependencyContainer( storeKitManager = StoreKitManager(context, purchaseController, googleBillingWrapper) delegateAdapter = SuperwallDelegateAdapter() - storage = Storage(context = context, factory = this) - network = Network(factory = this) + storage = LocalStorage(context = context, factory = this, json = json()) + val httpConnection = + CustomHttpUrlConnection( + json = json(), + requestExecutor = + RequestExecutor { debugging, requestId -> + makeHeaders(debugging, requestId) + }, + ) + val options = options ?: SuperwallOptions() + + api = Api(networkEnvironment = options.networkEnvironment) + network = + Network( + baseHostService = + BaseHostService( + host = api.base.host, + Api.version1, + factory = this, + json = json(), + customHttpUrlConnection = httpConnection, + ), + collectorService = + CollectorService( + host = api.collector.host, + version = Api.version1, + factory = this, + json = json(), + customHttpUrlConnection = httpConnection, + ), + geoService = + GeoService( + host = api.geo.host, + version = Api.version1, + factory = this, + customHttpUrlConnection = httpConnection, + ), + factory = this, + ) errorTracker = ErrorTracker(scope = ioScope, cache = storage) paywallRequestManager = PaywallRequestManager( @@ -181,9 +236,6 @@ class DependencyContainer( factory = this, ) - val options = options ?: SuperwallOptions() - api = Api(networkEnvironment = options.networkEnvironment) - deviceHelper = DeviceHelper( context = context, @@ -192,6 +244,21 @@ class DependencyContainer( factory = this, ) + assignments = + Assignments( + storage = storage, + network = network, + ioScope, + ) + + paywallPreload = + PaywallPreload( + factory = this, + storage = storage, + assignments = assignments, + paywallManager = paywallManager, + ) + configManager = ConfigManager( context = context, @@ -202,6 +269,13 @@ class DependencyContainer( factory = this, paywallManager = paywallManager, deviceHelper = deviceHelper, + assignments = assignments, + ioScope = ioScope, + paywallPreload = + paywallPreload, + track = { + Superwall.instance.track(it) + }, ) eventsQueue = EventsQueue(context, configManager = configManager, network = network) @@ -250,6 +324,15 @@ class DependencyContainer( factory = this, context = context, ) + + /** + * This loads the webview libraries in the background thread, giving us 100-200ms less lag + * on first webview render. + * For more info check https://issuetracker.google.com/issues/245155339 + */ + ioScope.launch { + WebSettings.getDefaultUserAgent(context) + } } override suspend fun makeHeaders( @@ -358,7 +441,7 @@ class DependencyContainer( return view } - override fun makeCache(): PaywallViewCache = PaywallViewCache(context, Superwall.instance.viewStore(), activityProvider!!, deviceHelper) + override fun makeCache(): PaywallViewCache = PaywallViewCache(context, makeViewStore(), activityProvider!!, deviceHelper) override fun makeDeviceInfo(): DeviceInfo = DeviceInfo( @@ -526,4 +609,14 @@ class DependencyContainer( hasExternalPurchaseController = makeHasExternalPurchaseController(), hasDelegate = delegateAdapter.kotlinDelegate != null || delegateAdapter.javaDelegate != null, ) + + // Mark - ViewModel management + + private val storeOwner + get() = SuperwallStoreOwner() + private val vmFactory + get() = ViewModelFactory() + private val vmProvider = ViewModelProvider(storeOwner, vmFactory) + + override fun makeViewStore(): ViewStorageViewModel = vmProvider[ViewStorageViewModel::class.java] } 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 e4261575..80ade6a8 100644 --- a/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt +++ b/superwall/src/main/java/com/superwall/sdk/dependencies/FactoryProtocols.kt @@ -28,16 +28,17 @@ import com.superwall.sdk.paywall.presentation.internal.request.PresentationInfo import com.superwall.sdk.paywall.request.PaywallRequest import com.superwall.sdk.paywall.request.ResponseIdentifiers import com.superwall.sdk.paywall.vc.PaywallView +import com.superwall.sdk.paywall.vc.ViewStorage import com.superwall.sdk.paywall.vc.delegate.PaywallViewDelegateAdapter import com.superwall.sdk.paywall.vc.web_view.templating.models.JsonVariables -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.store.abstractions.transactions.StoreTransaction import kotlinx.coroutines.flow.StateFlow interface ApiFactory : JsonFactory { // TODO: Think of an alternative way such that we don't need to do this: var api: Api - var storage: Storage + var storage: LocalStorage // var storage: Storage! { get } var deviceHelper: DeviceHelper @@ -187,3 +188,7 @@ interface OptionsFactory { interface TriggerFactory { suspend fun makeTriggers(): Set } + +internal interface ViewStoreFactory { + fun makeViewStore(): ViewStorage +} diff --git a/superwall/src/main/java/com/superwall/sdk/deprecated/PaywallMessages.kt b/superwall/src/main/java/com/superwall/sdk/deprecated/PaywallMessages.kt index 3effbc87..97eb9b47 100644 --- a/superwall/src/main/java/com/superwall/sdk/deprecated/PaywallMessages.kt +++ b/superwall/src/main/java/com/superwall/sdk/deprecated/PaywallMessages.kt @@ -1,7 +1,9 @@ package com.superwall.sdk.deprecated import android.net.Uri -import android.util.Log +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import org.json.JSONObject import java.net.URL @@ -47,7 +49,11 @@ sealed class PaywallMessage { } fun parseWrappedPaywallMessages(jsonString: String): WrappedPaywallMessages { - Log.d("SWWebViewInterface", jsonString) + Logger.debug( + LogLevel.debug, + LogScope.all, + "SWWebViewInterface:$jsonString", + ) val jsonObject = JSONObject(jsonString) val version = jsonObject.optInt("version", 1) val payloadJson = jsonObject.getJSONObject("payload") diff --git a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt index 183b65bd..0f443a02 100644 --- a/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/identity/IdentityManager.kt @@ -13,8 +13,8 @@ import com.superwall.sdk.network.device.DeviceHelper import com.superwall.sdk.storage.AliasId import com.superwall.sdk.storage.AppUserId import com.superwall.sdk.storage.DidTrackFirstSeen +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.Seed -import com.superwall.sdk.storage.Storage import com.superwall.sdk.storage.UserAttributes import com.superwall.sdk.utilities.withErrorTrackingAsync import kotlinx.coroutines.CoroutineScope @@ -32,10 +32,10 @@ import java.util.concurrent.Executors class IdentityManager( private val deviceHelper: DeviceHelper, - private val storage: Storage, + private val storage: LocalStorage, private val configManager: ConfigManager, ) { - private var _appUserId: String? = storage.get(AppUserId) + private var _appUserId: String? = storage.read(AppUserId) val appUserId: String? get() = @@ -44,7 +44,7 @@ class IdentityManager( } private var _aliasId: String = - storage.get(AliasId) ?: IdentityLogic.generateAlias() + storage.read(AliasId) ?: IdentityLogic.generateAlias() val aliasId: String get() = @@ -53,7 +53,7 @@ class IdentityManager( } private var _seed: Int = - storage.get(Seed) ?: IdentityLogic.generateSeed() + storage.read(Seed) ?: IdentityLogic.generateSeed() val seed: Int get() = @@ -68,7 +68,7 @@ class IdentityManager( } private var _userAttributes: Map = - storage.get(UserAttributes) ?: emptyMap() + storage.read(UserAttributes) ?: emptyMap() val userAttributes: Map get() = @@ -88,15 +88,15 @@ class IdentityManager( init { val extraAttributes = mutableMapOf() - val aliasId = storage.get(AliasId) + val aliasId = storage.read(AliasId) if (aliasId == null) { - storage.save(_aliasId, AliasId) + storage.write(AliasId, _aliasId) extraAttributes["aliasId"] = _aliasId } - val seed = storage.get(Seed) + val seed = storage.read(Seed) if (seed == null) { - storage.save(_seed, Seed) + storage.write(Seed, _seed) extraAttributes["seed"] = _seed } @@ -112,7 +112,7 @@ class IdentityManager( CoroutineScope(Dispatchers.IO).launch { val neverCalledStaticConfig = storage.neverCalledStaticConfig val isFirstAppOpen = - !(storage.get(DidTrackFirstSeen) ?: false) + !(storage.read(DidTrackFirstSeen) ?: false) if (IdentityLogic.shouldGetAssignments( isLoggedIn, @@ -206,10 +206,10 @@ class IdentityManager( // called from the didSet of vars, who are already // being set within the queue. _appUserId?.let { - storage.save(it, AppUserId) + storage.write(AppUserId, it) } - storage.save(_aliasId, AliasId) - storage.save(_seed, Seed) + storage.write(AliasId, _aliasId) + storage.write(Seed, _seed) val newUserAttributes = mutableMapOf( @@ -281,7 +281,7 @@ class IdentityManager( Superwall.instance.track(trackableEvent) } } - storage.save(mergedAttributes, UserAttributes) + storage.write(UserAttributes, mergedAttributes) _userAttributes = mergedAttributes } } diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt b/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt index 5bafeca0..a9c3b2ed 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/Config+AwaitFirstValidConfig.kt @@ -1,18 +1,16 @@ package com.superwall.sdk.misc import com.superwall.sdk.config.models.ConfigState -import com.superwall.sdk.config.models.getConfig import com.superwall.sdk.models.config.Config import kotlinx.coroutines.flow.Flow +import kotlinx.coroutines.flow.filterIsInstance import kotlinx.coroutines.flow.first -suspend fun Flow>.awaitFirstValidConfig(): Config? { - return try { - first { result -> - if (result is Result.Failure) return@first false - result.getSuccess()?.getConfig() != null - }.getSuccess()?.getConfig() +suspend fun Flow.awaitFirstValidConfig(): Config = + try { + filterIsInstance() + .first() + .config } catch (e: Throwable) { - null + throw e } -} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt b/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt index 3a84f9e7..c30f3cdc 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/CurrentActivityTracker.kt @@ -3,27 +3,43 @@ package com.superwall.sdk.misc import android.app.Activity import android.app.Application import android.os.Bundle +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import java.lang.ref.WeakReference class CurrentActivityTracker : Application.ActivityLifecycleCallbacks, ActivityProvider { - private var currentActivity: Activity? = null + private var currentActivity: WeakReference? = null override fun onActivityCreated( activity: Activity, savedInstanceState: Bundle?, ) { - println("!! onActivityCreated: $activity") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! onActivityCreated: $activity", + ) } override fun onActivityStarted(activity: Activity) { - println("!! onActivityStarted: $activity") - currentActivity = activity + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! onActivityStarted: $activity", + ) + currentActivity = WeakReference(activity) } override fun onActivityResumed(activity: Activity) { - println("!! onActivityResumed: $activity") - currentActivity = activity + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! onActivityResumed: $activity", + ) + currentActivity = WeakReference(activity) } override fun onActivityPaused(activity: Activity) {} @@ -36,14 +52,22 @@ class CurrentActivityTracker : ) {} override fun onActivityDestroyed(activity: Activity) { - println("!! onActivityDestroyed: $activity") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! onActivityDestroyed: $activity", + ) if (currentActivity == activity) { currentActivity = null } } override fun getCurrentActivity(): Activity? { - println("!! getCurrentActivity: $currentActivity") - return currentActivity + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! getCurrentActivity: $currentActivity", + ) + return currentActivity?.get() } } diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Either.kt b/superwall/src/main/java/com/superwall/sdk/misc/Either.kt new file mode 100644 index 00000000..572ac326 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/misc/Either.kt @@ -0,0 +1,72 @@ +package com.superwall.sdk.misc + +sealed class Either { + data class Success( + val value: T, + ) : Either() + + data class Failure( + val error: E, + ) : Either() + + fun getSuccess(): T? = + when (this) { + is Success -> this.value + else -> null + } +} + +suspend fun Either.then(then: suspend (In) -> Unit): Either = + when (this) { + is Either.Success -> { + then(this.value) + this + } + + is Either.Failure -> this + } + +fun Either.map(transform: (In) -> Out): Either = + when (this) { + is Either.Success -> Either.Success(transform(this.value)) + is Either.Failure -> this + } + +fun Either.mapError(transform: (E) -> F): Either = + when (this) { + is Either.Success -> this + is Either.Failure -> Either.Failure(transform(this.error)) + } + +fun Either.onError(onError: (E) -> Unit): Either = + when (this) { + is Either.Success -> this + is Either.Failure -> { + onError(this.error) + this + } + } + +fun Either.flatMap(transform: (T) -> Either): Either = + when (this) { + is Either.Success -> transform(this.value) + is Either.Failure -> this + } + +fun Either.unwrap(): T = + when (this) { + is Either.Success -> this.value + is Either.Failure -> throw this.error + } + +suspend inline fun Either.fold( + crossinline onSuccess: suspend (T) -> Unit, + crossinline onFailure: suspend (E) -> Unit, +) { + when (this) { + is Either.Success -> onSuccess(this.value) + is Either.Failure -> onFailure(this.error) + } +} + +suspend inline fun Either.into(crossinline map: suspend (Either) -> Either): Either = map(this) diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Result.kt b/superwall/src/main/java/com/superwall/sdk/misc/Result.kt deleted file mode 100644 index 5195b06c..00000000 --- a/superwall/src/main/java/com/superwall/sdk/misc/Result.kt +++ /dev/null @@ -1,17 +0,0 @@ -package com.superwall.sdk.misc - -sealed class Result { - data class Success( - val value: T, - ) : Result() - - data class Failure( - val error: Throwable, - ) : Result() - - fun getSuccess(): T? = - when (this) { - is Success -> this.value - else -> null - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/misc/Task+Retrying.kt b/superwall/src/main/java/com/superwall/sdk/misc/Task+Retrying.kt index 24d6502f..19a9e21e 100644 --- a/superwall/src/main/java/com/superwall/sdk/misc/Task+Retrying.kt +++ b/superwall/src/main/java/com/superwall/sdk/misc/Task+Retrying.kt @@ -1,27 +1,30 @@ package com.superwall.sdk.misc import com.superwall.sdk.network.session.TaskRetryLogic +import kotlinx.coroutines.Job import kotlinx.coroutines.delay import kotlinx.coroutines.ensureActive import kotlinx.coroutines.withContext -import kotlin.coroutines.CoroutineContext suspend fun retrying( - coroutineContext: CoroutineContext, maxRetryCount: Int, - isRetryingCallback: (() -> Unit)?, + isRetryingCallback: (suspend () -> Unit)?, operation: suspend () -> T, ): T = - withContext(coroutineContext) { + run { + val job = Job() for (attempt in 0 until maxRetryCount) { try { - return@withContext operation() + withContext(job) { + return@withContext operation() + } + return operation() } catch (e: Throwable) { isRetryingCallback?.invoke() val delayTime = TaskRetryLogic.delay(attempt, maxRetryCount) ?: break delay(delayTime) } } - ensureActive() + job.ensureActive() operation() } diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt index cc154751..fff1d094 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/Config.kt @@ -13,10 +13,10 @@ data class Config( @SerialName("triggerOptions") var triggers: Set, @SerialName("paywallResponses") var paywalls: List, var logLevel: Int, + @SerialName("postback") var postback: PostbackRequest, @SerialName("appSessionTimeoutMs") var appSessionTimeout: Long, @SerialName("toggles") var rawFeatureFlags: List, -// @SerialName("toggles") var featureFlags: List, @SerialName("disablePreload") var preloadingDisabled: PreloadingDisabled, @SerialName("localization") var localizationConfig: LocalizationConfig, var requestId: String? = null, @@ -38,14 +38,24 @@ data class Config( val featureFlags: FeatureFlags get() = FeatureFlags( - enableMultiplePaywallUrls = rawFeatureFlags.find { it.key == "enable_multiple_paywall_urls" }?.enabled ?: false, - enableConfigRefresh = rawFeatureFlags.find { it.key == "enable_config_refresh" }?.enabled ?: false, + enableMultiplePaywallUrls = + rawFeatureFlags.find { it.key == "enable_multiple_paywall_urls" }?.enabled + ?: false, + enableConfigRefresh = + rawFeatureFlags.find { it.key == "enable_config_refresh" }?.enabled + ?: false, enableSessionEvents = rawFeatureFlags.find { it.key == "enable_session_events" }?.enabled ?: false, - enablePostback = rawFeatureFlags.find { it.key == "enable_postback" }?.enabled ?: false, - enableUserIdSeed = rawFeatureFlags.find { it.key == "enable_userid_seed" }?.enabled ?: false, - disableVerboseEvents = rawFeatureFlags.find { it.key == "disable_verbose_events" }?.enabled ?: false, + enablePostback = + rawFeatureFlags.find { it.key == "enable_postback" }?.enabled + ?: false, + enableUserIdSeed = + rawFeatureFlags.find { it.key == "enable_userid_seed" }?.enabled + ?: false, + disableVerboseEvents = + rawFeatureFlags.find { it.key == "disable_verbose_events" }?.enabled + ?: false, ) companion object { diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt b/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt index 5ab60b83..3c108e35 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/FeatureFlags.kt @@ -5,7 +5,9 @@ import kotlinx.serialization.Serializable @Serializable data class RawFeatureFlag( + @SerialName("key") val key: String, + @SerialName("enabled") val enabled: Boolean, ) diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/LocalizationConfig.kt b/superwall/src/main/java/com/superwall/sdk/models/config/LocalizationConfig.kt index 6614aa8f..8f0d7cbb 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/LocalizationConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/LocalizationConfig.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.models.config +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable @@ -8,6 +9,7 @@ data class LocalizationConfig( ) { @Serializable data class LocaleConfig( + @SerialName("locale") var locale: String, ) } diff --git a/superwall/src/main/java/com/superwall/sdk/models/config/PreloadingDisabled.kt b/superwall/src/main/java/com/superwall/sdk/models/config/PreloadingDisabled.kt index 9e5ea351..ad4410b1 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/config/PreloadingDisabled.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/config/PreloadingDisabled.kt @@ -1,10 +1,13 @@ package com.superwall.sdk.models.config +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable @Serializable data class PreloadingDisabled( + @SerialName("all") val all: Boolean, + @SerialName("triggers") val triggers: Set, ) { companion object { diff --git a/superwall/src/main/java/com/superwall/sdk/models/geo/GeoInfo.kt b/superwall/src/main/java/com/superwall/sdk/models/geo/GeoInfo.kt index b5bf5bcb..076a00f5 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/geo/GeoInfo.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/geo/GeoInfo.kt @@ -14,4 +14,20 @@ data class GeoInfo( val metroCode: String?, val postalCode: String?, val timezone: String?, -) +) { + internal companion object { + internal fun stub(): GeoInfo = + GeoInfo( + city = "NYC", + country = "USA", + longitude = 40.7128, + latitude = -74.0060, + region = "New York", + regionCode = "NY", + continent = "North America", + metroCode = "501", + postalCode = "10001", + timezone = "America/New_York", + ) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt b/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt index 361fbc93..f0e978aa 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/paywall/LocalNotification.kt @@ -5,14 +5,20 @@ import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlinx.serialization.Serializer import kotlinx.serialization.encoding.Decoder +import kotlinx.serialization.encoding.Encoder import kotlin.random.Random @Serializable class LocalNotification( + @SerialName("id") val id: Int = Random.nextInt(), + @SerialName("type") val type: LocalNotificationType, + @SerialName("title") val title: String, + @SerialName("body") val body: String, + @SerialName("delay") val delay: Long, ) @@ -21,6 +27,7 @@ sealed class LocalNotificationType { @SerialName("TRIAL_STARTED") object TrialStarted : LocalNotificationType() + @SerialName("UNSUPPORTED") object Unsupported : LocalNotificationType() } @@ -31,4 +38,16 @@ object LocalNotificationTypeSerializer : KSerializer { "TRIAL_STARTED" -> LocalNotificationType.TrialStarted else -> LocalNotificationType.Unsupported } + + override fun serialize( + encoder: Encoder, + value: LocalNotificationType, + ) { + encoder.encodeString( + when (value) { + LocalNotificationType.TrialStarted -> "TRIAL_STARTED" + LocalNotificationType.Unsupported -> "UNSUPPORTED" + }, + ) + } } 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 254a4e37..be22af8b 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 @@ -34,8 +34,11 @@ data class Paywalls( data class Paywall( @SerialName("id") val databaseId: String, + @SerialName("identifier") var identifier: PaywallIdentifier, + @SerialName("name") val name: String, + @SerialName("url") val url: @Serializable(with = URLSerializer::class) URL, @@ -45,6 +48,7 @@ data class Paywall( private val presentationStyle: String, @SerialName("presentation_delay") private val presentationDelay: Long, + @SerialName("presentation_condition") private val presentationCondition: String, @kotlinx.serialization.Transient() var presentation: PaywallPresentationInfo = @@ -61,7 +65,9 @@ data class Paywall( condition = PresentationCondition.valueOf(presentationCondition.uppercase()), delay = presentationDelay, ), + @SerialName("background_color_hex") val backgroundColorHex: String, + @SerialName("dark_background_color_hex") val darkBackgroundColorHex: String? = null, // Declared as private to prevent direct access @kotlinx.serialization.Transient() @@ -83,13 +89,16 @@ data class Paywall( var isFreeTrialAvailable: Boolean = false, // / The source of the presentation request. Either 'implicit', 'getPaywall', 'register'. var presentationSourceType: String? = null, + @SerialName("feature_gating") var featureGating: FeatureGatingBehavior = FeatureGatingBehavior.NonGated, @SerialName("computed_properties") var computedPropertyRequests: List = emptyList(), + @SerialName("local_notifications") var localNotifications: List = emptyList(), /** * Indicates whether the caching of the paywall is enabled or not. */ + @SerialName("on_device_cache") var onDeviceCache: OnDeviceCaching = OnDeviceCaching.Disabled, @kotlinx.serialization.Transient() var experiment: Experiment? = null, @@ -106,6 +115,7 @@ data class Paywall( /** Surveys to potentially show when an action happens in the paywall. */ + @SerialName("surveys") var surveys: List = emptyList(), ) : SerializableEntity { // Public getter for productItems diff --git a/superwall/src/main/java/com/superwall/sdk/models/postback/PostbackRequest.kt b/superwall/src/main/java/com/superwall/sdk/models/postback/PostbackRequest.kt index 248d92d8..e0595fdd 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/postback/PostbackRequest.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/postback/PostbackRequest.kt @@ -1,6 +1,7 @@ package com.superwall.sdk.models.postback import com.superwall.sdk.models.SerializableEntity +import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import kotlin.random.Random @@ -11,7 +12,9 @@ data class PostBackResponse( @Serializable data class PostbackRequest( + @SerialName("products") val products: List, + @SerialName("delay") val delay: Int? = null, ) { val postbackDelay: Double @@ -36,7 +39,9 @@ data class PostbackRequest( @Serializable data class PostbackProductIdentifier( + @SerialName("identifier") val identifier: String, + @SerialName("platform") val platform: String, ) { val isiOS: Boolean diff --git a/superwall/src/main/java/com/superwall/sdk/models/serialization/AnyMapSerializer.kt b/superwall/src/main/java/com/superwall/sdk/models/serialization/AnyMapSerializer.kt index e88fac0f..18098842 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/serialization/AnyMapSerializer.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/serialization/AnyMapSerializer.kt @@ -1,5 +1,8 @@ package com.superwall.sdk.models.serialization +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import kotlinx.serialization.* import kotlinx.serialization.builtins.serializer import kotlinx.serialization.descriptors.* @@ -39,7 +42,11 @@ object AnyMapSerializer : KSerializer> { else -> { // TODO: Figure out when this is happening put(k, JsonNull) - println("!! Warning: Unsupported type ${v::class}, skipping...") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! Warning: Unsupported type ${v::class}, skipping...", + ) // throw SerializationException("$v is not supported") } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/serialization/AnySerializer.kt b/superwall/src/main/java/com/superwall/sdk/models/serialization/AnySerializer.kt index 46bc1dc8..a3ba0c26 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/serialization/AnySerializer.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/serialization/AnySerializer.kt @@ -1,5 +1,8 @@ package com.superwall.sdk.models.serialization +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import kotlinx.serialization.* import kotlinx.serialization.builtins.ListSerializer import kotlinx.serialization.builtins.MapSerializer @@ -48,7 +51,11 @@ object AnySerializer : KSerializer { } null -> encoder.encodeNull() else -> { - println("Warning: Unsupported type ${value::class}, skipping...") + Logger.debug( + LogLevel.debug, + LogScope.all, + "Warning: Unsupported type ${value::class}, skipping...", + ) encoder.encodeNull() } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/triggers/Experiment.kt b/superwall/src/main/java/com/superwall/sdk/models/triggers/Experiment.kt index 2ea279ba..044b40d0 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/triggers/Experiment.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/triggers/Experiment.kt @@ -9,17 +9,24 @@ data class Experiment( val id: String, @SerialName("trigger_experiment_group_id") val groupId: String, + @SerialName("variant") val variant: Variant, ) { @Serializable data class Variant( + @SerialName("id") val id: String, + @SerialName("type") val type: VariantType, @SerialName("paywall_identifier") val paywallId: String?, ) { + @Serializable enum class VariantType { + @SerialName("TREATMENT") TREATMENT, + + @SerialName("HOLDOUT") HOLDOUT, } } diff --git a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt index e46f3826..e4df0f34 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRule.kt @@ -78,14 +78,21 @@ enum class TriggerPreloadBehavior( @Serializable data class TriggerRule( + @SerialName("experiment_id") var experimentId: String, + @SerialName("experiment_group_id") var experimentGroupId: String, + @SerialName("variants") var variants: List, + @SerialName("expression") val expression: String? = null, + @SerialName("expression_js") val expressionJs: String? = null, + @SerialName("occurrence") val occurrence: TriggerRuleOccurrence? = null, @SerialName("computed_properties") val computedPropertyRequests: List = emptyList(), + @SerialName("preload") val preload: TriggerPreload, ) { @Serializable(with = TriggerPreloadSerializer::class) diff --git a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRuleOccurrence.kt b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRuleOccurrence.kt index e337408d..8f733573 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRuleOccurrence.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/triggers/TriggerRuleOccurrence.kt @@ -6,18 +6,25 @@ import kotlinx.serialization.Transient @Serializable data class RawInterval( + @SerialName("type") val type: IntervalType, val minutes: Int? = null, ) { + @Serializable enum class IntervalType { + @SerialName("MINUTES") MINUTES, + + @SerialName("INFINITY") INFINITY, } } @Serializable data class TriggerRuleOccurrence( + @SerialName("key") val key: String, + @SerialName("max_count") var maxCount: Int, @SerialName("interval") val rawInterval: RawInterval, diff --git a/superwall/src/main/java/com/superwall/sdk/models/triggers/VariantOption.kt b/superwall/src/main/java/com/superwall/sdk/models/triggers/VariantOption.kt index 263abc87..f2cda15f 100644 --- a/superwall/src/main/java/com/superwall/sdk/models/triggers/VariantOption.kt +++ b/superwall/src/main/java/com/superwall/sdk/models/triggers/VariantOption.kt @@ -11,6 +11,7 @@ data class VariantOption( var type: Experiment.Variant.VariantType, @SerialName("variant_id") var id: String, + @SerialName("percentage") var percentage: Int, @SerialName("paywall_identifier") var paywallId: String? = null, diff --git a/superwall/src/main/java/com/superwall/sdk/network/API.kt b/superwall/src/main/java/com/superwall/sdk/network/API.kt index f16ccc5c..3481d40f 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/API.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/API.kt @@ -10,8 +10,6 @@ data class Api( ) { companion object { const val version1 = "/api/v1/" - - // const val scheme = "http" const val scheme = "https" } diff --git a/superwall/src/main/java/com/superwall/sdk/network/AwaitUntilNetworkExists.kt b/superwall/src/main/java/com/superwall/sdk/network/AwaitUntilNetworkExists.kt new file mode 100644 index 00000000..bdeea271 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/AwaitUntilNetworkExists.kt @@ -0,0 +1,42 @@ +import android.Manifest +import android.content.Context +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import androidx.core.content.ContextCompat +import kotlinx.coroutines.delay + +internal suspend fun Context.awaitUntilNetworkExists(checkInterval: Long = 1000) { + while (!isNetworkAvailable(this)) { + delay(checkInterval) + } + delay(checkInterval) +} + +private fun hasNetworkPermission(context: Context): Boolean = + ContextCompat.checkSelfPermission( + context, + Manifest.permission.ACCESS_NETWORK_STATE, + ) == PackageManager.PERMISSION_GRANTED + +private fun isNetworkAvailable(context: Context): Boolean { + val connectivityManager = context.getSystemService(Context.CONNECTIVITY_SERVICE) as ConnectivityManager + if (!hasNetworkPermission(context)) return true + + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + val network = connectivityManager.activeNetwork ?: return false + val activeNetwork = connectivityManager.getNetworkCapabilities(network) ?: return false + + return when { + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) -> true + activeNetwork.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR) -> true + else -> false + } + } else { + @Suppress("DEPRECATION") + val networkInfo = connectivityManager.activeNetworkInfo ?: return false + @Suppress("DEPRECATION") + return networkInfo.isConnected + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt new file mode 100644 index 00000000..5a4ba4dc --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/BaseHostService.kt @@ -0,0 +1,79 @@ +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.assignment.AssignmentPostback +import com.superwall.sdk.models.assignment.ConfirmedAssignmentResponse +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.paywall.Paywall +import com.superwall.sdk.models.paywall.Paywalls +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.network.NetworkService +import com.superwall.sdk.network.URLQueryItem +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class BaseHostService( + override val host: String, + override val version: String, + val factory: ApiFactory, + private val json: Json, + override val customHttpUrlConnection: CustomHttpUrlConnection, +) : NetworkService() { + override suspend fun makeHeaders( + isForDebugging: Boolean, + requestId: String, + ): Map = factory.makeHeaders(isForDebugging, requestId) + + suspend fun config(requestId: String) = + get( + "static_config", + requestId = requestId, + queryItems = listOf(URLQueryItem("pk", factory.storage.apiKey)), + ) + + suspend fun assignments() = get("assignments") + + suspend fun confirmAssignments(confirmableAssignments: AssignmentPostback) = + post( + "confirm_assignments", + body = json.encodeToString(confirmableAssignments).toByteArray(), + ) + + suspend fun paywalls() = get("paywalls") + + suspend fun paywall(identifier: String? = null): Either { + // WARNING: Do not modify anything about this request without considering our cache eviction code + // we must know all the exact urls we need to invalidate so changing the order, inclusion, etc of any query + // parameters will cause issues + val queryItems = mutableListOf(URLQueryItem("pk", factory.storage.apiKey)) + + // In the config endpoint we return all the locales, this code will check if: + // 1. The device locale (ex: en_US) exists in the locales list + // 2. The shortened device locale (ex: en) exists in the locale list + // If either exist (preferring the most specific) include the locale in the + // the url as a query param. + factory.configManager.config?.let { config -> + if (config.locales.contains(factory.deviceHelper.locale)) { + val localeQuery = + URLQueryItem( + name = "locale", + value = factory.deviceHelper.locale, + ) + queryItems.add(localeQuery) + } else { + val shortLocale = factory.deviceHelper.locale.split("_")[0] + if (config.locales.contains(shortLocale)) { + val localeQuery = + URLQueryItem( + name = "locale", + value = shortLocale, + ) + queryItems.add(localeQuery) + } + return@let + } + } + + return get("paywall/$identifier", queryItems = queryItems, isForDebugging = true) + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/CollectorService.kt b/superwall/src/main/java/com/superwall/sdk/network/CollectorService.kt new file mode 100644 index 00000000..ccaae39c --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/CollectorService.kt @@ -0,0 +1,27 @@ +package com.superwall.sdk.network + +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.models.events.EventsRequest +import com.superwall.sdk.models.events.EventsResponse +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import kotlinx.serialization.encodeToString +import kotlinx.serialization.json.Json + +class CollectorService( + override val host: String, + override val version: String, + val factory: ApiFactory, + private val json: Json, + override val customHttpUrlConnection: CustomHttpUrlConnection, +) : NetworkService() { + override suspend fun makeHeaders( + isForDebugging: Boolean, + requestId: String, + ): Map = factory.makeHeaders(isForDebugging, requestId) + + suspend fun events(eventsRequest: EventsRequest) = + post( + "events", + body = json.encodeToString(eventsRequest).toByteArray(), + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt b/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt deleted file mode 100644 index c1eafdcb..00000000 --- a/superwall/src/main/java/com/superwall/sdk/network/Endpoint.kt +++ /dev/null @@ -1,344 +0,0 @@ -package com.superwall.sdk.network - -import com.superwall.sdk.dependencies.ApiFactory -import com.superwall.sdk.models.SerializableEntity -import com.superwall.sdk.models.assignment.AssignmentPostback -import com.superwall.sdk.models.assignment.ConfirmedAssignmentResponse -import com.superwall.sdk.models.config.Config -import com.superwall.sdk.models.events.EventData -import com.superwall.sdk.models.events.EventsRequest -import com.superwall.sdk.models.events.EventsResponse -import com.superwall.sdk.models.geo.GeoWrapper -import com.superwall.sdk.models.paywall.Paywall -import com.superwall.sdk.models.paywall.Paywalls -import com.superwall.sdk.models.postback.PostBackResponse -import com.superwall.sdk.models.postback.Postback -import kotlinx.coroutines.coroutineScope -import kotlinx.serialization.encodeToString -import java.net.HttpURLConnection -import java.net.URL -import java.util.* - -data class URLQueryItem( - val name: String, - val value: String, -) - -data class Endpoint( - val components: Components? = null, - val url: URL? = null, - var method: HttpMethod = HttpMethod.GET, - var requestId: String = UUID.randomUUID().toString(), - var isForDebugging: Boolean = false, - val factory: ApiFactory, - val retryCount: Int = 6, -) { - enum class HttpMethod( - val method: String, - ) { - GET("GET"), - POST("POST"), - } - - data class Components( - var scheme: String? = Api.scheme, - val host: String? = null, - val path: String, - var queryItems: List? = null, - var bodyData: ByteArray? = null, - ) - - suspend fun makeRequest(): HttpURLConnection? = - coroutineScope { - val url: URL - - if (components != null) { - val query = components.queryItems?.joinToString("&") { "${it.name}=${it.value}" } - val urlString = - "${components.scheme}://${components.host}${components.path}?${query ?: ""}" - url = URL(urlString) - } else if (this@Endpoint.url != null) { - url = this@Endpoint.url!! - } else { - return@coroutineScope null - } - - val headers = - factory.makeHeaders( - isForDebugging = isForDebugging, - requestId = requestId, - ) - val connection = url.openConnection() as HttpURLConnection - headers.forEach { header -> - connection.setRequestProperty(header.key, header.value) - } - - connection.doOutput = method.method == HttpMethod.POST.method - if (components?.bodyData != null) { - connection.doInput = true - } - - if (components?.bodyData != null) { - val outputStream = connection.outputStream - outputStream.write(components.bodyData) - outputStream.close() - } - - connection.requestMethod = method.method - - return@coroutineScope connection - } - - companion object { - fun events( - eventsRequest: EventsRequest, - factory: ApiFactory, - ): Endpoint { - val bodyData = factory.json().encodeToString(eventsRequest).toByteArray() - val collectorHost = factory.api.collector.host - - return Endpoint( - components = - Components( - host = collectorHost, - path = Api.version1 + "events", - bodyData = bodyData, - ), - method = HttpMethod.POST, - factory = factory, - ) - } - - fun config( - requestId: String, - factory: ApiFactory, - ): Endpoint { - val queryItems = listOf(URLQueryItem("pk", factory.storage.apiKey)) - val baseHost = factory.api.base.host - - return Endpoint( - components = - Components( - host = baseHost, - path = Api.version1 + "static_config", - queryItems = queryItems, - ), - method = HttpMethod.GET, - requestId = requestId, - factory = factory, - ) - } - - fun assignments(factory: ApiFactory): Endpoint { - val baseHost = factory.api.base.host - - return Endpoint( - components = - Components( - host = baseHost, - path = Api.version1 + "assignments", - ), - method = HttpMethod.GET, - factory = factory, - ) - } - - fun confirmAssignments( - confirmableAssignments: AssignmentPostback, - factory: ApiFactory, - ): Endpoint { - val json = factory.json() - val bodyData = json.encodeToString(confirmableAssignments).toByteArray() - val baseHost = factory.api.base.host - - return Endpoint( - components = - Components( - host = baseHost, - path = Api.version1 + "confirm_assignments", - bodyData = bodyData, - ), - method = HttpMethod.POST, - factory = factory, - ) - } - - fun postback( - postback: Postback, - factory: ApiFactory, - ): Endpoint { - val json = factory.json() - val bodyData = json.encodeToString(postback).toByteArray() - val collectorHost = factory.api.collector.host - - return Endpoint( - components = - Components( - host = collectorHost, - path = Api.version1 + "postback", - bodyData = bodyData, - ), - method = HttpMethod.POST, - factory = factory, - ) - } - - fun paywalls(factory: ApiFactory): Endpoint { - val baseHost = factory.api.base.host - return Endpoint( - components = - Components( - host = baseHost, - path = Api.version1 + "paywalls", - ), - method = HttpMethod.GET, - isForDebugging = true, - factory = factory, - ) - } - - fun geo(factory: ApiFactory): Endpoint { - val geoHost = factory.api.geo.host - return Endpoint( - components = - Components( - host = geoHost, - path = Api.version1 + "geo", - ), - method = HttpMethod.GET, - factory = factory, - ) - } - - fun paywall( - identifier: String? = null, - event: EventData? = null, - factory: ApiFactory, - ): Endpoint { - val bodyData: ByteArray? - - bodyData = - when { - identifier != null -> { - return paywall(identifier, factory) - } - else -> { - throw Exception("Invalid paywall request, only load via identifier is supported") - } - } - } - - private fun paywall( - identifier: String, - factory: ApiFactory, - ): Endpoint { - // WARNING: Do not modify anything about this request without considering our cache eviction code - // we must know all the exact urls we need to invalidate so changing the order, inclusion, etc of any query - // parameters will cause issues - val queryItems = mutableListOf(URLQueryItem("pk", factory.storage.apiKey)) - - // In the config endpoint we return all the locales, this code will check if: - // 1. The device locale (ex: en_US) exists in the locales list - // 2. The shortened device locale (ex: en) exists in the locale list - // If either exist (preferring the most specific) include the locale in the - // the url as a query param. - factory.configManager.config?.let { config -> - if (config.locales.contains(factory.deviceHelper.locale)) { - val localeQuery = - URLQueryItem( - name = "locale", - value = factory.deviceHelper.locale, - ) - queryItems.add(localeQuery) - } else { - val shortLocale = factory.deviceHelper.locale.split("_")[0] - if (config.locales.contains(shortLocale)) { - val localeQuery = - URLQueryItem( - name = "locale", - value = shortLocale, - ) - queryItems.add(localeQuery) - } - return@let - } - } - - val baseHost = factory.api.base.host - - return Endpoint( - components = - Components( - host = baseHost, - path = Api.version1 + "paywall/$identifier", - queryItems = queryItems, - ), - method = HttpMethod.GET, - factory = factory, - ) - } - - private fun createEndpointWithBodyData( - bodyData: String?, - factory: ApiFactory, - ): Endpoint { - val baseHost = factory.api.base.host - - var _bodyData: ByteArray? = null - if (bodyData != null) { - _bodyData = bodyData.toByteArray() - } - - return Endpoint( - components = - Components( - host = baseHost, - path = Api.version1 + "paywall", - bodyData = _bodyData, - ), - method = HttpMethod.POST, - factory = factory, - ) - } -// -// private fun paywallByIdentifier( -// identifier: String, -// factory: ApiFactory -// ): Endpoint { -// var queryItems = mutableListOf(URLQueryItem("pk", factory.storage.apiKey)) -// -// factory.configManager.config?.let { config -> -// when { -// config.locales.contains(factory.deviceHelper.locale) -> { -// val localeQuery = URLQueryItem( -// name = "locale", -// value = factory.deviceHelper.locale -// ) -// queryItems.add(localeQuery) -// } -// else -> { -// val shortLocale = factory.deviceHelper.locale.split("_")[0] -// if (config.locales.contains(shortLocale)) { -// val localeQuery = URLQueryItem( -// name = "locale", -// value = shortLocale -// ) -// queryItems.add(localeQuery) -// } -// } -// } -// } -// val baseHost = factory.api.base.host -// -m -// return Endpoint( -// components = Components( -// host = baseHost, -// path = "${Api.version1}paywall/$identifier", -// queryItems = queryItems -// ), -// method = HttpMethod.GET, -// factory = factory -// ) -// } - } -} diff --git a/superwall/src/main/java/com/superwall/sdk/network/GeoService.kt b/superwall/src/main/java/com/superwall/sdk/network/GeoService.kt new file mode 100644 index 00000000..d05a4255 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/GeoService.kt @@ -0,0 +1,18 @@ +import com.superwall.sdk.dependencies.ApiFactory +import com.superwall.sdk.models.geo.GeoWrapper +import com.superwall.sdk.network.NetworkService +import com.superwall.sdk.network.session.CustomHttpUrlConnection + +class GeoService( + override val host: String, + override val version: String, + val factory: ApiFactory, + override val customHttpUrlConnection: CustomHttpUrlConnection, +) : NetworkService() { + override suspend fun makeHeaders( + isForDebugging: Boolean, + requestId: String, + ): Map = factory.makeHeaders(isForDebugging, requestId) + + suspend fun geo() = get("geo") +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/JsonFactory.kt b/superwall/src/main/java/com/superwall/sdk/network/JsonFactory.kt index e617aed8..fc549232 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/JsonFactory.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/JsonFactory.kt @@ -1,12 +1,18 @@ package com.superwall.sdk.network +import com.superwall.sdk.models.paywall.LocalNotificationTypeSerializer import kotlinx.serialization.json.Json import kotlinx.serialization.json.JsonNamingStrategy +import kotlinx.serialization.modules.serializersModuleOf interface JsonFactory { fun json() = Json { + ignoreUnknownKeys = true encodeDefaults = true namingStrategy = JsonNamingStrategy.SnakeCase + serializersModuleOf( + LocalNotificationTypeSerializer, + ) } } diff --git a/superwall/src/main/java/com/superwall/sdk/network/Network.kt b/superwall/src/main/java/com/superwall/sdk/network/Network.kt index 3d10fc9c..6e8b2e21 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/Network.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/Network.kt @@ -1,211 +1,127 @@ package com.superwall.sdk.network +import BaseHostService +import GeoService import com.superwall.sdk.dependencies.ApiFactory import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.map +import com.superwall.sdk.misc.onError import com.superwall.sdk.models.assignment.Assignment import com.superwall.sdk.models.assignment.AssignmentPostback -import com.superwall.sdk.models.assignment.ConfirmedAssignmentResponse import com.superwall.sdk.models.config.Config import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.events.EventsRequest import com.superwall.sdk.models.events.EventsResponse import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.models.paywall.Paywall -import com.superwall.sdk.network.session.CustomHttpUrlConnection import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import java.util.UUID open class Network( - private val urlSession: CustomHttpUrlConnection = CustomHttpUrlConnection(), + private val baseHostService: BaseHostService, + private val collectorService: CollectorService, + private val geoService: GeoService, private val factory: ApiFactory, -) { -// private val applicationStatePublisher: Flow = -// UIApplication.shared.publisher { property -> -// if (property == "applicationState") { -// emit(UIApplication.sharedInstance().applicationState) -// } -// } - - suspend fun sendEvents(events: EventsRequest) { - try { - val result = - urlSession.request( - Endpoint.events( - eventsRequest = events, - factory = factory, - ), - ) - - when (result.status) { - EventsResponse.Status.OK -> { - } - - EventsResponse.Status.PARTIAL_SUCCESS -> { +) : SuperwallAPI { + override suspend fun sendEvents(events: EventsRequest): Either = + collectorService + .events( + events, + ).map { + if (it.status == EventsResponse.Status.PARTIAL_SUCCESS) { Logger.debug( logLevel = LogLevel.warn, scope = LogScope.network, message = "Request had partial success: /events", ) } - } - } catch (error: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /events", - info = mapOf("payload" to events), - error = error, - ) - } - } + Unit + }.logError("/events", mapOf("payload" to events)) - // @MainActor - suspend fun getConfig( - isRetryingCallback: () -> Unit, -// injectedApplicationStatePublisher: (Flow)? = null - ): Config { + override suspend fun getConfig(isRetryingCallback: suspend () -> Unit): Either { awaitUntilAppInForeground() - return try { - val requestId = UUID.randomUUID().toString() - val config = - urlSession.request( - Endpoint.config( - factory = factory, - requestId = requestId, - ), - isRetryingCallback = isRetryingCallback, - ) - config.requestId = requestId - return config - } catch (error: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /static_config", - error = error, - ) - throw error - } - } + val requestId = UUID.randomUUID().toString() - open suspend fun confirmAssignments(confirmableAssignments: AssignmentPostback) { - try { - urlSession.request( - Endpoint.confirmAssignments( - confirmableAssignments = confirmableAssignments, - factory = factory, - ), - ) - } catch (error: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /confirm_assignments", - info = mapOf("assignments" to confirmableAssignments), - error = error, - ) - } + return baseHostService + .config( + requestId, + ).map { config -> + config.requestId = requestId + config + }.logError("/static_config") } - suspend fun getPaywall( - identifier: String? = null, - event: EventData? = null, - ): Paywall = - try { - urlSession.request( - Endpoint.paywall( - identifier = identifier, - event = event, - factory = factory, - ), - ) - } catch (error: Throwable) { - if (identifier == null) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /paywall", - info = - mapOf( - "identifier" to (identifier ?: "none"), - "event" to (event?.name ?: ""), - ), - error = error, - ) - } else { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /paywall/:$identifier", - error = error, - ) - } - throw error - } + override suspend fun confirmAssignments(confirmableAssignments: AssignmentPostback) = + baseHostService + .confirmAssignments(confirmableAssignments) + .map { } + .logError("/confirm_assignments", mapOf("assignments" to confirmableAssignments)) - suspend fun getPaywalls(): List = - try { - val paywalls = - urlSession.request( - Endpoint.paywalls(factory = factory), - ) - paywalls.paywalls - } catch (error: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /paywalls", - error = error, + override suspend fun getPaywall( + identifier: String?, + event: EventData?, + ): Either = + baseHostService + .paywall(identifier) + .logError( + "/paywall${identifier?.let { "/:$it" } ?: ""}", + if (identifier == null) { + mapOf( + "identifier" to (identifier ?: "none"), + "event" to (event?.name ?: ""), + ) + } else { + null + }, ) - throw error - } - suspend fun getGeoInfo(): GeoInfo? = - try { - awaitUntilAppInForeground() + override suspend fun getPaywalls(): Either, NetworkError> = + baseHostService + .paywalls() + .map { + it.paywalls + }.logError("/paywalls") - val geoWrapper = - urlSession.request( - Endpoint.geo(factory = factory), - ) - geoWrapper.info - } catch (error: Exception) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /geo", - error = error, - ) - null - } + override suspend fun getGeoInfo(): Either { + awaitUntilAppInForeground() - open suspend fun getAssignments(): List = - try { - val result = - urlSession.request( - Endpoint.assignments(factory = factory), - ) - result.assignments - } catch (error: Throwable) { - Logger.debug( - logLevel = LogLevel.error, - scope = LogScope.network, - message = "Request Failed: /assignments", - error = error, - ) - throw error - } + return geoService + .geo() + .map { + it.info + }.logError("/geo") + } + + override suspend fun getAssignments(): Either, NetworkError> = + baseHostService + .assignments() + .map { + it.assignments + }.logError("/assignments") private suspend fun awaitUntilAppInForeground() { // Wait until the app is not in the background. - factory.appLifecycleObserver .isInBackground .filter { !it } .first() } + + private fun Either.logError( + url: String, + data: Map? = null, + ) = onError { + Logger.debug( + logLevel = LogLevel.error, + scope = LogScope.network, + message = "Request Failed: $url", + info = data, + error = it, + ) + } } diff --git a/superwall/src/main/java/com/superwall/sdk/network/NetworkError.kt b/superwall/src/main/java/com/superwall/sdk/network/NetworkError.kt new file mode 100644 index 00000000..059d1c41 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/NetworkError.kt @@ -0,0 +1,20 @@ +package com.superwall.sdk.network + +sealed class NetworkError( + message: String, + cause: Throwable? = null, +) : Throwable(message, cause) { + data class Unknown( + override val cause: Throwable? = null, + ) : NetworkError("An unknown error occurred.", cause) + + class NotAuthenticated : NetworkError("Unauthorized.") + + class Decoding( + cause: Throwable? = null, + ) : NetworkError("Decoding error.", cause) + + class NotFound : NetworkError("Not found.") + + class InvalidUrl : NetworkError("URL invalid.") +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/NetworkRequestData.kt b/superwall/src/main/java/com/superwall/sdk/network/NetworkRequestData.kt new file mode 100644 index 00000000..3f0a1e40 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/NetworkRequestData.kt @@ -0,0 +1,34 @@ +package com.superwall.sdk.network + +import kotlinx.serialization.Serializable +import java.net.URL +import java.util.* + +data class URLQueryItem( + val name: String, + val value: String, +) + +class NetworkRequestData( + val components: Components? = null, + val url: URL? = null, + var method: HttpMethod = HttpMethod.GET, + var requestId: String = UUID.randomUUID().toString(), + var isForDebugging: Boolean = false, + val factory: suspend (isForDebugging: Boolean, requestId: String) -> Map, +) where Response : @Serializable Any { + enum class HttpMethod( + val method: String, + ) { + GET("GET"), + POST("POST"), + } + + data class Components( + var scheme: String? = Api.scheme, + val host: String? = null, + val path: String, + var queryItems: List? = null, + var bodyData: ByteArray? = null, + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt b/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt new file mode 100644 index 00000000..630255c0 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/NetworkService.kt @@ -0,0 +1,69 @@ +package com.superwall.sdk.network + +import com.superwall.sdk.misc.Either +import com.superwall.sdk.network.NetworkRequestData.HttpMethod +import com.superwall.sdk.network.session.CustomHttpUrlConnection +import kotlinx.serialization.Serializable +import java.util.UUID + +abstract class NetworkService { + abstract val customHttpUrlConnection: CustomHttpUrlConnection + + abstract suspend fun makeHeaders( + isForDebugging: Boolean, + requestId: String, + ): Map + + abstract val host: String + abstract val version: String + + suspend inline fun get( + path: String, + queryItems: List? = null, + isForDebugging: Boolean = false, + requestId: String = UUID.randomUUID().toString(), + retryCount: Int = 6, + ): Either where T : @Serializable Any = + customHttpUrlConnection.request( + buildRequestData = { + NetworkRequestData( + isForDebugging = isForDebugging, + requestId = requestId, + components = + NetworkRequestData.Components( + host = host, + path = version + path, + queryItems = queryItems, + ), + method = HttpMethod.GET, + factory = this::makeHeaders, + ) + }, + retryCount = retryCount, + ) + + suspend inline fun post( + path: String, + isForDebugging: Boolean = false, + body: ByteArray? = null, + requestId: String = UUID.randomUUID().toString(), + retryCount: Int = 6, + ): Either where T : @Serializable Any = + customHttpUrlConnection.request( + buildRequestData = { + NetworkRequestData( + isForDebugging = isForDebugging, + requestId = requestId, + components = + NetworkRequestData.Components( + host = host, + path = version + path, + bodyData = body, + ), + method = HttpMethod.POST, + factory = this::makeHeaders, + ) + }, + retryCount = retryCount, + ) +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/RequestExecutor.kt b/superwall/src/main/java/com/superwall/sdk/network/RequestExecutor.kt new file mode 100644 index 00000000..0f97e7a5 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/RequestExecutor.kt @@ -0,0 +1,189 @@ +package com.superwall.sdk.network + +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either +import com.superwall.sdk.network.NetworkRequestData.HttpMethod +import kotlinx.serialization.Serializable +import java.net.HttpURLConnection +import java.net.URL + +class RequestExecutor( + val buildHeaders: suspend (isForDebugging: Boolean, requestId: String) -> Map, +) { + suspend fun execute(requestData: NetworkRequestData<*>): Either { + try { + val headers = buildHeaders(requestData.isForDebugging, requestData.requestId) + val request = + try { + requestData.buildRequest(headers) + } catch (e: Throwable) { + return Either.Failure(NetworkError.Unknown(e)) + } + val auth = + request?.getRequestProperty("Authorization") + ?: return Either.Failure(NetworkError.NotAuthenticated()) + + Logger.debug( + LogLevel.debug, + LogScope.network, + "Request Started", + mapOf( + "url" to (request.url?.toString() ?: "unknown"), + ), + ) + + val startTime = System.currentTimeMillis() + val responseCode: Int = + try { + request.responseCode + } catch (e: Throwable) { + return Either.Failure(NetworkError.Unknown(e)) + } + + var responseMessage: String? = null + if (responseCode == HttpURLConnection.HTTP_OK) { + responseMessage = request.inputStream.bufferedReader().use { it.readText() } + request.disconnect() + } else { + Logger.debug( + LogLevel.error, + LogScope.network, + "Request failed : ${request.responseCode}", + mapOf( + "request" to request.toString(), + "api_key" to auth, + "url" to (request.url?.toString() ?: "unknown"), + ), + ) + request.disconnect() + return Either.Failure(NetworkError.Unknown(Exception("Failed, response code $responseCode"))) + } + + val requestDuration = (System.currentTimeMillis() - startTime) / 1000.0 + val requestId = + try { + request.getRequestId(auth, requestDuration) + } catch (e: Throwable) { + return Either.Failure( + if (e is NetworkError) { + e + } else { + NetworkError.Unknown( + Exception("Failed to get request id. ${e.message}"), + ) + }, + ) + } + + Logger.debug( + LogLevel.debug, + LogScope.network, + "Request Completed", + mapOf( + "request" to request.toString(), + "api_key" to auth, + "url" to (request.url?.toString() ?: "unknown"), + "request_id" to requestId, + "request_duration" to requestDuration, + ), + ) + return Either.Success( + RequestResult( + requestId, + responseCode, + responseMessage, + requestDuration, + headers, + ), + ) + } catch (e: Throwable) { + return Either.Failure(NetworkError.Unknown(e)) + } + } + + private fun NetworkRequestData.buildRequest(headers: Map): HttpURLConnection? { + val url: URL + + if (components != null) { + val query = components.queryItems?.joinToString("&") { "${it.name}=${it.value}" } + val urlString = + "${components.scheme}://${components.host}${components.path}?${query ?: ""}" + url = URL(urlString) + } else if (this.url != null) { + url = this.url!! + } else { + return null + } + + val connection = url.openConnection() as HttpURLConnection + headers.forEach { header -> + connection.setRequestProperty(header.key, header.value) + } + + connection.doOutput = method.method == HttpMethod.POST.method + if (components?.bodyData != null) { + connection.doInput = true + } + + if (components?.bodyData != null) { + val outputStream = connection.outputStream + outputStream.write(components.bodyData) + outputStream.close() + } + + connection.requestMethod = method.method + + return connection + } + + @Throws(NetworkError::class) + private fun HttpURLConnection.getRequestId( + auth: String, + requestDuration: Double, + ): String { + var requestId = "unknown" + val request = this + + val id = request.getHeaderField("x-request-id") + if (id != null) { + requestId = id + } + + when (request.responseCode) { + HttpURLConnection.HTTP_UNAUTHORIZED -> { + Logger.debug( + LogLevel.error, + LogScope.network, + "Unable to Authenticate", + mapOf( + "request" to request.toString(), + "api_key" to auth, + "url" to request.url.toString(), + "request_id" to requestId, + "request_duration" to requestDuration, + ), + ) + throw NetworkError.NotAuthenticated() + } + + HttpURLConnection.HTTP_NOT_FOUND -> { + Logger.debug( + LogLevel.error, + LogScope.network, + "Not Found", + mapOf( + "request" to request.toString(), + "api_key" to auth, + "url" to request.url.toString(), + "request_id" to requestId, + "request_duration" to requestDuration, + ), + ) + throw NetworkError.NotFound() + } + } + return requestId + } +} diff --git a/superwall/src/main/java/com/superwall/sdk/network/RequestResult.kt b/superwall/src/main/java/com/superwall/sdk/network/RequestResult.kt new file mode 100644 index 00000000..1c922068 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/RequestResult.kt @@ -0,0 +1,11 @@ +package com.superwall.sdk.network + +class RequestResult( + val requestId: String, + val responseCode: Int, + val responseMessage: String, + val duration: Double, + val headers: Map, +) + +fun RequestResult.authHeader(): String = headers["Authorization"] ?: "" diff --git a/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt new file mode 100644 index 00000000..057abdf0 --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/network/SuperwallAPI.kt @@ -0,0 +1,29 @@ +package com.superwall.sdk.network + +import com.superwall.sdk.misc.Either +import com.superwall.sdk.models.assignment.Assignment +import com.superwall.sdk.models.assignment.AssignmentPostback +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.events.EventData +import com.superwall.sdk.models.events.EventsRequest +import com.superwall.sdk.models.geo.GeoInfo +import com.superwall.sdk.models.paywall.Paywall + +interface SuperwallAPI { + suspend fun sendEvents(events: EventsRequest): Either + + suspend fun getConfig(isRetryingCallback: suspend () -> Unit): Either + + suspend fun confirmAssignments(confirmableAssignments: AssignmentPostback): Either + + suspend fun getPaywall( + identifier: String? = null, + event: EventData? = null, + ): Either + + suspend fun getPaywalls(): Either, NetworkError> + + suspend fun getGeoInfo(): Either + + suspend fun getAssignments(): Either, NetworkError> +} 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 47f2d64b..99aa5b28 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 @@ -11,7 +11,6 @@ import android.net.NetworkCapabilities import android.os.Build import android.os.PowerManager import android.provider.Settings -import android.util.Log import androidx.core.content.ContextCompat import com.superwall.sdk.BuildConfig import com.superwall.sdk.Superwall @@ -20,13 +19,15 @@ 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.misc.then import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.network.JsonFactory -import com.superwall.sdk.network.Network +import com.superwall.sdk.network.SuperwallAPI 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.LatestGeoInfo +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.TotalPaywallViews import com.superwall.sdk.utilities.DateUtils import com.superwall.sdk.utilities.dateFormat @@ -50,8 +51,8 @@ enum class InterfaceStyle( class DeviceHelper( private val context: Context, - val storage: Storage, - val network: Network, + val storage: LocalStorage, + val network: SuperwallAPI, val factory: Factory, ) { interface Factory : @@ -86,7 +87,7 @@ class DeviceHelper( private val daysSinceLastPaywallView: Int? get() { - val fromDate = storage.get(LastPaywallView) ?: return null + val fromDate = storage.read(LastPaywallView) ?: return null val toDate = Date() val fromInstant = fromDate.toInstant() val toInstant = toDate.toInstant() @@ -96,7 +97,7 @@ class DeviceHelper( private val minutesSinceLastPaywallView: Int? get() { - val fromDate = storage.get(LastPaywallView) ?: return null + val fromDate = storage.read(LastPaywallView) ?: return null val toDate = Date() val fromInstant = fromDate.toInstant() val toInstant = toDate.toInstant() @@ -106,10 +107,10 @@ class DeviceHelper( private val totalPaywallViews: Int get() { - return storage.get(TotalPaywallViews) ?: 0 + return storage.read(TotalPaywallViews) ?: 0 } - private val geoInfo: MutableStateFlow = MutableStateFlow(null) + private val lastGeoInfo: MutableStateFlow = MutableStateFlow(storage.read(LatestGeoInfo)) val locale: String get() { @@ -123,7 +124,11 @@ class DeviceHelper( val packageInfo = context.packageManager.getPackageInfo(context.packageName, 0) packageInfo.versionName } catch (e: PackageManager.NameNotFoundException) { - Log.e("DeviceHelper", "Failed to load version info", e) + Logger.debug( + LogLevel.error, + LogScope.device, + "DeviceHelper: Failed to load version info - $e", + ) "" } @@ -406,7 +411,7 @@ class DeviceHelper( val geo = try { withTimeout(1.minutes) { - geoInfo.first { it != null } + lastGeoInfo.first { it != null } } } catch (e: Throwable) { Logger.debug( @@ -418,7 +423,8 @@ class DeviceHelper( ) null } - val capabilities: List = listOf(Capability.PaywallEventReceiver(), Capability.MultiplePaywallUrls) + val capabilities: List = + listOf(Capability.PaywallEventReceiver(), Capability.MultiplePaywallUrls) val deviceTemplate = DeviceTemplate( @@ -473,7 +479,8 @@ class DeviceHelper( ipContinent = geo?.continent, ipTimezone = geo?.timezone, capabilities = capabilities.map { it.name }, - capabilitiesConfig = capabilities.toJson(factory.json()), + capabilitiesConfig = + capabilities.toJson(factory.json()), platformWrapper = platformWrapper, platformWrapperVersion = platformWrapperVersion, ) @@ -481,9 +488,10 @@ class DeviceHelper( return deviceTemplate.toDictionary() } - suspend fun getGeoInfo() { - network.getGeoInfo().let { - geoInfo.value = it - } - } + suspend fun getGeoInfo() = + network + .getGeoInfo() + .then { + lastGeoInfo.value = it + } } diff --git a/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt b/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt index a8462aec..47790c95 100644 --- a/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt +++ b/superwall/src/main/java/com/superwall/sdk/network/session/CustomURLSession.kt @@ -3,209 +3,55 @@ package com.superwall.sdk.network.session import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.flatMap import com.superwall.sdk.misc.retrying -import com.superwall.sdk.models.SerializableEntity -import com.superwall.sdk.network.Endpoint -import kotlinx.coroutines.Dispatchers +import com.superwall.sdk.network.NetworkError +import com.superwall.sdk.network.NetworkRequestData +import com.superwall.sdk.network.RequestExecutor +import com.superwall.sdk.network.authHeader +import kotlinx.serialization.Serializable import kotlinx.serialization.json.Json -import kotlinx.serialization.json.JsonNamingStrategy -import java.net.HttpURLConnection - -sealed class NetworkError( - message: String, -) : Throwable(message) { - class Unknown : NetworkError("An unknown error occurred.") - - class NotAuthenticated : NetworkError("Unauthorized.") - - class Decoding : NetworkError("Decoding error.") - - class NotFound : NetworkError("Not found.") - - class InvalidUrl : NetworkError("URL invalid.") -} - -class CustomHttpUrlConnection { - val json = - Json { - ignoreUnknownKeys = true - namingStrategy = JsonNamingStrategy.SnakeCase - } -// -// suspend fun request(endpoint: String): String { -// var result = "" -// withContext(Dispatchers.IO) { -// try { -// val url = URL(endpoint) -// val connection = url.openConnection() as HttpURLConnection -// connection.requestMethod = "GET" -// connection.doInput = true -// connection.doOutput = true -// -// val auth = connection.getRequestProperty("Authorization") -// ?: throw NetworkError.NotAuthenticated() -// -// val startTime = System.currentTimeMillis() -// val responseCode = connection.responseCode -// if (responseCode == HttpURLConnection.HTTP_OK) { -// val reader = BufferedReader(InputStreamReader(connection.inputStream)) -// val response = StringBuilder() -// var responseLine: String? -// while (reader.readLine().also { responseLine = it } != null) { -// response.append(responseLine!!.trim { it <= ' ' }) -// } -// result = response.toString() -// } else { -// when (responseCode) { -// HttpURLConnection.HTTP_UNAUTHORIZED -> throw NetworkError.NotAuthenticated() -// HttpURLConnection.HTTP_NOT_FOUND -> throw NetworkError.NotFound() -// else -> throw NetworkError.Unknown() -// } -// } -// -// val requestDuration = System.currentTimeMillis() - startTime -// val requestId = connection.getHeaderField("x-request-id") ?: "unknown" -// -// // Log here the request completed -// -// } catch (e: Exception) { -// // Log here the request error -// throw NetworkError.Decoding() -// } -// } -// return result -// } - - @Throws(NetworkError::class) - suspend inline fun request( - endpoint: Endpoint, - noinline isRetryingCallback: (() -> Unit)? = null, - ): Response { - val request = endpoint.makeRequest() ?: throw NetworkError.Unknown() - - val auth = - request.getRequestProperty("Authorization") ?: throw NetworkError.NotAuthenticated() - - Logger.debug( - LogLevel.debug, - LogScope.network, - "Request Started", - mapOf( - "url" to (request.url?.toString() ?: "unknown"), - ), - ) - - val startTime = System.currentTimeMillis() - - val responseCode: Int = - retrying( - coroutineContext = Dispatchers.IO, - maxRetryCount = endpoint.retryCount, - isRetryingCallback = isRetryingCallback, - ) { - request.responseCode - } - - var responseMessage: String? = null - if (responseCode == HttpURLConnection.HTTP_OK) { - responseMessage = request.inputStream.bufferedReader().use { it.readText() } - request.disconnect() - } else { - println("!!!Error: ${request.responseCode}") - request.disconnect() - throw NetworkError.Unknown() - } - - val requestDuration = (System.currentTimeMillis() - startTime) / 1000.0 - val requestId = - try { - getRequestId(request, auth, requestDuration) - } catch (e: Throwable) { - throw NetworkError.Unknown() - } - - Logger.debug( - LogLevel.debug, - LogScope.network, - "Request Completed", - mapOf( - "request" to request.toString(), - "api_key" to auth, - "url" to (request.url?.toString() ?: "unknown"), - "request_id" to requestId, - "request_duration" to requestDuration, - ), - ) - - val value: Response? = - try { - this.json.decodeFromString(responseMessage) - } catch (e: Throwable) { - Logger.debug( - LogLevel.error, - LogScope.network, - "Request Error", - mapOf( - "request" to request.toString(), - "api_key" to auth, - "url" to (request.url?.toString() ?: "unknown"), - "message" to "Unable to decode response to type ${Response::class.simpleName}", - "info" to responseMessage, - "request_duration" to requestDuration, - ), - ) - println("!!!Error: ${e.message}") - throw NetworkError.Decoding() - } - - return value ?: throw NetworkError.Decoding() - } +class CustomHttpUrlConnection( + val json: Json, + val requestExecutor: RequestExecutor, +) { @Throws(NetworkError::class) - fun getRequestId( - request: HttpURLConnection, - auth: String, - requestDuration: Double, - ): String { - var requestId = "unknown" - - val id = request.getHeaderField("x-request-id") - if (id != null) { - requestId = id - } - - when (request.responseCode) { - HttpURLConnection.HTTP_UNAUTHORIZED -> { - Logger.debug( - LogLevel.error, - LogScope.network, - "Unable to Authenticate", - mapOf( - "request" to request.toString(), - "api_key" to auth, - "url" to request.url.toString(), - "request_id" to requestId, - "request_duration" to requestDuration, - ), - ) - throw NetworkError.NotAuthenticated() - } - HttpURLConnection.HTTP_NOT_FOUND -> { - Logger.debug( - LogLevel.error, - LogScope.network, - "Not Found", - mapOf( - "request" to request.toString(), - "api_key" to auth, - "url" to request.url.toString(), - "request_id" to requestId, - "request_duration" to requestDuration, - ), - ) - throw NetworkError.NotFound() + suspend inline fun request( + crossinline buildRequestData: suspend () -> NetworkRequestData, + retryCount: Int, + noinline isRetryingCallback: (suspend () -> Unit)? = null, + ): Either { + return retrying( + maxRetryCount = retryCount, + isRetryingCallback = isRetryingCallback, + ) { + val requestData = buildRequestData() + return@retrying requestExecutor.execute(requestData).flatMap { + try { + Either.Success( + this.json.decodeFromString( + it.responseMessage, + ), + ) + } catch (e: Throwable) { + Logger.debug( + LogLevel.error, + LogScope.network, + "Request Error", + mapOf( + "request" to it.toString(), + "api_key" to it.authHeader(), + "url" to (requestData.url?.toString() ?: "unknown"), + "message" to "Unable to decode response to type ${Response::class.simpleName}", + "info" to it.responseMessage, + "request_duration" to it.duration, + ), + ) + Either.Failure(NetworkError.Decoding(e)) + } } } - return requestId } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt index 970026f2..9261b7fd 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/manager/PaywallViewCache.kt @@ -19,7 +19,8 @@ class PaywallViewCache( private val activityProvider: ActivityProvider, private val deviceHelper: DeviceHelper, ) { - private val ctx: Context = activityProvider.getCurrentActivity() ?: appCtx + private val ctx: Context + get() = activityProvider.getCurrentActivity() ?: appCtx private var _activePaywallVcKey: String? = null private val loadingView: LoadingView = LoadingView(context = ctx) private val shimmerView: ShimmerView = ShimmerView(context = ctx) diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmHoldoutAssignment.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmHoldoutAssignment.kt index 44935328..3aa8f4ea 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmHoldoutAssignment.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmHoldoutAssignment.kt @@ -16,6 +16,6 @@ fun Superwall.confirmHoldoutAssignment( if (request.flags.type == PresentationRequestType.GetImplicitPresentationResult) return if (rulesOutcome.triggerResult !is InternalTriggerResult.Holdout) return rulesOutcome.confirmableAssignment?.let { - container.configManager.confirmAssignment(it) + container.assignments.confirmAssignment(it) } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmPaywallAssignment.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmPaywallAssignment.kt index ea321605..57c87c53 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmPaywallAssignment.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/ConfirmPaywallAssignment.kt @@ -29,6 +29,6 @@ fun Superwall.confirmPaywallAssignment( } confirmableAssignment?.let { - actualDependencyContainer.configManager.confirmAssignment(it) + actualDependencyContainer.assignments.confirmAssignment(it) } } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt index 5a67bf30..33959924 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/EvaluateRules.kt @@ -23,7 +23,7 @@ suspend fun Superwall.evaluateRules(request: PresentationRequest): RuleEvaluatio return if (eventData != null) { val ruleLogic = RuleLogic( - configManager = dependencyContainer.configManager, + assignments = dependencyContainer.assignments, storage = dependencyContainer.storage, factory = dependencyContainer, javascriptEvaluator = dependencyContainer.provideJavascriptEvaluator(context), diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt index 4e2c23d4..f2985536 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/GetExperiment.kt @@ -13,7 +13,7 @@ import com.superwall.sdk.paywall.presentation.internal.PresentationRequestType import com.superwall.sdk.paywall.presentation.internal.state.PaywallSkippedReason import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.presentation.rule_logic.RuleEvaluationOutcome -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.flow.MutableSharedFlow // Assuming you have definitions for all the classes and functions used in the below code. @@ -35,7 +35,7 @@ suspend fun Superwall.getExperiment( rulesOutcome: RuleEvaluationOutcome, debugInfo: Map, paywallStatePublisher: MutableSharedFlow? = null, - storage: Storage, + storage: LocalStorage, ): Experiment { val errorType: PresentationPipelineError diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt index 1833fe76..ddbf13fa 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/LogErrors.kt @@ -31,8 +31,6 @@ suspend fun Superwall.logErrors( track(trackedEvent) } } - } else { - track(InternalSuperwallEvent.ErrorThrown(Exception(error))) } Logger.debug( diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt index acd5d263..3e5e8fa6 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/internal/operators/WaitForSubsStatusAndConfig.kt @@ -10,215 +10,141 @@ import com.superwall.sdk.dependencies.DependencyContainer import com.superwall.sdk.logger.LogLevel import com.superwall.sdk.logger.LogScope import com.superwall.sdk.logger.Logger -import com.superwall.sdk.misc.Result import com.superwall.sdk.paywall.presentation.internal.InternalPresentationLogic import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatus import com.superwall.sdk.paywall.presentation.internal.PaywallPresentationRequestStatusReason import com.superwall.sdk.paywall.presentation.internal.PresentationRequest import com.superwall.sdk.paywall.presentation.internal.state.PaywallState import com.superwall.sdk.paywall.presentation.internal.userIsSubscribed -import kotlinx.coroutines.CancellationException -import kotlinx.coroutines.CompletableDeferred -import kotlinx.coroutines.CoroutineScope -import kotlinx.coroutines.Dispatchers -import kotlinx.coroutines.Job -import kotlinx.coroutines.async -import kotlinx.coroutines.delay +import kotlinx.coroutines.TimeoutCancellationException import kotlinx.coroutines.flow.MutableSharedFlow +import kotlinx.coroutines.flow.StateFlow import kotlinx.coroutines.flow.filter import kotlinx.coroutines.flow.first import kotlinx.coroutines.launch +import kotlinx.coroutines.withTimeout +import kotlin.time.Duration.Companion.seconds -// Kotlin version of `waitForSubsStatusAndConfig` function internal suspend fun Superwall.waitForSubsStatusAndConfig( request: PresentationRequest, paywallStatePublisher: MutableSharedFlow? = null, dependencyContainer: DependencyContainer? = null, ) { + @Suppress("NAME_SHADOWING") val dependencyContainer = dependencyContainer ?: this.dependencyContainer - val scope = CoroutineScope(Dispatchers.IO) - val subscriptionStatusTask = - getValueWithTimeout( - task = { - try { - request.flags.subscriptionStatus - .filter { it != SubscriptionStatus.UNKNOWN } - .first() - } catch (e: CancellationException) { - // Handle exception, cancel the task, and log timeout and fail the request - // Logic here... - scope.launch { - val trackedEvent = - InternalSuperwallEvent.PresentationRequest( - eventData = request.presentationInfo.eventData, - type = request.flags.type, - status = PaywallPresentationRequestStatus.Timeout, - statusReason = PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout(), - factory = dependencyContainer, - ) - track(trackedEvent) - } - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = - "Timeout: Superwall.instance.subscriptionStatus has been \"unknown\" for " + - "over 5 seconds resulting in a failure.", - ) - val error = - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 105, - title = "Timeout", - value = "The subscription status failed to change from \"unknown\".", - ) - paywallStatePublisher?.emit(PaywallState.PresentationError(error)) - throw PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout() - } - }, - timeout = 5000, - ) - subscriptionStatusTask.cancelTimeout() - val configState = dependencyContainer.configManager.configState - - if (subscriptionStatusTask.value == SubscriptionStatus.ACTIVE) { - if (configState.value.getSuccess()?.getConfig() == null) { - if (configState.value.getSuccess() is ConfigState.Retrieving) { - // If config is nil and we're still retrieving, wait for <=1 second. - // At 1s we cancel the task and check config again. - val result = - getValueWithTimeout( - task = { - try { - configState - .first { result -> - if (result is Result.Failure) throw result.error - result.getSuccess()?.getConfig() != null - } - } catch (e: CancellationException) { - scope.launch { - val trackedEvent = - InternalSuperwallEvent.PresentationRequest( - eventData = request.presentationInfo.eventData, - type = request.flags.type, - status = PaywallPresentationRequestStatus.Timeout, - statusReason = PaywallPresentationRequestStatusReason.NoConfig(), - factory = dependencyContainer, - ) - track(trackedEvent) - } - Logger.debug( - logLevel = LogLevel.info, - scope = LogScope.paywallPresentation, - message = "Timeout: The config could not be retrieved in a reasonable time for a subscribed user.", - ) - throw userIsSubscribed(paywallStatePublisher) - } - }, - timeout = 1000, - ) - result.cancelTimeout() - } else { - // If the user is subscribed and there's no config (for whatever reason), - // just call the feature block. - throw userIsSubscribed(paywallStatePublisher) - } - } else { - // If the user is subscribed and there is config, continue. + try { + withTimeout(5.seconds) { + request.flags.subscriptionStatus + .filter { it != SubscriptionStatus.UNKNOWN } + .first() } - } else { - try { - configState - .first { result -> - if (result is Result.Failure) throw result.error - result.getSuccess()?.getConfig() != null - } - } catch (e: Throwable) { - // If config completely dies, then throw an error - val error = - InternalPresentationLogic.presentationError( - domain = "SWKPresentationError", - code = 104, - title = "No Config", - value = "Trying to present paywall without the Superwall config.", + } catch (e: TimeoutCancellationException) { + // Handle exception, cancel the task, and log timeout and fail the request + ioScope.launch { + val trackedEvent = + InternalSuperwallEvent.PresentationRequest( + eventData = request.presentationInfo.eventData, + type = request.flags.type, + status = PaywallPresentationRequestStatus.Timeout, + statusReason = PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout(), + factory = dependencyContainer, ) - val state = PaywallState.PresentationError(error) - paywallStatePublisher?.emit(state) - throw PaywallPresentationRequestStatusReason.NoConfig() + track(trackedEvent) } + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = + "Timeout: Superwall.instance.subscriptionStatus has been \"unknown\" for " + + "over 5 seconds resulting in a failure.", + ) + val error = + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 105, + title = "Timeout", + value = "The subscription status failed to change from \"unknown\".", + ) + paywallStatePublisher?.emit(PaywallState.PresentationError(error)) + throw PaywallPresentationRequestStatusReason.SubscriptionStatusTimeout() } - // Get the identity. This may or may not wait depending on whether the dev - // specifically wants to wait for assignments. - dependencyContainer.identityManager.hasIdentity.first() -} + val configState = dependencyContainer.configManager.configState -private data class ValueResult( - val value: T, - private val delayJob: Job, - private val collectionJob: Job, -) { - fun cancelTimeout() { - delayJob.cancel() - collectionJob.cancel() + suspend fun StateFlow.configOrThrow() { + first { result -> + if (result is ConfigState.Failed) throw result.throwable + result is ConfigState.Retrieved + } } -} -/** - * Executes a given suspending task with a timeout. - * - * @param T The type of the result returned by the task. - * @param task The suspending function to execute. - * @param timeout Duration in milliseconds after which the task will be cancelled if not completed. - * @return [ValueResult] object encapsulating the result and the ability to cancel the timeout. - * @throws CancellationException if the task gets cancelled. - */ -private suspend fun getValueWithTimeout( - task: suspend () -> T, - timeout: Long, -): ValueResult { - val scope = CoroutineScope(Dispatchers.IO) - // Deferred object to hold the result of the 'task' - val valueResult = CompletableDeferred() - - // Start the given task in a separate coroutine and store its result in 'valueResult' - val valueTask = - scope - .async { - try { - val result = task() - valueResult.complete(result) - } catch (e: CancellationException) { - // Rethrow any cancellation exception to be handled by the caller - throw e + val subscriptionIsActive = subscriptionStatus.value == SubscriptionStatus.ACTIVE + when { + // Config is still retrieving, wait for <=1 second. + // At 1s we cancel the task and check config again. + subscriptionIsActive && + configState.value is ConfigState.Retrieving -> { + try { + withTimeout(1.seconds) { + configState + .configOrThrow() + } + } catch (e: TimeoutCancellationException) { + ioScope.launch { + val trackedEvent = + InternalSuperwallEvent.PresentationRequest( + eventData = request.presentationInfo.eventData, + type = request.flags.type, + status = PaywallPresentationRequestStatus.Timeout, + statusReason = PaywallPresentationRequestStatusReason.NoConfig(), + factory = dependencyContainer, + ) + track(trackedEvent) } + Logger.debug( + logLevel = LogLevel.info, + scope = LogScope.paywallPresentation, + message = "Timeout: The config could not be retrieved in a reasonable time for a subscribed user.", + ) + throw userIsSubscribed(paywallStatePublisher) } + } + // If the user is subscribed and there's no config (for whatever reason), + // just call the feature block. + subscriptionIsActive && + configState.value.getConfig() == null && + configState.value !is ConfigState.Retrieving -> { + throw userIsSubscribed(paywallStatePublisher) + } - // SharedFlow to act as a signal for when the timeout has occurred - val publisher = MutableSharedFlow() - - // Job to introduce the delay for the timeout - val delayJob = - scope.launch { - delay(timeout) // Wait for the timeout duration - publisher.emit(Unit) // Emit a signal indicating timeout has occurred + subscriptionIsActive && + configState.value.getConfig() != null -> { + // If the user is subscribed and there is config, continue. } - // Job to listen for the timeout signal and cancel the 'valueTask' - val collectionJob = - scope.launch { - publisher.collect { - valueTask.cancel() // Cancel the task - valueResult.cancel() // Cancel the result deferred + // User is not subscribed, so we either wait for config and show the paywall + // Or we show the paywall without config. + else -> { + try { + configState.configOrThrow() + } catch (e: Throwable) { + // If config completely dies, then throw an error + val error = + InternalPresentationLogic.presentationError( + domain = "SWKPresentationError", + code = 104, + title = "No Config", + value = "Trying to present paywall without the Superwall config.", + ) + val state = PaywallState.PresentationError(error) + paywallStatePublisher?.emit(state) + throw PaywallPresentationRequestStatusReason.NoConfig() } } - - // Await the result of the task and return it wrapped in a ValueResult - return try { - ValueResult(valueResult.await(), delayJob, collectionJob) - } catch (e: CancellationException) { - // Rethrow any cancellation exception to be handled by the caller - throw e } + +// Get the identity. This may or may not wait depending on whether the dev +// specifically wants to wait for assignments. + dependencyContainer.identityManager.hasIdentity.first() } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt index ba772e5d..7896ee14 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/RuleLogic.kt @@ -1,6 +1,6 @@ package com.superwall.sdk.paywall.presentation.rule_logic -import com.superwall.sdk.config.ConfigManager +import Assignments import com.superwall.sdk.dependencies.RuleAttributesFactory import com.superwall.sdk.models.assignment.ConfirmableAssignment import com.superwall.sdk.models.events.EventData @@ -13,7 +13,7 @@ import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.ExpressionEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.utilities.withErrorTrackingAsync data class RuleEvaluationOutcome( @@ -33,8 +33,8 @@ sealed class RuleMatchOutcome { } class RuleLogic( - private val configManager: ConfigManager, - private val storage: Storage, + private val assignments: Assignments, + private val storage: LocalStorage, private val factory: RuleAttributesFactory, private val javascriptEvaluator: JavascriptEvaluator, ) { @@ -63,7 +63,7 @@ class RuleLogic( var confirmableAssignment: ConfirmableAssignment? = null variant = confirmedAssignments[rule.experiment.id] - ?: configManager.unconfirmedAssignments[rule.experiment.id] + ?: assignments.unconfirmedAssignments[rule.experiment.id] ?: run { return@withErrorTrackingAsync RuleEvaluationOutcome( triggerResult = diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt index 0b19f200..f79204af 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluator.kt @@ -7,7 +7,7 @@ import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.javascript.JavascriptEvaluator import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import org.json.JSONObject interface ExpressionEvaluating { @@ -18,7 +18,7 @@ interface ExpressionEvaluating { } class ExpressionEvaluator( - private val storage: Storage, + private val storage: LocalStorage, private val factory: RuleAttributesFactory, private val evaluator: JavascriptEvaluator, ) : ExpressionEvaluating { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt index 1a2f0ace..07867e18 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/expression_evaluator/ExpressionEvaluatorParams.kt @@ -1,5 +1,8 @@ package com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import kotlinx.serialization.SerializationException import org.json.JSONObject import java.util.* @@ -18,7 +21,11 @@ data class LiquidExpressionEvaluatorParams( fun toBase64Input(): String? = try { val jsonString = toJson() - println("!! jsonString: $jsonString") + Logger.debug( + LogLevel.debug, + LogScope.all, + "!! jsonString: $jsonString", + ) jsonString.encodeToByteArray().toBase64() } catch (e: SerializationException) { null diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt index 86170b98..ce411b60 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/DefaultJavascriptEvalutor.kt @@ -10,7 +10,7 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Deferred import kotlinx.coroutines.async @@ -22,7 +22,7 @@ class DefaultJavascriptEvalutor( private val ioScope: CoroutineScope, private val uiScope: CoroutineScope, private val context: Context, - private val storage: Storage, + private val storage: LocalStorage, private val createSandbox: suspend (ctx: Context) -> JavaScriptSandbox = { JavaScriptSandbox.createConnectedInstanceAsync(it).await() }, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt index 63219655..c24e92e5 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/NoSupportedEvaluator.kt @@ -1,7 +1,8 @@ package com.superwall.sdk.paywall.presentation.rule_logic.javascript -import android.util.Log import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule @@ -11,7 +12,11 @@ object NoSupportedEvaluator : JavascriptEvaluator { base64params: String, rule: TriggerRule, ): TriggerRuleOutcome { - Log.e(LogLevel.warn.toString(), "Javascript sandbox and Webview are unsupported, nothing was evaluated.") + Logger.debug( + LogLevel.warn, + LogScope.jsEvaluator, + "Javascript sandbox and Webview are unsupported, nothing was evaluated.", + ) return TriggerRuleOutcome.noMatch( UnmatchedRule.Source.EXPRESSION, rule.experiment.id, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt index 7c46744e..38fe43df 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/SandboxJavascriptEvaluator.kt @@ -9,7 +9,7 @@ import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.SDKJS import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.guava.await import kotlinx.coroutines.runBlocking @@ -18,7 +18,7 @@ import kotlinx.coroutines.withContext internal class SandboxJavascriptEvaluator( private val jsSandbox: JavaScriptSandbox, private val ioScope: CoroutineScope, - private val storage: Storage, + private val storage: LocalStorage, ) : JavascriptEvaluator { override suspend fun evaluate( base64params: String, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt index 19e9820b..3ac5996a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/javascript/WebviewJavascriptEvaluator.kt @@ -11,7 +11,7 @@ import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule import com.superwall.sdk.paywall.presentation.rule_logic.expression_evaluator.SDKJS import com.superwall.sdk.paywall.presentation.rule_logic.tryToMatchOccurrence -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.CompletableDeferred import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -21,7 +21,7 @@ import kotlinx.coroutines.launch internal class WebviewJavascriptEvaluator( private val webView: WebView, private val mainScope: CoroutineScope, - private val storage: Storage, + private val storage: LocalStorage, ) : JavascriptEvaluator { init { webView.settings.javaScriptEnabled = true diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/utils.kt b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/utils.kt index b7d625d0..b8440a5d 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/utils.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/presentation/rule_logic/utils.kt @@ -6,10 +6,10 @@ import com.superwall.sdk.logger.Logger import com.superwall.sdk.models.triggers.TriggerRule import com.superwall.sdk.models.triggers.TriggerRuleOutcome import com.superwall.sdk.models.triggers.UnmatchedRule -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage internal suspend fun TriggerRule.tryToMatchOccurrence( - storage: Storage, + storage: LocalStorage, expressionMatched: Boolean, ): TriggerRuleOutcome { if (expressionMatched) { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt index 3fb978cd..2120bc47 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/request/PaywallRequestManager.kt @@ -5,6 +5,14 @@ import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.dependencies.ConfigManagerFactory import com.superwall.sdk.dependencies.DeviceInfoFactory +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger +import com.superwall.sdk.misc.Either +import com.superwall.sdk.misc.map +import com.superwall.sdk.misc.mapError +import com.superwall.sdk.misc.onError +import com.superwall.sdk.misc.then import com.superwall.sdk.models.events.EventData import com.superwall.sdk.models.paywall.Paywall import com.superwall.sdk.network.Network @@ -63,18 +71,17 @@ class PaywallRequestManager( val deferredTask = CompletableDeferred() activeTasks[requestHash] = deferredTask - try { - val rawPaywall = - getRawPaywall(request) - val finalPaywall = - addProducts(rawPaywall, request) - saveRequestHash(requestHash, finalPaywall, request.isDebuggerLaunched) + getRawPaywall(request) + .then { + val finalPaywall = + addProducts(it, request) + saveRequestHash(requestHash, finalPaywall, request.isDebuggerLaunched) - deferredTask.complete(finalPaywall) - } catch (error: Throwable) { - activeTasks.remove(requestHash) - deferredTask.completeExceptionally(error) - } + deferredTask.complete(finalPaywall) + }.onError { + activeTasks.remove(requestHash) + deferredTask.completeExceptionally(it) + } paywall = deferredTask.await() paywall = updatePaywall(paywall, request) @@ -104,58 +111,68 @@ class PaywallRequestManager( } } - suspend fun getRawPaywall(request: PaywallRequest): Paywall = + suspend fun getRawPaywall(request: PaywallRequest): Either = withContext(ioScope.coroutineContext) { - println("!!getRawPaywall - ${request.responseIdentifiers.paywallId}") - trackResponseStarted(event = request.eventData) - val paywall = getPaywallResponse(request) - - val paywallInfo = - paywall.getInfo( - fromEvent = request.eventData, - ) - trackResponseLoaded( - paywallInfo, - event = request.eventData, + Logger.debug( + LogLevel.debug, + LogScope.all, + "!!getRawPaywall - ${request.responseIdentifiers.paywallId}", ) - - return@withContext paywall + trackResponseStarted(event = request.eventData) + return@withContext getPaywallResponse(request) + .then { + val paywallInfo = + it.getInfo( + fromEvent = request.eventData, + ) + trackResponseLoaded( + paywallInfo, + event = request.eventData, + ) + } } - private suspend fun getPaywallResponse(request: PaywallRequest): Paywall = + private suspend fun getPaywallResponse(request: PaywallRequest): Either = withContext(ioScope.coroutineContext) { val responseLoadStartTime = Date() val paywallId = request.responseIdentifiers.paywallId val event = request.eventData - val paywall: Paywall = - try { - factory.makeStaticPaywall( + return@withContext ( + factory + .makeStaticPaywall( paywallId = paywallId, isDebuggerLaunched = request.isDebuggerLaunched, - ) ?: network.getPaywall( - identifier = paywallId, - event = event, - ) - } catch (error: Throwable) { - val errorResponse = - PaywallLogic.handlePaywallError( - error, - event, - ) - throw errorResponse - } - - println("!!getPaywallResponse - $paywallId - $paywall") - - paywall.experiment = request.responseIdentifiers.experiment - paywall.responseLoadingInfo.startAt = responseLoadStartTime - paywall.responseLoadingInfo.endAt = Date() - - println("!!getPaywallResponse - $paywallId - $paywall - ${paywall.experiment}") - - return@withContext paywall + )?.let { + Either.Success(it) + } ?: network.getPaywall( + identifier = paywallId, + event = event, + ) + ).then { + Logger.debug( + LogLevel.debug, + LogScope.all, + "!!getPaywallResponse - $paywallId - $it", + ) + }.map { + it.experiment = request.responseIdentifiers.experiment + it.responseLoadingInfo.startAt = responseLoadStartTime + it.responseLoadingInfo.endAt = Date() + it + }.then { + Logger.debug( + LogLevel.debug, + LogScope.all, + "!!getPaywallResponse - $paywallId - $it - ${it.experiment}", + ) + }.mapError { + PaywallLogic.handlePaywallError( + it, + event, + ) + } } // MARK: - Analytics diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt index 6be275a3..12ca85c9 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/PaywallView.kt @@ -51,7 +51,7 @@ import com.superwall.sdk.paywall.vc.web_view.SWWebView 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.storage.LocalStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers import kotlinx.coroutines.flow.MutableSharedFlow @@ -68,7 +68,7 @@ class PaywallView( var callback: PaywallViewDelegateAdapter? = null, val deviceHelper: DeviceHelper, val factory: Factory, - val storage: Storage, + val storage: LocalStorage, val paywallManager: PaywallManager, override val webView: SWWebView, private val loadingView: LoadingView = LoadingView(context), @@ -220,6 +220,18 @@ class PaywallView( loadingView.setupFor(this, loadingState) } + fun setupWith( + shimmerView: ShimmerView, + loadingView: LoadingView, + ) { + if (webView.parent == null) { + addView(webView) + } + this.shimmerView = shimmerView + this.loadingViewController = loadingView + layoutSubviews() + } + fun present( presenter: Activity, request: PresentationRequest, @@ -782,15 +794,12 @@ class PaywallView( } override fun openDeepLink(url: String) { - // TODO: Implement this -// dismiss( -// result = Result.DECLINED -// ) { -// eventDidOccur(PaywallWebEvent.OPENED_DEEP_LINK(url)) -// val context = this.context // Or replace with appropriate context if not inside an activity/fragment -// val deepLinkIntent = Intent(Intent.ACTION_VIEW, Uri.parse(url.toString())) -// context.startActivity(deepLinkIntent) -// } + // TODO add track paywall dismiss + var uri = Uri.parse(url) + eventDidOccur(PaywallWebEvent.OpenedDeepLink(uri)) + val context = encapsulatingActivity?.get() + val deepLinkIntent = Intent(Intent.ACTION_VIEW, uri) + context?.startActivity(deepLinkIntent) } @Deprecated("Will be removed in the upcoming versions, use presentBrowserInApp instead") 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 index c0f48f5e..041a1bf9 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/SuperwallPaywallActivity.kt @@ -32,6 +32,9 @@ import androidx.core.view.children import com.google.android.material.bottomsheet.BottomSheetBehavior import com.superwall.sdk.Superwall import com.superwall.sdk.dependencies.DeviceHelperFactory +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import com.superwall.sdk.misc.isLightColor import com.superwall.sdk.models.paywall.LocalNotification import com.superwall.sdk.models.paywall.PaywallPresentationStyle @@ -69,15 +72,16 @@ class SuperwallPaywallActivity : AppCompatActivity() { if (view.webView.parent == null) { view.addView(view.webView) } - val viewStorageViewModel = Superwall.instance.viewStore() + val viewStorageViewModel = Superwall.instance.dependencyContainer.makeViewStore() // If we started it directly and the view does not have shimmer and loading attached // We set them up for this PaywallView if (view.children.none { it is LoadingView || it is ShimmerView }) { - (viewStorageViewModel.retrieveView(LoadingView.TAG) as LoadingView) - .let { view.setupLoading(it) } - (viewStorageViewModel.retrieveView(ShimmerView.TAG) as ShimmerView) - .let { view.setupShimmer(it) } - view.layoutSubviews() + val loading = + (viewStorageViewModel.retrieveView(LoadingView.TAG) as LoadingView) + + val shimmer = + (viewStorageViewModel.retrieveView(ShimmerView.TAG) as ShimmerView) + view.setupWith(shimmer, loading) } val intent = Intent(context, SuperwallPaywallActivity::class.java).apply { @@ -109,11 +113,14 @@ class SuperwallPaywallActivity : AppCompatActivity() { private fun paywallView(): PaywallView? = contentView?.findViewWithTag(ACTIVE_PAYWALL_TAG) - private fun setupBottomSheetLayout(paywallView: PaywallView) { + private fun setupBottomSheetLayout( + paywallView: PaywallView, + isExpanded: Boolean, + ) { val activityView = layoutInflater.inflate(com.superwall.sdk.R.layout.activity_bottom_sheet, null) setContentView(activityView) - initBottomSheetBehavior() + initBottomSheetBehavior(isExpanded) val container = activityView.findViewById(com.superwall.sdk.R.id.container) activityView.setOnClickListener { finish() } @@ -121,9 +128,9 @@ class SuperwallPaywallActivity : AppCompatActivity() { container.requestLayout() } - private fun initBottomSheetBehavior() { - var bottomSheetBehavior = BottomSheetBehavior.from((contentView as ViewGroup).getChildAt(0)) - bottomSheetBehavior.halfExpandedRatio = 0.7f + private fun initBottomSheetBehavior(isExpanded: Boolean) { + val bottomSheetBehavior = BottomSheetBehavior.from((contentView as ViewGroup).getChildAt(0)) + bottomSheetBehavior.halfExpandedRatio = if (isExpanded) 0.95f else 0.7f // Expanded by default bottomSheetBehavior.state = BottomSheetBehavior.STATE_HALF_EXPANDED bottomSheetBehavior.skipCollapsed = true @@ -182,7 +189,17 @@ class SuperwallPaywallActivity : AppCompatActivity() { return } - val viewStorageViewModel = Superwall.instance.viewStore() + val viewStorageViewModel = + try { + Superwall.instance.dependencyContainer.makeViewStore() + } catch (e: Exception) { + Logger.debug( + LogLevel.error, + LogScope.paywallView, + "Cannot access viewStore or create view - has Superwall been initialised?", + ) + return + } val view = viewStorageViewModel.retrieveView(key) as? PaywallView ?: run { @@ -190,14 +207,15 @@ class SuperwallPaywallActivity : AppCompatActivity() { return } - val isBottomSheetStyle = presentationStyle == PaywallPresentationStyle.DRAWER + val isBottomSheetStyle = + presentationStyle == PaywallPresentationStyle.DRAWER || presentationStyle == PaywallPresentationStyle.MODAL (view.parent as? ViewGroup)?.removeView(view) view.tag = ACTIVE_PAYWALL_TAG view.encapsulatingActivity = WeakReference(this) // If it's a bottom sheet, we set activity as transparent and show the UI in a bottom sheet container if (isBottomSheetStyle && Build.VERSION.SDK_INT >= Build.VERSION_CODES.R) { - setupBottomSheetLayout(view) + setupBottomSheetLayout(view, presentationStyle == PaywallPresentationStyle.MODAL) } else { setContentView(view) } @@ -247,9 +265,7 @@ class SuperwallPaywallActivity : AppCompatActivity() { enableEdgeToEdge() } - PaywallPresentationStyle.MODAL -> { - // TODO: Not yet supported in Android - } + PaywallPresentationStyle.MODAL, PaywallPresentationStyle.NONE, PaywallPresentationStyle.DRAWER, null, diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt index a5a485fd..efb2b597 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/Survey/SurveyManager.kt @@ -27,7 +27,7 @@ import com.superwall.sdk.paywall.presentation.PaywallInfo import com.superwall.sdk.paywall.presentation.internal.state.PaywallResult import com.superwall.sdk.paywall.vc.PaywallView import com.superwall.sdk.paywall.vc.delegate.PaywallLoadingState -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import com.superwall.sdk.storage.SurveyAssignmentKey import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.Dispatchers @@ -45,7 +45,7 @@ object SurveyManager { loadingState: PaywallLoadingState, isDebuggerLaunched: Boolean, paywallInfo: PaywallInfo, - storage: Storage, + storage: LocalStorage, factory: TriggerFactory, completion: (SurveyPresentationResult) -> Unit, ) { @@ -75,7 +75,7 @@ object SurveyManager { if (!isDebuggerLaunched) { // Make sure we don't assess this survey with this assignment key again. - storage.save(survey.assignmentKey, SurveyAssignmentKey) + storage.write(SurveyAssignmentKey, survey.assignmentKey) } if (isHoldout) { 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 index 2881da5e..1d2c727f 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/ViewStorageViewModel.kt @@ -7,7 +7,7 @@ import java.util.concurrent.ConcurrentHashMap /* * Stores already loaded or preloaded paywalls * */ -internal class ViewStorageViewModel : +class ViewStorageViewModel : ViewModel(), ViewStorage { override val views = ConcurrentHashMap() 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 b5953f20..9f248496 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 @@ -99,6 +99,9 @@ class SWWebView( loadUrl = { loadUrl(it.url) }, + stopLoading = { + stopLoading() + }, ) this.webViewClient = client listenToWebviewClientEvents(this.webViewClient as DefaultWebviewClient) @@ -139,7 +142,11 @@ class SWWebView( // Use the new URL val urlString = newUri.toString() - println("SWWebView.loadUrl: $urlString") + Logger.debug( + LogLevel.debug, + LogScope.paywallView, + "SWWebView.loadUrl: $urlString", + ) super.loadUrl(urlString) } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt index c7f3227a..df196761 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewClientEvent.kt @@ -9,7 +9,7 @@ sealed class WebviewClientEvent { val webviewError: WebviewError, ) : WebviewClientEvent() - data object LoadingFallback : WebviewClientEvent() + object LoadingFallback : WebviewClientEvent() } sealed class WebviewError { @@ -27,7 +27,7 @@ sealed class WebviewError { override fun toString(): String = "The webview has attempted to load too many times." } - data object NoUrls : WebviewError() { + object NoUrls : WebviewError() { override fun toString() = "There were no paywall URLs provided." } diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt index 3415f4ca..4b93a62a 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/WebviewFallbackClient.kt @@ -23,6 +23,7 @@ internal class WebviewFallbackClient( private val ioScope: CoroutineScope, private val mainScope: CoroutineScope, private val loadUrl: (PaywallWebviewUrl) -> Unit, + private val stopLoading: () -> Unit, ) : DefaultWebviewClient(ioScope) { private class MaxAttemptsReachedException : Exception("Max attempts reached") @@ -32,6 +33,10 @@ internal class WebviewFallbackClient( private val untriedUrls = urls.toMutableSet() + /* + * The state of currently Loading URL, reset to None when URL is loaded + * */ + private sealed interface UrlState { object None : UrlState @@ -67,6 +72,9 @@ internal class WebviewFallbackClient( timeoutFlow.first { it is UrlState.PageStarted || it is UrlState.PageError } } } catch (e: TimeoutCancellationException) { + mainScope.launch { + stopLoading() + } timeoutFlow.update { UrlState.Timeout } } } @@ -81,12 +89,12 @@ internal class WebviewFallbackClient( view: WebView?, url: String?, ) { - super.onLoadResource(view, url) ioScope.launch { if (timeoutFlow.value == UrlState.Loading) { timeoutFlow.emit(UrlState.PageStarted) } } + super.onLoadResource(view, url) } internal fun nextUrl(): PaywallWebviewUrl { diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt index 04cc716a..3e9b5b8c 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessage.kt @@ -1,7 +1,9 @@ package com.superwall.sdk.paywall.vc.web_view import android.net.Uri -import android.util.Log +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger import org.json.JSONObject import java.net.URL @@ -57,7 +59,11 @@ sealed class PaywallMessage { } fun parseWrappedPaywallMessages(jsonString: String): WrappedPaywallMessages { - Log.d("SWWebViewInterface", jsonString) + Logger.debug( + LogLevel.debug, + LogScope.superwallCore, + "SWWebViewInterface $jsonString", + ) val jsonObject = JSONObject(jsonString) val version = jsonObject.optInt("version", 1) val payloadJson = jsonObject.getJSONObject("payload") diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt index 1fbe0f3b..abc1d8b1 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallMessageHandler.kt @@ -1,7 +1,7 @@ package com.superwall.sdk.paywall.vc.web_view.messaging import TemplateLogic -import android.util.Log +import android.net.Uri import android.webkit.JavascriptInterface import android.webkit.WebView import com.superwall.sdk.Superwall @@ -69,7 +69,11 @@ class PaywallMessageHandler( @JavascriptInterface fun postMessage(message: String) { // Print out the message to the console using Log.d - Log.d("SWWebViewInterface", message) + Logger.debug( + LogLevel.debug, + LogScope.superwallCore, + "SWWebViewInterface: $message", + ) // Attempt to parse the message to json // and print out the version number @@ -77,13 +81,21 @@ class PaywallMessageHandler( try { wrappedPaywallMessages = parseWrappedPaywallMessages(message) } catch (e: Throwable) { - Log.e("SWWebViewInterface", "Error parsing message", e) + Logger.debug( + LogLevel.debug, + LogScope.superwallCore, + "SWWebViewInterface: Error parsing message - $e", + ) return } // Loop through the messages and print out the event name for (paywallMessage in wrappedPaywallMessages.payload.messages) { - Log.d("SWWebViewInterface", paywallMessage.javaClass.simpleName) + Logger.debug( + LogLevel.debug, + LogScope.superwallCore, + "SWWebViewInterface: ${paywallMessage.javaClass.simpleName}", + ) handle(paywallMessage) } } @@ -122,7 +134,7 @@ class PaywallMessageHandler( is PaywallMessage.OpenUrl -> openUrl(message.url) is PaywallMessage.OpenUrlInBrowser -> openUrlInBrowser(message.url) - is PaywallMessage.OpenDeepLink -> openDeepLink(URL(message.url.toString())) + is PaywallMessage.OpenDeepLink -> openDeepLink(Uri.parse(message.url.toString())) is PaywallMessage.Restore -> restorePurchases() is PaywallMessage.Purchase -> purchaseProduct(withId = message.productId) is PaywallMessage.PaywallOpen -> { @@ -341,7 +353,7 @@ class PaywallMessageHandler( delegate?.presentBrowserExternal(url.toString()) } - private fun openDeepLink(url: URL) { + private fun openDeepLink(url: Uri) { detectHiddenPaywallEvent( "openDeepLink", mapOf("url" to url), diff --git a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt index c2bf9a05..76ac5390 100644 --- a/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt +++ b/superwall/src/main/java/com/superwall/sdk/paywall/vc/web_view/messaging/PaywallWebEvent.kt @@ -1,5 +1,6 @@ package com.superwall.sdk.paywall.vc.web_view.messaging +import android.net.Uri import kotlinx.serialization.SerialName import kotlinx.serialization.Serializable import org.json.JSONObject @@ -33,7 +34,7 @@ sealed class PaywallWebEvent { @SerialName("opened_deep_link") data class OpenedDeepLink( - val url: URL, + val url: Uri, ) : PaywallWebEvent() @SerialName("custom_placement") diff --git a/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt b/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt index 5f4a30f3..a4ae1464 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/Cache.kt @@ -17,6 +17,7 @@ import java.io.File class Cache( val context: Context, private val ioQueue: ExecutorCoroutineDispatcher = newSingleThreadContext(Cache.ioQueuePrefix), + private val json: Json, ) : CoroutineScope by CoroutineScope(ioQueue) { companion object { private const val userSpecificDocumentDirectoryPrefix = "com.superwall.document.userSpecific.Store" @@ -41,7 +42,7 @@ class Cache( var jsonString = "" try { jsonString = file.readText(Charsets.UTF_8) - data = Json.decodeFromString(storable.serializer, jsonString) + data = json.decodeFromString(storable.serializer, jsonString) data?.let { memCache[storable.key] = it } @@ -68,7 +69,7 @@ class Cache( launch { val file = File(storable.path(context = context)) - val jsonString = Json.encodeToString(storable.serializer, data) + val jsonString = json.encodeToString(storable.serializer, data) file.writeText(jsonString, Charsets.UTF_8) } } @@ -85,7 +86,7 @@ class Cache( //region Clean - fun cleanUserFiles() { + fun clean() { memCache.clear() cleanDiskCache() } diff --git a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt index fd08a7a0..ed1ae497 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/CacheKeys.kt @@ -2,6 +2,8 @@ package com.superwall.sdk.storage import android.content.Context import com.superwall.sdk.delegate.SubscriptionStatus +import com.superwall.sdk.models.config.Config +import com.superwall.sdk.models.geo.GeoInfo import com.superwall.sdk.models.serialization.AnySerializer import com.superwall.sdk.models.triggers.Experiment import com.superwall.sdk.models.triggers.ExperimentID @@ -240,6 +242,25 @@ internal object ErrorLog : Storable { override val serializer: KSerializer get() = ErrorTracking.ErrorOccurence.serializer() } + +internal object LatestConfig : Storable { + override val key: String + get() = "store.configCache" + override val directory: SearchPathDirectory + get() = SearchPathDirectory.CACHE + override val serializer: KSerializer + get() = Config.serializer() +} + +internal object LatestGeoInfo : Storable { + override val key: String + get() = "store.geoInfoCache" + override val directory: SearchPathDirectory + get() = SearchPathDirectory.CACHE + override val serializer: KSerializer + get() = GeoInfo.serializer() +} + //endregion // region Serializers diff --git a/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt new file mode 100644 index 00000000..e1431c4b --- /dev/null +++ b/superwall/src/main/java/com/superwall/sdk/storage/LocalStorage.kt @@ -0,0 +1,223 @@ +package com.superwall.sdk.storage + +import android.content.Context +import com.superwall.sdk.Superwall +import com.superwall.sdk.analytics.internal.TrackingResult +import com.superwall.sdk.analytics.internal.track +import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent +import com.superwall.sdk.analytics.internal.trackable.Trackable +import com.superwall.sdk.dependencies.DeviceHelperFactory +import com.superwall.sdk.dependencies.HasExternalPurchaseControllerFactory +import com.superwall.sdk.misc.sdkVersion +import com.superwall.sdk.models.triggers.Experiment +import com.superwall.sdk.models.triggers.ExperimentID +import com.superwall.sdk.storage.core_data.CoreDataManager +import kotlinx.coroutines.CoroutineScope +import kotlinx.coroutines.Dispatchers +import kotlinx.coroutines.launch +import kotlinx.coroutines.newSingleThreadContext +import kotlinx.coroutines.runBlocking +import kotlinx.serialization.json.Json +import java.util.Date + +open class LocalStorage( + context: Context, + private val json: Json, + private val factory: LocalStorage.Factory, + // / The disk cache. + private val cache: Cache = Cache(context = context, json = json), + // / The interface that manages core data. + val coreDataManager: CoreDataManager = CoreDataManager(context = context), +) : Storage { + interface Factory : + DeviceHelperFactory, + HasExternalPurchaseControllerFactory + + // / The API key, set on configure. + var apiKey: String = "" + + // / The API key for debugging, set when handling a deep link. + var debugKey: String = "" + + // / Indicates whether first seen has been tracked. + var didTrackFirstSeen: Boolean + get() = + runBlocking(queue) { + _didTrackFirstSeen + } + set(value) { + CoroutineScope(queue).launch { + _didTrackFirstSeen = value + } + } + private var _didTrackFirstSeen: Boolean = false + + // / Indicates whether first seen has been tracked. + var didTrackFirstSession: Boolean + get() = + runBlocking(queue) { + _didTrackFirstSession + } + set(value) { + CoroutineScope(queue).launch { + _didTrackFirstSession = value + } + } + private var _didTrackFirstSession: Boolean = false + + // / Indicates whether static config hasn't been called before. + // / + // / Users upgrading from older SDK versions will not have called static config. + // / This means that we'll need to wait for assignments before firing triggers. + var neverCalledStaticConfig: Boolean = false + + // / The confirmed assignments for the user loaded from the cache. + private var p_confirmedAssignments: Map? + get() = + runBlocking(queue) { + _confirmedAssignments + } + set(value) { + CoroutineScope(queue).launch { + _confirmedAssignments = value + } + } + private var _confirmedAssignments: Map? = null + + private val queue = newSingleThreadContext("com.superwall.storage") + + init { + _didTrackFirstSeen = cache.read(DidTrackFirstSeen) == true + + // If we've already tracked firstSeen, then it can't be the first session. Useful for those upgrading. + if (_didTrackFirstSeen) { + _didTrackFirstSession = true + } else { + _didTrackFirstSession = cache.read(DidTrackFirstSession) == true + } + } + + fun configure(apiKey: String) { + updateSdkVersion() + this.apiKey = apiKey + } + + // / Checks to see whether a user has upgraded from normal to static config. + // / This blocks triggers until assignments is returned. + private fun updateSdkVersion() { + val actualSdkVersion = sdkVersion + val previousSdkVersion = read(SdkVersion) + + if (actualSdkVersion != previousSdkVersion) { + write(SdkVersion, actualSdkVersion) + } + + if (previousSdkVersion == null) { + neverCalledStaticConfig = true + } + } + + // / Clears data that is user specific. + fun reset() { + coreDataManager.deleteAllEntities() + cache.clean() + + CoroutineScope(queue).launch { + _confirmedAssignments = null + _didTrackFirstSeen = false + } + recordFirstSeenTracked() + } + + //region Custom + + // / Tracks and stores first seen for the user. + fun recordFirstSeenTracked() { + CoroutineScope(queue).launch { + if (_didTrackFirstSeen) return@launch + + CoroutineScope(Dispatchers.IO).launch { + Superwall.instance.track(InternalSuperwallEvent.FirstSeen()) + } + + write(DidTrackFirstSeen, true) + _didTrackFirstSeen = true + } + } + + fun recordFirstSessionTracked() { + CoroutineScope(queue).launch { + if (_didTrackFirstSession) return@launch + + write(DidTrackFirstSession, true) + _didTrackFirstSession = true + } + } + + // / Records the app install + fun recordAppInstall(trackEvent: suspend (Trackable) -> TrackingResult) { + val didTrackAppInstall = read(DidTrackAppInstall) ?: false + if (didTrackAppInstall) { + return + } + + val hasExternalPurchaseController = factory.makeHasExternalPurchaseController() + val deviceInfo = factory.makeDeviceInfo() + + CoroutineScope(Dispatchers.IO).launch { + val event = + InternalSuperwallEvent.AppInstall( + appInstalledAtString = deviceInfo.appInstalledAtString, + hasExternalPurchaseController = hasExternalPurchaseController, + ) + trackEvent(event) + } + write(DidTrackAppInstall, true) + } + + open fun clearCachedSessionEvents() { + cache.delete(Transactions) + } + + fun trackPaywallOpen() { + val totalPaywallViews = read(TotalPaywallViews) ?: 0 + write(TotalPaywallViews, totalPaywallViews + 1) + write(LastPaywallView, Date()) + } + + open fun saveConfirmedAssignments(assignments: Map) { + write(ConfirmedAssignments, assignments) + p_confirmedAssignments = assignments + } + + open fun getConfirmedAssignments(): Map { + p_confirmedAssignments?.let { + return it + } ?: run { + val assignments = read(ConfirmedAssignments) ?: emptyMap() + p_confirmedAssignments = assignments + return assignments + } + } + + //endregion + + //region Cache Reading & Writing + + override fun delete(storable: Storable) = cache.delete(storable) + + override fun clean() { + reset() + } + + override fun read(storable: Storable): T? = cache.read(storable) + + override fun write( + storable: Storable, + data: T, + ) { + cache.write(storable, data = data) + } + + //endregion +} diff --git a/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt b/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt index 5fa56331..cc3c8d42 100644 --- a/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt +++ b/superwall/src/main/java/com/superwall/sdk/storage/Storage.kt @@ -1,214 +1,15 @@ package com.superwall.sdk.storage -import android.content.Context -import com.superwall.sdk.Superwall -import com.superwall.sdk.analytics.internal.TrackingResult -import com.superwall.sdk.analytics.internal.track -import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent -import com.superwall.sdk.analytics.internal.trackable.Trackable -import com.superwall.sdk.dependencies.DeviceHelperFactory -import com.superwall.sdk.dependencies.HasExternalPurchaseControllerFactory -import com.superwall.sdk.misc.sdkVersion -import com.superwall.sdk.models.triggers.Experiment -import com.superwall.sdk.models.triggers.ExperimentID -import com.superwall.sdk.storage.core_data.CoreDataManager -import kotlinx.coroutines.* -import kotlinx.coroutines.launch -import java.util.Date +interface Storage { + fun read(storable: Storable): T? -open class Storage( - context: Context, - private val factory: Storage.Factory, - // / The disk cache. - private val cache: Cache = Cache(context = context), - // / The interface that manages core data. - val coreDataManager: CoreDataManager = CoreDataManager(context = context), -) { - interface Factory : - DeviceHelperFactory, - HasExternalPurchaseControllerFactory - - // / The API key, set on configure. - var apiKey: String = "" - - // / The API key for debugging, set when handling a deep link. - var debugKey: String = "" - - // / Indicates whether first seen has been tracked. - var didTrackFirstSeen: Boolean - get() = - runBlocking(queue) { - _didTrackFirstSeen - } - set(value) { - CoroutineScope(queue).launch { - _didTrackFirstSeen = value - } - } - private var _didTrackFirstSeen: Boolean = false - - // / Indicates whether first seen has been tracked. - var didTrackFirstSession: Boolean - get() = - runBlocking(queue) { - _didTrackFirstSession - } - set(value) { - CoroutineScope(queue).launch { - _didTrackFirstSession = value - } - } - private var _didTrackFirstSession: Boolean = false - - // / Indicates whether static config hasn't been called before. - // / - // / Users upgrading from older SDK versions will not have called static config. - // / This means that we'll need to wait for assignments before firing triggers. - var neverCalledStaticConfig: Boolean = false - - // / The confirmed assignments for the user loaded from the cache. - private var p_confirmedAssignments: Map? - get() = - runBlocking(queue) { - _confirmedAssignments - } - set(value) { - CoroutineScope(queue).launch { - _confirmedAssignments = value - } - } - private var _confirmedAssignments: Map? = null - - private val queue = newSingleThreadContext("com.superwall.storage") - - init { - _didTrackFirstSeen = cache.read(DidTrackFirstSeen) == true - - // If we've already tracked firstSeen, then it can't be the first session. Useful for those upgrading. - if (_didTrackFirstSeen) { - _didTrackFirstSession = true - } else { - _didTrackFirstSession = cache.read(DidTrackFirstSession) == true - } - } - - fun configure(apiKey: String) { - updateSdkVersion() - this.apiKey = apiKey - } - - // / Checks to see whether a user has upgraded from normal to static config. - // / This blocks triggers until assignments is returned. - private fun updateSdkVersion() { - val actualSdkVersion = sdkVersion - val previousSdkVersion = get(SdkVersion) - - if (actualSdkVersion != previousSdkVersion) { - save(actualSdkVersion, SdkVersion) - } - - if (previousSdkVersion == null) { - neverCalledStaticConfig = true - } - } - - // / Clears data that is user specific. - fun reset() { - coreDataManager.deleteAllEntities() - cache.cleanUserFiles() - - CoroutineScope(queue).launch { - _confirmedAssignments = null - _didTrackFirstSeen = false - } - recordFirstSeenTracked() - } - - //region Custom - - // / Tracks and stores first seen for the user. - fun recordFirstSeenTracked() { - CoroutineScope(queue).launch { - if (_didTrackFirstSeen) return@launch - - CoroutineScope(Dispatchers.IO).launch { - Superwall.instance.track(InternalSuperwallEvent.FirstSeen()) - } - - save(true, DidTrackFirstSeen) - _didTrackFirstSeen = true - } - } - - fun recordFirstSessionTracked() { - CoroutineScope(queue).launch { - if (_didTrackFirstSession) return@launch - - save(true, DidTrackFirstSession) - _didTrackFirstSession = true - } - } - - // / Records the app install - fun recordAppInstall(trackEvent: suspend (Trackable) -> TrackingResult) { - val didTrackAppInstall = get(DidTrackAppInstall) ?: false - if (didTrackAppInstall) { - return - } - - val hasExternalPurchaseController = factory.makeHasExternalPurchaseController() - val deviceInfo = factory.makeDeviceInfo() - - CoroutineScope(Dispatchers.IO).launch { - val event = - InternalSuperwallEvent.AppInstall( - appInstalledAtString = deviceInfo.appInstalledAtString, - hasExternalPurchaseController = hasExternalPurchaseController, - ) - trackEvent(event) - } - save(true, DidTrackAppInstall) - } - - open fun clearCachedSessionEvents() { - cache.delete(Transactions) - } - - fun trackPaywallOpen() { - val totalPaywallViews = get(TotalPaywallViews) ?: 0 - save(totalPaywallViews + 1, TotalPaywallViews) - save(Date(), LastPaywallView) - } - - open fun saveConfirmedAssignments(assignments: Map) { - save(assignments, ConfirmedAssignments) - p_confirmedAssignments = assignments - } - - open fun getConfirmedAssignments(): Map { - p_confirmedAssignments?.let { - return it - } ?: run { - val assignments = get(ConfirmedAssignments) ?: emptyMap() - p_confirmedAssignments = assignments - return assignments - } - } - - //endregion - - //region Cache Reading & Writing - - fun remove(storable: Storable) = cache.delete(storable) - - fun get(storable: Storable): T? = cache.read(storable) - - fun save( - data: T, + fun write( storable: Storable, - ) { - cache.write(storable, data = data) + data: T, + ) + + fun delete(storable: Storable) { } - //endregion + fun clean() } diff --git a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt index df2adabe..802da74b 100644 --- a/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt +++ b/superwall/src/main/java/com/superwall/sdk/store/transactions/TransactionManager.kt @@ -70,7 +70,11 @@ class TransactionManager( } val rawStoreProduct = product.rawStoreProduct - println("!!! Purchasing product ${rawStoreProduct.hasFreeTrial}") + Logger.debug( + LogLevel.debug, + LogScope.paywallTransactions, + "!!! Purchasing product ${rawStoreProduct.hasFreeTrial}", + ) val productDetails = rawStoreProduct.underlyingProductDetails val activity = activityProvider.getCurrentActivity() ?: return diff --git a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt index 5ec68173..f147aaca 100644 --- a/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt +++ b/superwall/src/main/java/com/superwall/sdk/utilities/ErrorTracking.kt @@ -4,7 +4,7 @@ import com.superwall.sdk.Superwall import com.superwall.sdk.analytics.internal.track import com.superwall.sdk.analytics.internal.trackable.InternalSuperwallEvent import com.superwall.sdk.storage.ErrorLog -import com.superwall.sdk.storage.Storage +import com.superwall.sdk.storage.LocalStorage import kotlinx.coroutines.CoroutineScope import kotlinx.coroutines.launch import kotlinx.serialization.SerialName @@ -33,7 +33,7 @@ internal interface ErrorTracking { **/ internal class ErrorTracker( scope: CoroutineScope, - private val cache: Storage, + private val cache: LocalStorage, private val track: suspend (InternalSuperwallEvent.ErrorThrown) -> Unit = { Superwall.instance.track( it, @@ -41,7 +41,7 @@ internal class ErrorTracker( }, ) : ErrorTracking { init { - val exists = cache.get(ErrorLog) + val exists = cache.read(ErrorLog) if (exists != null) { scope.launch { track( @@ -51,7 +51,7 @@ internal class ErrorTracker( exists.timestamp, ), ) - cache.remove(ErrorLog) + cache.delete(ErrorLog) } } } @@ -63,14 +63,18 @@ internal class ErrorTracker( stacktrace = throwable.stackTraceToString(), timestamp = System.currentTimeMillis(), ) - cache.save(errorOccurence, ErrorLog) + cache.write(ErrorLog, errorOccurence) } } // Utility methods and closures for error tracking internal fun Superwall.trackError(e: Throwable) { - dependencyContainer.errorTracker.trackError(e) + try { + dependencyContainer.errorTracker.trackError(e) + } catch (e: Exception) { + throw e + } } internal fun withErrorTracking(block: () -> Unit) { diff --git a/superwall/src/main/java/com/superwall/sdk/view/SWWebViewInterface.kt b/superwall/src/main/java/com/superwall/sdk/view/SWWebViewInterface.kt index a12c8853..4cee65f8 100644 --- a/superwall/src/main/java/com/superwall/sdk/view/SWWebViewInterface.kt +++ b/superwall/src/main/java/com/superwall/sdk/view/SWWebViewInterface.kt @@ -1,11 +1,13 @@ package com.superwall.sdk.view import android.content.Context -import android.util.Log import android.webkit.JavascriptInterface import com.superwall.sdk.deprecated.PaywallMessage import com.superwall.sdk.deprecated.WrappedPaywallMessages import com.superwall.sdk.deprecated.parseWrappedPaywallMessages +import com.superwall.sdk.logger.LogLevel +import com.superwall.sdk.logger.LogScope +import com.superwall.sdk.logger.Logger interface PaywallMessageDelegate { fun didReceiveMessage(message: PaywallMessage) @@ -20,7 +22,11 @@ class SWWebViewInterface( @JavascriptInterface fun postMessage(message: String) { // Print out the message to the console using Log.d - Log.d("SWWebViewInterface", message) + Logger.debug( + LogLevel.debug, + LogScope.superwallCore, + "SWWebViewInterface: $message", + ) // Attempt to parse the message to json // and print out the version number @@ -28,13 +34,21 @@ class SWWebViewInterface( try { wrappedPaywallMessages = parseWrappedPaywallMessages(message) } catch (e: Throwable) { - Log.e("SWWebViewInterface", "Error parsing message", e) + Logger.debug( + LogLevel.error, + LogScope.superwallCore, + "SWWebViewInterface: Error parsing message$e", + ) return } // Loop through the messages and print out the event name for (paywallMessage in wrappedPaywallMessages.payload.messages) { - Log.d("SWWebViewInterface", paywallMessage.javaClass.simpleName) + Logger.debug( + LogLevel.debug, + LogScope.superwallCore, + "SWWebViewInterface:" + paywallMessage.javaClass.simpleName, + ) delegate.didReceiveMessage(paywallMessage) } } diff --git a/superwall/src/main/res/drawable/rounded_shape.xml b/superwall/src/main/res/drawable/rounded_shape.xml new file mode 100644 index 00000000..e5a03d25 --- /dev/null +++ b/superwall/src/main/res/drawable/rounded_shape.xml @@ -0,0 +1,7 @@ + + + + + + diff --git a/superwall/src/main/res/layout/activity_bottom_sheet.xml b/superwall/src/main/res/layout/activity_bottom_sheet.xml index 1f7d4115..a563e916 100644 --- a/superwall/src/main/res/layout/activity_bottom_sheet.xml +++ b/superwall/src/main/res/layout/activity_bottom_sheet.xml @@ -9,6 +9,8 @@ android:layout_height="match_parent" android:layout_gravity="bottom" android:id="@+id/container" + android:background="@drawable/rounded_shape" + android:clipToOutline="true" app:behavior_hideable="true" app:layout_behavior="com.google.android.material.bottomsheet.BottomSheetBehavior"> diff --git a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt index 709b51bb..103e0df6 100644 --- a/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt +++ b/superwall/src/test/java/com/superwall/sdk/products/ProductFetcherTest.kt @@ -341,8 +341,9 @@ class ProductFetcherInstrumentedTest { val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) - val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.getDefault()) + val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.US) val formattedDate = dateIn30Days.format(dateFormatter) + println("Comparing -${storeProduct.trialPeriodEndDateString}- with -$formattedDate-") assert(storeProduct.trialPeriodEndDateString == formattedDate) } @@ -391,7 +392,7 @@ class ProductFetcherInstrumentedTest { val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) - val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.getDefault()) + val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.US) val formattedDate = dateIn30Days.format(dateFormatter) assert(storeProduct.trialPeriodEndDateString == formattedDate) } @@ -441,7 +442,7 @@ class ProductFetcherInstrumentedTest { val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) - val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.getDefault()) + val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.US) val formattedDate = dateIn30Days.format(dateFormatter) assert(storeProduct.trialPeriodEndDateString == formattedDate) } @@ -493,7 +494,7 @@ class ProductFetcherInstrumentedTest { val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) - val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.getDefault()) + val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.US) val formattedDate = dateIn30Days.format(dateFormatter) assert(storeProduct.trialPeriodEndDateString == formattedDate) } @@ -546,7 +547,7 @@ class ProductFetcherInstrumentedTest { val currentDate = LocalDate.now() val dateIn30Days = currentDate.plusMonths(1) - val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.getDefault()) + val dateFormatter = DateTimeFormatter.ofPattern(DateUtils.MMM_dd_yyyy, Locale.US) val formattedDate = dateIn30Days.format(dateFormatter) assert(storeProduct.trialPeriodEndDateString == formattedDate) }