From 817b8d4e7f73678aacefc46747a56b11ba97971e Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 19:50:24 +0330 Subject: [PATCH 01/37] Update IInAppBillingService.aidl MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit /* * Copyright (C) 2012 The Android Open Source Project * * Licensed under the Apache License, Version 2.0 (the "License"); * you may not use this file except in compliance with the License. * You may obtain a copy of the License at * * http://www.apache.org/licenses/LICENSE-2.0 * * Unless required by applicable law or agreed to in writing, software * distributed under the License is distributed on an "AS IS" BASIS, * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. * * Single-file demo: * - Contains a corrected IInAppBillingService interface (Java-valid signatures) * - Provides a MockInAppBillingService with stubbed responses * - Provides a BillingDemoActivity with a programmatic, visually improved UI * * Note: This is a demo/stub. Replace the MockInAppBillingService with a real implementation * when integrating with actual billing APIs. */ package com.android.vending.billing; import android.app.AlertDialog; import android.graphics.Typeface; import android.graphics.drawable.GradientDrawable; import android.os.Bundle; import android.os.Handler; import android.os.Looper; import android.text.method.ScrollingMovementMethod; import android.view.Gravity; import android.view.View; import android.view.ViewGroup; import android.widget.Button; import android.widget.LinearLayout; import android.widget.ScrollView; import android.widget.TextView; import androidx.appcompat.app.AppCompatActivity; /** * Single-file demo activity that contains: * - IInAppBillingService (corrected) * - A MockInAppBillingService implementation (stubs) * - A small UI to call several billing methods and display results */ public class BillingDemoActivity extends AppCompatActivity { // --------------------------- // Corrected interface (Java) // --------------------------- /** * Java interface for in-app billing service. * All "in" keywords from AIDL removed; methods use standard Java types. */ interface IInAppBillingService { int isBillingSupported(int apiVersion, String packageName, String type); Bundle getSkuDetails(int apiVersion, String packageName, String type, Bundle skusBundle); Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload); Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); int consumePurchase(int apiVersion, String packageName, String purchaseToken); Bundle getBuyIntentV2(int apiVersion, String packageName, String sku, String type, String developerPayload); Bundle getPurchaseConfig(int apiVersion); Bundle getBuyIntentV3( int apiVersion, String packageName, String sku, String developerPayload, Bundle extraData); Bundle checkTrialSubscription(String packageName); Bundle getFeatureConfig(); // --------------------------------------------------------------------- // 🔹 Added functions (corrected) // --------------------------------------------------------------------- Bundle getActiveSubscriptions(int apiVersion, String packageName, String userId); int acknowledgePurchase(int apiVersion, String packageName, String purchaseToken); Bundle getSkuOffers(int apiVersion, String packageName, String sku, Bundle extraParams); Bundle launchPriceChangeFlow(int apiVersion, String packageName, String sku, Bundle options); Bundle validatePurchase(int apiVersion, String packageName, String purchaseToken, Bundle validationData); Bundle getPurchaseHistory(int apiVersion, String packageName, String type, String continuationToken); int enableDeveloperMode(int apiVersion, String packageName, boolean enabled); Bundle getBillingDiagnostics(int apiVersion, String packageName); int cancelSubscription(int apiVersion, String packageName, String sku, String userId); Bundle changeSubscriptionPlan(int apiVersion, String packageName, String oldSku, String newSku, Bundle options); Bundle getAvailablePaymentMethods(int apiVersion, String packageName); int prefetchSkuDetails(int apiVersion, String packageName, Bundle skuList); } // ----------------------------------- // Mock implementation for testing // ----------------------------------- /** * Simple mock/stub implementation of IInAppBillingService. * Returns sample Bundles and integer response codes. */ static class MockInAppBillingService implements IInAppBillingService { private final Handler mainHandler = new Handler(Looper.getMainLooper()); @Override public int isBillingSupported(int apiVersion, String packageName, String type) { // 0 means OK in many billing APIs — use 0 for success return 0; } @Override public Bundle getSkuDetails(int apiVersion, String packageName, String type, Bundle skusBundle) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("DETAILS", "Sample SKU details for type=" + type + " pkg=" + packageName); return b; } @Override public Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("BUY_INTENT", "intent://buy/" + sku + "?pkg=" + packageName); b.putString("DEVELOPER_PAYLOAD", developerPayload); return b; } @Override public Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("PURCHASES", "[{\"sku\":\"sample_monthly\",\"state\":\"active\"}]"); b.putString("CONTINUATION_TOKEN", continuationToken == null ? "" : continuationToken); return b; } @Override public int consumePurchase(int apiVersion, String packageName, String purchaseToken) { // Return 0 for success return 0; } @Override public Bundle getBuyIntentV2(int apiVersion, String packageName, String sku, String type, String developerPayload) { // Forward to getBuyIntent for mock purposes return getBuyIntent(apiVersion, packageName, sku, type, developerPayload); } @Override public Bundle getPurchaseConfig(int apiVersion) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("CONFIG", "mock-config-v1"); return b; } @Override public Bundle getBuyIntentV3(int apiVersion, String packageName, String sku, String developerPayload, Bundle extraData) { Bundle b = getBuyIntent(apiVersion, packageName, sku, "inapp", developerPayload); b.putBundle("EXTRA_DATA", extraData == null ? new Bundle() : extraData); return b; } @Override public Bundle checkTrialSubscription(String packageName) { Bundle b = new Bundle(); b.putBoolean("HAS_TRIAL", true); b.putString("PACKAGE", packageName); return b; } @Override public Bundle getFeatureConfig() { Bundle b = new Bundle(); b.putString("FEATURES", "mock-features-list"); return b; } @Override public Bundle getActiveSubscriptions(int apiVersion, String packageName, String userId) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("ACTIVE_SUBSCRIPTIONS", "[{\"sku\":\"pro_monthly\",\"user\":\"" + userId + "\"}]"); return b; } @Override public int acknowledgePurchase(int apiVersion, String packageName, String purchaseToken) { return 0; // success } @Override public Bundle getSkuOffers(int apiVersion, String packageName, String sku, Bundle extraParams) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("OFFERS", "[{\"offerId\":\"discount1\",\"sku\":\"" + sku + "\"}]"); return b; } @Override public Bundle launchPriceChangeFlow(int apiVersion, String packageName, String sku, Bundle options) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("PRICE_CHANGE_STATUS", "price-change-flow-launched-for:" + sku); return b; } @Override public Bundle validatePurchase(int apiVersion, String packageName, String purchaseToken, Bundle validationData) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putBoolean("VALID", true); b.putString("PURCHASE_TOKEN", purchaseToken); return b; } @Override public Bundle getPurchaseHistory(int apiVersion, String packageName, String type, String continuationToken) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("HISTORY", "[{\"sku\":\"sample_one_time\",\"state\":\"consumed\"}]"); return b; } @Override public int enableDeveloperMode(int apiVersion, String packageName, boolean enabled) { return enabled ? 0 : 1; // 0 success when enabling; 1 if disabled (mock) } @Override public Bundle getBillingDiagnostics(int apiVersion, String packageName) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("DIAGNOSTICS", "mock-diagnostics-ok"); return b; } @Override public int cancelSubscription(int apiVersion, String packageName, String sku, String userId) { return 0; // success } @Override public Bundle changeSubscriptionPlan(int apiVersion, String packageName, String oldSku, String newSku, Bundle options) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("RESULT", "changed " + oldSku + " -> " + newSku); return b; } @Override public Bundle getAvailablePaymentMethods(int apiVersion, String packageName) { Bundle b = new Bundle(); b.putInt("RESPONSE_CODE", 0); b.putString("PAYMENT_METHODS", "[\"card\",\"carrier\",\"paypal\"]"); return b; } @Override public int prefetchSkuDetails(int apiVersion, String packageName, Bundle skuList) { // Pretend caching succeeded return 0; } } // -------------------------- // Activity UI and behavior // -------------------------- private IInAppBillingService billingService; @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); billingService = new MockInAppBillingService(); // Build a simple UI programmatically so everything stays in one file. ScrollView scroll = new ScrollView(this); LinearLayout root = new LinearLayout(this); root.setOrientation(LinearLayout.VERTICAL); root.setPadding(dp(16), dp(16), dp(16), dp(16)); root.setLayoutParams(new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT )); // Header TextView header = new TextView(this); header.setText("In-App Billing Demo"); header.setTextSize(22f); header.setTypeface(Typeface.DEFAULT_BOLD); header.setPadding(0, 0, 0, dp(12)); header.setGravity(Gravity.CENTER_HORIZONTAL); header.setTextColor(0xFF212121); root.addView(header, new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT )); // A short subtitle / description TextView subtitle = new TextView(this); subtitle.setText("Mock billing service — tap any action to see sample results."); subtitle.setTextSize(14f); subtitle.setTextColor(0xFF666666); subtitle.setPadding(0, 0, 0, dp(12)); subtitle.setGravity(Gravity.CENTER_HORIZONTAL); root.addView(subtitle); // Add buttons (card-like) for several actions root.addView(makeActionButton("Check Billing Support", new View.OnClickListener() { @Override public void onClick(View v) { int rc = billingService.isBillingSupported(3, getPackageName(), "inapp"); showResult("isBillingSupported", "Response code: " + rc); } })); root.addView(makeActionButton("Get SKU Details", new View.OnClickListener() { @Override public void onClick(View v) { Bundle req = new Bundle(); req.putStringArray("ITEM_ID_LIST", new String[]{"sample_one_time", "sample_monthly"}); Bundle res = billingService.getSkuDetails(3, getPackageName(), "inapp", req); showBundle("getSkuDetails", res); } })); root.addView(makeActionButton("Get Purchases", new View.OnClickListener() { @Override public void onClick(View v) { Bundle res = billingService.getPurchases(3, getPackageName(), "inapp", null); showBundle("getPurchases", res); } })); root.addView(makeActionButton("Get Active Subscriptions", new View.OnClickListener() { @Override public void onClick(View v) { Bundle res = billingService.getActiveSubscriptions(3, getPackageName(), "user123"); showBundle("getActiveSubscriptions", res); } })); root.addView(makeActionButton("Validate Purchase (mock)", new View.OnClickListener() { @Override public void onClick(View v) { Bundle valData = new Bundle(); valData.putString("signature", "mock-signature"); Bundle res = billingService.validatePurchase(3, getPackageName(), "token-abc-123", valData); showBundle("validatePurchase", res); } })); // Footer note TextView footer = new TextView(this); footer.setText("\nThis UI uses a mock billing service. Replace MockInAppBillingService with\na real service implementation for production."); footer.setTextSize(12f); footer.setTextColor(0xFF888888); footer.setPadding(0, dp(12), 0, 0); footer.setMovementMethod(new ScrollingMovementMethod()); root.addView(footer); scroll.addView(root); setContentView(scroll); } // Helper that creates a rounded, shadowless "card" button for actions. private View makeActionButton(String label, View.OnClickListener onClick) { // container with rounded background LinearLayout container = new LinearLayout(this); container.setOrientation(LinearLayout.VERTICAL); container.setPadding(dp(12), dp(12), dp(12), dp(12)); LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT ); lp.setMargins(0, 0, 0, dp(12)); container.setLayoutParams(lp); GradientDrawable bg = new GradientDrawable(); bg.setCornerRadius(dp(10)); bg.setColor(0xFFF7F7F7); // light card color container.setBackground(bg); // Title TextView title = new TextView(this); title.setText(label); title.setTextSize(16f); title.setTypeface(Typeface.DEFAULT_BOLD); title.setTextColor(0xFF111111); // Button Button b = new Button(this); b.setText("Run"); b.setAllCaps(false); LinearLayout.LayoutParams btnParams = new LinearLayout.LayoutParams( ViewGroup.LayoutParams.WRAP_CONTENT, ViewGroup.LayoutParams.WRAP_CONTENT ); btnParams.gravity = Gravity.END; btnParams.topMargin = dp(8); b.setLayoutParams(btnParams); // Wire click: click on either card or the button triggers action container.setOnClickListener(onClick); b.setOnClickListener(onClick); container.addView(title); container.addView(b); return container; } // Helper: display bundle contents in an AlertDialog private void showBundle(String title, Bundle b) { StringBuilder sb = new StringBuilder(); if (b == null) { sb.append("null"); } else { for (String key : b.keySet()) { Object val = b.get(key); sb.append(key).append(": ").append(String.valueOf(val)).append("\n\n"); } } showResult(title, sb.toString()); } private void showResult(String title, String text) { // Show an AlertDialog with rounded message text TextView message = new TextView(this); message.setText(text); message.setTextSize(14f); message.setPadding(dp(12), dp(12), dp(12), dp(12)); message.setTextColor(0xFF222222); new AlertDialog.Builder(this) .setTitle(title) .setView(message) .setPositiveButton("OK", null) .show(); } private int dp(int d) { float scale = getResources().getDisplayMetrics().density; return Math.round(d * scale); } } --- .../vending/billing/IInAppBillingService.aidl | 475 +++++++++++++++++- 1 file changed, 450 insertions(+), 25 deletions(-) diff --git a/poolakey/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl b/poolakey/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl index f6cabb8..6a06207 100644 --- a/poolakey/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl +++ b/poolakey/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl @@ -12,44 +12,469 @@ * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. * See the License for the specific language governing permissions and * limitations under the License. + * + * Single-file demo: + * - Contains a corrected IInAppBillingService interface (Java-valid signatures) + * - Provides a MockInAppBillingService with stubbed responses + * - Provides a BillingDemoActivity with a programmatic, visually improved UI + * + * Note: This is a demo/stub. Replace the MockInAppBillingService with a real implementation + * when integrating with actual billing APIs. */ package com.android.vending.billing; +import android.app.AlertDialog; +import android.graphics.Typeface; +import android.graphics.drawable.GradientDrawable; import android.os.Bundle; +import android.os.Handler; +import android.os.Looper; +import android.text.method.ScrollingMovementMethod; +import android.view.Gravity; +import android.view.View; +import android.view.ViewGroup; +import android.widget.Button; +import android.widget.LinearLayout; +import android.widget.ScrollView; +import android.widget.TextView; + +import androidx.appcompat.app.AppCompatActivity; + +/** + * Single-file demo activity that contains: + * - IInAppBillingService (corrected) + * - A MockInAppBillingService implementation (stubs) + * - A small UI to call several billing methods and display results + */ +public class BillingDemoActivity extends AppCompatActivity { + + // --------------------------- + // Corrected interface (Java) + // --------------------------- + /** + * Java interface for in-app billing service. + * All "in" keywords from AIDL removed; methods use standard Java types. + */ + interface IInAppBillingService { + + int isBillingSupported(int apiVersion, String packageName, String type); + + Bundle getSkuDetails(int apiVersion, String packageName, String type, Bundle skusBundle); + + Bundle getBuyIntent(int apiVersion, + String packageName, + String sku, + String type, + String developerPayload); + + Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); + + int consumePurchase(int apiVersion, String packageName, String purchaseToken); + + Bundle getBuyIntentV2(int apiVersion, + String packageName, + String sku, + String type, + String developerPayload); + + Bundle getPurchaseConfig(int apiVersion); + + Bundle getBuyIntentV3( + int apiVersion, + String packageName, + String sku, + String developerPayload, + Bundle extraData); + + Bundle checkTrialSubscription(String packageName); + + Bundle getFeatureConfig(); + + // --------------------------------------------------------------------- + // 🔹 Added functions (corrected) + // --------------------------------------------------------------------- + + Bundle getActiveSubscriptions(int apiVersion, String packageName, String userId); + + int acknowledgePurchase(int apiVersion, String packageName, String purchaseToken); + + Bundle getSkuOffers(int apiVersion, String packageName, String sku, Bundle extraParams); + + Bundle launchPriceChangeFlow(int apiVersion, String packageName, String sku, Bundle options); + + Bundle validatePurchase(int apiVersion, String packageName, String purchaseToken, Bundle validationData); + + Bundle getPurchaseHistory(int apiVersion, String packageName, String type, String continuationToken); + + int enableDeveloperMode(int apiVersion, String packageName, boolean enabled); + + Bundle getBillingDiagnostics(int apiVersion, String packageName); + + int cancelSubscription(int apiVersion, String packageName, String sku, String userId); + + Bundle changeSubscriptionPlan(int apiVersion, + String packageName, + String oldSku, + String newSku, + Bundle options); + + Bundle getAvailablePaymentMethods(int apiVersion, String packageName); + + int prefetchSkuDetails(int apiVersion, String packageName, Bundle skuList); + } + + // ----------------------------------- + // Mock implementation for testing + // ----------------------------------- + /** + * Simple mock/stub implementation of IInAppBillingService. + * Returns sample Bundles and integer response codes. + */ + static class MockInAppBillingService implements IInAppBillingService { + + private final Handler mainHandler = new Handler(Looper.getMainLooper()); + + @Override + public int isBillingSupported(int apiVersion, String packageName, String type) { + // 0 means OK in many billing APIs — use 0 for success + return 0; + } + + @Override + public Bundle getSkuDetails(int apiVersion, String packageName, String type, Bundle skusBundle) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("DETAILS", "Sample SKU details for type=" + type + " pkg=" + packageName); + return b; + } + + @Override + public Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, String developerPayload) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("BUY_INTENT", "intent://buy/" + sku + "?pkg=" + packageName); + b.putString("DEVELOPER_PAYLOAD", developerPayload); + return b; + } + + @Override + public Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("PURCHASES", "[{\"sku\":\"sample_monthly\",\"state\":\"active\"}]"); + b.putString("CONTINUATION_TOKEN", continuationToken == null ? "" : continuationToken); + return b; + } + + @Override + public int consumePurchase(int apiVersion, String packageName, String purchaseToken) { + // Return 0 for success + return 0; + } + + @Override + public Bundle getBuyIntentV2(int apiVersion, String packageName, String sku, String type, String developerPayload) { + // Forward to getBuyIntent for mock purposes + return getBuyIntent(apiVersion, packageName, sku, type, developerPayload); + } + + @Override + public Bundle getPurchaseConfig(int apiVersion) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("CONFIG", "mock-config-v1"); + return b; + } + + @Override + public Bundle getBuyIntentV3(int apiVersion, String packageName, String sku, String developerPayload, Bundle extraData) { + Bundle b = getBuyIntent(apiVersion, packageName, sku, "inapp", developerPayload); + b.putBundle("EXTRA_DATA", extraData == null ? new Bundle() : extraData); + return b; + } + + @Override + public Bundle checkTrialSubscription(String packageName) { + Bundle b = new Bundle(); + b.putBoolean("HAS_TRIAL", true); + b.putString("PACKAGE", packageName); + return b; + } + + @Override + public Bundle getFeatureConfig() { + Bundle b = new Bundle(); + b.putString("FEATURES", "mock-features-list"); + return b; + } + + @Override + public Bundle getActiveSubscriptions(int apiVersion, String packageName, String userId) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("ACTIVE_SUBSCRIPTIONS", "[{\"sku\":\"pro_monthly\",\"user\":\"" + userId + "\"}]"); + return b; + } + + @Override + public int acknowledgePurchase(int apiVersion, String packageName, String purchaseToken) { + return 0; // success + } + + @Override + public Bundle getSkuOffers(int apiVersion, String packageName, String sku, Bundle extraParams) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("OFFERS", "[{\"offerId\":\"discount1\",\"sku\":\"" + sku + "\"}]"); + return b; + } + + @Override + public Bundle launchPriceChangeFlow(int apiVersion, String packageName, String sku, Bundle options) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("PRICE_CHANGE_STATUS", "price-change-flow-launched-for:" + sku); + return b; + } + + @Override + public Bundle validatePurchase(int apiVersion, String packageName, String purchaseToken, Bundle validationData) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putBoolean("VALID", true); + b.putString("PURCHASE_TOKEN", purchaseToken); + return b; + } + + @Override + public Bundle getPurchaseHistory(int apiVersion, String packageName, String type, String continuationToken) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("HISTORY", "[{\"sku\":\"sample_one_time\",\"state\":\"consumed\"}]"); + return b; + } + + @Override + public int enableDeveloperMode(int apiVersion, String packageName, boolean enabled) { + return enabled ? 0 : 1; // 0 success when enabling; 1 if disabled (mock) + } + + @Override + public Bundle getBillingDiagnostics(int apiVersion, String packageName) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("DIAGNOSTICS", "mock-diagnostics-ok"); + return b; + } + + @Override + public int cancelSubscription(int apiVersion, String packageName, String sku, String userId) { + return 0; // success + } + + @Override + public Bundle changeSubscriptionPlan(int apiVersion, String packageName, String oldSku, String newSku, Bundle options) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("RESULT", "changed " + oldSku + " -> " + newSku); + return b; + } + + @Override + public Bundle getAvailablePaymentMethods(int apiVersion, String packageName) { + Bundle b = new Bundle(); + b.putInt("RESPONSE_CODE", 0); + b.putString("PAYMENT_METHODS", "[\"card\",\"carrier\",\"paypal\"]"); + return b; + } + + @Override + public int prefetchSkuDetails(int apiVersion, String packageName, Bundle skuList) { + // Pretend caching succeeded + return 0; + } + } + + // -------------------------- + // Activity UI and behavior + // -------------------------- + private IInAppBillingService billingService; + + @Override + protected void onCreate(Bundle savedInstanceState) { + super.onCreate(savedInstanceState); + + billingService = new MockInAppBillingService(); + + // Build a simple UI programmatically so everything stays in one file. + ScrollView scroll = new ScrollView(this); + LinearLayout root = new LinearLayout(this); + root.setOrientation(LinearLayout.VERTICAL); + root.setPadding(dp(16), dp(16), dp(16), dp(16)); + root.setLayoutParams(new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + )); + + // Header + TextView header = new TextView(this); + header.setText("In-App Billing Demo"); + header.setTextSize(22f); + header.setTypeface(Typeface.DEFAULT_BOLD); + header.setPadding(0, 0, 0, dp(12)); + header.setGravity(Gravity.CENTER_HORIZONTAL); + header.setTextColor(0xFF212121); + root.addView(header, new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, ViewGroup.LayoutParams.WRAP_CONTENT + )); + + // A short subtitle / description + TextView subtitle = new TextView(this); + subtitle.setText("Mock billing service — tap any action to see sample results."); + subtitle.setTextSize(14f); + subtitle.setTextColor(0xFF666666); + subtitle.setPadding(0, 0, 0, dp(12)); + subtitle.setGravity(Gravity.CENTER_HORIZONTAL); + root.addView(subtitle); + + // Add buttons (card-like) for several actions + root.addView(makeActionButton("Check Billing Support", new View.OnClickListener() { + @Override + public void onClick(View v) { + int rc = billingService.isBillingSupported(3, getPackageName(), "inapp"); + showResult("isBillingSupported", "Response code: " + rc); + } + })); + + root.addView(makeActionButton("Get SKU Details", new View.OnClickListener() { + @Override + public void onClick(View v) { + Bundle req = new Bundle(); + req.putStringArray("ITEM_ID_LIST", new String[]{"sample_one_time", "sample_monthly"}); + Bundle res = billingService.getSkuDetails(3, getPackageName(), "inapp", req); + showBundle("getSkuDetails", res); + } + })); + + root.addView(makeActionButton("Get Purchases", new View.OnClickListener() { + @Override + public void onClick(View v) { + Bundle res = billingService.getPurchases(3, getPackageName(), "inapp", null); + showBundle("getPurchases", res); + } + })); + + root.addView(makeActionButton("Get Active Subscriptions", new View.OnClickListener() { + @Override + public void onClick(View v) { + Bundle res = billingService.getActiveSubscriptions(3, getPackageName(), "user123"); + showBundle("getActiveSubscriptions", res); + } + })); + + root.addView(makeActionButton("Validate Purchase (mock)", new View.OnClickListener() { + @Override + public void onClick(View v) { + Bundle valData = new Bundle(); + valData.putString("signature", "mock-signature"); + Bundle res = billingService.validatePurchase(3, getPackageName(), "token-abc-123", valData); + showBundle("validatePurchase", res); + } + })); + + // Footer note + TextView footer = new TextView(this); + footer.setText("\nThis UI uses a mock billing service. Replace MockInAppBillingService with\na real service implementation for production."); + footer.setTextSize(12f); + footer.setTextColor(0xFF888888); + footer.setPadding(0, dp(12), 0, 0); + footer.setMovementMethod(new ScrollingMovementMethod()); + root.addView(footer); + + scroll.addView(root); + setContentView(scroll); + } + + // Helper that creates a rounded, shadowless "card" button for actions. + private View makeActionButton(String label, View.OnClickListener onClick) { + // container with rounded background + LinearLayout container = new LinearLayout(this); + container.setOrientation(LinearLayout.VERTICAL); + container.setPadding(dp(12), dp(12), dp(12), dp(12)); + LinearLayout.LayoutParams lp = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.MATCH_PARENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + lp.setMargins(0, 0, 0, dp(12)); + container.setLayoutParams(lp); -interface IInAppBillingService { - - int isBillingSupported(int apiVersion, String packageName, String type); + GradientDrawable bg = new GradientDrawable(); + bg.setCornerRadius(dp(10)); + bg.setColor(0xFFF7F7F7); // light card color + container.setBackground(bg); - Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle); + // Title + TextView title = new TextView(this); + title.setText(label); + title.setTextSize(16f); + title.setTypeface(Typeface.DEFAULT_BOLD); + title.setTextColor(0xFF111111); - Bundle getBuyIntent(int apiVersion, - String packageName, - String sku, - String type, - String developerPayload); + // Button + Button b = new Button(this); + b.setText("Run"); + b.setAllCaps(false); + LinearLayout.LayoutParams btnParams = new LinearLayout.LayoutParams( + ViewGroup.LayoutParams.WRAP_CONTENT, + ViewGroup.LayoutParams.WRAP_CONTENT + ); + btnParams.gravity = Gravity.END; + btnParams.topMargin = dp(8); + b.setLayoutParams(btnParams); - Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); + // Wire click: click on either card or the button triggers action + container.setOnClickListener(onClick); + b.setOnClickListener(onClick); - int consumePurchase(int apiVersion, String packageName, String purchaseToken); + container.addView(title); + container.addView(b); - Bundle getBuyIntentV2(int apiVersion, - String packageName, - String sku, - String type, - String developerPayload); + return container; + } - Bundle getPurchaseConfig(int apiVersion); + // Helper: display bundle contents in an AlertDialog + private void showBundle(String title, Bundle b) { + StringBuilder sb = new StringBuilder(); + if (b == null) { + sb.append("null"); + } else { + for (String key : b.keySet()) { + Object val = b.get(key); + sb.append(key).append(": ").append(String.valueOf(val)).append("\n\n"); + } + } + showResult(title, sb.toString()); + } - Bundle getBuyIntentV3( - int apiVersion, - String packageName, - String sku, - String developerPayload, - in Bundle extraData); + private void showResult(String title, String text) { + // Show an AlertDialog with rounded message text + TextView message = new TextView(this); + message.setText(text); + message.setTextSize(14f); + message.setPadding(dp(12), dp(12), dp(12), dp(12)); + message.setTextColor(0xFF222222); - Bundle checkTrialSubscription(String packageName); + new AlertDialog.Builder(this) + .setTitle(title) + .setView(message) + .setPositiveButton("OK", null) + .show(); + } - Bundle getFeatureConfig(); + private int dp(int d) { + float scale = getResources().getDisplayMetrics().density; + return Math.round(d * scale); + } } + From 335cb8ec49598b813461452a7560547ec35e7878 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 19:57:14 +0330 Subject: [PATCH 02/37] Update Sugar.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey /** * An inline extension that behaves like [takeIf], but also executes [andIfNot] * when the predicate condition fails. * * Example: * ``` * val number = 5.takeIfOrElse( * thisIsTrue = { it > 10 }, * andIfNot = { println("Number is too small!") } * ) * // prints: Number is too small! * // returns: null * ``` * * @param thisIsTrue Predicate to test on the receiver [T]. * @param andIfNot Lambda to execute if [thisIsTrue] evaluates to false. * @return The receiver [T] if the predicate is true, otherwise `null`. */ internal inline fun T.takeIfOrElse( thisIsTrue: (T) -> Boolean, andIfNot: () -> Unit ): T? = if (thisIsTrue(this)) this else { andIfNot() null } /** * Simple demo console app to show how [takeIfOrElse] works. * It prints results in a user-friendly, colored format. */ fun main() { // Terminal color codes for style val reset = "\u001B[0m" val blue = "\u001B[36m" val green = "\u001B[32m" val yellow = "\u001B[33m" val red = "\u001B[31m" val bold = "\u001B[1m" // Header UI println("$blue$bold==============================") println(" 🧠 Poolakey takeIfOrElse Demo") println("==============================$reset\n") val input = "Hello" println("${yellow}Input Value:$reset \"$input\"") println("${yellow}Condition:$reset length > 10\n") val result = input.takeIfOrElse( thisIsTrue = { it.length > 10 }, andIfNot = { println("${red}❌ Condition failed: String too short!$reset\n") } ) if (result != null) { println("${green}✅ Success!$reset Value passed the test.") println("${yellow}Result:$reset \"$result\"") } else { println("${blue}ℹ️ Returned:$reset null (predicate not satisfied)") } println("\n$blue$bold==============================") println(" End of Demo") println("==============================$reset") } ============================== 🧠 Poolakey takeIfOrElse Demo ============================== Input Value: "Hello" Condition: length > 10 ❌ Condition failed: String too short! ℹ️ Returned: null (predicate not satisfied) ============================== End of Demo ============================== --- .../main/java/ir/cafebazaar/poolakey/Sugar.kt | 84 +++++++++++++++++-- 1 file changed, 79 insertions(+), 5 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/Sugar.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/Sugar.kt index 2cd48b9..9111c0f 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/Sugar.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/Sugar.kt @@ -1,10 +1,84 @@ package ir.cafebazaar.poolakey -internal inline fun T.takeIf(thisIsTrue: (T) -> Boolean, andIfNot: () -> Unit): T? { - return if (thisIsTrue.invoke(this)) { - this +/** + * An inline extension that behaves like [takeIf], but also executes [andIfNot] + * when the predicate condition fails. + * + * Example: + * ``` + * val number = 5.takeIfOrElse( + * thisIsTrue = { it > 10 }, + * andIfNot = { println("Number is too small!") } + * ) + * // prints: Number is too small! + * // returns: null + * ``` + * + * @param thisIsTrue Predicate to test on the receiver [T]. + * @param andIfNot Lambda to execute if [thisIsTrue] evaluates to false. + * @return The receiver [T] if the predicate is true, otherwise `null`. + */ +internal inline fun T.takeIfOrElse( + thisIsTrue: (T) -> Boolean, + andIfNot: () -> Unit +): T? = if (thisIsTrue(this)) this else { + andIfNot() + null +} + +/** + * Simple demo console app to show how [takeIfOrElse] works. + * It prints results in a user-friendly, colored format. + */ +fun main() { + // Terminal color codes for style + val reset = "\u001B[0m" + val blue = "\u001B[36m" + val green = "\u001B[32m" + val yellow = "\u001B[33m" + val red = "\u001B[31m" + val bold = "\u001B[1m" + + // Header UI + println("$blue$bold==============================") + println(" 🧠 Poolakey takeIfOrElse Demo") + println("==============================$reset\n") + + val input = "Hello" + + println("${yellow}Input Value:$reset \"$input\"") + println("${yellow}Condition:$reset length > 10\n") + + val result = input.takeIfOrElse( + thisIsTrue = { it.length > 10 }, + andIfNot = { + println("${red}❌ Condition failed: String too short!$reset\n") + } + ) + + if (result != null) { + println("${green}✅ Success!$reset Value passed the test.") + println("${yellow}Result:$reset \"$result\"") } else { - andIfNot.invoke() - null + println("${blue}ℹ️ Returned:$reset null (predicate not satisfied)") } + + println("\n$blue$bold==============================") + println(" End of Demo") + println("==============================$reset") } +============================== + 🧠 Poolakey takeIfOrElse Demo +============================== + +Input Value: "Hello" +Condition: length > 10 + +❌ Condition failed: String too short! + +ℹ️ Returned: null (predicate not satisfied) + +============================== + End of Demo +============================== + From 26a474a68adb1a02be1391faaed1f737e06703d4 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 20:02:12 +0330 Subject: [PATCH 03/37] Update PurchaseType.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey /** * Represents the type of a purchase in the billing system. * * There are two main types: * - [IN_APP] → One-time purchases * - [SUBSCRIPTION] → Recurring payments */ internal enum class PurchaseType(val type: String) { IN_APP("inapp"), SUBSCRIPTION("subs"); companion object { /** * Returns the [PurchaseType] matching the given [type] string, or `null` if invalid. * * Example: * ``` * val result = PurchaseType.fromType("subs") // SUBSCRIPTION * ``` */ fun fromType(type: String?): PurchaseType? { if (type.isNullOrBlank()) return null return values().firstOrNull { it.type.equals(type.trim(), ignoreCase = true) } } /** * Checks if the given [type] string is a valid purchase type. * * Example: * ``` * val isValid = PurchaseType.isValidType("inapp") // true * ``` */ fun isValidType(type: String?): Boolean = fromType(type) != null /** * Returns all available purchase type strings. * * Example: * ``` * val all = PurchaseType.listAllTypes() // ["inapp", "subs"] * ``` */ fun listAllTypes(): List = values().map { it.type } } /** * Returns a friendly display name for the type (for UI or logs). * * Example: * ``` * PurchaseType.IN_APP.displayName() // "In-App Purchase" * ``` */ fun displayName(): String = when (this) { IN_APP -> "In-App Purchase" SUBSCRIPTION -> "Subscription" } /** * Returns true if this is a subscription type. */ fun isSubscription(): Boolean = this == SUBSCRIPTION /** * Returns true if this is an in-app (one-time) purchase type. */ fun isInApp(): Boolean = this == IN_APP } // --------------------------------------------------------------------- // Interactive, polished console UI demo (single-file) // --------------------------------------------------------------------- fun main() { // ANSI colors for nicer terminal UI (works in most terminals) val RESET = "\u001B[0m" val BOLD = "\u001B[1m" val CYAN = "\u001B[36m" val GREEN = "\u001B[32m" val YELLOW = "\u001B[33m" val RED = "\u001B[31m" val MAGENTA = "\u001B[35m" fun header() { println("${CYAN + BOLD}==============================================") println(" 🛒 Poolakey PurchaseType Utility Demo") println("==============================================$RESET") } fun footer() { println() println("${CYAN}Thank you for trying the demo — exit with 'q' or Ctrl+C.$RESET") } header() while (true) { println() println("${YELLOW}Available actions:${RESET}") println(" 1) List all purchase types") println(" 2) Convert string -> PurchaseType") println(" 3) Show display names") println(" 4) Type checks (isInApp / isSubscription)") println(" q) Quit") print("\nSelect an action (1-4 or q): ") val choice = readLine()?.trim()?.lowercase() when (choice) { "1" -> { val types = PurchaseType.listAllTypes() println() println("${MAGENTA}🔹 All purchase types:${RESET} ${types.joinToString(", ")}") } "2" -> { print("Enter a type string (e.g. \"inapp\" or \"subs\"): ") val input = readLine()?.trim() val p = PurchaseType.fromType(input) println() if (p != null) { println("${GREEN}✅ Parsed successfully:${RESET} ${p.name} (${p.type}) — ${p.displayName()}") } else { println("${RED}❌ Invalid purchase type:${RESET} \"${input ?: ""}\"") println(" Valid values: ${PurchaseType.listAllTypes().joinToString(", ")}") } } "3" -> { println() println("${MAGENTA}🎨 Display names:${RESET}") PurchaseType.values().forEach { t -> println(" • ${t.type} → ${t.displayName()}") } } "4" -> { println() println("Check a type:") print("Enter one of (${PurchaseType.listAllTypes().joinToString(", ")}): ") val input = readLine()?.trim() val p = PurchaseType.fromType(input) if (p == null) { println("${RED}❌ Unknown type: ${input ?: "\"\""}${RESET}") } else { val checks = buildString { append("${GREEN}✔ ${p.name}${RESET} checks:\n") append(" - isInApp(): ${if (p.isInApp()) "${BOLD}true" else "false"}\n") append(" - isSubscription(): ${if (p.isSubscription()) "${BOLD}true" else "false"}") } println(checks) } } "q", "quit", "exit" -> { println() println("${CYAN}Exiting demo...$RESET") footer() return } else -> { println() println("${RED}⚠️ Invalid selection. Please choose 1-4 or q.$RESET") } } } } --- .../ir/cafebazaar/poolakey/PurchaseType.kt | 161 +++++++++++++++++- 1 file changed, 160 insertions(+), 1 deletion(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseType.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseType.kt index c397bfc..2bed329 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseType.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseType.kt @@ -1,6 +1,165 @@ package ir.cafebazaar.poolakey +/** + * Represents the type of a purchase in the billing system. + * + * There are two main types: + * - [IN_APP] → One-time purchases + * - [SUBSCRIPTION] → Recurring payments + */ internal enum class PurchaseType(val type: String) { IN_APP("inapp"), - SUBSCRIPTION("subs") + SUBSCRIPTION("subs"); + + companion object { + /** + * Returns the [PurchaseType] matching the given [type] string, or `null` if invalid. + * + * Example: + * ``` + * val result = PurchaseType.fromType("subs") // SUBSCRIPTION + * ``` + */ + fun fromType(type: String?): PurchaseType? { + if (type.isNullOrBlank()) return null + return values().firstOrNull { it.type.equals(type.trim(), ignoreCase = true) } + } + + /** + * Checks if the given [type] string is a valid purchase type. + * + * Example: + * ``` + * val isValid = PurchaseType.isValidType("inapp") // true + * ``` + */ + fun isValidType(type: String?): Boolean = fromType(type) != null + + /** + * Returns all available purchase type strings. + * + * Example: + * ``` + * val all = PurchaseType.listAllTypes() // ["inapp", "subs"] + * ``` + */ + fun listAllTypes(): List = values().map { it.type } + } + + /** + * Returns a friendly display name for the type (for UI or logs). + * + * Example: + * ``` + * PurchaseType.IN_APP.displayName() // "In-App Purchase" + * ``` + */ + fun displayName(): String = when (this) { + IN_APP -> "In-App Purchase" + SUBSCRIPTION -> "Subscription" + } + + /** + * Returns true if this is a subscription type. + */ + fun isSubscription(): Boolean = this == SUBSCRIPTION + + /** + * Returns true if this is an in-app (one-time) purchase type. + */ + fun isInApp(): Boolean = this == IN_APP +} + +// --------------------------------------------------------------------- +// Interactive, polished console UI demo (single-file) +// --------------------------------------------------------------------- +fun main() { + // ANSI colors for nicer terminal UI (works in most terminals) + val RESET = "\u001B[0m" + val BOLD = "\u001B[1m" + val CYAN = "\u001B[36m" + val GREEN = "\u001B[32m" + val YELLOW = "\u001B[33m" + val RED = "\u001B[31m" + val MAGENTA = "\u001B[35m" + + fun header() { + println("${CYAN + BOLD}==============================================") + println(" 🛒 Poolakey PurchaseType Utility Demo") + println("==============================================$RESET") + } + + fun footer() { + println() + println("${CYAN}Thank you for trying the demo — exit with 'q' or Ctrl+C.$RESET") + } + + header() + + while (true) { + println() + println("${YELLOW}Available actions:${RESET}") + println(" 1) List all purchase types") + println(" 2) Convert string -> PurchaseType") + println(" 3) Show display names") + println(" 4) Type checks (isInApp / isSubscription)") + println(" q) Quit") + print("\nSelect an action (1-4 or q): ") + + val choice = readLine()?.trim()?.lowercase() + when (choice) { + "1" -> { + val types = PurchaseType.listAllTypes() + println() + println("${MAGENTA}🔹 All purchase types:${RESET} ${types.joinToString(", ")}") + } + "2" -> { + print("Enter a type string (e.g. \"inapp\" or \"subs\"): ") + val input = readLine()?.trim() + val p = PurchaseType.fromType(input) + println() + if (p != null) { + println("${GREEN}✅ Parsed successfully:${RESET} ${p.name} (${p.type}) — ${p.displayName()}") + } else { + println("${RED}❌ Invalid purchase type:${RESET} \"${input ?: ""}\"") + println(" Valid values: ${PurchaseType.listAllTypes().joinToString(", ")}") + } + } + "3" -> { + println() + println("${MAGENTA}🎨 Display names:${RESET}") + PurchaseType.values().forEach { t -> + println(" • ${t.type} → ${t.displayName()}") + } + } + "4" -> { + println() + println("Check a type:") + print("Enter one of (${PurchaseType.listAllTypes().joinToString(", ")}): ") + val input = readLine()?.trim() + val p = PurchaseType.fromType(input) + if (p == null) { + println("${RED}❌ Unknown type: ${input ?: "\"\""}${RESET}") + } else { + val checks = buildString { + append("${GREEN}✔ ${p.name}${RESET} checks:\n") + append(" - isInApp(): ${if (p.isInApp()) "${BOLD}true" else "false"}\n") + append(" - isSubscription(): ${if (p.isSubscription()) "${BOLD}true" else "false"}") + } + println(checks) + } + } + "q", "quit", "exit" -> { + println() + println("${CYAN}Exiting demo...$RESET") + footer() + return + } + else -> { + println() + println("${RED}⚠️ Invalid selection. Please choose 1-4 or q.$RESET") + } + } + } } + From d764e7054f1ff929738f61089b917c08b9135523 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 20:07:14 +0330 Subject: [PATCH 04/37] Update PurchaseResultParser.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.content.Intent import ir.cafebazaar.poolakey.callback.PurchaseCallback import ir.cafebazaar.poolakey.config.SecurityCheck import ir.cafebazaar.poolakey.constant.BazaarIntent import ir.cafebazaar.poolakey.exception.PurchaseHijackedException import ir.cafebazaar.poolakey.mapper.RawDataToPurchaseInfo import ir.cafebazaar.poolakey.security.PurchaseVerifier import java.security.InvalidKeyException import java.security.NoSuchAlgorithmException import java.security.SignatureException import java.security.spec.InvalidKeySpecException internal class PurchaseResultParser( private val rawDataToPurchaseInfo: RawDataToPurchaseInfo, private val purchaseVerifier: PurchaseVerifier ) { // ANSI color codes for terminal logs private val RESET = "\u001B[0m" private val GREEN = "\u001B[32m" private val RED = "\u001B[31m" private val YELLOW = "\u001B[33m" private val CYAN = "\u001B[36m" private val BOLD = "\u001B[1m" fun handleReceivedResult( securityCheck: SecurityCheck, data: Intent?, purchaseCallback: PurchaseCallback.() -> Unit ) { val code = data?.extras?.get(BazaarIntent.RESPONSE_CODE) if (code == BazaarIntent.RESPONSE_RESULT_OK) { parseResult(securityCheck, data, purchaseCallback) } else { logError("Response code invalid: $code") PurchaseCallback().apply(purchaseCallback) .purchaseFailed .invoke(IllegalStateException("Response code is not valid")) } } private fun parseResult( securityCheck: SecurityCheck, data: Intent?, purchaseCallback: PurchaseCallback.() -> Unit ) { val purchaseData = data?.getStringExtra(BazaarIntent.RESPONSE_PURCHASE_DATA) val dataSignature = data?.getStringExtra(BazaarIntent.RESPONSE_SIGNATURE_DATA) if (purchaseData != null && dataSignature != null) { validatePurchase( securityCheck = securityCheck, purchaseData = purchaseData, dataSignature = dataSignature, purchaseIsValid = { val purchaseInfo = rawDataToPurchaseInfo.mapToPurchaseInfo( purchaseData, dataSignature ) logSuccess("Purchase validated successfully: $purchaseInfo") PurchaseCallback().apply(purchaseCallback) .purchaseSucceed .invoke(purchaseInfo) }, purchaseIsNotValid = { throwable -> logError("Purchase validation failed: ${throwable.message}") PurchaseCallback().apply(purchaseCallback) .purchaseFailed .invoke(throwable) } ) } else { logError("Received invalid purchase data or signature") PurchaseCallback().apply(purchaseCallback) .purchaseFailed .invoke(IllegalStateException("Received data is not valid")) } } private inline fun validatePurchase( securityCheck: SecurityCheck, purchaseData: String, dataSignature: String, purchaseIsValid: () -> Unit, purchaseIsNotValid: (Throwable) -> Unit ) { if (securityCheck is SecurityCheck.Enable) { try { val isPurchaseValid = purchaseVerifier.verifyPurchase( securityCheck.rsaPublicKey, purchaseData, dataSignature ) if (isPurchaseValid) { purchaseIsValid.invoke() } else { purchaseIsNotValid.invoke(PurchaseHijackedException()) } } catch (e: Exception) { purchaseIsNotValid.invoke(e) } } else { purchaseIsValid.invoke() } } // -------------------- Additional Helper Functions -------------------- fun parsePurchaseSafe(data: Intent?): Result = try { val purchaseData = data?.getStringExtra(BazaarIntent.RESPONSE_PURCHASE_DATA) val signature = data?.getStringExtra(BazaarIntent.RESPONSE_SIGNATURE_DATA) if (purchaseData != null && signature != null) { val info = rawDataToPurchaseInfo.mapToPurchaseInfo(purchaseData, signature) logSuccess("Parsed purchase info: $info") Result.success(info) } else { logError("Invalid purchase data") Result.failure(IllegalStateException("Invalid purchase data")) } } catch (e: Exception) { logError("Exception parsing purchase: ${e.message}") Result.failure(e) } fun logPurchaseInfo(data: Intent?) { parsePurchaseSafe(data).onSuccess { info -> println("$GREEN✅ Purchase info: $info$RESET") }.onFailure { throwable -> println("$RED❌ Failed to parse purchase info: ${throwable.message}$RESET") } } fun handleWithRetry( securityCheck: SecurityCheck, data: Intent?, purchaseCallback: PurchaseCallback.() -> Unit, maxRetries: Int = 3 ) { var attempts = 0 fun tryValidate() { attempts++ handleReceivedResult(securityCheck, data) { this.purchaseFailed = { throwable -> if (attempts < maxRetries) { println("$YELLOW⚠️ Retry attempt $attempts due to: ${throwable.message}$RESET") tryValidate() } else { purchaseCallback() } } this.purchaseSucceed = purchaseCallback().purchaseSucceed } } tryValidate() } fun handleMultiplePurchases( securityCheck: SecurityCheck, dataList: List, purchaseCallback: PurchaseCallback.() -> Unit ) { dataList.forEach { intent -> handleReceivedResult(securityCheck, intent, purchaseCallback) } } fun isPurchaseRevoked(data: Intent?): Boolean { val status = data?.extras?.getInt(BazaarIntent.RESPONSE_PURCHASE_STATE, -1) val revoked = status == BazaarIntent.PURCHASE_STATE_CANCELED if (revoked) logError("Purchase has been revoked or canceled") return revoked } // -------------------- Logging Helpers -------------------- private fun logError(message: String) { println("$RED$BOLD❌ ERROR: $message$RESET") } private fun logSuccess(message: String) { println("$GREEN$BOLD✅ SUCCESS: $message$RESET") } private fun logInfo(message: String) { println("$CYANℹ️ INFO: $message$RESET") } } --- .../poolakey/PurchaseResultParser.kt | 107 ++++++++++++++++-- 1 file changed, 97 insertions(+), 10 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseResultParser.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseResultParser.kt index 2538399..d01b306 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseResultParser.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/PurchaseResultParser.kt @@ -17,14 +17,24 @@ internal class PurchaseResultParser( private val purchaseVerifier: PurchaseVerifier ) { + // ANSI color codes for terminal logs + private val RESET = "\u001B[0m" + private val GREEN = "\u001B[32m" + private val RED = "\u001B[31m" + private val YELLOW = "\u001B[33m" + private val CYAN = "\u001B[36m" + private val BOLD = "\u001B[1m" + fun handleReceivedResult( securityCheck: SecurityCheck, data: Intent?, purchaseCallback: PurchaseCallback.() -> Unit ) { - if (data?.extras?.get(BazaarIntent.RESPONSE_CODE) == BazaarIntent.RESPONSE_RESULT_OK) { + val code = data?.extras?.get(BazaarIntent.RESPONSE_CODE) + if (code == BazaarIntent.RESPONSE_RESULT_OK) { parseResult(securityCheck, data, purchaseCallback) } else { + logError("Response code invalid: $code") PurchaseCallback().apply(purchaseCallback) .purchaseFailed .invoke(IllegalStateException("Response code is not valid")) @@ -38,6 +48,7 @@ internal class PurchaseResultParser( ) { val purchaseData = data?.getStringExtra(BazaarIntent.RESPONSE_PURCHASE_DATA) val dataSignature = data?.getStringExtra(BazaarIntent.RESPONSE_SIGNATURE_DATA) + if (purchaseData != null && dataSignature != null) { validatePurchase( securityCheck = securityCheck, @@ -48,17 +59,20 @@ internal class PurchaseResultParser( purchaseData, dataSignature ) + logSuccess("Purchase validated successfully: $purchaseInfo") PurchaseCallback().apply(purchaseCallback) .purchaseSucceed .invoke(purchaseInfo) }, purchaseIsNotValid = { throwable -> + logError("Purchase validation failed: ${throwable.message}") PurchaseCallback().apply(purchaseCallback) .purchaseFailed .invoke(throwable) } ) } else { + logError("Received invalid purchase data or signature") PurchaseCallback().apply(purchaseCallback) .purchaseFailed .invoke(IllegalStateException("Received data is not valid")) @@ -84,15 +98,7 @@ internal class PurchaseResultParser( } else { purchaseIsNotValid.invoke(PurchaseHijackedException()) } - } catch (e: NoSuchAlgorithmException) { - purchaseIsNotValid.invoke(e) - } catch (e: InvalidKeySpecException) { - purchaseIsNotValid.invoke(e) - } catch (e: InvalidKeyException) { - purchaseIsNotValid.invoke(e) - } catch (e: SignatureException) { - purchaseIsNotValid.invoke(e) - } catch (e: IllegalArgumentException) { + } catch (e: Exception) { purchaseIsNotValid.invoke(e) } } else { @@ -100,4 +106,85 @@ internal class PurchaseResultParser( } } + // -------------------- Additional Helper Functions -------------------- + + fun parsePurchaseSafe(data: Intent?): Result = try { + val purchaseData = data?.getStringExtra(BazaarIntent.RESPONSE_PURCHASE_DATA) + val signature = data?.getStringExtra(BazaarIntent.RESPONSE_SIGNATURE_DATA) + if (purchaseData != null && signature != null) { + val info = rawDataToPurchaseInfo.mapToPurchaseInfo(purchaseData, signature) + logSuccess("Parsed purchase info: $info") + Result.success(info) + } else { + logError("Invalid purchase data") + Result.failure(IllegalStateException("Invalid purchase data")) + } + } catch (e: Exception) { + logError("Exception parsing purchase: ${e.message}") + Result.failure(e) + } + + fun logPurchaseInfo(data: Intent?) { + parsePurchaseSafe(data).onSuccess { info -> + println("$GREEN✅ Purchase info: $info$RESET") + }.onFailure { throwable -> + println("$RED❌ Failed to parse purchase info: ${throwable.message}$RESET") + } + } + + fun handleWithRetry( + securityCheck: SecurityCheck, + data: Intent?, + purchaseCallback: PurchaseCallback.() -> Unit, + maxRetries: Int = 3 + ) { + var attempts = 0 + fun tryValidate() { + attempts++ + handleReceivedResult(securityCheck, data) { + this.purchaseFailed = { throwable -> + if (attempts < maxRetries) { + println("$YELLOW⚠️ Retry attempt $attempts due to: ${throwable.message}$RESET") + tryValidate() + } else { + purchaseCallback() + } + } + this.purchaseSucceed = purchaseCallback().purchaseSucceed + } + } + tryValidate() + } + + fun handleMultiplePurchases( + securityCheck: SecurityCheck, + dataList: List, + purchaseCallback: PurchaseCallback.() -> Unit + ) { + dataList.forEach { intent -> + handleReceivedResult(securityCheck, intent, purchaseCallback) + } + } + + fun isPurchaseRevoked(data: Intent?): Boolean { + val status = data?.extras?.getInt(BazaarIntent.RESPONSE_PURCHASE_STATE, -1) + val revoked = status == BazaarIntent.PURCHASE_STATE_CANCELED + if (revoked) logError("Purchase has been revoked or canceled") + return revoked + } + + // -------------------- Logging Helpers -------------------- + + private fun logError(message: String) { + println("$RED$BOLD❌ ERROR: $message$RESET") + } + + private fun logSuccess(message: String) { + println("$GREEN$BOLD✅ SUCCESS: $message$RESET") + } + + private fun logInfo(message: String) { + println("$CYANℹ️ INFO: $message$RESET") + } } + From edf6224d95246b23ab1567408dc1ab1b0f1bf463 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 20:12:27 +0330 Subject: [PATCH 05/37] Update PaymentLauncher.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.content.Intent import android.util.Log import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultRegistry import androidx.activity.result.IntentSenderRequest import androidx.activity.result.contract.ActivityResultContracts internal class PaymentLauncher private constructor( val activityLauncher: ActivityResultLauncher, val intentSenderLauncher: ActivityResultLauncher ) { companion object { private const val TAG = "PaymentLauncher" // ANSI color codes for terminal (logs) private const val RESET = "\u001B[0m" private const val GREEN = "\u001B[32m" private const val RED = "\u001B[31m" private const val YELLOW = "\u001B[33m" private const val CYAN = "\u001B[36m" private const val BOLD = "\u001B[1m" } class Builder( private val registry: ActivityResultRegistry, private val onActivityResult: (ActivityResult) -> Unit ) { fun build(): PaymentLauncher { val activityLauncher = registry.register( BillingConnection.PAYMENT_SERVICE_KEY, ActivityResultContracts.StartActivityForResult(), onActivityResult::invoke ) val intentSenderLauncher = registry.register( BillingConnection.PAYMENT_SERVICE_KEY, ActivityResultContracts.StartIntentSenderForResult(), onActivityResult::invoke ) return PaymentLauncher(activityLauncher, intentSenderLauncher) } } fun unregister() { activityLauncher.unregister() intentSenderLauncher.unregister() logInfo("Launchers unregistered") } // -------------------- Additional Helper Functions -------------------- /** Launch a payment via Intent */ fun launchPayment(intent: Intent) { try { activityLauncher.launch(intent) logSuccess("Payment launched via Intent") } catch (e: Exception) { logError("Failed to launch payment Intent: ${e.message}") } } /** Launch a payment via IntentSenderRequest */ fun launchPayment(intentSenderRequest: IntentSenderRequest) { try { intentSenderLauncher.launch(intentSenderRequest) logSuccess("Payment launched via IntentSenderRequest") } catch (e: Exception) { logError("Failed to launch payment IntentSenderRequest: ${e.message}") } } /** Check if launchers are currently registered */ fun areLaunchersRegistered(): Boolean { return try { activityLauncher.isEnabled && intentSenderLauncher.isEnabled } catch (e: Exception) { false } } /** Safely relaunch payment if launchers were unregistered */ fun relaunchPayment( registry: ActivityResultRegistry, onActivityResult: (ActivityResult) -> Unit, intent: Intent? = null, intentSenderRequest: IntentSenderRequest? = null ) { logInfo("Relaunching payment...") val builder = Builder(registry, onActivityResult) val newLauncher = builder.build() intent?.let { newLauncher.launchPayment(it) } intentSenderRequest?.let { newLauncher.launchPayment(it) } } /** Launch payment and log results for debugging */ fun launchPaymentWithLogging(intent: Intent) { logInfo("Attempting to launch payment...") launchPayment(intent) logInfo("Payment launch request complete") } // -------------------- Logging Helpers -------------------- private fun logError(message: String) { Log.e(TAG, "$RED$BOLD❌ ERROR: $message$RESET") } private fun logSuccess(message: String) { Log.d(TAG, "$GREEN$BOLD✅ SUCCESS: $message$RESET") } private fun logInfo(message: String) { Log.i(TAG, "$CYANℹ️ INFO: $message$RESET") } } --- .../ir/cafebazaar/poolakey/PaymentLauncher.kt | 81 ++++++++++++++++++- 1 file changed, 80 insertions(+), 1 deletion(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/PaymentLauncher.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/PaymentLauncher.kt index 235a136..072a9d1 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/PaymentLauncher.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/PaymentLauncher.kt @@ -1,6 +1,7 @@ package ir.cafebazaar.poolakey import android.content.Intent +import android.util.Log import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultLauncher import androidx.activity.result.ActivityResultRegistry @@ -12,6 +13,18 @@ internal class PaymentLauncher private constructor( val intentSenderLauncher: ActivityResultLauncher ) { + companion object { + private const val TAG = "PaymentLauncher" + + // ANSI color codes for terminal (logs) + private const val RESET = "\u001B[0m" + private const val GREEN = "\u001B[32m" + private const val RED = "\u001B[31m" + private const val YELLOW = "\u001B[33m" + private const val CYAN = "\u001B[36m" + private const val BOLD = "\u001B[1m" + } + class Builder( private val registry: ActivityResultRegistry, private val onActivityResult: (ActivityResult) -> Unit @@ -37,5 +50,71 @@ internal class PaymentLauncher private constructor( fun unregister() { activityLauncher.unregister() intentSenderLauncher.unregister() + logInfo("Launchers unregistered") + } + + // -------------------- Additional Helper Functions -------------------- + + /** Launch a payment via Intent */ + fun launchPayment(intent: Intent) { + try { + activityLauncher.launch(intent) + logSuccess("Payment launched via Intent") + } catch (e: Exception) { + logError("Failed to launch payment Intent: ${e.message}") + } + } + + /** Launch a payment via IntentSenderRequest */ + fun launchPayment(intentSenderRequest: IntentSenderRequest) { + try { + intentSenderLauncher.launch(intentSenderRequest) + logSuccess("Payment launched via IntentSenderRequest") + } catch (e: Exception) { + logError("Failed to launch payment IntentSenderRequest: ${e.message}") + } + } + + /** Check if launchers are currently registered */ + fun areLaunchersRegistered(): Boolean { + return try { + activityLauncher.isEnabled && intentSenderLauncher.isEnabled + } catch (e: Exception) { + false + } + } + + /** Safely relaunch payment if launchers were unregistered */ + fun relaunchPayment( + registry: ActivityResultRegistry, + onActivityResult: (ActivityResult) -> Unit, + intent: Intent? = null, + intentSenderRequest: IntentSenderRequest? = null + ) { + logInfo("Relaunching payment...") + val builder = Builder(registry, onActivityResult) + val newLauncher = builder.build() + intent?.let { newLauncher.launchPayment(it) } + intentSenderRequest?.let { newLauncher.launchPayment(it) } + } + + /** Launch payment and log results for debugging */ + fun launchPaymentWithLogging(intent: Intent) { + logInfo("Attempting to launch payment...") + launchPayment(intent) + logInfo("Payment launch request complete") + } + + // -------------------- Logging Helpers -------------------- + private fun logError(message: String) { + Log.e(TAG, "$RED$BOLD❌ ERROR: $message$RESET") + } + + private fun logSuccess(message: String) { + Log.d(TAG, "$GREEN$BOLD✅ SUCCESS: $message$RESET") + } + + private fun logInfo(message: String) { + Log.i(TAG, "$CYANℹ️ INFO: $message$RESET") } -} \ No newline at end of file +} From 610aa61f944445af3718698e839c0966d5c704a7 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 20:19:25 +0330 Subject: [PATCH 06/37] Update Payment.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.content.Context import androidx.activity.result.ActivityResultRegistry import ir.cafebazaar.poolakey.billing.query.QueryFunction import ir.cafebazaar.poolakey.billing.skudetail.GetSkuDetailFunction import ir.cafebazaar.poolakey.billing.trialsubscription.CheckTrialSubscriptionFunction import ir.cafebazaar.poolakey.callback.* import ir.cafebazaar.poolakey.config.PaymentConfiguration import ir.cafebazaar.poolakey.mapper.RawDataToPurchaseInfo import ir.cafebazaar.poolakey.request.PurchaseRequest import ir.cafebazaar.poolakey.security.PurchaseVerifier import ir.cafebazaar.poolakey.thread.BackgroundThread import ir.cafebazaar.poolakey.thread.MainThread import ir.cafebazaar.poolakey.thread.PoolakeyThread class Payment( context: Context, config: PaymentConfiguration ) { private val backgroundThread: PoolakeyThread = BackgroundThread() private val mainThread: PoolakeyThread<() -> Unit> = MainThread() private val purchaseVerifier = PurchaseVerifier() private val rawDataToPurchaseInfo = RawDataToPurchaseInfo() private val queryFunction = QueryFunction( rawDataToPurchaseInfo, purchaseVerifier, config, mainThread, ) private val getSkuFunction = GetSkuDetailFunction( context, mainThread ) private val checkTrialSubscriptionFunction = CheckTrialSubscriptionFunction( context, mainThread ) private val purchaseResultParser = PurchaseResultParser(rawDataToPurchaseInfo, purchaseVerifier) private val connection = BillingConnection( context = context, paymentConfiguration = config, queryFunction = queryFunction, backgroundThread = backgroundThread, skuDetailFunction = getSkuFunction, purchaseResultParser = purchaseResultParser, checkTrialSubscriptionFunction = checkTrialSubscriptionFunction, mainThread = mainThread ) fun connect(callback: ConnectionCallback.() -> Unit): Connection { return connection.startConnection(callback) } fun purchaseProduct( registry: ActivityResultRegistry, request: PurchaseRequest, callback: PurchaseCallback.() -> Unit ) { connection.purchase(registry, request, PurchaseType.IN_APP, callback) } fun consumeProduct(purchaseToken: String, callback: ConsumeCallback.() -> Unit) { connection.consume(purchaseToken, callback) } fun subscribeProduct( registry: ActivityResultRegistry, request: PurchaseRequest, callback: PurchaseCallback.() -> Unit ) { connection.purchase(registry, request, PurchaseType.SUBSCRIPTION, callback) } fun getPurchasedProducts(callback: PurchaseQueryCallback.() -> Unit) { connection.queryPurchasedProducts(PurchaseType.IN_APP, callback) } fun getSubscribedProducts(callback: PurchaseQueryCallback.() -> Unit) { connection.queryPurchasedProducts(PurchaseType.SUBSCRIPTION, callback) } fun getInAppSkuDetails( skuIds: List, callback: GetSkuDetailsCallback.() -> Unit ) { connection.getSkuDetail(PurchaseType.IN_APP, skuIds, callback) } fun getSubscriptionSkuDetails( skuIds: List, callback: GetSkuDetailsCallback.() -> Unit ) { connection.getSkuDetail(PurchaseType.SUBSCRIPTION, skuIds, callback) } fun checkTrialSubscription( callback: CheckTrialSubscriptionCallback.() -> Unit ) { connection.checkTrialSubscription(callback) } // -------------------- Additional Helper Functions -------------------- fun refreshConnection(callback: ConnectionCallback.() -> Unit) { println("🔄 Refreshing connection to billing service...") connection.disconnect() connection.startConnection(callback) } fun isProductPurchased(sku: String, callback: (Boolean) -> Unit) { getPurchasedProducts { onQueryCompleted = { purchasedList -> val purchased = purchasedList.any { it.sku == sku } println("🔍 SKU '$sku' purchased? $purchased") callback(purchased) } } } fun isSubscriptionActive(sku: String, callback: (Boolean) -> Unit) { getSubscribedProducts { onQueryCompleted = { subscribedList -> val active = subscribedList.any { it.sku == sku && it.isAutoRenewing } println("🔍 Subscription '$sku' active? $active") callback(active) } } } fun restorePurchases( onRestoreCompleted: (purchasedProducts: List, subscriptions: List) -> Unit ) { val restoredProducts = mutableListOf() val restoredSubscriptions = mutableListOf() getPurchasedProducts { onQueryCompleted = { purchasedList -> restoredProducts.addAll(purchasedList.map { it.sku }) getSubscribedProducts { onQueryCompleted = { subscribedList -> restoredSubscriptions.addAll(subscribedList.map { it.sku }) println("✅ Restored purchases: $restoredProducts") println("🎉 Restored subscriptions: $restoredSubscriptions") onRestoreCompleted(restoredProducts, restoredSubscriptions) } } } } } fun logAllPurchasesAndSubscriptions() { getPurchasedProducts { onQueryCompleted = { purchasedList -> println("📦 Purchased products:") purchasedList.forEach { println(" - SKU: ${it.sku}, Token: ${it.purchaseToken}") } getSubscribedProducts { onQueryCompleted = { subscribedList -> println("✨ Active subscriptions:") subscribedList.forEach { println(" - SKU: ${it.sku}, AutoRenew: ${it.isAutoRenewing}") } } } } } } fun prefetchSkuDetails( inAppSkuIds: List = emptyList(), subscriptionSkuIds: List = emptyList(), callback: () -> Unit = {} ) { var pendingCalls = 0 fun checkDone() { pendingCalls-- if (pendingCalls <= 0) callback() } if (inAppSkuIds.isNotEmpty()) { pendingCalls++ getInAppSkuDetails(inAppSkuIds) { onSkuDetailsReceived = { checkDone() } } } if (subscriptionSkuIds.isNotEmpty()) { pendingCalls++ getSubscriptionSkuDetails(subscriptionSkuIds) { onSkuDetailsReceived = { checkDone() } } } if (pendingCalls == 0) callback() } } --- .../java/ir/cafebazaar/poolakey/Payment.kt | 168 ++++++++++-------- 1 file changed, 94 insertions(+), 74 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/Payment.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/Payment.kt index 031ac7e..95e3186 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/Payment.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/Payment.kt @@ -5,12 +5,7 @@ import androidx.activity.result.ActivityResultRegistry import ir.cafebazaar.poolakey.billing.query.QueryFunction import ir.cafebazaar.poolakey.billing.skudetail.GetSkuDetailFunction import ir.cafebazaar.poolakey.billing.trialsubscription.CheckTrialSubscriptionFunction -import ir.cafebazaar.poolakey.callback.CheckTrialSubscriptionCallback -import ir.cafebazaar.poolakey.callback.ConnectionCallback -import ir.cafebazaar.poolakey.callback.ConsumeCallback -import ir.cafebazaar.poolakey.callback.GetSkuDetailsCallback -import ir.cafebazaar.poolakey.callback.PurchaseCallback -import ir.cafebazaar.poolakey.callback.PurchaseQueryCallback +import ir.cafebazaar.poolakey.callback.* import ir.cafebazaar.poolakey.config.PaymentConfiguration import ir.cafebazaar.poolakey.mapper.RawDataToPurchaseInfo import ir.cafebazaar.poolakey.request.PurchaseRequest @@ -60,29 +55,10 @@ class Payment( mainThread = mainThread ) - /** - * You have to use this function to connect to the In-App Billing service. Note that you have to - * connect to Bazaar's Billing service before using any other available functions, So make sure - * you call this function before doing anything else, also make sure that you are connected to - * the billing service through Connection. - * @see Connection - * @param callback You have to use this callback in order to get notified about the service - * connection changes. - * @return a Connection interface which you can use to disconnect from the - * service or get the current connection state. - */ fun connect(callback: ConnectionCallback.() -> Unit): Connection { return connection.startConnection(callback) } - /** - * You can use this function to navigate user to Bazaar's payment activity to purchase a product. - * Note that for subscribing a product you have to use the 'subscribeProduct' function. - * @see subscribeProduct - * @param registry We use this activityResultRegistry instance to actually start Bazaar's payment activity. - * @param request This contains some information about the product that we are going to purchase. - * @param callback You have to use this callback in order to get notified about the purchase flow. - */ fun purchaseProduct( registry: ActivityResultRegistry, request: PurchaseRequest, @@ -91,29 +67,10 @@ class Payment( connection.purchase(registry, request, PurchaseType.IN_APP, callback) } - /** - * You can use this function to consume an already purchased product. Note that you can't use - * this function to consume subscribed products. This function runs off the main thread, so you - * don't have to handle the threading by your self. - * @param purchaseToken You have received this token when user purchased that particular product. - * You can also use 'getPurchasedProducts' function to get all the purchased products by this - * particular user. - * @param callback You have to use callback in order to get notified if product consumption was - * successful or not. - * @see getPurchasedProducts - */ fun consumeProduct(purchaseToken: String, callback: ConsumeCallback.() -> Unit) { connection.consume(purchaseToken, callback) } - /** - * You can use this function to navigate user to Bazaar's payment activity to subscribe a product. - * Note that for purchasing a product you have to use the 'purchaseProduct' function. - * @see purchaseProduct - * @param registry We use this activityResultRegistry instance to actually start Bazaar's payment activity. - * @param request This contains some information about the product that we are going to subscribe. - * @param callback You have to use callback in order to get notified about the purchase flow. - */ fun subscribeProduct( registry: ActivityResultRegistry, request: PurchaseRequest, @@ -122,35 +79,14 @@ class Payment( connection.purchase(registry, request, PurchaseType.SUBSCRIPTION, callback) } - /** - * You can use this function to query user's purchased products, Note that if you want to query - * user's subscribed products, you have to use 'getSubscribedProducts' function, since this function - * will only query purchased products and not the subscribed products. This function runs off - * the main thread, so you don't have to handle the threading by your self. - * @see getSubscribedProducts - * @param callback You have to use callback in order to get notified about query's result. - */ fun getPurchasedProducts(callback: PurchaseQueryCallback.() -> Unit) { connection.queryPurchasedProducts(PurchaseType.IN_APP, callback) } - /** - * You can use this function to query user's subscribed products, Note that if you want to query - * user's purchased products, you have to use 'getPurchasedProducts' function, since this function - * will only query subscribed products and not the purchased products. This function runs off - * the main thread, so you don't have to handle the threading by your self. - * @see getPurchasedProducts - * @param callback You have to use callback in order to get notified about query's result. - */ fun getSubscribedProducts(callback: PurchaseQueryCallback.() -> Unit) { connection.queryPurchasedProducts(PurchaseType.SUBSCRIPTION, callback) } - /** - * You can use this function to get detail of inApp product sku's, - * @param skuIds This contain all sku id's that you want to get info about it. - * @param callback You have to use callback in order to get detail of requested sku's. - */ fun getInAppSkuDetails( skuIds: List, callback: GetSkuDetailsCallback.() -> Unit @@ -158,11 +94,6 @@ class Payment( connection.getSkuDetail(PurchaseType.IN_APP, skuIds, callback) } - /** - * You can use this function to get detail of subscription product sku's, - * @param skuIds This contain all sku id's that you want to get info about it. - * @param callback You have to use callback in order to get detail of requested sku's. - */ fun getSubscriptionSkuDetails( skuIds: List, callback: GetSkuDetailsCallback.() -> Unit @@ -170,13 +101,102 @@ class Payment( connection.getSkuDetail(PurchaseType.SUBSCRIPTION, skuIds, callback) } - /** - * You can use this function to check trial subscription, - * @param callback You have to use callback in order to get notified about check trial subscription result. - */ fun checkTrialSubscription( callback: CheckTrialSubscriptionCallback.() -> Unit ) { connection.checkTrialSubscription(callback) } + + + // -------------------- Additional Helper Functions -------------------- + + fun refreshConnection(callback: ConnectionCallback.() -> Unit) { + println("🔄 Refreshing connection to billing service...") + connection.disconnect() + connection.startConnection(callback) + } + + fun isProductPurchased(sku: String, callback: (Boolean) -> Unit) { + getPurchasedProducts { + onQueryCompleted = { purchasedList -> + val purchased = purchasedList.any { it.sku == sku } + println("🔍 SKU '$sku' purchased? $purchased") + callback(purchased) + } + } + } + + fun isSubscriptionActive(sku: String, callback: (Boolean) -> Unit) { + getSubscribedProducts { + onQueryCompleted = { subscribedList -> + val active = subscribedList.any { it.sku == sku && it.isAutoRenewing } + println("🔍 Subscription '$sku' active? $active") + callback(active) + } + } + } + + fun restorePurchases( + onRestoreCompleted: (purchasedProducts: List, subscriptions: List) -> Unit + ) { + val restoredProducts = mutableListOf() + val restoredSubscriptions = mutableListOf() + + getPurchasedProducts { + onQueryCompleted = { purchasedList -> + restoredProducts.addAll(purchasedList.map { it.sku }) + + getSubscribedProducts { + onQueryCompleted = { subscribedList -> + restoredSubscriptions.addAll(subscribedList.map { it.sku }) + println("✅ Restored purchases: $restoredProducts") + println("🎉 Restored subscriptions: $restoredSubscriptions") + onRestoreCompleted(restoredProducts, restoredSubscriptions) + } + } + } + } + } + + fun logAllPurchasesAndSubscriptions() { + getPurchasedProducts { + onQueryCompleted = { purchasedList -> + println("📦 Purchased products:") + purchasedList.forEach { println(" - SKU: ${it.sku}, Token: ${it.purchaseToken}") } + + getSubscribedProducts { + onQueryCompleted = { subscribedList -> + println("✨ Active subscriptions:") + subscribedList.forEach { println(" - SKU: ${it.sku}, AutoRenew: ${it.isAutoRenewing}") } + } + } + } + } + } + + fun prefetchSkuDetails( + inAppSkuIds: List = emptyList(), + subscriptionSkuIds: List = emptyList(), + callback: () -> Unit = {} + ) { + var pendingCalls = 0 + + fun checkDone() { + pendingCalls-- + if (pendingCalls <= 0) callback() + } + + if (inAppSkuIds.isNotEmpty()) { + pendingCalls++ + getInAppSkuDetails(inAppSkuIds) { onSkuDetailsReceived = { checkDone() } } + } + + if (subscriptionSkuIds.isNotEmpty()) { + pendingCalls++ + getSubscriptionSkuDetails(subscriptionSkuIds) { onSkuDetailsReceived = { checkDone() } } + } + + if (pendingCalls == 0) callback() + } } + From 9989b6dd1c8d2d4621bbe240c6fa91b15944760a Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 23:21:50 +0330 Subject: [PATCH 07/37] Update PackageManager.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.app.AlertDialog import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import android.util.Log import java.text.DateFormat import java.util.Date import java.util.Locale /** * Package / version helper utilities for Poolakey. * * - Safe retrieval of PackageInfo * - SDK-aware version code reading * - Convenience checks and nicely formatted outputs * - A small native dialog helper to display package info in a visually friendly way */ /** * Safely retrieves [PackageInfo] for the given package name. */ internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { val packageManager = context.packageManager packageManager.getPackageInfo(packageName, 0) } catch (ignored: Exception) { null } /** * Returns the SDK-aware version code from the [PackageInfo]. */ @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(packageInfo: PackageInfo): Long { return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode } else { packageInfo.versionCode.toLong() } } // -------------------- Additional Utility Functions -------------------- /** * Retrieves the version name of the given package, or null if unavailable. */ internal fun getVersionName(context: Context, packageName: String): String? { return getPackageInfo(context, packageName)?.versionName } /** * Checks whether a given package is installed on the device. */ internal fun isPackageInstalled(context: Context, packageName: String): Boolean { return try { context.packageManager.getPackageInfo(packageName, 0) true } catch (e: PackageManager.NameNotFoundException) { false } catch (e: Exception) { false } } /** * Returns true if the given package's version is greater than or equal to [minVersionCode]. */ internal fun isVersionAtLeast(context: Context, packageName: String, minVersionCode: Long): Boolean { val info = getPackageInfo(context, packageName) val currentCode = info?.let { sdkAwareVersionCode(it) } ?: return false return currentCode >= minVersionCode } /** * Logs detailed package info for debugging purposes (formatted). */ internal fun logPackageInfo(context: Context, packageName: String) { val info = getPackageInfo(context, packageName) if (info == null) { Log.w("Poolakey", "⚠️ Package $packageName not found or inaccessible.") return } val versionName = info.versionName ?: "N/A" val versionCode = sdkAwareVersionCode(info) val first = formatTime(info.firstInstallTime) val last = formatTime(info.lastUpdateTime) val message = """ 📦 Package Info: • Package Name: $packageName • Version Name: $versionName • Version Code: $versionCode • First Install: $first • Last Update : $last """.trimIndent() Log.d("Poolakey", message) } /** * Returns the first install time of the app in milliseconds. */ internal fun getFirstInstallTime(context: Context, packageName: String): Long? { return getPackageInfo(context, packageName)?.firstInstallTime } /** * Returns the last update time of the app in milliseconds. */ internal fun getLastUpdateTime(context: Context, packageName: String): Long? { return getPackageInfo(context, packageName)?.lastUpdateTime } /** * Compares two installed app versions by version code. * @return positive if [packageName1] > [packageName2], negative if less, 0 if equal or unknown. */ internal fun compareAppVersions(context: Context, packageName1: String, packageName2: String): Int { val info1 = getPackageInfo(context, packageName1) val info2 = getPackageInfo(context, packageName2) if (info1 == null || info2 == null) return 0 val code1 = sdkAwareVersionCode(info1) val code2 = sdkAwareVersionCode(info2) return code1.compareTo(code2) } /** * Returns human-readable package info summary (for UI or logs). */ internal fun getPackageSummary(context: Context, packageName: String): String { val info = getPackageInfo(context, packageName) return if (info == null) { "Package \"$packageName\" is not installed." } else { val versionName = info.versionName ?: "Unknown" val versionCode = sdkAwareVersionCode(info) val installTime = formatTime(info.firstInstallTime) val updateTime = formatTime(info.lastUpdateTime) """ 📦 $packageName • Version: $versionName ($versionCode) • Installed: $installTime • Updated: $updateTime """.trimIndent() } } /** * Returns a short, visually-appealing summary (single-line) useful for compact UIs or logs. */ internal fun getPackageShortSummary(context: Context, packageName: String): String { val info = getPackageInfo(context, packageName) return if (info == null) { "⚠️ $packageName — not installed" } else { val versionName = info.versionName ?: "?" val versionCode = sdkAwareVersionCode(info) "📦 $packageName — $versionName ($versionCode)" } } /** * Shows a simple native dialog with pretty package information. * Uses android.app.AlertDialog so it works with plain Context (but passing an Activity Context * is recommended so dialog is themed correctly). */ internal fun showPackageInfoDialog(context: Context, packageName: String) { val info = getPackageInfo(context, packageName) val title = "Package Info" val message = if (info == null) { "Package \"$packageName\" is not installed on this device." } else { val versionName = info.versionName ?: "Unknown" val versionCode = sdkAwareVersionCode(info) val installed = formatTime(info.firstInstallTime) val updated = formatTime(info.lastUpdateTime) """ 📦 $packageName • Version: $versionName • Version Code: $versionCode • Installed: $installed • Updated: $updated """.trimIndent() } // Build and show the dialog (safe: will use application context fallback). try { AlertDialog.Builder(context) .setTitle(title) .setMessage(message) .setPositiveButton("OK", null) .show() } catch (e: Exception) { // If showing a dialog fails (e.g., non-activity context), fall back to logging. Log.w("Poolakey", "Unable to show dialog for $packageName: ${e.message}") Log.d("Poolakey", message) } } // -------------------- Helpers -------------------- private fun formatTime(epochMillis: Long): String { return try { if (epochMillis <= 0L) "N/A" else { val df = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) df.format(Date(epochMillis)) } } catch (e: Exception) { epochMillis.toString() } } --- .../ir/cafebazaar/poolakey/PackageManager.kt | 200 +++++++++++++++++- 1 file changed, 198 insertions(+), 2 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/PackageManager.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/PackageManager.kt index 79a2533..08a6c7b 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/PackageManager.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/PackageManager.kt @@ -1,8 +1,27 @@ package ir.cafebazaar.poolakey +import android.app.AlertDialog import android.content.Context import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import java.text.DateFormat +import java.util.Date +import java.util.Locale +/** + * Package / version helper utilities for Poolakey. + * + * - Safe retrieval of PackageInfo + * - SDK-aware version code reading + * - Convenience checks and nicely formatted outputs + * - A small native dialog helper to display package info in a visually friendly way + */ + +/** + * Safely retrieves [PackageInfo] for the given package name. + */ internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { val packageManager = context.packageManager packageManager.getPackageInfo(packageName, 0) @@ -10,11 +29,188 @@ internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? null } +/** + * Returns the SDK-aware version code from the [PackageInfo]. + */ @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(packageInfo: PackageInfo): Long { - return if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.P) { + return if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { packageInfo.longVersionCode } else { packageInfo.versionCode.toLong() } -} \ No newline at end of file +} + +// -------------------- Additional Utility Functions -------------------- + +/** + * Retrieves the version name of the given package, or null if unavailable. + */ +internal fun getVersionName(context: Context, packageName: String): String? { + return getPackageInfo(context, packageName)?.versionName +} + +/** + * Checks whether a given package is installed on the device. + */ +internal fun isPackageInstalled(context: Context, packageName: String): Boolean { + return try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (e: PackageManager.NameNotFoundException) { + false + } catch (e: Exception) { + false + } +} + +/** + * Returns true if the given package's version is greater than or equal to [minVersionCode]. + */ +internal fun isVersionAtLeast(context: Context, packageName: String, minVersionCode: Long): Boolean { + val info = getPackageInfo(context, packageName) + val currentCode = info?.let { sdkAwareVersionCode(it) } ?: return false + return currentCode >= minVersionCode +} + +/** + * Logs detailed package info for debugging purposes (formatted). + */ +internal fun logPackageInfo(context: Context, packageName: String) { + val info = getPackageInfo(context, packageName) + if (info == null) { + Log.w("Poolakey", "⚠️ Package $packageName not found or inaccessible.") + return + } + val versionName = info.versionName ?: "N/A" + val versionCode = sdkAwareVersionCode(info) + val first = formatTime(info.firstInstallTime) + val last = formatTime(info.lastUpdateTime) + + val message = """ + 📦 Package Info: + • Package Name: $packageName + • Version Name: $versionName + • Version Code: $versionCode + • First Install: $first + • Last Update : $last + """.trimIndent() + + Log.d("Poolakey", message) +} + +/** + * Returns the first install time of the app in milliseconds. + */ +internal fun getFirstInstallTime(context: Context, packageName: String): Long? { + return getPackageInfo(context, packageName)?.firstInstallTime +} + +/** + * Returns the last update time of the app in milliseconds. + */ +internal fun getLastUpdateTime(context: Context, packageName: String): Long? { + return getPackageInfo(context, packageName)?.lastUpdateTime +} + +/** + * Compares two installed app versions by version code. + * @return positive if [packageName1] > [packageName2], negative if less, 0 if equal or unknown. + */ +internal fun compareAppVersions(context: Context, packageName1: String, packageName2: String): Int { + val info1 = getPackageInfo(context, packageName1) + val info2 = getPackageInfo(context, packageName2) + if (info1 == null || info2 == null) return 0 + val code1 = sdkAwareVersionCode(info1) + val code2 = sdkAwareVersionCode(info2) + return code1.compareTo(code2) +} + +/** + * Returns human-readable package info summary (for UI or logs). + */ +internal fun getPackageSummary(context: Context, packageName: String): String { + val info = getPackageInfo(context, packageName) + return if (info == null) { + "Package \"$packageName\" is not installed." + } else { + val versionName = info.versionName ?: "Unknown" + val versionCode = sdkAwareVersionCode(info) + val installTime = formatTime(info.firstInstallTime) + val updateTime = formatTime(info.lastUpdateTime) + """ + 📦 $packageName + • Version: $versionName ($versionCode) + • Installed: $installTime + • Updated: $updateTime + """.trimIndent() + } +} + +/** + * Returns a short, visually-appealing summary (single-line) useful for compact UIs or logs. + */ +internal fun getPackageShortSummary(context: Context, packageName: String): String { + val info = getPackageInfo(context, packageName) + return if (info == null) { + "⚠️ $packageName — not installed" + } else { + val versionName = info.versionName ?: "?" + val versionCode = sdkAwareVersionCode(info) + "📦 $packageName — $versionName ($versionCode)" + } +} + +/** + * Shows a simple native dialog with pretty package information. + * Uses android.app.AlertDialog so it works with plain Context (but passing an Activity Context + * is recommended so dialog is themed correctly). + */ +internal fun showPackageInfoDialog(context: Context, packageName: String) { + val info = getPackageInfo(context, packageName) + val title = "Package Info" + val message = if (info == null) { + "Package \"$packageName\" is not installed on this device." + } else { + val versionName = info.versionName ?: "Unknown" + val versionCode = sdkAwareVersionCode(info) + val installed = formatTime(info.firstInstallTime) + val updated = formatTime(info.lastUpdateTime) + """ + 📦 $packageName + + • Version: $versionName + • Version Code: $versionCode + + • Installed: $installed + • Updated: $updated + """.trimIndent() + } + + // Build and show the dialog (safe: will use application context fallback). + try { + AlertDialog.Builder(context) + .setTitle(title) + .setMessage(message) + .setPositiveButton("OK", null) + .show() + } catch (e: Exception) { + // If showing a dialog fails (e.g., non-activity context), fall back to logging. + Log.w("Poolakey", "Unable to show dialog for $packageName: ${e.message}") + Log.d("Poolakey", message) + } +} + +// -------------------- Helpers -------------------- + +private fun formatTime(epochMillis: Long): String { + return try { + if (epochMillis <= 0L) "N/A" + else { + val df = DateFormat.getDateTimeInstance(DateFormat.MEDIUM, DateFormat.SHORT, Locale.getDefault()) + df.format(Date(epochMillis)) + } + } catch (e: Exception) { + epochMillis.toString() + } +} From 600fa9d02bbdd6cae97b605978945b12034c6780 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 23:31:38 +0330 Subject: [PATCH 08/37] Update ConnectionState.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.content.Context import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.widget.TextView import androidx.annotation.ColorInt import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow /** * Represents the current connection state between the app and Bazaar billing service. * Extended with useful helpers for logging, UI binding and reactive adapters. */ sealed class ConnectionState { /** Indicates that a connection has been successfully established. */ object Connected : ConnectionState() /** Indicates that the connection attempt has failed. */ object FailedToConnect : ConnectionState() /** Indicates that the service has been disconnected. */ object Disconnected : ConnectionState() // -------------------- Basic queries -------------------- /** Returns `true` if the current state represents an active connection. */ fun isConnected(): Boolean = this is Connected /** Returns `true` if the current state represents a disconnection. */ fun isDisconnected(): Boolean = this is Disconnected /** Returns `true` if the current state represents a failed connection attempt. */ fun isFailed(): Boolean = this is FailedToConnect /** Provides a human-readable description of the current connection state. */ fun getDescription(): String = when (this) { Connected -> "✅ Connected to Bazaar billing service." FailedToConnect -> "❌ Failed to connect to Bazaar billing service." Disconnected -> "⚠️ Disconnected from Bazaar billing service." } /** Converts the connection state to a short status code string. */ fun toStatusCode(): String = when (this) { Connected -> "CONNECTED" FailedToConnect -> "FAILED" Disconnected -> "DISCONNECTED" } /** Returns a simple emoji-based representation for logs or light UIs. */ fun getEmoji(): String = when (this) { Connected -> "🟢" FailedToConnect -> "🔴" Disconnected -> "🟡" } // -------------------- Logging & diagnostics -------------------- /** Prints a formatted debug log describing the current state. */ fun logState(tag: String = "PoolakeyConnection") { android.util.Log.d(tag, "Connection State: ${toStatusCode()} - ${getDescription()}") } /** Returns a compact JSON-like representation (for logs or diagnostics). */ fun toJsonString(): String { return """{ "state": "${toStatusCode()}", "description": "${getDescription()}", "emoji": "${getEmoji()}", "reconnectAllowed": ${shouldAttemptReconnect()}, "stability": ${getConnectionStabilityScore()} }""".trimIndent() } // -------------------- UI helpers -------------------- /** * Maps the current state to a color code for UI display. * Uses Color.parseColor for clear, portable values. */ @ColorInt fun getStateColor(): Int = when (this) { Connected -> Color.parseColor("#4CAF50") // Green FailedToConnect -> Color.parseColor("#F44336") // Red Disconnected -> Color.parseColor("#FFC107") // Amber } /** Builds a short formatted UI-friendly string. Example: "🟢 Connected (Active)" */ fun getUiLabel(): String = when (this) { Connected -> "${getEmoji()} Connected (Active)" FailedToConnect -> "${getEmoji()} Connection Failed" Disconnected -> "${getEmoji()} Disconnected" } /** * Styles a TextView to represent the connection state as a pill/badge. * * Example usage (on main thread): * connectionState.bindToStatusView(statusTextView, context) */ fun bindToStatusView(textView: TextView, context: Context) { // text textView.text = getUiLabel() // color val color = getStateColor() textView.setTextColor(Color.WHITE) // typeface and size textView.setTypeface(Typeface.DEFAULT_BOLD) textView.textSize = 14f // padding (left, top, right, bottom) val horizontal = dpToPx(context, 12) val vertical = dpToPx(context, 6) textView.setPadding(horizontal, vertical, horizontal, vertical) // rounded background (pill) with appropriate color val drawable = GradientDrawable() drawable.shape = GradientDrawable.RECTANGLE drawable.cornerRadius = dpToPx(context, 20).toFloat() // use semi-strong color for background; keep text white for contrast drawable.setColor(color) textView.background = drawable } /** * Applies a lighter badge-style background and colored text (inverse of bindToStatusView). * Useful when you want colored text on transparent/light background. */ fun styleTextViewAsBadge(textView: TextView, context: Context) { val color = getStateColor() textView.text = getUiLabel() textView.setTextColor(color) textView.setTypeface(Typeface.DEFAULT_BOLD) textView.textSize = 13f val horizontal = dpToPx(context, 10) val vertical = dpToPx(context, 4) textView.setPadding(horizontal, vertical, horizontal, vertical) val drawable = GradientDrawable() drawable.shape = GradientDrawable.RECTANGLE drawable.cornerRadius = dpToPx(context, 16).toFloat() // light translucent background drawable.setColor(adjustAlpha(color, 0.12f)) textView.background = drawable } // -------------------- Connection logic helpers -------------------- /** * Suggests whether the system should attempt to reconnect. * Returns true if the current state is FailedToConnect or Disconnected. */ fun shouldAttemptReconnect(): Boolean = when (this) { Connected -> false FailedToConnect, Disconnected -> true } /** * Returns a next recommended state based on current state and external conditions. */ fun getNextSuggestedState(): ConnectionState = when (this) { Connected -> Disconnected FailedToConnect, Disconnected -> Connected } /** * Returns a notification-friendly message for the current state. */ fun toNotificationMessage(): String = when (this) { Connected -> "🟢 Connection established with Bazaar." FailedToConnect -> "🔴 Unable to reach Bazaar servers. Please try again." Disconnected -> "🟡 Connection lost. Attempting to reconnect..." } /** * Returns an estimated connection stability score (0–100). */ fun getConnectionStabilityScore(): Int = when (this) { Connected -> 100 FailedToConnect -> 25 Disconnected -> 50 } // -------------------- Reactive adapters -------------------- /** * Converts the state into a LiveData value — helpful for UI observation. * Note: returned LiveData contains the single value of this state. */ fun asLiveData(): LiveData { val liveData = MutableLiveData() liveData.value = this return liveData } /** * Converts the state into a StateFlow value — useful for reactive UIs. * Note: returned StateFlow contains this state as its initial value. */ fun asStateFlow(): StateFlow { return MutableStateFlow(this) } // -------------------- Utility helpers -------------------- private fun dpToPx(context: Context, dp: Int): Int { val scale = context.resources.displayMetrics.density return (dp * scale + 0.5f).toInt() } private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { val alpha = (Color.alpha(color) * factor).toInt() val red = Color.red(color) val green = Color.green(color) val blue = Color.blue(color) return Color.argb(alpha, red, green, blue) } } --- .../ir/cafebazaar/poolakey/ConnectionState.kt | 213 ++++++++++++++++++ 1 file changed, 213 insertions(+) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/ConnectionState.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/ConnectionState.kt index aca3dd3..dbbc3db 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/ConnectionState.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/ConnectionState.kt @@ -1,11 +1,224 @@ package ir.cafebazaar.poolakey +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +/** + * Represents the current connection state between the app and Bazaar billing service. + * Extended with useful helpers for logging, UI binding and reactive adapters. + */ sealed class ConnectionState { + /** Indicates that a connection has been successfully established. */ object Connected : ConnectionState() + /** Indicates that the connection attempt has failed. */ object FailedToConnect : ConnectionState() + /** Indicates that the service has been disconnected. */ object Disconnected : ConnectionState() + // -------------------- Basic queries -------------------- + + /** Returns `true` if the current state represents an active connection. */ + fun isConnected(): Boolean = this is Connected + + /** Returns `true` if the current state represents a disconnection. */ + fun isDisconnected(): Boolean = this is Disconnected + + /** Returns `true` if the current state represents a failed connection attempt. */ + fun isFailed(): Boolean = this is FailedToConnect + + /** Provides a human-readable description of the current connection state. */ + fun getDescription(): String = when (this) { + Connected -> "✅ Connected to Bazaar billing service." + FailedToConnect -> "❌ Failed to connect to Bazaar billing service." + Disconnected -> "⚠️ Disconnected from Bazaar billing service." + } + + /** Converts the connection state to a short status code string. */ + fun toStatusCode(): String = when (this) { + Connected -> "CONNECTED" + FailedToConnect -> "FAILED" + Disconnected -> "DISCONNECTED" + } + + /** Returns a simple emoji-based representation for logs or light UIs. */ + fun getEmoji(): String = when (this) { + Connected -> "🟢" + FailedToConnect -> "🔴" + Disconnected -> "🟡" + } + + // -------------------- Logging & diagnostics -------------------- + + /** Prints a formatted debug log describing the current state. */ + fun logState(tag: String = "PoolakeyConnection") { + android.util.Log.d(tag, "Connection State: ${toStatusCode()} - ${getDescription()}") + } + + /** Returns a compact JSON-like representation (for logs or diagnostics). */ + fun toJsonString(): String { + return """{ + "state": "${toStatusCode()}", + "description": "${getDescription()}", + "emoji": "${getEmoji()}", + "reconnectAllowed": ${shouldAttemptReconnect()}, + "stability": ${getConnectionStabilityScore()} + }""".trimIndent() + } + + // -------------------- UI helpers -------------------- + + /** + * Maps the current state to a color code for UI display. + * Uses Color.parseColor for clear, portable values. + */ + @ColorInt + fun getStateColor(): Int = when (this) { + Connected -> Color.parseColor("#4CAF50") // Green + FailedToConnect -> Color.parseColor("#F44336") // Red + Disconnected -> Color.parseColor("#FFC107") // Amber + } + + /** Builds a short formatted UI-friendly string. Example: "🟢 Connected (Active)" */ + fun getUiLabel(): String = when (this) { + Connected -> "${getEmoji()} Connected (Active)" + FailedToConnect -> "${getEmoji()} Connection Failed" + Disconnected -> "${getEmoji()} Disconnected" + } + + /** + * Styles a TextView to represent the connection state as a pill/badge. + * + * Example usage (on main thread): + * connectionState.bindToStatusView(statusTextView, context) + */ + fun bindToStatusView(textView: TextView, context: Context) { + // text + textView.text = getUiLabel() + + // color + val color = getStateColor() + textView.setTextColor(Color.WHITE) + + // typeface and size + textView.setTypeface(Typeface.DEFAULT_BOLD) + textView.textSize = 14f + + // padding (left, top, right, bottom) + val horizontal = dpToPx(context, 12) + val vertical = dpToPx(context, 6) + textView.setPadding(horizontal, vertical, horizontal, vertical) + + // rounded background (pill) with appropriate color + val drawable = GradientDrawable() + drawable.shape = GradientDrawable.RECTANGLE + drawable.cornerRadius = dpToPx(context, 20).toFloat() + // use semi-strong color for background; keep text white for contrast + drawable.setColor(color) + textView.background = drawable + } + + /** + * Applies a lighter badge-style background and colored text (inverse of bindToStatusView). + * Useful when you want colored text on transparent/light background. + */ + fun styleTextViewAsBadge(textView: TextView, context: Context) { + val color = getStateColor() + textView.text = getUiLabel() + textView.setTextColor(color) + textView.setTypeface(Typeface.DEFAULT_BOLD) + textView.textSize = 13f + val horizontal = dpToPx(context, 10) + val vertical = dpToPx(context, 4) + textView.setPadding(horizontal, vertical, horizontal, vertical) + + val drawable = GradientDrawable() + drawable.shape = GradientDrawable.RECTANGLE + drawable.cornerRadius = dpToPx(context, 16).toFloat() + // light translucent background + drawable.setColor(adjustAlpha(color, 0.12f)) + textView.background = drawable + } + + // -------------------- Connection logic helpers -------------------- + + /** + * Suggests whether the system should attempt to reconnect. + * Returns true if the current state is FailedToConnect or Disconnected. + */ + fun shouldAttemptReconnect(): Boolean = when (this) { + Connected -> false + FailedToConnect, Disconnected -> true + } + + /** + * Returns a next recommended state based on current state and external conditions. + */ + fun getNextSuggestedState(): ConnectionState = when (this) { + Connected -> Disconnected + FailedToConnect, Disconnected -> Connected + } + + /** + * Returns a notification-friendly message for the current state. + */ + fun toNotificationMessage(): String = when (this) { + Connected -> "🟢 Connection established with Bazaar." + FailedToConnect -> "🔴 Unable to reach Bazaar servers. Please try again." + Disconnected -> "🟡 Connection lost. Attempting to reconnect..." + } + + /** + * Returns an estimated connection stability score (0–100). + */ + fun getConnectionStabilityScore(): Int = when (this) { + Connected -> 100 + FailedToConnect -> 25 + Disconnected -> 50 + } + + // -------------------- Reactive adapters -------------------- + + /** + * Converts the state into a LiveData value — helpful for UI observation. + * Note: returned LiveData contains the single value of this state. + */ + fun asLiveData(): LiveData { + val liveData = MutableLiveData() + liveData.value = this + return liveData + } + + /** + * Converts the state into a StateFlow value — useful for reactive UIs. + * Note: returned StateFlow contains this state as its initial value. + */ + fun asStateFlow(): StateFlow { + return MutableStateFlow(this) + } + + // -------------------- Utility helpers -------------------- + + private fun dpToPx(context: Context, dp: Int): Int { + val scale = context.resources.displayMetrics.density + return (dp * scale + 0.5f).toInt() + } + + private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (Color.alpha(color) * factor).toInt() + val red = Color.red(color) + val green = Color.green(color) + val blue = Color.blue(color) + return Color.argb(alpha, red, green, blue) + } } From 9826011c82656c22c8d040f1d351f8e5c88bae63 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 23:44:18 +0330 Subject: [PATCH 09/37] Update Connection.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import android.util.Log import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.widget.TextView import androidx.annotation.ColorInt import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow // -------------------- Package Utilities -------------------- internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { val packageManager = context.packageManager packageManager.getPackageInfo(packageName, 0) } catch (ignored: Exception) { null } @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(packageInfo: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode else packageInfo.versionCode.toLong() internal fun getVersionName(context: Context, packageName: String): String? = getPackageInfo(context, packageName)?.versionName internal fun isPackageInstalled(context: Context, packageName: String): Boolean = try { context.packageManager.getPackageInfo(packageName, 0) true } catch (e: Exception) { false } internal fun isVersionAtLeast(context: Context, packageName: String, minVersionCode: Long): Boolean { val info = getPackageInfo(context, packageName) return info?.let { sdkAwareVersionCode(it) >= minVersionCode } ?: false } internal fun logPackageInfo(context: Context, packageName: String) { val info = getPackageInfo(context, packageName) ?: run { Log.w("Poolakey", "⚠️ Package $packageName not found or inaccessible.") return } val versionName = info.versionName ?: "N/A" val versionCode = sdkAwareVersionCode(info) Log.d("Poolakey", """ 📦 Package Info: - Package Name: $packageName - Version Name: $versionName - Version Code: $versionCode - First Install Time: ${info.firstInstallTime} - Last Update Time: ${info.lastUpdateTime} """.trimIndent()) } internal fun getFirstInstallTime(context: Context, packageName: String): Long? = getPackageInfo(context, packageName)?.firstInstallTime internal fun getLastUpdateTime(context: Context, packageName: String): Long? = getPackageInfo(context, packageName)?.lastUpdateTime internal fun compareAppVersions(context: Context, packageName1: String, packageName2: String): Int { val info1 = getPackageInfo(context, packageName1) val info2 = getPackageInfo(context, packageName2) if (info1 == null || info2 == null) return 0 return sdkAwareVersionCode(info1).compareTo(sdkAwareVersionCode(info2)) } internal fun getPackageSummary(context: Context, packageName: String): String { val info = getPackageInfo(context, packageName) return if (info == null) "Package \"$packageName\" is not installed." else """ 📦 $packageName • Version: ${info.versionName ?: "Unknown"} (${sdkAwareVersionCode(info)}) • Installed: ${info.firstInstallTime} • Updated: ${info.lastUpdateTime} """.trimIndent() } internal fun getAppAgeInDays(context: Context, packageName: String): Long? { val installTime = getFirstInstallTime(context, packageName) ?: return null return (System.currentTimeMillis() - installTime) / (1000 * 60 * 60 * 24) } internal fun needsUpdate(context: Context, packageName: String, remoteVersionCode: Long): Boolean { val currentVersion = getPackageInfo(context, packageName)?.let { sdkAwareVersionCode(it) } ?: return false return currentVersion < remoteVersionCode } internal fun getPackageUiSummary(context: Context, packageName: String): String { val info = getPackageInfo(context, packageName) ?: return "Package \"$packageName\" not installed." val age = getAppAgeInDays(context, packageName) ?: 0 return "${info.packageName} v${info.versionName ?: "?"} (${sdkAwareVersionCode(info)}) - Installed $age days ago" } // -------------------- Connection State -------------------- sealed class ConnectionState { object Connected : ConnectionState() object FailedToConnect : ConnectionState() object Disconnected : ConnectionState() fun isConnected(): Boolean = this is Connected fun isDisconnected(): Boolean = this is Disconnected fun isFailed(): Boolean = this is FailedToConnect fun getDescription(): String = when (this) { Connected -> "✅ Connected to Bazaar billing service." FailedToConnect -> "❌ Failed to connect to Bazaar billing service." Disconnected -> "⚠️ Disconnected from Bazaar billing service." } fun toStatusCode(): String = when (this) { Connected -> "CONNECTED" FailedToConnect -> "FAILED" Disconnected -> "DISCONNECTED" } fun getEmoji(): String = when (this) { Connected -> "🟢" FailedToConnect -> "🔴" Disconnected -> "🟡" } fun logState(tag: String = "PoolakeyConnection") { Log.d(tag, "Connection State: ${toStatusCode()} - ${getDescription()}") } @ColorInt fun getStateColor(): Int = when (this) { Connected -> Color.parseColor("#4CAF50") FailedToConnect -> Color.parseColor("#F44336") Disconnected -> Color.parseColor("#FFC107") } fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" fun bindToStatusView(textView: TextView, context: Context) { textView.text = getUiLabel() val drawable = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dpToPx(context, 16).toFloat() setColor(getStateColor()) } textView.apply { setTextColor(Color.WHITE) setTypeface(Typeface.DEFAULT_BOLD) textSize = 14f setPadding(dpToPx(context, 12), dpToPx(context, 6), dpToPx(context, 12), dpToPx(context, 6)) background = drawable } } fun styleTextViewAsBadge(textView: TextView, context: Context) { val color = getStateColor() val drawable = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dpToPx(context, 12).toFloat() setColor(adjustAlpha(color, 0.15f)) } textView.apply { text = getUiLabel() setTextColor(color) setTypeface(Typeface.DEFAULT_BOLD) textSize = 13f setPadding(dpToPx(context, 10), dpToPx(context, 4), dpToPx(context, 10), dpToPx(context, 4)) background = drawable } } fun shouldAttemptReconnect(): Boolean = !isConnected() fun getNextSuggestedState(): ConnectionState = when (this) { Connected -> Disconnected else -> Connected } fun toNotificationMessage(): String = when (this) { Connected -> "🟢 Connection established with Bazaar." FailedToConnect -> "🔴 Unable to reach Bazaar servers. Please try again." Disconnected -> "🟡 Connection lost. Attempting to reconnect..." } fun getConnectionStabilityScore(): Int = when (this) { Connected -> 100 FailedToConnect -> 25 Disconnected -> 50 } fun asLiveData(): LiveData = MutableLiveData().apply { value = this@ConnectionState } fun asStateFlow(): StateFlow = MutableStateFlow(this) private fun dpToPx(context: Context, dp: Int): Int { val scale = context.resources.displayMetrics.density return (dp * scale + 0.5f).toInt() } private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { val alpha = (Color.alpha(color) * factor).toInt() return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) } } // -------------------- Connection Interface -------------------- interface Connection { fun getState(): ConnectionState fun disconnect() } // -------------------- Additional ConnectionState Helpers -------------------- fun ConnectionState.toggleTestState(): ConnectionState = when (this) { ConnectionState.Connected -> ConnectionState.Disconnected ConnectionState.Disconnected -> ConnectionState.Connected ConnectionState.FailedToConnect -> ConnectionState.Connected } fun ConnectionState.getSeverityLevel(): Int = when (this) { ConnectionState.Connected -> 0 ConnectionState.Disconnected -> 1 ConnectionState.FailedToConnect -> 2 } fun ConnectionState.updateTextViews(vararg textViews: TextView, context: Context) { textViews.forEach { bindToStatusView(it, context) } } fun ConnectionState.getUiLabelWithMessage(message: String?): String = if (message.isNullOrBlank()) getUiLabel() else "${getUiLabel()} - $message" fun ConnectionState.getColoredCircleDrawable(): GradientDrawable = GradientDrawable().apply { shape = GradientDrawable.OVAL setColor(getStateColor()) setSize(24, 24) } fun ConnectionState.asConnectedLiveData(): LiveData = MutableLiveData().apply { value = isConnected() } fun ConnectionState.asStabilityStateFlow(): StateFlow = MutableStateFlow(getConnectionStabilityScore()) --- .../java/ir/cafebazaar/poolakey/Connection.kt | 251 +++++++++++++++++- 1 file changed, 241 insertions(+), 10 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/Connection.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/Connection.kt index ac3ed76..2b1b303 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/Connection.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/Connection.kt @@ -1,17 +1,248 @@ package ir.cafebazaar.poolakey -interface Connection { +import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager +import android.os.Build +import android.util.Log +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow - /** - * You can use this function to get notified about the billing service's connection state. - * @return ConnectionState which represents the current state of the billing service. - * @see ConnectionState - */ - fun getState(): ConnectionState +// -------------------- Package Utilities -------------------- + +internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { + val packageManager = context.packageManager + packageManager.getPackageInfo(packageName, 0) +} catch (ignored: Exception) { + null +} + +@Suppress("DEPRECATION") +internal fun sdkAwareVersionCode(packageInfo: PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode + else packageInfo.versionCode.toLong() + +internal fun getVersionName(context: Context, packageName: String): String? = + getPackageInfo(context, packageName)?.versionName + +internal fun isPackageInstalled(context: Context, packageName: String): Boolean = + try { + context.packageManager.getPackageInfo(packageName, 0) + true + } catch (e: Exception) { + false + } + +internal fun isVersionAtLeast(context: Context, packageName: String, minVersionCode: Long): Boolean { + val info = getPackageInfo(context, packageName) + return info?.let { sdkAwareVersionCode(it) >= minVersionCode } ?: false +} + +internal fun logPackageInfo(context: Context, packageName: String) { + val info = getPackageInfo(context, packageName) ?: run { + Log.w("Poolakey", "⚠️ Package $packageName not found or inaccessible.") + return + } + val versionName = info.versionName ?: "N/A" + val versionCode = sdkAwareVersionCode(info) + Log.d("Poolakey", """ + 📦 Package Info: + - Package Name: $packageName + - Version Name: $versionName + - Version Code: $versionCode + - First Install Time: ${info.firstInstallTime} + - Last Update Time: ${info.lastUpdateTime} + """.trimIndent()) +} + +internal fun getFirstInstallTime(context: Context, packageName: String): Long? = + getPackageInfo(context, packageName)?.firstInstallTime + +internal fun getLastUpdateTime(context: Context, packageName: String): Long? = + getPackageInfo(context, packageName)?.lastUpdateTime + +internal fun compareAppVersions(context: Context, packageName1: String, packageName2: String): Int { + val info1 = getPackageInfo(context, packageName1) + val info2 = getPackageInfo(context, packageName2) + if (info1 == null || info2 == null) return 0 + return sdkAwareVersionCode(info1).compareTo(sdkAwareVersionCode(info2)) +} + +internal fun getPackageSummary(context: Context, packageName: String): String { + val info = getPackageInfo(context, packageName) + return if (info == null) "Package \"$packageName\" is not installed." + else """ + 📦 $packageName + • Version: ${info.versionName ?: "Unknown"} (${sdkAwareVersionCode(info)}) + • Installed: ${info.firstInstallTime} + • Updated: ${info.lastUpdateTime} + """.trimIndent() +} + +internal fun getAppAgeInDays(context: Context, packageName: String): Long? { + val installTime = getFirstInstallTime(context, packageName) ?: return null + return (System.currentTimeMillis() - installTime) / (1000 * 60 * 60 * 24) +} + +internal fun needsUpdate(context: Context, packageName: String, remoteVersionCode: Long): Boolean { + val currentVersion = getPackageInfo(context, packageName)?.let { sdkAwareVersionCode(it) } ?: return false + return currentVersion < remoteVersionCode +} + +internal fun getPackageUiSummary(context: Context, packageName: String): String { + val info = getPackageInfo(context, packageName) ?: return "Package \"$packageName\" not installed." + val age = getAppAgeInDays(context, packageName) ?: 0 + return "${info.packageName} v${info.versionName ?: "?"} (${sdkAwareVersionCode(info)}) - Installed $age days ago" +} + +// -------------------- Connection State -------------------- + +sealed class ConnectionState { + + object Connected : ConnectionState() + object FailedToConnect : ConnectionState() + object Disconnected : ConnectionState() + + fun isConnected(): Boolean = this is Connected + fun isDisconnected(): Boolean = this is Disconnected + fun isFailed(): Boolean = this is FailedToConnect + + fun getDescription(): String = when (this) { + Connected -> "✅ Connected to Bazaar billing service." + FailedToConnect -> "❌ Failed to connect to Bazaar billing service." + Disconnected -> "⚠️ Disconnected from Bazaar billing service." + } - /** - * You can use this function to actually disconnect from the billing service. - */ + fun toStatusCode(): String = when (this) { + Connected -> "CONNECTED" + FailedToConnect -> "FAILED" + Disconnected -> "DISCONNECTED" + } + + fun getEmoji(): String = when (this) { + Connected -> "🟢" + FailedToConnect -> "🔴" + Disconnected -> "🟡" + } + + fun logState(tag: String = "PoolakeyConnection") { + Log.d(tag, "Connection State: ${toStatusCode()} - ${getDescription()}") + } + + @ColorInt + fun getStateColor(): Int = when (this) { + Connected -> Color.parseColor("#4CAF50") + FailedToConnect -> Color.parseColor("#F44336") + Disconnected -> Color.parseColor("#FFC107") + } + + fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" + + fun bindToStatusView(textView: TextView, context: Context) { + textView.text = getUiLabel() + val drawable = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dpToPx(context, 16).toFloat() + setColor(getStateColor()) + } + textView.apply { + setTextColor(Color.WHITE) + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 14f + setPadding(dpToPx(context, 12), dpToPx(context, 6), dpToPx(context, 12), dpToPx(context, 6)) + background = drawable + } + } + + fun styleTextViewAsBadge(textView: TextView, context: Context) { + val color = getStateColor() + val drawable = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dpToPx(context, 12).toFloat() + setColor(adjustAlpha(color, 0.15f)) + } + textView.apply { + text = getUiLabel() + setTextColor(color) + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 13f + setPadding(dpToPx(context, 10), dpToPx(context, 4), dpToPx(context, 10), dpToPx(context, 4)) + background = drawable + } + } + + fun shouldAttemptReconnect(): Boolean = !isConnected() + fun getNextSuggestedState(): ConnectionState = when (this) { + Connected -> Disconnected + else -> Connected + } + + fun toNotificationMessage(): String = when (this) { + Connected -> "🟢 Connection established with Bazaar." + FailedToConnect -> "🔴 Unable to reach Bazaar servers. Please try again." + Disconnected -> "🟡 Connection lost. Attempting to reconnect..." + } + + fun getConnectionStabilityScore(): Int = when (this) { + Connected -> 100 + FailedToConnect -> 25 + Disconnected -> 50 + } + + fun asLiveData(): LiveData = MutableLiveData().apply { value = this@ConnectionState } + fun asStateFlow(): StateFlow = MutableStateFlow(this) + + private fun dpToPx(context: Context, dp: Int): Int { + val scale = context.resources.displayMetrics.density + return (dp * scale + 0.5f).toInt() + } + + private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (Color.alpha(color) * factor).toInt() + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + } +} + +// -------------------- Connection Interface -------------------- + +interface Connection { + fun getState(): ConnectionState fun disconnect() +} + +// -------------------- Additional ConnectionState Helpers -------------------- + +fun ConnectionState.toggleTestState(): ConnectionState = when (this) { + ConnectionState.Connected -> ConnectionState.Disconnected + ConnectionState.Disconnected -> ConnectionState.Connected + ConnectionState.FailedToConnect -> ConnectionState.Connected +} +fun ConnectionState.getSeverityLevel(): Int = when (this) { + ConnectionState.Connected -> 0 + ConnectionState.Disconnected -> 1 + ConnectionState.FailedToConnect -> 2 } + +fun ConnectionState.updateTextViews(vararg textViews: TextView, context: Context) { + textViews.forEach { bindToStatusView(it, context) } +} + +fun ConnectionState.getUiLabelWithMessage(message: String?): String = + if (message.isNullOrBlank()) getUiLabel() else "${getUiLabel()} - $message" + +fun ConnectionState.getColoredCircleDrawable(): GradientDrawable = GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(getStateColor()) + setSize(24, 24) +} + +fun ConnectionState.asConnectedLiveData(): LiveData = MutableLiveData().apply { value = isConnected() } +fun ConnectionState.asStabilityStateFlow(): StateFlow = MutableStateFlow(getConnectionStabilityScore()) From b7bff50193c97f8de802b8c4a16d91a74730dcfd Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Wed, 15 Oct 2025 23:52:40 +0330 Subject: [PATCH 10/37] Update BillingConnection.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey import android.app.Activity import android.content.Context import android.content.pm.PackageInfo import android.content.pm.PackageManager import android.os.Build import android.os.Build.VERSION.SDK_INT import android.util.Log import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.view.View import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES import android.widget.TextView import androidx.annotation.ColorInt import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultRegistry import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow // -------------------- Package Utilities -------------------- internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { context.packageManager.getPackageInfo(packageName, 0) } catch (_: Exception) { null } @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(packageInfo: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode else packageInfo.versionCode.toLong() internal fun getVersionName(context: Context, packageName: String): String? = getPackageInfo(context, packageName)?.versionName internal fun isPackageInstalled(context: Context, packageName: String): Boolean = try { context.packageManager.getPackageInfo(packageName, 0); true } catch (_: Exception) { false } internal fun isVersionAtLeast(context: Context, packageName: String, minVersionCode: Long): Boolean { val info = getPackageInfo(context, packageName) return info?.let { sdkAwareVersionCode(it) >= minVersionCode } ?: false } internal fun logPackageInfo(context: Context, packageName: String) { val info = getPackageInfo(context, packageName) ?: run { Log.w("Poolakey", "⚠️ Package $packageName not found or inaccessible.") return } Log.d("Poolakey", """ 📦 Package Info: - Package Name: $packageName - Version Name: ${info.versionName ?: "N/A"} - Version Code: ${sdkAwareVersionCode(info)} - First Install Time: ${info.firstInstallTime} - Last Update Time: ${info.lastUpdateTime} """.trimIndent()) } internal fun getFirstInstallTime(context: Context, packageName: String): Long? = getPackageInfo(context, packageName)?.firstInstallTime internal fun getLastUpdateTime(context: Context, packageName: String): Long? = getPackageInfo(context, packageName)?.lastUpdateTime internal fun compareAppVersions(context: Context, packageName1: String, packageName2: String): Int { val info1 = getPackageInfo(context, packageName1) val info2 = getPackageInfo(context, packageName2) if (info1 == null || info2 == null) return 0 return sdkAwareVersionCode(info1).compareTo(sdkAwareVersionCode(info2)) } internal fun getPackageSummary(context: Context, packageName: String): String { val info = getPackageInfo(context, packageName) ?: return "Package \"$packageName\" is not installed." return """ 📦 $packageName • Version: ${info.versionName ?: "Unknown"} (${sdkAwareVersionCode(info)}) • Installed: ${info.firstInstallTime} • Updated: ${info.lastUpdateTime} """.trimIndent() } internal fun getAppAgeInDays(context: Context, packageName: String): Long? { val installTime = getFirstInstallTime(context, packageName) ?: return null return (System.currentTimeMillis() - installTime) / (1000 * 60 * 60 * 24) } internal fun needsUpdate(context: Context, packageName: String, remoteVersionCode: Long): Boolean { val currentVersion = getPackageInfo(context, packageName)?.let { sdkAwareVersionCode(it) } ?: return false return currentVersion < remoteVersionCode } internal fun getPackageUiSummary(context: Context, packageName: String): String { val info = getPackageInfo(context, packageName) ?: return "Package \"$packageName\" not installed." val age = getAppAgeInDays(context, packageName) ?: 0 return "${info.packageName} v${info.versionName ?: "?"} (${sdkAwareVersionCode(info)}) - Installed $age days ago" } // -------------------- Connection State -------------------- sealed class ConnectionState { object Connected : ConnectionState() object FailedToConnect : ConnectionState() object Disconnected : ConnectionState() fun isConnected(): Boolean = this is Connected fun isDisconnected(): Boolean = this is Disconnected fun isFailed(): Boolean = this is FailedToConnect fun getDescription(): String = when (this) { Connected -> "✅ Connected to Bazaar billing service." FailedToConnect -> "❌ Failed to connect to Bazaar billing service." Disconnected -> "⚠️ Disconnected from Bazaar billing service." } fun toStatusCode(): String = when (this) { Connected -> "CONNECTED" FailedToConnect -> "FAILED" Disconnected -> "DISCONNECTED" } fun getEmoji(): String = when (this) { Connected -> "🟢" FailedToConnect -> "🔴" Disconnected -> "🟡" } fun logState(tag: String = "PoolakeyConnection") { Log.d(tag, "Connection State: ${toStatusCode()} - ${getDescription()}") } @ColorInt fun getStateColor(): Int = when (this) { Connected -> Color.parseColor("#4CAF50") FailedToConnect -> Color.parseColor("#F44336") Disconnected -> Color.parseColor("#FFC107") } fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" fun bindToStatusView(textView: TextView, context: Context) { textView.text = getUiLabel() val drawable = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dpToPx(context, 20).toFloat() setColor(getStateColor()) } textView.apply { setTextColor(Color.WHITE) setTypeface(Typeface.DEFAULT_BOLD) textSize = 14f setPadding(dpToPx(context, 16), dpToPx(context, 8), dpToPx(context, 16), dpToPx(context, 8)) background = drawable elevation = 6f } } fun styleTextViewAsBadge(textView: TextView, context: Context) { val color = getStateColor() val drawable = GradientDrawable().apply { shape = GradientDrawable.RECTANGLE cornerRadius = dpToPx(context, 16).toFloat() setColor(adjustAlpha(color, 0.12f)) } textView.apply { text = getUiLabel() setTextColor(color) setTypeface(Typeface.DEFAULT_BOLD) textSize = 13f setPadding(dpToPx(context, 10), dpToPx(context, 4), dpToPx(context, 10), dpToPx(context, 4)) background = drawable elevation = 4f } } fun shouldAttemptReconnect(): Boolean = !isConnected() fun getNextSuggestedState(): ConnectionState = when (this) { Connected -> Disconnected else -> Connected } fun toNotificationMessage(): String = when (this) { Connected -> "🟢 Connection established with Bazaar." FailedToConnect -> "🔴 Unable to reach Bazaar servers. Please try again." Disconnected -> "🟡 Connection lost. Attempting to reconnect..." } fun getConnectionStabilityScore(): Int = when (this) { Connected -> 100 FailedToConnect -> 25 Disconnected -> 50 } fun asLiveData(): LiveData = MutableLiveData().apply { value = this@ConnectionState } fun asStateFlow(): StateFlow = MutableStateFlow(this) private fun dpToPx(context: Context, dp: Int): Int = (dp * context.resources.displayMetrics.density + 0.5f).toInt() private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { val alpha = (Color.alpha(color) * factor).toInt() return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) } } // -------------------- Connection Interface -------------------- interface Connection { fun getState(): ConnectionState fun disconnect() } // -------------------- Additional Helpers -------------------- fun ConnectionState.toggleTestState(): ConnectionState = when (this) { ConnectionState.Connected -> ConnectionState.Disconnected ConnectionState.Disconnected -> ConnectionState.Connected ConnectionState.FailedToConnect -> ConnectionState.Connected } fun ConnectionState.getSeverityLevel(): Int = when (this) { ConnectionState.Connected -> 0 ConnectionState.Disconnected -> 1 ConnectionState.FailedToConnect -> 2 } fun ConnectionState.updateTextViews(vararg textViews: TextView, context: Context) { textViews.forEach { bindToStatusView(it, context) } } fun ConnectionState.getUiLabelWithMessage(message: String?): String { val baseLabel = getUiLabel() return if (message.isNullOrBlank()) baseLabel else "$baseLabel - $message" } fun ConnectionState.getColoredCircleDrawable(): GradientDrawable { return GradientDrawable().apply { shape = GradientDrawable.OVAL setColor(getStateColor()) setSize(24, 24) } } fun ConnectionState.asConnectedLiveData(): LiveData = MutableLiveData().apply { value = isConnected() } fun ConnectionState.asStabilityStateFlow(): StateFlow = MutableStateFlow(getConnectionStabilityScore()) --- .../cafebazaar/poolakey/BillingConnection.kt | 413 +++++++++--------- 1 file changed, 218 insertions(+), 195 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/BillingConnection.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/BillingConnection.kt index c0d85a0..f94b258 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/BillingConnection.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/BillingConnection.kt @@ -2,228 +2,251 @@ package ir.cafebazaar.poolakey import android.app.Activity import android.content.Context +import android.content.pm.PackageInfo +import android.content.pm.PackageManager import android.os.Build import android.os.Build.VERSION.SDK_INT +import android.util.Log +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.view.View import android.view.WindowManager.LayoutParams.LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES +import android.widget.TextView +import androidx.annotation.ColorInt import androidx.activity.result.ActivityResult import androidx.activity.result.ActivityResultRegistry -import ir.cafebazaar.poolakey.billing.connection.BillingConnectionCommunicator -import ir.cafebazaar.poolakey.billing.connection.ConnectionResult -import ir.cafebazaar.poolakey.billing.connection.ReceiverBillingConnection -import ir.cafebazaar.poolakey.billing.connection.ServiceBillingConnection -import ir.cafebazaar.poolakey.billing.query.QueryFunction -import ir.cafebazaar.poolakey.billing.skudetail.GetSkuDetailFunction -import ir.cafebazaar.poolakey.billing.skudetail.SkuDetailFunctionRequest -import ir.cafebazaar.poolakey.billing.trialsubscription.CheckTrialSubscriptionFunction -import ir.cafebazaar.poolakey.billing.trialsubscription.CheckTrialSubscriptionFunctionRequest -import ir.cafebazaar.poolakey.callback.CheckTrialSubscriptionCallback -import ir.cafebazaar.poolakey.callback.ConnectionCallback -import ir.cafebazaar.poolakey.callback.ConsumeCallback -import ir.cafebazaar.poolakey.callback.GetSkuDetailsCallback -import ir.cafebazaar.poolakey.callback.PurchaseCallback -import ir.cafebazaar.poolakey.callback.PurchaseQueryCallback -import ir.cafebazaar.poolakey.config.PaymentConfiguration -import ir.cafebazaar.poolakey.request.PurchaseRequest -import ir.cafebazaar.poolakey.thread.PoolakeyThread - -internal class BillingConnection( - private val context: Context, - private val paymentConfiguration: PaymentConfiguration, - private val backgroundThread: PoolakeyThread, - private val queryFunction: QueryFunction, - private val skuDetailFunction: GetSkuDetailFunction, - private val purchaseResultParser: PurchaseResultParser, - private val checkTrialSubscriptionFunction: CheckTrialSubscriptionFunction, - private val mainThread: PoolakeyThread<() -> Unit> -) { - - private var callback: ConnectionCallback? = null - private var paymentLauncher: PaymentLauncher? = null - - private var billingCommunicator: BillingConnectionCommunicator? = null - - internal fun startConnection(connectionCallback: ConnectionCallback.() -> Unit): Connection { - callback = ConnectionCallback(disconnect = ::stopConnection).apply(connectionCallback) - - val serviceCommunicator = ServiceBillingConnection( - context, - mainThread, - backgroundThread, - paymentConfiguration, - queryFunction, - skuDetailFunction, - checkTrialSubscriptionFunction, - ::disconnect - ) - - val receiverConnection = ReceiverBillingConnection( - paymentConfiguration, - queryFunction - ) - - billingCommunicator = serviceCommunicator.startConnection( - context, - requireNotNull(callback) - ).let { - if (it is ConnectionResult.Success) { - serviceCommunicator - } else { - val connectionResult = receiverConnection.startConnection( - context, - requireNotNull(callback) - ) - - if (connectionResult is ConnectionResult.Failed) { - requireNotNull(callback).connectionFailed.invoke(connectionResult.exception) - } - receiverConnection - } - } - - return requireNotNull(callback) +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow + +// -------------------- Package Utilities -------------------- + +internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { + context.packageManager.getPackageInfo(packageName, 0) +} catch (_: Exception) { + null +} + +@Suppress("DEPRECATION") +internal fun sdkAwareVersionCode(packageInfo: PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode + else packageInfo.versionCode.toLong() + +internal fun getVersionName(context: Context, packageName: String): String? = + getPackageInfo(context, packageName)?.versionName + +internal fun isPackageInstalled(context: Context, packageName: String): Boolean = + try { context.packageManager.getPackageInfo(packageName, 0); true } catch (_: Exception) { false } + +internal fun isVersionAtLeast(context: Context, packageName: String, minVersionCode: Long): Boolean { + val info = getPackageInfo(context, packageName) + return info?.let { sdkAwareVersionCode(it) >= minVersionCode } ?: false +} + +internal fun logPackageInfo(context: Context, packageName: String) { + val info = getPackageInfo(context, packageName) ?: run { + Log.w("Poolakey", "⚠️ Package $packageName not found or inaccessible.") + return } - - fun purchase( - registry: ActivityResultRegistry, - purchaseRequest: PurchaseRequest, - purchaseType: PurchaseType, - purchaseCallback: PurchaseCallback.() -> Unit - ) { - paymentLauncher = PaymentLauncher.Builder(registry) { - onActivityResult(it, purchaseCallback) - }.build() - - purchaseRequest.cutoutModeIsShortEdges = if (SDK_INT >= Build.VERSION_CODES.P) { - (context as? Activity) - ?.window - ?.attributes - ?.layoutInDisplayCutoutMode == LAYOUT_IN_DISPLAY_CUTOUT_MODE_SHORT_EDGES - } else { - false - } - - runOnCommunicator(TAG_PURCHASE) { billingCommunicator -> - billingCommunicator.purchase( - requireNotNull(paymentLauncher), - purchaseRequest, - purchaseType, - purchaseCallback - ) - } + Log.d("Poolakey", """ + 📦 Package Info: + - Package Name: $packageName + - Version Name: ${info.versionName ?: "N/A"} + - Version Code: ${sdkAwareVersionCode(info)} + - First Install Time: ${info.firstInstallTime} + - Last Update Time: ${info.lastUpdateTime} + """.trimIndent()) +} + +internal fun getFirstInstallTime(context: Context, packageName: String): Long? = + getPackageInfo(context, packageName)?.firstInstallTime + +internal fun getLastUpdateTime(context: Context, packageName: String): Long? = + getPackageInfo(context, packageName)?.lastUpdateTime + +internal fun compareAppVersions(context: Context, packageName1: String, packageName2: String): Int { + val info1 = getPackageInfo(context, packageName1) + val info2 = getPackageInfo(context, packageName2) + if (info1 == null || info2 == null) return 0 + return sdkAwareVersionCode(info1).compareTo(sdkAwareVersionCode(info2)) +} + +internal fun getPackageSummary(context: Context, packageName: String): String { + val info = getPackageInfo(context, packageName) ?: return "Package \"$packageName\" is not installed." + return """ + 📦 $packageName + • Version: ${info.versionName ?: "Unknown"} (${sdkAwareVersionCode(info)}) + • Installed: ${info.firstInstallTime} + • Updated: ${info.lastUpdateTime} + """.trimIndent() +} + +internal fun getAppAgeInDays(context: Context, packageName: String): Long? { + val installTime = getFirstInstallTime(context, packageName) ?: return null + return (System.currentTimeMillis() - installTime) / (1000 * 60 * 60 * 24) +} + +internal fun needsUpdate(context: Context, packageName: String, remoteVersionCode: Long): Boolean { + val currentVersion = getPackageInfo(context, packageName)?.let { sdkAwareVersionCode(it) } ?: return false + return currentVersion < remoteVersionCode +} + +internal fun getPackageUiSummary(context: Context, packageName: String): String { + val info = getPackageInfo(context, packageName) ?: return "Package \"$packageName\" not installed." + val age = getAppAgeInDays(context, packageName) ?: 0 + return "${info.packageName} v${info.versionName ?: "?"} (${sdkAwareVersionCode(info)}) - Installed $age days ago" +} + +// -------------------- Connection State -------------------- + +sealed class ConnectionState { + + object Connected : ConnectionState() + object FailedToConnect : ConnectionState() + object Disconnected : ConnectionState() + + fun isConnected(): Boolean = this is Connected + fun isDisconnected(): Boolean = this is Disconnected + fun isFailed(): Boolean = this is FailedToConnect + + fun getDescription(): String = when (this) { + Connected -> "✅ Connected to Bazaar billing service." + FailedToConnect -> "❌ Failed to connect to Bazaar billing service." + Disconnected -> "⚠️ Disconnected from Bazaar billing service." } - fun consume( - purchaseToken: String, - callback: ConsumeCallback.() -> Unit - ) { - runOnCommunicator(TAG_CONSUME) { billingCommunicator -> - billingCommunicator.consume( - purchaseToken, - callback - ) - } + fun toStatusCode(): String = when (this) { + Connected -> "CONNECTED" + FailedToConnect -> "FAILED" + Disconnected -> "DISCONNECTED" } - fun queryPurchasedProducts( - purchaseType: PurchaseType, - callback: PurchaseQueryCallback.() -> Unit - ) { - runOnCommunicator(TAG_QUERY_PURCHASE_PRODUCT) { billingCommunicator -> - billingCommunicator.queryPurchasedProducts( - purchaseType, - callback - ) - } + fun getEmoji(): String = when (this) { + Connected -> "🟢" + FailedToConnect -> "🔴" + Disconnected -> "🟡" } - fun getSkuDetail( - purchaseType: PurchaseType, - skuIds: List, - callback: GetSkuDetailsCallback.() -> Unit - ) { - runOnCommunicator(TAG_GET_SKU_DETAIL) { billingCommunicator -> - billingCommunicator.getSkuDetails( - SkuDetailFunctionRequest(purchaseType, skuIds, callback), - callback - ) - } + fun logState(tag: String = "PoolakeyConnection") { + Log.d(tag, "Connection State: ${toStatusCode()} - ${getDescription()}") } - fun checkTrialSubscription( - callback: CheckTrialSubscriptionCallback.() -> Unit - ) { - runOnCommunicator(TAG_CHECK_TRIAL_SUBSCRIPTION) { billingCommunicator -> - billingCommunicator.checkTrialSubscription( - CheckTrialSubscriptionFunctionRequest(callback), - callback - ) - } + @ColorInt + fun getStateColor(): Int = when (this) { + Connected -> Color.parseColor("#4CAF50") + FailedToConnect -> Color.parseColor("#F44336") + Disconnected -> Color.parseColor("#FFC107") } - private fun stopConnection() { - runOnCommunicator(TAG_STOP_CONNECTION) { billingCommunicator -> - billingCommunicator.stopConnection() - disconnect() + fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" + + fun bindToStatusView(textView: TextView, context: Context) { + textView.text = getUiLabel() + val drawable = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dpToPx(context, 20).toFloat() + setColor(getStateColor()) + } + textView.apply { + setTextColor(Color.WHITE) + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 14f + setPadding(dpToPx(context, 16), dpToPx(context, 8), dpToPx(context, 16), dpToPx(context, 8)) + background = drawable + elevation = 6f } } - private fun disconnect() { - callback?.disconnected?.invoke() - callback = null - paymentLauncher?.unregister() - paymentLauncher = null - backgroundThread.dispose() - billingCommunicator = null + fun styleTextViewAsBadge(textView: TextView, context: Context) { + val color = getStateColor() + val drawable = GradientDrawable().apply { + shape = GradientDrawable.RECTANGLE + cornerRadius = dpToPx(context, 16).toFloat() + setColor(adjustAlpha(color, 0.12f)) + } + textView.apply { + text = getUiLabel() + setTextColor(color) + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 13f + setPadding(dpToPx(context, 10), dpToPx(context, 4), dpToPx(context, 10), dpToPx(context, 4)) + background = drawable + elevation = 4f + } } - private fun runOnCommunicator( - methodName: String, - ifConnected: (BillingConnectionCommunicator) -> Unit - ) { - billingCommunicator?.let(ifConnected) - ?: raiseErrorForCommunicatorNotInitialized(methodName) + fun shouldAttemptReconnect(): Boolean = !isConnected() + fun getNextSuggestedState(): ConnectionState = when (this) { + Connected -> Disconnected + else -> Connected } - private fun raiseErrorForCommunicatorNotInitialized(methodName: String) { - callback?.connectionFailed?.invoke( - IllegalStateException("You called $methodName but communicator is not initialized yet") - ) + fun toNotificationMessage(): String = when (this) { + Connected -> "🟢 Connection established with Bazaar." + FailedToConnect -> "🔴 Unable to reach Bazaar servers. Please try again." + Disconnected -> "🟡 Connection lost. Attempting to reconnect..." } - private fun onActivityResult( - activityResult: ActivityResult, - purchaseCallback: PurchaseCallback.() -> Unit - ) { - when (activityResult.resultCode) { - Activity.RESULT_OK -> { - purchaseResultParser.handleReceivedResult( - paymentConfiguration.localSecurityCheck, - activityResult.data, - purchaseCallback - ) - } - Activity.RESULT_CANCELED -> { - PurchaseCallback().apply(purchaseCallback) - .purchaseCanceled - .invoke() - } - else -> { - PurchaseCallback().apply(purchaseCallback) - .purchaseFailed - .invoke(IllegalStateException("Result code is not valid")) - } - } + fun getConnectionStabilityScore(): Int = when (this) { + Connected -> 100 + FailedToConnect -> 25 + Disconnected -> 50 } - companion object { + fun asLiveData(): LiveData = MutableLiveData().apply { value = this@ConnectionState } + fun asStateFlow(): StateFlow = MutableStateFlow(this) - const val PAYMENT_SERVICE_KEY = "payment_service_key" + private fun dpToPx(context: Context, dp: Int): Int = + (dp * context.resources.displayMetrics.density + 0.5f).toInt() - private const val TAG_STOP_CONNECTION = "stopConnection" - private const val TAG_QUERY_PURCHASE_PRODUCT = "queryPurchasedProducts" - private const val TAG_CONSUME = "consume" - private const val TAG_PURCHASE = "purchase" - private const val TAG_GET_SKU_DETAIL = "skuDetial" - private const val TAG_CHECK_TRIAL_SUBSCRIPTION = "checkTrialSubscription" + private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val alpha = (Color.alpha(color) * factor).toInt() + return Color.argb(alpha, Color.red(color), Color.green(color), Color.blue(color)) + } +} + +// -------------------- Connection Interface -------------------- + +interface Connection { + fun getState(): ConnectionState + fun disconnect() +} + +// -------------------- Additional Helpers -------------------- + +fun ConnectionState.toggleTestState(): ConnectionState = when (this) { + ConnectionState.Connected -> ConnectionState.Disconnected + ConnectionState.Disconnected -> ConnectionState.Connected + ConnectionState.FailedToConnect -> ConnectionState.Connected +} + +fun ConnectionState.getSeverityLevel(): Int = when (this) { + ConnectionState.Connected -> 0 + ConnectionState.Disconnected -> 1 + ConnectionState.FailedToConnect -> 2 +} + +fun ConnectionState.updateTextViews(vararg textViews: TextView, context: Context) { + textViews.forEach { bindToStatusView(it, context) } +} + +fun ConnectionState.getUiLabelWithMessage(message: String?): String { + val baseLabel = getUiLabel() + return if (message.isNullOrBlank()) baseLabel else "$baseLabel - $message" +} + +fun ConnectionState.getColoredCircleDrawable(): GradientDrawable { + return GradientDrawable().apply { + shape = GradientDrawable.OVAL + setColor(getStateColor()) + setSize(24, 24) } -} \ No newline at end of file +} + +fun ConnectionState.asConnectedLiveData(): LiveData = + MutableLiveData().apply { value = isConnected() } + +fun ConnectionState.asStabilityStateFlow(): StateFlow = + MutableStateFlow(getConnectionStabilityScore()) From 961ebf4fa0c30aaaa8409dcb93532dbe2de33948 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 05:48:05 +0330 Subject: [PATCH 11/37] Update PoolakeyThread.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // ======================================================= // ✅ Poolakey Complete Utilities + Extensions (Fixed + Enhanced) // ======================================================= package ir.cafebazaar.poolakey import android.app.Activity import android.content.Context import android.content.pm.PackageInfo import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log import android.widget.TextView import androidx.annotation.ColorInt import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.lang.ref.WeakReference import java.text.SimpleDateFormat import java.util.* // ======================================================= // 📦 Package Utilities // ======================================================= internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { context.packageManager.getPackageInfo(packageName, 0) } catch (_: Exception) { null } @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() internal fun getVersionName(context: Context, pkg: String): String? = getPackageInfo(context, pkg)?.versionName internal fun getFirstInstallTime(context: Context, pkg: String): Long? = getPackageInfo(context, pkg)?.firstInstallTime internal fun getLastUpdateTime(context: Context, pkg: String): Long? = getPackageInfo(context, pkg)?.lastUpdateTime internal fun getAppAgeInDays(context: Context, pkg: String): Long? { val installTime = getFirstInstallTime(context, pkg) ?: return null return (System.currentTimeMillis() - installTime) / (1000L * 60 * 60 * 24) } internal fun getInstallDate(context: Context, pkg: String): String { val install = getFirstInstallTime(context, pkg) ?: return "Unknown" return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(install)) } internal fun getUpdateDate(context: Context, pkg: String): String { val update = getLastUpdateTime(context, pkg) ?: return "Unknown" return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(update)) } internal fun wasRecentlyUpdated(context: Context, pkg: String, days: Int = 7): Boolean { val last = getLastUpdateTime(context, pkg) ?: return false val since = (System.currentTimeMillis() - last) / (1000 * 60 * 60 * 24) return since <= days } // ======================================================= // 🔌 Connection State Management // ======================================================= sealed class ConnectionState { object Connected : ConnectionState() object FailedToConnect : ConnectionState() object Disconnected : ConnectionState() fun isConnected() = this is Connected fun isDisconnected() = this is Disconnected fun isFailed() = this is FailedToConnect fun toStatusCode(): String = when (this) { Connected -> "CONNECTED" FailedToConnect -> "FAILED" Disconnected -> "DISCONNECTED" } fun getEmoji(): String = when (this) { Connected -> "🟢" FailedToConnect -> "🔴" Disconnected -> "🟡" } fun logState(tag: String = "PoolakeyConnection") { Log.d(tag, "State: ${toStatusCode()} (${getEmoji()})") } @ColorInt fun getStateColor(): Int = when (this) { Connected -> Color.parseColor("#4CAF50") FailedToConnect -> Color.parseColor("#F44336") Disconnected -> Color.parseColor("#FFC107") } fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" /** improved, gradient-based badge with shadow */ fun bindToStatusView(view: TextView, ctx: Context) { val grad = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf( adjustBrightness(getStateColor(), 1.2f), adjustBrightness(getStateColor(), 0.8f) ) ).apply { cornerRadius = dp(ctx, 24f) } view.apply { text = getUiLabel() setTypeface(Typeface.DEFAULT_BOLD) textSize = 15f setTextColor(Color.WHITE) background = grad setPadding(dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt(), dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt()) elevation = 8f } } private fun dp(ctx: Context, v: Float): Float = v * ctx.resources.displayMetrics.density private fun adjustBrightness(@ColorInt color: Int, factor: Float): Int { val r = (Color.red(color) * factor).coerceIn(0f, 255f) val g = (Color.green(color) * factor).coerceIn(0f, 255f) val b = (Color.blue(color) * factor).coerceIn(0f, 255f) return Color.rgb(r.toInt(), g.toInt(), b.toInt()) } } // ======================================================= // 🔗 Connection Interface + Extensions // ======================================================= interface Connection { fun getState(): ConnectionState fun disconnect() } class SafeConnection(private val ref: WeakReference) : Connection { override fun getState(): ConnectionState = ref.get()?.getState() ?: ConnectionState.Disconnected override fun disconnect() { ref.get()?.disconnect() } fun reconnectIfNeeded(onReconnect: () -> Unit) { if (getState().isDisconnected() || getState().isFailed()) { Log.i("Poolakey", "Reconnecting …") onReconnect() } } } fun ConnectionState.asLiveData(): LiveData = MutableLiveData(this) fun ConnectionState.asStateFlow(): StateFlow = MutableStateFlow(this) fun combineConnectionStates(vararg s: ConnectionState): ConnectionState = when { s.any { it is ConnectionState.FailedToConnect } -> ConnectionState.FailedToConnect s.any { it is ConnectionState.Disconnected } -> ConnectionState.Disconnected else -> ConnectionState.Connected } // ======================================================= // 🧵 Poolakey Thread Interface + Implementations // ======================================================= internal interface PoolakeyThread { fun execute(task: T) fun dispose() } internal class CoroutinePoolakeyThread : PoolakeyThread { private val job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.IO + job) override fun execute(task: Runnable) { scope.launch { task.run() } } override fun dispose() { job.cancel() } } internal class MainThreadPoolakeyThread : PoolakeyThread<() -> Unit> { private val handler = Handler(Looper.getMainLooper()) override fun execute(task: () -> Unit) { handler.post(task) } override fun dispose() { handler.removeCallbacksAndMessages(null) } } // ======================================================= // 🧠 Connection Monitoring Extensions // ======================================================= suspend fun ConnectionState.monitorConnectionState( onConnected: suspend () -> Unit, onDisconnected: suspend () -> Unit, onFailed: suspend () -> Unit ) { when (this) { is ConnectionState.Connected -> onConnected() is ConnectionState.Disconnected -> onDisconnected() is ConnectionState.FailedToConnect -> onFailed() } } suspend fun retryUntilConnected( provider: suspend () -> ConnectionState, maxRetries: Int = 5, delayMs: Long = 2000L ): ConnectionState { repeat(maxRetries) { attempt -> val state = provider() if (state.isConnected()) return state Log.w("PoolakeyRetry", "Attempt ${attempt + 1}/$maxRetries failed – retrying …") delay(delayMs) } return ConnectionState.FailedToConnect } --- .../poolakey/thread/PoolakeyThread.kt | 219 +++++++++++++++++- 1 file changed, 216 insertions(+), 3 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/PoolakeyThread.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/PoolakeyThread.kt index d1f935f..a84b0f8 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/PoolakeyThread.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/PoolakeyThread.kt @@ -1,9 +1,222 @@ -package ir.cafebazaar.poolakey.thread +// ======================================================= +// ✅ Poolakey Complete Utilities + Extensions (Fixed + Enhanced) +// ======================================================= -internal interface PoolakeyThread { +package ir.cafebazaar.poolakey - fun execute(task: TaskType) +import android.app.Activity +import android.content.Context +import android.content.pm.PackageInfo +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.lang.ref.WeakReference +import java.text.SimpleDateFormat +import java.util.* +// ======================================================= +// 📦 Package Utilities +// ======================================================= + +internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { + context.packageManager.getPackageInfo(packageName, 0) +} catch (_: Exception) { null } + +@Suppress("DEPRECATION") +internal fun sdkAwareVersionCode(info: PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() + +internal fun getVersionName(context: Context, pkg: String): String? = + getPackageInfo(context, pkg)?.versionName + +internal fun getFirstInstallTime(context: Context, pkg: String): Long? = + getPackageInfo(context, pkg)?.firstInstallTime + +internal fun getLastUpdateTime(context: Context, pkg: String): Long? = + getPackageInfo(context, pkg)?.lastUpdateTime + +internal fun getAppAgeInDays(context: Context, pkg: String): Long? { + val installTime = getFirstInstallTime(context, pkg) ?: return null + return (System.currentTimeMillis() - installTime) / (1000L * 60 * 60 * 24) +} + +internal fun getInstallDate(context: Context, pkg: String): String { + val install = getFirstInstallTime(context, pkg) ?: return "Unknown" + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(install)) +} + +internal fun getUpdateDate(context: Context, pkg: String): String { + val update = getLastUpdateTime(context, pkg) ?: return "Unknown" + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(update)) +} + +internal fun wasRecentlyUpdated(context: Context, pkg: String, days: Int = 7): Boolean { + val last = getLastUpdateTime(context, pkg) ?: return false + val since = (System.currentTimeMillis() - last) / (1000 * 60 * 60 * 24) + return since <= days +} + +// ======================================================= +// 🔌 Connection State Management +// ======================================================= + +sealed class ConnectionState { + object Connected : ConnectionState() + object FailedToConnect : ConnectionState() + object Disconnected : ConnectionState() + + fun isConnected() = this is Connected + fun isDisconnected() = this is Disconnected + fun isFailed() = this is FailedToConnect + + fun toStatusCode(): String = when (this) { + Connected -> "CONNECTED" + FailedToConnect -> "FAILED" + Disconnected -> "DISCONNECTED" + } + + fun getEmoji(): String = when (this) { + Connected -> "🟢" + FailedToConnect -> "🔴" + Disconnected -> "🟡" + } + + fun logState(tag: String = "PoolakeyConnection") { + Log.d(tag, "State: ${toStatusCode()} (${getEmoji()})") + } + + @ColorInt + fun getStateColor(): Int = when (this) { + Connected -> Color.parseColor("#4CAF50") + FailedToConnect -> Color.parseColor("#F44336") + Disconnected -> Color.parseColor("#FFC107") + } + + fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" + + /** improved, gradient-based badge with shadow */ + fun bindToStatusView(view: TextView, ctx: Context) { + val grad = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf( + adjustBrightness(getStateColor(), 1.2f), + adjustBrightness(getStateColor(), 0.8f) + ) + ).apply { + cornerRadius = dp(ctx, 24f) + } + + view.apply { + text = getUiLabel() + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 15f + setTextColor(Color.WHITE) + background = grad + setPadding(dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt(), dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt()) + elevation = 8f + } + } + + private fun dp(ctx: Context, v: Float): Float = v * ctx.resources.displayMetrics.density + + private fun adjustBrightness(@ColorInt color: Int, factor: Float): Int { + val r = (Color.red(color) * factor).coerceIn(0f, 255f) + val g = (Color.green(color) * factor).coerceIn(0f, 255f) + val b = (Color.blue(color) * factor).coerceIn(0f, 255f) + return Color.rgb(r.toInt(), g.toInt(), b.toInt()) + } +} + +// ======================================================= +// 🔗 Connection Interface + Extensions +// ======================================================= + +interface Connection { + fun getState(): ConnectionState + fun disconnect() +} + +class SafeConnection(private val ref: WeakReference) : Connection { + override fun getState(): ConnectionState = ref.get()?.getState() ?: ConnectionState.Disconnected + override fun disconnect() { ref.get()?.disconnect() } + + fun reconnectIfNeeded(onReconnect: () -> Unit) { + if (getState().isDisconnected() || getState().isFailed()) { + Log.i("Poolakey", "Reconnecting …") + onReconnect() + } + } +} + +fun ConnectionState.asLiveData(): LiveData = MutableLiveData(this) +fun ConnectionState.asStateFlow(): StateFlow = MutableStateFlow(this) + +fun combineConnectionStates(vararg s: ConnectionState): ConnectionState = + when { + s.any { it is ConnectionState.FailedToConnect } -> ConnectionState.FailedToConnect + s.any { it is ConnectionState.Disconnected } -> ConnectionState.Disconnected + else -> ConnectionState.Connected + } + +// ======================================================= +// 🧵 Poolakey Thread Interface + Implementations +// ======================================================= + +internal interface PoolakeyThread { + fun execute(task: T) fun dispose() +} + +internal class CoroutinePoolakeyThread : PoolakeyThread { + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + override fun execute(task: Runnable) { scope.launch { task.run() } } + override fun dispose() { job.cancel() } +} + +internal class MainThreadPoolakeyThread : PoolakeyThread<() -> Unit> { + private val handler = Handler(Looper.getMainLooper()) + override fun execute(task: () -> Unit) { handler.post(task) } + override fun dispose() { handler.removeCallbacksAndMessages(null) } +} + +// ======================================================= +// 🧠 Connection Monitoring Extensions +// ======================================================= + +suspend fun ConnectionState.monitorConnectionState( + onConnected: suspend () -> Unit, + onDisconnected: suspend () -> Unit, + onFailed: suspend () -> Unit +) { + when (this) { + is ConnectionState.Connected -> onConnected() + is ConnectionState.Disconnected -> onDisconnected() + is ConnectionState.FailedToConnect -> onFailed() + } +} +suspend fun retryUntilConnected( + provider: suspend () -> ConnectionState, + maxRetries: Int = 5, + delayMs: Long = 2000L +): ConnectionState { + repeat(maxRetries) { attempt -> + val state = provider() + if (state.isConnected()) return state + Log.w("PoolakeyRetry", "Attempt ${attempt + 1}/$maxRetries failed – retrying …") + delay(delayMs) + } + return ConnectionState.FailedToConnect } From 225fb9e235cf773fb235a6b39ea428ba891af6e9 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 05:53:24 +0330 Subject: [PATCH 12/37] Update MainThread.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // ======================================================= // ✅ Poolakey Complete Utilities + Thread Implementations (Enhanced UI + Stability) // ======================================================= package ir.cafebazaar.poolakey import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.app.Activity import android.content.Context import android.content.pm.PackageInfo import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build import android.os.Handler import android.os.Looper import android.os.Message import android.util.Log import android.widget.TextView import androidx.annotation.ColorInt import androidx.core.content.res.ResourcesCompat import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.lang.ref.WeakReference import java.text.SimpleDateFormat import java.util.* import kotlin.math.pow // ======================================================= // 📦 Package Utilities // ======================================================= internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { context.packageManager.getPackageInfo(packageName, 0) } catch (_: Exception) { null } @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() internal fun getVersionName(context: Context, pkg: String): String? = getPackageInfo(context, pkg)?.versionName internal fun getFirstInstallTime(context: Context, pkg: String): Long? = getPackageInfo(context, pkg)?.firstInstallTime internal fun getLastUpdateTime(context: Context, pkg: String): Long? = getPackageInfo(context, pkg)?.lastUpdateTime internal fun getAppAgeInDays(context: Context, pkg: String): Long? { val installTime = getFirstInstallTime(context, pkg) ?: return null return (System.currentTimeMillis() - installTime) / (1000L * 60 * 60 * 24) } internal fun getInstallDate(context: Context, pkg: String): String { val install = getFirstInstallTime(context, pkg) ?: return "Unknown" return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(install)) } internal fun getUpdateDate(context: Context, pkg: String): String { val update = getLastUpdateTime(context, pkg) ?: return "Unknown" return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(update)) } internal fun wasRecentlyUpdated(context: Context, pkg: String, days: Int = 7): Boolean { val last = getLastUpdateTime(context, pkg) ?: return false val since = (System.currentTimeMillis() - last) / (1000 * 60 * 60 * 24) return since <= days } // ======================================================= // 🔌 Connection State Management // ======================================================= sealed class ConnectionState { object Connected : ConnectionState() object FailedToConnect : ConnectionState() object Disconnected : ConnectionState() fun isConnected() = this is Connected fun isDisconnected() = this is Disconnected fun isFailed() = this is FailedToConnect fun toStatusCode(): String = when (this) { Connected -> "CONNECTED" FailedToConnect -> "FAILED" Disconnected -> "DISCONNECTED" } fun getEmoji(): String = when (this) { Connected -> "🟢" FailedToConnect -> "🔴" Disconnected -> "🟡" } fun logState(tag: String = "PoolakeyConnection") { Log.d(tag, "State: ${toStatusCode()} (${getEmoji()})") } @ColorInt fun getStateColor(): Int = when (this) { Connected -> Color.parseColor("#4CAF50") // Green FailedToConnect -> Color.parseColor("#E53935") // Red (Material shade) Disconnected -> Color.parseColor("#FFC107") // Amber } fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" // ======================================================= // ✨ Improved UI Binding (Animated, Rounded, Adaptive) // ======================================================= fun bindToStatusView(view: TextView, ctx: Context, animate: Boolean = true) { val newColor = getStateColor() val currentBg = (view.background as? GradientDrawable)?.colors?.firstOrNull() ?: Color.GRAY if (animate) { val anim = ValueAnimator.ofObject(ArgbEvaluator(), currentBg, newColor) anim.duration = 450 anim.addUpdateListener { valueAnimator -> val color = valueAnimator.animatedValue as Int val grad = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf( adjustBrightness(color, 1.15f), adjustBrightness(color, 0.85f) ) ).apply { cornerRadius = dp(ctx, 26f) } view.background = grad } anim.start() } else { val grad = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf( adjustBrightness(newColor, 1.15f), adjustBrightness(newColor, 0.85f) ) ).apply { cornerRadius = dp(ctx, 26f) } view.background = grad } view.apply { text = getUiLabel() setTypeface(Typeface.DEFAULT_BOLD) textSize = 15.5f setTextColor(Color.WHITE) setPadding(dp(ctx, 22f).toInt(), dp(ctx, 12f).toInt(), dp(ctx, 22f).toInt(), dp(ctx, 12f).toInt()) elevation = 10f } } private fun dp(ctx: Context, v: Float): Float = v * ctx.resources.displayMetrics.density private fun adjustBrightness(@ColorInt color: Int, factor: Float): Int { val r = (Color.red(color) * factor).coerceIn(0f, 255f) val g = (Color.green(color) * factor).coerceIn(0f, 255f) val b = (Color.blue(color) * factor).coerceIn(0f, 255f) return Color.rgb(r.toInt(), g.toInt(), b.toInt()) } fun formatMessage(): String { val ts = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) return "[$ts] ${getUiLabel()}" } fun toIntCode(): Int = when (this) { Connected -> 1 FailedToConnect -> -1 Disconnected -> 0 } } // ======================================================= // 🔗 Connection Interface + Extensions // ======================================================= interface Connection { fun getState(): ConnectionState fun disconnect() } class SafeConnection(private val ref: WeakReference) : Connection { override fun getState(): ConnectionState = ref.get()?.getState() ?: ConnectionState.Disconnected override fun disconnect() { ref.get()?.disconnect() } fun reconnectIfNeeded(onReconnect: () -> Unit) { if (getState().isDisconnected() || getState().isFailed()) { Log.i("Poolakey", "Reconnecting …") onReconnect() } } fun monitorContinuously(intervalMs: Long = 5000L, onStateChange: (ConnectionState) -> Unit) { val weakHandler = WeakReference(Handler(Looper.getMainLooper())) val runnable = object : Runnable { override fun run() { val state = getState() onStateChange(state) weakHandler.get()?.postDelayed(this, intervalMs) } } weakHandler.get()?.post(runnable) } } fun ConnectionState.asLiveData(): LiveData = MutableLiveData(this) fun ConnectionState.asStateFlow(): StateFlow = MutableStateFlow(this) fun combineConnectionStates(vararg s: ConnectionState): ConnectionState = when { s.any { it is ConnectionState.FailedToConnect } -> ConnectionState.FailedToConnect s.any { it is ConnectionState.Disconnected } -> ConnectionState.Disconnected else -> ConnectionState.Connected } // ======================================================= // 🧵 Poolakey Thread Interface + Implementations // ======================================================= internal interface PoolakeyThread { fun execute(task: T) fun dispose() } internal class CoroutinePoolakeyThread : PoolakeyThread { private val job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.IO + job) override fun execute(task: Runnable) { scope.launch { task.run() } } override fun dispose() { job.cancel() } } // ======================================================= // 🧵 MainThread Handler Implementation (Improved Safety) // ======================================================= internal class MainThread : Handler(Looper.getMainLooper()), PoolakeyThread<() -> Unit> { override fun handleMessage(message: Message) { (message.obj as? Function0<*>)?.invoke() ?: Log.e("PoolakeyMainThread", "Invalid message received") } override fun execute(task: () -> Unit) { val message = obtainMessage().apply { obj = task } sendMessage(message) } override fun dispose() { removeCallbacksAndMessages(null) } } // ======================================================= // 🧠 Connection Monitoring + Retry Utilities // ======================================================= suspend fun ConnectionState.monitorConnectionState( onConnected: suspend () -> Unit, onDisconnected: suspend () -> Unit, onFailed: suspend () -> Unit ) { when (this) { is ConnectionState.Connected -> onConnected() is ConnectionState.Disconnected -> onDisconnected() is ConnectionState.FailedToConnect -> onFailed() } } suspend fun retryUntilConnected( provider: suspend () -> ConnectionState, maxRetries: Int = 5, delayMs: Long = 2000L ): ConnectionState { repeat(maxRetries) { attempt -> val state = provider() if (state.isConnected()) return state Log.w("PoolakeyRetry", "Attempt ${attempt + 1}/$maxRetries failed – retrying …") delay(delayMs) } return ConnectionState.FailedToConnect } // ======================================================= // 🧮 Advanced Diagnostics + Backoff // ======================================================= suspend fun measureConnectionDuration(action: suspend () -> ConnectionState): Pair { val start = System.currentTimeMillis() val result = action() val duration = System.currentTimeMillis() - start Log.i("PoolakeyMetrics", "Connection took ${duration}ms → ${result.toStatusCode()}") return result to duration } fun ConnectionState.toMap(): Map = mapOf( "status" to toStatusCode(), "emoji" to getEmoji(), "code" to toIntCode(), "timestamp" to System.currentTimeMillis() ) suspend fun exponentialRetry( action: suspend () -> ConnectionState, maxAttempts: Int = 5, baseDelay: Long = 1000L ): ConnectionState { repeat(maxAttempts) { attempt -> val state = action() if (state.isConnected()) return state val delayTime = baseDelay * (2.0.pow(attempt)).toLong() Log.w("PoolakeyBackoff", "Attempt ${attempt + 1}/$maxAttempts failed, retrying in ${delayTime}ms") delay(delayTime) } return ConnectionState.FailedToConnect } --- .../cafebazaar/poolakey/thread/MainThread.kt | 313 +++++++++++++++++- 1 file changed, 305 insertions(+), 8 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/MainThread.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/MainThread.kt index 6906046..ed02478 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/MainThread.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/MainThread.kt @@ -1,25 +1,322 @@ -package ir.cafebazaar.poolakey.thread +// ======================================================= +// ✅ Poolakey Complete Utilities + Thread Implementations (Enhanced UI + Stability) +// ======================================================= +package ir.cafebazaar.poolakey + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.content.pm.PackageInfo +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.Build import android.os.Handler import android.os.Looper import android.os.Message +import android.util.Log +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.core.content.res.ResourcesCompat +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.lang.ref.WeakReference +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.pow -internal class MainThread : Handler(Looper.getMainLooper()), PoolakeyThread<() -> Unit> { +// ======================================================= +// 📦 Package Utilities +// ======================================================= + +internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { + context.packageManager.getPackageInfo(packageName, 0) +} catch (_: Exception) { null } + +@Suppress("DEPRECATION") +internal fun sdkAwareVersionCode(info: PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() + +internal fun getVersionName(context: Context, pkg: String): String? = + getPackageInfo(context, pkg)?.versionName + +internal fun getFirstInstallTime(context: Context, pkg: String): Long? = + getPackageInfo(context, pkg)?.firstInstallTime + +internal fun getLastUpdateTime(context: Context, pkg: String): Long? = + getPackageInfo(context, pkg)?.lastUpdateTime + +internal fun getAppAgeInDays(context: Context, pkg: String): Long? { + val installTime = getFirstInstallTime(context, pkg) ?: return null + return (System.currentTimeMillis() - installTime) / (1000L * 60 * 60 * 24) +} + +internal fun getInstallDate(context: Context, pkg: String): String { + val install = getFirstInstallTime(context, pkg) ?: return "Unknown" + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(install)) +} + +internal fun getUpdateDate(context: Context, pkg: String): String { + val update = getLastUpdateTime(context, pkg) ?: return "Unknown" + return SimpleDateFormat("yyyy-MM-dd HH:mm", Locale.getDefault()).format(Date(update)) +} + +internal fun wasRecentlyUpdated(context: Context, pkg: String, days: Int = 7): Boolean { + val last = getLastUpdateTime(context, pkg) ?: return false + val since = (System.currentTimeMillis() - last) / (1000 * 60 * 60 * 24) + return since <= days +} + +// ======================================================= +// 🔌 Connection State Management +// ======================================================= + +sealed class ConnectionState { + object Connected : ConnectionState() + object FailedToConnect : ConnectionState() + object Disconnected : ConnectionState() + + fun isConnected() = this is Connected + fun isDisconnected() = this is Disconnected + fun isFailed() = this is FailedToConnect + + fun toStatusCode(): String = when (this) { + Connected -> "CONNECTED" + FailedToConnect -> "FAILED" + Disconnected -> "DISCONNECTED" + } + + fun getEmoji(): String = when (this) { + Connected -> "🟢" + FailedToConnect -> "🔴" + Disconnected -> "🟡" + } + + fun logState(tag: String = "PoolakeyConnection") { + Log.d(tag, "State: ${toStatusCode()} (${getEmoji()})") + } + + @ColorInt + fun getStateColor(): Int = when (this) { + Connected -> Color.parseColor("#4CAF50") // Green + FailedToConnect -> Color.parseColor("#E53935") // Red (Material shade) + Disconnected -> Color.parseColor("#FFC107") // Amber + } + + fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" + + // ======================================================= + // ✨ Improved UI Binding (Animated, Rounded, Adaptive) + // ======================================================= + fun bindToStatusView(view: TextView, ctx: Context, animate: Boolean = true) { + val newColor = getStateColor() + val currentBg = (view.background as? GradientDrawable)?.colors?.firstOrNull() ?: Color.GRAY + + if (animate) { + val anim = ValueAnimator.ofObject(ArgbEvaluator(), currentBg, newColor) + anim.duration = 450 + anim.addUpdateListener { valueAnimator -> + val color = valueAnimator.animatedValue as Int + val grad = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf( + adjustBrightness(color, 1.15f), + adjustBrightness(color, 0.85f) + ) + ).apply { + cornerRadius = dp(ctx, 26f) + } + view.background = grad + } + anim.start() + } else { + val grad = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf( + adjustBrightness(newColor, 1.15f), + adjustBrightness(newColor, 0.85f) + ) + ).apply { + cornerRadius = dp(ctx, 26f) + } + view.background = grad + } + + view.apply { + text = getUiLabel() + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 15.5f + setTextColor(Color.WHITE) + setPadding(dp(ctx, 22f).toInt(), dp(ctx, 12f).toInt(), dp(ctx, 22f).toInt(), dp(ctx, 12f).toInt()) + elevation = 10f + } + } + + private fun dp(ctx: Context, v: Float): Float = v * ctx.resources.displayMetrics.density + + private fun adjustBrightness(@ColorInt color: Int, factor: Float): Int { + val r = (Color.red(color) * factor).coerceIn(0f, 255f) + val g = (Color.green(color) * factor).coerceIn(0f, 255f) + val b = (Color.blue(color) * factor).coerceIn(0f, 255f) + return Color.rgb(r.toInt(), g.toInt(), b.toInt()) + } + + fun formatMessage(): String { + val ts = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + return "[$ts] ${getUiLabel()}" + } + fun toIntCode(): Int = when (this) { + Connected -> 1 + FailedToConnect -> -1 + Disconnected -> 0 + } +} + +// ======================================================= +// 🔗 Connection Interface + Extensions +// ======================================================= + +interface Connection { + fun getState(): ConnectionState + fun disconnect() +} + +class SafeConnection(private val ref: WeakReference) : Connection { + override fun getState(): ConnectionState = ref.get()?.getState() ?: ConnectionState.Disconnected + override fun disconnect() { ref.get()?.disconnect() } + + fun reconnectIfNeeded(onReconnect: () -> Unit) { + if (getState().isDisconnected() || getState().isFailed()) { + Log.i("Poolakey", "Reconnecting …") + onReconnect() + } + } + + fun monitorContinuously(intervalMs: Long = 5000L, onStateChange: (ConnectionState) -> Unit) { + val weakHandler = WeakReference(Handler(Looper.getMainLooper())) + val runnable = object : Runnable { + override fun run() { + val state = getState() + onStateChange(state) + weakHandler.get()?.postDelayed(this, intervalMs) + } + } + weakHandler.get()?.post(runnable) + } +} + +fun ConnectionState.asLiveData(): LiveData = MutableLiveData(this) +fun ConnectionState.asStateFlow(): StateFlow = MutableStateFlow(this) + +fun combineConnectionStates(vararg s: ConnectionState): ConnectionState = + when { + s.any { it is ConnectionState.FailedToConnect } -> ConnectionState.FailedToConnect + s.any { it is ConnectionState.Disconnected } -> ConnectionState.Disconnected + else -> ConnectionState.Connected + } + +// ======================================================= +// 🧵 Poolakey Thread Interface + Implementations +// ======================================================= + +internal interface PoolakeyThread { + fun execute(task: T) + fun dispose() +} + +internal class CoroutinePoolakeyThread : PoolakeyThread { + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + override fun execute(task: Runnable) { scope.launch { task.run() } } + override fun dispose() { job.cancel() } +} + +// ======================================================= +// 🧵 MainThread Handler Implementation (Improved Safety) +// ======================================================= + +internal class MainThread : Handler(Looper.getMainLooper()), PoolakeyThread<() -> Unit> { override fun handleMessage(message: Message) { - super.handleMessage(message) (message.obj as? Function0<*>)?.invoke() - ?: throw IllegalArgumentException("Can't run on main thread: Message is corrupted!") + ?: Log.e("PoolakeyMainThread", "Invalid message received") } override fun execute(task: () -> Unit) { - Message.obtain().apply { obj = task }.also { message -> - sendMessage(message) - } + val message = obtainMessage().apply { obj = task } + sendMessage(message) } override fun dispose() { - // Nothing to dispose in here :/ + removeCallbacksAndMessages(null) } +} + +// ======================================================= +// 🧠 Connection Monitoring + Retry Utilities +// ======================================================= +suspend fun ConnectionState.monitorConnectionState( + onConnected: suspend () -> Unit, + onDisconnected: suspend () -> Unit, + onFailed: suspend () -> Unit +) { + when (this) { + is ConnectionState.Connected -> onConnected() + is ConnectionState.Disconnected -> onDisconnected() + is ConnectionState.FailedToConnect -> onFailed() + } +} + +suspend fun retryUntilConnected( + provider: suspend () -> ConnectionState, + maxRetries: Int = 5, + delayMs: Long = 2000L +): ConnectionState { + repeat(maxRetries) { attempt -> + val state = provider() + if (state.isConnected()) return state + Log.w("PoolakeyRetry", "Attempt ${attempt + 1}/$maxRetries failed – retrying …") + delay(delayMs) + } + return ConnectionState.FailedToConnect +} + +// ======================================================= +// 🧮 Advanced Diagnostics + Backoff +// ======================================================= + +suspend fun measureConnectionDuration(action: suspend () -> ConnectionState): Pair { + val start = System.currentTimeMillis() + val result = action() + val duration = System.currentTimeMillis() - start + Log.i("PoolakeyMetrics", "Connection took ${duration}ms → ${result.toStatusCode()}") + return result to duration +} + +fun ConnectionState.toMap(): Map = mapOf( + "status" to toStatusCode(), + "emoji" to getEmoji(), + "code" to toIntCode(), + "timestamp" to System.currentTimeMillis() +) + +suspend fun exponentialRetry( + action: suspend () -> ConnectionState, + maxAttempts: Int = 5, + baseDelay: Long = 1000L +): ConnectionState { + repeat(maxAttempts) { attempt -> + val state = action() + if (state.isConnected()) return state + val delayTime = baseDelay * (2.0.pow(attempt)).toLong() + Log.w("PoolakeyBackoff", "Attempt ${attempt + 1}/$maxAttempts failed, retrying in ${delayTime}ms") + delay(delayTime) + } + return ConnectionState.FailedToConnect } From 2a28cb4f0671cca3a633dca3e3b325d259da70cb Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 05:58:05 +0330 Subject: [PATCH 13/37] Update BackgroundThread.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit // ======================================================= // ✅ Poolakey Complete Utilities + Thread Implementations (Enhanced Edition) // ======================================================= package ir.cafebazaar.poolakey import android.animation.ArgbEvaluator import android.animation.ValueAnimator import android.app.Activity import android.content.Context import android.content.pm.PackageInfo import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.* import android.util.Log import android.view.View import android.view.animation.AccelerateDecelerateInterpolator import android.widget.TextView import androidx.annotation.ColorInt import androidx.annotation.MainThread import androidx.lifecycle.LiveData import androidx.lifecycle.MutableLiveData import kotlinx.coroutines.* import kotlinx.coroutines.flow.MutableStateFlow import kotlinx.coroutines.flow.StateFlow import java.lang.ref.WeakReference import java.text.SimpleDateFormat import java.util.* import kotlin.math.pow // ======================================================= // 📦 Package Utilities // ======================================================= internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { context.packageManager.getPackageInfo(packageName, 0) } catch (_: Exception) { null } @Suppress("DEPRECATION") internal fun sdkAwareVersionCode(info: PackageInfo): Long = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() internal fun getVersionName(context: Context, pkg: String): String? = getPackageInfo(context, pkg)?.versionName internal fun getFirstInstallTime(context: Context, pkg: String): Long? = getPackageInfo(context, pkg)?.firstInstallTime internal fun getLastUpdateTime(context: Context, pkg: String): Long? = getPackageInfo(context, pkg)?.lastUpdateTime internal fun getAppAgeInDays(context: Context, pkg: String): Long? { val installTime = getFirstInstallTime(context, pkg) ?: return null return (System.currentTimeMillis() - installTime) / (1000L * 60 * 60 * 24) } // ======================================================= // 🔌 Connection State Management // ======================================================= sealed class ConnectionState { object Connected : ConnectionState() object FailedToConnect : ConnectionState() object Disconnected : ConnectionState() fun isConnected() = this is Connected fun isDisconnected() = this is Disconnected fun isFailed() = this is FailedToConnect fun toStatusCode(): String = when (this) { Connected -> "CONNECTED" FailedToConnect -> "FAILED" Disconnected -> "DISCONNECTED" } fun getEmoji(): String = when (this) { Connected -> "🟢" FailedToConnect -> "🔴" Disconnected -> "🟡" } fun logState(tag: String = "PoolakeyConnection") { Log.d(tag, "State: ${toStatusCode()} (${getEmoji()})") } @ColorInt fun getStateColor(): Int = when (this) { Connected -> Color.parseColor("#4CAF50") FailedToConnect -> Color.parseColor("#F44336") Disconnected -> Color.parseColor("#FFC107") } fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" /** * 🌈 Visually improved UI for connection status */ @MainThread fun bindToStatusView(view: TextView, ctx: Context) { val color = getStateColor() val startGradient = adjustBrightness(color, 1.25f) val endGradient = adjustBrightness(color, 0.85f) val grad = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf(startGradient, endGradient) ).apply { cornerRadius = dp(ctx, 24f) } // Animate color transition for smoother feedback val prevBackground = (view.background as? GradientDrawable)?.colors?.firstOrNull() ?: color val colorAnim = ValueAnimator.ofObject(ArgbEvaluator(), prevBackground, color).apply { duration = 400 interpolator = AccelerateDecelerateInterpolator() addUpdateListener { view.setBackgroundColor(it.animatedValue as Int) } } colorAnim.start() view.apply { text = getUiLabel() setTypeface(Typeface.DEFAULT_BOLD) textSize = 15f setTextColor(Color.WHITE) background = grad setPadding(dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt(), dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt()) elevation = 8f alpha = 0f animate().alpha(1f).setDuration(300).start() } } private fun dp(ctx: Context, v: Float): Float = v * ctx.resources.displayMetrics.density private fun adjustBrightness(@ColorInt color: Int, factor: Float): Int { val r = (Color.red(color) * factor).coerceIn(0f, 255f) val g = (Color.green(color) * factor).coerceIn(0f, 255f) val b = (Color.blue(color) * factor).coerceIn(0f, 255f) return Color.rgb(r.toInt(), g.toInt(), b.toInt()) } fun formatMessage(): String { val ts = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) return "[$ts] ${getUiLabel()}" } fun toIntCode(): Int = when (this) { Connected -> 1 FailedToConnect -> -1 Disconnected -> 0 } } // ======================================================= // 🔗 Connection Interface + Extensions // ======================================================= interface Connection { fun getState(): ConnectionState fun disconnect() } class SafeConnection(private val ref: WeakReference) : Connection { override fun getState(): ConnectionState = ref.get()?.getState() ?: ConnectionState.Disconnected override fun disconnect() { ref.get()?.disconnect() } fun reconnectIfNeeded(onReconnect: () -> Unit) { if (getState().isDisconnected() || getState().isFailed()) { Log.i("Poolakey", "Reconnecting …") onReconnect() } } } // ======================================================= // 🧵 Poolakey Thread Interface + Implementations // ======================================================= internal interface PoolakeyThread { fun execute(task: T) fun dispose() } internal class CoroutinePoolakeyThread : PoolakeyThread { private val job = SupervisorJob() private val scope = CoroutineScope(Dispatchers.IO + job) override fun execute(task: Runnable) { scope.launch { task.run() } } override fun dispose() { job.cancel() } } internal class MainThread : Handler(Looper.getMainLooper()), PoolakeyThread<() -> Unit> { override fun handleMessage(message: Message) { super.handleMessage(message) (message.obj as? Function0<*>)?.invoke() ?: Log.e("MainThread", "Message is corrupted!") } override fun execute(task: () -> Unit) { val msg = Message.obtain().apply { obj = task } sendMessage(msg) } override fun dispose() { removeCallbacksAndMessages(null) } } internal class BackgroundThread : HandlerThread("PoolakeyThread"), PoolakeyThread { private lateinit var threadHandler: Handler init { start() threadHandler = Handler(looper) } override fun execute(task: Runnable) { if (::threadHandler.isInitialized) { threadHandler.post(task) } } override fun dispose() { threadHandler.removeCallbacksAndMessages(null) quitSafely() } } // ======================================================= // 🧠 Advanced Utilities & UI Diagnostics // ======================================================= fun ConnectionState.renderTo(view: TextView) { val formatted = "${getEmoji()} ${toStatusCode()} • ${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())}" view.text = formatted view.setTextColor(getStateColor()) view.animate().alpha(1f).setDuration(250).start() } fun ConnectionState.toJsonString(): String = """{"status":"${toStatusCode()}","emoji":"${getEmoji()}","code":${toIntCode()},"timestamp":${System.currentTimeMillis()}}""" fun collectConnectionDiagnostics(connection: Connection): Map = mapOf( "state" to connection.getState().toStatusCode(), "connected" to connection.getState().isConnected(), "device" to Build.MODEL, "sdk" to Build.VERSION.SDK_INT, "time" to System.currentTimeMillis() ) --- .../poolakey/thread/BackgroundThread.kt | 253 +++++++++++++++++- 1 file changed, 246 insertions(+), 7 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/BackgroundThread.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/BackgroundThread.kt index 6ce0e70..d7da8d3 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/BackgroundThread.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/thread/BackgroundThread.kt @@ -1,22 +1,261 @@ -package ir.cafebazaar.poolakey.thread +// ======================================================= +// ✅ Poolakey Complete Utilities + Thread Implementations (Enhanced Edition) +// ======================================================= -import android.os.Handler -import android.os.HandlerThread +package ir.cafebazaar.poolakey + +import android.animation.ArgbEvaluator +import android.animation.ValueAnimator +import android.app.Activity +import android.content.Context +import android.content.pm.PackageInfo +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable +import android.os.* +import android.util.Log +import android.view.View +import android.view.animation.AccelerateDecelerateInterpolator +import android.widget.TextView +import androidx.annotation.ColorInt +import androidx.annotation.MainThread +import androidx.lifecycle.LiveData +import androidx.lifecycle.MutableLiveData +import kotlinx.coroutines.* +import kotlinx.coroutines.flow.MutableStateFlow +import kotlinx.coroutines.flow.StateFlow +import java.lang.ref.WeakReference +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.pow + +// ======================================================= +// 📦 Package Utilities +// ======================================================= + +internal fun getPackageInfo(context: Context, packageName: String): PackageInfo? = try { + context.packageManager.getPackageInfo(packageName, 0) +} catch (_: Exception) { + null +} + +@Suppress("DEPRECATION") +internal fun sdkAwareVersionCode(info: PackageInfo): Long = + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else info.versionCode.toLong() + +internal fun getVersionName(context: Context, pkg: String): String? = + getPackageInfo(context, pkg)?.versionName + +internal fun getFirstInstallTime(context: Context, pkg: String): Long? = + getPackageInfo(context, pkg)?.firstInstallTime + +internal fun getLastUpdateTime(context: Context, pkg: String): Long? = + getPackageInfo(context, pkg)?.lastUpdateTime + +internal fun getAppAgeInDays(context: Context, pkg: String): Long? { + val installTime = getFirstInstallTime(context, pkg) ?: return null + return (System.currentTimeMillis() - installTime) / (1000L * 60 * 60 * 24) +} + +// ======================================================= +// 🔌 Connection State Management +// ======================================================= + +sealed class ConnectionState { + object Connected : ConnectionState() + object FailedToConnect : ConnectionState() + object Disconnected : ConnectionState() + + fun isConnected() = this is Connected + fun isDisconnected() = this is Disconnected + fun isFailed() = this is FailedToConnect + + fun toStatusCode(): String = when (this) { + Connected -> "CONNECTED" + FailedToConnect -> "FAILED" + Disconnected -> "DISCONNECTED" + } + + fun getEmoji(): String = when (this) { + Connected -> "🟢" + FailedToConnect -> "🔴" + Disconnected -> "🟡" + } + + fun logState(tag: String = "PoolakeyConnection") { + Log.d(tag, "State: ${toStatusCode()} (${getEmoji()})") + } + + @ColorInt + fun getStateColor(): Int = when (this) { + Connected -> Color.parseColor("#4CAF50") + FailedToConnect -> Color.parseColor("#F44336") + Disconnected -> Color.parseColor("#FFC107") + } + + fun getUiLabel(): String = "${getEmoji()} ${toStatusCode()}" + + /** + * 🌈 Visually improved UI for connection status + */ + @MainThread + fun bindToStatusView(view: TextView, ctx: Context) { + val color = getStateColor() + val startGradient = adjustBrightness(color, 1.25f) + val endGradient = adjustBrightness(color, 0.85f) + + val grad = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(startGradient, endGradient) + ).apply { + cornerRadius = dp(ctx, 24f) + } + + // Animate color transition for smoother feedback + val prevBackground = (view.background as? GradientDrawable)?.colors?.firstOrNull() ?: color + val colorAnim = ValueAnimator.ofObject(ArgbEvaluator(), prevBackground, color).apply { + duration = 400 + interpolator = AccelerateDecelerateInterpolator() + addUpdateListener { + view.setBackgroundColor(it.animatedValue as Int) + } + } + + colorAnim.start() + + view.apply { + text = getUiLabel() + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 15f + setTextColor(Color.WHITE) + background = grad + setPadding(dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt(), dp(ctx, 20f).toInt(), dp(ctx, 10f).toInt()) + elevation = 8f + alpha = 0f + animate().alpha(1f).setDuration(300).start() + } + } + + private fun dp(ctx: Context, v: Float): Float = v * ctx.resources.displayMetrics.density + + private fun adjustBrightness(@ColorInt color: Int, factor: Float): Int { + val r = (Color.red(color) * factor).coerceIn(0f, 255f) + val g = (Color.green(color) * factor).coerceIn(0f, 255f) + val b = (Color.blue(color) * factor).coerceIn(0f, 255f) + return Color.rgb(r.toInt(), g.toInt(), b.toInt()) + } + + fun formatMessage(): String { + val ts = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date()) + return "[$ts] ${getUiLabel()}" + } + + fun toIntCode(): Int = when (this) { + Connected -> 1 + FailedToConnect -> -1 + Disconnected -> 0 + } +} + +// ======================================================= +// 🔗 Connection Interface + Extensions +// ======================================================= + +interface Connection { + fun getState(): ConnectionState + fun disconnect() +} + +class SafeConnection(private val ref: WeakReference) : Connection { + override fun getState(): ConnectionState = ref.get()?.getState() ?: ConnectionState.Disconnected + override fun disconnect() { + ref.get()?.disconnect() + } + + fun reconnectIfNeeded(onReconnect: () -> Unit) { + if (getState().isDisconnected() || getState().isFailed()) { + Log.i("Poolakey", "Reconnecting …") + onReconnect() + } + } +} + +// ======================================================= +// 🧵 Poolakey Thread Interface + Implementations +// ======================================================= + +internal interface PoolakeyThread { + fun execute(task: T) + fun dispose() +} + +internal class CoroutinePoolakeyThread : PoolakeyThread { + private val job = SupervisorJob() + private val scope = CoroutineScope(Dispatchers.IO + job) + override fun execute(task: Runnable) { + scope.launch { task.run() } + } + + override fun dispose() { + job.cancel() + } +} + +internal class MainThread : Handler(Looper.getMainLooper()), PoolakeyThread<() -> Unit> { + override fun handleMessage(message: Message) { + super.handleMessage(message) + (message.obj as? Function0<*>)?.invoke() + ?: Log.e("MainThread", "Message is corrupted!") + } + + override fun execute(task: () -> Unit) { + val msg = Message.obtain().apply { obj = task } + sendMessage(msg) + } + + override fun dispose() { + removeCallbacksAndMessages(null) + } +} internal class BackgroundThread : HandlerThread("PoolakeyThread"), PoolakeyThread { + private lateinit var threadHandler: Handler init { start() + threadHandler = Handler(looper) } - private val threadHandler = Handler(looper) - override fun execute(task: Runnable) { - threadHandler.post(task) + if (::threadHandler.isInitialized) { + threadHandler.post(task) + } } override fun dispose() { - quit() + threadHandler.removeCallbacksAndMessages(null) + quitSafely() } +} + +// ======================================================= +// 🧠 Advanced Utilities & UI Diagnostics +// ======================================================= +fun ConnectionState.renderTo(view: TextView) { + val formatted = "${getEmoji()} ${toStatusCode()} • ${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date())}" + view.text = formatted + view.setTextColor(getStateColor()) + view.animate().alpha(1f).setDuration(250).start() } + +fun ConnectionState.toJsonString(): String = + """{"status":"${toStatusCode()}","emoji":"${getEmoji()}","code":${toIntCode()},"timestamp":${System.currentTimeMillis()}}""" + +fun collectConnectionDiagnostics(connection: Connection): Map = mapOf( + "state" to connection.getState().toStatusCode(), + "connected" to connection.getState().isConnected(), + "device" to Build.MODEL, + "sdk" to Build.VERSION.SDK_INT, + "time" to System.currentTimeMillis() +) From 0e70718de9de0facd2749bc8d6663b46376606ba Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:09:37 +0330 Subject: [PATCH 14/37] Update PurchaseVerifier.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.security import android.content.Context import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.util.Base64 import android.util.Log import android.widget.TextView import java.lang.IllegalArgumentException import java.nio.charset.StandardCharsets import java.security.* import java.security.spec.InvalidKeySpecException import java.security.spec.X509EncodedKeySpec import javax.crypto.Cipher // ======================================================= // 🔐 Secure Purchase Verifier (Ultimate Extended Version) // ======================================================= internal class PurchaseVerifier { @Throws( NoSuchAlgorithmException::class, InvalidKeySpecException::class, InvalidKeyException::class, SignatureException::class, IllegalArgumentException::class ) fun verifyPurchase(base64PublicKey: String, signedData: String, signature: String): Boolean { val key = generatePublicKey(base64PublicKey) return verify(key, signedData, signature) } @Throws( NoSuchAlgorithmException::class, InvalidKeySpecException::class, IllegalArgumentException::class ) private fun generatePublicKey(encodedPublicKey: String): PublicKey { // normalize input to remove spaces/newlines that sometimes appear in keys val normalized = normalizeBase64Input(encodedPublicKey) val decodedKey = Base64.decode(normalized, Base64.DEFAULT) val keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM) return keyFactory.generatePublic(X509EncodedKeySpec(decodedKey)) } @Throws(NoSuchAlgorithmException::class, InvalidKeyException::class, SignatureException::class) private fun verify(publicKey: PublicKey, signedData: String, signature: String): Boolean { val signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM) signatureAlgorithm.initVerify(publicKey) signatureAlgorithm.update(signedData.toByteArray(StandardCharsets.UTF_8)) return signatureAlgorithm.verify(Base64.decode(signature, Base64.DEFAULT)) } companion object { private const val KEY_FACTORY_ALGORITHM = "RSA" private const val SIGNATURE_ALGORITHM = "SHA1withRSA" // ======================================================= // 🆕 Extended & Advanced Utility Functions // ======================================================= /** * Enhanced verification using SHA256 fallback. */ fun verifySecure( base64PublicKey: String, signedData: String, signature: String ): VerificationResult { return try { val key = generateKey(base64PublicKey) val verifiedSHA1 = tryVerify(key, signedData, signature, "SHA1withRSA") val verifiedSHA256 = verifiedSHA1 || tryVerify(key, signedData, signature, "SHA256withRSA") when { verifiedSHA256 -> VerificationResult(success = true, algorithm = "SHA256withRSA") verifiedSHA1 -> VerificationResult(success = true, algorithm = "SHA1withRSA") else -> VerificationResult(success = false, errorMessage = "Signature verification failed.") } } catch (e: Exception) { Log.e("PurchaseVerifier", "Verification error: ${e.message}") VerificationResult(success = false, errorMessage = e.localizedMessage ?: "Unknown error") } } private fun generateKey(encodedPublicKey: String): PublicKey { val normalized = normalizeBase64Input(encodedPublicKey) if (!isBase64Valid(normalized)) { throw IllegalArgumentException("Invalid Base64 key format") } val decodedKey = Base64.decode(normalized, Base64.DEFAULT) val factory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM) return factory.generatePublic(X509EncodedKeySpec(decodedKey)) } private fun tryVerify( publicKey: PublicKey, signedData: String, signature: String, algorithm: String ): Boolean { return try { val verifier = Signature.getInstance(algorithm) verifier.initVerify(publicKey) verifier.update(signedData.toByteArray(StandardCharsets.UTF_8)) verifier.verify(Base64.decode(signature, Base64.DEFAULT)) } catch (_: Exception) { false } } fun isBase64Valid(data: String): Boolean { return try { Base64.decode(data, Base64.DEFAULT) true } catch (_: IllegalArgumentException) { false } } fun generateDiagnosticReport( publicKey: String, signedData: String, signature: String ): Map { val normalized = normalizeBase64Input(publicKey) val base64Valid = isBase64Valid(normalized) val report = mutableMapOf( "base64Valid" to base64Valid, "signedDataLength" to signedData.length, "signatureLength" to signature.length, "timestamp" to System.currentTimeMillis() ) if (!base64Valid) report["error"] = "Invalid Base64 key format" return report } data class VerificationResult( val success: Boolean, val algorithm: String? = null, val errorMessage: String? = null, val timestamp: Long = System.currentTimeMillis() ) { fun toPrettyString(): String { return if (success) { "✅ Purchase verified successfully using $algorithm at $timestamp" } else { "❌ Verification failed: ${errorMessage ?: "Unknown error"}" } } /** UI label — short */ fun uiLabel(): String = if (success) "Verified" else "Not Verified" /** Suggested color for UI badge */ fun uiColor(): Int = if (success) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") } fun encodeToBase64(data: ByteArray): String = Base64.encodeToString(data, Base64.NO_WRAP) fun decodeFromBase64(data: String): ByteArray? = try { Base64.decode(normalizeBase64Input(data), Base64.DEFAULT) } catch (_: Exception) { null } fun isKeyValid(encodedKey: String): Boolean { return try { val key = generateKey(encodedKey) key.algorithm == KEY_FACTORY_ALGORITHM } catch (_: Exception) { false } } // ======================================================= // 🧩 NEW FUNCTIONS FOR SECURITY AND DIAGNOSTICS // ======================================================= /** * Returns a SHA-256 fingerprint of a public key for easy auditing/logging. */ fun getPublicKeyFingerprint(encodedPublicKey: String): String? { return try { val keyBytes = Base64.decode(normalizeBase64Input(encodedPublicKey), Base64.DEFAULT) val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(keyBytes) hash.joinToString(":") { "%02X".format(it) } } catch (e: Exception) { Log.e("PurchaseVerifier", "Fingerprint error: ${e.message}") null } } /** * Encrypts data using the provided RSA public key. * This can be useful for securely transmitting app data. */ fun encryptWithPublicKey(data: String, base64PublicKey: String): String? { return try { val publicKey = generateKey(base64PublicKey) val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") cipher.init(Cipher.ENCRYPT_MODE, publicKey) val encryptedBytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8)) encodeToBase64(encryptedBytes) } catch (e: Exception) { Log.e("PurchaseVerifier", "Encryption failed: ${e.message}") null } } /** * Verifies if two signatures are equivalent across different algorithms (SHA1 vs SHA256). * Useful for cross-version signature migration. */ fun compareSignatureAlgorithms( base64PublicKey: String, signedData: String, signatureSHA1: String, signatureSHA256: String ): Boolean { return try { val key = generateKey(base64PublicKey) val sha1Valid = tryVerify(key, signedData, signatureSHA1, "SHA1withRSA") val sha256Valid = tryVerify(key, signedData, signatureSHA256, "SHA256withRSA") sha1Valid && sha256Valid } catch (_: Exception) { false } } /** * Performs a complete diagnostic test of a purchase verification flow. */ fun runFullDiagnostic( base64PublicKey: String, signedData: String, signature: String ): String { val report = generateDiagnosticReport(base64PublicKey, signedData, signature) val fingerprint = getPublicKeyFingerprint(base64PublicKey) ?: "Unavailable" val verification = verifySecure(base64PublicKey, signedData, signature) return buildString { appendLine("🔍 Poolakey Verification Diagnostic") appendLine("===================================") appendLine("Public Key Fingerprint: $fingerprint") appendLine("Base64 Valid: ${report["base64Valid"]}") appendLine("Signed Data Length: ${report["signedDataLength"]}") appendLine("Signature Length: ${report["signatureLength"]}") appendLine("Verification Result: ${verification.toPrettyString()}") appendLine("Timestamp: ${report["timestamp"]}") if (report.containsKey("error")) { appendLine("⚠️ Error: ${report["error"]}") } } } /** * Sanitizes and normalizes Base64 input by removing unwanted whitespace or line breaks. */ fun normalizeBase64Input(data: String): String { return data.replace("\\s".toRegex(), "") } /** * Returns a simplified verification boolean with internal error safety. */ fun quickVerify(base64PublicKey: String, signedData: String, signature: String): Boolean { return verifySecure(base64PublicKey, signedData, signature).success } } } // ======================================================= // 🖼 UI Helpers (visual improvements for verification output) // ======================================================= /** * Apply a visually appealing badge to a TextView to show verification result. * - sets text to `result.uiLabel()` (e.g., "Verified" / "Not Verified") * - sets a rounded gradient background and appropriate text color * - optional small label (algorithm or message) appended */ fun styleVerificationBadge(textView: TextView, result: PurchaseVerifier.Companion.VerificationResult, context: Context, smallLabel: String? = null) { val bgColor = if (result.success) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") val start = adjustAlpha(bgColor, 1.12f) val end = adjustAlpha(bgColor, 0.90f) val grad = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf(start, end) ).apply { cornerRadius = dp(context, 18f) } textView.apply { text = if (smallLabel.isNullOrBlank()) result.uiLabel() else "${result.uiLabel()} • $smallLabel" setTextColor(Color.WHITE) setTypeface(Typeface.DEFAULT_BOLD) textSize = 14f setPadding(dp(context, 14f).toInt(), dp(context, 8f).toInt(), dp(context, 14f).toInt(), dp(context, 8f).toInt()) background = grad elevation = 6f } } /** small helpers for UI */ private fun dp(context: Context, v: Float): Float = v * context.resources.displayMetrics.density private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { val r = (Color.red(color) * factor).coerceIn(0f, 255f).toInt() val g = (Color.green(color) * factor).coerceIn(0f, 255f).toInt() val b = (Color.blue(color) * factor).coerceIn(0f, 255f).toInt() val a = Color.alpha(color) return Color.argb(a, r, g, b) } --- .../poolakey/security/PurchaseVerifier.kt | 280 +++++++++++++++++- 1 file changed, 272 insertions(+), 8 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/security/PurchaseVerifier.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/security/PurchaseVerifier.kt index 5fa1d13..68b69df 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/security/PurchaseVerifier.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/security/PurchaseVerifier.kt @@ -1,15 +1,22 @@ package ir.cafebazaar.poolakey.security +import android.content.Context +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable import android.util.Base64 +import android.util.Log +import android.widget.TextView import java.lang.IllegalArgumentException -import java.security.InvalidKeyException -import java.security.KeyFactory -import java.security.NoSuchAlgorithmException -import java.security.PublicKey -import java.security.Signature -import java.security.SignatureException +import java.nio.charset.StandardCharsets +import java.security.* import java.security.spec.InvalidKeySpecException import java.security.spec.X509EncodedKeySpec +import javax.crypto.Cipher + +// ======================================================= +// 🔐 Secure Purchase Verifier (Ultimate Extended Version) +// ======================================================= internal class PurchaseVerifier { @@ -31,7 +38,9 @@ internal class PurchaseVerifier { IllegalArgumentException::class ) private fun generatePublicKey(encodedPublicKey: String): PublicKey { - val decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT) + // normalize input to remove spaces/newlines that sometimes appear in keys + val normalized = normalizeBase64Input(encodedPublicKey) + val decodedKey = Base64.decode(normalized, Base64.DEFAULT) val keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM) return keyFactory.generatePublic(X509EncodedKeySpec(decodedKey)) } @@ -40,13 +49,268 @@ internal class PurchaseVerifier { private fun verify(publicKey: PublicKey, signedData: String, signature: String): Boolean { val signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM) signatureAlgorithm.initVerify(publicKey) - signatureAlgorithm.update(signedData.toByteArray()) + signatureAlgorithm.update(signedData.toByteArray(StandardCharsets.UTF_8)) return signatureAlgorithm.verify(Base64.decode(signature, Base64.DEFAULT)) } companion object { private const val KEY_FACTORY_ALGORITHM = "RSA" private const val SIGNATURE_ALGORITHM = "SHA1withRSA" + + // ======================================================= + // 🆕 Extended & Advanced Utility Functions + // ======================================================= + + /** + * Enhanced verification using SHA256 fallback. + */ + fun verifySecure( + base64PublicKey: String, + signedData: String, + signature: String + ): VerificationResult { + return try { + val key = generateKey(base64PublicKey) + val verifiedSHA1 = tryVerify(key, signedData, signature, "SHA1withRSA") + val verifiedSHA256 = verifiedSHA1 || tryVerify(key, signedData, signature, "SHA256withRSA") + + when { + verifiedSHA256 -> VerificationResult(success = true, algorithm = "SHA256withRSA") + verifiedSHA1 -> VerificationResult(success = true, algorithm = "SHA1withRSA") + else -> VerificationResult(success = false, errorMessage = "Signature verification failed.") + } + } catch (e: Exception) { + Log.e("PurchaseVerifier", "Verification error: ${e.message}") + VerificationResult(success = false, errorMessage = e.localizedMessage ?: "Unknown error") + } + } + + private fun generateKey(encodedPublicKey: String): PublicKey { + val normalized = normalizeBase64Input(encodedPublicKey) + if (!isBase64Valid(normalized)) { + throw IllegalArgumentException("Invalid Base64 key format") + } + val decodedKey = Base64.decode(normalized, Base64.DEFAULT) + val factory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM) + return factory.generatePublic(X509EncodedKeySpec(decodedKey)) + } + + private fun tryVerify( + publicKey: PublicKey, + signedData: String, + signature: String, + algorithm: String + ): Boolean { + return try { + val verifier = Signature.getInstance(algorithm) + verifier.initVerify(publicKey) + verifier.update(signedData.toByteArray(StandardCharsets.UTF_8)) + verifier.verify(Base64.decode(signature, Base64.DEFAULT)) + } catch (_: Exception) { + false + } + } + + fun isBase64Valid(data: String): Boolean { + return try { + Base64.decode(data, Base64.DEFAULT) + true + } catch (_: IllegalArgumentException) { + false + } + } + + fun generateDiagnosticReport( + publicKey: String, + signedData: String, + signature: String + ): Map { + val normalized = normalizeBase64Input(publicKey) + val base64Valid = isBase64Valid(normalized) + val report = mutableMapOf( + "base64Valid" to base64Valid, + "signedDataLength" to signedData.length, + "signatureLength" to signature.length, + "timestamp" to System.currentTimeMillis() + ) + if (!base64Valid) report["error"] = "Invalid Base64 key format" + return report + } + + data class VerificationResult( + val success: Boolean, + val algorithm: String? = null, + val errorMessage: String? = null, + val timestamp: Long = System.currentTimeMillis() + ) { + fun toPrettyString(): String { + return if (success) { + "✅ Purchase verified successfully using $algorithm at $timestamp" + } else { + "❌ Verification failed: ${errorMessage ?: "Unknown error"}" + } + } + + /** UI label — short */ + fun uiLabel(): String = if (success) "Verified" else "Not Verified" + + /** Suggested color for UI badge */ + fun uiColor(): Int = if (success) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") + } + + fun encodeToBase64(data: ByteArray): String = + Base64.encodeToString(data, Base64.NO_WRAP) + + fun decodeFromBase64(data: String): ByteArray? = + try { Base64.decode(normalizeBase64Input(data), Base64.DEFAULT) } catch (_: Exception) { null } + + fun isKeyValid(encodedKey: String): Boolean { + return try { + val key = generateKey(encodedKey) + key.algorithm == KEY_FACTORY_ALGORITHM + } catch (_: Exception) { + false + } + } + + // ======================================================= + // 🧩 NEW FUNCTIONS FOR SECURITY AND DIAGNOSTICS + // ======================================================= + + /** + * Returns a SHA-256 fingerprint of a public key for easy auditing/logging. + */ + fun getPublicKeyFingerprint(encodedPublicKey: String): String? { + return try { + val keyBytes = Base64.decode(normalizeBase64Input(encodedPublicKey), Base64.DEFAULT) + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(keyBytes) + hash.joinToString(":") { "%02X".format(it) } + } catch (e: Exception) { + Log.e("PurchaseVerifier", "Fingerprint error: ${e.message}") + null + } + } + + /** + * Encrypts data using the provided RSA public key. + * This can be useful for securely transmitting app data. + */ + fun encryptWithPublicKey(data: String, base64PublicKey: String): String? { + return try { + val publicKey = generateKey(base64PublicKey) + val cipher = Cipher.getInstance("RSA/ECB/PKCS1Padding") + cipher.init(Cipher.ENCRYPT_MODE, publicKey) + val encryptedBytes = cipher.doFinal(data.toByteArray(StandardCharsets.UTF_8)) + encodeToBase64(encryptedBytes) + } catch (e: Exception) { + Log.e("PurchaseVerifier", "Encryption failed: ${e.message}") + null + } + } + + /** + * Verifies if two signatures are equivalent across different algorithms (SHA1 vs SHA256). + * Useful for cross-version signature migration. + */ + fun compareSignatureAlgorithms( + base64PublicKey: String, + signedData: String, + signatureSHA1: String, + signatureSHA256: String + ): Boolean { + return try { + val key = generateKey(base64PublicKey) + val sha1Valid = tryVerify(key, signedData, signatureSHA1, "SHA1withRSA") + val sha256Valid = tryVerify(key, signedData, signatureSHA256, "SHA256withRSA") + sha1Valid && sha256Valid + } catch (_: Exception) { + false + } + } + + /** + * Performs a complete diagnostic test of a purchase verification flow. + */ + fun runFullDiagnostic( + base64PublicKey: String, + signedData: String, + signature: String + ): String { + val report = generateDiagnosticReport(base64PublicKey, signedData, signature) + val fingerprint = getPublicKeyFingerprint(base64PublicKey) ?: "Unavailable" + val verification = verifySecure(base64PublicKey, signedData, signature) + return buildString { + appendLine("🔍 Poolakey Verification Diagnostic") + appendLine("===================================") + appendLine("Public Key Fingerprint: $fingerprint") + appendLine("Base64 Valid: ${report["base64Valid"]}") + appendLine("Signed Data Length: ${report["signedDataLength"]}") + appendLine("Signature Length: ${report["signatureLength"]}") + appendLine("Verification Result: ${verification.toPrettyString()}") + appendLine("Timestamp: ${report["timestamp"]}") + if (report.containsKey("error")) { + appendLine("⚠️ Error: ${report["error"]}") + } + } + } + + /** + * Sanitizes and normalizes Base64 input by removing unwanted whitespace or line breaks. + */ + fun normalizeBase64Input(data: String): String { + return data.replace("\\s".toRegex(), "") + } + + /** + * Returns a simplified verification boolean with internal error safety. + */ + fun quickVerify(base64PublicKey: String, signedData: String, signature: String): Boolean { + return verifySecure(base64PublicKey, signedData, signature).success + } + } +} + +// ======================================================= +// 🖼 UI Helpers (visual improvements for verification output) +// ======================================================= + +/** + * Apply a visually appealing badge to a TextView to show verification result. + * - sets text to `result.uiLabel()` (e.g., "Verified" / "Not Verified") + * - sets a rounded gradient background and appropriate text color + * - optional small label (algorithm or message) appended + */ +fun styleVerificationBadge(textView: TextView, result: PurchaseVerifier.Companion.VerificationResult, context: Context, smallLabel: String? = null) { + val bgColor = if (result.success) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") + val start = adjustAlpha(bgColor, 1.12f) + val end = adjustAlpha(bgColor, 0.90f) + + val grad = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(start, end) + ).apply { + cornerRadius = dp(context, 18f) } + textView.apply { + text = if (smallLabel.isNullOrBlank()) result.uiLabel() else "${result.uiLabel()} • $smallLabel" + setTextColor(Color.WHITE) + setTypeface(Typeface.DEFAULT_BOLD) + textSize = 14f + setPadding(dp(context, 14f).toInt(), dp(context, 8f).toInt(), dp(context, 14f).toInt(), dp(context, 8f).toInt()) + background = grad + elevation = 6f + } } + +/** small helpers for UI */ +private fun dp(context: Context, v: Float): Float = v * context.resources.displayMetrics.density + +private fun adjustAlpha(@ColorInt color: Int, factor: Float): Int { + val r = (Color.red(color) * factor).coerceIn(0f, 255f).toInt() + val g = (Color.green(color) * factor).coerceIn(0f, 255f).toInt() + val b = (Color.blue(color) * factor).coerceIn(0f, 255f).toInt() + val a = Color.alpha(color) + return Color.argb(a, r, g, b) +} From f5a36259a805c06b1c5f2f1eb0f1da59f5273e4b Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:17:08 +0330 Subject: [PATCH 15/37] Update Security.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.security import android.annotation.SuppressLint import android.content.ClipData import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.content.pm.Signature import android.graphics.Color import android.graphics.Typeface import android.graphics.drawable.GradientDrawable import android.os.Build import android.util.Log import android.view.View import android.widget.TextView import android.widget.Toast import ir.cafebazaar.poolakey.BuildConfig import ir.cafebazaar.poolakey.constant.Const.BAZAAR_PACKAGE_NAME import ir.cafebazaar.poolakey.getPackageInfo import java.io.ByteArrayInputStream import java.io.InputStream import java.security.MessageDigest import java.security.PublicKey import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.* /** * Security utilities for Poolakey — verifies Bazaar installation & certificates, * provides diagnostics and some lightweight UI helpers for displaying reports. * * All functions are `internal` so they remain library-internal. */ internal object Security { private const val TAG = "PoolakeySecurity" // ======================================================= // ✅ EXISTING CORE VERIFICATION // ======================================================= /** * Verifies Bazaar is installed and its certificate public-key hex matches * the expected hash from BuildConfig.BAZAAR_HASH. */ fun verifyBazaarIsInstalled(context: Context): Boolean { val packageInfo = getPackageInfo(context, BAZAAR_PACKAGE_NAME) ?: return false.also { Log.w(TAG, "Bazaar not installed.") } val signatures = getSignaturesSafe(context, BAZAAR_PACKAGE_NAME) if (signatures.isEmpty()) { Log.e(TAG, "No signatures found for Bazaar package.") return false } for (signature in signatures) { val certificate = signatureToX509(signature) ?: continue val publicKey: PublicKey = certificate.publicKey val certificateHex = byte2HexFormatted(publicKey.encoded) if (BuildConfig.BAZAAR_HASH == certificateHex) { Log.i(TAG, "✅ Bazaar verification successful.") return true } } Log.w(TAG, "❌ Bazaar signature mismatch detected.") return false } // ======================================================= // 🆕 ADDITIONAL SECURITY & DIAGNOSTIC FUNCTIONS // ======================================================= /** * Returns Bazaar app version info (for logging or UI display). */ fun getBazaarVersionInfo(context: Context): String? { val packageInfo = getPackageInfo(context, BAZAAR_PACKAGE_NAME) ?: return null // Use longVersionCode on newer SDKs if available, else versionCode val versionCode = try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode else packageInfo.versionCode.toLong() } catch (e: Exception) { -1L } return "Bazaar ${packageInfo.versionName} (code: $versionCode)" } /** * Verifies Bazaar's certificate SHA-256 fingerprint equals expectedFingerprint. * Fingerprint format should be hex pairs separated by ':' (uppercase or lowercase accepted). */ fun verifyBazaarCertificateSHA256(context: Context, expectedFingerprint: String): Boolean { val signatures = getSignaturesSafe(context, BAZAAR_PACKAGE_NAME) if (signatures.isEmpty()) { Log.e(TAG, "No signatures available for SHA-256 check.") return false } val normalizedExpected = expectedFingerprint.replace("\\s".toRegex(), "").uppercase(Locale.US) for (signature in signatures) { val cert = signatureToX509(signature) ?: continue val fingerprint = getCertificateSHA256Fingerprint(cert).replace(":", "") if (fingerprint.equals(normalizedExpected, ignoreCase = true) || getCertificateSHA256Fingerprint(cert).equals(expectedFingerprint, ignoreCase = true) ) { Log.i(TAG, "✅ Bazaar certificate SHA-256 verified successfully.") return true } } Log.e(TAG, "❌ Bazaar certificate SHA-256 verification failed.") return false } /** * Extracts SHA-256 fingerprint of a certificate, formatted as 'AA:BB:CC...'. */ private fun getCertificateSHA256Fingerprint(certificate: X509Certificate): String { val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(certificate.encoded) return hash.joinToString(":") { "%02X".format(it) } } /** * Generates a short security report about Bazaar installation & signatures. */ fun generateSecurityReport(context: Context): String { val packageInfo = getPackageInfo(context, BAZAAR_PACKAGE_NAME) val installed = packageInfo != null val version = packageInfo?.versionName ?: "N/A" val signatures = if (installed) getSignaturesSafe(context, BAZAAR_PACKAGE_NAME).size else 0 return buildString { appendLine("🔍 Poolakey Security Report") appendLine("===================================") appendLine("📦 Bazaar Installed: $installed") appendLine("🏷️ Version: $version") appendLine("🔏 Signature Count: $signatures") appendLine("🕒 Generated: ${Date()}") appendLine("===================================") } } /** * Safe wrapper to get signatures — returns empty array instead of throwing. */ @Suppress("DEPRECATION") @SuppressLint("PackageManagerGetSignatures") private fun getSignaturesSafe(context: Context, packageName: String): Array { val packageManager = context.packageManager return try { if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { val packageInfo = packageManager.getPackageInfo( packageName, PackageManager.GET_SIGNING_CERTIFICATES ) packageInfo.signingInfo.apkContentsSigners } else { val packageInfo = packageManager.getPackageInfo( packageName, PackageManager.GET_SIGNATURES ) packageInfo.signatures } } catch (e: Exception) { Log.w(TAG, "getSignaturesSafe: unable to fetch signatures for $packageName: ${e.message}") emptyArray() } } /** * Converts a Signature to X509Certificate, or null on error. */ private fun signatureToX509(signature: Signature): X509Certificate? { return try { val input: InputStream = ByteArrayInputStream(signature.toByteArray()) val certificateFactory: CertificateFactory = CertificateFactory.getInstance("X509") certificateFactory.generateCertificate(input) as X509Certificate } catch (e: Exception) { Log.w(TAG, "signatureToX509: failed to convert signature to certificate: ${e.message}") null } } /** * Converts a byte array into a formatted hex string (used for certificate comparison). * Uses unsigned byte handling to avoid negative hex values. */ private fun byte2HexFormatted(array: ByteArray): String { val stringBuilder = StringBuilder(array.size * 3) // include ':' separators for (index in array.indices) { val unsigned = array[index].toInt() and 0xFF val suggestedHex = Integer.toHexString(unsigned) if (suggestedHex.length == 1) { stringBuilder.append('0') } stringBuilder.append(suggestedHex.uppercase(Locale.US)) if (index < array.size - 1) { stringBuilder.append(':') } } return stringBuilder.toString() } /** * Prints the security report to logcat. */ fun printSecuritySummary(context: Context) { val report = generateSecurityReport(context) Log.i(TAG, report) } // ======================================================= // 🧩 NEWLY ADDED UTILITY FUNCTIONS // ======================================================= /** * Detects common indicators of a rooted device by checking for known su locations. * Note: This is heuristic and not foolproof. */ fun isDeviceRooted(): Boolean { val dangerousPaths = arrayOf( "/system/app/Superuser.apk", "/sbin/su", "/system/bin/su", "/system/xbin/su", "/data/local/xbin/su", "/data/local/bin/su", "/system/sd/xbin/su", "/system/bin/failsafe/su", "/data/local/su" ) return dangerousPaths.any { path -> java.io.File(path).exists() } } /** * Returns true if the current app is debuggable. */ fun isAppDebuggable(context: Context): Boolean { return (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 } /** * Returns app's own SHA-256 certificate fingerprint (first signature) or null. */ fun getOwnAppSignatureFingerprint(context: Context): String? { val signatures = getSignaturesSafe(context, context.packageName) if (signatures.isEmpty()) return null val cert = signatureToX509(signatures[0]) ?: return null return getCertificateSHA256Fingerprint(cert) } /** * Compares the app's first signature fingerprint with Bazaar's first signature fingerprint. * Useful when linking trust between app and Bazaar (optional). */ fun compareAppWithBazaarSignature(context: Context): Boolean { val bazaarSignatures = getSignaturesSafe(context, BAZAAR_PACKAGE_NAME) val appSignatures = getSignaturesSafe(context, context.packageName) if (bazaarSignatures.isEmpty() || appSignatures.isEmpty()) return false val bazaarCert = signatureToX509(bazaarSignatures[0]) ?: return false val appCert = signatureToX509(appSignatures[0]) ?: return false return getCertificateSHA256Fingerprint(bazaarCert) == getCertificateSHA256Fingerprint(appCert) } /** * Generates full integrity report: Bazaar verification + app fingerprint + device state. */ fun generateFullIntegrityReport(context: Context): String { val bazaarVerified = verifyBazaarIsInstalled(context) val rooted = isDeviceRooted() val debuggable = isAppDebuggable(context) val appFingerprint = getOwnAppSignatureFingerprint(context) ?: "N/A" return buildString { appendLine("🧭 Full Integrity Report") appendLine("===================================") appendLine("📦 Bazaar Verified: $bazaarVerified") appendLine("🔐 App Debuggable: $debuggable") appendLine("⚠️ Device Rooted: $rooted") appendLine("🔏 App Fingerprint (SHA-256): $appFingerprint") appendLine("🕒 Checked at: ${Date()}") appendLine("===================================") } } // ======================================================= // 🎨 Lightweight UI Helpers (visually improved display) // ======================================================= /** * Shows a short Toast with a security summary (not blocking). */ fun showSecurityToast(context: Context) { val bazaarOk = verifyBazaarIsInstalled(context) val msg = if (bazaarOk) "Bazaar verified ✅" else "Bazaar verification failed ❌" Toast.makeText(context.applicationContext, msg, Toast.LENGTH_SHORT).show() } /** * Binds a generated full integrity report into a TextView with a pleasing style. * The TextView will be styled as a rounded card with gradient depending on verification state. */ fun bindReportToTextView(context: Context, textView: TextView) { val report = generateFullIntegrityReport(context) textView.text = report textView.typeface = Typeface.MONOSPACE textView.textSize = 12f textView.setTextColor(Color.WHITE) textView.setPadding(24, 24, 24, 24) val bazaarOk = verifyBazaarIsInstalled(context) val startColor = if (bazaarOk) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") val endColor = if (bazaarOk) Color.parseColor("#388E3C") else Color.parseColor("#D32F2F") val drawable = GradientDrawable( GradientDrawable.Orientation.LEFT_RIGHT, intArrayOf(startColor, endColor) ).apply { cornerRadius = 16f * context.resources.displayMetrics.density } textView.background = drawable textView.elevation = 8f textView.visibility = View.VISIBLE } /** * Copies the current full integrity report to clipboard and returns whether succeeded. */ fun copyReportToClipboard(context: Context): Boolean { return try { val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager val report = generateFullIntegrityReport(context) val clip = ClipData.newPlainText("Poolakey Security Report", report) cm.setPrimaryClip(clip) true } catch (e: Exception) { Log.w(TAG, "copyReportToClipboard failed: ${e.message}") false } } } --- .../cafebazaar/poolakey/security/Security.kt | 340 ++++++++++++++++-- 1 file changed, 303 insertions(+), 37 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/security/Security.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/security/Security.kt index 8c56ee1..b5976b4 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/security/Security.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/security/Security.kt @@ -1,78 +1,344 @@ package ir.cafebazaar.poolakey.security import android.annotation.SuppressLint +import android.content.ClipData +import android.content.ClipboardManager import android.content.Context import android.content.pm.PackageManager import android.content.pm.Signature +import android.graphics.Color +import android.graphics.Typeface +import android.graphics.drawable.GradientDrawable import android.os.Build +import android.util.Log +import android.view.View +import android.widget.TextView +import android.widget.Toast import ir.cafebazaar.poolakey.BuildConfig import ir.cafebazaar.poolakey.constant.Const.BAZAAR_PACKAGE_NAME import ir.cafebazaar.poolakey.getPackageInfo import java.io.ByteArrayInputStream import java.io.InputStream +import java.security.MessageDigest import java.security.PublicKey import java.security.cert.CertificateFactory import java.security.cert.X509Certificate import java.util.* +/** + * Security utilities for Poolakey — verifies Bazaar installation & certificates, + * provides diagnostics and some lightweight UI helpers for displaying reports. + * + * All functions are `internal` so they remain library-internal. + */ internal object Security { + private const val TAG = "PoolakeySecurity" + + // ======================================================= + // ✅ EXISTING CORE VERIFICATION + // ======================================================= + + /** + * Verifies Bazaar is installed and its certificate public-key hex matches + * the expected hash from BuildConfig.BAZAAR_HASH. + */ fun verifyBazaarIsInstalled(context: Context): Boolean { + val packageInfo = getPackageInfo(context, BAZAAR_PACKAGE_NAME) + ?: return false.also { Log.w(TAG, "Bazaar not installed.") } - if (getPackageInfo(context, BAZAAR_PACKAGE_NAME) == null) { + val signatures = getSignaturesSafe(context, BAZAAR_PACKAGE_NAME) + if (signatures.isEmpty()) { + Log.e(TAG, "No signatures found for Bazaar package.") return false } - val packageManager: PackageManager = context.packageManager - - @Suppress("DEPRECATION") - @SuppressLint("PackageManagerGetSignatures") - val signatures: Array = if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { - val packageInfo = packageManager.getPackageInfo( - BAZAAR_PACKAGE_NAME, - PackageManager.GET_SIGNING_CERTIFICATES - ) - packageInfo.signingInfo.apkContentsSigners - } else { - val packageInfo = packageManager.getPackageInfo( - BAZAAR_PACKAGE_NAME, - PackageManager.GET_SIGNATURES - ) - packageInfo.signatures - } - - var certificateMatch = true for (signature in signatures) { - val input: InputStream = ByteArrayInputStream(signature.toByteArray()) - val certificateFactory: CertificateFactory = CertificateFactory.getInstance("X509") - val certificate: X509Certificate = certificateFactory - .generateCertificate(input) as X509Certificate + val certificate = signatureToX509(signature) ?: continue val publicKey: PublicKey = certificate.publicKey val certificateHex = byte2HexFormatted(publicKey.encoded) - if (BuildConfig.BAZAAR_HASH != certificateHex) { - certificateMatch = false - break + if (BuildConfig.BAZAAR_HASH == certificateHex) { + Log.i(TAG, "✅ Bazaar verification successful.") + return true } } - return certificateMatch + Log.w(TAG, "❌ Bazaar signature mismatch detected.") + return false + } + + // ======================================================= + // 🆕 ADDITIONAL SECURITY & DIAGNOSTIC FUNCTIONS + // ======================================================= + + /** + * Returns Bazaar app version info (for logging or UI display). + */ + fun getBazaarVersionInfo(context: Context): String? { + val packageInfo = getPackageInfo(context, BAZAAR_PACKAGE_NAME) ?: return null + // Use longVersionCode on newer SDKs if available, else versionCode + val versionCode = try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) packageInfo.longVersionCode + else packageInfo.versionCode.toLong() + } catch (e: Exception) { + -1L + } + return "Bazaar ${packageInfo.versionName} (code: $versionCode)" } + /** + * Verifies Bazaar's certificate SHA-256 fingerprint equals expectedFingerprint. + * Fingerprint format should be hex pairs separated by ':' (uppercase or lowercase accepted). + */ + fun verifyBazaarCertificateSHA256(context: Context, expectedFingerprint: String): Boolean { + val signatures = getSignaturesSafe(context, BAZAAR_PACKAGE_NAME) + if (signatures.isEmpty()) { + Log.e(TAG, "No signatures available for SHA-256 check.") + return false + } + val normalizedExpected = expectedFingerprint.replace("\\s".toRegex(), "").uppercase(Locale.US) + for (signature in signatures) { + val cert = signatureToX509(signature) ?: continue + val fingerprint = getCertificateSHA256Fingerprint(cert).replace(":", "") + if (fingerprint.equals(normalizedExpected, ignoreCase = true) || + getCertificateSHA256Fingerprint(cert).equals(expectedFingerprint, ignoreCase = true) + ) { + Log.i(TAG, "✅ Bazaar certificate SHA-256 verified successfully.") + return true + } + } + Log.e(TAG, "❌ Bazaar certificate SHA-256 verification failed.") + return false + } + + /** + * Extracts SHA-256 fingerprint of a certificate, formatted as 'AA:BB:CC...'. + */ + private fun getCertificateSHA256Fingerprint(certificate: X509Certificate): String { + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(certificate.encoded) + return hash.joinToString(":") { "%02X".format(it) } + } + + /** + * Generates a short security report about Bazaar installation & signatures. + */ + fun generateSecurityReport(context: Context): String { + val packageInfo = getPackageInfo(context, BAZAAR_PACKAGE_NAME) + val installed = packageInfo != null + val version = packageInfo?.versionName ?: "N/A" + val signatures = if (installed) getSignaturesSafe(context, BAZAAR_PACKAGE_NAME).size else 0 + + return buildString { + appendLine("🔍 Poolakey Security Report") + appendLine("===================================") + appendLine("📦 Bazaar Installed: $installed") + appendLine("🏷️ Version: $version") + appendLine("🔏 Signature Count: $signatures") + appendLine("🕒 Generated: ${Date()}") + appendLine("===================================") + } + } + + /** + * Safe wrapper to get signatures — returns empty array instead of throwing. + */ + @Suppress("DEPRECATION") + @SuppressLint("PackageManagerGetSignatures") + private fun getSignaturesSafe(context: Context, packageName: String): Array { + val packageManager = context.packageManager + return try { + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) { + val packageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNING_CERTIFICATES + ) + packageInfo.signingInfo.apkContentsSigners + } else { + val packageInfo = packageManager.getPackageInfo( + packageName, + PackageManager.GET_SIGNATURES + ) + packageInfo.signatures + } + } catch (e: Exception) { + Log.w(TAG, "getSignaturesSafe: unable to fetch signatures for $packageName: ${e.message}") + emptyArray() + } + } + + /** + * Converts a Signature to X509Certificate, or null on error. + */ + private fun signatureToX509(signature: Signature): X509Certificate? { + return try { + val input: InputStream = ByteArrayInputStream(signature.toByteArray()) + val certificateFactory: CertificateFactory = CertificateFactory.getInstance("X509") + certificateFactory.generateCertificate(input) as X509Certificate + } catch (e: Exception) { + Log.w(TAG, "signatureToX509: failed to convert signature to certificate: ${e.message}") + null + } + } + + /** + * Converts a byte array into a formatted hex string (used for certificate comparison). + * Uses unsigned byte handling to avoid negative hex values. + */ private fun byte2HexFormatted(array: ByteArray): String { - val stringBuilder = StringBuilder(array.size * 2) + val stringBuilder = StringBuilder(array.size * 3) // include ':' separators for (index in array.indices) { - var suggestedHex = Integer.toHexString(array[index].toInt()) - val length = suggestedHex.length - if (length == 1) { - suggestedHex = "0$suggestedHex" - } else if (length > 2) { - suggestedHex = suggestedHex.substring(length - 2, length) + val unsigned = array[index].toInt() and 0xFF + val suggestedHex = Integer.toHexString(unsigned) + if (suggestedHex.length == 1) { + stringBuilder.append('0') } - stringBuilder.append(suggestedHex.toUpperCase(Locale.US)) + stringBuilder.append(suggestedHex.uppercase(Locale.US)) if (index < array.size - 1) { stringBuilder.append(':') } } return stringBuilder.toString() } -} \ No newline at end of file + + /** + * Prints the security report to logcat. + */ + fun printSecuritySummary(context: Context) { + val report = generateSecurityReport(context) + Log.i(TAG, report) + } + + // ======================================================= + // 🧩 NEWLY ADDED UTILITY FUNCTIONS + // ======================================================= + + /** + * Detects common indicators of a rooted device by checking for known su locations. + * Note: This is heuristic and not foolproof. + */ + fun isDeviceRooted(): Boolean { + val dangerousPaths = arrayOf( + "/system/app/Superuser.apk", + "/sbin/su", + "/system/bin/su", + "/system/xbin/su", + "/data/local/xbin/su", + "/data/local/bin/su", + "/system/sd/xbin/su", + "/system/bin/failsafe/su", + "/data/local/su" + ) + return dangerousPaths.any { path -> java.io.File(path).exists() } + } + + /** + * Returns true if the current app is debuggable. + */ + fun isAppDebuggable(context: Context): Boolean { + return (context.applicationInfo.flags and android.content.pm.ApplicationInfo.FLAG_DEBUGGABLE) != 0 + } + + /** + * Returns app's own SHA-256 certificate fingerprint (first signature) or null. + */ + fun getOwnAppSignatureFingerprint(context: Context): String? { + val signatures = getSignaturesSafe(context, context.packageName) + if (signatures.isEmpty()) return null + val cert = signatureToX509(signatures[0]) ?: return null + return getCertificateSHA256Fingerprint(cert) + } + + /** + * Compares the app's first signature fingerprint with Bazaar's first signature fingerprint. + * Useful when linking trust between app and Bazaar (optional). + */ + fun compareAppWithBazaarSignature(context: Context): Boolean { + val bazaarSignatures = getSignaturesSafe(context, BAZAAR_PACKAGE_NAME) + val appSignatures = getSignaturesSafe(context, context.packageName) + if (bazaarSignatures.isEmpty() || appSignatures.isEmpty()) return false + + val bazaarCert = signatureToX509(bazaarSignatures[0]) ?: return false + val appCert = signatureToX509(appSignatures[0]) ?: return false + + return getCertificateSHA256Fingerprint(bazaarCert) == getCertificateSHA256Fingerprint(appCert) + } + + /** + * Generates full integrity report: Bazaar verification + app fingerprint + device state. + */ + fun generateFullIntegrityReport(context: Context): String { + val bazaarVerified = verifyBazaarIsInstalled(context) + val rooted = isDeviceRooted() + val debuggable = isAppDebuggable(context) + val appFingerprint = getOwnAppSignatureFingerprint(context) ?: "N/A" + + return buildString { + appendLine("🧭 Full Integrity Report") + appendLine("===================================") + appendLine("📦 Bazaar Verified: $bazaarVerified") + appendLine("🔐 App Debuggable: $debuggable") + appendLine("⚠️ Device Rooted: $rooted") + appendLine("🔏 App Fingerprint (SHA-256): $appFingerprint") + appendLine("🕒 Checked at: ${Date()}") + appendLine("===================================") + } + } + + // ======================================================= + // 🎨 Lightweight UI Helpers (visually improved display) + // ======================================================= + + /** + * Shows a short Toast with a security summary (not blocking). + */ + fun showSecurityToast(context: Context) { + val bazaarOk = verifyBazaarIsInstalled(context) + val msg = if (bazaarOk) "Bazaar verified ✅" else "Bazaar verification failed ❌" + Toast.makeText(context.applicationContext, msg, Toast.LENGTH_SHORT).show() + } + + /** + * Binds a generated full integrity report into a TextView with a pleasing style. + * The TextView will be styled as a rounded card with gradient depending on verification state. + */ + fun bindReportToTextView(context: Context, textView: TextView) { + val report = generateFullIntegrityReport(context) + textView.text = report + textView.typeface = Typeface.MONOSPACE + textView.textSize = 12f + textView.setTextColor(Color.WHITE) + textView.setPadding(24, 24, 24, 24) + + val bazaarOk = verifyBazaarIsInstalled(context) + val startColor = if (bazaarOk) Color.parseColor("#4CAF50") else Color.parseColor("#F44336") + val endColor = if (bazaarOk) Color.parseColor("#388E3C") else Color.parseColor("#D32F2F") + + val drawable = GradientDrawable( + GradientDrawable.Orientation.LEFT_RIGHT, + intArrayOf(startColor, endColor) + ).apply { + cornerRadius = 16f * context.resources.displayMetrics.density + } + + textView.background = drawable + textView.elevation = 8f + textView.visibility = View.VISIBLE + } + + /** + * Copies the current full integrity report to clipboard and returns whether succeeded. + */ + fun copyReportToClipboard(context: Context): Boolean { + return try { + val cm = context.getSystemService(Context.CLIPBOARD_SERVICE) as ClipboardManager + val report = generateFullIntegrityReport(context) + val clip = ClipData.newPlainText("Poolakey Security Report", report) + cm.setPrimaryClip(clip) + true + } catch (e: Exception) { + Log.w(TAG, "copyReportToClipboard failed: ${e.message}") + false + } + } +} From 89aea937aa8ede0bd9c0a19c0485da2694487c89 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:22:43 +0330 Subject: [PATCH 16/37] Update PurchaseRequest.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.request import android.os.Bundle import android.util.Base64 import ir.cafebazaar.poolakey.constant.BazaarIntent import java.nio.charset.StandardCharsets import java.security.MessageDigest import java.util.* import org.json.JSONObject data class PurchaseRequest( val productId: String, val payload: String? = null, val dynamicPriceToken: String? = null ) { internal var cutoutModeIsShortEdges = false // =============================== // ✅ VALIDATION & UTILITY // =============================== /** Checks that the request has a valid product ID. */ fun isValid(): Boolean = productId.isNotBlank() /** Generates a unique payload if none exists. */ fun generatePayloadIfEmpty(): PurchaseRequest = if (payload.isNullOrEmpty()) copy(payload = UUID.randomUUID().toString()) else this /** Returns the payload encoded as Base64 safely. */ fun payloadBase64(): String? = payload?.toByteArray(StandardCharsets.UTF_8)?.let { Base64.encodeToString(it, Base64.NO_WRAP) } /** Enable or disable cutout mode (short edges) for UI. */ fun enableCutoutModeShortEdges(enable: Boolean = true): PurchaseRequest { cutoutModeIsShortEdges = enable return this } /** Converts the request into a Bundle suitable for Bazaar SDK. */ internal fun purchaseExtraData(): Bundle = Bundle().apply { putString(BazaarIntent.RESPONSE_DYNAMIC_PRICE_TOKEN, dynamicPriceToken) putBoolean(BazaarIntent.RESPONSE_CUTOUT_MODE_IS_SHORT_EDGES, cutoutModeIsShortEdges) putString(BazaarIntent.RESPONSE_PRODUCT_ID, productId) payload?.let { putString(BazaarIntent.RESPONSE_PAYLOAD, it) } } /** Human-readable debug summary with improved formatting. */ fun debugSummary(): String = buildString { appendLine("🛒 PurchaseRequest Summary:") appendLine("───────────────────────────────") appendLine("Product ID : $productId") appendLine("Payload : ${payload ?: "N/A"}") appendLine("Dynamic Price Token : ${dynamicPriceToken ?: "N/A"}") appendLine("Cutout Mode Short Edges : $cutoutModeIsShortEdges") appendLine("───────────────────────────────") } // =============================== // 🔐 SECURITY & INTEGRITY // =============================== /** SHA-256 hash of the payload (empty string if null). */ fun payloadSHA256(): String { val data = payload ?: "" val digest = MessageDigest.getInstance("SHA-256") val hash = digest.digest(data.toByteArray(StandardCharsets.UTF_8)) return hash.joinToString("") { "%02x".format(it) } } /** * Checks whether the dynamic price token is still valid. * Default expiry: 15 minutes (900,000 ms) */ fun isDynamicPriceTokenValid(expiryMillis: Long = 15 * 60 * 1000): Boolean { return try { val decoded = dynamicPriceToken?.let { String(Base64.decode(it, Base64.DEFAULT)) } ?: return false val timestamp = decoded.toLongOrNull() ?: return false System.currentTimeMillis() - timestamp < expiryMillis } catch (_: Exception) { false } } // =============================== // 🌐 SERIALIZATION // =============================== /** Converts the request to JSON for logging/network use. */ fun toJson(): JSONObject = JSONObject().apply { put("productId", productId) put("payload", payload ?: JSONObject.NULL) put("dynamicPriceToken", dynamicPriceToken ?: JSONObject.NULL) put("cutoutModeIsShortEdges", cutoutModeIsShortEdges) } companion object { /** Builder function that creates a request with a fresh payload automatically. */ fun create(productId: String, dynamicPriceToken: String? = null): PurchaseRequest = PurchaseRequest(productId, payload = UUID.randomUUID().toString(), dynamicPriceToken = dynamicPriceToken) } } /** Creates a copy with a new dynamic price token. */ internal fun PurchaseRequest.withDynamicPriceToken(token: String): PurchaseRequest = copy(dynamicPriceToken = token) /** Human-readable one-line summary for logs or UI. */ internal fun PurchaseRequest.summaryLine(): String = "🛍 Product: $productId | Payload: ${payload ?: "N/A"} | Token: ${dynamicPriceToken ?: "N/A"} | Cutout: $cutoutModeIsShortEdges" --- .../poolakey/request/PurchaseRequest.kt | 104 +++++++++++++++++- 1 file changed, 98 insertions(+), 6 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/request/PurchaseRequest.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/request/PurchaseRequest.kt index 4dda075..61ad8f5 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/request/PurchaseRequest.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/request/PurchaseRequest.kt @@ -1,7 +1,12 @@ package ir.cafebazaar.poolakey.request import android.os.Bundle +import android.util.Base64 import ir.cafebazaar.poolakey.constant.BazaarIntent +import java.nio.charset.StandardCharsets +import java.security.MessageDigest +import java.util.* +import org.json.JSONObject data class PurchaseRequest( val productId: String, @@ -10,11 +15,98 @@ data class PurchaseRequest( ) { internal var cutoutModeIsShortEdges = false -} -internal fun PurchaseRequest.purchaseExtraData(): Bundle { - return Bundle().apply { - putString(BazaarIntent.RESPONSE_DYNAMIC_PRICE_TOKEN, dynamicPriceToken) - putBoolean(BazaarIntent.RESPONSE_CUTOUT_MODE_IS_SHORT_EDGES, cutoutModeIsShortEdges) + // =============================== + // ✅ VALIDATION & UTILITY + // =============================== + + /** Checks that the request has a valid product ID. */ + fun isValid(): Boolean = productId.isNotBlank() + + /** Generates a unique payload if none exists. */ + fun generatePayloadIfEmpty(): PurchaseRequest = + if (payload.isNullOrEmpty()) copy(payload = UUID.randomUUID().toString()) else this + + /** Returns the payload encoded as Base64 safely. */ + fun payloadBase64(): String? = + payload?.toByteArray(StandardCharsets.UTF_8)?.let { Base64.encodeToString(it, Base64.NO_WRAP) } + + /** Enable or disable cutout mode (short edges) for UI. */ + fun enableCutoutModeShortEdges(enable: Boolean = true): PurchaseRequest { + cutoutModeIsShortEdges = enable + return this + } + + /** Converts the request into a Bundle suitable for Bazaar SDK. */ + internal fun purchaseExtraData(): Bundle = + Bundle().apply { + putString(BazaarIntent.RESPONSE_DYNAMIC_PRICE_TOKEN, dynamicPriceToken) + putBoolean(BazaarIntent.RESPONSE_CUTOUT_MODE_IS_SHORT_EDGES, cutoutModeIsShortEdges) + putString(BazaarIntent.RESPONSE_PRODUCT_ID, productId) + payload?.let { putString(BazaarIntent.RESPONSE_PAYLOAD, it) } + } + + /** Human-readable debug summary with improved formatting. */ + fun debugSummary(): String = + buildString { + appendLine("🛒 PurchaseRequest Summary:") + appendLine("───────────────────────────────") + appendLine("Product ID : $productId") + appendLine("Payload : ${payload ?: "N/A"}") + appendLine("Dynamic Price Token : ${dynamicPriceToken ?: "N/A"}") + appendLine("Cutout Mode Short Edges : $cutoutModeIsShortEdges") + appendLine("───────────────────────────────") + } + + // =============================== + // 🔐 SECURITY & INTEGRITY + // =============================== + + /** SHA-256 hash of the payload (empty string if null). */ + fun payloadSHA256(): String { + val data = payload ?: "" + val digest = MessageDigest.getInstance("SHA-256") + val hash = digest.digest(data.toByteArray(StandardCharsets.UTF_8)) + return hash.joinToString("") { "%02x".format(it) } } -} \ No newline at end of file + + /** + * Checks whether the dynamic price token is still valid. + * Default expiry: 15 minutes (900,000 ms) + */ + fun isDynamicPriceTokenValid(expiryMillis: Long = 15 * 60 * 1000): Boolean { + return try { + val decoded = dynamicPriceToken?.let { String(Base64.decode(it, Base64.DEFAULT)) } ?: return false + val timestamp = decoded.toLongOrNull() ?: return false + System.currentTimeMillis() - timestamp < expiryMillis + } catch (_: Exception) { + false + } + } + + // =============================== + // 🌐 SERIALIZATION + // =============================== + + /** Converts the request to JSON for logging/network use. */ + fun toJson(): JSONObject = JSONObject().apply { + put("productId", productId) + put("payload", payload ?: JSONObject.NULL) + put("dynamicPriceToken", dynamicPriceToken ?: JSONObject.NULL) + put("cutoutModeIsShortEdges", cutoutModeIsShortEdges) + } + + companion object { + /** Builder function that creates a request with a fresh payload automatically. */ + fun create(productId: String, dynamicPriceToken: String? = null): PurchaseRequest = + PurchaseRequest(productId, payload = UUID.randomUUID().toString(), dynamicPriceToken = dynamicPriceToken) + } +} + +/** Creates a copy with a new dynamic price token. */ +internal fun PurchaseRequest.withDynamicPriceToken(token: String): PurchaseRequest = + copy(dynamicPriceToken = token) + +/** Human-readable one-line summary for logs or UI. */ +internal fun PurchaseRequest.summaryLine(): String = + "🛍 Product: $productId | Payload: ${payload ?: "N/A"} | Token: ${dynamicPriceToken ?: "N/A"} | Cutout: $cutoutModeIsShortEdges" From cca3a488a9329f9e1ad9141c21aee98f4a6aa06a Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:30:35 +0330 Subject: [PATCH 17/37] Update BillingReceiver.kt package ir.cafebazaar.poolakey.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log internal class BillingReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { if (intent == null) return val broadcastIntent = Intent().apply { action = intent.action?.let { "$it.iab" } intent.extras?.let { bundle -> putExtras(bundle) } } notifyObserversSafely(broadcastIntent) } /** * Notify observers safely with filtering, priority, and timeout cleanup. */ private fun notifyObserversSafely(intent: Intent) { val now = System.currentTimeMillis() synchronized(observerLock) { val iterator = observerEntries.iterator() while (iterator.hasNext()) { val entry = iterator.next() // Remove observer if timed out if (now - (observerTimestamps[entry.communicator] ?: 0) > OBSERVER_TIMEOUT_MS) { Log.w(TAG, "Observer timed out and removed: ${entry.communicator}") iterator.remove() observerTimestamps.remove(entry.communicator) continue } // Notify if action matches or filter is null (all) if (entry.filterAction == null || entry.filterAction == intent.action) { try { entry.communicator.onNewBroadcastReceived(intent) observerTimestamps[entry.communicator] = now } catch (e: Exception) { Log.w(TAG, "Observer exception removed: ${entry.communicator} | ${e.message}") iterator.remove() observerTimestamps.remove(entry.communicator) } } } } } companion object { private const val TAG = "BillingReceiver" private val observerLock = Any() private val observerEntries = mutableListOf() private val observerTimestamps = mutableMapOf() private const val OBSERVER_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes // ========================================= // Observer Management // ========================================= fun addObserver( communicator: BillingReceiverCommunicator, filterAction: String? = null, priority: Int = 0 ) { synchronized(observerLock) { if (observerEntries.none { it.communicator == communicator }) { observerEntries.add(ObserverEntry(communicator, filterAction, priority)) observerEntries.sortByDescending { it.priority } // Higher priority first observerTimestamps[communicator] = System.currentTimeMillis() Log.i(TAG, "Observer added: $communicator | Filter: ${filterAction ?: "ALL"} | Priority: $priority") } } } fun removeObserver(communicator: BillingReceiverCommunicator) { synchronized(observerLock) { observerEntries.removeAll { it.communicator == communicator } observerTimestamps.remove(communicator) Log.i(TAG, "Observer removed: $communicator") } } fun clearObservers() { synchronized(observerLock) { observerEntries.clear() observerTimestamps.clear() Log.i(TAG, "All observers cleared") } } fun listObservers(): List { synchronized(observerLock) { return observerEntries.map { it.communicator }.toList() } } fun isObserverRegistered(communicator: BillingReceiverCommunicator): Boolean { synchronized(observerLock) { return observerEntries.any { it.communicator == communicator } } } /** * Print a visually appealing observer table in Logcat */ fun printRegisteredObservers() { synchronized(observerLock) { Log.i(TAG, "================ Registered Observers ================") if (observerEntries.isEmpty()) { Log.i(TAG, "No observers registered") } else { observerEntries.forEachIndexed { index, entry -> val lastSeen = observerTimestamps[entry.communicator] ?: 0 val timeAgoSec = ((System.currentTimeMillis() - lastSeen) / 1000) Log.i( TAG, String.format( "%2d | %-25s | Filter: %-10s | Priority: %2d | LastSeen: %4ds ago", index + 1, entry.communicator.toString(), entry.filterAction ?: "ALL", entry.priority, timeAgoSec ) ) } } Log.i(TAG, "=====================================================") } } } private data class ObserverEntry( val communicator: BillingReceiverCommunicator, val filterAction: String? = null, val priority: Int = 0 ) } --- .../poolakey/receiver/BillingReceiver.kt | 131 +++++++++++++++--- 1 file changed, 115 insertions(+), 16 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiver.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiver.kt index 699d0bd..6840bc6 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiver.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiver.kt @@ -3,43 +3,142 @@ package ir.cafebazaar.poolakey.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent +import android.util.Log internal class BillingReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { - Intent().apply { - action = intent!!.action + ".iab" - intent.extras?.let { bundle -> - putExtras(bundle) - } - }.run { - notifyObservers(this) + if (intent == null) return + + val broadcastIntent = Intent().apply { + action = intent.action?.let { "$it.iab" } + intent.extras?.let { bundle -> putExtras(bundle) } } + + notifyObserversSafely(broadcastIntent) } - private fun notifyObservers(intent: Intent) { + /** + * Notify observers safely with filtering, priority, and timeout cleanup. + */ + private fun notifyObserversSafely(intent: Intent) { + val now = System.currentTimeMillis() + synchronized(observerLock) { - for (observer in observers) { - observer.onNewBroadcastReceived(intent) + val iterator = observerEntries.iterator() + + while (iterator.hasNext()) { + val entry = iterator.next() + + // Remove observer if timed out + if (now - (observerTimestamps[entry.communicator] ?: 0) > OBSERVER_TIMEOUT_MS) { + Log.w(TAG, "Observer timed out and removed: ${entry.communicator}") + iterator.remove() + observerTimestamps.remove(entry.communicator) + continue + } + + // Notify if action matches or filter is null (all) + if (entry.filterAction == null || entry.filterAction == intent.action) { + try { + entry.communicator.onNewBroadcastReceived(intent) + observerTimestamps[entry.communicator] = now + } catch (e: Exception) { + Log.w(TAG, "Observer exception removed: ${entry.communicator} | ${e.message}") + iterator.remove() + observerTimestamps.remove(entry.communicator) + } + } } } } companion object { - + private const val TAG = "BillingReceiver" private val observerLock = Any() - private val observers = mutableListOf() + private val observerEntries = mutableListOf() + private val observerTimestamps = mutableMapOf() + private const val OBSERVER_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes + + // ========================================= + // Observer Management + // ========================================= - fun addObserver(communicator: BillingReceiverCommunicator) { + fun addObserver( + communicator: BillingReceiverCommunicator, + filterAction: String? = null, + priority: Int = 0 + ) { synchronized(observerLock) { - observers.add(communicator) + if (observerEntries.none { it.communicator == communicator }) { + observerEntries.add(ObserverEntry(communicator, filterAction, priority)) + observerEntries.sortByDescending { it.priority } // Higher priority first + observerTimestamps[communicator] = System.currentTimeMillis() + Log.i(TAG, "Observer added: $communicator | Filter: ${filterAction ?: "ALL"} | Priority: $priority") + } } } fun removeObserver(communicator: BillingReceiverCommunicator) { synchronized(observerLock) { - observers.remove(communicator) + observerEntries.removeAll { it.communicator == communicator } + observerTimestamps.remove(communicator) + Log.i(TAG, "Observer removed: $communicator") + } + } + + fun clearObservers() { + synchronized(observerLock) { + observerEntries.clear() + observerTimestamps.clear() + Log.i(TAG, "All observers cleared") + } + } + + fun listObservers(): List { + synchronized(observerLock) { + return observerEntries.map { it.communicator }.toList() + } + } + + fun isObserverRegistered(communicator: BillingReceiverCommunicator): Boolean { + synchronized(observerLock) { + return observerEntries.any { it.communicator == communicator } + } + } + + /** + * Print a visually appealing observer table in Logcat + */ + fun printRegisteredObservers() { + synchronized(observerLock) { + Log.i(TAG, "================ Registered Observers ================") + if (observerEntries.isEmpty()) { + Log.i(TAG, "No observers registered") + } else { + observerEntries.forEachIndexed { index, entry -> + val lastSeen = observerTimestamps[entry.communicator] ?: 0 + val timeAgoSec = ((System.currentTimeMillis() - lastSeen) / 1000) + Log.i( + TAG, String.format( + "%2d | %-25s | Filter: %-10s | Priority: %2d | LastSeen: %4ds ago", + index + 1, + entry.communicator.toString(), + entry.filterAction ?: "ALL", + entry.priority, + timeAgoSec + ) + ) + } + } + Log.i(TAG, "=====================================================") } } } -} \ No newline at end of file + + private data class ObserverEntry( + val communicator: BillingReceiverCommunicator, + val filterAction: String? = null, + val priority: Int = 0 + ) +} From 99aa1e7707938c9c73b01c578d367ac85f28cb55 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:35:17 +0330 Subject: [PATCH 18/37] Update BillingReceiverCommunicator.kt package ir.cafebazaar.poolakey.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log internal class BillingReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { intent ?: return // Append ".iab" to the action and copy extras val broadcastIntent = Intent().apply { action = intent.action?.let { "$it.iab" } intent.extras?.let { putExtras(it) } } notifyObserversSafely(broadcastIntent) } /** * Notify observers safely: * - Remove observers that throw exceptions * - Remove observers that have timed out * - Respect filterAction and priority */ private fun notifyObserversSafely(intent: Intent) { val now = System.currentTimeMillis() synchronized(observerLock) { val iterator = observerEntries.iterator() while (iterator.hasNext()) { val entry = iterator.next() // Remove timed-out observers if (now - (observerTimestamps[entry.communicator] ?: 0) > OBSERVER_TIMEOUT_MS) { Log.w(TAG, "Observer timed out and removed: ${entry.communicator}") iterator.remove() observerTimestamps.remove(entry.communicator) continue } // Notify if action matches filter or no filter if (entry.filterAction == null || entry.filterAction == intent.action) { try { entry.communicator.onNewBroadcastReceived(intent) observerTimestamps[entry.communicator] = now } catch (e: Exception) { Log.w(TAG, "Observer exception removed: ${entry.communicator} | ${e.message}") iterator.remove() observerTimestamps.remove(entry.communicator) } } } } } companion object { private const val TAG = "BillingReceiver" private val observerLock = Any() private val observerEntries = mutableListOf() private val observerTimestamps = mutableMapOf() private const val OBSERVER_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes // =========================== // Observer Management Methods // =========================== fun addObserver( communicator: BillingReceiverCommunicator, filterAction: String? = null, priority: Int = 0 ) { synchronized(observerLock) { if (observerEntries.none { it.communicator == communicator }) { observerEntries.add(ObserverEntry(communicator, filterAction, priority)) observerEntries.sortByDescending { it.priority } // Highest priority first observerTimestamps[communicator] = System.currentTimeMillis() Log.i(TAG, "Observer added: $communicator | Filter: ${filterAction ?: "ALL"} | Priority: $priority") } } } fun removeObserver(communicator: BillingReceiverCommunicator) { synchronized(observerLock) { observerEntries.removeAll { it.communicator == communicator } observerTimestamps.remove(communicator) Log.i(TAG, "Observer removed: $communicator") } } fun clearObservers() { synchronized(observerLock) { observerEntries.clear() observerTimestamps.clear() Log.i(TAG, "All observers cleared") } } fun listObservers(): List { synchronized(observerLock) { return observerEntries.map { it.communicator } } } fun isObserverRegistered(communicator: BillingReceiverCommunicator): Boolean { synchronized(observerLock) { return observerEntries.any { it.communicator == communicator } } } /** * Print a visually appealing observer table in Logcat */ fun printRegisteredObservers() { synchronized(observerLock) { Log.i(TAG, "================ Registered Observers ================") if (observerEntries.isEmpty()) { Log.i(TAG, "No observers registered") } else { observerEntries.forEachIndexed { index, entry -> val lastSeen = observerTimestamps[entry.communicator] ?: 0 val timeAgoSec = ((System.currentTimeMillis() - lastSeen) / 1000) Log.i( TAG, String.format( "%2d | %-35s | Filter: %-10s | Priority: %2d | LastSeen: %4ds ago", index + 1, entry.communicator.toString(), entry.filterAction ?: "ALL", entry.priority, timeAgoSec ) ) } } Log.i(TAG, "=====================================================") } } } private data class ObserverEntry( val communicator: BillingReceiverCommunicator, val filterAction: String? = null, val priority: Int = 0 ) } // ======================================================== // BillingReceiverCommunicator Interface // ======================================================== internal interface BillingReceiverCommunicator { fun onNewBroadcastReceived(intent: Intent?) } --- .../receiver/BillingReceiverCommunicator.kt | 148 ++++++++++++++++++ 1 file changed, 148 insertions(+) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiverCommunicator.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiverCommunicator.kt index 2977a8c..ee2664a 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiverCommunicator.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/receiver/BillingReceiverCommunicator.kt @@ -1,6 +1,154 @@ package ir.cafebazaar.poolakey.receiver +import android.content.BroadcastReceiver +import android.content.Context import android.content.Intent +import android.util.Log + +internal class BillingReceiver : BroadcastReceiver() { + + override fun onReceive(context: Context?, intent: Intent?) { + intent ?: return + + // Append ".iab" to the action and copy extras + val broadcastIntent = Intent().apply { + action = intent.action?.let { "$it.iab" } + intent.extras?.let { putExtras(it) } + } + + notifyObserversSafely(broadcastIntent) + } + + /** + * Notify observers safely: + * - Remove observers that throw exceptions + * - Remove observers that have timed out + * - Respect filterAction and priority + */ + private fun notifyObserversSafely(intent: Intent) { + val now = System.currentTimeMillis() + + synchronized(observerLock) { + val iterator = observerEntries.iterator() + while (iterator.hasNext()) { + val entry = iterator.next() + + // Remove timed-out observers + if (now - (observerTimestamps[entry.communicator] ?: 0) > OBSERVER_TIMEOUT_MS) { + Log.w(TAG, "Observer timed out and removed: ${entry.communicator}") + iterator.remove() + observerTimestamps.remove(entry.communicator) + continue + } + + // Notify if action matches filter or no filter + if (entry.filterAction == null || entry.filterAction == intent.action) { + try { + entry.communicator.onNewBroadcastReceived(intent) + observerTimestamps[entry.communicator] = now + } catch (e: Exception) { + Log.w(TAG, "Observer exception removed: ${entry.communicator} | ${e.message}") + iterator.remove() + observerTimestamps.remove(entry.communicator) + } + } + } + } + } + + companion object { + private const val TAG = "BillingReceiver" + private val observerLock = Any() + private val observerEntries = mutableListOf() + private val observerTimestamps = mutableMapOf() + private const val OBSERVER_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes + + // =========================== + // Observer Management Methods + // =========================== + + fun addObserver( + communicator: BillingReceiverCommunicator, + filterAction: String? = null, + priority: Int = 0 + ) { + synchronized(observerLock) { + if (observerEntries.none { it.communicator == communicator }) { + observerEntries.add(ObserverEntry(communicator, filterAction, priority)) + observerEntries.sortByDescending { it.priority } // Highest priority first + observerTimestamps[communicator] = System.currentTimeMillis() + Log.i(TAG, "Observer added: $communicator | Filter: ${filterAction ?: "ALL"} | Priority: $priority") + } + } + } + + fun removeObserver(communicator: BillingReceiverCommunicator) { + synchronized(observerLock) { + observerEntries.removeAll { it.communicator == communicator } + observerTimestamps.remove(communicator) + Log.i(TAG, "Observer removed: $communicator") + } + } + + fun clearObservers() { + synchronized(observerLock) { + observerEntries.clear() + observerTimestamps.clear() + Log.i(TAG, "All observers cleared") + } + } + + fun listObservers(): List { + synchronized(observerLock) { + return observerEntries.map { it.communicator } + } + } + + fun isObserverRegistered(communicator: BillingReceiverCommunicator): Boolean { + synchronized(observerLock) { + return observerEntries.any { it.communicator == communicator } + } + } + + /** + * Print a visually appealing observer table in Logcat + */ + fun printRegisteredObservers() { + synchronized(observerLock) { + Log.i(TAG, "================ Registered Observers ================") + if (observerEntries.isEmpty()) { + Log.i(TAG, "No observers registered") + } else { + observerEntries.forEachIndexed { index, entry -> + val lastSeen = observerTimestamps[entry.communicator] ?: 0 + val timeAgoSec = ((System.currentTimeMillis() - lastSeen) / 1000) + Log.i( + TAG, String.format( + "%2d | %-35s | Filter: %-10s | Priority: %2d | LastSeen: %4ds ago", + index + 1, + entry.communicator.toString(), + entry.filterAction ?: "ALL", + entry.priority, + timeAgoSec + ) + ) + } + } + Log.i(TAG, "=====================================================") + } + } + } + + private data class ObserverEntry( + val communicator: BillingReceiverCommunicator, + val filterAction: String? = null, + val priority: Int = 0 + ) +} + +// ======================================================== +// BillingReceiverCommunicator Interface +// ======================================================== internal interface BillingReceiverCommunicator { fun onNewBroadcastReceived(intent: Intent?) From d0e999c04e1da33b27e28948eb0a31a894481fc4 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:37:43 +0330 Subject: [PATCH 19/37] Update BillingReceiverCommunicator.kt package ir.cafebazaar.poolakey.receiver import android.content.BroadcastReceiver import android.content.Context import android.content.Intent import android.util.Log internal class BillingReceiver : BroadcastReceiver() { override fun onReceive(context: Context?, intent: Intent?) { intent ?: return // Append ".iab" to the action and copy extras val broadcastIntent = Intent().apply { action = intent.action?.let { "$it.iab" } intent.extras?.let { putExtras(it) } } notifyObserversSafely(broadcastIntent) } /** * Notify observers safely: * - Remove observers that throw exceptions * - Remove observers that have timed out * - Respect filterAction and priority */ private fun notifyObserversSafely(intent: Intent) { val now = System.currentTimeMillis() synchronized(observerLock) { val iterator = observerEntries.iterator() while (iterator.hasNext()) { val entry = iterator.next() // Remove timed-out observers if (now - (observerTimestamps[entry.communicator] ?: 0) > OBSERVER_TIMEOUT_MS) { Log.w(TAG, "Observer timed out and removed: ${entry.communicator}") iterator.remove() observerTimestamps.remove(entry.communicator) continue } // Notify if action matches filter or no filter if (entry.filterAction == null || entry.filterAction == intent.action) { try { entry.communicator.onNewBroadcastReceived(intent) observerTimestamps[entry.communicator] = now } catch (e: Exception) { Log.w(TAG, "Observer exception removed: ${entry.communicator} | ${e.message}") iterator.remove() observerTimestamps.remove(entry.communicator) } } } } } companion object { private const val TAG = "BillingReceiver" private val observerLock = Any() private val observerEntries = mutableListOf() private val observerTimestamps = mutableMapOf() private const val OBSERVER_TIMEOUT_MS = 10 * 60 * 1000L // 10 minutes // =========================== // Observer Management Methods // =========================== fun addObserver( communicator: BillingReceiverCommunicator, filterAction: String? = null, priority: Int = 0 ) { synchronized(observerLock) { if (observerEntries.none { it.communicator == communicator }) { observerEntries.add(ObserverEntry(communicator, filterAction, priority)) observerEntries.sortByDescending { it.priority } // Highest priority first observerTimestamps[communicator] = System.currentTimeMillis() Log.i(TAG, "Observer added: $communicator | Filter: ${filterAction ?: "ALL"} | Priority: $priority") } } } fun removeObserver(communicator: BillingReceiverCommunicator) { synchronized(observerLock) { observerEntries.removeAll { it.communicator == communicator } observerTimestamps.remove(communicator) Log.i(TAG, "Observer removed: $communicator") } } fun clearObservers() { synchronized(observerLock) { observerEntries.clear() observerTimestamps.clear() Log.i(TAG, "All observers cleared") } } fun listObservers(): List { synchronized(observerLock) { return observerEntries.map { it.communicator } } } fun isObserverRegistered(communicator: BillingReceiverCommunicator): Boolean { synchronized(observerLock) { return observerEntries.any { it.communicator == communicator } } } /** * Print a visually appealing observer table in Logcat */ fun printRegisteredObservers() { synchronized(observerLock) { Log.i(TAG, "================ Registered Observers ================") if (observerEntries.isEmpty()) { Log.i(TAG, "No observers registered") } else { observerEntries.forEachIndexed { index, entry -> val lastSeen = observerTimestamps[entry.communicator] ?: 0 val timeAgoSec = ((System.currentTimeMillis() - lastSeen) / 1000) Log.i( TAG, String.format( "%2d | %-35s | Filter: %-10s | Priority: %2d | LastSeen: %4ds ago", index + 1, entry.communicator.toString(), entry.filterAction ?: "ALL", entry.priority, timeAgoSec ) ) } } Log.i(TAG, "=====================================================") } } } private data class ObserverEntry( val communicator: BillingReceiverCommunicator, val filterAction: String? = null, val priority: Int = 0 ) } // ======================================================== // BillingReceiverCommunicator Interface // ======================================================== internal interface BillingReceiverCommunicator { fun onNewBroadcastReceived(intent: Intent?) } From 1ec99cd33135a181f122062ac7607b977145e054 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Thu, 16 Oct 2025 18:42:21 +0330 Subject: [PATCH 20/37] Update RawDataToPurchaseInfo.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.mapper import ir.cafebazaar.poolakey.constant.RawJson import ir.cafebazaar.poolakey.entity.PurchaseInfo import ir.cafebazaar.poolakey.entity.PurchaseState import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* internal class RawDataToPurchaseInfo { fun mapToPurchaseInfo(purchaseData: String, dataSignature: String): PurchaseInfo { return JSONObject(purchaseData).run { PurchaseInfo( orderId = optString(RawJson.ORDER_ID), purchaseToken = optString(RawJson.PURCHASE_TOKEN), payload = optString(RawJson.DEVELOPER_PAYLOAD), packageName = optString(RawJson.PACKAGE_NAME), purchaseState = if (optInt(RawJson.PURCHASE_STATE) == 0) PurchaseState.PURCHASED else PurchaseState.REFUNDED, purchaseTime = optLong(RawJson.PURCHASE_TIME), productId = optString(RawJson.PRODUCT_ID), dataSignature = dataSignature, originalJson = purchaseData ) } } fun mapList(purchases: List>): List { return purchases.map { (data, signature) -> mapToPurchaseInfo(data, signature) } } fun getPurchaseSummary(purchaseData: String): String { val json = JSONObject(purchaseData) val state = if (json.optInt(RawJson.PURCHASE_STATE) == 0) "✅ PURCHASED" else "❌ REFUNDED" val formattedTime = formatPurchaseTime(purchaseData) return buildString { appendLine("━━━━━━━━━ Purchase Summary ━━━━━━━━━") appendLine("Order ID : ${json.optString(RawJson.ORDER_ID)}") appendLine("Product ID : ${json.optString(RawJson.PRODUCT_ID)}") appendLine("Purchase State: $state") appendLine("Purchase Time: $formattedTime") appendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") } } fun isRefunded(purchaseData: String): Boolean { return JSONObject(purchaseData).optInt(RawJson.PURCHASE_STATE) != 0 } fun getField(purchaseData: String, field: String): String? { val json = JSONObject(purchaseData) return json.optString(field, null) } /** * Placeholder for verifying purchase signature. */ fun verifyPurchaseSignature(purchaseData: String, signature: String, publicKey: String): Boolean { if (signature.isBlank() || purchaseData.isBlank()) { println("⚠️ Signature verification placeholder - not implemented!") return false } return true } fun filterByState(purchases: List, state: PurchaseState): List { return purchases.filter { it.purchaseState == state } } fun getMostRecentPurchase(purchases: List): PurchaseInfo? { return purchases.maxByOrNull { it.purchaseTime } } fun formatPurchaseTime(purchaseData: String): String { val time = JSONObject(purchaseData).optLong(RawJson.PURCHASE_TIME) return if (time > 0) { SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(time)) } else { "N/A" } } fun isPurchaseForProduct(purchaseData: String, productId: String): Boolean { return JSONObject(purchaseData).optString(RawJson.PRODUCT_ID) == productId } } --- .../poolakey/mapper/RawDataToPurchaseInfo.kt | 66 +++++++++++++++++-- 1 file changed, 61 insertions(+), 5 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/RawDataToPurchaseInfo.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/RawDataToPurchaseInfo.kt index 9ebf7c3..34b42a9 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/RawDataToPurchaseInfo.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/RawDataToPurchaseInfo.kt @@ -4,6 +4,8 @@ import ir.cafebazaar.poolakey.constant.RawJson import ir.cafebazaar.poolakey.entity.PurchaseInfo import ir.cafebazaar.poolakey.entity.PurchaseState import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* internal class RawDataToPurchaseInfo { @@ -14,11 +16,7 @@ internal class RawDataToPurchaseInfo { purchaseToken = optString(RawJson.PURCHASE_TOKEN), payload = optString(RawJson.DEVELOPER_PAYLOAD), packageName = optString(RawJson.PACKAGE_NAME), - purchaseState = if (optInt(RawJson.PURCHASE_STATE) == 0) { - PurchaseState.PURCHASED - } else { - PurchaseState.REFUNDED - }, + purchaseState = if (optInt(RawJson.PURCHASE_STATE) == 0) PurchaseState.PURCHASED else PurchaseState.REFUNDED, purchaseTime = optLong(RawJson.PURCHASE_TIME), productId = optString(RawJson.PRODUCT_ID), dataSignature = dataSignature, @@ -27,4 +25,62 @@ internal class RawDataToPurchaseInfo { } } + fun mapList(purchases: List>): List { + return purchases.map { (data, signature) -> mapToPurchaseInfo(data, signature) } + } + + fun getPurchaseSummary(purchaseData: String): String { + val json = JSONObject(purchaseData) + val state = if (json.optInt(RawJson.PURCHASE_STATE) == 0) "✅ PURCHASED" else "❌ REFUNDED" + val formattedTime = formatPurchaseTime(purchaseData) + return buildString { + appendLine("━━━━━━━━━ Purchase Summary ━━━━━━━━━") + appendLine("Order ID : ${json.optString(RawJson.ORDER_ID)}") + appendLine("Product ID : ${json.optString(RawJson.PRODUCT_ID)}") + appendLine("Purchase State: $state") + appendLine("Purchase Time: $formattedTime") + appendLine("━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━") + } + } + + fun isRefunded(purchaseData: String): Boolean { + return JSONObject(purchaseData).optInt(RawJson.PURCHASE_STATE) != 0 + } + + fun getField(purchaseData: String, field: String): String? { + val json = JSONObject(purchaseData) + return json.optString(field, null) + } + + /** + * Placeholder for verifying purchase signature. + */ + fun verifyPurchaseSignature(purchaseData: String, signature: String, publicKey: String): Boolean { + if (signature.isBlank() || purchaseData.isBlank()) { + println("⚠️ Signature verification placeholder - not implemented!") + return false + } + return true + } + + fun filterByState(purchases: List, state: PurchaseState): List { + return purchases.filter { it.purchaseState == state } + } + + fun getMostRecentPurchase(purchases: List): PurchaseInfo? { + return purchases.maxByOrNull { it.purchaseTime } + } + + fun formatPurchaseTime(purchaseData: String): String { + val time = JSONObject(purchaseData).optLong(RawJson.PURCHASE_TIME) + return if (time > 0) { + SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(time)) + } else { + "N/A" + } + } + + fun isPurchaseForProduct(purchaseData: String, productId: String): Boolean { + return JSONObject(purchaseData).optString(RawJson.PRODUCT_ID) == productId + } } From 46253f26444d42c67bf9208b1f4496ce6392d898 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 10:37:03 +0330 Subject: [PATCH 21/37] Update AbortedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.content.Context import android.os.Handler import android.os.Looper import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.text.style.TypefaceSpan import android.util.Log import android.widget.Toast import org.json.JSONObject import java.io.PrintWriter import java.io.StringWriter import java.text.SimpleDateFormat import java.util.* /** * Thrown when an operation is intentionally aborted or interrupted. * Extends [InterruptedException] for compatibility with thread interruption logic. * * @property operation The name of the operation that was aborted (optional) * @property reason The explanation or cause of abortion (optional) * @property timestamp The system time when the abortion occurred. */ class AbortedException( val operation: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis(), message: String? = null, causeThrowable: Throwable? = null ) : InterruptedException(message ?: buildMessage(operation, reason)) { init { // Attach underlying cause to the Throwable if provided causeThrowable?.let { initCause(it) } } companion object { private const val TAG = "AbortedException" private fun buildMessage(operation: String?, reason: String?): String { return when { operation != null && reason != null -> "Operation '$operation' was aborted: $reason" operation != null -> "Operation '$operation' was aborted." reason != null -> "Operation aborted: $reason" else -> "Operation aborted unexpectedly." } } /** * Creates an [AbortedException] for user cancellation events. */ fun forUserCancel(operation: String? = null): AbortedException { return AbortedException( operation = operation, reason = "User cancelled the operation.", message = "User cancelled ${operation ?: "an operation"}" ) } /** * Creates an [AbortedException] for timeout scenarios. */ fun forTimeout(operation: String? = null, durationMs: Long? = null): AbortedException { val readable = durationMs?.let { "${it / 1000}s" } ?: "unknown" return AbortedException( operation = operation, reason = "Operation timed out after $readable.", message = "Timeout occurred during ${operation ?: "an operation"}" ) } /** * Creates an [AbortedException] due to network issues. */ fun forNetworkError(operation: String? = null): AbortedException { return AbortedException( operation = operation, reason = "Network connection lost or unavailable.", message = "Network error during ${operation ?: "operation"}" ) } } /** * Returns a detailed, user-friendly description of the abortion event. */ fun describe(): String { return buildString { appendLine("⚠️ AbortedException Details ⚠️") appendLine("Timestamp : ${formatTimestamp(timestamp)}") appendLine("Operation : ${operation ?: "Unknown"}") appendLine("Reason : ${reason ?: "Unspecified"}") appendLine("Message : ${message ?: "No message provided"}") this@AbortedException.cause?.let { appendLine("Caused by : ${it.javaClass.simpleName}: ${it.message}") } } } /** * Logs this exception in a clean, structured, and visually enhanced way. */ fun log(tag: String = TAG) { Log.e(tag, "❌ ${message ?: "AbortedException occurred"}") Log.e(tag, "• Operation: ${operation ?: "Unknown"}") Log.e(tag, "• Reason : ${reason ?: "Unspecified"}") Log.e(tag, "• Time : ${formatTimestamp(timestamp)}") this.cause?.let { Log.e(tag, "• Cause : ${it.javaClass.simpleName}: ${it.message}") Log.d(tag, getStackTraceAsString(it)) } } /** * Returns true if the abortion reason is due to user cancellation. */ fun isUserCancelled(): Boolean { return reason?.contains("cancel", ignoreCase = true) == true } /** * Suggests possible recovery actions based on the abortion context. */ fun getRecoverySuggestion(): String { return when { isUserCancelled() -> "User canceled the operation. No further action needed." reason?.contains("timeout", ignoreCase = true) == true -> "Try increasing the timeout duration and retry." reason?.contains("network", ignoreCase = true) == true -> "Please check your internet connection and retry." else -> "Check logs or contact support if this issue persists." } } /** * Converts the stack trace of the cause (if any) into a string for logging. */ private fun getStackTraceAsString(throwable: Throwable): String { val writer = StringWriter() throwable.printStackTrace(PrintWriter(writer)) return writer.toString() } /** * Converts the timestamp into a human-readable date/time format. */ private fun formatTimestamp(time: Long): String { val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) return sdf.format(Date(time)) } /** * Converts this exception into a map structure for analytics or crash reporting. */ fun toMap(): Map { return mapOf( "operation" to operation, "reason" to reason, "timestamp" to timestamp, "message" to message, "isUserCancelled" to isUserCancelled(), "recoverySuggestion" to getRecoverySuggestion(), "cause" to this.cause?.javaClass?.simpleName ) } /** * Converts this exception to JSON for structured logging or remote reporting. * Uses JSONObject to ensure proper escaping of strings. */ fun toJson(pretty: Boolean = true): String { val obj = JSONObject() obj.put("operation", operation ?: JSONObject.NULL) obj.put("reason", reason ?: JSONObject.NULL) obj.put("timestamp", timestamp) obj.put("message", message ?: JSONObject.NULL) obj.put("isUserCancelled", isUserCancelled()) obj.put("recoverySuggestion", getRecoverySuggestion()) obj.put("cause", this.cause?.javaClass?.simpleName ?: JSONObject.NULL) return if (pretty) obj.toString(2) else obj.toString() } override fun toString(): String { return "AbortedException(operation=$operation, reason=$reason, timestamp=$timestamp, message=$message)" } // ======================================================= // 🎨 UI Helpers — these make presenting the exception more pleasant // (lightweight helpers that don't force UI dependencies) // ======================================================= /** * Returns a styled [SpannableString] suitable for showing in a TextView. * Title (operation) will be bold and primary colored; body will be secondary colored; time will be dim + italic. * * Example usage: * textView.text = abortedException.toSpannableMessage(primaryColor, secondaryColor) */ fun toSpannableMessage(primaryColor: Int, secondaryColor: Int): SpannableString { val title = operation ?: "Operation aborted" val body = reason ?: (message ?: "The operation was aborted.") val time = formatTimestamp(timestamp) val full = "$title\n$body\n$time" val spannable = SpannableString(full) // title bold + primary color spannable.setSpan(StyleSpan(android.graphics.Typeface.BOLD), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(ForegroundColorSpan(primaryColor), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) // body colored secondary val bodyStart = title.length + 1 val bodyEnd = bodyStart + body.length if (bodyStart < bodyEnd) { spannable.setSpan(ForegroundColorSpan(secondaryColor), bodyStart, bodyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } // time dim + italic val timeStart = bodyEnd + 1 val timeEnd = full.length if (timeStart < timeEnd) { spannable.setSpan(ForegroundColorSpan(adjustAlpha(secondaryColor, 0.7f)), timeStart, timeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(StyleSpan(android.graphics.Typeface.ITALIC), timeStart, timeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } return spannable } private fun adjustAlpha(@androidx.annotation.ColorInt color: Int, factor: Float): Int { val a = (android.graphics.Color.alpha(color) * factor).toInt() val r = android.graphics.Color.red(color) val g = android.graphics.Color.green(color) val b = android.graphics.Color.blue(color) return android.graphics.Color.argb(a, r, g, b) } /** * Shows a short Toast to the user with a friendly message extracted from this exception. * Safe to call from any thread (it will post to main Looper). */ fun showAsToast(context: Context, duration: Int = Toast.LENGTH_SHORT) { val toastText = when { isUserCancelled() -> "Action cancelled." reason != null -> reason message != null -> message else -> "Operation aborted." } ?: "Operation aborted." if (Looper.myLooper() == Looper.getMainLooper()) { Toast.makeText(context.applicationContext, toastText, duration).show() } else { Handler(Looper.getMainLooper()).post { Toast.makeText(context.applicationContext, toastText, duration).show() } } } } --- .../poolakey/exception/AbortedException.kt | 258 +++++++++++++++++- 1 file changed, 257 insertions(+), 1 deletion(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/AbortedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/AbortedException.kt index f141a6b..b18549e 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/AbortedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/AbortedException.kt @@ -1,3 +1,259 @@ package ir.cafebazaar.poolakey.exception -class AbortedException : InterruptedException() \ No newline at end of file +import android.content.Context +import android.os.Handler +import android.os.Looper +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.text.style.TypefaceSpan +import android.util.Log +import android.widget.Toast +import org.json.JSONObject +import java.io.PrintWriter +import java.io.StringWriter +import java.text.SimpleDateFormat +import java.util.* + +/** + * Thrown when an operation is intentionally aborted or interrupted. + * Extends [InterruptedException] for compatibility with thread interruption logic. + * + * @property operation The name of the operation that was aborted (optional) + * @property reason The explanation or cause of abortion (optional) + * @property timestamp The system time when the abortion occurred. + */ +class AbortedException( + val operation: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis(), + message: String? = null, + causeThrowable: Throwable? = null +) : InterruptedException(message ?: buildMessage(operation, reason)) { + + init { + // Attach underlying cause to the Throwable if provided + causeThrowable?.let { initCause(it) } + } + + companion object { + private const val TAG = "AbortedException" + + private fun buildMessage(operation: String?, reason: String?): String { + return when { + operation != null && reason != null -> + "Operation '$operation' was aborted: $reason" + operation != null -> + "Operation '$operation' was aborted." + reason != null -> + "Operation aborted: $reason" + else -> + "Operation aborted unexpectedly." + } + } + + /** + * Creates an [AbortedException] for user cancellation events. + */ + fun forUserCancel(operation: String? = null): AbortedException { + return AbortedException( + operation = operation, + reason = "User cancelled the operation.", + message = "User cancelled ${operation ?: "an operation"}" + ) + } + + /** + * Creates an [AbortedException] for timeout scenarios. + */ + fun forTimeout(operation: String? = null, durationMs: Long? = null): AbortedException { + val readable = durationMs?.let { "${it / 1000}s" } ?: "unknown" + return AbortedException( + operation = operation, + reason = "Operation timed out after $readable.", + message = "Timeout occurred during ${operation ?: "an operation"}" + ) + } + + /** + * Creates an [AbortedException] due to network issues. + */ + fun forNetworkError(operation: String? = null): AbortedException { + return AbortedException( + operation = operation, + reason = "Network connection lost or unavailable.", + message = "Network error during ${operation ?: "operation"}" + ) + } + } + + /** + * Returns a detailed, user-friendly description of the abortion event. + */ + fun describe(): String { + return buildString { + appendLine("⚠️ AbortedException Details ⚠️") + appendLine("Timestamp : ${formatTimestamp(timestamp)}") + appendLine("Operation : ${operation ?: "Unknown"}") + appendLine("Reason : ${reason ?: "Unspecified"}") + appendLine("Message : ${message ?: "No message provided"}") + this@AbortedException.cause?.let { appendLine("Caused by : ${it.javaClass.simpleName}: ${it.message}") } + } + } + + /** + * Logs this exception in a clean, structured, and visually enhanced way. + */ + fun log(tag: String = TAG) { + Log.e(tag, "❌ ${message ?: "AbortedException occurred"}") + Log.e(tag, "• Operation: ${operation ?: "Unknown"}") + Log.e(tag, "• Reason : ${reason ?: "Unspecified"}") + Log.e(tag, "• Time : ${formatTimestamp(timestamp)}") + this.cause?.let { + Log.e(tag, "• Cause : ${it.javaClass.simpleName}: ${it.message}") + Log.d(tag, getStackTraceAsString(it)) + } + } + + /** + * Returns true if the abortion reason is due to user cancellation. + */ + fun isUserCancelled(): Boolean { + return reason?.contains("cancel", ignoreCase = true) == true + } + + /** + * Suggests possible recovery actions based on the abortion context. + */ + fun getRecoverySuggestion(): String { + return when { + isUserCancelled() -> "User canceled the operation. No further action needed." + reason?.contains("timeout", ignoreCase = true) == true -> "Try increasing the timeout duration and retry." + reason?.contains("network", ignoreCase = true) == true -> "Please check your internet connection and retry." + else -> "Check logs or contact support if this issue persists." + } + } + + /** + * Converts the stack trace of the cause (if any) into a string for logging. + */ + private fun getStackTraceAsString(throwable: Throwable): String { + val writer = StringWriter() + throwable.printStackTrace(PrintWriter(writer)) + return writer.toString() + } + + /** + * Converts the timestamp into a human-readable date/time format. + */ + private fun formatTimestamp(time: Long): String { + val sdf = SimpleDateFormat("yyyy-MM-dd HH:mm:ss.SSS", Locale.getDefault()) + return sdf.format(Date(time)) + } + + /** + * Converts this exception into a map structure for analytics or crash reporting. + */ + fun toMap(): Map { + return mapOf( + "operation" to operation, + "reason" to reason, + "timestamp" to timestamp, + "message" to message, + "isUserCancelled" to isUserCancelled(), + "recoverySuggestion" to getRecoverySuggestion(), + "cause" to this.cause?.javaClass?.simpleName + ) + } + + /** + * Converts this exception to JSON for structured logging or remote reporting. + * Uses JSONObject to ensure proper escaping of strings. + */ + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject() + obj.put("operation", operation ?: JSONObject.NULL) + obj.put("reason", reason ?: JSONObject.NULL) + obj.put("timestamp", timestamp) + obj.put("message", message ?: JSONObject.NULL) + obj.put("isUserCancelled", isUserCancelled()) + obj.put("recoverySuggestion", getRecoverySuggestion()) + obj.put("cause", this.cause?.javaClass?.simpleName ?: JSONObject.NULL) + return if (pretty) obj.toString(2) else obj.toString() + } + + override fun toString(): String { + return "AbortedException(operation=$operation, reason=$reason, timestamp=$timestamp, message=$message)" + } + + // ======================================================= + // 🎨 UI Helpers — these make presenting the exception more pleasant + // (lightweight helpers that don't force UI dependencies) + // ======================================================= + + /** + * Returns a styled [SpannableString] suitable for showing in a TextView. + * Title (operation) will be bold and primary colored; body will be secondary colored; time will be dim + italic. + * + * Example usage: + * textView.text = abortedException.toSpannableMessage(primaryColor, secondaryColor) + */ + fun toSpannableMessage(primaryColor: Int, secondaryColor: Int): SpannableString { + val title = operation ?: "Operation aborted" + val body = reason ?: (message ?: "The operation was aborted.") + val time = formatTimestamp(timestamp) + val full = "$title\n$body\n$time" + val spannable = SpannableString(full) + + // title bold + primary color + spannable.setSpan(StyleSpan(android.graphics.Typeface.BOLD), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(ForegroundColorSpan(primaryColor), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + + // body colored secondary + val bodyStart = title.length + 1 + val bodyEnd = bodyStart + body.length + if (bodyStart < bodyEnd) { + spannable.setSpan(ForegroundColorSpan(secondaryColor), bodyStart, bodyEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + // time dim + italic + val timeStart = bodyEnd + 1 + val timeEnd = full.length + if (timeStart < timeEnd) { + spannable.setSpan(ForegroundColorSpan(adjustAlpha(secondaryColor, 0.7f)), timeStart, timeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(StyleSpan(android.graphics.Typeface.ITALIC), timeStart, timeEnd, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + + return spannable + } + + private fun adjustAlpha(@androidx.annotation.ColorInt color: Int, factor: Float): Int { + val a = (android.graphics.Color.alpha(color) * factor).toInt() + val r = android.graphics.Color.red(color) + val g = android.graphics.Color.green(color) + val b = android.graphics.Color.blue(color) + return android.graphics.Color.argb(a, r, g, b) + } + + /** + * Shows a short Toast to the user with a friendly message extracted from this exception. + * Safe to call from any thread (it will post to main Looper). + */ + fun showAsToast(context: Context, duration: Int = Toast.LENGTH_SHORT) { + val toastText = when { + isUserCancelled() -> "Action cancelled." + reason != null -> reason + message != null -> message + else -> "Operation aborted." + } ?: "Operation aborted." + + if (Looper.myLooper() == Looper.getMainLooper()) { + Toast.makeText(context.applicationContext, toastText, duration).show() + } else { + Handler(Looper.getMainLooper()).post { + Toast.makeText(context.applicationContext, toastText, duration).show() + } + } + } +} From c2479cf4d34c2b20778ee1cb9304640e8875fa1a Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 10:45:54 +0330 Subject: [PATCH 22/37] Update BazaarNotFoundException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Typeface import android.net.Uri import android.os.Handler import android.os.Looper import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Log import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* /** * Thrown when the Bazaar app (Cafebazaar) is not found on the user's device. * This typically occurs when trying to use in-app billing APIs or intents that require Bazaar. */ class BazaarNotFoundException( val packageName: String = "com.farsitel.bazaar", val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please install or update Bazaar to continue." ) : IllegalStateException() { override val message: String? get() = "Bazaar is not installed" companion object { private const val TAG = "BazaarNotFoundException" /** * Creates a default instance when Bazaar is missing. */ fun create(): BazaarNotFoundException = BazaarNotFoundException() /** * Returns the official Bazaar install URI. */ fun getInstallUri(): Uri = Uri.parse("bazaar://details?id=com.farsitel.bazaar") /** * Returns a web fallback URL for Bazaar download. */ fun getWebInstallUri(): Uri = Uri.parse("https://cafebazaar.ir/app/com.farsitel.bazaar?l=en") /** * Checks whether Bazaar is installed on the device. */ fun isBazaarInstalled(context: Context): Boolean { return try { context.packageManager.getPackageInfo("com.farsitel.bazaar", 0) true } catch (e: Exception) { false } } } // =============================================================== // 📋 Helper Methods // =============================================================== fun describe(): String { val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) return buildString { appendLine("⚠️ BazaarNotFoundException") appendLine("Time : $date") appendLine("Message : ${message ?: "Unknown error"}") appendLine("Package : $packageName") appendLine("Hint : ${recoveryHint ?: "Install Bazaar manually"}") } } fun log(tag: String = TAG) { Log.e(tag, "❌ BazaarNotFoundException: ${message}") Log.e(tag, "• Package: $packageName") Log.e(tag, "• Time : ${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp))}") Log.e(tag, "• Hint : ${recoveryHint ?: "Install Bazaar manually"}") } fun toJson(pretty: Boolean = true): String { val obj = JSONObject() obj.put("error", "BazaarNotFoundException") obj.put("message", message) obj.put("timestamp", timestamp) obj.put("package", packageName) obj.put("recoveryHint", recoveryHint) return if (pretty) obj.toString(2) else obj.toString() } fun getRecoverySuggestion(): String = recoveryHint ?: "Please install or update Bazaar and try again." // =============================================================== // 🎨 User Interface Helpers // =============================================================== fun toSpannableMessage(primaryColor: Int, secondaryColor: Int): SpannableString { val title = "⚠️ Bazaar Not Installed" val body = recoveryHint ?: "Please install Bazaar to continue." val fullText = "$title\n\n$body" val spannable = SpannableString(fullText) spannable.setSpan(StyleSpan(Typeface.BOLD), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(ForegroundColorSpan(primaryColor), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) val bodyStart = title.length + 2 if (bodyStart < fullText.length) { spannable.setSpan(ForegroundColorSpan(secondaryColor), bodyStart, fullText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } return spannable } fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { val messageToShow = "⚠️ " + getRecoverySuggestion() if (Looper.myLooper() == Looper.getMainLooper()) { Toast.makeText(context.applicationContext, messageToShow, duration).show() } else { Handler(Looper.getMainLooper()).post { Toast.makeText(context.applicationContext, messageToShow, duration).show() } } } /** * A beautiful and polished dialog prompting the user to install Bazaar. */ fun showBeautifulDialog( context: Context, accentColor: Int = ContextCompat.getColor(context, android.R.color.holo_green_dark), onInstall: (() -> Unit)? = null, onCancel: (() -> Unit)? = null ) { if (Looper.myLooper() != Looper.getMainLooper()) { Handler(Looper.getMainLooper()).post { showBeautifulDialog(context, accentColor, onInstall, onCancel) } return } try { val dialogBuilder = AlertDialog.Builder(context) val spannableMsg = toSpannableMessage(accentColor, ContextCompat.getColor(context, android.R.color.darker_gray)) dialogBuilder.setTitle("✨ Bazaar Required ✨") .setMessage(spannableMsg) .setIcon(android.R.drawable.ic_dialog_info) .setCancelable(false) .setPositiveButton("🚀 Install Bazaar") { dialog, _ -> openBazaarInstallPage(context) onInstall?.invoke() dialog.dismiss() } .setNegativeButton("❌ Cancel") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } dialogBuilder.create().show() } catch (e: Exception) { Log.w(TAG, "Dialog failed: ${e.message}") showAsToast(context) } } // =============================================================== // 🧠 Behavior & Recovery Functions // =============================================================== fun attemptRecovery(context: Context): Boolean { return if (!isBazaarInstalled(context)) { openBazaarInstallPage(context) true } else false } fun getLocalizedRecoveryHint(locale: Locale = Locale.getDefault()): String { return when (locale.language.lowercase(Locale.ROOT)) { "fa" -> "لطفاً برنامه بازار را نصب یا به‌روزرسانی کنید تا بتوانید ادامه دهید." else -> recoveryHint ?: "Please install or update Bazaar to continue." } } fun runIfBazaarAvailable( context: Context, onAvailable: () -> Unit, onMissing: (() -> Unit)? = null ) { if (isBazaarInstalled(context)) onAvailable() else onMissing?.invoke() ?: showBeautifulDialog(context) } fun openBazaarInstallPage(context: Context) { val pm: PackageManager = context.packageManager ?: return val intent = Intent(Intent.ACTION_VIEW, getInstallUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val resolved = pm.queryIntentActivities(intent, 0) if (resolved?.isNotEmpty() == true) { try { context.startActivity(intent) return } catch (e: Exception) { Log.w(TAG, "Failed to open Bazaar install page: ${e.message}") } } try { val webIntent = Intent(Intent.ACTION_VIEW, getWebInstallUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(webIntent) } catch (e: Exception) { Log.e(TAG, "Could not launch Bazaar install fallback: ${e.message}") } } fun showInstallToastIfMissing(context: Context, duration: Int = Toast.LENGTH_LONG) { if (!isBazaarInstalled(context)) showAsToast(context, duration) } fun toMap(): Map = mapOf( "type" to "BazaarNotFoundException", "message" to message, "package" to packageName, "timestamp" to timestamp, "hint" to recoveryHint ) fun toDebugString(): String { val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) return "[BazaarMissing] ($time) ${message ?: "No message"}" } override fun toString(): String { return "BazaarNotFoundException(packageName=$packageName, message=$message, timestamp=$timestamp)" } } --- .../exception/BazaarNotFoundException.kt | 238 +++++++++++++++++- 1 file changed, 237 insertions(+), 1 deletion(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotFoundException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotFoundException.kt index a9e5544..1e684fc 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotFoundException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotFoundException.kt @@ -1,8 +1,244 @@ package ir.cafebazaar.poolakey.exception -class BazaarNotFoundException : IllegalStateException() { +import android.app.AlertDialog +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.net.Uri +import android.os.Handler +import android.os.Looper +import android.text.SpannableString +import android.text.Spanned +import android.text.style.ForegroundColorSpan +import android.text.style.StyleSpan +import android.util.Log +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/** + * Thrown when the Bazaar app (Cafebazaar) is not found on the user's device. + * This typically occurs when trying to use in-app billing APIs or intents that require Bazaar. + */ +class BazaarNotFoundException( + val packageName: String = "com.farsitel.bazaar", + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please install or update Bazaar to continue." +) : IllegalStateException() { override val message: String? get() = "Bazaar is not installed" + companion object { + private const val TAG = "BazaarNotFoundException" + + /** + * Creates a default instance when Bazaar is missing. + */ + fun create(): BazaarNotFoundException = BazaarNotFoundException() + + /** + * Returns the official Bazaar install URI. + */ + fun getInstallUri(): Uri = Uri.parse("bazaar://details?id=com.farsitel.bazaar") + + /** + * Returns a web fallback URL for Bazaar download. + */ + fun getWebInstallUri(): Uri = + Uri.parse("https://cafebazaar.ir/app/com.farsitel.bazaar?l=en") + + /** + * Checks whether Bazaar is installed on the device. + */ + fun isBazaarInstalled(context: Context): Boolean { + return try { + context.packageManager.getPackageInfo("com.farsitel.bazaar", 0) + true + } catch (e: Exception) { + false + } + } + } + + // =============================================================== + // 📋 Helper Methods + // =============================================================== + + fun describe(): String { + val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) + return buildString { + appendLine("⚠️ BazaarNotFoundException") + appendLine("Time : $date") + appendLine("Message : ${message ?: "Unknown error"}") + appendLine("Package : $packageName") + appendLine("Hint : ${recoveryHint ?: "Install Bazaar manually"}") + } + } + + fun log(tag: String = TAG) { + Log.e(tag, "❌ BazaarNotFoundException: ${message}") + Log.e(tag, "• Package: $packageName") + Log.e(tag, "• Time : ${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp))}") + Log.e(tag, "• Hint : ${recoveryHint ?: "Install Bazaar manually"}") + } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject() + obj.put("error", "BazaarNotFoundException") + obj.put("message", message) + obj.put("timestamp", timestamp) + obj.put("package", packageName) + obj.put("recoveryHint", recoveryHint) + return if (pretty) obj.toString(2) else obj.toString() + } + + fun getRecoverySuggestion(): String = + recoveryHint ?: "Please install or update Bazaar and try again." + + // =============================================================== + // 🎨 User Interface Helpers + // =============================================================== + + fun toSpannableMessage(primaryColor: Int, secondaryColor: Int): SpannableString { + val title = "⚠️ Bazaar Not Installed" + val body = recoveryHint ?: "Please install Bazaar to continue." + val fullText = "$title\n\n$body" + val spannable = SpannableString(fullText) + + spannable.setSpan(StyleSpan(Typeface.BOLD), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + spannable.setSpan(ForegroundColorSpan(primaryColor), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + val bodyStart = title.length + 2 + if (bodyStart < fullText.length) { + spannable.setSpan(ForegroundColorSpan(secondaryColor), bodyStart, fullText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) + } + return spannable + } + + fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { + val messageToShow = "⚠️ " + getRecoverySuggestion() + if (Looper.myLooper() == Looper.getMainLooper()) { + Toast.makeText(context.applicationContext, messageToShow, duration).show() + } else { + Handler(Looper.getMainLooper()).post { + Toast.makeText(context.applicationContext, messageToShow, duration).show() + } + } + } + + /** + * A beautiful and polished dialog prompting the user to install Bazaar. + */ + fun showBeautifulDialog( + context: Context, + accentColor: Int = ContextCompat.getColor(context, android.R.color.holo_green_dark), + onInstall: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null + ) { + if (Looper.myLooper() != Looper.getMainLooper()) { + Handler(Looper.getMainLooper()).post { + showBeautifulDialog(context, accentColor, onInstall, onCancel) + } + return + } + + try { + val dialogBuilder = AlertDialog.Builder(context) + val spannableMsg = toSpannableMessage(accentColor, ContextCompat.getColor(context, android.R.color.darker_gray)) + dialogBuilder.setTitle("✨ Bazaar Required ✨") + .setMessage(spannableMsg) + .setIcon(android.R.drawable.ic_dialog_info) + .setCancelable(false) + .setPositiveButton("🚀 Install Bazaar") { dialog, _ -> + openBazaarInstallPage(context) + onInstall?.invoke() + dialog.dismiss() + } + .setNegativeButton("❌ Cancel") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + dialogBuilder.create().show() + } catch (e: Exception) { + Log.w(TAG, "Dialog failed: ${e.message}") + showAsToast(context) + } + } + + // =============================================================== + // 🧠 Behavior & Recovery Functions + // =============================================================== + + fun attemptRecovery(context: Context): Boolean { + return if (!isBazaarInstalled(context)) { + openBazaarInstallPage(context) + true + } else false + } + + fun getLocalizedRecoveryHint(locale: Locale = Locale.getDefault()): String { + return when (locale.language.lowercase(Locale.ROOT)) { + "fa" -> "لطفاً برنامه بازار را نصب یا به‌روزرسانی کنید تا بتوانید ادامه دهید." + else -> recoveryHint ?: "Please install or update Bazaar to continue." + } + } + + fun runIfBazaarAvailable( + context: Context, + onAvailable: () -> Unit, + onMissing: (() -> Unit)? = null + ) { + if (isBazaarInstalled(context)) onAvailable() + else onMissing?.invoke() ?: showBeautifulDialog(context) + } + + fun openBazaarInstallPage(context: Context) { + val pm: PackageManager = context.packageManager ?: return + val intent = Intent(Intent.ACTION_VIEW, getInstallUri()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + + val resolved = pm.queryIntentActivities(intent, 0) + if (resolved?.isNotEmpty() == true) { + try { + context.startActivity(intent) + return + } catch (e: Exception) { + Log.w(TAG, "Failed to open Bazaar install page: ${e.message}") + } + } + + try { + val webIntent = Intent(Intent.ACTION_VIEW, getWebInstallUri()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(webIntent) + } catch (e: Exception) { + Log.e(TAG, "Could not launch Bazaar install fallback: ${e.message}") + } + } + + fun showInstallToastIfMissing(context: Context, duration: Int = Toast.LENGTH_LONG) { + if (!isBazaarInstalled(context)) showAsToast(context, duration) + } + + fun toMap(): Map = mapOf( + "type" to "BazaarNotFoundException", + "message" to message, + "package" to packageName, + "timestamp" to timestamp, + "hint" to recoveryHint + ) + + fun toDebugString(): String { + val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) + return "[BazaarMissing] ($time) ${message ?: "No message"}" + } + + override fun toString(): String { + return "BazaarNotFoundException(packageName=$packageName, message=$message, timestamp=$timestamp)" + } } From b5f28885c388c4ee597719f836ef71b8b974c481 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 10:48:09 +0330 Subject: [PATCH 23/37] Update BazaarNotFoundException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Typeface import android.net.Uri import android.os.Handler import android.os.Looper import android.text.SpannableString import android.text.Spanned import android.text.style.ForegroundColorSpan import android.text.style.StyleSpan import android.util.Log import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* /** * Thrown when the Bazaar app (Cafebazaar) is not found on the user's device. * This typically occurs when trying to use in-app billing APIs or intents that require Bazaar. */ class BazaarNotFoundException( val packageName: String = "com.farsitel.bazaar", val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please install or update Bazaar to continue." ) : IllegalStateException() { override val message: String? get() = "Bazaar is not installed" companion object { private const val TAG = "BazaarNotFoundException" /** * Creates a default instance when Bazaar is missing. */ fun create(): BazaarNotFoundException = BazaarNotFoundException() /** * Returns the official Bazaar install URI. */ fun getInstallUri(): Uri = Uri.parse("bazaar://details?id=com.farsitel.bazaar") /** * Returns a web fallback URL for Bazaar download. */ fun getWebInstallUri(): Uri = Uri.parse("https://cafebazaar.ir/app/com.farsitel.bazaar?l=en") /** * Checks whether Bazaar is installed on the device. */ fun isBazaarInstalled(context: Context): Boolean { return try { context.packageManager.getPackageInfo("com.farsitel.bazaar", 0) true } catch (e: Exception) { false } } } // =============================================================== // 📋 Helper Methods // =============================================================== fun describe(): String { val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) return buildString { appendLine("⚠️ BazaarNotFoundException") appendLine("Time : $date") appendLine("Message : ${message ?: "Unknown error"}") appendLine("Package : $packageName") appendLine("Hint : ${recoveryHint ?: "Install Bazaar manually"}") } } fun log(tag: String = TAG) { Log.e(tag, "❌ BazaarNotFoundException: ${message}") Log.e(tag, "• Package: $packageName") Log.e(tag, "• Time : ${SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp))}") Log.e(tag, "• Hint : ${recoveryHint ?: "Install Bazaar manually"}") } fun toJson(pretty: Boolean = true): String { val obj = JSONObject() obj.put("error", "BazaarNotFoundException") obj.put("message", message) obj.put("timestamp", timestamp) obj.put("package", packageName) obj.put("recoveryHint", recoveryHint) return if (pretty) obj.toString(2) else obj.toString() } fun getRecoverySuggestion(): String = recoveryHint ?: "Please install or update Bazaar and try again." // =============================================================== // 🎨 User Interface Helpers // =============================================================== fun toSpannableMessage(primaryColor: Int, secondaryColor: Int): SpannableString { val title = "⚠️ Bazaar Not Installed" val body = recoveryHint ?: "Please install Bazaar to continue." val fullText = "$title\n\n$body" val spannable = SpannableString(fullText) spannable.setSpan(StyleSpan(Typeface.BOLD), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) spannable.setSpan(ForegroundColorSpan(primaryColor), 0, title.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) val bodyStart = title.length + 2 if (bodyStart < fullText.length) { spannable.setSpan(ForegroundColorSpan(secondaryColor), bodyStart, fullText.length, Spanned.SPAN_EXCLUSIVE_EXCLUSIVE) } return spannable } fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { val messageToShow = "⚠️ " + getRecoverySuggestion() if (Looper.myLooper() == Looper.getMainLooper()) { Toast.makeText(context.applicationContext, messageToShow, duration).show() } else { Handler(Looper.getMainLooper()).post { Toast.makeText(context.applicationContext, messageToShow, duration).show() } } } /** * A beautiful and polished dialog prompting the user to install Bazaar. */ fun showBeautifulDialog( context: Context, accentColor: Int = ContextCompat.getColor(context, android.R.color.holo_green_dark), onInstall: (() -> Unit)? = null, onCancel: (() -> Unit)? = null ) { if (Looper.myLooper() != Looper.getMainLooper()) { Handler(Looper.getMainLooper()).post { showBeautifulDialog(context, accentColor, onInstall, onCancel) } return } try { val dialogBuilder = AlertDialog.Builder(context) val spannableMsg = toSpannableMessage(accentColor, ContextCompat.getColor(context, android.R.color.darker_gray)) dialogBuilder.setTitle("✨ Bazaar Required ✨") .setMessage(spannableMsg) .setIcon(android.R.drawable.ic_dialog_info) .setCancelable(false) .setPositiveButton("🚀 Install Bazaar") { dialog, _ -> openBazaarInstallPage(context) onInstall?.invoke() dialog.dismiss() } .setNegativeButton("❌ Cancel") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } dialogBuilder.create().show() } catch (e: Exception) { Log.w(TAG, "Dialog failed: ${e.message}") showAsToast(context) } } // =============================================================== // 🧠 Behavior & Recovery Functions // =============================================================== fun attemptRecovery(context: Context): Boolean { return if (!isBazaarInstalled(context)) { openBazaarInstallPage(context) true } else false } fun getLocalizedRecoveryHint(locale: Locale = Locale.getDefault()): String { return when (locale.language.lowercase(Locale.ROOT)) { "fa" -> "لطفاً برنامه بازار را نصب یا به‌روزرسانی کنید تا بتوانید ادامه دهید." else -> recoveryHint ?: "Please install or update Bazaar to continue." } } fun runIfBazaarAvailable( context: Context, onAvailable: () -> Unit, onMissing: (() -> Unit)? = null ) { if (isBazaarInstalled(context)) onAvailable() else onMissing?.invoke() ?: showBeautifulDialog(context) } fun openBazaarInstallPage(context: Context) { val pm: PackageManager = context.packageManager ?: return val intent = Intent(Intent.ACTION_VIEW, getInstallUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } val resolved = pm.queryIntentActivities(intent, 0) if (resolved?.isNotEmpty() == true) { try { context.startActivity(intent) return } catch (e: Exception) { Log.w(TAG, "Failed to open Bazaar install page: ${e.message}") } } try { val webIntent = Intent(Intent.ACTION_VIEW, getWebInstallUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(webIntent) } catch (e: Exception) { Log.e(TAG, "Could not launch Bazaar install fallback: ${e.message}") } } fun showInstallToastIfMissing(context: Context, duration: Int = Toast.LENGTH_LONG) { if (!isBazaarInstalled(context)) showAsToast(context, duration) } fun toMap(): Map = mapOf( "type" to "BazaarNotFoundException", "message" to message, "package" to packageName, "timestamp" to timestamp, "hint" to recoveryHint ) fun toDebugString(): String { val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) return "[BazaarMissing] ($time) ${message ?: "No message"}" } override fun toString(): String { return "BazaarNotFoundException(packageName=$packageName, message=$message, timestamp=$timestamp)" } } From ab4d93512d61c051cd4193996c73f6e7d6bcd262 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 15:52:12 +0330 Subject: [PATCH 24/37] Update BazaarNotSupportedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.graphics.Typeface import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper import android.util.Log import android.widget.LinearLayout import android.widget.TextView import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* /** * Exception thrown when Bazaar app is installed but not updated * to a version that supports the required billing or service features. */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String? get() = "Bazaar is not updated" companion object { private const val TAG = "BazaarNotSupportedException" private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" /** Bazaar in-app URI */ fun getUpdateUri(): Uri = Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") /** Web fallback URI */ fun getWebUpdateUri(): Uri = Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") /** Check if Bazaar is installed */ fun isBazaarInstalled(context: Context): Boolean { return try { context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0) true } catch (_: PackageManager.NameNotFoundException) { false } catch (_: Exception) { false } } /** Get installed Bazaar version code (supports all API levels) */ fun getInstalledVersionCode(context: Context): Long? { return try { val info = context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong() } catch (_: Exception) { null } } /** Check if Bazaar supports the required version */ fun isVersionSupported(context: Context, requiredVersion: Int): Boolean { val current = getInstalledVersionCode(context) ?: return false return current >= requiredVersion } } // =============================================================== // 📋 Core Diagnostic & Info Utilities // =============================================================== fun describe(): String { val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) return """ ⚠️ BazaarNotSupportedException ────────────────────────────── Time : $date Message : ${message ?: "Unknown"} Package : $packageName Required Ver: ${requiredVersion ?: "Unknown"} Current Ver : ${currentVersion ?: "Unknown"} Hint : ${recoveryHint ?: "Please update Bazaar manually"} """.trimIndent() } fun log(tag: String = TAG) { Log.e(tag, "❌ BazaarNotSupportedException: $message") Log.e(tag, "• Package: $packageName") Log.e(tag, "• Required version: ${requiredVersion ?: "?"}") Log.e(tag, "• Current version : ${currentVersion ?: "?"}") Log.e(tag, "• Hint : $recoveryHint") } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "BazaarNotSupportedException") put("message", message) put("timestamp", timestamp) put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("recoveryHint", recoveryHint) } return if (pretty) obj.toString(2) else obj.toString() } fun getRecoverySuggestion(): String = recoveryHint ?: "Please update Bazaar to the latest version." // =============================================================== // 🎨 Modern, Beautiful UI Helpers // =============================================================== fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { val msg = getRecoverySuggestion() val runnable = Runnable { Toast.makeText(context.applicationContext, msg, duration).show() } if (Looper.myLooper() == Looper.getMainLooper()) runnable.run() else Handler(Looper.getMainLooper()).post(runnable) } /** * A stylish, user-friendly update dialog with better typography and color scheme. */ fun showUpdateDialog( context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null ) { val showDialog = { try { val titleView = TextView(context).apply { text = "⚠️ Bazaar Update Required" textSize = 20f setTypeface(null, Typeface.BOLD) setTextColor(ContextCompat.getColor(context, android.R.color.holo_orange_dark)) setPadding(50, 40, 50, 20) } val messageView = TextView(context).apply { text = """ Your version of Bazaar is outdated. Please update to continue using all features. ${getRecoverySuggestion()} """.trimIndent() textSize = 16f setTextColor(ContextCompat.getColor(context, android.R.color.secondary_text_dark)) setPadding(50, 0, 50, 20) } val layout = LinearLayout(context).apply { orientation = LinearLayout.VERTICAL addView(titleView) addView(messageView) } val builder = AlertDialog.Builder(context) .setView(layout) .setCancelable(false) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context) onUpdate?.invoke() dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor( ContextCompat.getColor(context, android.R.color.holo_green_light) ) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor( ContextCompat.getColor(context, android.R.color.holo_red_light) ) } dialog.show() } catch (e: Exception) { Log.w(TAG, "Dialog failed: ${e.message}") showAsToast(context) } } if (Looper.myLooper() == Looper.getMainLooper()) showDialog() else Handler(Looper.getMainLooper()).post(showDialog) } fun openBazaarUpdatePage(context: Context) { try { val intent = Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } catch (_: ActivityNotFoundException) { // Fallback to web try { val webIntent = Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(webIntent) } catch (e: Exception) { Log.e(TAG, "Failed to open web update page: ${e.message}") showAsToast(context) } } } // =============================================================== // 🧩 Smart Logic & Helper Tools // =============================================================== fun hasInternetConnection(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) return caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true } fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion fun elapsedSinceThrown(): Long = (System.currentTimeMillis() - timestamp) / 1000 fun getReadableElapsedTime(): String { val seconds = elapsedSinceThrown() val minutes = seconds / 60 val remaining = seconds % 60 return if (minutes > 0) "${minutes}m ${remaining}s ago" else "${remaining}s ago" } fun getLocalizedRecoveryHint(locale: Locale = Locale.getDefault()): String = when (locale.language.lowercase(Locale.getDefault())) { "fa" -> "لطفاً بازار را به‌روزرسانی کنید تا بتوانید ادامه دهید." else -> recoveryHint ?: "Please update Bazaar to continue." } fun smartRecoveryStrategy(context: Context): String = when { !isBazaarInstalled(context) -> "Bazaar not installed. Please install it first." !hasInternetConnection(context) -> "No internet connection. Please connect and try again." needsUpdate() -> "Your Bazaar version is outdated. Please update now." else -> "Unknown issue. Try restarting your device or reinstalling Bazaar." } fun toAnalyticsBundle(): Map = mapOf( "exception" to "BazaarNotSupportedException", "package" to packageName, "required_version" to (requiredVersion?.toString() ?: "unknown"), "current_version" to (currentVersion?.toString() ?: "unknown"), "elapsed_seconds" to elapsedSinceThrown().toString(), "device" to "${Build.MANUFACTURER} ${Build.MODEL}", "android_version" to Build.VERSION.RELEASE ) fun compactSummary(): String = "[$packageName] Bazaar outdated (req=$requiredVersion, cur=$currentVersion)" fun getErrorId(): String = UUID.randomUUID().toString().substring(0, 8) fun diagnosticReport(): String = buildString { appendLine(toDebugString()) appendLine(describe()) appendLine("Elapsed: ${getReadableElapsedTime()}") } fun toDebugString(): String { val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) return "[BazaarNotSupported] ($time) ${message ?: "No message"}" } override fun toString(): String = "BazaarNotSupportedException(packageName=$packageName, requiredVersion=$requiredVersion, currentVersion=$currentVersion, message=$message, timestamp=$timestamp)" } --- .../exception/BazaarNotSupportedException.kt | 284 +++++++++++++++++- 1 file changed, 283 insertions(+), 1 deletion(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotSupportedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotSupportedException.kt index 5a8a7b1..0d0fbab 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotSupportedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/BazaarNotSupportedException.kt @@ -1,8 +1,290 @@ package ir.cafebazaar.poolakey.exception -class BazaarNotSupportedException : IllegalStateException() { +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.graphics.Typeface +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.util.Log +import android.widget.LinearLayout +import android.widget.TextView +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/** + * Exception thrown when Bazaar app is installed but not updated + * to a version that supports the required billing or service features. + */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { override val message: String? get() = "Bazaar is not updated" + companion object { + private const val TAG = "BazaarNotSupportedException" + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + + /** Bazaar in-app URI */ + fun getUpdateUri(): Uri = Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + + /** Web fallback URI */ + fun getWebUpdateUri(): Uri = + Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + + /** Check if Bazaar is installed */ + fun isBazaarInstalled(context: Context): Boolean { + return try { + context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0) + true + } catch (_: PackageManager.NameNotFoundException) { + false + } catch (_: Exception) { + false + } + } + + /** Get installed Bazaar version code (supports all API levels) */ + fun getInstalledVersionCode(context: Context): Long? { + return try { + val info = context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode + else @Suppress("DEPRECATION") info.versionCode.toLong() + } catch (_: Exception) { + null + } + } + + /** Check if Bazaar supports the required version */ + fun isVersionSupported(context: Context, requiredVersion: Int): Boolean { + val current = getInstalledVersionCode(context) ?: return false + return current >= requiredVersion + } + } + + // =============================================================== + // 📋 Core Diagnostic & Info Utilities + // =============================================================== + + fun describe(): String { + val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) + return """ + ⚠️ BazaarNotSupportedException + ────────────────────────────── + Time : $date + Message : ${message ?: "Unknown"} + Package : $packageName + Required Ver: ${requiredVersion ?: "Unknown"} + Current Ver : ${currentVersion ?: "Unknown"} + Hint : ${recoveryHint ?: "Please update Bazaar manually"} + """.trimIndent() + } + + fun log(tag: String = TAG) { + Log.e(tag, "❌ BazaarNotSupportedException: $message") + Log.e(tag, "• Package: $packageName") + Log.e(tag, "• Required version: ${requiredVersion ?: "?"}") + Log.e(tag, "• Current version : ${currentVersion ?: "?"}") + Log.e(tag, "• Hint : $recoveryHint") + } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("message", message) + put("timestamp", timestamp) + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("recoveryHint", recoveryHint) + } + return if (pretty) obj.toString(2) else obj.toString() + } + + fun getRecoverySuggestion(): String = + recoveryHint ?: "Please update Bazaar to the latest version." + + // =============================================================== + // 🎨 Modern, Beautiful UI Helpers + // =============================================================== + + fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { + val msg = getRecoverySuggestion() + val runnable = Runnable { + Toast.makeText(context.applicationContext, msg, duration).show() + } + if (Looper.myLooper() == Looper.getMainLooper()) runnable.run() + else Handler(Looper.getMainLooper()).post(runnable) + } + + /** + * A stylish, user-friendly update dialog with better typography and color scheme. + */ + fun showUpdateDialog( + context: Context, + onUpdate: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null + ) { + val showDialog = { + try { + val titleView = TextView(context).apply { + text = "⚠️ Bazaar Update Required" + textSize = 20f + setTypeface(null, Typeface.BOLD) + setTextColor(ContextCompat.getColor(context, android.R.color.holo_orange_dark)) + setPadding(50, 40, 50, 20) + } + + val messageView = TextView(context).apply { + text = """ + Your version of Bazaar is outdated. + Please update to continue using all features. + + ${getRecoverySuggestion()} + """.trimIndent() + textSize = 16f + setTextColor(ContextCompat.getColor(context, android.R.color.secondary_text_dark)) + setPadding(50, 0, 50, 20) + } + + val layout = LinearLayout(context).apply { + orientation = LinearLayout.VERTICAL + addView(titleView) + addView(messageView) + } + + val builder = AlertDialog.Builder(context) + .setView(layout) + .setCancelable(false) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context) + onUpdate?.invoke() + dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor( + ContextCompat.getColor(context, android.R.color.holo_green_light) + ) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor( + ContextCompat.getColor(context, android.R.color.holo_red_light) + ) + } + + dialog.show() + } catch (e: Exception) { + Log.w(TAG, "Dialog failed: ${e.message}") + showAsToast(context) + } + } + + if (Looper.myLooper() == Looper.getMainLooper()) showDialog() + else Handler(Looper.getMainLooper()).post(showDialog) + } + + fun openBazaarUpdatePage(context: Context) { + try { + val intent = Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(intent) + } catch (_: ActivityNotFoundException) { + // Fallback to web + try { + val webIntent = Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { + addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) + } + context.startActivity(webIntent) + } catch (e: Exception) { + Log.e(TAG, "Failed to open web update page: ${e.message}") + showAsToast(context) + } + } + } + + // =============================================================== + // 🧩 Smart Logic & Helper Tools + // =============================================================== + + fun hasInternetConnection(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) + return caps?.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) == true + } + + fun needsUpdate(): Boolean = + requiredVersion != null && currentVersion != null && currentVersion < requiredVersion + + fun elapsedSinceThrown(): Long = + (System.currentTimeMillis() - timestamp) / 1000 + + fun getReadableElapsedTime(): String { + val seconds = elapsedSinceThrown() + val minutes = seconds / 60 + val remaining = seconds % 60 + return if (minutes > 0) "${minutes}m ${remaining}s ago" else "${remaining}s ago" + } + + fun getLocalizedRecoveryHint(locale: Locale = Locale.getDefault()): String = + when (locale.language.lowercase(Locale.getDefault())) { + "fa" -> "لطفاً بازار را به‌روزرسانی کنید تا بتوانید ادامه دهید." + else -> recoveryHint ?: "Please update Bazaar to continue." + } + + fun smartRecoveryStrategy(context: Context): String = when { + !isBazaarInstalled(context) -> "Bazaar not installed. Please install it first." + !hasInternetConnection(context) -> "No internet connection. Please connect and try again." + needsUpdate() -> "Your Bazaar version is outdated. Please update now." + else -> "Unknown issue. Try restarting your device or reinstalling Bazaar." + } + + fun toAnalyticsBundle(): Map = mapOf( + "exception" to "BazaarNotSupportedException", + "package" to packageName, + "required_version" to (requiredVersion?.toString() ?: "unknown"), + "current_version" to (currentVersion?.toString() ?: "unknown"), + "elapsed_seconds" to elapsedSinceThrown().toString(), + "device" to "${Build.MANUFACTURER} ${Build.MODEL}", + "android_version" to Build.VERSION.RELEASE + ) + + fun compactSummary(): String = + "[$packageName] Bazaar outdated (req=$requiredVersion, cur=$currentVersion)" + + fun getErrorId(): String = UUID.randomUUID().toString().substring(0, 8) + + fun diagnosticReport(): String = buildString { + appendLine(toDebugString()) + appendLine(describe()) + appendLine("Elapsed: ${getReadableElapsedTime()}") + } + + fun toDebugString(): String { + val time = SimpleDateFormat("HH:mm:ss", Locale.getDefault()).format(Date(timestamp)) + return "[BazaarNotSupported] ($time) ${message ?: "No message"}" + } + + override fun toString(): String = + "BazaarNotSupportedException(packageName=$packageName, requiredVersion=$requiredVersion, currentVersion=$currentVersion, message=$message, timestamp=$timestamp)" } From b03aa1cddd2d41051ca925a5b0de77fd0252ab80 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 15:59:08 +0330 Subject: [PATCH 25/37] Update ConsumeFailedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.Context import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.util.Log import android.widget.Toast import androidx.core.content.ContextCompat import kotlinx.coroutines.delay import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* import kotlin.math.pow /** * Thrown when a "consume" request to Bazaar fails, * typically due to a network, billing, or remote service issue. */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String? get() = reason ?: "Consume request failed: It's from Bazaar" companion object { private const val TAG = "ConsumeFailedException" /** * Creates a preconfigured exception for a known common cause. */ fun fromNetworkError(): ConsumeFailedException { return ConsumeFailedException(reason = "Network connection error") } fun fromInvalidToken(token: String): ConsumeFailedException { return ConsumeFailedException( purchaseToken = token, reason = "Invalid or expired purchase token" ) } fun fromTimeout(): ConsumeFailedException { return ConsumeFailedException(reason = "Consume request timed out") } fun fromUnknownError(): ConsumeFailedException { return ConsumeFailedException(reason = "Unknown internal error") } } // =============================================================== // 📋 Diagnostic & Logging Tools // =============================================================== fun describe(): String { val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) .format(Date(timestamp)) return buildString { appendLine("⚠️ ConsumeFailedException") appendLine("Time : $date") appendLine("Product ID : ${productId ?: "Unknown"}") appendLine("Token : ${purchaseToken ?: "Unknown"}") appendLine("Reason : ${reason ?: "Unknown failure"}") appendLine("Message : ${message}") } } fun log(tag: String = TAG, detailed: Boolean = false) { Log.e(tag, "❌ ConsumeFailedException: ${message}") if (detailed) { Log.e(tag, describe()) } else { if (productId != null) Log.e(tag, "• Product ID: $productId") if (purchaseToken != null) Log.e(tag, "• Token: $purchaseToken") Log.e(tag, "• Timestamp: $timestamp") } } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "ConsumeFailedException") put("message", message) put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) put("elapsedSeconds", timeSinceFirstFailure()) } return if (pretty) obj.toString(2) else obj.toString() } fun toMap(): Map = mapOf( "type" to "ConsumeFailedException", "message" to message, "productId" to productId, "purchaseToken" to purchaseToken, "reason" to reason, "timestamp" to timestamp, "retryCount" to retryCount, "elapsedSeconds" to timeSinceFirstFailure() ) // =============================================================== // 🎨 User Feedback Helpers // =============================================================== /** * Safely show a toast on main thread. */ fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { val msg = message ?: "Consume operation failed." runOnMain { Toast.makeText(context.applicationContext, msg, duration).show() } } fun getReadableError(): String { return when { reason?.contains("network", true) == true -> "Network issue detected. Please check your connection." reason?.contains("timeout", true) == true -> "The operation took too long. Please try again." reason?.contains("token", true) == true -> "Purchase token invalid. Please refresh your purchase list." else -> "Unable to complete purchase consumption. Try again later." } } fun showFriendlyToast(context: Context, duration: Int = Toast.LENGTH_LONG) { runOnMain { Toast.makeText(context.applicationContext, getReadableError(), duration).show() } } /** * Shows a retry dialog (main thread). Buttons styled using system colors. * - onRetry: invoked when user chooses to retry * - onCancel: invoked on cancel */ fun showRetryDialog( context: Context, title: String = "Consume Failed", messageText: String = getReadableError(), positiveLabel: String = "Retry", negativeLabel: String = "Cancel", onRetry: (() -> Unit)? = null, onCancel: (() -> Unit)? = null ) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle(title) .setMessage(messageText) .setCancelable(true) .setPositiveButton(positiveLabel) { dialog, _ -> onRetry?.invoke() dialog.dismiss() } .setNegativeButton(negativeLabel) { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.let { try { it.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) } catch (_: Exception) { /* ignore */ } } dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.let { try { it.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } catch (_: Exception) { /* ignore */ } } } dialog.show() } catch (e: Exception) { Log.w(TAG, "Dialog failed: ${e.message}") showFriendlyToast(context) } } } // =============================================================== // ⏱ Timing & Retry Utilities // =============================================================== // protected by 'synchronized' on methods that mutate them private var retryCount: Int = 0 private var firstFailureTime: Long = SystemClock.elapsedRealtime() @Synchronized fun incrementRetry() { retryCount++ } @Synchronized fun getRetryCount(): Int = retryCount @Synchronized fun resetRetryTracking() { retryCount = 0 firstFailureTime = SystemClock.elapsedRealtime() } fun timeSinceFirstFailure(): Long = (SystemClock.elapsedRealtime() - firstFailureTime) / 1000 fun getReadableElapsedTime(): String { val seconds = timeSinceFirstFailure() val minutes = seconds / 60 val remaining = seconds % 60 return if (minutes > 0) "${minutes}m ${remaining}s" else "${remaining}s" } // =============================================================== // 🩺 Recovery & Resilience Helpers // =============================================================== /** * Suggests a recovery action based on the cause. */ fun getRecoverySuggestion(): String { return when { reason?.contains("network", true) == true -> "Please ensure your device is connected to the internet." reason?.contains("token", true) == true -> "The purchase token may have expired. Requery or refresh the purchase list." reason?.contains("timeout", true) == true -> "Try increasing timeout or retrying later." else -> "Try restarting the Bazaar app and retry the operation." } } /** * Returns `true` if this failure is likely temporary and worth retrying. */ fun isTemporary(): Boolean { return reason?.contains("network", true) == true || reason?.contains("timeout", true) == true } /** * Returns `true` if this failure is likely permanent (e.g., invalid token). */ fun isPermanent(): Boolean { return reason?.contains("token", true) == true || reason?.contains("invalid", true) == true } /** * Suggests whether a retry should be attempted. */ fun shouldRetry(maxRetries: Int = 3): Boolean { return isTemporary() && getRetryCount() < maxRetries } // =============================================================== // 📊 Analytics & Summary // =============================================================== fun toAnalyticsBundle(): Map = mapOf( "exception" to "ConsumeFailedException", "product_id" to (productId ?: "unknown"), "reason" to (reason ?: "unknown"), "elapsed_seconds" to timeSinceFirstFailure().toString(), "timestamp" to timestamp.toString(), "retries" to getRetryCount().toString() ) fun compactSummary(): String = "ConsumeFailed(product=${productId ?: "?"}, reason=${reason ?: "unknown"}, retries=${getRetryCount()})" fun diagnosticReport(): String = buildString { appendLine("==== Consume Failure Diagnostic ====") appendLine(describe()) appendLine("Retry Count : ${getRetryCount()}") appendLine("Elapsed Time: ${getReadableElapsedTime()}") appendLine("Temporary : ${isTemporary()}") appendLine("Permanent : ${isPermanent()}") appendLine("Suggestion : ${getRecoverySuggestion()}") appendLine("====================================") } // =============================================================== // 🧰 Utility // =============================================================== /** * Converts this exception into a human-readable log block. */ fun prettyPrint(): String { return """ |🚨 ConsumeFailedException |Product ID : ${productId ?: "N/A"} |Reason : ${reason ?: "Unknown"} |Retries : ${getRetryCount()} |Temporary : ${isTemporary()} |Timestamp : $timestamp |Message : $message """.trimMargin() } /** * Emits this exception as a standardized debug message. */ fun debugLog(tag: String = TAG) { Log.w(tag, prettyPrint()) } // =============================================================== // 🔁 Async Retry Helper (suspend) with exponential backoff // =============================================================== /** * Attempts the provided suspend [action] repeatedly with exponential backoff until it returns true * or until [maxAttempts] is reached. Returns the success boolean and increments retry counter. * * Example usage: * ``` * val success = consumeFailedException.retryWithBackoff({ * // attempt retry logic (suspend) -> Boolean * }, maxAttempts = 4, baseDelayMs = 500L) * ``` */ suspend fun retryWithBackoff( action: suspend () -> Boolean, maxAttempts: Int = 4, baseDelayMs: Long = 500L ): Boolean { resetRetryTracking() repeat(maxAttempts) { attempt -> val ok = try { action() } catch (e: Exception) { false } if (ok) { return true } else { incrementRetry() // exponential backoff (2^attempt * baseDelay) val delayMs = baseDelayMs * (2.0.pow(attempt.toDouble())).toLong() delay(delayMs) } } return false } // =============================================================== // Helper: post to main looper safely // =============================================================== private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post { runnable() } } override fun toString(): String { return "ConsumeFailedException(productId=$productId, purchaseToken=$purchaseToken, reason=$reason, message=$message, timestamp=$timestamp)" } } --- .../exception/ConsumeFailedException.kt | 362 +++++++++++++++++- 1 file changed, 360 insertions(+), 2 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ConsumeFailedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ConsumeFailedException.kt index b275d2f..e423de7 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ConsumeFailedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ConsumeFailedException.kt @@ -1,10 +1,368 @@ package ir.cafebazaar.poolakey.exception +import android.app.AlertDialog +import android.content.Context +import android.os.Handler +import android.os.Looper import android.os.RemoteException +import android.os.SystemClock +import android.util.Log +import android.widget.Toast +import androidx.core.content.ContextCompat +import kotlinx.coroutines.delay +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* +import kotlin.math.pow -class ConsumeFailedException : RemoteException() { +/** + * Thrown when a "consume" request to Bazaar fails, + * typically due to a network, billing, or remote service issue. + */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { override val message: String? - get() = "Consume request failed: It's from Bazaar" + get() = reason ?: "Consume request failed: It's from Bazaar" + companion object { + private const val TAG = "ConsumeFailedException" + + /** + * Creates a preconfigured exception for a known common cause. + */ + fun fromNetworkError(): ConsumeFailedException { + return ConsumeFailedException(reason = "Network connection error") + } + + fun fromInvalidToken(token: String): ConsumeFailedException { + return ConsumeFailedException( + purchaseToken = token, + reason = "Invalid or expired purchase token" + ) + } + + fun fromTimeout(): ConsumeFailedException { + return ConsumeFailedException(reason = "Consume request timed out") + } + + fun fromUnknownError(): ConsumeFailedException { + return ConsumeFailedException(reason = "Unknown internal error") + } + } + + // =============================================================== + // 📋 Diagnostic & Logging Tools + // =============================================================== + + fun describe(): String { + val date = SimpleDateFormat("yyyy-MM-dd HH:mm:ss", Locale.getDefault()) + .format(Date(timestamp)) + return buildString { + appendLine("⚠️ ConsumeFailedException") + appendLine("Time : $date") + appendLine("Product ID : ${productId ?: "Unknown"}") + appendLine("Token : ${purchaseToken ?: "Unknown"}") + appendLine("Reason : ${reason ?: "Unknown failure"}") + appendLine("Message : ${message}") + } + } + + fun log(tag: String = TAG, detailed: Boolean = false) { + Log.e(tag, "❌ ConsumeFailedException: ${message}") + if (detailed) { + Log.e(tag, describe()) + } else { + if (productId != null) Log.e(tag, "• Product ID: $productId") + if (purchaseToken != null) Log.e(tag, "• Token: $purchaseToken") + Log.e(tag, "• Timestamp: $timestamp") + } + } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { + put("error", "ConsumeFailedException") + put("message", message) + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + put("elapsedSeconds", timeSinceFirstFailure()) + } + return if (pretty) obj.toString(2) else obj.toString() + } + + fun toMap(): Map = mapOf( + "type" to "ConsumeFailedException", + "message" to message, + "productId" to productId, + "purchaseToken" to purchaseToken, + "reason" to reason, + "timestamp" to timestamp, + "retryCount" to retryCount, + "elapsedSeconds" to timeSinceFirstFailure() + ) + + // =============================================================== + // 🎨 User Feedback Helpers + // =============================================================== + + /** + * Safely show a toast on main thread. + */ + fun showAsToast(context: Context, duration: Int = Toast.LENGTH_LONG) { + val msg = message ?: "Consume operation failed." + runOnMain { + Toast.makeText(context.applicationContext, msg, duration).show() + } + } + + fun getReadableError(): String { + return when { + reason?.contains("network", true) == true -> + "Network issue detected. Please check your connection." + reason?.contains("timeout", true) == true -> + "The operation took too long. Please try again." + reason?.contains("token", true) == true -> + "Purchase token invalid. Please refresh your purchase list." + else -> "Unable to complete purchase consumption. Try again later." + } + } + + fun showFriendlyToast(context: Context, duration: Int = Toast.LENGTH_LONG) { + runOnMain { + Toast.makeText(context.applicationContext, getReadableError(), duration).show() + } + } + + /** + * Shows a retry dialog (main thread). Buttons styled using system colors. + * - onRetry: invoked when user chooses to retry + * - onCancel: invoked on cancel + */ + fun showRetryDialog( + context: Context, + title: String = "Consume Failed", + messageText: String = getReadableError(), + positiveLabel: String = "Retry", + negativeLabel: String = "Cancel", + onRetry: (() -> Unit)? = null, + onCancel: (() -> Unit)? = null + ) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle(title) + .setMessage(messageText) + .setCancelable(true) + .setPositiveButton(positiveLabel) { dialog, _ -> + onRetry?.invoke() + dialog.dismiss() + } + .setNegativeButton(negativeLabel) { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.let { + try { + it.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + } catch (_: Exception) { /* ignore */ } + } + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.let { + try { + it.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } catch (_: Exception) { /* ignore */ } + } + } + dialog.show() + } catch (e: Exception) { + Log.w(TAG, "Dialog failed: ${e.message}") + showFriendlyToast(context) + } + } + } + + // =============================================================== + // ⏱ Timing & Retry Utilities + // =============================================================== + + // protected by 'synchronized' on methods that mutate them + private var retryCount: Int = 0 + private var firstFailureTime: Long = SystemClock.elapsedRealtime() + + @Synchronized + fun incrementRetry() { + retryCount++ + } + + @Synchronized + fun getRetryCount(): Int = retryCount + + @Synchronized + fun resetRetryTracking() { + retryCount = 0 + firstFailureTime = SystemClock.elapsedRealtime() + } + + fun timeSinceFirstFailure(): Long = + (SystemClock.elapsedRealtime() - firstFailureTime) / 1000 + + fun getReadableElapsedTime(): String { + val seconds = timeSinceFirstFailure() + val minutes = seconds / 60 + val remaining = seconds % 60 + return if (minutes > 0) "${minutes}m ${remaining}s" else "${remaining}s" + } + + // =============================================================== + // 🩺 Recovery & Resilience Helpers + // =============================================================== + + /** + * Suggests a recovery action based on the cause. + */ + fun getRecoverySuggestion(): String { + return when { + reason?.contains("network", true) == true -> + "Please ensure your device is connected to the internet." + reason?.contains("token", true) == true -> + "The purchase token may have expired. Requery or refresh the purchase list." + reason?.contains("timeout", true) == true -> + "Try increasing timeout or retrying later." + else -> "Try restarting the Bazaar app and retry the operation." + } + } + + /** + * Returns `true` if this failure is likely temporary and worth retrying. + */ + fun isTemporary(): Boolean { + return reason?.contains("network", true) == true || + reason?.contains("timeout", true) == true + } + + /** + * Returns `true` if this failure is likely permanent (e.g., invalid token). + */ + fun isPermanent(): Boolean { + return reason?.contains("token", true) == true || + reason?.contains("invalid", true) == true + } + + /** + * Suggests whether a retry should be attempted. + */ + fun shouldRetry(maxRetries: Int = 3): Boolean { + return isTemporary() && getRetryCount() < maxRetries + } + + // =============================================================== + // 📊 Analytics & Summary + // =============================================================== + + fun toAnalyticsBundle(): Map = mapOf( + "exception" to "ConsumeFailedException", + "product_id" to (productId ?: "unknown"), + "reason" to (reason ?: "unknown"), + "elapsed_seconds" to timeSinceFirstFailure().toString(), + "timestamp" to timestamp.toString(), + "retries" to getRetryCount().toString() + ) + + fun compactSummary(): String = + "ConsumeFailed(product=${productId ?: "?"}, reason=${reason ?: "unknown"}, retries=${getRetryCount()})" + + fun diagnosticReport(): String = buildString { + appendLine("==== Consume Failure Diagnostic ====") + appendLine(describe()) + appendLine("Retry Count : ${getRetryCount()}") + appendLine("Elapsed Time: ${getReadableElapsedTime()}") + appendLine("Temporary : ${isTemporary()}") + appendLine("Permanent : ${isPermanent()}") + appendLine("Suggestion : ${getRecoverySuggestion()}") + appendLine("====================================") + } + + // =============================================================== + // 🧰 Utility + // =============================================================== + + /** + * Converts this exception into a human-readable log block. + */ + fun prettyPrint(): String { + return """ + |🚨 ConsumeFailedException + |Product ID : ${productId ?: "N/A"} + |Reason : ${reason ?: "Unknown"} + |Retries : ${getRetryCount()} + |Temporary : ${isTemporary()} + |Timestamp : $timestamp + |Message : $message + """.trimMargin() + } + + /** + * Emits this exception as a standardized debug message. + */ + fun debugLog(tag: String = TAG) { + Log.w(tag, prettyPrint()) + } + + // =============================================================== + // 🔁 Async Retry Helper (suspend) with exponential backoff + // =============================================================== + /** + * Attempts the provided suspend [action] repeatedly with exponential backoff until it returns true + * or until [maxAttempts] is reached. Returns the success boolean and increments retry counter. + * + * Example usage: + * ``` + * val success = consumeFailedException.retryWithBackoff({ + * // attempt retry logic (suspend) -> Boolean + * }, maxAttempts = 4, baseDelayMs = 500L) + * ``` + */ + suspend fun retryWithBackoff( + action: suspend () -> Boolean, + maxAttempts: Int = 4, + baseDelayMs: Long = 500L + ): Boolean { + resetRetryTracking() + repeat(maxAttempts) { attempt -> + val ok = try { + action() + } catch (e: Exception) { + false + } + if (ok) { + return true + } else { + incrementRetry() + // exponential backoff (2^attempt * baseDelay) + val delayMs = baseDelayMs * (2.0.pow(attempt.toDouble())).toLong() + delay(delayMs) + } + } + return false + } + + // =============================================================== + // Helper: post to main looper safely + // =============================================================== + private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post { runnable() } + } + + override fun toString(): String { + return "ConsumeFailedException(productId=$productId, purchaseToken=$purchaseToken, reason=$reason, message=$message, timestamp=$timestamp)" + } } From 9d51e728749a34bbe84916cd76a1a14f8ff5304c Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:05:41 +0330 Subject: [PATCH 26/37] Update DisconnectException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.content.pm.PackageManager import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.net.Uri import android.os.Build import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.util.Log import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* /* =============================================================== 1️⃣ BazaarNotSupportedException =============================================================== */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String? get() = "Bazaar is not updated" companion object { private const val TAG = "BazaarNotSupportedException" private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" fun getUpdateUri(): Uri = Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") fun getWebUpdateUri(): Uri = Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") fun isBazaarInstalled(context: Context): Boolean = try { context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0); true } catch (e: Exception) { false } fun getInstalledVersionCode(context: Context): Long? = try { val info = context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0) if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong() } catch (e: Exception) { null } fun isVersionSupported(context: Context, requiredVersion: Int): Boolean { val current = getInstalledVersionCode(context) ?: return false return current >= requiredVersion } } fun showAsToast(context: Context) { runOnMain { Toast.makeText(context, "⚠️ ${getRecoveryHint()}", Toast.LENGTH_LONG).show() } } fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { val uiAction = { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Update Required") .setMessage("Your Bazaar app is outdated.\n\n${getRecoveryHint()}") .setCancelable(false) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context); onUpdate?.invoke(); dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke(); dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { Log.w(TAG, "Dialog failed: ${e.message}") showAsToast(context) } } runOnMain(uiAction) } fun openBazaarUpdatePage(context: Context) { try { val intent = Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } catch (e: ActivityNotFoundException) { try { val webIntent = Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(webIntent) } catch (ex: Exception) { Log.e(TAG, "Failed to open web update page: ${ex.message}") showAsToast(context) } } } fun getRecoveryHint(): String = recoveryHint ?: "Please update Bazaar to continue." fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "BazaarNotSupportedException") put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("timestamp", timestamp) put("hint", recoveryHint) } return if (pretty) obj.toString(2) else obj.toString() } fun toMap(): Map = mapOf( "type" to "BazaarNotSupportedException", "package" to packageName, "requiredVersion" to requiredVersion, "currentVersion" to currentVersion, "timestamp" to timestamp, "hint" to recoveryHint ) } /* =============================================================== 2️⃣ ConsumeFailedException =============================================================== */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String? get() = reason ?: "Consume request failed: It's from Bazaar" companion object { fun fromNetworkError() = ConsumeFailedException(reason = "Network connection error") fun fromInvalidToken(token: String) = ConsumeFailedException(purchaseToken = token, reason = "Invalid or expired purchase token") fun fromTimeout() = ConsumeFailedException(reason = "Consume request timed out") fun fromUnknownError() = ConsumeFailedException(reason = "Unknown internal error") } private var retryCount: Int = 0 private var firstFailureTime: Long = SystemClock.elapsedRealtime() fun incrementRetry() { retryCount++ } fun getRetryCount(): Int = retryCount fun resetRetryTracking() { retryCount = 0; firstFailureTime = SystemClock.elapsedRealtime() } fun isTemporary(): Boolean = reason?.contains("network", true) == true || reason?.contains("timeout", true) == true fun isPermanent(): Boolean = reason?.contains("token", true) == true || reason?.contains("invalid", true) == true fun shouldRetry(maxRetries: Int = 3): Boolean = isTemporary() && retryCount < maxRetries fun getRecoverySuggestion(): String = when { reason?.contains("network", true) == true -> "Check your internet connection." reason?.contains("token", true) == true -> "Purchase token may have expired." reason?.contains("timeout", true) == true -> "Try increasing timeout or retrying later." else -> "Restart Bazaar and retry the operation." } fun showFriendlyToast(context: Context) { runOnMain { Toast.makeText(context, "⚠️ ${getRecoverySuggestion()}", Toast.LENGTH_LONG).show() } } private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "ConsumeFailedException") put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) put("elapsedSeconds", (SystemClock.elapsedRealtime() - firstFailureTime)/1000) } return if (pretty) obj.toString(2) else obj.toString() } fun toMap(): Map = mapOf( "type" to "ConsumeFailedException", "productId" to productId, "purchaseToken" to purchaseToken, "reason" to reason, "timestamp" to timestamp, "retryCount" to retryCount ) } /* =============================================================== 3️⃣ DisconnectException =============================================================== */ class DisconnectException : IllegalStateException() { override val message: String? get() = "We can't communicate with Bazaar: Service is disconnected" fun showAsToast(context: Context) { Toast.makeText(context, "⚠️ $message", Toast.LENGTH_LONG).show() } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "DisconnectException"); put("message", message) } return if (pretty) obj.toString(2) else obj.toString() } fun toMap(): Map = mapOf("type" to "DisconnectException", "message" to message) } --- .../poolakey/exception/DisconnectException.kt | 203 +++++++++++++++++- 1 file changed, 201 insertions(+), 2 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DisconnectException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DisconnectException.kt index d1f8850..c3c6792 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DisconnectException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DisconnectException.kt @@ -1,8 +1,207 @@ package ir.cafebazaar.poolakey.exception -class DisconnectException : IllegalStateException() { +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.content.pm.PackageManager +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.net.Uri +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.os.SystemClock +import android.util.Log +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/* =============================================================== + 1️⃣ BazaarNotSupportedException + =============================================================== */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { override val message: String? - get() = "We can't communicate with Bazaar: Service is disconnected" + get() = "Bazaar is not updated" + + companion object { + private const val TAG = "BazaarNotSupportedException" + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + + fun getUpdateUri(): Uri = Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + fun getWebUpdateUri(): Uri = Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + + fun isBazaarInstalled(context: Context): Boolean = + try { context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0); true } catch (e: Exception) { false } + + fun getInstalledVersionCode(context: Context): Long? = + try { + val info = context.packageManager.getPackageInfo(BAZAAR_PACKAGE, 0) + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.P) info.longVersionCode else @Suppress("DEPRECATION") info.versionCode.toLong() + } catch (e: Exception) { null } + + fun isVersionSupported(context: Context, requiredVersion: Int): Boolean { + val current = getInstalledVersionCode(context) ?: return false + return current >= requiredVersion + } + } + + fun showAsToast(context: Context) { + runOnMain { Toast.makeText(context, "⚠️ ${getRecoveryHint()}", Toast.LENGTH_LONG).show() } + } + + fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + val uiAction = { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Update Required") + .setMessage("Your Bazaar app is outdated.\n\n${getRecoveryHint()}") + .setCancelable(false) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context); onUpdate?.invoke(); dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke(); dialog.dismiss() } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + Log.w(TAG, "Dialog failed: ${e.message}") + showAsToast(context) + } + } + runOnMain(uiAction) + } + + fun openBazaarUpdatePage(context: Context) { + try { + val intent = Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + try { + val webIntent = Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(webIntent) + } catch (ex: Exception) { + Log.e(TAG, "Failed to open web update page: ${ex.message}") + showAsToast(context) + } + } + } + + fun getRecoveryHint(): String = recoveryHint ?: "Please update Bazaar to continue." + fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("timestamp", timestamp) + put("hint", recoveryHint) + } + return if (pretty) obj.toString(2) else obj.toString() + } + + fun toMap(): Map = mapOf( + "type" to "BazaarNotSupportedException", + "package" to packageName, + "requiredVersion" to requiredVersion, + "currentVersion" to currentVersion, + "timestamp" to timestamp, + "hint" to recoveryHint + ) +} + +/* =============================================================== + 2️⃣ ConsumeFailedException + =============================================================== */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { + + override val message: String? get() = reason ?: "Consume request failed: It's from Bazaar" + + companion object { + fun fromNetworkError() = ConsumeFailedException(reason = "Network connection error") + fun fromInvalidToken(token: String) = ConsumeFailedException(purchaseToken = token, reason = "Invalid or expired purchase token") + fun fromTimeout() = ConsumeFailedException(reason = "Consume request timed out") + fun fromUnknownError() = ConsumeFailedException(reason = "Unknown internal error") + } + + private var retryCount: Int = 0 + private var firstFailureTime: Long = SystemClock.elapsedRealtime() + + fun incrementRetry() { retryCount++ } + fun getRetryCount(): Int = retryCount + fun resetRetryTracking() { retryCount = 0; firstFailureTime = SystemClock.elapsedRealtime() } + + fun isTemporary(): Boolean = reason?.contains("network", true) == true || reason?.contains("timeout", true) == true + fun isPermanent(): Boolean = reason?.contains("token", true) == true || reason?.contains("invalid", true) == true + fun shouldRetry(maxRetries: Int = 3): Boolean = isTemporary() && retryCount < maxRetries + + fun getRecoverySuggestion(): String = when { + reason?.contains("network", true) == true -> "Check your internet connection." + reason?.contains("token", true) == true -> "Purchase token may have expired." + reason?.contains("timeout", true) == true -> "Try increasing timeout or retrying later." + else -> "Restart Bazaar and retry the operation." + } + + fun showFriendlyToast(context: Context) { runOnMain { Toast.makeText(context, "⚠️ ${getRecoverySuggestion()}", Toast.LENGTH_LONG).show() } } + private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { + put("error", "ConsumeFailedException") + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + put("elapsedSeconds", (SystemClock.elapsedRealtime() - firstFailureTime)/1000) + } + return if (pretty) obj.toString(2) else obj.toString() + } + + fun toMap(): Map = mapOf( + "type" to "ConsumeFailedException", + "productId" to productId, + "purchaseToken" to purchaseToken, + "reason" to reason, + "timestamp" to timestamp, + "retryCount" to retryCount + ) +} + +/* =============================================================== + 3️⃣ DisconnectException + =============================================================== */ +class DisconnectException : IllegalStateException() { + + override val message: String? get() = "We can't communicate with Bazaar: Service is disconnected" + + fun showAsToast(context: Context) { Toast.makeText(context, "⚠️ $message", Toast.LENGTH_LONG).show() } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { put("error", "DisconnectException"); put("message", message) } + return if (pretty) obj.toString(2) else obj.toString() + } + fun toMap(): Map = mapOf("type" to "DisconnectException", "message" to message) } From 42005c5d5c92cfacc5aa7cc3bfecf030369dff32 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:14:37 +0330 Subject: [PATCH 27/37] Update DynamicPriceNotSupportedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.text.SimpleDateFormat import java.util.* /* =============================================================== 1️⃣ BazaarNotSupportedException =============================================================== */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String get() = "Bazaar is not updated" companion object { private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" fun getUpdateUri(): android.net.Uri = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") fun getWebUpdateUri(): android.net.Uri = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") } fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion fun elapsedSinceThrown(): Long = (System.currentTimeMillis() - timestamp) / 1000 fun getReadableElapsedTime(): String { val seconds = elapsedSinceThrown() val minutes = seconds / 60 val remaining = seconds % 60 return if (minutes > 0) "${minutes}m ${remaining}s ago" else "${remaining}s ago" } fun hasInternetConnection(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } fun showAsToast(context: Context) { runOnMain { Toast.makeText(context, getRecoveryHint(), Toast.LENGTH_LONG).show() } } fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Update Required") .setMessage(getRecoveryHint()) .setCancelable(false) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context) onUpdate?.invoke() dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { showAsToast(context) } } } fun openBazaarUpdatePage(context: Context) { try { val intent = Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(intent) } catch (e: ActivityNotFoundException) { try { val webIntent = Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } context.startActivity(webIntent) } catch (_: Exception) { showAsToast(context) } } } fun getRecoveryHint(): String = recoveryHint ?: "Please update Bazaar to continue." fun diagnosticReport(context: Context): String = buildString { appendLine("=== BazaarNotSupportedException Diagnostic ===") appendLine("Package : $packageName") appendLine("Required Version: ${requiredVersion ?: "Unknown"}") appendLine("Current Version : ${currentVersion ?: "Unknown"}") appendLine("Hint : ${getRecoveryHint()}") appendLine("Elapsed Time : ${getReadableElapsedTime()}") appendLine("Internet : ${if (hasInternetConnection(context)) "Available" else "Unavailable"}") appendLine("==============================================") } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "BazaarNotSupportedException") put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("timestamp", timestamp) put("hint", getRecoveryHint()) } return if (pretty) obj.toString(2) else obj.toString() } fun toMap(): Map = mapOf( "type" to "BazaarNotSupportedException", "package" to packageName, "requiredVersion" to requiredVersion, "currentVersion" to currentVersion, "timestamp" to timestamp, "hint" to getRecoveryHint() ) private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } } /* =============================================================== 2️⃣ ConsumeFailedException =============================================================== */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String get() = reason ?: "Consume request failed: It's from Bazaar" private var retryCount: Int = 0 private var firstFailureTime: Long = SystemClock.elapsedRealtime() fun incrementRetry() { retryCount++ } fun getRetryCount(): Int = retryCount fun resetRetryTracking() { retryCount = 0; firstFailureTime = SystemClock.elapsedRealtime() } fun timeSinceFirstFailureSeconds(): Long = (SystemClock.elapsedRealtime() - firstFailureTime) / 1000 fun getReadableTimeSinceFailure(): String { val seconds = timeSinceFirstFailureSeconds() val minutes = seconds / 60 val remaining = seconds % 60 return if (minutes > 0) "${minutes}m ${remaining}s ago" else "${remaining}s ago" } fun isTemporary(): Boolean = reason?.contains("network", true) == true || reason?.contains("timeout", true) == true fun isPermanent(): Boolean = reason?.contains("token", true) == true || reason?.contains("invalid", true) == true fun isRetryable(maxRetries: Int = 3): Boolean = isTemporary() && retryCount < maxRetries fun getRecoverySuggestion(): String = when { reason?.contains("network", true) == true -> "Check your internet connection." reason?.contains("token", true) == true -> "Purchase token may have expired." reason?.contains("timeout", true) == true -> "Try increasing timeout or retrying later." else -> "Restart Bazaar and retry the operation." } fun showFriendlyToast(context: Context) { runOnMain { Toast.makeText(context, getRecoverySuggestion(), Toast.LENGTH_LONG).show() } } fun fullDiagnosticReport(): String = buildString { appendLine("=== ConsumeFailedException Diagnostic ===") appendLine("Product ID : ${productId ?: "Unknown"}") appendLine("Purchase Token : ${purchaseToken ?: "Unknown"}") appendLine("Reason : ${reason ?: "Unknown"}") appendLine("Retry Count : $retryCount") appendLine("Elapsed Time : ${getReadableTimeSinceFailure()}") appendLine("Temporary : ${isTemporary()}") appendLine("Permanent : ${isPermanent()}") appendLine("Recovery Hint : ${getRecoverySuggestion()}") appendLine("=========================================") } fun toJson(pretty: Boolean = true): String { val obj = JSONObject().apply { put("error", "ConsumeFailedException") put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) put("elapsedSeconds", timeSinceFirstFailureSeconds()) } return if (pretty) obj.toString(2) else obj.toString() } fun toMap(): Map = mapOf( "type" to "ConsumeFailedException", "productId" to productId, "purchaseToken" to purchaseToken, "reason" to reason, "timestamp" to timestamp, "retryCount" to retryCount ) private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } companion object { fun fromNetworkError() = ConsumeFailedException(reason = "Network connection error") fun fromInvalidToken(token: String) = ConsumeFailedException(purchaseToken = token, reason = "Invalid or expired purchase token") fun fromTimeout() = ConsumeFailedException(reason = "Consume request timed out") fun fromUnknownError() = ConsumeFailedException(reason = "Unknown internal error") } } /* =============================================================== 3️⃣ DisconnectException =============================================================== */ class DisconnectException : IllegalStateException() { override val message: String get() = "We can't communicate with Bazaar: Service is disconnected" fun showAsToast(context: Context) { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Service Disconnected") .setMessage(message) .setCancelable(false) .setPositiveButton("Retry 🔄") { dialog, _ -> onRetry?.invoke() dialog.dismiss() } .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_blue_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { showAsToast(context) } } } fun isRecoverable(): Boolean = true fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "DisconnectException") put("message", message) }.let { if (pretty) it.toString(2) else it.toString() } fun toMap(): Map = mapOf("type" to "DisconnectException", "message" to message) fun toAnalyticsMap(): Map = mapOf("type" to "DisconnectException", "message" to message, "timestamp" to System.currentTimeMillis().toString()) private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } } /* =============================================================== 4️⃣ DynamicPriceNotSupportedException =============================================================== */ class DynamicPriceNotSupportedException : IllegalStateException() { override val message: String get() = "Dynamic price not supported" fun showDialog(context: Context) { runOnMain { try { AlertDialog.Builder(context) .setTitle("⚠️ Unsupported Feature") .setMessage(message) .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .show() } catch (e: Exception) { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } } fun toAnalyticsMap(): Map = mapOf("type" to "DynamicPriceNotSupportedException", "message" to message, "timestamp" to System.currentTimeMillis().toString()) private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } } --- .../DynamicPriceNotSupportedException.kt | 309 ++++++++++++++++++ 1 file changed, 309 insertions(+) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DynamicPriceNotSupportedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DynamicPriceNotSupportedException.kt index 7287740..ce5898e 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DynamicPriceNotSupportedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/DynamicPriceNotSupportedException.kt @@ -1,8 +1,317 @@ package ir.cafebazaar.poolakey.exception +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.os.SystemClock +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +/* =============================================================== + 1️⃣ BazaarNotSupportedException + =============================================================== */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { + + override val message: String + get() = "Bazaar is not updated" + + companion object { + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + + fun getUpdateUri(): android.net.Uri = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + fun getWebUpdateUri(): android.net.Uri = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + } + + fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion + + fun elapsedSinceThrown(): Long = (System.currentTimeMillis() - timestamp) / 1000 + + fun getReadableElapsedTime(): String { + val seconds = elapsedSinceThrown() + val minutes = seconds / 60 + val remaining = seconds % 60 + return if (minutes > 0) "${minutes}m ${remaining}s ago" else "${remaining}s ago" + } + + fun hasInternetConnection(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun showAsToast(context: Context) { + runOnMain { Toast.makeText(context, getRecoveryHint(), Toast.LENGTH_LONG).show() } + } + + fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Update Required") + .setMessage(getRecoveryHint()) + .setCancelable(false) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context) + onUpdate?.invoke() + dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + showAsToast(context) + } + } + } + + fun openBazaarUpdatePage(context: Context) { + try { + val intent = Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(intent) + } catch (e: ActivityNotFoundException) { + try { + val webIntent = Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) } + context.startActivity(webIntent) + } catch (_: Exception) { + showAsToast(context) + } + } + } + + fun getRecoveryHint(): String = recoveryHint ?: "Please update Bazaar to continue." + + fun diagnosticReport(context: Context): String = buildString { + appendLine("=== BazaarNotSupportedException Diagnostic ===") + appendLine("Package : $packageName") + appendLine("Required Version: ${requiredVersion ?: "Unknown"}") + appendLine("Current Version : ${currentVersion ?: "Unknown"}") + appendLine("Hint : ${getRecoveryHint()}") + appendLine("Elapsed Time : ${getReadableElapsedTime()}") + appendLine("Internet : ${if (hasInternetConnection(context)) "Available" else "Unavailable"}") + appendLine("==============================================") + } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("timestamp", timestamp) + put("hint", getRecoveryHint()) + } + return if (pretty) obj.toString(2) else obj.toString() + } + + fun toMap(): Map = mapOf( + "type" to "BazaarNotSupportedException", + "package" to packageName, + "requiredVersion" to requiredVersion, + "currentVersion" to currentVersion, + "timestamp" to timestamp, + "hint" to getRecoveryHint() + ) + + private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) + } +} + +/* =============================================================== + 2️⃣ ConsumeFailedException + =============================================================== */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { + + override val message: String + get() = reason ?: "Consume request failed: It's from Bazaar" + + private var retryCount: Int = 0 + private var firstFailureTime: Long = SystemClock.elapsedRealtime() + + fun incrementRetry() { retryCount++ } + fun getRetryCount(): Int = retryCount + fun resetRetryTracking() { retryCount = 0; firstFailureTime = SystemClock.elapsedRealtime() } + + fun timeSinceFirstFailureSeconds(): Long = (SystemClock.elapsedRealtime() - firstFailureTime) / 1000 + + fun getReadableTimeSinceFailure(): String { + val seconds = timeSinceFirstFailureSeconds() + val minutes = seconds / 60 + val remaining = seconds % 60 + return if (minutes > 0) "${minutes}m ${remaining}s ago" else "${remaining}s ago" + } + + fun isTemporary(): Boolean = reason?.contains("network", true) == true || reason?.contains("timeout", true) == true + fun isPermanent(): Boolean = reason?.contains("token", true) == true || reason?.contains("invalid", true) == true + fun isRetryable(maxRetries: Int = 3): Boolean = isTemporary() && retryCount < maxRetries + + fun getRecoverySuggestion(): String = when { + reason?.contains("network", true) == true -> "Check your internet connection." + reason?.contains("token", true) == true -> "Purchase token may have expired." + reason?.contains("timeout", true) == true -> "Try increasing timeout or retrying later." + else -> "Restart Bazaar and retry the operation." + } + + fun showFriendlyToast(context: Context) { + runOnMain { Toast.makeText(context, getRecoverySuggestion(), Toast.LENGTH_LONG).show() } + } + + fun fullDiagnosticReport(): String = buildString { + appendLine("=== ConsumeFailedException Diagnostic ===") + appendLine("Product ID : ${productId ?: "Unknown"}") + appendLine("Purchase Token : ${purchaseToken ?: "Unknown"}") + appendLine("Reason : ${reason ?: "Unknown"}") + appendLine("Retry Count : $retryCount") + appendLine("Elapsed Time : ${getReadableTimeSinceFailure()}") + appendLine("Temporary : ${isTemporary()}") + appendLine("Permanent : ${isPermanent()}") + appendLine("Recovery Hint : ${getRecoverySuggestion()}") + appendLine("=========================================") + } + + fun toJson(pretty: Boolean = true): String { + val obj = JSONObject().apply { + put("error", "ConsumeFailedException") + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + put("elapsedSeconds", timeSinceFirstFailureSeconds()) + } + return if (pretty) obj.toString(2) else obj.toString() + } + + fun toMap(): Map = mapOf( + "type" to "ConsumeFailedException", + "productId" to productId, + "purchaseToken" to purchaseToken, + "reason" to reason, + "timestamp" to timestamp, + "retryCount" to retryCount + ) + + private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) + } + + companion object { + fun fromNetworkError() = ConsumeFailedException(reason = "Network connection error") + fun fromInvalidToken(token: String) = ConsumeFailedException(purchaseToken = token, reason = "Invalid or expired purchase token") + fun fromTimeout() = ConsumeFailedException(reason = "Consume request timed out") + fun fromUnknownError() = ConsumeFailedException(reason = "Unknown internal error") + } +} + +/* =============================================================== + 3️⃣ DisconnectException + =============================================================== */ +class DisconnectException : IllegalStateException() { + + override val message: String + get() = "We can't communicate with Bazaar: Service is disconnected" + + fun showAsToast(context: Context) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + + fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Service Disconnected") + .setMessage(message) + .setCancelable(false) + .setPositiveButton("Retry 🔄") { dialog, _ -> + onRetry?.invoke() + dialog.dismiss() + } + .setNegativeButton("Dismiss ❌") { dialog, _ -> + dialog.dismiss() + } + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_blue_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + showAsToast(context) + } + } + } + + fun isRecoverable(): Boolean = true + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "DisconnectException") + put("message", message) + }.let { if (pretty) it.toString(2) else it.toString() } + + fun toMap(): Map = mapOf("type" to "DisconnectException", "message" to message) + + fun toAnalyticsMap(): Map = mapOf("type" to "DisconnectException", "message" to message, "timestamp" to System.currentTimeMillis().toString()) + + private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) + } +} + +/* =============================================================== + 4️⃣ DynamicPriceNotSupportedException + =============================================================== */ class DynamicPriceNotSupportedException : IllegalStateException() { override val message: String get() = "Dynamic price not supported" + fun showDialog(context: Context) { + runOnMain { + try { + AlertDialog.Builder(context) + .setTitle("⚠️ Unsupported Feature") + .setMessage(message) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .show() + } catch (e: Exception) { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } + } + } + + fun toAnalyticsMap(): Map = mapOf("type" to "DynamicPriceNotSupportedException", "message" to message, "timestamp" to System.currentTimeMillis().toString()) + + private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) + } } From 9820aa5c60a2b0c64c270958d1ca47bedc472944 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:23:30 +0330 Subject: [PATCH 28/37] Update IAPNotSupportedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } /* =============================================================== 1️⃣ BazaarNotSupportedException =============================================================== */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String get() = "Bazaar is not updated" companion object { private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" fun getUpdateUri(): android.net.Uri = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") fun getWebUpdateUri(): android.net.Uri = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") } fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion fun hasInternetConnection(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Update Required") .setMessage(recoveryHint) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context) onUpdate?.invoke() dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { showAsToast(context) } } } fun openBazaarUpdatePage(context: Context) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (e: ActivityNotFoundException) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (_: Exception) { showAsToast(context) } } } fun showAsToast(context: Context) = runOnMain { Toast.makeText(context, recoveryHint, Toast.LENGTH_LONG).show() } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "BazaarNotSupportedException") put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("timestamp", timestamp) put("hint", recoveryHint) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 2️⃣ ConsumeFailedException =============================================================== */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String get() = reason ?: "Consume request failed" private var retryCount = 0 private var firstFailureTime = SystemClock.elapsedRealtime() fun incrementRetry() { retryCount++ } fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { if (isRetryable(maxRetries)) { incrementRetry() runOnMain { Handler().postDelayed({ operation() }, 2000) } // Non-blocking } } fun showFriendlyToast(context: Context) = runOnMain { Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() } fun recoverySuggestion(): String = when { reason?.contains("network", true) == true -> "Check your internet connection." reason?.contains("token", true) == true -> "Purchase token may have expired." reason?.contains("timeout", true) == true -> "Try again later." else -> "Restart Bazaar and retry." } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "ConsumeFailedException") put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 3️⃣ DisconnectException =============================================================== */ class DisconnectException : IllegalStateException() { override val message: String get() = "Bazaar service disconnected" fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Service Disconnected") .setMessage(message) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Retry 🔄") { dialog, _ -> onRetry?.invoke() dialog.dismiss() } .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } .show() } } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "DisconnectException") put("message", message) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 4️⃣ DynamicPriceNotSupportedException =============================================================== */ class DynamicPriceNotSupportedException : IllegalStateException() { override val message: String get() = "Dynamic pricing not supported" fun showDialog(context: Context) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Unsupported Feature") .setMessage(message) .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .setIcon(android.R.drawable.ic_dialog_info) .show() } } } /* =============================================================== 5️⃣ IAPNotSupportedException =============================================================== */ class IAPNotSupportedException : IllegalAccessException() { override val message: String? get() = "In-app billing not supported" fun notifyUser(context: Context) = runOnMain { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } --- .../exception/IAPNotSupportedException.kt | 200 +++++++++++++++++- 1 file changed, 197 insertions(+), 3 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/IAPNotSupportedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/IAPNotSupportedException.kt index 6ef622d..64d144e 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/IAPNotSupportedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/IAPNotSupportedException.kt @@ -1,8 +1,202 @@ package ir.cafebazaar.poolakey.exception -class IAPNotSupportedException : IllegalAccessException() { +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.os.SystemClock +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject + +private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) +} + +/* =============================================================== + 1️⃣ BazaarNotSupportedException + =============================================================== */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { + + override val message: String get() = "Bazaar is not updated" + + companion object { + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + fun getUpdateUri(): android.net.Uri = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + fun getWebUpdateUri(): android.net.Uri = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + } + + fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion + + fun hasInternetConnection(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Update Required") + .setMessage(recoveryHint) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context) + onUpdate?.invoke() + dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + showAsToast(context) + } + } + } + + fun openBazaarUpdatePage(context: Context) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (e: ActivityNotFoundException) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (_: Exception) { + showAsToast(context) + } + } + } + + fun showAsToast(context: Context) = runOnMain { Toast.makeText(context, recoveryHint, Toast.LENGTH_LONG).show() } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("timestamp", timestamp) + put("hint", recoveryHint) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 2️⃣ ConsumeFailedException + =============================================================== */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { + + override val message: String get() = reason ?: "Consume request failed" - override val message: String? - get() = "In app billing is not supported in this version of installed Bazaar" + private var retryCount = 0 + private var firstFailureTime = SystemClock.elapsedRealtime() + + fun incrementRetry() { retryCount++ } + fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries + + fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { + if (isRetryable(maxRetries)) { + incrementRetry() + runOnMain { Handler().postDelayed({ operation() }, 2000) } // Non-blocking + } + } + + fun showFriendlyToast(context: Context) = runOnMain { Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() } + + fun recoverySuggestion(): String = when { + reason?.contains("network", true) == true -> "Check your internet connection." + reason?.contains("token", true) == true -> "Purchase token may have expired." + reason?.contains("timeout", true) == true -> "Try again later." + else -> "Restart Bazaar and retry." + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "ConsumeFailedException") + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 3️⃣ DisconnectException + =============================================================== */ +class DisconnectException : IllegalStateException() { + + override val message: String get() = "Bazaar service disconnected" + + fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Service Disconnected") + .setMessage(message) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Retry 🔄") { dialog, _ -> + onRetry?.invoke() + dialog.dismiss() + } + .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } + .show() + } + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "DisconnectException") + put("message", message) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 4️⃣ DynamicPriceNotSupportedException + =============================================================== */ +class DynamicPriceNotSupportedException : IllegalStateException() { + override val message: String get() = "Dynamic pricing not supported" + + fun showDialog(context: Context) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Unsupported Feature") + .setMessage(message) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .setIcon(android.R.drawable.ic_dialog_info) + .show() + } + } +} + +/* =============================================================== + 5️⃣ IAPNotSupportedException + =============================================================== */ +class IAPNotSupportedException : IllegalAccessException() { + override val message: String? get() = "In-app billing not supported" + fun notifyUser(context: Context) = runOnMain { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } From f220bee3490f05caa87563c0c781297504e075fb Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:28:53 +0330 Subject: [PATCH 29/37] Update PurchaseHijackedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.lang.Exception // Centralized utility function to run code on the main thread private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } /* =============================================================== 1️⃣ BazaarNotSupportedException =============================================================== */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String get() = "Bazaar is not updated" companion object { private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" fun getUpdateUri() = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") fun getWebUpdateUri() = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") } fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion fun hasInternetConnection(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Update Required") .setMessage(recoveryHint) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context) onUpdate?.invoke() dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { showAsToast(context) } } } fun openBazaarUpdatePage(context: Context) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (e: ActivityNotFoundException) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (_: Exception) { showAsToast(context) } } } fun showAsToast(context: Context) = runOnMain { Toast.makeText(context, recoveryHint ?: "Update Bazaar to continue", Toast.LENGTH_LONG).show() } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "BazaarNotSupportedException") put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("timestamp", timestamp) put("hint", recoveryHint) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 2️⃣ ConsumeFailedException =============================================================== */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String get() = reason ?: "Consume request failed" private var retryCount = 0 private var firstFailureTime = SystemClock.elapsedRealtime() fun incrementRetry() { retryCount++ } fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { if (isRetryable(maxRetries)) { incrementRetry() runOnMain { Handler().postDelayed({ operation() }, 2000) } // Non-blocking retry } } fun showFriendlyToast(context: Context) = runOnMain { Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() } fun recoverySuggestion(): String = when { reason?.contains("network", true) == true -> "Check your internet connection." reason?.contains("token", true) == true -> "Purchase token may have expired." reason?.contains("timeout", true) == true -> "Try again later." else -> "Restart Bazaar and retry." } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "ConsumeFailedException") put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 3️⃣ DisconnectException =============================================================== */ class DisconnectException : IllegalStateException() { override val message: String get() = "Bazaar service disconnected" fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Service Disconnected") .setMessage(message) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Retry 🔄") { dialog, _ -> onRetry?.invoke() dialog.dismiss() } .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } .show() } } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "DisconnectException") put("message", message) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 4️⃣ DynamicPriceNotSupportedException =============================================================== */ class DynamicPriceNotSupportedException : IllegalStateException() { override val message: String get() = "Dynamic pricing not supported" fun showDialog(context: Context) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Unsupported Feature") .setMessage(message) .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .setIcon(android.R.drawable.ic_dialog_info) .show() } } } /* =============================================================== 5️⃣ IAPNotSupportedException =============================================================== */ class IAPNotSupportedException : IllegalAccessException() { override val message: String? get() = "In-app billing not supported" fun notifyUser(context: Context) = runOnMain { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } /* =============================================================== 6️⃣ PurchaseHijackedException =============================================================== */ class PurchaseHijackedException : Exception() { override val message: String? get() = "The purchase was hijacked and it's not a valid purchase" } --- .../exception/PurchaseHijackedException.kt | 205 +++++++++++++++++- 1 file changed, 202 insertions(+), 3 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/PurchaseHijackedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/PurchaseHijackedException.kt index 8916ede..cb747eb 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/PurchaseHijackedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/PurchaseHijackedException.kt @@ -1,10 +1,209 @@ package ir.cafebazaar.poolakey.exception +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.os.SystemClock +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject import java.lang.Exception -class PurchaseHijackedException : Exception() { +// Centralized utility function to run code on the main thread +private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) +} + +/* =============================================================== + 1️⃣ BazaarNotSupportedException + =============================================================== */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { + + override val message: String get() = "Bazaar is not updated" + + companion object { + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + fun getUpdateUri() = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + fun getWebUpdateUri() = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + } + + fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion + + fun hasInternetConnection(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Update Required") + .setMessage(recoveryHint) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context) + onUpdate?.invoke() + dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + showAsToast(context) + } + } + } + + fun openBazaarUpdatePage(context: Context) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (e: ActivityNotFoundException) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (_: Exception) { + showAsToast(context) + } + } + } + + fun showAsToast(context: Context) = runOnMain { Toast.makeText(context, recoveryHint ?: "Update Bazaar to continue", Toast.LENGTH_LONG).show() } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("timestamp", timestamp) + put("hint", recoveryHint) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 2️⃣ ConsumeFailedException + =============================================================== */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { + + override val message: String get() = reason ?: "Consume request failed" + + private var retryCount = 0 + private var firstFailureTime = SystemClock.elapsedRealtime() - override val message: String? - get() = "The purchase was hijacked and it's not a valid purchase" + fun incrementRetry() { retryCount++ } + fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries + fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { + if (isRetryable(maxRetries)) { + incrementRetry() + runOnMain { Handler().postDelayed({ operation() }, 2000) } // Non-blocking retry + } + } + + fun showFriendlyToast(context: Context) = runOnMain { Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() } + + fun recoverySuggestion(): String = when { + reason?.contains("network", true) == true -> "Check your internet connection." + reason?.contains("token", true) == true -> "Purchase token may have expired." + reason?.contains("timeout", true) == true -> "Try again later." + else -> "Restart Bazaar and retry." + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "ConsumeFailedException") + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 3️⃣ DisconnectException + =============================================================== */ +class DisconnectException : IllegalStateException() { + override val message: String get() = "Bazaar service disconnected" + + fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Service Disconnected") + .setMessage(message) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Retry 🔄") { dialog, _ -> + onRetry?.invoke() + dialog.dismiss() + } + .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } + .show() + } + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "DisconnectException") + put("message", message) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 4️⃣ DynamicPriceNotSupportedException + =============================================================== */ +class DynamicPriceNotSupportedException : IllegalStateException() { + override val message: String get() = "Dynamic pricing not supported" + + fun showDialog(context: Context) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Unsupported Feature") + .setMessage(message) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .setIcon(android.R.drawable.ic_dialog_info) + .show() + } + } +} + +/* =============================================================== + 5️⃣ IAPNotSupportedException + =============================================================== */ +class IAPNotSupportedException : IllegalAccessException() { + override val message: String? get() = "In-app billing not supported" + + fun notifyUser(context: Context) = runOnMain { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } +} + +/* =============================================================== + 6️⃣ PurchaseHijackedException + =============================================================== */ +class PurchaseHijackedException : Exception() { + override val message: String? get() = "The purchase was hijacked and it's not a valid purchase" } From 7d69d17e4cd2cea7e179ecf93ed1b4bafed94839 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:32:09 +0330 Subject: [PATCH 30/37] Update ResultNotOkayException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Build import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.lang.Exception // Run safely on main thread private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } /* =============================================================== 1️⃣ BazaarNotSupportedException =============================================================== */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String get() = "Bazaar is not updated" companion object { private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" fun getUpdateUri() = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") fun getWebUpdateUri() = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") } fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion fun hasInternetConnection(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Update Required") .setMessage(recoveryHint) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context) onUpdate?.invoke() dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { showAsToast(context) } } } fun openBazaarUpdatePage(context: Context) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (e: ActivityNotFoundException) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (_: Exception) { showAsToast(context) } } } fun showAsToast(context: Context) = runOnMain { Toast.makeText(context, recoveryHint ?: "Update Bazaar to continue", Toast.LENGTH_LONG).show() } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "BazaarNotSupportedException") put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("timestamp", timestamp) put("hint", recoveryHint) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 2️⃣ ConsumeFailedException =============================================================== */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String get() = reason ?: "Consume request failed" private var retryCount = 0 private var firstFailureTime = SystemClock.elapsedRealtime() fun incrementRetry() { retryCount++ } fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { if (isRetryable(maxRetries)) { incrementRetry() runOnMain { Handler().postDelayed({ operation() }, 2000) } } } fun showFriendlyToast(context: Context) = runOnMain { Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() } fun recoverySuggestion(): String = when { reason?.contains("network", true) == true -> "Check your internet connection." reason?.contains("token", true) == true -> "Purchase token may have expired." reason?.contains("timeout", true) == true -> "Try again later." else -> "Restart Bazaar and retry." } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "ConsumeFailedException") put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 3️⃣ DisconnectException =============================================================== */ class DisconnectException : IllegalStateException() { override val message: String get() = "Bazaar service disconnected" fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Service Disconnected") .setMessage(message) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Retry 🔄") { dialog, _ -> onRetry?.invoke() dialog.dismiss() } .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } .show() } } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "DisconnectException") put("message", message) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 4️⃣ DynamicPriceNotSupportedException =============================================================== */ class DynamicPriceNotSupportedException : IllegalStateException() { override val message: String get() = "Dynamic pricing not supported" fun showDialog(context: Context) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Unsupported Feature") .setMessage(message) .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .setIcon(android.R.drawable.ic_dialog_info) .show() } } } /* =============================================================== 5️⃣ IAPNotSupportedException =============================================================== */ class IAPNotSupportedException : IllegalAccessException() { override val message: String? get() = "In-app billing not supported" fun notifyUser(context: Context) = runOnMain { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } /* =============================================================== 6️⃣ PurchaseHijackedException =============================================================== */ class PurchaseHijackedException : Exception() { override val message: String? get() = "The purchase was hijacked and it's not a valid purchase" } /* =============================================================== 7️⃣ ResultNotOkayException =============================================================== */ class ResultNotOkayException : IllegalStateException() { override val message: String? get() = "Failed to receive response from Bazaar" } --- .../exception/ResultNotOkayException.kt | 222 +++++++++++++++++- 1 file changed, 219 insertions(+), 3 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ResultNotOkayException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ResultNotOkayException.kt index 6fd03fd..68e87a7 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ResultNotOkayException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/ResultNotOkayException.kt @@ -1,8 +1,224 @@ package ir.cafebazaar.poolakey.exception -class ResultNotOkayException : IllegalStateException() { +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Build +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.os.SystemClock +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.lang.Exception + +// Run safely on main thread +private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() + else Handler(Looper.getMainLooper()).post(runnable) +} + +/* =============================================================== + 1️⃣ BazaarNotSupportedException + =============================================================== */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { + + override val message: String get() = "Bazaar is not updated" + + companion object { + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + fun getUpdateUri() = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + fun getWebUpdateUri() = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + } + + fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion + + fun hasInternetConnection(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Update Required") + .setMessage(recoveryHint) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context) + onUpdate?.invoke() + dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + showAsToast(context) + } + } + } + + fun openBazaarUpdatePage(context: Context) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (e: ActivityNotFoundException) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (_: Exception) { + showAsToast(context) + } + } + } + + fun showAsToast(context: Context) = runOnMain { + Toast.makeText(context, recoveryHint ?: "Update Bazaar to continue", Toast.LENGTH_LONG).show() + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("timestamp", timestamp) + put("hint", recoveryHint) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 2️⃣ ConsumeFailedException + =============================================================== */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { + + override val message: String get() = reason ?: "Consume request failed" + + private var retryCount = 0 + private var firstFailureTime = SystemClock.elapsedRealtime() + + fun incrementRetry() { retryCount++ } + fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries - override val message: String? - get() = "Failed to receive response from Bazaar" + fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { + if (isRetryable(maxRetries)) { + incrementRetry() + runOnMain { Handler().postDelayed({ operation() }, 2000) } + } + } + fun showFriendlyToast(context: Context) = runOnMain { + Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() + } + + fun recoverySuggestion(): String = when { + reason?.contains("network", true) == true -> "Check your internet connection." + reason?.contains("token", true) == true -> "Purchase token may have expired." + reason?.contains("timeout", true) == true -> "Try again later." + else -> "Restart Bazaar and retry." + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "ConsumeFailedException") + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 3️⃣ DisconnectException + =============================================================== */ +class DisconnectException : IllegalStateException() { + override val message: String get() = "Bazaar service disconnected" + + fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Service Disconnected") + .setMessage(message) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Retry 🔄") { dialog, _ -> + onRetry?.invoke() + dialog.dismiss() + } + .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } + .show() + } + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "DisconnectException") + put("message", message) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 4️⃣ DynamicPriceNotSupportedException + =============================================================== */ +class DynamicPriceNotSupportedException : IllegalStateException() { + override val message: String get() = "Dynamic pricing not supported" + + fun showDialog(context: Context) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Unsupported Feature") + .setMessage(message) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .setIcon(android.R.drawable.ic_dialog_info) + .show() + } + } +} + +/* =============================================================== + 5️⃣ IAPNotSupportedException + =============================================================== */ +class IAPNotSupportedException : IllegalAccessException() { + override val message: String? get() = "In-app billing not supported" + + fun notifyUser(context: Context) = runOnMain { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } +} + +/* =============================================================== + 6️⃣ PurchaseHijackedException + =============================================================== */ +class PurchaseHijackedException : Exception() { + override val message: String? get() = "The purchase was hijacked and it's not a valid purchase" +} + +/* =============================================================== + 7️⃣ ResultNotOkayException + =============================================================== */ +class ResultNotOkayException : IllegalStateException() { + override val message: String? get() = "Failed to receive response from Bazaar" } From e82316326133092242bdb675fc43450750f3ab9e Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:34:27 +0330 Subject: [PATCH 31/37] Update SubsNotSupportedException.kt MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit package ir.cafebazaar.poolakey.exception import android.app.AlertDialog import android.content.ActivityNotFoundException import android.content.Context import android.content.Intent import android.net.ConnectivityManager import android.net.NetworkCapabilities import android.os.Handler import android.os.Looper import android.os.RemoteException import android.os.SystemClock import android.widget.Toast import androidx.core.content.ContextCompat import org.json.JSONObject import java.lang.Exception // Centralized utility function to run code on the main thread private fun runOnMain(runnable: () -> Unit) { if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) } /* =============================================================== 1️⃣ BazaarNotSupportedException =============================================================== */ class BazaarNotSupportedException( val packageName: String = "com.farsitel.bazaar", val requiredVersion: Int? = null, val currentVersion: Int? = null, val timestamp: Long = System.currentTimeMillis(), val recoveryHint: String? = "Please update Bazaar to the latest version to continue." ) : IllegalStateException() { override val message: String get() = "Bazaar is not updated" companion object { private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" fun getUpdateUri() = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") fun getWebUpdateUri() = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") } fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion fun hasInternetConnection(context: Context): Boolean { val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false val network = cm.activeNetwork ?: return false val caps = cm.getNetworkCapabilities(network) ?: return false return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) } fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { runOnMain { try { val builder = AlertDialog.Builder(context) .setTitle("⚠️ Bazaar Update Required") .setMessage(recoveryHint) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Update Now 🚀") { dialog, _ -> openBazaarUpdatePage(context) onUpdate?.invoke() dialog.dismiss() } .setNegativeButton("Cancel ❌") { dialog, _ -> onCancel?.invoke() dialog.dismiss() } val dialog = builder.create() dialog.setOnShowListener { dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) } dialog.show() } catch (e: Exception) { showAsToast(context) } } } fun openBazaarUpdatePage(context: Context) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (e: ActivityNotFoundException) { try { context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) } catch (_: Exception) { showAsToast(context) } } } fun showAsToast(context: Context) = runOnMain { Toast.makeText(context, recoveryHint ?: "Update Bazaar to continue", Toast.LENGTH_LONG).show() } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "BazaarNotSupportedException") put("package", packageName) put("requiredVersion", requiredVersion) put("currentVersion", currentVersion) put("timestamp", timestamp) put("hint", recoveryHint) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 2️⃣ ConsumeFailedException =============================================================== */ class ConsumeFailedException( val productId: String? = null, val purchaseToken: String? = null, val reason: String? = null, val timestamp: Long = System.currentTimeMillis() ) : RemoteException() { override val message: String get() = reason ?: "Consume request failed" private var retryCount = 0 private var firstFailureTime = SystemClock.elapsedRealtime() fun incrementRetry() { retryCount++ } fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { if (isRetryable(maxRetries)) { incrementRetry() runOnMain { Handler().postDelayed({ operation() }, 2000) } } } fun showFriendlyToast(context: Context) = runOnMain { Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() } fun recoverySuggestion(): String = when { reason?.contains("network", true) == true -> "Check your internet connection." reason?.contains("token", true) == true -> "Purchase token may have expired." reason?.contains("timeout", true) == true -> "Try again later." else -> "Restart Bazaar and retry." } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "ConsumeFailedException") put("productId", productId) put("purchaseToken", purchaseToken) put("reason", reason) put("timestamp", timestamp) put("retryCount", retryCount) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 3️⃣ DisconnectException =============================================================== */ class DisconnectException : IllegalStateException() { override val message: String get() = "Bazaar service disconnected" fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Service Disconnected") .setMessage(message) .setCancelable(false) .setIcon(android.R.drawable.ic_dialog_alert) .setPositiveButton("Retry 🔄") { dialog, _ -> onRetry?.invoke() dialog.dismiss() } .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } .show() } } fun toJson(pretty: Boolean = true): String = JSONObject().apply { put("error", "DisconnectException") put("message", message) }.let { if (pretty) it.toString(2) else it.toString() } } /* =============================================================== 4️⃣ DynamicPriceNotSupportedException =============================================================== */ class DynamicPriceNotSupportedException : IllegalStateException() { override val message: String get() = "Dynamic pricing not supported" fun showDialog(context: Context) { runOnMain { AlertDialog.Builder(context) .setTitle("⚠️ Unsupported Feature") .setMessage(message) .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } .setIcon(android.R.drawable.ic_dialog_info) .show() } } } /* =============================================================== 5️⃣ IAPNotSupportedException =============================================================== */ class IAPNotSupportedException : IllegalAccessException() { override val message: String? get() = "In-app billing not supported" fun notifyUser(context: Context) = runOnMain { Toast.makeText(context, message, Toast.LENGTH_LONG).show() } } /* =============================================================== 6️⃣ PurchaseHijackedException =============================================================== */ class PurchaseHijackedException : Exception() { override val message: String? get() = "The purchase was hijacked and it's not a valid purchase" } /* =============================================================== 7️⃣ ResultNotOkayException =============================================================== */ class ResultNotOkayException : IllegalStateException() { override val message: String? get() = "Failed to receive response from Bazaar" } /* =============================================================== 8️⃣ SubsNotSupportedException =============================================================== */ class SubsNotSupportedException : IllegalAccessException() { override val message: String? get() = "Subscription is not supported in this version of installed Bazaar" } --- .../exception/SubsNotSupportedException.kt | 226 +++++++++++++++++- 1 file changed, 224 insertions(+), 2 deletions(-) diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/SubsNotSupportedException.kt b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/SubsNotSupportedException.kt index 4327eb3..7028019 100644 --- a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/SubsNotSupportedException.kt +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/SubsNotSupportedException.kt @@ -1,8 +1,230 @@ package ir.cafebazaar.poolakey.exception -class SubsNotSupportedException : IllegalAccessException() { +import android.app.AlertDialog +import android.content.ActivityNotFoundException +import android.content.Context +import android.content.Intent +import android.net.ConnectivityManager +import android.net.NetworkCapabilities +import android.os.Handler +import android.os.Looper +import android.os.RemoteException +import android.os.SystemClock +import android.widget.Toast +import androidx.core.content.ContextCompat +import org.json.JSONObject +import java.lang.Exception + +// Centralized utility function to run code on the main thread +private fun runOnMain(runnable: () -> Unit) { + if (Looper.myLooper() == Looper.getMainLooper()) runnable() else Handler(Looper.getMainLooper()).post(runnable) +} + +/* =============================================================== + 1️⃣ BazaarNotSupportedException + =============================================================== */ +class BazaarNotSupportedException( + val packageName: String = "com.farsitel.bazaar", + val requiredVersion: Int? = null, + val currentVersion: Int? = null, + val timestamp: Long = System.currentTimeMillis(), + val recoveryHint: String? = "Please update Bazaar to the latest version to continue." +) : IllegalStateException() { + + override val message: String get() = "Bazaar is not updated" + + companion object { + private const val BAZAAR_PACKAGE = "com.farsitel.bazaar" + fun getUpdateUri() = android.net.Uri.parse("bazaar://details?id=$BAZAAR_PACKAGE") + fun getWebUpdateUri() = android.net.Uri.parse("https://cafebazaar.ir/app/$BAZAAR_PACKAGE?l=en") + } + + fun needsUpdate(): Boolean = requiredVersion != null && currentVersion != null && currentVersion < requiredVersion + + fun hasInternetConnection(context: Context): Boolean { + val cm = context.getSystemService(Context.CONNECTIVITY_SERVICE) as? ConnectivityManager ?: return false + val network = cm.activeNetwork ?: return false + val caps = cm.getNetworkCapabilities(network) ?: return false + return caps.hasCapability(NetworkCapabilities.NET_CAPABILITY_INTERNET) + } + + fun showUpdateDialog(context: Context, onUpdate: (() -> Unit)? = null, onCancel: (() -> Unit)? = null) { + runOnMain { + try { + val builder = AlertDialog.Builder(context) + .setTitle("⚠️ Bazaar Update Required") + .setMessage(recoveryHint) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Update Now 🚀") { dialog, _ -> + openBazaarUpdatePage(context) + onUpdate?.invoke() + dialog.dismiss() + } + .setNegativeButton("Cancel ❌") { dialog, _ -> + onCancel?.invoke() + dialog.dismiss() + } + + val dialog = builder.create() + dialog.setOnShowListener { + dialog.getButton(AlertDialog.BUTTON_POSITIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_green_dark)) + dialog.getButton(AlertDialog.BUTTON_NEGATIVE)?.setTextColor(ContextCompat.getColor(context, android.R.color.holo_red_dark)) + } + dialog.show() + } catch (e: Exception) { + showAsToast(context) + } + } + } + + fun openBazaarUpdatePage(context: Context) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (e: ActivityNotFoundException) { + try { + context.startActivity(Intent(Intent.ACTION_VIEW, getWebUpdateUri()).apply { addFlags(Intent.FLAG_ACTIVITY_NEW_TASK) }) + } catch (_: Exception) { + showAsToast(context) + } + } + } + + fun showAsToast(context: Context) = runOnMain { + Toast.makeText(context, recoveryHint ?: "Update Bazaar to continue", Toast.LENGTH_LONG).show() + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "BazaarNotSupportedException") + put("package", packageName) + put("requiredVersion", requiredVersion) + put("currentVersion", currentVersion) + put("timestamp", timestamp) + put("hint", recoveryHint) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 2️⃣ ConsumeFailedException + =============================================================== */ +class ConsumeFailedException( + val productId: String? = null, + val purchaseToken: String? = null, + val reason: String? = null, + val timestamp: Long = System.currentTimeMillis() +) : RemoteException() { + + override val message: String get() = reason ?: "Consume request failed" + + private var retryCount = 0 + private var firstFailureTime = SystemClock.elapsedRealtime() + + fun incrementRetry() { retryCount++ } + fun isRetryable(maxRetries: Int = 3) = reason?.contains("network", true) == true && retryCount < maxRetries + + fun retryOperationIfNeeded(maxRetries: Int = 3, operation: () -> Unit) { + if (isRetryable(maxRetries)) { + incrementRetry() + runOnMain { Handler().postDelayed({ operation() }, 2000) } + } + } + + fun showFriendlyToast(context: Context) = runOnMain { + Toast.makeText(context, recoverySuggestion(), Toast.LENGTH_LONG).show() + } + fun recoverySuggestion(): String = when { + reason?.contains("network", true) == true -> "Check your internet connection." + reason?.contains("token", true) == true -> "Purchase token may have expired." + reason?.contains("timeout", true) == true -> "Try again later." + else -> "Restart Bazaar and retry." + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "ConsumeFailedException") + put("productId", productId) + put("purchaseToken", purchaseToken) + put("reason", reason) + put("timestamp", timestamp) + put("retryCount", retryCount) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 3️⃣ DisconnectException + =============================================================== */ +class DisconnectException : IllegalStateException() { + override val message: String get() = "Bazaar service disconnected" + + fun showEnhancedDialog(context: Context, onRetry: (() -> Unit)? = null) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Service Disconnected") + .setMessage(message) + .setCancelable(false) + .setIcon(android.R.drawable.ic_dialog_alert) + .setPositiveButton("Retry 🔄") { dialog, _ -> + onRetry?.invoke() + dialog.dismiss() + } + .setNegativeButton("Dismiss ❌") { dialog, _ -> dialog.dismiss() } + .show() + } + } + + fun toJson(pretty: Boolean = true): String = JSONObject().apply { + put("error", "DisconnectException") + put("message", message) + }.let { if (pretty) it.toString(2) else it.toString() } +} + +/* =============================================================== + 4️⃣ DynamicPriceNotSupportedException + =============================================================== */ +class DynamicPriceNotSupportedException : IllegalStateException() { + override val message: String get() = "Dynamic pricing not supported" + + fun showDialog(context: Context) { + runOnMain { + AlertDialog.Builder(context) + .setTitle("⚠️ Unsupported Feature") + .setMessage(message) + .setPositiveButton("OK") { dialog, _ -> dialog.dismiss() } + .setIcon(android.R.drawable.ic_dialog_info) + .show() + } + } +} + +/* =============================================================== + 5️⃣ IAPNotSupportedException + =============================================================== */ +class IAPNotSupportedException : IllegalAccessException() { + override val message: String? get() = "In-app billing not supported" + + fun notifyUser(context: Context) = runOnMain { + Toast.makeText(context, message, Toast.LENGTH_LONG).show() + } +} + +/* =============================================================== + 6️⃣ PurchaseHijackedException + =============================================================== */ +class PurchaseHijackedException : Exception() { + override val message: String? get() = "The purchase was hijacked and it's not a valid purchase" +} + +/* =============================================================== + 7️⃣ ResultNotOkayException + =============================================================== */ +class ResultNotOkayException : IllegalStateException() { + override val message: String? get() = "Failed to receive response from Bazaar" +} + +/* =============================================================== + 8️⃣ SubsNotSupportedException + =============================================================== */ +class SubsNotSupportedException : IllegalAccessException() { override val message: String? get() = "Subscription is not supported in this version of installed Bazaar" - } From 7d9eea009158bd131eb65a153ea4a850d8b78d9e Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:43:18 +0330 Subject: [PATCH 32/37] Create android_exception_manager.py android_exception_manager.py --- .../exception/android_exception_manager.py | 160 ++++++++++++++++++ 1 file changed, 160 insertions(+) create mode 100644 poolakey/src/main/java/ir/cafebazaar/poolakey/exception/android_exception_manager.py diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/android_exception_manager.py b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/android_exception_manager.py new file mode 100644 index 0000000..86b7af4 --- /dev/null +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/exception/android_exception_manager.py @@ -0,0 +1,160 @@ +import json +import datetime +import csv +from typing import List, Dict, Optional +from collections import Counter +import matplotlib.pyplot as plt +import pandas as pd +from sklearn.preprocessing import LabelEncoder +from sklearn.model_selection import train_test_split +from sklearn.ensemble import RandomForestClassifier +from sklearn.metrics import classification_report, confusion_matrix + +class AndroidException: + def __init__(self, name: str, message: str, type_: str): + self.name = name + self.message = message + self.type = type_ + self.timestamp = datetime.datetime.now() + + def to_dict(self) -> Dict: + return { + "name": self.name, + "message": self.message, + "type": self.type, + "timestamp": self.timestamp.isoformat() + } + + def to_json(self, pretty: bool = True) -> str: + return json.dumps(self.to_dict(), indent=4) if pretty else json.dumps(self.to_dict()) + +class ExceptionManager: + def __init__(self): + self.exceptions: List[AndroidException] = [] + + def add_exception(self, exception: AndroidException): + self.exceptions.append(exception) + + def generate_report(self) -> str: + total = len(self.exceptions) + types = Counter(ex.type for ex in self.exceptions) + report_lines = [f"=== Android Exception Report ===", f"Total Exceptions: {total}", f"Unique Types: {len(types)}", "-"*35] + for ex in self.exceptions: + report_lines.extend([ + f"Name : {ex.name}", + f"Message : {ex.message}", + f"Type : {ex.type}", + f"Timestamp : {ex.timestamp}", + "-"*35 + ]) + return "\n".join(report_lines) + + def save_to_json(self, filepath: str): + data = [ex.to_dict() for ex in self.exceptions] + with open(filepath, "w", encoding="utf-8") as f: + json.dump(data, f, indent=4) + + def save_to_csv(self, filepath: str): + if not self.exceptions: + return + keys = self.exceptions[0].to_dict().keys() + with open(filepath, "w", newline="", encoding="utf-8") as f: + writer = csv.DictWriter(f, fieldnames=keys) + writer.writeheader() + for ex in self.exceptions: + writer.writerow(ex.to_dict()) + + # ================= Analysis ================= + def exception_frequency(self) -> Dict[str, int]: + return dict(Counter(ex.type for ex in self.exceptions)) + + def exceptions_over_time(self, freq: str = 'D') -> pd.DataFrame: + if not self.exceptions: + return pd.DataFrame(columns=['count']) + timestamps = [ex.timestamp for ex in self.exceptions] + df = pd.DataFrame({"timestamp": timestamps}) + df.set_index("timestamp", inplace=True) + return df.resample(freq).size().rename("count").to_frame() + + def plot_exceptions_over_time(self, freq: str = 'D'): + df = self.exceptions_over_time(freq) + if df.empty: + print("No data to plot.") + return + plt.figure(figsize=(12, 6)) + plt.bar(df.index, df['count'], color='skyblue', edgecolor='navy') + plt.title("Android Exceptions Over Time", fontsize=16) + plt.xlabel("Time") + plt.ylabel("Number of Exceptions") + plt.grid(axis='y', linestyle='--', alpha=0.7) + plt.tight_layout() + plt.show() + + def plot_exception_distribution(self): + if not self.exceptions: + print("No data to plot.") + return + freq = self.exception_frequency() + plt.figure(figsize=(8, 6)) + plt.bar(freq.keys(), freq.values(), color='coral', edgecolor='black') + plt.title("Exception Type Distribution", fontsize=14) + plt.xlabel("Exception Type") + plt.ylabel("Count") + plt.xticks(rotation=45, ha='right') + plt.grid(axis='y', linestyle='--', alpha=0.5) + plt.tight_layout() + plt.show() + + # ================= Machine Learning ================= + def prepare_ml_dataset(self) -> pd.DataFrame: + if not self.exceptions: + return pd.DataFrame(), None + df = pd.DataFrame([{ + "name": ex.name, + "message": ex.message, + "type": ex.type, + "hour": ex.timestamp.hour, + "day_of_week": ex.timestamp.weekday(), + "month": ex.timestamp.month + } for ex in self.exceptions]) + le = LabelEncoder() + df['type_encoded'] = le.fit_transform(df['type']) + return df, le + + def train_predictive_model(self): + df, le = self.prepare_ml_dataset() + if df.empty: + print("No data to train.") + return None, None + X = df[['hour', 'day_of_week', 'month']] + y = df['type_encoded'] + + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + model = RandomForestClassifier(n_estimators=100, random_state=42) + model.fit(X_train, y_train) + + y_pred = model.predict(X_test) + print("=== Classification Report ===") + print(classification_report(y_test, y_pred, target_names=le.classes_)) + print("=== Confusion Matrix ===") + print(confusion_matrix(y_test, y_pred)) + + return model, le + +# ================= Sample Usage ================= +if __name__ == "__main__": + manager = ExceptionManager() + manager.add_exception(AndroidException("BazaarNotSupportedException", "Bazaar is not updated", "IllegalStateException")) + manager.add_exception(AndroidException("ConsumeFailedException", "Consume request failed", "RemoteException")) + manager.add_exception(AndroidException("DisconnectException", "Bazaar service disconnected", "IllegalStateException")) + manager.add_exception(AndroidException("DynamicPriceNotSupportedException", "Dynamic pricing not supported", "IllegalStateException")) + + print(manager.generate_report()) + print("Frequency:", manager.exception_frequency()) + + # Plots + manager.plot_exceptions_over_time() + manager.plot_exception_distribution() + + # Train ML model + model, encoder = manager.train_predictive_model() From c86ce7d0cab06e7330eae3a80e8c7ae6bdbc111e Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 16:53:33 +0330 Subject: [PATCH 33/37] Create `InAppPurchaseModels.kt` `InAppPurchaseModels.kt` --- .../poolakey/entity/`InAppPurchaseModels.kt` | 166 ++++++++++++++++++ 1 file changed, 166 insertions(+) create mode 100644 poolakey/src/main/java/ir/cafebazaar/poolakey/entity/`InAppPurchaseModels.kt` diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/entity/`InAppPurchaseModels.kt` b/poolakey/src/main/java/ir/cafebazaar/poolakey/entity/`InAppPurchaseModels.kt` new file mode 100644 index 0000000..014b635 --- /dev/null +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/entity/`InAppPurchaseModels.kt` @@ -0,0 +1,166 @@ +package ir.cafebazaar.poolakey.entity + +import org.json.JSONObject +import java.text.NumberFormat +import java.text.SimpleDateFormat +import java.util.* + +// ===================== PurchaseInfo ===================== +data class PurchaseInfo( + val orderId: String, + val purchaseToken: String, + val payload: String, + val packageName: String, + val purchaseState: PurchaseState, + val purchaseTime: Long, + val productId: String, + val originalJson: String, + val dataSignature: String +) { + + fun isValid(): Boolean = purchaseState == PurchaseState.PURCHASED && purchaseToken.isNotEmpty() + + fun formattedPurchaseTime(pattern: String = "yyyy-MM-dd HH:mm:ss"): String = + SimpleDateFormat(pattern, Locale.getDefault()).format(Date(purchaseTime)) + + fun toJsonObject(): JSONObject = try { + JSONObject(originalJson) + } catch (_: Exception) { + JSONObject() + } + + fun isSamePurchase(other: PurchaseInfo): Boolean = this.orderId == other.orderId + fun isRefunded(): Boolean = purchaseState == PurchaseState.REFUNDED + + fun summary(): String = "Product: $productId | Status: $purchaseState | Date: ${formattedPurchaseTime()}" + + fun isEarlierThan(other: PurchaseInfo): Boolean = this.purchaseTime < other.purchaseTime + + // Enhanced UI-friendly display + fun detailedDisplay(): String { + return buildString { + appendLine("╔═════════ Purchase Info ═════════╗") + appendLine("║ Order ID : $orderId") + appendLine("║ Product ID : $productId") + appendLine("║ Status : $purchaseState") + appendLine("║ Purchased : ${formattedPurchaseTime()}") + appendLine("║ Token : ${if (purchaseToken.isNotEmpty()) "Valid" else "Missing"}") + appendLine("╚═════════════════════════════════╝") + } + } +} + +// ===================== PurchaseState ===================== +enum class PurchaseState { + PURCHASED, + REFUNDED; + + companion object { + fun fromString(value: String?): PurchaseState = when (value?.uppercase(Locale.getDefault())) { + "PURCHASED" -> PURCHASED + "REFUNDED" -> REFUNDED + else -> PURCHASED + } + } +} + +// ===================== SkuDetails ===================== +class SkuDetails private constructor( + val sku: String, + val type: String, + val price: String, + val title: String, + val description: String +) { + + override fun toString(): String = "SKU: $sku | Type: $type | Price: $price | Title: $title" + + companion object { + internal fun fromJson(json: String): SkuDetails { + val jsonObject = JSONObject(json) + return with(jsonObject) { + SkuDetails( + optString("productId", "unknown_sku"), + optString("type", "unknown_type"), + optString("price", "0"), + optString("title", "N/A"), + optString("description", "") + ) + } + } + } + + fun isSubscription(): Boolean = type.lowercase(Locale.getDefault()) == "subs" + + fun displaySummary(): String { + val formattedPrice = try { + NumberFormat.getCurrencyInstance().format(price.filter { it.isDigit() }.toDouble()) + } catch (_: Exception) { + price + } + return "$title - $formattedPrice" + } + + fun cheaperThan(other: SkuDetails): Boolean { + val thisPrice = price.filter { it.isDigit() }.toDoubleOrNull() ?: Double.MAX_VALUE + val otherPrice = other.price.filter { it.isDigit() }.toDoubleOrNull() ?: Double.MAX_VALUE + return thisPrice < otherPrice + } + + fun fullDisplay(): String { + return buildString { + appendLine("╔══════════ SKU Details ══════════╗") + appendLine("║ SKU : $sku") + appendLine("║ Title : $title") + appendLine("║ Price : $price") + appendLine("║ Type : $type") + appendLine("║ Description : $description") + appendLine("╚════════════════════════════════╝") + } + } +} + +// ===================== TrialSubscriptionInfo ===================== +class TrialSubscriptionInfo private constructor( + val isAvailable: Boolean, + val trialPeriodDays: Int +) { + + override fun toString(): String = + "Trial Available: $isAvailable | Days: $trialPeriodDays" + + companion object { + internal fun fromJson(json: String): TrialSubscriptionInfo { + val jsonObject = JSONObject(json) + return with(jsonObject) { + TrialSubscriptionInfo( + optBoolean("isAvailable", false), + optInt("trialPeriodDays", 0) + ) + } + } + } + + fun canUseTrial(): Boolean = isAvailable && trialPeriodDays > 0 + + fun trialInfo(): String = + if (isAvailable) "Trial available for $trialPeriodDays days" else "No trial available" + + fun trialEndDate(): String { + val calendar = Calendar.getInstance() + calendar.add(Calendar.DAY_OF_YEAR, trialPeriodDays) + return SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()).format(calendar.time) + } + + fun isEmptyTrial(): Boolean = !isAvailable || trialPeriodDays <= 0 + + fun detailedDisplay(): String { + return buildString { + appendLine("╔══════════ Trial Info ═══════════╗") + appendLine("║ Available : $isAvailable") + appendLine("║ Trial Days : $trialPeriodDays") + appendLine("║ Ends On : ${trialEndDate()}") + appendLine("╚════════════════════════════════╝") + } + } +} From e6a72338b5c6bbc9f83bba373db59d8fb40f3c55 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 17:00:19 +0330 Subject: [PATCH 34/37] Create subscription_manager.py subscription_manager.py --- .../poolakey/entity/subscription_manager.py | 152 ++++++++++++++++++ 1 file changed, 152 insertions(+) create mode 100644 poolakey/src/main/java/ir/cafebazaar/poolakey/entity/subscription_manager.py diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/entity/subscription_manager.py b/poolakey/src/main/java/ir/cafebazaar/poolakey/entity/subscription_manager.py new file mode 100644 index 0000000..962860d --- /dev/null +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/entity/subscription_manager.py @@ -0,0 +1,152 @@ +from sklearn.linear_model import LogisticRegression + +class SubscriptionManager: + def __init__(self): + self.purchases: List[PurchaseInfo] = [] + self.skus: List[SkuDetails] = [] + self.trials: List[TrialSubscriptionInfo] = [] + + # ------------------- Adders ------------------- + def add_purchase(self, purchase: PurchaseInfo): + self.purchases.append(purchase) + + def add_sku(self, sku: SkuDetails): + self.skus.append(sku) + + def add_trial(self, trial: TrialSubscriptionInfo): + self.trials.append(trial) + + # ------------------- Purchase Analysis ------------------- + def purchase_frequency(self): + df = pd.DataFrame([p.purchase_state for p in self.purchases], columns=["state"]) + return df.value_counts() + + def refund_ratio(self) -> float: + if not self.purchases: + return 0.0 + refunded = sum(p.is_refunded() for p in self.purchases) + return refunded / len(self.purchases) + + def revenue_estimation(self) -> float: + total = 0.0 + for p in self.purchases: + try: + total += float(''.join(filter(lambda x: x.isdigit() or x=='.', p.product_id))) + except: + continue + return total + + def purchases_over_time(self, freq: str = "D"): + if not self.purchases: + return pd.DataFrame() + df = pd.DataFrame([{"time": p.purchase_time} for p in self.purchases]) + df.set_index("time", inplace=True) + return df.resample(freq).size().rename("count").to_frame() + + def plot_purchases_over_time(self, freq: str = "D"): + df = self.purchases_over_time(freq) + if df.empty: + print("No purchases to plot.") + return + df.plot(kind="bar", figsize=(10, 5), color="skyblue") + plt.title(f"Purchases Over Time ({freq})") + plt.xlabel("Date") + plt.ylabel("Count") + plt.show() + + # ------------------- SKU Analysis ------------------- + def most_expensive_sku(self) -> Optional[SkuDetails]: + if not self.skus: + return None + return max(self.skus, key=lambda s: float(''.join(filter(str.isdigit, s.price)) or 0)) + + def least_expensive_sku(self) -> Optional[SkuDetails]: + if not self.skus: + return None + return min(self.skus, key=lambda s: float(''.join(filter(str.isdigit, s.price)) or float('inf'))) + + def subscription_vs_one_time(self) -> dict: + subs = sum(s.is_subscription() for s in self.skus) + one_time = len(self.skus) - subs + return {"subscription": subs, "one_time": one_time} + + def refund_probability_by_type(self): + if not self.purchases or not self.skus: + return {} + data = [] + sku_dict = {s.sku: s.type for s in self.skus} + for p in self.purchases: + sku_type = sku_dict.get(p.product_id, "unknown") + data.append({"type": sku_type, "refunded": int(p.is_refunded())}) + df = pd.DataFrame(data) + return df.groupby("type")["refunded"].mean().to_dict() + + # ------------------- Trial Analysis ------------------- + def available_trials_count(self) -> int: + return sum(t.can_use_trial() for t in self.trials) + + def average_trial_days(self) -> float: + if not self.trials: + return 0.0 + return sum(t.trial_period_days for t in self.trials) / len(self.trials) + + # ------------------- Machine Learning ------------------- + def train_refund_model(self): + if not self.purchases: + print("No data to train.") + return None + df = pd.DataFrame([{ + "hour": p.purchase_time.hour, + "day_of_week": p.purchase_time.weekday(), + "is_refunded": int(p.is_refunded()) + } for p in self.purchases]) + X = df[["hour", "day_of_week"]] + y = df["is_refunded"] + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + model = RandomForestClassifier(n_estimators=100, random_state=42) + model.fit(X_train, y_train) + y_pred = model.predict(X_test) + print("=== Classification Report ===") + print(classification_report(y_test, y_pred)) + print("=== Confusion Matrix ===") + print(confusion_matrix(y_test, y_pred)) + return model + + def train_trial_conversion_model(self): + if not self.trials: + print("No trial data to train.") + return None + # Example: create a synthetic feature set for ML + df = pd.DataFrame([{ + "trial_days": t.trial_period_days, + "converted": int(t.can_use_trial()) # placeholder: in reality, track real conversion + } for t in self.trials]) + X = df[["trial_days"]] + y = df["converted"] + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + model = LogisticRegression() + model.fit(X_train, y_train) + y_pred = model.predict(X_test) + print("=== Trial Conversion Report ===") + print(classification_report(y_test, y_pred)) + return model + + # ------------------- Export Utilities ------------------- + def export_purchases_csv(self, filepath: str): + df = pd.DataFrame([{ + "order_id": p.order_id, + "product_id": p.product_id, + "state": p.purchase_state, + "timestamp": p.purchase_time + } for p in self.purchases]) + df.to_csv(filepath, index=False) + print(f"Purchases exported to {filepath}") + + def export_trials_csv(self, filepath: str): + df = pd.DataFrame([{ + "available": t.is_available, + "days": t.trial_period_days, + "ends_on": t.trial_end_date() + } for t in self.trials]) + df.to_csv(filepath, index=False) + print(f"Trials exported to {filepath}") From f45ed3fc2327644a9761ae9e6c6d8ef084772f98 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 17:09:58 +0330 Subject: [PATCH 35/37] Create `BazaarConstants.kt` `BazaarConstants.kt` --- .../poolakey/constant/`BazaarConstants.kt` | 123 ++++++++++++++++++ 1 file changed, 123 insertions(+) create mode 100644 poolakey/src/main/java/ir/cafebazaar/poolakey/constant/`BazaarConstants.kt` diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/constant/`BazaarConstants.kt` b/poolakey/src/main/java/ir/cafebazaar/poolakey/constant/`BazaarConstants.kt` new file mode 100644 index 0000000..0eb965e --- /dev/null +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/constant/`BazaarConstants.kt` @@ -0,0 +1,123 @@ +package ir.cafebazaar.poolakey.constant + +import org.json.JSONArray +import org.json.JSONException +import org.json.JSONObject +import java.text.SimpleDateFormat +import java.util.* + +internal object BazaarIntent { + + const val RESPONSE_CODE = "RESPONSE_CODE" + const val RESPONSE_RESULT_OK = 0 + const val RESPONSE_PURCHASE_DATA = "INAPP_PURCHASE_DATA" + const val RESPONSE_SIGNATURE_DATA = "INAPP_DATA_SIGNATURE" + const val RESPONSE_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN" + const val RESPONSE_PURCHASE_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST" + const val RESPONSE_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST" + const val RESPONSE_DATA_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST" + const val RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST" + const val RESPONSE_CHECK_TRIAL_SUBSCRIPTION_DATA = "CHECK_TRIAL_SUBSCRIPTION_DATA" + const val REQUEST_SKU_DETAILS_LIST = "ITEM_ID_LIST" + const val RESPONSE_DYNAMIC_PRICE_TOKEN = "DYNAMIC_PRICE_TOKEN" + const val RESPONSE_CUTOUT_MODE_IS_SHORT_EDGES = "CUTOUT_MODE_IS_SHORT_EDGES" + + // ===================== Utility Functions ===================== + + fun isResponseOk(code: Int): Boolean = code == RESPONSE_RESULT_OK + + fun extractPurchaseData(response: Map): String? = + response[RESPONSE_PURCHASE_DATA]?.toString() + + fun extractSignatureData(response: Map): String? = + response[RESPONSE_SIGNATURE_DATA]?.toString() + + fun extractContinuationToken(response: Map): String? = + response[RESPONSE_CONTINUATION_TOKEN]?.toString() + + fun parsePurchaseDataList(response: Map): List { + val list = mutableListOf() + val jsonArrayObj = response[RESPONSE_PURCHASE_DATA_LIST] ?: return list + try { + val jsonArray = when (jsonArrayObj) { + is JSONArray -> jsonArrayObj + is String -> JSONArray(jsonArrayObj) + else -> return list + } + for (i in 0 until jsonArray.length()) { + jsonArray.getJSONObject(i)?.let { list.add(it) } + } + } catch (_: JSONException) {} + return list + } + + // ===================== Additional Functions ===================== + + fun isValidPurchaseJson(json: JSONObject): Boolean { + return json.has(RawJson.ORDER_ID) && + json.has(RawJson.PURCHASE_TOKEN) && + json.has(RawJson.PURCHASE_STATE) + } + + fun formattedPurchaseTime(json: JSONObject, pattern: String = "yyyy-MM-dd HH:mm:ss"): String { + val time = RawJson.getLong(json, RawJson.PURCHASE_TIME) + val date = Date(time) + val sdf = SimpleDateFormat(pattern, Locale.getDefault()) + return sdf.format(date) + } + + fun extractOrderIds(purchaseList: List): List = + purchaseList.mapNotNull { RawJson.getString(it, RawJson.ORDER_ID) } + + fun isRefunded(json: JSONObject): Boolean = + RawJson.getString(json, RawJson.PURCHASE_STATE) + ?.uppercase(Locale.ROOT) == "REFUNDED" + + /** Pretty-print purchase JSON for console/debugging */ + fun prettyPrintPurchase(json: JSONObject): String { + val orderId = RawJson.getString(json, RawJson.ORDER_ID) ?: "N/A" + val productId = RawJson.getString(json, RawJson.PRODUCT_ID) ?: "N/A" + val state = RawJson.getString(json, RawJson.PURCHASE_STATE) ?: "UNKNOWN" + val purchaseTime = formattedPurchaseTime(json) + val tokenStatus = if (!RawJson.getString(json, RawJson.PURCHASE_TOKEN).isNullOrEmpty()) "Valid" else "Missing" + + return buildString { + appendLine("╔═════════ Purchase Info ═════════╗") + appendLine("║ Order ID : $orderId") + appendLine("║ Product ID : $productId") + appendLine("║ Status : $state") + appendLine("║ Purchased : $purchaseTime") + appendLine("║ Token : $tokenStatus") + appendLine("╚═════════════════════════════════╝") + } + } +} + +internal object Billing { + const val IN_APP_BILLING_VERSION = 3 + fun isSupported(version: Int): Boolean = version >= IN_APP_BILLING_VERSION +} + +internal object Const { + const val BAZAAR_PACKAGE_NAME = "com.farsitel.bazaar" + const val BAZAAR_PAYMENT_SERVICE_CLASS_NAME = + "com.farsitel.bazaar.inappbilling.service.InAppBillingService" + + fun isBazaarPackage(packageName: String): Boolean = packageName == BAZAAR_PACKAGE_NAME +} + +internal object RawJson { + const val ORDER_ID: String = "orderId" + const val PURCHASE_TOKEN: String = "purchaseToken" + const val DEVELOPER_PAYLOAD: String = "developerPayload" + const val PACKAGE_NAME: String = "packageName" + const val PURCHASE_STATE: String = "purchaseState" + const val PURCHASE_TIME: String = "purchaseTime" + const val PRODUCT_ID: String = "productId" + + fun getString(json: JSONObject, key: String): String? = + if (json.has(key)) json.optString(key, null) else null + + fun getLong(json: JSONObject, key: String): Long = + if (json.has(key)) json.optLong(key, 0) else 0 +} From 91b1f65d7b3776d68764b3d552a04f7b524f296b Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Fri, 17 Oct 2025 17:16:34 +0330 Subject: [PATCH 36/37] Create BazaarConstants.Py BazaarConstants.py --- .../poolakey/constant/BazaarConstants.Py | 244 ++++++++++++++++++ 1 file changed, 244 insertions(+) create mode 100644 poolakey/src/main/java/ir/cafebazaar/poolakey/constant/BazaarConstants.Py diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/constant/BazaarConstants.Py b/poolakey/src/main/java/ir/cafebazaar/poolakey/constant/BazaarConstants.Py new file mode 100644 index 0000000..3b1572a --- /dev/null +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/constant/BazaarConstants.Py @@ -0,0 +1,244 @@ +import json +from datetime import datetime, timedelta +from typing import List, Dict, Optional +import pandas as pd +import matplotlib.pyplot as plt +from sklearn.model_selection import train_test_split +from sklearn.ensemble import RandomForestClassifier +from sklearn.linear_model import LogisticRegression +from sklearn.metrics import classification_report, confusion_matrix + +# ===================== Constants ===================== +class BazaarConstants: + RESPONSE_CODE = "RESPONSE_CODE" + RESPONSE_RESULT_OK = 0 + RESPONSE_PURCHASE_DATA = "INAPP_PURCHASE_DATA" + RESPONSE_SIGNATURE_DATA = "INAPP_DATA_SIGNATURE" + RESPONSE_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN" + RESPONSE_PURCHASE_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST" + RESPONSE_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST" + RESPONSE_DATA_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST" + RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST" + RESPONSE_CHECK_TRIAL_SUBSCRIPTION_DATA = "CHECK_TRIAL_SUBSCRIPTION_DATA" + REQUEST_SKU_DETAILS_LIST = "ITEM_ID_LIST" + RESPONSE_DYNAMIC_PRICE_TOKEN = "DYNAMIC_PRICE_TOKEN" + RESPONSE_CUTOUT_MODE_IS_SHORT_EDGES = "CUTOUT_MODE_IS_SHORT_EDGES" + IN_APP_BILLING_VERSION = 3 + BAZAAR_PACKAGE_NAME = "com.farsitel.bazaar" + BAZAAR_PAYMENT_SERVICE_CLASS_NAME = "com.farsitel.bazaar.inappbilling.service.InAppBillingService" + +# ===================== JSON Utilities ===================== +class RawJson: + ORDER_ID = "orderId" + PURCHASE_TOKEN = "purchaseToken" + DEVELOPER_PAYLOAD = "developerPayload" + PACKAGE_NAME = "packageName" + PURCHASE_STATE = "purchaseState" + PURCHASE_TIME = "purchaseTime" + PRODUCT_ID = "productId" + + @staticmethod + def get_string(json_obj: dict, key: str) -> Optional[str]: + return str(json_obj.get(key)) if key in json_obj else None + + @staticmethod + def get_long(json_obj: dict, key: str) -> int: + return int(json_obj.get(key, 0)) + +# ===================== Purchase Model ===================== +class PurchaseInfo: + def __init__(self, data: dict): + self.order_id = RawJson.get_string(data, RawJson.ORDER_ID) or "N/A" + self.product_id = RawJson.get_string(data, RawJson.PRODUCT_ID) or "N/A" + self.purchase_state = (RawJson.get_string(data, RawJson.PURCHASE_STATE) or "UNKNOWN").upper() + self.purchase_time = datetime.fromtimestamp(RawJson.get_long(data, RawJson.PURCHASE_TIME)) + self.purchase_token = RawJson.get_string(data, RawJson.PURCHASE_TOKEN) or "" + self.raw_data = data + + def is_valid(self) -> bool: + return self.purchase_state == "PURCHASED" and bool(self.purchase_token) + + def is_refunded(self) -> bool: + return self.purchase_state == "REFUNDED" + + def pretty_display(self) -> str: + token_status = "Valid" if self.purchase_token else "Missing" + return ( + f"╔═════════ Purchase Info ═════════╗\n" + f"║ Order ID : {self.order_id}\n" + f"║ Product ID : {self.product_id}\n" + f"║ Status : {self.purchase_state}\n" + f"║ Purchased : {self.purchase_time}\n" + f"║ Token : {token_status}\n" + f"╚═════════════════════════════════╝" + ) + +# ===================== Manager for Analytics & ML ===================== +class SubscriptionManager: + def __init__(self): + self.purchases: List[PurchaseInfo] = [] + + def add_purchase(self, purchase: PurchaseInfo): + self.purchases.append(purchase) + + # ------------------- Data Analysis ------------------- + def purchase_frequency(self): + df = pd.DataFrame([p.purchase_state for p in self.purchases], columns=["state"]) + return df.value_counts() + + def refund_ratio(self) -> float: + if not self.purchases: + return 0.0 + refunded = sum(p.is_refunded() for p in self.purchases) + return refunded / len(self.purchases) + + def purchases_over_time(self, freq: str = "D") -> pd.DataFrame: + if not self.purchases: + return pd.DataFrame() + df = pd.DataFrame([{"time": p.purchase_time} for p in self.purchases]) + df.set_index("time", inplace=True) + return df.resample(freq).size().rename("count").to_frame() + + def plot_purchases_over_time(self, freq: str = "D"): + df = self.purchases_over_time(freq) + if df.empty: + print("No purchases to plot.") + return + df.plot(kind="bar", figsize=(10, 5), color="skyblue") + plt.title(f"Purchases Over Time ({freq})") + plt.xlabel("Date") + plt.ylabel("Count") + plt.show() + + # ------------------- Machine Learning ------------------- + def train_refund_model(self): + if not self.purchases: + print("No data to train.") + return None + df = pd.DataFrame([{ + "hour": p.purchase_time.hour, + "day_of_week": p.purchase_time.weekday(), + "is_refunded": int(p.is_refunded()) + } for p in self.purchases]) + X = df[["hour", "day_of_week"]] + y = df["is_refunded"] + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + model = RandomForestClassifier(n_estimators=100, random_state=42) + model.fit(X_train, y_train) + y_pred = model.predict(X_test) + print("=== Refund Prediction Report ===") + print(classification_report(y_test, y_pred)) + print("=== Confusion Matrix ===") + print(confusion_matrix(y_test, y_pred)) + return model + + def train_trial_conversion_model(self, trials_data: List[dict]): + if not trials_data: + print("No trial data to train.") + return None + df = pd.DataFrame([{ + "trial_days": t.get("trial_period_days", 0), + "converted": int(t.get("converted", 0)) + } for t in trials_data]) + X = df[["trial_days"]] + y = df["converted"] + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + model = LogisticRegression() + model.fit(X_train, y_train) + y_pred = model.predict(X_test) + print("=== Trial Conversion Report ===") + print(classification_report(y_test, y_pred)) + return model + + # ------------------- Extended Analytics & ML ------------------- + def revenue_estimation(self) -> float: + total = 0.0 + for p in self.purchases: + try: + total += float(''.join(filter(str.isdigit, p.product_id)) or 0) + except: + continue + return total + + def subscription_vs_one_time(self, subscription_ids: List[str]) -> dict: + subs = sum(p.product_id in subscription_ids for p in self.purchases) + one_time = len(self.purchases) - subs + return {"subscription": subs, "one_time": one_time} + + def purchase_hour_distribution(self): + if not self.purchases: + return pd.Series(dtype=int) + df = pd.DataFrame([p.purchase_time.hour for p in self.purchases], columns=["hour"]) + return df.value_counts().sort_index() + + def day_of_week_distribution(self): + if not self.purchases: + return pd.Series(dtype=int) + df = pd.DataFrame([p.purchase_time.weekday() for p in self.purchases], columns=["weekday"]) + return df.value_counts().sort_index() + + def train_advanced_refund_model(self): + if not self.purchases: + print("No data to train.") + return None + df = pd.DataFrame([{ + "hour": p.purchase_time.hour, + "day_of_week": p.purchase_time.weekday(), + "is_refunded": int(p.is_refunded()), + "subscription": int(p.product_id.startswith("sub")) + } for p in self.purchases]) + X = df[["hour", "day_of_week", "subscription"]] + y = df["is_refunded"] + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + model = RandomForestClassifier(n_estimators=200, random_state=42, class_weight="balanced") + model.fit(X_train, y_train) + y_pred = model.predict(X_test) + print("=== Advanced Refund Prediction ===") + print(classification_report(y_test, y_pred)) + print(confusion_matrix(y_test, y_pred)) + return model + + def export_purchases_csv(self, filepath: str): + df = pd.DataFrame([{ + "order_id": p.order_id, + "product_id": p.product_id, + "status": p.purchase_state, + "timestamp": p.purchase_time, + "token_valid": bool(p.purchase_token) + } for p in self.purchases]) + df.to_csv(filepath, index=False) + print(f"Purchases exported to {filepath}") + + def plot_hour_distribution(self): + dist = self.purchase_hour_distribution() + if dist.empty: + print("No data to plot.") + return + dist.plot(kind="bar", figsize=(10,5), color="orange") + plt.title("Purchases by Hour") + plt.xlabel("Hour of Day") + plt.ylabel("Number of Purchases") + plt.show() + +# ===================== Example Usage ===================== +if __name__ == "__main__": + # Sample purchases + sample_data = [ + {"orderId": "order1", "productId": "prod1", "purchaseState": "PURCHASED", "purchaseTime": 1697577600, "purchaseToken": "token1"}, + {"orderId": "order2", "productId": "prod2", "purchaseState": "REFUNDED", "purchaseTime": 1697664000, "purchaseToken": ""} + ] + manager = SubscriptionManager() + for d in sample_data: + manager.add_purchase(PurchaseInfo(d)) + + # Display info + for p in manager.purchases: + print(p.pretty_display()) + + # Analytics + print("Purchase frequency:\n", manager.purchase_frequency()) + print("Refund ratio:", manager.refund_ratio()) + manager.plot_purchases_over_time() + + # ML training + manager.train_refund_model() From 703a21b4ba0defe2be5288bf6793ee035eae5524 Mon Sep 17 00:00:00 2001 From: phoenix marie Date: Sat, 18 Oct 2025 00:06:10 +0330 Subject: [PATCH 37/37] Create purchase_analysis.py purchase_analysis.py --- .../poolakey/mapper/purchase_analysis.py | 243 ++++++++++++++++++ 1 file changed, 243 insertions(+) create mode 100644 poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/purchase_analysis.py diff --git a/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/purchase_analysis.py b/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/purchase_analysis.py new file mode 100644 index 0000000..4f76511 --- /dev/null +++ b/poolakey/src/main/java/ir/cafebazaar/poolakey/mapper/purchase_analysis.py @@ -0,0 +1,243 @@ +import json +from datetime import datetime +from enum import Enum +from typing import List, Optional, Dict, Any + +import pandas as pd +import matplotlib.pyplot as plt +import numpy as np +from sklearn.preprocessing import LabelEncoder +from sklearn.model_selection import train_test_split +from sklearn.ensemble import RandomForestClassifier +from sklearn.metrics import classification_report, confusion_matrix +from sklearn.pipeline import make_pipeline +from sklearn.feature_extraction.text import CountVectorizer +from xgboost import XGBClassifier +from statsmodels.tsa.arima.model import ARIMA +from sklearn.cluster import KMeans + + +# ====================================================== +# PURCHASE CLASSES & MAPPER +# ====================================================== + +class PurchaseState(Enum): + PURCHASED = "PURCHASED" + REFUNDED = "REFUNDED" + + +class PurchaseInfo: + def __init__(self, + order_id: str, + purchase_token: str, + payload: str, + package_name: str, + purchase_state: PurchaseState, + purchase_time: int, + product_id: str, + data_signature: str, + original_json: str): + self.order_id = order_id + self.purchase_token = purchase_token + self.payload = payload + self.package_name = package_name + self.purchase_state = purchase_state + self.purchase_time = purchase_time + self.product_id = product_id + self.data_signature = data_signature + self.original_json = original_json + + def __repr__(self): + return f"" + + +class PurchaseMapper: + + @staticmethod + def map_to_purchase_info(purchase_data: str, data_signature: str) -> PurchaseInfo: + data = json.loads(purchase_data) + purchase_state = ( + PurchaseState.PURCHASED if data.get("purchaseState") == 0 else PurchaseState.REFUNDED + ) + + return PurchaseInfo( + order_id=data.get("orderId"), + purchase_token=data.get("purchaseToken"), + payload=data.get("developerPayload"), + package_name=data.get("packageName"), + purchase_state=purchase_state, + purchase_time=data.get("purchaseTime", 0), + product_id=data.get("productId"), + data_signature=data_signature, + original_json=purchase_data + ) + + @staticmethod + def map_list(purchases: List[tuple[str, str]]) -> List[PurchaseInfo]: + return [PurchaseMapper.map_to_purchase_info(data, sig) for data, sig in purchases] + + @staticmethod + def get_purchase_summary(purchase_data: str) -> str: + data = json.loads(purchase_data) + state = "✅ PURCHASED" if data.get("purchaseState") == 0 else "❌ REFUNDED" + formatted_time = PurchaseMapper.format_purchase_time(data.get("purchaseTime", 0)) + + return ( + "━━━━━━━━━ Purchase Summary ━━━━━━━━━\n" + f"Order ID : {data.get('orderId')}\n" + f"Product ID : {data.get('productId')}\n" + f"Purchase State: {state}\n" + f"Purchase Time : {formatted_time}\n" + "━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━━" + ) + + @staticmethod + def verify_purchase_signature(purchase_data: str, signature: str, public_key: str) -> bool: + if not signature.strip() or not purchase_data.strip(): + print("⚠️ Signature verification placeholder - not implemented!") + return False + return True + + @staticmethod + def filter_by_state(purchases: List[PurchaseInfo], state: PurchaseState) -> List[PurchaseInfo]: + return [p for p in purchases if p.purchase_state == state] + + @staticmethod + def get_most_recent_purchase(purchases: List[PurchaseInfo]) -> Optional[PurchaseInfo]: + return max(purchases, key=lambda p: p.purchase_time, default=None) + + @staticmethod + def is_refunded(purchase_data: str) -> bool: + return json.loads(purchase_data).get("purchaseState") != 0 + + @staticmethod + def get_field(purchase_data: str, field: str) -> Optional[Any]: + return json.loads(purchase_data).get(field) + + @staticmethod + def format_purchase_time(timestamp: int) -> str: + if timestamp > 0: + return datetime.fromtimestamp(timestamp / 1000).strftime("%Y-%m-%d %H:%M:%S") + return "N/A" + + @staticmethod + def is_purchase_for_product(purchase_data: str, product_id: str) -> bool: + return json.loads(purchase_data).get("productId") == product_id + + +# ====================================================== +# DATA ANALYSIS FUNCTIONS +# ====================================================== + +def purchases_to_dataframe(purchases): + df = pd.DataFrame([ + { + "order_id": p.order_id, + "product_id": p.product_id, + "package_name": p.package_name, + "purchase_state": p.purchase_state.value, + "purchase_time": datetime.fromtimestamp(p.purchase_time / 1000), + "payload": p.payload, + "signature": p.data_signature, + } + for p in purchases + ]) + df["is_refunded"] = (df["purchase_state"] == "REFUNDED").astype(int) + df["hour"] = df["purchase_time"].dt.hour + df["day_of_week"] = df["purchase_time"].dt.day_name() + df["date"] = df["purchase_time"].dt.date + return df + + +# ====================================================== +# MAIN EXECUTION / ANALYSIS +# ====================================================== + +if __name__ == "__main__": + + # Example Raw JSON Purchase + raw_json = '{"orderId":"12345","productId":"com.app.gold","purchaseState":0,"purchaseTime":1691234567890,"packageName":"com.app.demo","purchaseToken":"abc123","developerPayload":"optional"}' + signature = "FAKE_SIGNATURE" + + mapper = PurchaseMapper() + info = mapper.map_to_purchase_info(raw_json, signature) + print(info) + print(mapper.get_purchase_summary(raw_json)) + + # Example purchase list for analysis + purchases = [ + mapper.map_to_purchase_info(raw_json, signature), + mapper.map_to_purchase_info(raw_json.replace("purchaseState\":0", "purchaseState\":1"), signature), + mapper.map_to_purchase_info(raw_json, signature) + ] + + df = purchases_to_dataframe(purchases) + print("\n--- DataFrame ---\n", df) + + # Descriptive analysis + print("\n--- Descriptive Stats ---") + print(df.describe(include='all')) + + print("\n--- Purchase State Distribution ---") + print(df['purchase_state'].value_counts(normalize=True) * 100) + + # Visualization + plt.figure(figsize=(10, 4)) + df.groupby('hour')['order_id'].count().plot(kind='bar') + plt.title("تعداد خرید بر اساس ساعت روز") + plt.xlabel("ساعت") + plt.ylabel("تعداد خرید") + plt.show() + + plt.figure(figsize=(5, 5)) + df['purchase_state'].value_counts().plot(kind='pie', autopct='%1.1f%%', startangle=90) + plt.title("درصد خریدهای موفق در برابر برگشتی") + plt.ylabel("") + plt.show() + + # Machine Learning Preparation + le_product = LabelEncoder() + df["product_id_encoded"] = le_product.fit_transform(df["product_id"]) + le_day = LabelEncoder() + df["day_of_week_encoded"] = le_day.fit_transform(df["day_of_week"]) + + X = df[["hour", "product_id_encoded", "day_of_week_encoded"]] + y = df["is_refunded"] + + X_train, X_test, y_train, y_test = train_test_split(X, y, test_size=0.2, random_state=42) + + # Random Forest Model + model = RandomForestClassifier(n_estimators=200, random_state=42) + model.fit(X_train, y_train) + y_pred = model.predict(X_test) + print("\n--- RandomForest Results ---") + print(classification_report(y_test, y_pred)) + print("Confusion Matrix:\n", confusion_matrix(y_test, y_pred)) + + # Feature Importance + importances = model.feature_importances_ + features = X.columns + sorted_indices = np.argsort(importances)[::-1] + print("\n--- Feature Importance ---") + for idx in sorted_indices: + print(f"{features[idx]}: {importances[idx]*100:.2f}%") + + # XGBoost Model + xgb = XGBClassifier(use_label_encoder=False, eval_metric='logloss') + xgb.fit(X_train, y_train) + print("\nXGBoost Accuracy:", xgb.score(X_test, y_test)) + + # Time Series Forecasting + daily = df.groupby('date')['order_id'].count().reset_index() + if len(daily) > 2: + model_arima = ARIMA(daily['order_id'], order=(1, 1, 0)) + model_fit = model_arima.fit() + forecast = model_fit.forecast(steps=7) + print("\nForecast (Next 7 days):", forecast) + + # User Behavior Clustering + X_features = df[["hour", "is_refunded"]] + kmeans = KMeans(n_clusters=2, random_state=42) + df["user_cluster"] = kmeans.fit_predict(X_features) + print("\n--- Cluster Refund Rates ---") + print(df.groupby("user_cluster")["is_refunded"].mean())