From 78bc23881e25ac903811779600a0a844b2dca38e Mon Sep 17 00:00:00 2001 From: Daehoon Kim Date: Wed, 3 Sep 2025 05:30:14 +0900 Subject: [PATCH] feat: implement paywall and banner functionality - Add paywall UI with MonetaiPaywallActivity and MonetaiPaywallManager - Implement banner system with MonetaiBannerView and MonetaiBannerManager - Add comprehensive paywall and banner models (PaywallConfig, BannerParams, etc.) - Enhance SDK with utility classes for styling, dimensions, and threading - Add WebView constants and promotion timer functionality - Update sample apps to demonstrate new features --- .../com/monetai/sample/java/MainActivity.java | 187 ++++--- .../src/main/res/layout/activity_main.xml | 18 +- .../src/main/AndroidManifest.xml | 1 + .../com/monetai/sample/kotlin/MainActivity.kt | 248 ++++++---- .../src/main/res/layout/activity_main.xml | 14 +- monetai-sdk/build.gradle | 1 + monetai-sdk/src/main/AndroidManifest.xml | 18 + .../main/java/com/monetai/sdk/MonetaiSDK.kt | 182 +++++-- .../java/com/monetai/sdk/MonetaiSDKJava.kt | 194 +++++++- .../sdk/banner/MonetaiBannerManager.kt | 247 +++++++++ .../monetai/sdk/banner/MonetaiBannerView.kt | 248 ++++++++++ .../monetai/sdk/constants/WebViewConstants.kt | 16 + .../com/monetai/sdk/models/BannerParams.kt | 16 + .../com/monetai/sdk/models/DiscountInfo.kt | 13 + .../java/com/monetai/sdk/models/Feature.kt | 14 + .../com/monetai/sdk/models/PaywallConfig.kt | 34 ++ .../com/monetai/sdk/models/PaywallParams.kt | 19 + .../com/monetai/sdk/models/PaywallStyle.kt | 15 + .../sdk/paywall/MonetaiPaywallActivity.kt | 467 ++++++++++++++++++ .../sdk/paywall/MonetaiPaywallManager.kt | 211 ++++++++ .../com/monetai/sdk/utils/BannerStyleUtils.kt | 49 ++ .../com/monetai/sdk/utils/ContextUtils.kt | 44 ++ .../com/monetai/sdk/utils/DimensionUtils.kt | 35 ++ .../sdk/utils/MonetaiPromotionTimer.kt | 191 +++++++ .../java/com/monetai/sdk/utils/ThreadUtils.kt | 30 ++ monetai-sdk/src/main/res/values/styles.xml | 13 + 26 files changed, 2323 insertions(+), 202 deletions(-) create mode 100644 monetai-sdk/src/main/AndroidManifest.xml create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerManager.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/banner/MonetaiBannerView.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/constants/WebViewConstants.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/models/BannerParams.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/models/DiscountInfo.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/models/Feature.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallConfig.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallParams.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/models/PaywallStyle.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallActivity.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/paywall/MonetaiPaywallManager.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/utils/BannerStyleUtils.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/utils/ContextUtils.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/utils/DimensionUtils.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/utils/MonetaiPromotionTimer.kt create mode 100644 monetai-sdk/src/main/java/com/monetai/sdk/utils/ThreadUtils.kt create mode 100644 monetai-sdk/src/main/res/values/styles.xml 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