-
Notifications
You must be signed in to change notification settings - Fork 0
feat: add Purchasely SDK mode setting (full/paywallObserver) with restart #1
New issue
Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.
By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.
Already on GitHub? Sign in to your account
base: main
Are you sure you want to change the base?
Changes from all commits
File filter
Filter by extension
Conversations
Jump to
Diff view
Diff view
There are no files selected for viewing
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -7,9 +7,9 @@ A cocktail discovery app demonstrating a production-quality [Purchasely](https:/ | |
| | Feature | Android | iOS | Placement | | ||
| |---------|---------|-----|-----------| | ||
| | Onboarding paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `onboarding` | | ||
| | Recipe detail paywall | `presentationView` | `presentationController` | `recipe_detail` | | ||
| | Favorites paywall | `presentationView` | `presentationController` | `favorites` | | ||
| | Filters paywall | `presentationView` | `presentationController` | `filters` | | ||
| | Recipe detail paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `recipe_detail` | | ||
| | Favorites paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `favorites` | | ||
| | Filters paywall | `fetchPresentation` + `display` | `fetchPresentation` + `display` | `filters` | | ||
| | User login/logout | `userLogin` / `userLogout` | `userLogin` / `userLogout` | - | | ||
| | Restore purchases | `restoreAllProducts` | `restoreAllProducts` | - | | ||
| | User attributes | `setUserAttribute` / `incrementUserAttribute` | `setUserAttribute` / `incrementUserAttribute` | - | | ||
|
|
@@ -110,6 +110,16 @@ Shaker/ | |
| └── Podfile | ||
| ``` | ||
|
|
||
| ## Known Constraints | ||
|
|
||
| - Purchasely placements and plans must be configured in Console before paywalls can be displayed. | ||
| - A public demo Purchasely API key is hardcoded by default for quick startup. | ||
| - You can override it with local config files: | ||
| - Android: `android/local.properties` (`purchasely.apiKey=...`) | ||
| - iOS: `ios/Config.xcconfig` (`PURCHASELY_API_KEY = ...`) | ||
| - Paywall flows require `fetchPresentation + display()`. Do not use convenience APIs like `presentationView` or `presentationController`. | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| - Cocktail catalog browsing works offline from bundled JSON, but paywall fetch requires network access. | ||
|
|
||
| ## Links | ||
|
|
||
| - [Purchasely Documentation](https://docs.purchasely.com/) | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -26,8 +26,7 @@ android { | |
| buildConfigField( | ||
| "String", | ||
| "PURCHASELY_API_KEY", | ||
| "\"6cda6b92-d63c-4444-bd55-5a164c989bd4\"" | ||
| //"\"${localProperties.getProperty("purchasely.apiKey", "")}\"" | ||
| "\"${localProperties.getProperty("purchasely.apiKey", "6cda6b92-d63c-4444-bd55-5a164c989bd4")}\"" | ||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The change to use "${localProperties.getProperty("purchasely.apiKey", "6cda6b92-d63c-4444-bd55-5a164c989bd4")}" |
||
| ) | ||
| } | ||
|
|
||
|
|
||
| Original file line number | Diff line number | Diff line change | ||||||||||||
|---|---|---|---|---|---|---|---|---|---|---|---|---|---|---|
|
|
@@ -4,13 +4,13 @@ import android.app.Application | |||||||||||||
| import android.content.Intent | ||||||||||||||
| import android.net.Uri | ||||||||||||||
| import android.util.Log | ||||||||||||||
| import com.purchasely.shaker.data.PurchaselySdkMode | ||||||||||||||
| import com.purchasely.shaker.data.PremiumManager | ||||||||||||||
| import com.purchasely.shaker.di.appModule | ||||||||||||||
| import io.purchasely.ext.EventListener | ||||||||||||||
| import io.purchasely.ext.LogLevel | ||||||||||||||
| import io.purchasely.ext.PLYEvent | ||||||||||||||
| import io.purchasely.ext.PLYPresentationAction | ||||||||||||||
| import io.purchasely.ext.PLYRunningMode | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||||||||||||||
| import io.purchasely.ext.Purchasely | ||||||||||||||
| import io.purchasely.google.GoogleStore | ||||||||||||||
| import org.koin.android.ext.android.inject | ||||||||||||||
|
|
@@ -31,16 +31,18 @@ class ShakerApp : Application() { | |||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private fun initPurchasely() { | ||||||||||||||
| val selectedMode = getSdkModeFromStorage() | ||||||||||||||
|
|
||||||||||||||
| Purchasely.Builder(this) | ||||||||||||||
| .apiKey("6cda6b92-d63c-4444-bd55-5a164c989bd4") | ||||||||||||||
| .logLevel(LogLevel.DEBUG) | ||||||||||||||
| .readyToOpenDeeplink(true) | ||||||||||||||
| .runningMode(PLYRunningMode.Full) | ||||||||||||||
| .runningMode(selectedMode.runningMode) | ||||||||||||||
| .stores(listOf(GoogleStore())) | ||||||||||||||
| .build() | ||||||||||||||
| .start { isConfigured, error -> | ||||||||||||||
| if (isConfigured) { | ||||||||||||||
| Log.d(TAG, "[Shaker] Purchasely SDK configured successfully") | ||||||||||||||
| Log.d(TAG, "[Shaker] Purchasely SDK configured successfully (mode: ${selectedMode.label})") | ||||||||||||||
|
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. Updating the log message to include the selected SDK mode provides more context during debugging and startup, which is very helpful.
Suggested change
|
||||||||||||||
| val premiumManager: PremiumManager by inject() | ||||||||||||||
| premiumManager.refreshPremiumStatus() | ||||||||||||||
| } | ||||||||||||||
|
|
@@ -77,6 +79,24 @@ class ShakerApp : Application() { | |||||||||||||
| } | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| fun restartPurchaselySdk() { | ||||||||||||||
| Log.d(TAG, "[Shaker] Restarting Purchasely SDK") | ||||||||||||||
| Purchasely.close() | ||||||||||||||
| initPurchasely() | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| private fun getSdkModeFromStorage(): PurchaselySdkMode { | ||||||||||||||
| val prefs = getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, MODE_PRIVATE) | ||||||||||||||
| val storedMode = prefs.getString(PurchaselySdkMode.KEY, null) | ||||||||||||||
| val resolvedMode = PurchaselySdkMode.fromStorage(storedMode) | ||||||||||||||
|
|
||||||||||||||
| if (storedMode != resolvedMode.storageValue) { | ||||||||||||||
| prefs.edit().putString(PurchaselySdkMode.KEY, resolvedMode.storageValue).apply() | ||||||||||||||
| } | ||||||||||||||
|
Comment on lines
+93
to
+95
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This block ensures that if the stored mode is different from the resolved mode (e.g., if the stored value was invalid and defaulted), the correct default value is persisted back to storage. This maintains data consistency.
Suggested change
|
||||||||||||||
|
|
||||||||||||||
| return resolvedMode | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| companion object { | ||||||||||||||
| private const val TAG = "ShakerApp" | ||||||||||||||
| } | ||||||||||||||
|
|
||||||||||||||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
| @@ -0,0 +1,30 @@ | ||
| package com.purchasely.shaker.data | ||
|
|
||
| import io.purchasely.ext.PLYRunningMode | ||
|
|
||
| enum class PurchaselySdkMode( | ||
| val storageValue: String, | ||
| val label: String, | ||
| val runningMode: PLYRunningMode | ||
| ) { | ||
| PAYWALL_OBSERVER( | ||
| storageValue = "paywallObserver", | ||
| label = "Paywall Observer", | ||
| runningMode = PLYRunningMode.PaywallObserver | ||
| ), | ||
| FULL( | ||
| storageValue = "full", | ||
| label = "Full", | ||
| runningMode = PLYRunningMode.Full | ||
| ); | ||
|
|
||
| companion object { | ||
| const val PREFERENCES_NAME = "shaker_settings" | ||
| const val KEY = "purchasely_sdk_mode" | ||
| val DEFAULT = PAYWALL_OBSERVER | ||
|
|
||
| fun fromStorage(value: String?): PurchaselySdkMode { | ||
| return values().firstOrNull { it.storageValue == value } ?: DEFAULT | ||
| } | ||
| } | ||
| } | ||
|
Comment on lines
+1
to
+30
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -15,8 +15,8 @@ import androidx.compose.foundation.rememberScrollState | |
| import androidx.compose.foundation.verticalScroll | ||
| import androidx.compose.material.icons.Icons | ||
| import androidx.compose.material.icons.filled.Person | ||
| import androidx.compose.material3.AlertDialog | ||
| import androidx.compose.material3.Button | ||
| import androidx.compose.material3.ButtonDefaults | ||
| import androidx.compose.material3.HorizontalDivider | ||
| import androidx.compose.material3.Icon | ||
| import androidx.compose.material3.MaterialTheme | ||
|
|
@@ -27,6 +27,7 @@ import androidx.compose.material3.SegmentedButtonDefaults | |
| import androidx.compose.material3.SingleChoiceSegmentedButtonRow | ||
| import androidx.compose.material3.Switch | ||
| import androidx.compose.material3.Text | ||
| import androidx.compose.material3.TextButton | ||
| import androidx.compose.runtime.Composable | ||
| import androidx.compose.runtime.LaunchedEffect | ||
| import androidx.compose.runtime.collectAsState | ||
|
|
@@ -38,6 +39,7 @@ import androidx.compose.ui.Alignment | |
| import androidx.compose.ui.Modifier | ||
| import androidx.compose.ui.platform.LocalContext | ||
| import androidx.compose.ui.unit.dp | ||
| import com.purchasely.shaker.data.PurchaselySdkMode | ||
| import io.purchasely.ext.PLYPresentationType | ||
| import io.purchasely.ext.PLYProductViewResult | ||
| import io.purchasely.ext.Purchasely | ||
|
|
@@ -51,6 +53,8 @@ fun SettingsScreen( | |
| val isPremium by viewModel.isPremium.collectAsState() | ||
| val restoreMessage by viewModel.restoreMessage.collectAsState() | ||
| val themeMode by viewModel.themeMode.collectAsState() | ||
| val sdkMode by viewModel.sdkMode.collectAsState() | ||
| val sdkModeChangeAlert by viewModel.sdkModeChangeAlert.collectAsState() | ||
| val analyticsConsent by viewModel.analyticsConsent.collectAsState() | ||
| val identifiedAnalyticsConsent by viewModel.identifiedAnalyticsConsent.collectAsState() | ||
| val personalizationConsent by viewModel.personalizationConsent.collectAsState() | ||
|
|
@@ -67,6 +71,19 @@ fun SettingsScreen( | |
| } | ||
| } | ||
|
|
||
| if (sdkModeChangeAlert != null) { | ||
| AlertDialog( | ||
| onDismissRequest = { viewModel.clearSdkModeChangeAlert() }, | ||
| title = { Text("SDK Restart Required") }, | ||
| text = { Text(sdkModeChangeAlert ?: "") }, | ||
| confirmButton = { | ||
| TextButton(onClick = { viewModel.clearSdkModeChangeAlert() }) { | ||
| Text("OK") | ||
| } | ||
| } | ||
| ) | ||
|
Comment on lines
+74
to
+84
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The AlertDialog(
onDismissRequest = { viewModel.clearSdkModeChangeAlert() },
title = { Text("SDK Restart Required") },
text = { Text(sdkModeChangeAlert ?: "") },
confirmButton = {
TextButton(onClick = { viewModel.clearSdkModeChangeAlert() }) {
Text("OK")
}
}
) |
||
| } | ||
|
|
||
| Column( | ||
| modifier = Modifier | ||
| .fillMaxSize() | ||
|
|
@@ -188,6 +205,38 @@ fun SettingsScreen( | |
| HorizontalDivider() | ||
| Spacer(modifier = Modifier.height(24.dp)) | ||
|
|
||
| // Purchasely SDK section | ||
| Text( | ||
| text = "Purchasely SDK", | ||
| style = MaterialTheme.typography.titleMedium, | ||
| color = MaterialTheme.colorScheme.primary | ||
| ) | ||
| Spacer(modifier = Modifier.height(12.dp)) | ||
|
|
||
| val sdkModes = listOf(PurchaselySdkMode.PAYWALL_OBSERVER, PurchaselySdkMode.FULL) | ||
| SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) { | ||
| sdkModes.forEachIndexed { index, mode -> | ||
| SegmentedButton( | ||
| selected = sdkMode == mode, | ||
| onClick = { viewModel.setSdkMode(mode) }, | ||
| shape = SegmentedButtonDefaults.itemShape(index = index, count = sdkModes.size) | ||
| ) { | ||
| Text(mode.label) | ||
| } | ||
| } | ||
| } | ||
|
|
||
| Spacer(modifier = Modifier.height(8.dp)) | ||
| Text( | ||
| text = "Default mode is Paywall Observer. Changing mode restarts the SDK.", | ||
| style = MaterialTheme.typography.bodySmall, | ||
| color = MaterialTheme.colorScheme.onSurfaceVariant | ||
| ) | ||
|
Comment on lines
+208
to
+234
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The new "Purchasely SDK" section with a segmented button for selecting the SDK mode is well-implemented. It provides a clear and intuitive way for users to change this setting. The descriptive text below the segmented button is also helpful. // Purchasely SDK section
Text(
text = "Purchasely SDK",
style = MaterialTheme.typography.titleMedium,
color = MaterialTheme.colorScheme.primary
)
Spacer(modifier = Modifier.height(12.dp))
val sdkModes = listOf(PurchaselySdkMode.PAYWALL_OBSERVER, PurchaselySdkMode.FULL)
SingleChoiceSegmentedButtonRow(modifier = Modifier.fillMaxWidth()) {
sdkModes.forEachIndexed { index, mode ->
SegmentedButton(
selected = sdkMode == mode,
onClick = { viewModel.setSdkMode(mode) },
shape = SegmentedButtonDefaults.itemShape(index = index, count = sdkModes.size)
) {
Text(mode.label)
}
}
}
Spacer(modifier = Modifier.height(8.dp))
Text(
text = "Default mode is Paywall Observer. Changing mode restarts the SDK.",
style = MaterialTheme.typography.bodySmall,
color = MaterialTheme.colorScheme.onSurfaceVariant
) |
||
|
|
||
| Spacer(modifier = Modifier.height(24.dp)) | ||
| HorizontalDivider() | ||
| Spacer(modifier = Modifier.height(24.dp)) | ||
|
|
||
| // Data Privacy section | ||
| Text( | ||
| text = "Data Privacy", | ||
|
|
||
| Original file line number | Diff line number | Diff line change |
|---|---|---|
|
|
@@ -4,6 +4,8 @@ import android.content.Context | |
| import android.content.SharedPreferences | ||
| import android.util.Log | ||
| import androidx.lifecycle.ViewModel | ||
| import com.purchasely.shaker.ShakerApp | ||
| import com.purchasely.shaker.data.PurchaselySdkMode | ||
| import com.purchasely.shaker.data.PremiumManager | ||
| import io.purchasely.ext.PLYDataProcessingPurpose | ||
| import io.purchasely.ext.Purchasely | ||
|
|
@@ -17,7 +19,7 @@ class SettingsViewModel( | |
| ) : ViewModel() { | ||
|
|
||
| private val prefs: SharedPreferences = | ||
| context.getSharedPreferences("shaker_settings", Context.MODE_PRIVATE) | ||
| context.getSharedPreferences(PurchaselySdkMode.PREFERENCES_NAME, Context.MODE_PRIVATE) | ||
|
Comment on lines
21
to
+22
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. |
||
|
|
||
| private val _userId = MutableStateFlow(prefs.getString(KEY_USER_ID, null)) | ||
| val userId: StateFlow<String?> = _userId.asStateFlow() | ||
|
|
@@ -30,6 +32,16 @@ class SettingsViewModel( | |
| private val _themeMode = MutableStateFlow(prefs.getString(KEY_THEME, "system") ?: "system") | ||
| val themeMode: StateFlow<String> = _themeMode.asStateFlow() | ||
|
|
||
| private val _sdkMode = MutableStateFlow( | ||
| PurchaselySdkMode.fromStorage( | ||
| prefs.getString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue) | ||
| ) | ||
| ) | ||
| val sdkMode: StateFlow<PurchaselySdkMode> = _sdkMode.asStateFlow() | ||
|
|
||
| private val _sdkModeChangeAlert = MutableStateFlow<String?>(null) | ||
| val sdkModeChangeAlert: StateFlow<String?> = _sdkModeChangeAlert.asStateFlow() | ||
|
|
||
| // Data privacy consent toggles (default: true = consent given) | ||
| private val _analyticsConsent = MutableStateFlow(prefs.getBoolean(KEY_CONSENT_ANALYTICS, true)) | ||
| val analyticsConsent: StateFlow<Boolean> = _analyticsConsent.asStateFlow() | ||
|
|
@@ -47,6 +59,9 @@ class SettingsViewModel( | |
| val thirdPartyConsent: StateFlow<Boolean> = _thirdPartyConsent.asStateFlow() | ||
|
|
||
| init { | ||
| if (!prefs.contains(PurchaselySdkMode.KEY)) { | ||
| prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply() | ||
| } | ||
|
Comment on lines
+62
to
+64
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. This if (!prefs.contains(PurchaselySdkMode.KEY)) {
prefs.edit().putString(PurchaselySdkMode.KEY, PurchaselySdkMode.DEFAULT.storageValue).apply()
} |
||
| applyConsentPreferences() | ||
| } | ||
|
|
||
|
|
@@ -103,6 +118,18 @@ class SettingsViewModel( | |
| Purchasely.setUserAttribute("app_theme", mode) | ||
| } | ||
|
|
||
| fun setSdkMode(mode: PurchaselySdkMode) { | ||
| if (_sdkMode.value == mode) return | ||
|
|
||
| _sdkMode.value = mode | ||
| prefs.edit().putString(PurchaselySdkMode.KEY, mode.storageValue).apply() | ||
| restartPurchaselySdk(mode) | ||
| } | ||
|
|
||
| fun clearSdkModeChangeAlert() { | ||
| _sdkModeChangeAlert.value = null | ||
| } | ||
|
|
||
| fun setAnalyticsConsent(enabled: Boolean) { | ||
| _analyticsConsent.value = enabled | ||
| prefs.edit().putBoolean(KEY_CONSENT_ANALYTICS, enabled).apply() | ||
|
|
@@ -133,6 +160,20 @@ class SettingsViewModel( | |
| applyConsentPreferences() | ||
| } | ||
|
|
||
| private fun restartPurchaselySdk(mode: PurchaselySdkMode) { | ||
| val app = context.applicationContext as? ShakerApp | ||
| if (app == null) { | ||
| Log.e(TAG, "[Shaker] Could not restart SDK: application context is not ShakerApp") | ||
| _sdkModeChangeAlert.value = "Mode saved (${mode.label}). Please kill and relaunch the app." | ||
| return | ||
| } | ||
|
|
||
| app.restartPurchaselySdk() | ||
| _sdkModeChangeAlert.value = | ||
| "Purchasely SDK switched to ${mode.label}. Please kill and relaunch the app." | ||
| Log.d(TAG, "[Shaker] SDK mode updated to ${mode.storageValue}") | ||
|
Comment on lines
+163
to
+174
There was a problem hiding this comment. Choose a reason for hiding this commentThe reason will be displayed to describe this comment to others. Learn more. The private fun restartPurchaselySdk(mode: PurchaselySdkMode) {
val app = context.applicationContext as? ShakerApp
if (app == null) {
Log.e(TAG, "[Shaker] Could not restart SDK: application context is not ShakerApp")
_sdkModeChangeAlert.value = "Mode saved (${mode.label}). Please kill and relaunch the app."
return
}
app.restartPurchaselySdk()
_sdkModeChangeAlert.value =
"Purchasely SDK switched to ${mode.label}. Please kill and relaunch the app."
Log.d(TAG, "[Shaker] SDK mode updated to ${mode.storageValue}")
} |
||
| } | ||
|
|
||
| private fun applyConsentPreferences() { | ||
| val revoked = mutableSetOf<PLYDataProcessingPurpose>() | ||
| if (!_analyticsConsent.value) revoked.add(PLYDataProcessingPurpose.Analytics) | ||
|
|
||
There was a problem hiding this comment.
Choose a reason for hiding this comment
The reason will be displayed to describe this comment to others. Learn more.
The
README.mdupdate correctly reflects the change frompresentationViewandpresentationControllertofetchPresentation + displayfor paywall flows. This is a good clarification for anyone setting up the project.