diff --git a/examples/sample-java/src/main/java/com/monetai/sample/java/MainActivity.java b/examples/sample-java/src/main/java/com/monetai/sample/java/MainActivity.java
index 6d287f3..318d5a0 100644
--- a/examples/sample-java/src/main/java/com/monetai/sample/java/MainActivity.java
+++ b/examples/sample-java/src/main/java/com/monetai/sample/java/MainActivity.java
@@ -8,10 +8,12 @@
import androidx.core.content.ContextCompat;
import com.monetai.sample.java.databinding.ActivityMainBinding;
-import com.monetai.sample.java.views.DiscountBannerView;
import com.monetai.sdk.MonetaiSDKJava;
import com.monetai.sdk.models.AppUserDiscount;
import com.monetai.sdk.models.PredictResult;
+import com.monetai.sdk.models.PaywallConfig;
+import com.monetai.sdk.models.PaywallStyle;
+import com.monetai.sdk.models.Feature;
import java.text.SimpleDateFormat;
import java.util.Date;
@@ -20,17 +22,20 @@
import java.util.Map;
import java.util.concurrent.ExecutorService;
import java.util.concurrent.Executors;
+import java.util.Arrays;
public class MainActivity extends AppCompatActivity {
private ActivityMainBinding binding;
- private DiscountBannerView discountBannerView;
private static final String TAG = "MainActivity";
private static final SimpleDateFormat dateFormat = new SimpleDateFormat("MMM dd, HH:mm", Locale.getDefault());
private final ExecutorService executor = Executors.newSingleThreadExecutor();
+ // MARK: - Paywall State
+ private PaywallConfig paywallConfig;
+
@Override
protected void onCreate(Bundle savedInstanceState) {
super.onCreate(savedInstanceState);
@@ -67,10 +72,8 @@ private void setupMonetaiSDK() {
(result, error) -> {
if (error != null) {
Log.e(TAG, "SDK initialization failed", error);
- updateStatus("SDK initialization failed: " + error.getMessage());
} else {
Log.d(TAG, "SDK initialized successfully: " + result);
- updateStatus("SDK Status: ✅ Ready");
// Enable buttons after successful initialization
runOnUiThread(() -> {
@@ -84,17 +87,14 @@ private void setupMonetaiSDK() {
Log.d(TAG, "Discount info changed: " + discount);
handleDiscountInfoChange(discount);
});
+
+ // Set up paywall AFTER initialization
+ setupPaywall();
}
}
);
}
- private void updateStatus(String message) {
- runOnUiThread(() -> {
- binding.statusLabel.setText(message);
- Toast.makeText(MainActivity.this, message, Toast.LENGTH_SHORT).show();
- });
- }
private void handleDiscountInfoChange(AppUserDiscount discountInfo) {
runOnUiThread(() -> {
@@ -110,73 +110,22 @@ private void handleDiscountInfoChange(AppUserDiscount discountInfo) {
System.out.println(" Time difference (ms): " + (endTime.getTime() - now.getTime()));
if (now.before(endTime)) {
- // Discount is valid - show banner
+ // Discount is valid - SDK banner will be shown automatically
binding.discountStatusLabel.setText("Discount: ✅ Active (Expires: " + dateFormat.format(endTime) + ")");
binding.discountStatusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark));
- showDiscountBanner(discountInfo);
} else {
// Discount expired
binding.discountStatusLabel.setText("Discount: ❌ Expired");
binding.discountStatusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_red_dark));
- hideDiscountBanner();
}
} else {
// No discount
binding.discountStatusLabel.setText("Discount: None");
binding.discountStatusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray));
- hideDiscountBanner();
}
});
}
- private void showDiscountBanner(AppUserDiscount discount) {
- // Remove existing banner if any
- hideDiscountBanner();
-
- // Create and add new banner
- discountBannerView = new DiscountBannerView(this);
-
- // Debug logging
- System.out.println("🎯 showDiscountBanner called");
- System.out.println(" discountBannerView created: " + (discountBannerView != null));
- System.out.println(" rootLayout: " + binding.getRoot());
-
- // Add banner to root layout
- binding.getRoot().addView(discountBannerView);
-
- // Set layout parameters to position at bottom
- androidx.constraintlayout.widget.ConstraintLayout.LayoutParams layoutParams =
- new androidx.constraintlayout.widget.ConstraintLayout.LayoutParams(
- androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.MATCH_PARENT,
- androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.WRAP_CONTENT
- );
- layoutParams.bottomToBottom = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID;
- layoutParams.startToStart = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID;
- layoutParams.endToEnd = androidx.constraintlayout.widget.ConstraintLayout.LayoutParams.PARENT_ID;
- layoutParams.bottomMargin = 20;
- layoutParams.leftMargin = 16;
- layoutParams.rightMargin = 16;
- discountBannerView.setLayoutParams(layoutParams);
-
- System.out.println(" Banner added to rootLayout");
- System.out.println(" Banner visibility before showDiscount: " + discountBannerView.getVisibility());
-
- // Show discount
- discountBannerView.showDiscount(discount);
-
- // Update result label
- binding.resultLabel.setText("🎉 Discount banner displayed!\nSpecial offer is now active.");
- binding.resultLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark));
-
- System.out.println(" showDiscountBanner completed");
- }
-
- private void hideDiscountBanner() {
- if (discountBannerView != null) {
- discountBannerView.hideDiscount();
- discountBannerView = null;
- }
- }
// MARK: - Button Actions
private void predictButtonTapped() {
@@ -198,11 +147,12 @@ private void predictButtonTapped() {
if (result.getPrediction() != null) {
PredictResult prediction = result.getPrediction();
if (prediction == PredictResult.NON_PURCHASER) {
- // When predicted as non-purchaser, offer discount
- System.out.println("Predicted as non-purchaser - discount can be applied");
+ // When predicted as non-purchaser, SDK should automatically show paywall
+ System.out.println("Predicted as non-purchaser - SDK should automatically show paywall");
+ System.out.println("Note: If paywall doesn't appear, check SDK's automatic display logic");
} else if (prediction == PredictResult.PURCHASER) {
// When predicted as purchaser
- System.out.println("Predicted as purchaser - discount not needed");
+ System.out.println("Predicted as purchaser - paywall not needed");
}
} else {
// When prediction is null
@@ -257,4 +207,111 @@ private void logEventButtonTapped() {
System.out.println("Event logged: button_click");
});
}
+
+ // MARK: - Paywall Methods
+ private void setupPaywall() {
+ paywallConfig = MonetaiSDKJava.createPaywallConfigBuilder()
+ .discountPercent(30)
+ .regularPrice("$99.99")
+ .discountedPrice("$69.99")
+ .locale("en")
+ .style(PaywallStyle.COMPACT)
+ .features(Arrays.asList(
+ new Feature("Unlimited Access", "Use all premium features without limits", true),
+ new Feature("Advanced Analytics", "AI-powered insights", false),
+ new Feature("Priority Support", "24/7 customer support", false)
+ ))
+ .enabled(true)
+ .bannerBottom(20f)
+ .isSubscriber(false)
+ .onPurchase(new MonetaiSDKJava.OnPurchaseCallback() {
+ @Override
+ public void onPurchase(com.monetai.sdk.models.PaywallContext paywallContext, Runnable closePaywall) {
+ System.out.println("🛒 Purchase button tapped in paywall!");
+ System.out.println(" 📱 Activity: " + paywallContext.getActivity().getClass().getSimpleName());
+ System.out.println(" 🏗️ App Context: " + paywallContext.getApplicationContext().getClass().getSimpleName());
+
+ runOnUiThread(() -> {
+ // Simulate subscription purchase
+ MonetaiSDKJava.setSubscriptionStatus(true);
+
+ // Show success toast
+ Toast.makeText(MainActivity.this, "🎉 Purchase successful! Welcome to Premium!", Toast.LENGTH_SHORT).show();
+ });
+
+ // Close paywall
+ closePaywall.run();
+ }
+ })
+ .onTermsOfService(new MonetaiSDKJava.OnTermsOfServiceCallback() {
+ @Override
+ public void onTermsOfService(com.monetai.sdk.models.PaywallContext paywallContext) {
+ System.out.println("📄 Terms of Service tapped!");
+ System.out.println(" 📱 Activity: " + paywallContext.getActivity().getClass().getSimpleName());
+ showTermsOfService(paywallContext);
+ }
+ })
+ .onPrivacyPolicy(new MonetaiSDKJava.OnPrivacyPolicyCallback() {
+ @Override
+ public void onPrivacyPolicy(com.monetai.sdk.models.PaywallContext paywallContext) {
+ System.out.println("🔒 Privacy Policy tapped!");
+ System.out.println(" 📱 Activity: " + paywallContext.getActivity().getClass().getSimpleName());
+ showPrivacyPolicy(paywallContext);
+ }
+ })
+ .build();
+
+ if (paywallConfig != null) {
+ // Configure paywall
+ MonetaiSDKJava.configurePaywall(paywallConfig);
+ System.out.println("🎯 Paywall configured successfully");
+ }
+ }
+
+ // MARK: - Paywall Callback Helpers
+ private static void showTermsOfService(com.monetai.sdk.models.PaywallContext paywallContext) {
+ System.out.println("📄 Showing Terms of Service...");
+ System.out.println(" 🎯 Context from: " + paywallContext.getActivity().getClass().getSimpleName());
+
+ // Use the Activity from PaywallContext for better dialog management
+ android.app.Activity contextActivity = paywallContext.getActivity();
+
+ // PaywallContext의 Activity는 이미 MonetaiPaywallActivity이므로 직접 사용
+ contextActivity.runOnUiThread(() -> {
+ String contextInfo = "Called from: " + paywallContext.getActivity().getClass().getSimpleName();
+
+ // 가장 간단한 기본 다이얼로그 - PaywallActivity에서 직접 표시
+ new AlertDialog.Builder(contextActivity)
+ .setTitle("Terms of Service")
+ .setMessage("Terms of Service content goes here...\n\n" + contextInfo)
+ .setPositiveButton("Accept", null)
+ .setNegativeButton("Cancel", null)
+ .show();
+
+ System.out.println("✅ Terms of Service dialog shown on PaywallActivity: " + contextActivity.getClass().getSimpleName());
+ });
+ }
+
+ private static void showPrivacyPolicy(com.monetai.sdk.models.PaywallContext paywallContext) {
+ System.out.println("🔒 Showing Privacy Policy...");
+ System.out.println(" 🎯 Context from: " + paywallContext.getActivity().getClass().getSimpleName());
+
+ // Use the Activity from PaywallContext for better dialog management
+ android.app.Activity contextActivity = paywallContext.getActivity();
+
+ // PaywallContext의 Activity는 이미 MonetaiPaywallActivity이므로 직접 사용
+ contextActivity.runOnUiThread(() -> {
+ String contextInfo = "Called from: " + paywallContext.getActivity().getClass().getSimpleName();
+
+ // 가장 간단한 기본 다이얼로그 - PaywallActivity에서 직접 표시
+ new AlertDialog.Builder(contextActivity)
+ .setTitle("Privacy Policy")
+ .setMessage("Privacy Policy content goes here...\n\n" + contextInfo)
+ .setPositiveButton("Accept", null)
+ .setNegativeButton("Cancel", null)
+ .show();
+
+ System.out.println("✅ Privacy Policy dialog shown on PaywallActivity: " + contextActivity.getClass().getSimpleName());
+ });
+ }
}
\ No newline at end of file
diff --git a/examples/sample-java/src/main/res/layout/activity_main.xml b/examples/sample-java/src/main/res/layout/activity_main.xml
index 1af3c9d..2210dd7 100644
--- a/examples/sample-java/src/main/res/layout/activity_main.xml
+++ b/examples/sample-java/src/main/res/layout/activity_main.xml
@@ -93,18 +93,32 @@
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="30dp" />
+
+
+
diff --git a/examples/sample-kotlin/src/main/AndroidManifest.xml b/examples/sample-kotlin/src/main/AndroidManifest.xml
index ead7037..241af8a 100644
--- a/examples/sample-kotlin/src/main/AndroidManifest.xml
+++ b/examples/sample-kotlin/src/main/AndroidManifest.xml
@@ -4,6 +4,7 @@
+
runOnUiThread {
+ println("🎯 Discount info changed callback triggered")
+ println(" discountInfo: $discountInfo")
+ println(" current time: ${Date()}")
+ if (discountInfo != null) {
+ println(" discount end time: ${discountInfo.endedAt}")
+ println(" is discount active: ${discountInfo.endedAt > Date()}")
+ }
handleDiscountInfoChange(discountInfo)
}
}
+
+ // Set up paywall AFTER initialization
+ setupPaywall()
}
}
}
@@ -88,26 +102,13 @@ class MainActivity : AppCompatActivity() {
private fun updateSDKStatus() {
val isInitialized = MonetaiSDK.shared.getInitialized()
+ binding.statusLabel.text = "SDK Status: ${if (isInitialized) "✅ Initialized" else "❌ Not Initialized"}"
- if (isInitialized) {
- binding.statusLabel.text = "SDK Status: ✅ Ready"
- binding.statusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark))
-
- // Enable buttons
- binding.predictButton.isEnabled = true
- binding.logEventButton.isEnabled = true
- binding.predictButton.alpha = 1.0f
- binding.logEventButton.alpha = 1.0f
- } else {
- binding.statusLabel.text = "SDK Status: ⏳ Initializing..."
- binding.statusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_orange_dark))
-
- // Disable buttons
- binding.predictButton.isEnabled = false
- binding.logEventButton.isEnabled = false
- binding.predictButton.alpha = 0.5f
- binding.logEventButton.alpha = 0.5f
- }
+ // Enable buttons only after initialization
+ binding.predictButton.isEnabled = isInitialized
+ binding.logEventButton.isEnabled = isInitialized
+ binding.predictButton.alpha = if (isInitialized) 1.0f else 0.5f
+ binding.logEventButton.alpha = if (isInitialized) 1.0f else 0.5f
}
private fun handleDiscountInfoChange(discountInfo: com.monetai.sdk.models.AppUserDiscount?) {
@@ -123,92 +124,46 @@ class MainActivity : AppCompatActivity() {
println(" Time difference (ms): ${endTime.time - now.time}")
if (now < endTime) {
- // Discount is valid - show banner
+ // Discount is valid - SDK will automatically show banner
binding.discountStatusLabel.text = "Discount: ✅ Active (Expires: ${dateFormat.format(endTime)})"
binding.discountStatusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark))
- showDiscountBanner(discountInfo)
+ println("🎯 Discount is active - SDK should automatically show banner")
} else {
// Discount expired
binding.discountStatusLabel.text = "Discount: ❌ Expired"
binding.discountStatusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_red_dark))
- hideDiscountBanner()
+ println("❌ Discount expired - SDK should automatically hide banner")
}
} else {
// No discount
binding.discountStatusLabel.text = "Discount: None"
binding.discountStatusLabel.setTextColor(ContextCompat.getColor(this, android.R.color.darker_gray))
- hideDiscountBanner()
+ println("📭 No discount - SDK should automatically hide banner")
}
}
- private fun showDiscountBanner(discount: com.monetai.sdk.models.AppUserDiscount) {
- // Remove existing banner if any
- hideDiscountBanner()
- // Create and add new banner
- discountBannerView = DiscountBannerView(this)
-
- // Debug logging
- println("🎯 showDiscountBanner called")
- println(" discountBannerView created: ${discountBannerView != null}")
- println(" rootLayout: ${binding.rootLayout}")
-
- // Add banner to root layout
- binding.rootLayout.addView(discountBannerView)
-
- // Set layout parameters to position at bottom
- val layoutParams = ConstraintLayout.LayoutParams(
- ConstraintLayout.LayoutParams.MATCH_PARENT,
- ConstraintLayout.LayoutParams.WRAP_CONTENT
- ).apply {
- bottomToBottom = ConstraintLayout.LayoutParams.PARENT_ID
- startToStart = ConstraintLayout.LayoutParams.PARENT_ID
- endToEnd = ConstraintLayout.LayoutParams.PARENT_ID
- bottomMargin = 20
- leftMargin = 16
- rightMargin = 16
- }
- discountBannerView?.layoutParams = layoutParams
-
- println(" Banner added to rootLayout")
- println(" Banner visibility before showDiscount: ${discountBannerView?.visibility}")
-
- // Show discount
- discountBannerView?.showDiscount(discount)
-
- // Update result label
- binding.resultLabel.text = "🎉 Discount banner displayed!\nSpecial offer is now active."
- binding.resultLabel.setTextColor(ContextCompat.getColor(this, android.R.color.holo_green_dark))
-
- println(" showDiscountBanner completed")
- }
-
- private fun hideDiscountBanner() {
- discountBannerView?.hideDiscount()
- discountBannerView = null
- }
// MARK: - Button Actions
private fun predictButtonTapped() {
MonetaiSDK.shared.predict { result, error ->
runOnUiThread {
if (error != null) {
- binding.resultLabel.text = "❌ Prediction failed: ${error.message}"
- binding.resultLabel.setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_red_dark))
println("Prediction failed: $error")
} else {
result?.let { predictResult ->
println("Prediction result: ${predictResult.prediction}")
println("Test group: ${predictResult.testGroup}")
- when (predictResult?.prediction) {
+ when (predictResult.prediction) {
PredictResult.NON_PURCHASER -> {
- // When predicted as non-purchaser, offer discount
- println("Predicted as non-purchaser - discount can be applied")
+ // When predicted as non-purchaser, SDK should automatically show paywall
+ println("Predicted as non-purchaser - SDK should automatically show paywall")
+ println("Note: If paywall doesn't appear, check SDK's automatic display logic")
}
PredictResult.PURCHASER -> {
// When predicted as purchaser
- println("Predicted as purchaser - discount not needed")
+ println("Predicted as purchaser - paywall not needed")
}
null -> {
// When prediction is null
@@ -216,12 +171,9 @@ class MainActivity : AppCompatActivity() {
}
}
- binding.resultLabel.text = "✅ Prediction completed - check console for details"
- binding.resultLabel.setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_green_dark))
-
// Show alert with prediction result
AlertDialog.Builder(this@MainActivity)
- .setTitle("Purchase Prediction")
+ .setTitle("AI Purchase Prediction Result")
.setMessage("Prediction: ${predictResult.prediction}\nTest Group: ${predictResult.testGroup}")
.setPositiveButton("OK", null)
.show()
@@ -230,6 +182,129 @@ class MainActivity : AppCompatActivity() {
}
}
}
+
+ // MARK: - Paywall Methods
+ private fun setupPaywall() {
+ paywallConfig = PaywallConfig(
+ discountPercent = 30,
+ regularPrice = "$99.99",
+ discountedPrice = "$69.99",
+ locale = "ko",
+ style = PaywallStyle.COMPACT,
+ features = listOf(
+ Feature(
+ title = "Unlimited Access",
+ description = "Use all premium features without limits",
+ isPremiumOnly = true
+ ),
+ Feature(
+ title = "Advanced Analytics",
+ description = "AI-powered insights",
+ isPremiumOnly = false
+ ),
+ Feature(
+ title = "Priority Support",
+ description = "24/7 customer support",
+ isPremiumOnly = false
+ )
+ ),
+ enabled = true,
+ bannerBottom = 20f,
+ isSubscriber = isSubscriber,
+
+
+ onPurchase = { paywallContext, closePaywall ->
+ println("💰 MainActivity: onPurchase callback triggered")
+ println(" 📱 Activity: ${paywallContext.activity.javaClass.simpleName}")
+ println(" 🏗️ App Context: ${paywallContext.applicationContext.javaClass.simpleName}")
+
+ // Process purchase immediately
+ println("💰 Processing purchase...")
+ // Update subscription status
+ isSubscriber = true
+ MonetaiSDK.shared.setSubscriptionStatus(true)
+ updateSubscriptionUI()
+ // Close paywall
+ closePaywall()
+ println("✅ Purchase completed successfully")
+ },
+ onTermsOfService = { paywallContext ->
+ println("🔗 MainActivity: onTermsOfService callback triggered")
+ println(" 📱 Activity: ${paywallContext.activity.javaClass.simpleName}")
+ showTermsOfService(paywallContext)
+ },
+ onPrivacyPolicy = { paywallContext ->
+ println("🔗 MainActivity: onPrivacyPolicy callback triggered")
+ println(" 📱 Activity: ${paywallContext.activity.javaClass.simpleName}")
+ showPrivacyPolicy(paywallContext)
+ }
+ )
+
+ paywallConfig?.let { config ->
+ // Configure paywall
+ MonetaiSDK.shared.configurePaywall(config)
+ println("🎯 Paywall configured successfully")
+ println(" config.enabled: ${config.enabled}")
+ println(" config.isSubscriber: ${config.isSubscriber}")
+ println(" config.bannerBottom: ${config.bannerBottom}")
+ }
+ }
+
+ private fun updateSubscriptionUI() {
+ val statusText = if (isSubscriber) "✅ Subscriber" else "❌ Non-subscriber"
+ binding.subscriptionStatusLabel.text = "Subscription Status: $statusText"
+ binding.subscriptionStatusLabel.setTextColor(
+ if (isSubscriber)
+ ContextCompat.getColor(this, android.R.color.holo_green_dark)
+ else
+ ContextCompat.getColor(this, android.R.color.holo_red_dark)
+ )
+ }
+
+
+ private fun showTermsOfService(paywallContext: com.monetai.sdk.models.PaywallContext) {
+ println("📄 MainActivity: showTermsOfService() called")
+ println(" 🎯 Context from: ${paywallContext.activity.javaClass.simpleName}")
+
+ // Use the Activity from PaywallContext for better dialog management
+ val contextActivity = paywallContext.activity
+
+ // PaywallContext의 Activity는 이미 MonetaiPaywallActivity이므로 직접 사용
+ contextActivity.runOnUiThread {
+ val contextInfo = "Called from: ${paywallContext.activity.javaClass.simpleName}"
+
+ // 가장 간단한 기본 다이얼로그 - PaywallActivity에서 직접 표시
+ AlertDialog.Builder(contextActivity)
+ .setTitle("Terms of Service")
+ .setMessage("Terms of service content will be displayed here.\n\n$contextInfo")
+ .setPositiveButton("OK", null)
+ .show()
+
+ println("✅ Terms of Service dialog shown on PaywallActivity: ${contextActivity.javaClass.simpleName}")
+ }
+ }
+
+ private fun showPrivacyPolicy(paywallContext: com.monetai.sdk.models.PaywallContext) {
+ println("🔒 MainActivity: showPrivacyPolicy() called")
+ println(" 🎯 Context from: ${paywallContext.activity.javaClass.simpleName}")
+
+ // Use the Activity from PaywallContext for better dialog management
+ val contextActivity = paywallContext.activity
+
+ // PaywallContext의 Activity는 이미 MonetaiPaywallActivity이므로 직접 사용
+ contextActivity.runOnUiThread {
+ val contextInfo = "Called from: ${paywallContext.activity.javaClass.simpleName}"
+
+ // 가장 간단한 기본 다이얼로그 - PaywallActivity에서 직접 표시
+ AlertDialog.Builder(contextActivity)
+ .setTitle("Privacy Policy")
+ .setMessage("Privacy policy content will be displayed here.\n\n$contextInfo")
+ .setPositiveButton("OK", null)
+ .show()
+
+ println("✅ Privacy Policy dialog shown on PaywallActivity: ${contextActivity.javaClass.simpleName}")
+ }
+ }
private fun logEventButtonTapped() {
// Log a sample event with parameters
@@ -240,8 +315,7 @@ class MainActivity : AppCompatActivity() {
MonetaiSDK.shared.logEvent("button_click", params)
- binding.resultLabel.text = "✅ Event logged: button_click\nParameters: button=test_button, screen=main"
- binding.resultLabel.setTextColor(ContextCompat.getColor(this@MainActivity, android.R.color.holo_green_dark))
+
println("Event logged: button_click")
}
diff --git a/examples/sample-kotlin/src/main/res/layout/activity_main.xml b/examples/sample-kotlin/src/main/res/layout/activity_main.xml
index 1af3c9d..866e27e 100644
--- a/examples/sample-kotlin/src/main/res/layout/activity_main.xml
+++ b/examples/sample-kotlin/src/main/res/layout/activity_main.xml
@@ -93,20 +93,20 @@
app:layout_constraintEnd_toEndOf="parent"
android:layout_marginTop="30dp" />
-
+
+
+
\ No newline at end of file
diff --git a/monetai-sdk/build.gradle b/monetai-sdk/build.gradle
index b9ba781..98bac9c 100644
--- a/monetai-sdk/build.gradle
+++ b/monetai-sdk/build.gradle
@@ -1,6 +1,7 @@
plugins {
id 'com.android.library'
id 'org.jetbrains.kotlin.android'
+ id 'kotlin-parcelize'
id 'maven-publish'
}
diff --git a/monetai-sdk/src/main/AndroidManifest.xml b/monetai-sdk/src/main/AndroidManifest.xml
new file mode 100644
index 0000000..fc5bdc1
--- /dev/null
+++ b/monetai-sdk/src/main/AndroidManifest.xml
@@ -0,0 +1,18 @@
+
+
+
+
+
+
+
+
+
+
+
+
+
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDK.kt b/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDK.kt
index 7ff448c..08cda9f 100644
--- a/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDK.kt
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDK.kt
@@ -6,6 +6,8 @@ import com.monetai.sdk.billing.BillingManager
import com.monetai.sdk.billing.ReceiptValidator
import com.monetai.sdk.models.*
import com.monetai.sdk.network.ApiRequests
+import com.monetai.sdk.paywall.MonetaiPaywallManager
+import com.monetai.sdk.banner.MonetaiBannerManager
import kotlinx.coroutines.*
import java.util.*
import java.util.concurrent.ConcurrentLinkedQueue
@@ -37,10 +39,21 @@ class MonetaiSDK private constructor() {
private var abTestGroup: ABTestGroup? = null
private val pendingEvents = ConcurrentLinkedQueue()
+ // Application context for UI components
+ private var applicationContext: Context? = null
// Billing components
private var billingManager: BillingManager? = null
private var receiptValidator: ReceiptValidator? = null
+ // Paywall and Banner components
+ internal val paywallManager = MonetaiPaywallManager()
+ internal val bannerManager: MonetaiBannerManager by lazy {
+ MonetaiBannerManager(applicationContext ?: throw IllegalStateException("SDK not initialized. Call initialize() first."))
+ }
+
+ // Paywall Configuration
+ private var paywallConfig: PaywallConfig? = null
+
// Coroutine scope for internal operations
private val internalScope = CoroutineScope(Dispatchers.IO + SupervisorJob())
@@ -54,6 +67,9 @@ class MonetaiSDK private constructor() {
}
}
+ // MARK: - Subscription State
+ private var isSubscriber: Boolean = false
+
// MARK: - Internal Properties
internal val currentSDKKey: String? get() = sdkKey
internal val currentUserId: String? get() = userId
@@ -81,22 +97,18 @@ class MonetaiSDK private constructor() {
// Reset if already initialized with different credentials
if (isInitialized && (this@MonetaiSDK.sdkKey != sdkKey || this@MonetaiSDK.userId != userId)) {
- Log.d(TAG, "SDK already initialized with different credentials - resetting")
reset()
}
- Log.d(TAG, "Initializing Monetai SDK...")
-
- // Minimal main-thread section: AndroidThreeTen init, SharedPreferences, Billing setup
+ // Minimal main-thread section: AndroidThreeTen init, Billing setup
withContext(Dispatchers.Main) {
// Initialize ThreeTenABP for timezone support
AndroidThreeTen.init(context)
-
-
// Store SDK key and user ID in memory
this@MonetaiSDK.sdkKey = sdkKey
this@MonetaiSDK.userId = userId
+ this@MonetaiSDK.applicationContext = context // Store application context
// Start billing observation (BillingClient requires main thread)
billingManager = BillingManager(context, sdkKey, userId)
@@ -126,9 +138,7 @@ class MonetaiSDK private constructor() {
isInitialized = true
// Process pending events (IO)
- Log.d(TAG, "SDK initialization complete - Starting to process pending events...")
processPendingEvents()
- Log.d(TAG, "Pending events processing complete")
// Automatically check discount information after initialization (IO)
loadDiscountInfoAutomatically()
@@ -176,8 +186,6 @@ class MonetaiSDK private constructor() {
onDiscountInfoChange?.invoke(discount)
}
- Log.d(TAG, "Discount information auto-load complete: ${if (discount != null) "Discount available" else "No discount"}")
-
} catch (e: Exception) {
Log.e(TAG, "Discount information auto-load failed", e)
currentDiscount = null
@@ -192,22 +200,15 @@ class MonetaiSDK private constructor() {
* @param options Event options to log
*/
fun logEvent(options: LogEventOptions) {
- Log.d(TAG, "Event logging request: ${options.eventName}")
- Log.d(TAG, "Event parameters: ${options.params ?: emptyMap()}")
-
val sdkKey = sdkKey
val userId = userId
if (sdkKey == null || userId == null) {
// Add to queue if SDK is not initialized
pendingEvents.offer(options)
- Log.d(TAG, "Before SDK initialization - Added to queue: ${options.eventName}")
- Log.d(TAG, "Current number of pending events: ${pendingEvents.size}")
return
}
- Log.d(TAG, "SDK initialized - Sending immediately: ${options.eventName}")
-
internalScope.launch {
try {
ApiRequests.createEvent(
@@ -217,7 +218,6 @@ class MonetaiSDK private constructor() {
params = options.params,
createdAt = options.createdAt
)
- Log.d(TAG, "Event logging success: ${options.eventName}")
} catch (e: Exception) {
Log.e(TAG, "Event logging failed: ${options.eventName}", e)
}
@@ -327,8 +327,6 @@ class MonetaiSDK private constructor() {
pendingEvents.clear()
currentDiscount = null
-
-
// Stop billing observation
billingManager?.stopObserving()
billingManager = null
@@ -358,6 +356,41 @@ class MonetaiSDK private constructor() {
*/
fun getExposureTimeSec(): Int? = exposureTimeSec
+ /**
+ * Set subscription status (can be called before or after configurePaywall)
+ * @param isSubscriber Whether the user is currently a subscriber
+ */
+ fun setSubscriptionStatus(isSubscriber: Boolean) {
+ this.isSubscriber = isSubscriber
+
+ // SDK automatically updates all UI when subscription status changes
+ if (paywallConfig != null) {
+ configureManagersAndUpdateUI()
+ }
+ }
+
+ /**
+ * Get current subscription status
+ */
+ fun getSubscriptionStatus(): Boolean = isSubscriber
+
+ /**
+ * Get application context
+ */
+ fun getApplicationContext(): Context? = applicationContext
+
+ /**
+ * Configure paywall with configuration
+ * @param config Paywall configuration
+ */
+ fun configurePaywall(config: PaywallConfig) {
+ this.paywallConfig = config
+
+ // SDK automatically configures managers and updates UI
+ configureManagersAndUpdateUI()
+ }
+
+
// MARK: - Private Methods
/**
@@ -379,19 +412,11 @@ class MonetaiSDK private constructor() {
pendingEvents.poll()?.let { events.add(it) }
}
- Log.d(TAG, "Starting to process pending events")
- Log.d(TAG, "Number of events to process: ${events.size}")
-
if (events.isEmpty()) {
- Log.d(TAG, "No pending events to process")
return
}
- Log.d(TAG, "List of events to process: ${events.map { it.eventName }}")
-
- events.forEachIndexed { index, event ->
- Log.d(TAG, "Processing ${index + 1}/${events.size}: ${event.eventName}")
-
+ events.forEach { event ->
try {
ApiRequests.createEvent(
sdkKey = sdkKey,
@@ -400,9 +425,8 @@ class MonetaiSDK private constructor() {
params = event.params,
createdAt = event.createdAt
)
- Log.d(TAG, "${index + 1}/${events.size} Success: ${event.eventName}")
} catch (e: Exception) {
- Log.e(TAG, "${index + 1}/${events.size} Failed: ${event.eventName}", e)
+ Log.e(TAG, "Failed to process event: ${event.eventName}", e)
}
}
}
@@ -430,16 +454,98 @@ class MonetaiSDK private constructor() {
endedAt = endedAt
)
- // Update state
- currentDiscount = discount
-
- // Call callback
- onDiscountInfoChange?.invoke(discount)
-
- Log.d(TAG, "Non-purchaser discount created successfully")
+ // Update state and UI automatically
+ handleDiscountInfoChange(discount)
}
} catch (e: Exception) {
Log.e(TAG, "Failed to create non-purchaser discount", e)
}
}
+
+ // MARK: - Paywall and Banner Management
+
+ /**
+ * Configure managers and update UI automatically
+ */
+ private fun configureManagersAndUpdateUI() {
+ val config = paywallConfig ?: return
+
+ // 1. Configure paywall manager
+ paywallManager.configure(config, convertToDiscountInfo())
+
+ // 2. Configure banner manager (no rootView needed for window overlay)
+ bannerManager.configure(config, convertToDiscountInfo(), paywallManager)
+
+ // 3. Automatically update banner visibility
+ updateBannerVisibilityAutomatically()
+
+ // 4. Paywall will be preloaded when banner is shown (better timing)
+ }
+
+ /**
+ * Update banner visibility automatically based on subscription status
+ */
+ private fun updateBannerVisibilityAutomatically() {
+ // Hide banner if user is subscriber
+ if (isSubscriber) {
+ bannerManager.hideBanner()
+ } else {
+ // Show banner if discount is active and paywall is enabled
+ val shouldShow = shouldShowBanner()
+
+ if (shouldShow) {
+ bannerManager.showBanner()
+ }
+ }
+ }
+
+ /**
+ * Check if banner should be shown
+ */
+ private fun shouldShowBanner(): Boolean {
+ return currentDiscount?.let { discount ->
+ discount.endedAt > Date() && paywallConfig?.enabled == true
+ } ?: false
+ }
+
+ /**
+ * Check if paywall should be shown automatically
+ */
+ private fun shouldShowPaywall(): Boolean {
+ return currentDiscount?.let { discount ->
+ discount.endedAt > Date() && paywallConfig?.enabled == true && !isSubscriber
+ } ?: false
+ }
+
+ /**
+ * Convert AppUserDiscount to DiscountInfo
+ */
+ private fun convertToDiscountInfo(): DiscountInfo? {
+ val currentDiscount = currentDiscount ?: return null
+
+ return DiscountInfo(
+ startedAt = currentDiscount.startedAt,
+ endedAt = currentDiscount.endedAt,
+ userId = currentDiscount.appUserId,
+ sdkKey = currentDiscount.sdkKey
+ )
+ }
+
+ /**
+ * Handle discount info change and update UI automatically
+ */
+ private fun handleDiscountInfoChange(discount: AppUserDiscount?) {
+ currentDiscount = discount
+
+ // Switch to main thread for UI updates
+ internalScope.launch(Dispatchers.Main) {
+ // Update managers with new discount info
+ if (paywallConfig != null) {
+ configureManagersAndUpdateUI()
+ }
+
+ // Call external callback
+ onDiscountInfoChange?.invoke(discount)
+ }
+ }
}
\ No newline at end of file
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDKJava.kt b/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDKJava.kt
index f110cfa..d7adaa3 100644
--- a/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDKJava.kt
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/MonetaiSDKJava.kt
@@ -15,8 +15,6 @@ class MonetaiSDKJava {
@JvmStatic
fun getShared(): MonetaiSDKJava = _shared
-
-
@JvmStatic
fun getUserId(): String? = MonetaiSDK.shared.getUserId()
@@ -28,6 +26,43 @@ class MonetaiSDKJava {
@JvmStatic
fun getExposureTimeSec(): Int? = MonetaiSDK.shared.getExposureTimeSec()
+
+ /**
+ * Set subscription status (Java compatible)
+ * @param isSubscriber Whether the user is currently a subscriber
+ */
+ @JvmStatic
+ fun setSubscriptionStatus(isSubscriber: Boolean) {
+ MonetaiSDK.shared.setSubscriptionStatus(isSubscriber)
+ }
+
+ /**
+ * Get current subscription status (Java compatible)
+ * @return Current subscription status
+ */
+ @JvmStatic
+ fun getSubscriptionStatus(): Boolean {
+ return MonetaiSDK.shared.getSubscriptionStatus()
+ }
+
+ /**
+ * Configure paywall with configuration (Java compatible)
+ * @param config Paywall configuration
+ */
+ @JvmStatic
+ fun configurePaywall(config: PaywallConfig) {
+ MonetaiSDK.shared.configurePaywall(config)
+ }
+
+
+ /**
+ * Create PaywallConfigBuilder (Java compatible)
+ * @return PaywallConfigBuilder instance
+ */
+ @JvmStatic
+ fun createPaywallConfigBuilder(): PaywallConfigBuilder {
+ return PaywallConfigBuilder()
+ }
}
/**
@@ -140,4 +175,157 @@ class MonetaiSDKJava {
fun reset() {
MonetaiSDK.shared.reset()
}
-}
\ No newline at end of file
+
+ /**
+ * Java-compatible callback interfaces for PaywallConfig
+ */
+ @FunctionalInterface
+ interface OnPurchaseCallback {
+ fun onPurchase(context: PaywallContext, closePaywall: Runnable)
+ }
+
+ @FunctionalInterface
+ interface OnTermsOfServiceCallback {
+ fun onTermsOfService(context: PaywallContext)
+ }
+
+ @FunctionalInterface
+ interface OnPrivacyPolicyCallback {
+ fun onPrivacyPolicy(context: PaywallContext)
+ }
+
+ /**
+ * Builder class for PaywallConfig (Java compatible)
+ * Provides the same functionality as iOS and React Native SDKs
+ */
+ class PaywallConfigBuilder {
+ private var discountPercent: Int = 0
+ private var regularPrice: String = ""
+ private var discountedPrice: String = ""
+ private var locale: String = ""
+ private var style: PaywallStyle = PaywallStyle.TEXT_FOCUSED
+ private var features: List = emptyList()
+ private var enabled: Boolean = true
+ private var bannerBottom: Float = 20f
+ private var isSubscriber: Boolean = false
+
+
+ private var onPurchase: ((PaywallContext, (() -> Unit)) -> Unit)? = null
+ private var onTermsOfService: ((PaywallContext) -> Unit)? = null
+ private var onPrivacyPolicy: ((PaywallContext) -> Unit)? = null
+
+ // Java-compatible callback holders
+ private var onPurchaseJava: OnPurchaseCallback? = null
+ private var onTermsOfServiceJava: OnTermsOfServiceCallback? = null
+ private var onPrivacyPolicyJava: OnPrivacyPolicyCallback? = null
+
+ fun discountPercent(value: Int): PaywallConfigBuilder {
+ this.discountPercent = value
+ return this
+ }
+
+ fun regularPrice(value: String): PaywallConfigBuilder {
+ this.regularPrice = value
+ return this
+ }
+
+ fun discountedPrice(value: String): PaywallConfigBuilder {
+ this.discountedPrice = value
+ return this
+ }
+
+ fun locale(value: String): PaywallConfigBuilder {
+ this.locale = value
+ return this
+ }
+
+ fun style(value: PaywallStyle): PaywallConfigBuilder {
+ this.style = value
+ return this
+ }
+
+ fun features(value: List): PaywallConfigBuilder {
+ this.features = value
+ return this
+ }
+
+ fun enabled(value: Boolean): PaywallConfigBuilder {
+ this.enabled = value
+ return this
+ }
+
+ fun bannerBottom(value: Float): PaywallConfigBuilder {
+ this.bannerBottom = value
+ return this
+ }
+
+ fun isSubscriber(value: Boolean): PaywallConfigBuilder {
+ this.isSubscriber = value
+ return this
+ }
+
+
+ fun onPurchase(value: ((PaywallContext, (() -> Unit)) -> Unit)?): PaywallConfigBuilder {
+ this.onPurchase = value
+ return this
+ }
+
+ fun onTermsOfService(value: ((PaywallContext) -> Unit)?): PaywallConfigBuilder {
+ this.onTermsOfService = value
+ return this
+ }
+
+ fun onPrivacyPolicy(value: ((PaywallContext) -> Unit)?): PaywallConfigBuilder {
+ this.onPrivacyPolicy = value
+ return this
+ }
+
+ // Java-compatible callback setters
+ fun onPurchase(value: OnPurchaseCallback?): PaywallConfigBuilder {
+ this.onPurchaseJava = value
+ return this
+ }
+
+ fun onTermsOfService(value: OnTermsOfServiceCallback?): PaywallConfigBuilder {
+ this.onTermsOfServiceJava = value
+ return this
+ }
+
+ fun onPrivacyPolicy(value: OnPrivacyPolicyCallback?): PaywallConfigBuilder {
+ this.onPrivacyPolicyJava = value
+ return this
+ }
+
+ fun build(): PaywallConfig {
+ // Convert Java callbacks to Kotlin functions if Java callbacks are provided
+ val kotlinOnPurchase = onPurchaseJava?.let { javaCallback ->
+ { context: PaywallContext, closePaywall: () -> Unit ->
+ javaCallback.onPurchase(context, Runnable { closePaywall() })
+ }
+ } ?: onPurchase
+
+ val kotlinOnTermsOfService = onTermsOfServiceJava?.let { javaCallback ->
+ { context: PaywallContext -> javaCallback.onTermsOfService(context) }
+ } ?: onTermsOfService
+
+ val kotlinOnPrivacyPolicy = onPrivacyPolicyJava?.let { javaCallback ->
+ { context: PaywallContext -> javaCallback.onPrivacyPolicy(context) }
+ } ?: onPrivacyPolicy
+
+ return PaywallConfig(
+ discountPercent = discountPercent,
+ regularPrice = regularPrice,
+ discountedPrice = discountedPrice,
+ locale = locale,
+ style = style,
+ features = features,
+ enabled = enabled,
+ bannerBottom = bannerBottom,
+ isSubscriber = isSubscriber,
+ onPurchase = kotlinOnPurchase,
+ onTermsOfService = kotlinOnTermsOfService,
+ onPrivacyPolicy = kotlinOnPrivacyPolicy
+ )
+ }
+ }
+}
\ No newline at end of file
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerManager.kt b/monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerManager.kt
new file mode 100644
index 0000000..6129775
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerManager.kt
@@ -0,0 +1,247 @@
+package com.monetai.sdk.banner
+
+import android.app.Activity
+import android.content.Context
+import android.os.Build
+import android.util.Log
+import android.view.ViewGroup
+import android.widget.FrameLayout
+import androidx.core.view.ViewCompat
+import androidx.core.view.WindowInsetsCompat
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.monetai.sdk.models.BannerParams
+import com.monetai.sdk.models.DiscountInfo
+import com.monetai.sdk.models.PaywallConfig
+import com.monetai.sdk.paywall.MonetaiPaywallManager
+import com.monetai.sdk.utils.BannerStyleUtils
+import com.monetai.sdk.utils.MonetaiPromotionTimer
+import com.monetai.sdk.utils.MonetaiPromotionTimerDelegate
+import com.monetai.sdk.utils.ThreadUtils
+import com.monetai.sdk.utils.ContextUtils
+import com.monetai.sdk.utils.DimensionUtils
+import java.util.*
+
+/**
+ * BannerManager handles the display and management of banner UI
+ * Includes automatic expiration management like iOS SDK
+ */
+class MonetaiBannerManager(private val context: Context) : MonetaiPromotionTimerDelegate {
+
+ companion object {
+ private const val TAG = "MonetaiBannerManager"
+ }
+
+
+ // MARK: - Published Properties
+ private val _bannerVisible = MutableLiveData(false)
+ val bannerVisible: LiveData = _bannerVisible
+
+ private val _bannerParams = MutableLiveData()
+ val bannerParams: LiveData = _bannerParams
+
+ // MARK: - Private Properties
+ private var paywallConfig: PaywallConfig? = null
+ private var discountInfo: DiscountInfo? = null
+ private var paywallManager: MonetaiPaywallManager? = null
+ private var bannerView: MonetaiBannerView? = null
+
+ // MARK: - Promotion Expiration Management
+ private var promotionTimer: MonetaiPromotionTimer? = null
+
+ init {
+ setupPromotionExpirationManager()
+ }
+
+ // MARK: - Promotion Expiration Setup
+ private fun setupPromotionExpirationManager() {
+ promotionTimer = MonetaiPromotionTimer(context, this)
+ }
+
+ // MARK: - MonetaiPromotionTimerDelegate
+ override fun promotionDidExpire() {
+ hideBanner()
+ }
+
+ // MARK: - Public Methods
+
+ /**
+ * Configure banner with configuration and discount info
+ */
+ fun configure(
+ paywallConfig: PaywallConfig,
+ discountInfo: DiscountInfo?,
+ paywallManager: MonetaiPaywallManager
+ ) {
+ this.paywallConfig = paywallConfig
+ this.discountInfo = discountInfo
+ this.paywallManager = paywallManager
+
+ updateBannerParams()
+
+ // Configure promotion expiration manager (like iOS SDK)
+ discountInfo?.let { discountInfo ->
+ promotionTimer?.configure(discountInfo)
+ }
+
+ updateBannerVisibility()
+
+ // Start monitoring if banner is visible
+ if (_bannerVisible.value == true) {
+ promotionTimer?.startMonitoring()
+ }
+ }
+
+ /**
+ * Show banner (internal use only)
+ */
+ internal fun showBanner() {
+ if (_bannerParams.value == null) {
+ Log.w(TAG, "Cannot show banner - bannerParams is null")
+ return
+ }
+
+ ThreadUtils.runOnMainThread {
+ _bannerVisible.value = true
+ createAndShowBannerView()
+ // Start monitoring promotion expiration when banner is shown (like iOS SDK)
+ promotionTimer?.startMonitoring()
+ }
+ }
+
+ /**
+ * Hide banner (internal use only)
+ */
+ internal fun hideBanner() {
+ ThreadUtils.runOnMainThread {
+ _bannerVisible.value = false
+ removeBannerView()
+ // Stop monitoring promotion expiration when banner is hidden (like iOS SDK)
+ promotionTimer?.stopMonitoring()
+ }
+ }
+
+ /**
+ * Cleanup resources when banner manager is no longer needed
+ */
+ fun destroy() {
+ hideBanner()
+ promotionTimer?.destroy()
+ promotionTimer = null
+ }
+
+ // MARK: - Private Methods
+
+ private fun updateBannerParams() {
+ val paywallConfig = paywallConfig ?: return
+ val discountInfo = discountInfo ?: return
+
+ val params = BannerParams(
+ enabled = paywallConfig.enabled,
+ isSubscriber = paywallConfig.isSubscriber,
+ locale = paywallConfig.locale,
+ discountPercent = paywallConfig.discountPercent,
+ endedAt = discountInfo.endedAt,
+ style = paywallConfig.style,
+ bottom = paywallConfig.bannerBottom
+ )
+
+ ThreadUtils.runOnMainThread {
+ _bannerParams.value = params
+ }
+ }
+
+ private fun updateBannerVisibility() {
+ if (shouldShowBanner()) {
+ showBanner()
+ } else {
+ hideBanner()
+ }
+ }
+
+ private fun shouldShowBanner(): Boolean {
+ val paywallConfig = paywallConfig ?: return false
+ val discountInfo = discountInfo ?: return false
+
+ val now = Date()
+ val isDiscountActive = discountInfo.endedAt > now
+
+ // Get real-time subscription status from SDK instead of static config
+ val isSubscriber = com.monetai.sdk.MonetaiSDK.shared.getSubscriptionStatus()
+
+ // Use real-time subscription status instead of static config
+ return paywallConfig.enabled && !isSubscriber && isDiscountActive
+ }
+
+ private fun createAndShowBannerView() {
+ val bannerParams = _bannerParams.value ?: return
+
+ // Create banner view if not exists
+ if (bannerView == null) {
+ bannerView = MonetaiBannerView(context).apply {
+ setBannerParams(bannerParams)
+ setOnPaywallClickListener {
+ paywallManager?.showPaywall()
+ }
+ }
+
+ // Calculate banner dimensions
+ val requestedBottomMargin = DimensionUtils.dpToPx(context, bannerParams.bottom)
+ val bannerHeightDp = BannerStyleUtils.getBannerHeight(bannerParams.style)
+ val bannerHeightPx = DimensionUtils.dpToPx(context, bannerHeightDp)
+ val horizontalMarginDp = BannerStyleUtils.getBannerHorizontalMargin(bannerParams.style)
+ val horizontalMarginPx = DimensionUtils.dpToPx(context, horizontalMarginDp)
+
+ // Find activity's root view (DecorView) - simpler approach
+ val activity = ContextUtils.findActivity(context)
+ if (activity != null) {
+ val decorView = activity.window.decorView as ViewGroup
+
+ // Calculate bottom margin relative to NavigationBar (like iOS safe area)
+ val windowInsets = ViewCompat.getRootWindowInsets(decorView)
+ val navBarHeight = windowInsets?.getInsets(WindowInsetsCompat.Type.navigationBars())?.bottom ?: 0
+ val bottomMarginPx = navBarHeight + requestedBottomMargin
+
+ // Simple FrameLayout params for bottom positioning with horizontal margins
+ val layoutParams = FrameLayout.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ bannerHeightPx,
+ android.view.Gravity.BOTTOM
+ ).apply {
+ setMargins(horizontalMarginPx, 0, horizontalMarginPx, bottomMarginPx)
+ }
+
+ // Add banner to activity's decor view
+ decorView.addView(bannerView, layoutParams)
+ } else {
+ Log.e(TAG, "Cannot find current activity")
+ return
+ }
+ }
+
+ // Show banner view
+ bannerView?.show()
+ }
+
+ private fun removeBannerView() {
+ bannerView?.let { banner ->
+ banner.hide()
+
+ // Remove from activity's decor view
+ try {
+ val activity = ContextUtils.findActivity(context)
+ if (activity != null) {
+ val decorView = activity.window.decorView as ViewGroup
+ decorView.removeView(banner)
+ } else {
+ Log.w(TAG, "Cannot find current activity to remove banner")
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to remove banner: ${e.message}")
+ }
+ }
+ bannerView = null
+ }
+
+
+}
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerView.kt b/monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerView.kt
new file mode 100644
index 0000000..b73882d
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerView.kt
@@ -0,0 +1,248 @@
+package com.monetai.sdk.banner
+
+import android.annotation.SuppressLint
+import android.content.Context
+import android.util.AttributeSet
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import android.view.ViewGroup.MarginLayoutParams
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.FrameLayout
+import com.monetai.sdk.constants.WebViewConstants
+import com.monetai.sdk.models.BannerParams
+import com.monetai.sdk.models.PaywallStyle
+import com.monetai.sdk.utils.BannerStyleUtils
+import com.monetai.sdk.utils.DateTimeHelper
+import com.monetai.sdk.utils.DimensionUtils
+import java.util.*
+
+/**
+ * BannerView displays the banner using WebView
+ */
+class MonetaiBannerView @JvmOverloads constructor(
+ context: Context,
+ attrs: AttributeSet? = null,
+ defStyleAttr: Int = 0
+) : FrameLayout(context, attrs, defStyleAttr) {
+
+ companion object {
+ private const val TAG = "MonetaiBannerView"
+ }
+
+
+ // MARK: - Properties
+ private var bannerParams: BannerParams? = null
+ private var onPaywallClickListener: (() -> Unit)? = null
+ private lateinit var webView: WebView
+
+ // MARK: - Initialization
+
+ init {
+ // setupWebView() will be called when setBannerParams() is called
+ // to ensure bannerParams is available for proper dimension calculation
+ }
+
+ // MARK: - Public Methods
+
+ fun setBannerParams(params: BannerParams) {
+ this.bannerParams = params
+ // Re-setup WebView with new banner params to ensure correct dimensions
+ setupWebView()
+ loadBanner()
+ }
+
+ fun setOnPaywallClickListener(listener: (() -> Unit)?) {
+ this.onPaywallClickListener = listener
+ }
+
+ fun show() {
+ Log.d(TAG, "👁️ MonetaiBannerView.show() called")
+ Log.d(TAG, " Current visibility: $visibility")
+ Log.d(TAG, " Current width: $width, height: $height")
+ Log.d(TAG, " Current x: $x, y: $y")
+ Log.d(TAG, " Parent: $parent")
+ Log.d(TAG, " LayoutParams: $layoutParams")
+
+ visibility = View.VISIBLE
+
+ // Force layout update
+ requestLayout()
+ invalidate()
+
+ Log.d(TAG, "✅ Visibility set to VISIBLE")
+ Log.d(TAG, " New visibility: $visibility")
+ Log.d(TAG, " New width: $width, height: $height")
+ Log.d(TAG, " New x: $x, y: $y")
+
+ // Post a delayed check to see if dimensions are set
+ post {
+ Log.d(TAG, "🔄 Post-check dimensions:")
+ Log.d(TAG, " Width: $width, Height: $height")
+ Log.d(TAG, " X: $x, Y: $y")
+ Log.d(TAG, " Is visible: ${visibility == View.VISIBLE}")
+ Log.d(TAG, " Alpha: $alpha")
+ }
+ }
+
+ fun hide() {
+ visibility = View.GONE
+ }
+
+ // MARK: - Private Methods
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun setupWebView() {
+ Log.d(TAG, "🔧 setupWebView() called")
+
+ // Set banner dimensions based on style
+ val style = bannerParams?.style ?: PaywallStyle.KEY_FEATURE_SUMMARY
+ val bannerHeightDp = BannerStyleUtils.getBannerHeight(style)
+ val heightPx = DimensionUtils.dpToPx(context, bannerHeightDp)
+
+ Log.d(TAG, " Banner height: ${bannerHeightDp}dp = ${heightPx}px")
+
+ // Set banner view size using ViewGroup.LayoutParams
+ var newLayoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ heightPx
+ )
+
+ Log.d(TAG, " Created LayoutParams: width=${newLayoutParams.width}, height=${newLayoutParams.height}")
+
+ // Set banner position (bottom)
+ val bannerParams = bannerParams
+ if (bannerParams != null) {
+ val bottomMarginPx = DimensionUtils.dpToPx(context, bannerParams.bottom)
+ Log.d(TAG, " Bottom margin: ${bannerParams.bottom}dp = ${bottomMarginPx}px")
+
+ // Create MarginLayoutParams directly to avoid smart cast issues
+ val marginLayoutParams = ViewGroup.MarginLayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ heightPx
+ )
+ marginLayoutParams.bottomMargin = bottomMarginPx
+ newLayoutParams = marginLayoutParams
+
+ Log.d(TAG, " ✅ Final LayoutParams: width=${marginLayoutParams.width}, height=${marginLayoutParams.height}, bottomMargin=${marginLayoutParams.bottomMargin}")
+ }
+
+ // Apply layout params
+ layoutParams = newLayoutParams
+ Log.d(TAG, " Applied layoutParams: $layoutParams")
+
+ // Apply corner radius based on style (matching iOS implementation)
+ val cornerRadiusDp = BannerStyleUtils.getBannerCornerRadius(style)
+ val cornerRadiusPx = DimensionUtils.dpToPx(context, cornerRadiusDp)
+ Log.d(TAG, " Corner radius: ${cornerRadiusDp}dp = ${cornerRadiusPx}px")
+
+ // Set corner radius for the banner view
+ background = android.graphics.drawable.GradientDrawable().apply {
+ setColor(android.graphics.Color.TRANSPARENT)
+ setCornerRadius(cornerRadiusPx.toFloat())
+ }
+ clipToOutline = true
+ Log.d(TAG, " ✅ Corner radius applied: ${cornerRadiusDp}dp")
+
+ Log.d(TAG, " Creating WebView...")
+ webView = WebView(context).apply {
+ layoutParams = LayoutParams(
+ LayoutParams.MATCH_PARENT,
+ LayoutParams.MATCH_PARENT
+ )
+
+ // Hide WebView initially to prevent flickering
+ alpha = 0f
+
+ Log.d(TAG, " WebView LayoutParams: width=${layoutParams.width}, height=${layoutParams.height}")
+
+ // WebView settings
+ settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ allowFileAccess = false
+ allowContentAccess = false
+ mediaPlaybackRequiresUserGesture = false
+ // Enable hardware acceleration for smoother rendering
+ setRenderPriority(android.webkit.WebSettings.RenderPriority.HIGH)
+ }
+
+ // Set layer type for better performance
+ setLayerType(View.LAYER_TYPE_HARDWARE, null)
+
+ // Set user agent
+ settings.userAgentString = WebViewConstants.WEBVIEW_USER_AGENT
+
+ // Add JavaScript interface
+ addJavascriptInterface(BannerJavaScriptInterface(), "monetai")
+
+ // Set WebView client
+ webViewClient = object : WebViewClient() {
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ Log.d(TAG, "✅ Banner loaded successfully: $url")
+
+ // Show WebView immediately after loading
+ alpha = 1f
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String?
+ ) {
+ super.onReceivedError(view, errorCode, description, failingUrl)
+ Log.w(TAG, "❌ Banner loading error: $errorCode - $description")
+ }
+ }
+ }
+
+ Log.d(TAG, " Adding WebView to MonetaiBannerView...")
+ addView(webView)
+ Log.d(TAG, " ✅ WebView added. Child count: $childCount")
+ }
+
+ private fun loadBanner() {
+ val url = buildBannerUrl()
+ webView.loadUrl(url)
+ }
+
+ private fun buildBannerUrl(): String {
+ val bannerParams = bannerParams ?: return ""
+ val baseUrl = "${WebViewConstants.WEB_BASE_URL}/banner"
+ val style = bannerParams.style.value
+
+ val queryParams = mutableListOf()
+
+ queryParams.add("discount=${bannerParams.discountPercent}")
+ queryParams.add("locale=${bannerParams.locale}")
+
+ // Use ISO8601 format with timezone info
+ val endedAtIso = DateTimeHelper.formatToISO8601(bannerParams.endedAt)
+ val encodedEndedAt = java.net.URLEncoder.encode(endedAtIso, "UTF-8")
+ queryParams.add("endedAt=$encodedEndedAt")
+
+ val queryString = queryParams.joinToString("&")
+ return "$baseUrl/$style?$queryString"
+ }
+
+ // MARK: - Private Methods
+
+ // MARK: - JavaScript Interface
+
+ inner class BannerJavaScriptInterface {
+ @JavascriptInterface
+ fun postMessage(message: String) {
+ Log.d(TAG, "📞 JavaScript message received: '$message'")
+ if (message == WebViewConstants.MESSAGE_CLICK_BANNER) {
+ Log.d(TAG, "🎯 Banner click detected - invoking paywall listener")
+ onPaywallClickListener?.invoke()
+ } else {
+ Log.d(TAG, "❓ Unknown message: '$message'")
+ }
+ }
+ }
+}
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/constants/WebViewConstants.kt b/monetai-sdk/src/main/java/com/monetai/sdk/constants/WebViewConstants.kt
new file mode 100644
index 0000000..4057000
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/constants/WebViewConstants.kt
@@ -0,0 +1,16 @@
+package com.monetai.sdk.constants
+
+/**
+ * Common WebView constants shared between Banner and Paywall
+ * Matches iOS SDK configuration
+ */
+object WebViewConstants {
+ /// Base URL for monetai web paywall/banner (matching iOS SDK)
+ const val WEB_BASE_URL = "https://dashboard.monetai.io/webview"
+
+ /// User-Agent for SDK webviews (matching iOS SDK)
+ const val WEBVIEW_USER_AGENT = "MonetaiSDK"
+
+ /// Message constants for JavaScript communication
+ const val MESSAGE_CLICK_BANNER = "CLICK_BANNER"
+}
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/models/BannerParams.kt b/monetai-sdk/src/main/java/com/monetai/sdk/models/BannerParams.kt
new file mode 100644
index 0000000..66d42a8
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/models/BannerParams.kt
@@ -0,0 +1,16 @@
+package com.monetai.sdk.models
+
+import java.util.Date
+
+/**
+ * Banner parameters for display
+ */
+data class BannerParams(
+ val enabled: Boolean, // Whether banner is enabled
+ val isSubscriber: Boolean, // Whether user is subscriber
+ val locale: String, // Language setting
+ val discountPercent: Int, // Discount percentage
+ val endedAt: Date, // Expiration date
+ val style: PaywallStyle, // Banner style
+ val bottom: Float = 20f // Bottom margin
+)
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/models/DiscountInfo.kt b/monetai-sdk/src/main/java/com/monetai/sdk/models/DiscountInfo.kt
new file mode 100644
index 0000000..37384a0
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/models/DiscountInfo.kt
@@ -0,0 +1,13 @@
+package com.monetai.sdk.models
+
+import java.util.Date
+
+/**
+ * Discount information
+ */
+data class DiscountInfo(
+ val startedAt: Date, // Start date
+ val endedAt: Date, // End date
+ val userId: String, // User ID
+ val sdkKey: String // SDK key
+)
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/models/Feature.kt b/monetai-sdk/src/main/java/com/monetai/sdk/models/Feature.kt
new file mode 100644
index 0000000..4187513
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/models/Feature.kt
@@ -0,0 +1,14 @@
+package com.monetai.sdk.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Feature model for paywall
+ */
+@Parcelize
+data class Feature(
+ val title: String, // Feature title
+ val description: String, // Feature description
+ val isPremiumOnly: Boolean = false // Whether this feature is premium only
+) : Parcelable
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallConfig.kt b/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallConfig.kt
new file mode 100644
index 0000000..bedee2e
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallConfig.kt
@@ -0,0 +1,34 @@
+package com.monetai.sdk.models
+
+import android.app.Activity
+import android.content.Context
+
+/**
+ * Context information provided to paywall callbacks
+ */
+data class PaywallContext(
+ val activity: Activity, // Current activity for UI operations
+ val applicationContext: Context // Application context for resources
+)
+
+/**
+ * Paywall configuration with Context Injection Pattern
+ * Provides the same functionality as iOS and React Native SDKs
+ */
+data class PaywallConfig @JvmOverloads constructor(
+ val discountPercent: Int, // Discount percentage (0-100)
+ val regularPrice: String, // Regular price
+ val discountedPrice: String, // Discounted price
+ val locale: String, // Language setting
+ val style: PaywallStyle, // Paywall style
+ val features: List = emptyList(), // Feature list
+ val enabled: Boolean = true, // Whether paywall is enabled
+ val bannerBottom: Float = 20f, // Banner bottom margin
+ val isSubscriber: Boolean = false, // Whether user is subscriber
+
+
+ // Context Injection Pattern - callbacks receive context for UI operations
+ val onPurchase: ((PaywallContext, (() -> Unit)) -> Unit)? = null, // Purchase handling with context
+ val onTermsOfService: ((PaywallContext) -> Unit)? = null, // Terms of service with context
+ val onPrivacyPolicy: ((PaywallContext) -> Unit)? = null // Privacy policy with context
+)
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallParams.kt b/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallParams.kt
new file mode 100644
index 0000000..602f840
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallParams.kt
@@ -0,0 +1,19 @@
+package com.monetai.sdk.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Paywall parameters for display
+ * Provides the same functionality as iOS and React Native SDKs
+ */
+@Parcelize
+data class PaywallParams(
+ val discountPercent: String, // Discount percentage (string)
+ val endedAt: String, // Expiration date (ISO8601)
+ val regularPrice: String, // Regular price
+ val discountedPrice: String, // Discounted price
+ val locale: String, // Language setting
+ val features: List, // Feature list
+ val style: PaywallStyle // Paywall style
+) : Parcelable
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallStyle.kt b/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallStyle.kt
new file mode 100644
index 0000000..ac8f768
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallStyle.kt
@@ -0,0 +1,15 @@
+package com.monetai.sdk.models
+
+import android.os.Parcelable
+import kotlinx.parcelize.Parcelize
+
+/**
+ * Paywall display style types
+ */
+@Parcelize
+enum class PaywallStyle(val value: String) : Parcelable {
+ COMPACT("compact"), // Compact style
+ HIGHLIGHT_BENEFITS("highlight-benefits"), // Highlight benefits style
+ KEY_FEATURE_SUMMARY("key-feature-summary"), // Key feature summary style
+ TEXT_FOCUSED("text-focused") // Text focused style
+}
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallActivity.kt b/monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallActivity.kt
new file mode 100644
index 0000000..3c71b3e
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallActivity.kt
@@ -0,0 +1,467 @@
+package com.monetai.sdk.paywall
+
+import android.annotation.SuppressLint
+import android.app.Activity
+import android.os.Bundle
+import android.util.Log
+import android.view.View
+import android.view.ViewGroup
+import android.view.animation.AnimationUtils
+import android.webkit.JavascriptInterface
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import android.widget.FrameLayout
+import android.widget.ProgressBar
+import androidx.appcompat.app.AppCompatActivity
+import com.monetai.sdk.constants.WebViewConstants
+import com.monetai.sdk.models.PaywallParams
+import com.monetai.sdk.models.PaywallStyle
+import com.monetai.sdk.utils.DimensionUtils
+
+/**
+ * PaywallActivity displays the paywall using WebView
+ * Provides the same functionality as iOS and React Native SDKs
+ */
+class MonetaiPaywallActivity : AppCompatActivity() {
+
+ companion object {
+ private const val TAG = "MonetaiPaywallActivity"
+
+ // Message constants (matching iOS/React Native)
+ private const val MESSAGE_KEY_PURCHASE_BUTTON = "CLICK_PURCHASE_BUTTON"
+ private const val MESSAGE_KEY_CLOSE_BUTTON = "CLICK_CLOSE_BUTTON"
+ private const val MESSAGE_KEY_TERMS_OF_SERVICE = "CLICK_TERMS_OF_SERVICE"
+ private const val MESSAGE_KEY_PRIVACY_POLICY = "CLICK_PRIVACY_POLICY"
+
+ // Static callback holder for Activity instances using WeakReference to prevent memory leaks
+ @Volatile
+ private var staticOnClose: java.lang.ref.WeakReference<(() -> Unit)?>? = null
+ @Volatile
+ private var staticOnPurchase: java.lang.ref.WeakReference<((com.monetai.sdk.models.PaywallContext, (() -> Unit)) -> Unit)?>? = null
+ @Volatile
+ private var staticOnTermsOfService: java.lang.ref.WeakReference<((com.monetai.sdk.models.PaywallContext) -> Unit)?>? = null
+ @Volatile
+ private var staticOnPrivacyPolicy: java.lang.ref.WeakReference<((com.monetai.sdk.models.PaywallContext) -> Unit)?>? = null
+
+ internal fun setStaticCallbacks(
+ onClose: (() -> Unit)?,
+ onPurchase: ((com.monetai.sdk.models.PaywallContext, (() -> Unit)) -> Unit)?,
+ onTermsOfService: ((com.monetai.sdk.models.PaywallContext) -> Unit)?,
+ onPrivacyPolicy: ((com.monetai.sdk.models.PaywallContext) -> Unit)?
+ ) {
+ staticOnClose = java.lang.ref.WeakReference(onClose)
+ staticOnPurchase = java.lang.ref.WeakReference(onPurchase)
+ staticOnTermsOfService = java.lang.ref.WeakReference(onTermsOfService)
+ staticOnPrivacyPolicy = java.lang.ref.WeakReference(onPrivacyPolicy)
+ }
+ }
+
+
+ // MARK: - Properties
+ private lateinit var paywallParams: PaywallParams
+ private var onClose: (() -> Unit)? = null
+ private var onPurchase: ((com.monetai.sdk.models.PaywallContext, (() -> Unit)) -> Unit)? = null
+ private var onTermsOfService: ((com.monetai.sdk.models.PaywallContext) -> Unit)? = null
+ private var onPrivacyPolicy: ((com.monetai.sdk.models.PaywallContext) -> Unit)? = null
+
+ private lateinit var webView: WebView
+ private lateinit var dimBackgroundView: View
+ private lateinit var progressBar: ProgressBar
+ private lateinit var rootView: FrameLayout
+
+ private var isLoaded = false
+ private var hasWebViewError = false
+
+ // MARK: - Lifecycle
+
+ override fun onCreate(savedInstanceState: Bundle?) {
+ super.onCreate(savedInstanceState)
+
+ // Get paywall params from intent
+ paywallParams = intent.getParcelableExtra("paywallParams")
+ ?: throw IllegalArgumentException("PaywallParams is required")
+
+ // Get static callbacks
+ onClose = staticOnClose?.get()
+ onPurchase = staticOnPurchase?.get()
+ onTermsOfService = staticOnTermsOfService?.get()
+ onPrivacyPolicy = staticOnPrivacyPolicy?.get()
+
+ setupUI()
+ loadPaywall()
+ }
+
+ override fun onDestroy() {
+ super.onDestroy()
+
+ // Clean up static callbacks to prevent memory leaks
+ staticOnClose = null
+ staticOnPurchase = null
+ staticOnTermsOfService = null
+ staticOnPrivacyPolicy = null
+
+ // Clean up WebView
+ webView.destroy()
+ }
+
+ // MARK: - UI Setup
+
+ private fun setupUI() {
+ // Set main view background to clear
+ rootView = FrameLayout(this).apply {
+ layoutParams = ViewGroup.LayoutParams(
+ ViewGroup.LayoutParams.MATCH_PARENT,
+ ViewGroup.LayoutParams.MATCH_PARENT
+ )
+ }
+
+ // PaywallActivity is a separate Activity, so no need for z-index/elevation controls
+
+ // Setup dim background view (matching iOS 40% opacity)
+ dimBackgroundView = View(this).apply {
+ setBackgroundColor(0x66000000.toInt()) // 40% black (same as iOS)
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT
+ )
+ }
+
+ // Setup progress bar
+ progressBar = ProgressBar(this).apply {
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT
+ ).apply {
+ gravity = android.view.Gravity.CENTER
+ }
+ }
+
+ // Setup WebView
+ setupWebView()
+
+ // Add rounded corners for COMPACT style (matching iOS 12pt corner radius)
+ if (paywallParams.style == PaywallStyle.COMPACT) {
+ val cornerRadius = DimensionUtils.dpToPx(this@MonetaiPaywallActivity, 12).toFloat()
+ webView.background = android.graphics.drawable.GradientDrawable().apply {
+ cornerRadii = floatArrayOf(
+ cornerRadius, cornerRadius, // top-left
+ cornerRadius, cornerRadius, // top-right
+ 0f, 0f, // bottom-right
+ 0f, 0f // bottom-left
+ )
+ setColor(android.graphics.Color.TRANSPARENT)
+ }
+ webView.clipToOutline = true
+ }
+
+ // Hide WebView initially to prevent white flash
+ webView.alpha = 0f
+
+ // Add views to root
+ rootView.addView(dimBackgroundView)
+ rootView.addView(webView)
+ rootView.addView(progressBar)
+
+ setContentView(rootView)
+
+ // Setup click listeners
+ dimBackgroundView.setOnClickListener {
+ onClose?.invoke()
+ finish() // Immediately close the activity
+ }
+
+ // For COMPACT style, also allow root view clicks to close paywall
+ if (paywallParams.style == PaywallStyle.COMPACT) {
+ rootView.setOnClickListener {
+ onClose?.invoke()
+ finish()
+ }
+
+ // Prevent WebView clicks from propagating to parent
+ webView.setOnClickListener {
+ // Consume the click event to prevent it from reaching parent
+ }
+ }
+
+ // Apply style-specific layout
+ applyStyleSpecificLayout()
+ }
+
+ @SuppressLint("SetJavaScriptEnabled")
+ private fun setupWebView() {
+
+ webView = WebView(this).apply {
+ // WebView settings
+ settings.apply {
+ javaScriptEnabled = true
+ domStorageEnabled = true
+ allowFileAccess = false
+ allowContentAccess = false
+ mediaPlaybackRequiresUserGesture = false
+ // Enable modern web features
+ setSupportZoom(false)
+ builtInZoomControls = false
+ displayZoomControls = false
+ }
+
+ // Set WebView background to transparent to prevent white flash
+ setBackgroundColor(android.graphics.Color.TRANSPARENT)
+
+ // Hide WebView initially to prevent white flash
+ alpha = 0f
+
+ // Set user agent
+ settings.userAgentString = WebViewConstants.WEBVIEW_USER_AGENT
+
+ // Add JavaScript interface
+ addJavascriptInterface(PaywallJavaScriptInterface(), "monetai")
+
+ // Set WebView client
+ webViewClient = createWebViewClient()
+ }
+
+ // Apply style-specific WebView layout
+ applyWebViewLayout()
+ }
+
+ private fun createWebViewClient(): WebViewClient {
+ return object : WebViewClient() {
+ override fun onPageStarted(view: WebView?, url: String?, favicon: android.graphics.Bitmap?) {
+ super.onPageStarted(view, url, favicon)
+ isLoaded = false
+ progressBar.visibility = View.VISIBLE
+ }
+
+ override fun onPageFinished(view: WebView?, url: String?) {
+ super.onPageFinished(view, url)
+ isLoaded = true
+ progressBar.visibility = View.GONE
+
+ // Show WebView immediately after loading (no animation for snappy response)
+ webView.alpha = 1f
+ }
+
+ override fun onReceivedError(
+ view: WebView?,
+ errorCode: Int,
+ description: String?,
+ failingUrl: String?
+ ) {
+ super.onReceivedError(view, errorCode, description, failingUrl)
+ Log.e(TAG, "WebView error: $errorCode - $description")
+ isLoaded = true
+ hasWebViewError = true
+ showError()
+ }
+
+ override fun onReceivedHttpError(
+ view: WebView?,
+ request: android.webkit.WebResourceRequest?,
+ errorResponse: android.webkit.WebResourceResponse?
+ ) {
+ super.onReceivedHttpError(view, request, errorResponse)
+ Log.e(TAG, "WebView HTTP error: ${errorResponse?.statusCode}")
+ isLoaded = true
+ hasWebViewError = true
+ showError()
+ }
+ }
+ }
+
+ private fun applyStyleSpecificLayout() {
+ when (paywallParams.style) {
+ PaywallStyle.COMPACT -> {
+ // Compact style: bottom sheet with dim background (matching iOS)
+ dimBackgroundView.alpha = 1.0f
+ webView.alpha = 0.0f
+
+ // Convert 372dp to pixels for initial translation (matching iOS 372pt)
+ webView.translationY = DimensionUtils.dpToPx(this@MonetaiPaywallActivity, 372).toFloat()
+
+ // iOS-style slide-up animation: 0.3s duration with ease-out curve
+ webView.post {
+ webView.animate()
+ .alpha(1.0f)
+ .translationY(0f)
+ .setDuration(300) // 0.3 seconds like iOS
+ .setInterpolator(AnimationUtils.loadInterpolator(this, android.R.interpolator.decelerate_quint)) // ease-out equivalent
+ .start()
+ }
+ }
+ else -> {
+ // Other styles: full screen coverage
+ dimBackgroundView.alpha = 1.0f
+ webView.alpha = 1.0f
+ webView.translationY = 0f
+ }
+ }
+ }
+
+ private fun applyWebViewLayout() {
+ when (paywallParams.style) {
+ PaywallStyle.COMPACT -> {
+ // Compact style: bottom-anchored with fixed height (matching iOS 372pt)
+ webView.layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ DimensionUtils.dpToPx(this@MonetaiPaywallActivity, 372)
+ ).apply {
+ gravity = android.view.Gravity.BOTTOM
+ // No margins - full width like iOS
+ }
+ }
+ else -> {
+ // Other styles: full screen
+ webView.layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT
+ )
+ }
+ }
+ }
+
+ private fun loadPaywall() {
+ val url = buildPaywallUrl()
+ webView.loadUrl(url)
+ }
+
+ private fun buildPaywallUrl(): String {
+ val baseUrl = "${WebViewConstants.WEB_BASE_URL}/paywall"
+ val style = paywallParams.style.value
+
+ val queryParams = mutableListOf()
+
+ if (paywallParams.discountPercent.isNotEmpty()) {
+ queryParams.add("discount=${paywallParams.discountPercent}")
+ }
+ if (paywallParams.endedAt.isNotEmpty()) {
+ val encodedEndedAt = java.net.URLEncoder.encode(paywallParams.endedAt, "UTF-8")
+ queryParams.add("endedAt=$encodedEndedAt")
+ }
+ if (paywallParams.regularPrice.isNotEmpty()) {
+ val encodedRegularPrice = java.net.URLEncoder.encode(paywallParams.regularPrice, "UTF-8")
+ queryParams.add("regularPrice=$encodedRegularPrice")
+ }
+ if (paywallParams.discountedPrice.isNotEmpty()) {
+ val encodedDiscountedPrice = java.net.URLEncoder.encode(paywallParams.discountedPrice, "UTF-8")
+ queryParams.add("discountedPrice=$encodedDiscountedPrice")
+ }
+ if (paywallParams.locale.isNotEmpty()) {
+ queryParams.add("locale=${paywallParams.locale}")
+ }
+ if (paywallParams.features.isNotEmpty()) {
+ val featuresJson = paywallParams.features.joinToString(",") { feature ->
+ "{\"title\":\"${feature.title}\",\"description\":\"${feature.description}\",\"isPremiumOnly\":${feature.isPremiumOnly}}"
+ }
+ val encodedFeatures = java.net.URLEncoder.encode("[$featuresJson]", "UTF-8")
+ queryParams.add("features=$encodedFeatures")
+ }
+
+ val queryString = if (queryParams.isNotEmpty()) "?${queryParams.joinToString("&")}" else ""
+ return "$baseUrl/$style$queryString"
+ }
+
+ private fun showError() {
+ // Show error overlay similar to React Native
+ val errorOverlay = FrameLayout(this).apply {
+ setBackgroundColor(0xCC000000.toInt()) // 80% black
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.MATCH_PARENT,
+ FrameLayout.LayoutParams.MATCH_PARENT
+ )
+ }
+
+ val errorCard = FrameLayout(this).apply {
+ setBackgroundColor(0xFFFFFFFF.toInt())
+ layoutParams = FrameLayout.LayoutParams(
+ FrameLayout.LayoutParams.WRAP_CONTENT,
+ FrameLayout.LayoutParams.WRAP_CONTENT
+ ).apply {
+ gravity = android.view.Gravity.CENTER
+ setMargins(32, 32, 32, 32)
+ }
+ }
+
+ // Add error card to error overlay
+ errorOverlay.addView(errorCard)
+
+ // Add error overlay to root
+ rootView.addView(errorOverlay)
+ }
+
+ // MARK: - JavaScript Interface
+
+ inner class PaywallJavaScriptInterface {
+ @JavascriptInterface
+ fun postMessage(message: String) {
+ runOnUiThread {
+ handleWebViewMessage(message)
+ }
+ }
+ }
+
+ private fun handleWebViewMessage(message: String) {
+
+ when (message) {
+ MESSAGE_KEY_PURCHASE_BUTTON -> {
+ val purchaseCallback = onPurchase
+ if (purchaseCallback != null) {
+ val paywallContext = com.monetai.sdk.models.PaywallContext(
+ activity = this@MonetaiPaywallActivity,
+ applicationContext = applicationContext
+ )
+ val closePaywall = {
+ finish()
+ }
+ purchaseCallback.invoke(paywallContext, closePaywall)
+ } else {
+ Log.e(TAG, "❌ onPurchase callback is null!")
+ }
+ }
+ MESSAGE_KEY_CLOSE_BUTTON -> {
+
+ // First, directly close this Activity
+ finish()
+
+ // Then invoke callback for cleanup
+ try {
+ onClose?.invoke()
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Error invoking close callback", e)
+ }
+ }
+ MESSAGE_KEY_TERMS_OF_SERVICE -> {
+ val paywallContext = com.monetai.sdk.models.PaywallContext(
+ activity = this@MonetaiPaywallActivity,
+ applicationContext = applicationContext
+ )
+ onTermsOfService?.invoke(paywallContext)
+ }
+ MESSAGE_KEY_PRIVACY_POLICY -> {
+ val paywallContext = com.monetai.sdk.models.PaywallContext(
+ activity = this@MonetaiPaywallActivity,
+ applicationContext = applicationContext
+ )
+ onPrivacyPolicy?.invoke(paywallContext)
+ }
+ else -> {
+ Log.w(TAG, "❓ Unknown message: '$message'")
+ }
+ }
+ }
+
+ // MARK: - Public Methods
+
+ fun setCallbacks(
+ onClose: (() -> Unit)? = null,
+ onPurchase: ((com.monetai.sdk.models.PaywallContext, (() -> Unit)) -> Unit)? = null,
+ onTermsOfService: ((com.monetai.sdk.models.PaywallContext) -> Unit)? = null,
+ onPrivacyPolicy: ((com.monetai.sdk.models.PaywallContext) -> Unit)? = null
+ ) {
+
+ this.onClose = onClose
+ this.onPurchase = onPurchase
+ this.onTermsOfService = onTermsOfService
+ this.onPrivacyPolicy = onPrivacyPolicy
+
+ }
+}
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallManager.kt b/monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallManager.kt
new file mode 100644
index 0000000..21f62bf
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallManager.kt
@@ -0,0 +1,211 @@
+package com.monetai.sdk.paywall
+
+import android.app.Activity
+import android.content.Context
+import android.content.Intent
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import android.webkit.WebView
+import android.webkit.WebViewClient
+import androidx.lifecycle.LiveData
+import androidx.lifecycle.MutableLiveData
+import com.monetai.sdk.models.DiscountInfo
+import com.monetai.sdk.models.PaywallConfig
+import com.monetai.sdk.models.PaywallParams
+import com.monetai.sdk.models.PaywallStyle
+import com.monetai.sdk.utils.DateTimeHelper
+import com.monetai.sdk.utils.ThreadUtils
+import com.monetai.sdk.utils.ContextUtils
+import java.util.*
+
+/**
+ * PaywallManager handles the display and management of paywall UI
+ * Provides the same functionality as iOS and React Native SDKs
+ */
+class MonetaiPaywallManager {
+
+ companion object {
+ private const val TAG = "MonetaiPaywallManager"
+ }
+
+ // MARK: - Published Properties
+ private val _paywallVisible = MutableLiveData(false)
+ val paywallVisible: LiveData = _paywallVisible
+
+ private val _paywallParams = MutableLiveData()
+ val paywallParams: LiveData = _paywallParams
+
+ // MARK: - Private Properties
+ private var paywallConfig: PaywallConfig? = null
+ private var discountInfo: DiscountInfo? = null
+ private var currentPaywallActivity: MonetaiPaywallActivity? = null
+
+ // MARK: - Public Methods
+
+ /**
+ * Configure paywall with configuration and discount info
+ */
+ fun configure(paywallConfig: PaywallConfig, discountInfo: DiscountInfo?) {
+ this.paywallConfig = paywallConfig
+ this.discountInfo = discountInfo
+
+ updatePaywallParams()
+ }
+
+ /**
+ * Show paywall (internal use only)
+ */
+ internal fun showPaywall() {
+ // Ensure we're on the main thread for UI operations
+ if (!ThreadUtils.isMainThread()) {
+ ThreadUtils.runOnMainThread {
+ showPaywall()
+ }
+ return
+ }
+
+ if (_paywallParams.value == null) {
+ updatePaywallParams()
+
+ if (_paywallParams.value == null) {
+ Log.e(TAG, "Cannot show paywall - paywallParams still null after update")
+ return
+ }
+ }
+
+ // Ensure UI update happens on main thread
+ ThreadUtils.runOnMainThread {
+ _paywallVisible.value = true
+ presentPaywall()
+ }
+ }
+
+ /**
+ * Hide paywall (internal use only)
+ */
+ internal fun hidePaywall() {
+ // Ensure UI update happens on main thread
+ ThreadUtils.runOnMainThread {
+ _paywallVisible.value = false
+ dismissPaywall()
+ }
+ }
+
+ /**
+ * Handle purchase action (internal use only)
+ */
+ internal fun handlePurchase(paywallContext: com.monetai.sdk.models.PaywallContext, closePaywall: (() -> Unit)) {
+ val onPurchase = paywallConfig?.onPurchase
+ if (onPurchase != null) {
+ onPurchase(paywallContext, closePaywall)
+ } else {
+ Log.w(TAG, "onPurchase callback not set")
+ }
+ }
+
+ /**
+ * Handle terms of service action (internal use only)
+ */
+ internal fun handleTermsOfService(paywallContext: com.monetai.sdk.models.PaywallContext) {
+ val callback = paywallConfig?.onTermsOfService
+ if (callback != null) {
+ callback.invoke(paywallContext)
+ } else {
+ Log.w(TAG, "onTermsOfService callback not set")
+ }
+ }
+
+ /**
+ * Handle privacy policy action (internal use only)
+ */
+ internal fun handlePrivacyPolicy(paywallContext: com.monetai.sdk.models.PaywallContext) {
+ val callback = paywallConfig?.onPrivacyPolicy
+ if (callback != null) {
+ callback.invoke(paywallContext)
+ } else {
+ Log.w(TAG, "onPrivacyPolicy callback not set")
+ }
+ }
+
+ // MARK: - Private Methods
+
+ private fun updatePaywallParams() {
+ val paywallConfig = paywallConfig ?: return
+ val discountInfo = discountInfo ?: return
+
+ val params = PaywallParams(
+ discountPercent = paywallConfig.discountPercent.toString(),
+ endedAt = DateTimeHelper.formatToISO8601(discountInfo.endedAt),
+ regularPrice = paywallConfig.regularPrice,
+ discountedPrice = paywallConfig.discountedPrice,
+ locale = paywallConfig.locale,
+ features = paywallConfig.features,
+ style = paywallConfig.style
+ )
+
+ // Ensure UI update happens on main thread
+ ThreadUtils.runOnMainThread {
+ _paywallParams.value = params
+ }
+ }
+
+ // MARK: - Paywall Presentation
+ private fun presentPaywall() {
+ val paywallParams = _paywallParams.value ?: run {
+ Log.e(TAG, "paywallParams is null in presentPaywall()")
+ return
+ }
+
+ val topActivity = ContextUtils.findActivity(null) ?: run {
+ Log.e(TAG, "Cannot find top activity")
+ return
+ }
+
+ // Avoid double-present
+ if (currentPaywallActivity != null) {
+ Log.w(TAG, "Paywall already presented")
+ return
+ }
+
+ // Create PaywallActivity
+ val paywallActivity = MonetaiPaywallActivity()
+
+ // Set static callbacks (works for any Activity instance)
+ MonetaiPaywallActivity.setStaticCallbacks(
+ onClose = { hidePaywall() },
+ onPurchase = { paywallContext, closePaywall -> handlePurchase(paywallContext, closePaywall) },
+ onTermsOfService = { paywallContext -> handleTermsOfService(paywallContext) },
+ onPrivacyPolicy = { paywallContext -> handlePrivacyPolicy(paywallContext) }
+ )
+
+ // Also set instance callbacks for completeness (though may not be used)
+ paywallActivity.setCallbacks(
+ onClose = { hidePaywall() },
+ onPurchase = { paywallContext, closePaywall -> handlePurchase(paywallContext, closePaywall) },
+ onTermsOfService = { paywallContext -> handleTermsOfService(paywallContext) },
+ onPrivacyPolicy = { paywallContext -> handlePrivacyPolicy(paywallContext) }
+ )
+
+ // Present paywall
+ val intent = Intent(topActivity, MonetaiPaywallActivity::class.java).apply {
+ putExtra("paywallParams", paywallParams)
+ }
+
+ try {
+ topActivity.startActivity(intent)
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to start activity", e)
+ return
+ }
+
+ currentPaywallActivity = paywallActivity
+ }
+
+ private fun dismissPaywall() {
+ currentPaywallActivity?.finish()
+ currentPaywallActivity = null
+ }
+
+}
+
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/utils/BannerStyleUtils.kt b/monetai-sdk/src/main/java/com/monetai/sdk/utils/BannerStyleUtils.kt
new file mode 100644
index 0000000..4e15ed0
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/utils/BannerStyleUtils.kt
@@ -0,0 +1,49 @@
+package com.monetai.sdk.utils
+
+import com.monetai.sdk.models.PaywallStyle
+
+/**
+ * Utility class for banner style calculations
+ * Consolidates style-related logic that was duplicated between BannerManager and BannerView
+ */
+object BannerStyleUtils {
+
+ /**
+ * Get banner height based on style (matching iOS implementation exactly)
+ */
+ fun getBannerHeight(style: PaywallStyle): Int {
+ return when (style) {
+ PaywallStyle.TEXT_FOCUSED -> 45 // iOS: 45
+ PaywallStyle.COMPACT -> 68 // iOS: 68
+ PaywallStyle.KEY_FEATURE_SUMMARY -> 56 // iOS: 56 (default)
+ PaywallStyle.HIGHLIGHT_BENEFITS -> 56 // iOS: 56 (default)
+ else -> 56 // iOS: 56 (default)
+ }
+ }
+
+ /**
+ * Get banner corner radius based on style (matching iOS implementation exactly)
+ */
+ fun getBannerCornerRadius(style: PaywallStyle): Int {
+ return when (style) {
+ PaywallStyle.TEXT_FOCUSED -> 12 // iOS: 12
+ PaywallStyle.COMPACT -> 16 // iOS: 16
+ PaywallStyle.KEY_FEATURE_SUMMARY -> 16 // iOS: 16
+ PaywallStyle.HIGHLIGHT_BENEFITS -> 12 // iOS: 12
+ else -> 12 // iOS: 12 (default)
+ }
+ }
+
+ /**
+ * Get banner horizontal margin based on style (matching iOS implementation exactly)
+ */
+ fun getBannerHorizontalMargin(style: PaywallStyle): Int {
+ return when (style) {
+ PaywallStyle.TEXT_FOCUSED -> 16 // iOS: 16
+ PaywallStyle.COMPACT -> 16 // iOS: 16
+ PaywallStyle.KEY_FEATURE_SUMMARY -> 16 // iOS: 16
+ PaywallStyle.HIGHLIGHT_BENEFITS -> 16 // iOS: 16
+ else -> 16 // iOS: 16 (default)
+ }
+ }
+}
\ No newline at end of file
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/utils/ContextUtils.kt b/monetai-sdk/src/main/java/com/monetai/sdk/utils/ContextUtils.kt
new file mode 100644
index 0000000..14a6c39
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/utils/ContextUtils.kt
@@ -0,0 +1,44 @@
+package com.monetai.sdk.utils
+
+import android.app.Activity
+import android.content.Context
+import android.util.Log
+
+/**
+ * Utility class for Context operations
+ */
+object ContextUtils {
+
+ private const val TAG = "ContextUtils"
+
+ /**
+ * Attempts to find the current Activity from the given Context
+ * @param context The context to search from
+ * @return Activity if found, null otherwise
+ */
+ fun findActivity(context: Context?): Activity? {
+ return try {
+ when (context) {
+ is Activity -> {
+ Log.d(TAG, "Direct Activity context found")
+ context
+ }
+ else -> {
+ // Try to get from MonetaiSDK's stored context
+ val sdkContext = com.monetai.sdk.MonetaiSDK.shared.getApplicationContext()
+ if (sdkContext is Activity) {
+ Log.d(TAG, "Activity found from SDK context")
+ sdkContext
+ } else {
+ Log.w(TAG, "Context is not Activity, cannot find Activity")
+ Log.w(TAG, "Hint: Make sure to call SDK.initialize() from Activity context")
+ null
+ }
+ }
+ }
+ } catch (e: Exception) {
+ Log.e(TAG, "Failed to find Activity: ${e.message}")
+ null
+ }
+ }
+}
\ No newline at end of file
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/utils/DimensionUtils.kt b/monetai-sdk/src/main/java/com/monetai/sdk/utils/DimensionUtils.kt
new file mode 100644
index 0000000..9bd5fd4
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/utils/DimensionUtils.kt
@@ -0,0 +1,35 @@
+package com.monetai.sdk.utils
+
+import android.content.Context
+
+/**
+ * Utility class for dimension conversions
+ */
+object DimensionUtils {
+
+ /**
+ * Converts dp to pixels
+ * @param context Context for getting display metrics
+ * @param dp Value in dp
+ * @return Value in pixels
+ */
+ fun dpToPx(context: Context, dp: Int): Int {
+ return (dp * context.resources.displayMetrics.density).toInt()
+ }
+
+ /**
+ * Converts dp to pixels
+ * @param context Context for getting display metrics
+ * @param dp Value in dp
+ * @return Value in pixels
+ */
+ fun dpToPx(context: Context, dp: Float): Int {
+ return (dp * context.resources.displayMetrics.density).toInt()
+ }
+}
+
+/**
+ * Extension functions for easy dp to px conversion
+ */
+fun Context.dpToPx(dp: Int): Int = DimensionUtils.dpToPx(this, dp)
+fun Context.dpToPx(dp: Float): Int = DimensionUtils.dpToPx(this, dp)
\ No newline at end of file
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/utils/MonetaiPromotionTimer.kt b/monetai-sdk/src/main/java/com/monetai/sdk/utils/MonetaiPromotionTimer.kt
new file mode 100644
index 0000000..bc7044c
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/utils/MonetaiPromotionTimer.kt
@@ -0,0 +1,191 @@
+package com.monetai.sdk.utils
+
+import android.content.BroadcastReceiver
+import android.content.Context
+import android.content.Intent
+import android.content.IntentFilter
+import android.os.Handler
+import android.os.Looper
+import android.util.Log
+import com.monetai.sdk.models.DiscountInfo
+import java.util.*
+
+/**
+ * Interface for handling promotion expiration events
+ */
+interface MonetaiPromotionTimerDelegate {
+ fun promotionDidExpire()
+}
+
+/**
+ * Manages promotion expiration timing with battery-optimized timer management
+ * Follows iOS SDK implementation pattern for consistency
+ */
+class MonetaiPromotionTimer(
+ private val context: Context,
+ private val delegate: MonetaiPromotionTimerDelegate
+) {
+
+ companion object {
+ private const val TAG = "MonetaiPromotionTimer"
+ private const val CHECK_INTERVAL_MS = 1000L // 1 second like iOS
+
+ // Intent actions for better readability
+ private const val ACTION_SCREEN_OFF = Intent.ACTION_SCREEN_OFF
+ private const val ACTION_SCREEN_ON = Intent.ACTION_SCREEN_ON
+ private const val ACTION_USER_PRESENT = Intent.ACTION_USER_PRESENT
+ }
+
+ // MARK: - Properties
+ private var discountInfo: DiscountInfo? = null
+ private var handler: Handler? = null
+ private var checkRunnable: Runnable? = null
+ private var isTimerActive = false
+ private var isAppInForeground = true
+
+ // App lifecycle receiver for battery optimization
+ private val appLifecycleReceiver = object : BroadcastReceiver() {
+ override fun onReceive(context: Context?, intent: Intent?) {
+ when (intent?.action) {
+ ACTION_SCREEN_OFF -> {
+ isAppInForeground = false
+ stopExpirationTimer()
+ }
+ ACTION_SCREEN_ON -> {
+ isAppInForeground = true
+ checkPromotionExpiration()
+ startExpirationTimerIfNeeded()
+ }
+ ACTION_USER_PRESENT -> {
+ checkPromotionExpiration()
+ startExpirationTimerIfNeeded()
+ }
+ }
+ }
+ }
+
+ init {
+ setupAppLifecycleObservers()
+ }
+
+ // MARK: - Public Methods
+
+ /**
+ * Configure the manager with discount information
+ */
+ fun configure(discountInfo: DiscountInfo) {
+ this.discountInfo = discountInfo
+ startExpirationTimerIfNeeded()
+ }
+
+ /**
+ * Start monitoring promotion expiration
+ */
+ fun startMonitoring() {
+ startExpirationTimerIfNeeded()
+ }
+
+ /**
+ * Stop monitoring promotion expiration
+ */
+ fun stopMonitoring() {
+ stopExpirationTimer()
+ }
+
+ /**
+ * Check if promotion has expired (public method for immediate check)
+ */
+ fun checkExpiration() {
+ checkPromotionExpiration()
+ }
+
+ /**
+ * Cleanup resources
+ */
+ fun destroy() {
+ stopExpirationTimer()
+ try {
+ context.unregisterReceiver(appLifecycleReceiver)
+ } catch (e: IllegalArgumentException) {
+ // Receiver was already unregistered
+ }
+ }
+
+ // MARK: - App Lifecycle Setup
+ private fun setupAppLifecycleObservers() {
+ val intentFilter = IntentFilter().apply {
+ addAction(ACTION_SCREEN_OFF)
+ addAction(ACTION_SCREEN_ON)
+ addAction(ACTION_USER_PRESENT)
+ }
+
+ try {
+ context.registerReceiver(appLifecycleReceiver, intentFilter)
+ } catch (e: Exception) {
+ Log.e(TAG, "❌ Failed to register lifecycle observers", e)
+ }
+ }
+
+ // MARK: - Promotion Expiration Management
+ private fun checkPromotionExpiration() {
+ val discountInfo = discountInfo ?: return
+
+ val now = Date()
+ val isExpired = now >= discountInfo.endedAt
+
+
+ if (isExpired) {
+ stopExpirationTimer()
+ delegate.promotionDidExpire()
+ }
+ }
+
+ private fun startExpirationTimerIfNeeded() {
+ val discountInfo = discountInfo ?: return
+
+ if (!isAppInForeground) {
+ return
+ }
+
+ val timeUntilExpiration = discountInfo.endedAt.time - System.currentTimeMillis()
+
+ if (timeUntilExpiration <= 0) {
+ delegate.promotionDidExpire()
+ return
+ }
+
+ startExpirationTimer()
+ }
+
+ private fun startExpirationTimer() {
+ // Stop existing timer if running
+ stopExpirationTimer()
+
+
+ // Create handler on main thread (like iOS main queue)
+ handler = Handler(Looper.getMainLooper())
+
+ checkRunnable = object : Runnable {
+ override fun run() {
+ checkPromotionExpiration()
+
+ // Schedule next check if timer is still active
+ if (isTimerActive) {
+ handler?.postDelayed(this, CHECK_INTERVAL_MS)
+ }
+ }
+ }
+
+ handler?.postDelayed(checkRunnable!!, CHECK_INTERVAL_MS)
+ isTimerActive = true
+ }
+
+ private fun stopExpirationTimer() {
+ if (isTimerActive) {
+ handler?.removeCallbacks(checkRunnable ?: return)
+ handler = null
+ checkRunnable = null
+ isTimerActive = false
+ }
+ }
+}
\ No newline at end of file
diff --git a/monetai-sdk/src/main/java/com/monetai/sdk/utils/ThreadUtils.kt b/monetai-sdk/src/main/java/com/monetai/sdk/utils/ThreadUtils.kt
new file mode 100644
index 0000000..d5fa2b9
--- /dev/null
+++ b/monetai-sdk/src/main/java/com/monetai/sdk/utils/ThreadUtils.kt
@@ -0,0 +1,30 @@
+package com.monetai.sdk.utils
+
+import android.os.Handler
+import android.os.Looper
+
+/**
+ * Utility class for thread management operations
+ */
+object ThreadUtils {
+
+ /**
+ * Executes the given action on the main thread
+ * If already on main thread, executes immediately
+ * Otherwise, posts to main thread handler
+ */
+ fun runOnMainThread(action: () -> Unit) {
+ if (Looper.myLooper() == Looper.getMainLooper()) {
+ action()
+ } else {
+ Handler(Looper.getMainLooper()).post(action)
+ }
+ }
+
+ /**
+ * Checks if current thread is the main thread
+ */
+ fun isMainThread(): Boolean {
+ return Looper.myLooper() == Looper.getMainLooper()
+ }
+}
\ No newline at end of file
diff --git a/monetai-sdk/src/main/res/values/styles.xml b/monetai-sdk/src/main/res/values/styles.xml
new file mode 100644
index 0000000..d7d9040
--- /dev/null
+++ b/monetai-sdk/src/main/res/values/styles.xml
@@ -0,0 +1,13 @@
+
+
+
+
+
\ No newline at end of file