Browse Source

增加google订阅查询以及支付拉起

zk 1 year ago
parent
commit
453106aa16

+ 24 - 9
app/build.gradle

@@ -38,21 +38,33 @@ android {
 
     signingConfigs {
         debug {
-            storeFile file("keystore/voiceAI.jks")
-            storePassword "voice888"
-            keyAlias "voice"
-            keyPassword "voice888"
+            storeFile file("keystore/atmob.keystore")
+            storePassword "atmob888"
+            keyAlias "atmob"
+            keyPassword "atmob888"
         }
         release {
-            storeFile file("keystore/voiceAI.jks")
-            storePassword "voice888"
-            keyAlias "voice"
-            keyPassword "voice888"
+            storeFile file("keystore/atmob.keystore")
+            storePassword "atmob888"
+            keyAlias "atmob"
+            keyPassword "atmob888"
         }
+//        debug {
+//            storeFile file("keystore/voiceAI.jks")
+//            storePassword "voice888"
+//            keyAlias "voice"
+//            keyPassword "voice888"
+//        }
+//        release {
+//            storeFile file("keystore/voiceAI.jks")
+//            storePassword "voice888"
+//            keyAlias "voice"
+//            keyPassword "voice888"
+//        }
     }
 
 
-    def env_release = PROD
+    def env_release = LOCAL
     def env_debug = LOCAL
 
     buildTypes {
@@ -197,4 +209,7 @@ dependencies {
     //lottie
     implementation "com.airbnb.android:lottie:$rootProject.lottie_version"
 
+    //google结算库
+    implementation "com.android.billingclient:billing:$rootProject.billing_version"
+
 }

+ 1 - 1
app/src/main/AndroidManifest.xml

@@ -6,7 +6,7 @@
     <!-- 允许访问网络,必选权限 -->
     <uses-permission android:name="android.permission.INTERNET" />
     <uses-permission android:name="android.permission.RECORD_AUDIO" />
-
+    <uses-permission android:name="com.android.vending.BILLING" />
     <application
         android:name=".App"
         android:allowBackup="false"

+ 7 - 0
app/src/main/java/com/atmob/voiceai/App.java

@@ -4,16 +4,23 @@ import android.content.Context;
 
 import com.atmob.app.lib.base.BaseApplication;
 import com.atmob.user.AtmobUser;
+import com.atmob.voiceai.sdk.billing.GPBillingClient;
 import com.atmob.voiceai.utils.ToastUtil;
 
 import java.util.Objects;
 
+import javax.inject.Inject;
+
 import dagger.hilt.android.HiltAndroidApp;
 
 
 @HiltAndroidApp
 public class App extends BaseApplication {
 
+
+    @Inject
+    GPBillingClient gpBillingClient;
+
     private static App INSTANCE;
 
     public static App getInstance() {

+ 53 - 4
app/src/main/java/com/atmob/voiceai/data/api/bean/GoodsBean.java

@@ -5,9 +5,10 @@ import androidx.databinding.Bindable;
 
 import com.atmob.common.text.TextUtil;
 import com.atmob.voiceai.BR;
+import com.atmob.voiceai.sdk.billing.bean.GPProductInfo;
 import com.google.gson.annotations.SerializedName;
 
-public class GoodsBean extends BaseObservable {
+public class GoodsBean extends BaseObservable implements Comparable<GoodsBean> {
     @SerializedName("id")
     private int id;
     @SerializedName("sort")
@@ -20,6 +21,16 @@ public class GoodsBean extends BaseObservable {
     private int originalAmount;
     @SerializedName("googleProductId")
     private String googleProductId;
+
+    @SerializedName("planId")
+    private String planId;
+
+    @SerializedName("lowGoogleProductId")
+    private String lowGoogleProductId;
+
+    @SerializedName("days")
+    private int days;
+
     @SerializedName("auth")
     private String auth;
     @SerializedName("content")
@@ -33,6 +44,16 @@ public class GoodsBean extends BaseObservable {
 
     private boolean isSelect;
 
+    private GPProductInfo gpProductInfo;
+
+    public GPProductInfo getGpProductInfo() {
+        return gpProductInfo;
+    }
+
+    public void setGpProductInfo(GPProductInfo gpProductInfo) {
+        this.gpProductInfo = gpProductInfo;
+    }
+
     @Bindable
     public boolean isSelect() {
         return isSelect;
@@ -43,6 +64,18 @@ public class GoodsBean extends BaseObservable {
         notifyPropertyChanged(BR.select);
     }
 
+    public String getPlanId() {
+        return planId;
+    }
+
+    public String getLowGoogleProductId() {
+        return lowGoogleProductId;
+    }
+
+    public int getDays() {
+        return days;
+    }
+
     public int getId() {
         return id;
     }
@@ -103,6 +136,21 @@ public class GoodsBean extends BaseObservable {
         return content;
     }
 
+    public String getPriceContent() {
+        if (content == null || content.isEmpty() || gpProductInfo == null) {
+            return content;
+        }
+        float goodsAmount = gpProductInfo.getAmount() / 1000000f;
+        String unit = "";
+        int unitIndex = gpProductInfo.getFormatPrice().indexOf(String.valueOf(goodsAmount));
+        if (unitIndex != -1) {
+            unit = gpProductInfo.getFormatPrice().substring(0, unitIndex);
+        }
+        String unPrice = unit + TextUtil.formatFloatWithout0End(goodsAmount / days, 2);
+        return content.replace("%s", unPrice);
+    }
+
+
     public void setContent(String content) {
         this.content = content;
     }
@@ -131,8 +179,9 @@ public class GoodsBean extends BaseObservable {
         this.popular = popular;
     }
 
-    public String getDollarAmount(int nub) {
-        return TextUtil.formatFloatWithout0End((float) (amount / 100.0), nub);
-    }
 
+    @Override
+    public int compareTo(GoodsBean o) {
+        return this.sort - o.sort;
+    }
 }

+ 4 - 4
app/src/main/java/com/atmob/voiceai/data/api/request/OrderPayRequest.java

@@ -6,16 +6,16 @@ public class OrderPayRequest extends BaseRequest {
 
 
     @SerializedName("itemId")
-    private String itemId;
+    private int itemId;
 
     @SerializedName("payPlatform")
-    private String payPlatform;
+    private int payPlatform;
 
     @SerializedName("payMethod")
-    private String payMethod;
+    private int payMethod;
 
 
-    public OrderPayRequest(String itemId, String payPlatform, String payMethod) {
+    public OrderPayRequest(int itemId, int payPlatform, int payMethod) {
         this.itemId = itemId;
         this.payPlatform = payPlatform;
         this.payMethod = payMethod;

+ 8 - 2
app/src/main/java/com/atmob/voiceai/data/repositories/MemberRepository.java

@@ -5,10 +5,10 @@ import androidx.lifecycle.MutableLiveData;
 
 import com.atmob.app.lib.handler.RxHttpHandler;
 import com.atmob.voiceai.data.api.AtmobApi;
+import com.atmob.voiceai.data.api.request.OrderPayRequest;
 import com.atmob.voiceai.data.api.request.PayGoodsRequest;
-import com.atmob.voiceai.data.api.request.TextTosSpeechRequest;
+import com.atmob.voiceai.data.api.response.OrderPayResponse;
 import com.atmob.voiceai.data.api.response.PayGoodsResponse;
-import com.atmob.voiceai.data.api.response.TextToSpeechResponse;
 
 import javax.inject.Inject;
 import javax.inject.Singleton;
@@ -44,4 +44,10 @@ public class MemberRepository {
                 .compose(RxHttpHandler.handle(true))
                 .compose(RxJavaUtil.SingleSchedule.io2Main());
     }
+
+    public Single<OrderPayResponse> requestPayOrder(int itemId, int payPlatform, int payMethod) {
+        return atmobApi.orderPay(new OrderPayRequest(itemId, payPlatform, payMethod))
+                .compose(RxHttpHandler.handle(false))
+                .compose(RxJavaUtil.SingleSchedule.io2Main());
+    }
 }

+ 108 - 28
app/src/main/java/com/atmob/voiceai/module/subscription/SubscriptionPageViewModel.java

@@ -5,29 +5,39 @@ import android.os.SystemClock;
 import androidx.lifecycle.LiveData;
 import androidx.lifecycle.MutableLiveData;
 
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingResult;
 import com.atmob.app.lib.base.BaseViewModel;
 import com.atmob.app.lib.livedata.SingleLiveEvent;
 import com.atmob.common.data.KVUtils;
+import com.atmob.common.logging.AtmobLog;
+import com.atmob.common.runtime.ActivityUtil;
 import com.atmob.common.runtime.ContextUtil;
 import com.atmob.voiceai.R;
 import com.atmob.voiceai.data.api.bean.GoodsBean;
 import com.atmob.voiceai.data.api.bean.PayOptionsBean;
-import com.atmob.voiceai.data.api.response.PayGoodsResponse;
+import com.atmob.voiceai.data.api.response.OrderPayResponse;
 import com.atmob.voiceai.data.repositories.MemberRepository;
 import com.atmob.voiceai.helper.ErrorHelper;
+import com.atmob.voiceai.sdk.billing.GPBillingClient;
 import com.atmob.voiceai.utils.SpannableUtil;
 import com.atmob.voiceai.utils.ToastUtil;
 
-import java.util.ArrayList;
+import java.util.HashMap;
 import java.util.List;
 import java.util.concurrent.TimeUnit;
 
 import javax.inject.Inject;
 
+import atmob.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
 import atmob.reactivex.rxjava3.annotations.NonNull;
+import atmob.reactivex.rxjava3.core.Observable;
+import atmob.reactivex.rxjava3.core.ObservableSource;
 import atmob.reactivex.rxjava3.core.SingleObserver;
+import atmob.reactivex.rxjava3.core.SingleSource;
 import atmob.reactivex.rxjava3.disposables.Disposable;
-import atmob.reactivex.rxjava3.functions.Action;
+import atmob.reactivex.rxjava3.functions.Function;
+import atmob.reactivex.rxjava3.schedulers.Schedulers;
 import atmob.rxjava.utils.RxJavaUtil;
 import dagger.hilt.android.lifecycle.HiltViewModel;
 
@@ -35,6 +45,7 @@ import dagger.hilt.android.lifecycle.HiltViewModel;
 public class SubscriptionPageViewModel extends BaseViewModel {
 
 
+    private final String TAG = SubscriptionPageViewModel.class.getSimpleName();
     private final String LAST_BACK_SHOW_RETAIN_TIME = "last_back_show_retain_time";
 
     private final SingleLiveEvent<?> showRetentionDialogEvent = new SingleLiveEvent<>();
@@ -42,18 +53,23 @@ public class SubscriptionPageViewModel extends BaseViewModel {
 
     private final MutableLiveData<Boolean> isShowCloseBtn = new MutableLiveData<>();
     private final MutableLiveData<List<GoodsBean>> goodsList = new MutableLiveData<>();
+    private final GPBillingClient gpBillingClient;
     private List<PayOptionsBean> payList;
     private final MutableLiveData<CharSequence> autoRenewableText = new MutableLiveData<>();
     private final MutableLiveData<CharSequence> subCancelTxt = new MutableLiveData<>();
     private final MemberRepository memberRepository;
-
     private GoodsBean checkGoodsBean;
+    private boolean isRequestSubmitOrder;
+
+    private final HashMap<String, GoodsBean> orderIdItemMap = new HashMap<>();
 
     @Inject
-    public SubscriptionPageViewModel(MemberRepository memberRepository) {
+    public SubscriptionPageViewModel(MemberRepository memberRepository, GPBillingClient gpBillingClient) {
         this.memberRepository = memberRepository;
+        this.gpBillingClient = gpBillingClient;
         init();
         refreshSubscriptionDetail();
+        gpBillingClient.startUrgentConnection();
     }
 
     public void setCheckGoodsBean(GoodsBean checkGoodsBean) {
@@ -114,23 +130,39 @@ public class SubscriptionPageViewModel extends BaseViewModel {
 
 
     private void refreshSubscriptionDetail() {
-        memberRepository.payGoodsList().subscribe(new SingleObserver<PayGoodsResponse>() {
-            @Override
-            public void onSubscribe(@NonNull Disposable d) {
-                addDisposable(d);
-            }
-
-            @Override
-            public void onSuccess(@NonNull PayGoodsResponse payGoodsResponse) {
-                goodsList.setValue(payGoodsResponse.getList());
-                payList = payGoodsResponse.getPayOptions();
-            }
-
-            @Override
-            public void onError(@NonNull Throwable e) {
-                ErrorHelper.errorThrowableToast(e, ToastUtil.LENGTH_SHORT);
-            }
-        });
+        memberRepository.payGoodsList()
+                .map(payGoodsResponse -> {
+                    payList = payGoodsResponse.getPayOptions();
+                    return payGoodsResponse.getList();
+                })
+                .flatMapObservable((Function<List<GoodsBean>, ObservableSource<GoodsBean>>) Observable::fromIterable)
+                .flatMapSingle((Function<GoodsBean, SingleSource<GoodsBean>>) memberBean
+                        -> gpBillingClient.querySkuDetails(BillingClient.ProductType.SUBS,
+                                memberBean.getGoogleProductId(), memberBean.getPlanId(), memberBean.getLowGoogleProductId())
+                        .map(gPProductInfo -> {
+                            memberBean.setGpProductInfo(gPProductInfo);
+                            return memberBean;
+                        })
+                        .subscribeOn(Schedulers.io())
+                )
+                .toSortedList()
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe(new SingleObserver<List<GoodsBean>>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        addDisposable(d);
+                    }
+
+                    @Override
+                    public void onSuccess(@NonNull List<GoodsBean> goodsBeanList) {
+                        goodsList.setValue(goodsBeanList);
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+                        ErrorHelper.errorThrowableToast(e, ToastUtil.LENGTH_SHORT);
+                    }
+                });
     }
 
     public void setRetentionTime() {
@@ -142,12 +174,11 @@ public class SubscriptionPageViewModel extends BaseViewModel {
         return SystemClock.elapsedRealtime() - lastShowTime > 12 * 60 * 60 * 1000;
     }
 
-    @Override
-    protected void onCleared() {
-        super.onCleared();
-    }
 
     public void onSubNowClick() {
+        if (isRequestSubmitOrder) {
+            return;
+        }
         if (checkGoodsBean == null) {
             ToastUtil.show(R.string.please_select_goods, ToastUtil.LENGTH_SHORT);
             return;
@@ -157,12 +188,61 @@ public class SubscriptionPageViewModel extends BaseViewModel {
             return;
         }
         PayOptionsBean payOptionsBean = payList.get(0);
-        int payMethod = payOptionsBean.getPayMethod();
         int payPlatform = payOptionsBean.getPayPlatform();
-
+        int payMethod = payOptionsBean.getPayMethod();
+        memberRepository.requestPayOrder(checkGoodsBean.getId(), payPlatform, payMethod)
+                .subscribe(new SingleObserver<OrderPayResponse>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        addDisposable(d);
+                        isRequestSubmitOrder = true;
+                    }
+
+                    @Override
+                    public void onSuccess(@NonNull OrderPayResponse orderPayResponse) {
+                        isRequestSubmitOrder = false;
+                        orderIdItemMap.put(orderPayResponse.getOutTradeNo(), checkGoodsBean);
+                        requestPayment(checkGoodsBean, orderPayResponse.getOutTradeNo());
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+                        isRequestSubmitOrder = false;
+                    }
+                });
+    }
+
+    private void requestPayment(GoodsBean bean, String orderNo) {
+        gpBillingClient.subscriptionGoods(ActivityUtil.getTopActivity(),
+                        bean.getGpProductInfo().getProductDetails(), bean.getPlanId(), orderNo)
+                .compose(RxJavaUtil.SingleSchedule.subThread2Main())
+                .subscribe(new SingleObserver<BillingResult>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        addDisposable(d);
+                    }
+
+                    @Override
+                    public void onSuccess(@NonNull BillingResult billingResult) {
+                        AtmobLog.d(TAG, "requestPayment onSuccess: " + billingResult);
+
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+
+                    }
+                });
     }
 
     public void onSubRetentionClick() {
         onSubNowClick();
     }
+
+
+    @Override
+    protected void onCleared() {
+        super.onCleared();
+        gpBillingClient.endUrgentConnection();
+    }
 }

+ 39 - 0
app/src/main/java/com/atmob/voiceai/sdk/billing/BillingStrategy.java

@@ -0,0 +1,39 @@
+package com.atmob.voiceai.sdk.billing;
+
+
+import android.app.Activity;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingResult;
+import com.atmob.voiceai.sdk.billing.bean.GPProductInfo;
+import com.atmob.voiceai.sdk.billing.operation.QueryGoods;
+import com.atmob.voiceai.sdk.billing.operation.Subscription;
+
+import atmob.reactivex.rxjava3.core.Single;
+
+public class BillingStrategy {
+
+
+    private static final String TAG = "BillingStrategy";
+
+    private final BillingClient billingClient;
+
+
+    public BillingStrategy(BillingClient billingClient) {
+        this.billingClient = billingClient;
+    }
+
+    public Single<GPProductInfo> queryGoodsDetails(String productType, @NonNull String productId, @Nullable String basePlanId, @Nullable String legacyProductId) {
+        return QueryGoods.queryGoodsDetails(billingClient, productType, productId, basePlanId, legacyProductId);
+    }
+
+
+    public Single<BillingResult> subscriptionGoods(Activity activity, Object productDetails, String basePlanId, String orderNo) {
+        return Subscription.subscriptionGoods(billingClient, activity, productDetails, basePlanId, orderNo);
+    }
+
+
+}

+ 317 - 0
app/src/main/java/com/atmob/voiceai/sdk/billing/GPBillingClient.java

@@ -0,0 +1,317 @@
+package com.atmob.voiceai.sdk.billing;
+
+
+import android.app.Activity;
+import android.app.Application;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+import androidx.lifecycle.DefaultLifecycleObserver;
+import androidx.lifecycle.LifecycleOwner;
+import androidx.lifecycle.ProcessLifecycleOwner;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingClientStateListener;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.PendingPurchasesParams;
+import com.android.billingclient.api.Purchase;
+import com.android.billingclient.api.PurchasesUpdatedListener;
+import com.atmob.common.logging.AtmobLog;
+import com.atmob.common.runtime.ProcessUtil;
+import com.atmob.voiceai.sdk.billing.bean.GPProductInfo;
+import com.atmob.voiceai.utils.ToastUtil;
+
+import java.util.List;
+import java.util.concurrent.LinkedBlockingQueue;
+import java.util.concurrent.ThreadPoolExecutor;
+import java.util.concurrent.TimeUnit;
+import java.util.concurrent.TimeoutException;
+import java.util.concurrent.atomic.AtomicInteger;
+import java.util.concurrent.locks.ReentrantLock;
+
+import javax.inject.Inject;
+import javax.inject.Singleton;
+
+import atmob.reactivex.rxjava3.android.schedulers.AndroidSchedulers;
+import atmob.reactivex.rxjava3.core.Observable;
+import atmob.reactivex.rxjava3.core.ObservableSource;
+import atmob.reactivex.rxjava3.core.Observer;
+import atmob.reactivex.rxjava3.core.Single;
+import atmob.reactivex.rxjava3.core.SingleSource;
+import atmob.reactivex.rxjava3.disposables.Disposable;
+import atmob.reactivex.rxjava3.functions.Function;
+import atmob.reactivex.rxjava3.schedulers.Schedulers;
+
+@Singleton
+public class GPBillingClient implements PurchasesUpdatedListener {
+
+    private static final String TAG = GPBillingClient.class.getSimpleName();
+
+    private final int BILLING_CLIENT_CONNECTED_TIMEOUT = 5000;
+    private final int TRY_CON_MAX_POW_TIMES = 3;
+
+    private final int TRY_URGENT_CON_TIMES = 300;
+
+    private final int TRY_URGENT_DELAY_TIME_MILLISECONDS = 200;
+
+    private final BillingClient billingClient;
+
+    private Disposable powConnectionDisposable;
+    private Disposable urgentConnectionDisposable;
+
+    private final ThreadPoolExecutor threadPoolExecutor;
+
+    private final ReentrantLock lock = new ReentrantLock();
+
+    private Runnable urgentConnectionRunnable;
+
+    private final BillingStrategy billingStrategy;
+
+    @Inject
+    public GPBillingClient(Application context) {
+        billingClient = BillingClient.newBuilder(context)
+                .setListener(this)
+                .enablePendingPurchases(PendingPurchasesParams.newBuilder().enablePrepaidPlans().enableOneTimeProducts().build())
+                .build();
+        billingStrategy = new BillingStrategy(billingClient);
+        threadPoolExecutor = new ThreadPoolExecutor(1, 1, 0L, TimeUnit.MILLISECONDS, new LinkedBlockingQueue<>());
+        addApplicationLifecycleListener();
+    }
+
+
+    /**
+     * 开启重要的尝试连接操作
+     */
+    public void startUrgentConnection() {
+        urgentConnectionRunnable = () -> {
+            if (billingClient.isReady()) {
+                return;
+            }
+            doUrgentConnection();
+        };
+        threadPoolExecutor.execute(urgentConnectionRunnable);
+    }
+
+    /**
+     * 关闭重要的尝试连接操作
+     */
+    public void endUrgentConnection() {
+        AtmobLog.d(TAG, "End connection.——Urgent");
+        if (urgentConnectionDisposable != null && !urgentConnectionDisposable.isDisposed()) {
+            urgentConnectionDisposable.dispose();
+        }
+        while (lock.getHoldCount() > 0) {
+            lock.unlock();
+        }
+        threadPoolExecutor.remove(urgentConnectionRunnable);
+    }
+
+
+    private void doUrgentConnection() {
+        Observable.create(emitter -> {
+                    billingClient.startConnection(new BillingClientStateListener() {
+                        @Override
+                        public void onBillingServiceDisconnected() {
+                            AtmobLog.e(TAG, "onBillingServiceDisconnected() called.——Urgent");
+                            emitter.onError(new RuntimeException("Billing service disconnected.——Urgent"));
+                        }
+
+                        @Override
+                        public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
+                            AtmobLog.d(TAG, "onBillingSetupFinished() called with: billingResult = [" + billingResult + "]——Urgent");
+                            if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+                                emitter.onNext(billingResult);
+                                emitter.onComplete();
+                            } else {
+                                emitter.onError(new RuntimeException("Billing setup failed.——Urgent"));
+                            }
+                        }
+                    });
+                })
+                .retryWhen(new Function<Observable<Throwable>, ObservableSource<?>>() {
+
+                    private final AtomicInteger count = new AtomicInteger(0);
+
+                    @Override
+                    public ObservableSource<?> apply(Observable<Throwable> throwableObservable) {
+                        return throwableObservable.takeWhile(throwable -> {
+                                    if (count.getAndIncrement() < TRY_URGENT_CON_TIMES) {
+                                        return true;
+                                    }
+                                    throw throwable;
+                                })
+                                .flatMap((Function<Throwable, ObservableSource<?>>) throwable -> Observable.timer(TRY_URGENT_DELAY_TIME_MILLISECONDS, TimeUnit.MILLISECONDS));
+                    }
+                })
+
+                .subscribe(new Observer<Object>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        urgentConnectionDisposable = d;
+                        lock.lock();
+                    }
+
+                    @Override
+                    public void onNext(@NonNull Object o) {
+
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+                        AtmobLog.d(TAG, "Billing connection retry times exceed max retry times, stop retry.——Urgent");
+                    }
+
+                    @Override
+                    public void onComplete() {
+                        AtmobLog.d(TAG, "Billing connection success.——Urgent");
+                        lock.unlock();
+                    }
+                });
+    }
+
+
+    /**
+     * 应用启动时尝试连接 billing client
+     */
+    private void addApplicationLifecycleListener() {
+        ProcessLifecycleOwner.get().getLifecycle().addObserver(new DefaultLifecycleObserver() {
+
+            @Override
+            public void onCreate(@NonNull LifecycleOwner owner) {
+                if (!billingClient.isReady()) {
+                    AtmobLog.d(TAG, "Billing client is not ready, start connection.");
+                    startPowConnection();
+                }
+            }
+
+            @Override
+            public void onDestroy(@NonNull LifecycleOwner owner) {
+                endPowConnection();
+            }
+        });
+    }
+
+    private void endPowConnection() {
+        AtmobLog.d(TAG, "End connection.");
+        if (billingClient.isReady()) {
+            AtmobLog.d(TAG, "Billing client is ready, end connection.");
+            billingClient.endConnection();
+        }
+        if (powConnectionDisposable != null && !powConnectionDisposable.isDisposed()) {
+            powConnectionDisposable.dispose();
+        }
+    }
+
+    private void startPowConnection() {
+        Observable.create(emitter -> {
+                    billingClient.startConnection(new BillingClientStateListener() {
+                        @Override
+                        public void onBillingServiceDisconnected() {
+                            AtmobLog.d(TAG, "Billing service disconnected.");
+                            emitter.onError(new RuntimeException("Billing service disconnected."));
+                        }
+
+                        @Override
+                        public void onBillingSetupFinished(@NonNull BillingResult billingResult) {
+                            AtmobLog.d(TAG, "Billing setup finished with result: " + billingResult.getResponseCode() + " " + billingResult.getDebugMessage());
+                            if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+                                emitter.onNext(billingResult);
+                                emitter.onComplete();
+                            } else {
+                                emitter.onError(new RuntimeException("Billing setup failed."));
+                            }
+                        }
+                    });
+                })
+                .retryWhen(new Function<Observable<Throwable>, ObservableSource<?>>() {
+
+                    private final AtomicInteger count = new AtomicInteger(0);
+
+                    @Override
+                    public ObservableSource<?> apply(Observable<Throwable> throwableObservable) {
+                        return throwableObservable.takeWhile(throwable -> {
+                                    if (count.getAndIncrement() < TRY_CON_MAX_POW_TIMES) {
+                                        return true;
+                                    }
+                                    throw throwable;
+                                })
+                                .flatMap((Function<Throwable, ObservableSource<?>>) throwable -> {
+                                    int i = count.get();
+                                    int delay = (int) Math.pow(2, i);
+                                    return Observable.timer(delay, TimeUnit.SECONDS);
+                                });
+                    }
+                })
+                .subscribeOn(Schedulers.io())
+                .observeOn(AndroidSchedulers.mainThread())
+                .subscribe(new Observer<Object>() {
+                    @Override
+                    public void onSubscribe(@NonNull Disposable d) {
+                        powConnectionDisposable = d;
+                    }
+
+                    @Override
+                    public void onNext(@NonNull Object o) {
+
+                    }
+
+                    @Override
+                    public void onError(@NonNull Throwable e) {
+                        AtmobLog.d(TAG, "Billing connection retry times exceed max retry times, stop retry.");
+                    }
+
+                    @Override
+                    public void onComplete() {
+                        AtmobLog.d(TAG, "Billing connection success.");
+                    }
+                });
+    }
+
+
+    @Override
+    public void onPurchasesUpdated(@NonNull BillingResult billingResult, @Nullable List<Purchase> list) {
+        ToastUtil.show("onPurchasesUpdated() called with: billingResult = [" + billingResult + "], list = [" + list + "]", ToastUtil.LENGTH_SHORT);
+        AtmobLog.d(TAG, "onPurchasesUpdated() called with: billingResult = [" + billingResult + "], list = [" + list + "]");
+    }
+
+
+    public Single<BillingResult> subscriptionGoods(Activity activity, Object productDetails, String basePlanId, String orderNo) {
+        return Single.fromCallable(() -> {
+                    if (ProcessUtil.isMainThread()) {
+                        throw new IllegalStateException("launchBillingFlow must not be called on the main thread");
+                    }
+                    if (lock.tryLock(BILLING_CLIENT_CONNECTED_TIMEOUT, TimeUnit.MILLISECONDS)) {
+                        lock.unlock();
+                        return true;
+                    } else {
+                        throw new TimeoutException();
+                    }
+                })
+                .flatMap((Function<Boolean, SingleSource<BillingResult>>) aBoolean
+                        -> billingStrategy.subscriptionGoods(activity, productDetails, basePlanId, orderNo))
+                .map(billingResult -> {
+                    if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+                        return billingResult;
+                    } else {
+                        throw new IllegalStateException("launchBillingFlow failed billingResult: " + billingResult);
+                    }
+                });
+    }
+
+
+    public Single<GPProductInfo> querySkuDetails(String productType, @NonNull String productId, @Nullable String basePlanId, @Nullable String legacyProductId) {
+        return Single.fromCallable(() -> {
+                    if (ProcessUtil.isMainThread()) {
+                        throw new IllegalStateException("querySkuDetails must not be called on the main thread");
+                    }
+                    if (lock.tryLock(BILLING_CLIENT_CONNECTED_TIMEOUT, TimeUnit.MILLISECONDS)) {
+                        lock.unlock();
+                        return true;
+                    } else {
+                        throw new TimeoutException();
+                    }
+                })
+                .flatMap((Function<Boolean, SingleSource<GPProductInfo>>) aBoolean
+                        -> billingStrategy.queryGoodsDetails(productType, productId, basePlanId, legacyProductId));
+    }
+}

+ 27 - 0
app/src/main/java/com/atmob/voiceai/sdk/billing/bean/GPProductInfo.java

@@ -0,0 +1,27 @@
+package com.atmob.voiceai.sdk.billing.bean;
+
+public class GPProductInfo {
+    private final String formatPrice;
+
+    private final long amount;
+
+    private final Object productDetails;
+
+    public GPProductInfo(String formatPrice, long amount, Object productDetails) {
+        this.formatPrice = formatPrice;
+        this.amount = amount;
+        this.productDetails = productDetails;
+    }
+
+    public long getAmount() {
+        return amount;
+    }
+
+    public String getFormatPrice() {
+        return formatPrice;
+    }
+
+    public Object getProductDetails() {
+        return productDetails;
+    }
+}

+ 98 - 0
app/src/main/java/com/atmob/voiceai/sdk/billing/operation/QueryGoods.java

@@ -0,0 +1,98 @@
+package com.atmob.voiceai.sdk.billing.operation;
+
+import androidx.annotation.NonNull;
+import androidx.annotation.Nullable;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.ProductDetails;
+import com.android.billingclient.api.QueryProductDetailsParams;
+import com.android.billingclient.api.SkuDetails;
+import com.android.billingclient.api.SkuDetailsParams;
+import com.atmob.common.logging.AtmobLog;
+import com.atmob.voiceai.sdk.billing.bean.GPProductInfo;
+
+import java.util.Collections;
+import java.util.List;
+import java.util.Objects;
+
+import atmob.reactivex.rxjava3.core.Single;
+
+public class QueryGoods {
+
+    private static final String TAG = QueryGoods.class.getSimpleName();
+
+    public static Single<GPProductInfo> queryGoodsDetails(BillingClient billingClient, String productType, @NonNull String productId, @Nullable String basePlanId, @Nullable String legacyProductId) {
+        if (billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS).getResponseCode() == BillingClient.BillingResponseCode.OK) {
+            return queryProductDetails(billingClient, productType, productId, basePlanId, legacyProductId);
+        } else {
+            return querySkuDetails(billingClient, productType, productId, basePlanId, legacyProductId);
+        }
+    }
+
+    private static Single<GPProductInfo> queryProductDetails(BillingClient billingClient, String productType, @NonNull String productId, @Nullable String basePlanId, @Nullable String legacyProductId) {
+        AtmobLog.i(TAG, "querySkuDetails() called with: productType = [" + productType + "], productId = [" + productId + "], basePlanId = [" + basePlanId + "]");
+        QueryProductDetailsParams productDetailsParams = QueryProductDetailsParams.newBuilder()
+                .setProductList(Collections.singletonList(QueryProductDetailsParams.Product.newBuilder()
+                        .setProductType(productType)
+                        .setProductId(productId)
+                        .build()))
+                .build();
+        return Single.create(emitter ->
+                billingClient.queryProductDetailsAsync(productDetailsParams, (billingResult, list) -> {
+                    AtmobLog.d(TAG, "onProductDetailsResponse() called with: billingResult = [" + billingResult + "], list = [" + list + "]");
+                    if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+                        if (!list.isEmpty()) {
+                            ProductDetails productDetails = list.get(0);
+                            String price = "";
+                            long amount = 0;
+                            ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails = getSubscriptionOfferDetails(productDetails, basePlanId);
+                            if (subscriptionOfferDetails != null) {
+                                List<ProductDetails.PricingPhase> pricingPhaseList = subscriptionOfferDetails.getPricingPhases().getPricingPhaseList();
+                                ProductDetails.PricingPhase pricingPhase = pricingPhaseList.get(pricingPhaseList.size() - 1);
+                                price = pricingPhase.getFormattedPrice();
+                                amount = pricingPhase.getPriceAmountMicros();
+                            }
+                            emitter.onSuccess(new GPProductInfo(price, amount, productDetails));
+                        } else {
+                            emitter.onError(new IllegalStateException("Failed to query sku details, empty list"));
+                        }
+                    } else {
+                        emitter.onError(new IllegalStateException("Failed to query sku details, billingResult: " + billingResult));
+                    }
+                }));
+    }
+
+
+    private static ProductDetails.SubscriptionOfferDetails getSubscriptionOfferDetails(ProductDetails details, String basePlanId) {
+        if (details.getSubscriptionOfferDetails() == null || details.getSubscriptionOfferDetails().isEmpty())
+            return null;
+        for (ProductDetails.SubscriptionOfferDetails subscriptionOfferDetail : details.getSubscriptionOfferDetails()) {
+            if (Objects.equals(subscriptionOfferDetail.getBasePlanId(), basePlanId)) {
+                return subscriptionOfferDetail;
+            }
+        }
+        return null;
+    }
+
+    private static Single<GPProductInfo> querySkuDetails(BillingClient billingClient, String productType, @NonNull String productId, @Nullable String basePlanId, @Nullable String legacyProductId) {
+        AtmobLog.i(TAG, "querySkuDetails() called with: productType = [" + productType + "], productId = [" + productId + "], legacyProductId = [" + legacyProductId + "]");
+        SkuDetailsParams detailsParams = SkuDetailsParams.newBuilder()
+                .setType(productType)
+                .setSkusList(Collections.singletonList(legacyProductId != null ? legacyProductId : productId))
+                .build();
+        return Single.create(emitter -> billingClient.querySkuDetailsAsync(detailsParams, (billingResult, list) -> {
+            AtmobLog.d(TAG, "onSkuDetailsResponse() called with: billingResult = [" + billingResult + "], list = [" + list + "]");
+            if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) {
+                if (list != null && !list.isEmpty()) {
+                    SkuDetails skuDetails = list.get(0);
+                    emitter.onSuccess(new GPProductInfo(skuDetails.getPrice(), skuDetails.getPriceAmountMicros(), skuDetails));
+                } else {
+                    emitter.onError(new IllegalStateException("Failed to query sku details, empty list"));
+                }
+            } else {
+                emitter.onError(new IllegalStateException("Failed to query sku details, billingResult: " + billingResult));
+            }
+        }));
+    }
+
+}

+ 87 - 0
app/src/main/java/com/atmob/voiceai/sdk/billing/operation/Subscription.java

@@ -0,0 +1,87 @@
+package com.atmob.voiceai.sdk.billing.operation;
+
+import android.app.Activity;
+
+import com.android.billingclient.api.BillingClient;
+import com.android.billingclient.api.BillingFlowParams;
+import com.android.billingclient.api.BillingResult;
+import com.android.billingclient.api.ProductDetails;
+import com.android.billingclient.api.SkuDetails;
+import com.atmob.common.logging.AtmobLog;
+
+import java.util.Collections;
+import java.util.Objects;
+
+import atmob.reactivex.rxjava3.core.Single;
+
+public class Subscription {
+
+    private static final String TAG = Subscription.class.getSimpleName();
+
+
+    public static Single<BillingResult> subscriptionGoods(BillingClient billingClient, Activity activity, Object productDetails, String basePlanId, String orderNo) {
+        if (billingClient.isFeatureSupported(BillingClient.FeatureType.PRODUCT_DETAILS).getResponseCode() == BillingClient.BillingResponseCode.OK) {
+            return launchProductBillingFlow(billingClient, activity, productDetails, basePlanId, orderNo);
+        } else {
+            return launchSkuBillingFlow(billingClient, activity, productDetails, orderNo);
+        }
+    }
+
+
+    private static Single<BillingResult> launchProductBillingFlow(BillingClient billingClient, Activity activity, Object productDetails, String basePlanId, String orderNo) {
+        AtmobLog.i(TAG, "launchBillingFlow() called with: productDetails = [" + productDetails + "], orderNo = [" + orderNo + "]");
+        ProductDetails details = productDetails instanceof ProductDetails ? ((ProductDetails) productDetails) : null;
+        if (details == null) {
+            return Single.error(new IllegalStateException("Failed to launch billing flow, product details is null"));
+        }
+        String offerToken = "";
+        ProductDetails.SubscriptionOfferDetails subscriptionOfferDetails = getSubscriptionOfferDetails(details, basePlanId);
+        if (subscriptionOfferDetails != null) {
+            offerToken = subscriptionOfferDetails.getOfferToken();
+        }
+        BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
+                .setObfuscatedAccountId(orderNo)
+                .setIsOfferPersonalized(true)
+                .setProductDetailsParamsList(Collections.singletonList(
+                        BillingFlowParams.ProductDetailsParams.newBuilder()
+                                .setProductDetails(details)
+                                .setOfferToken(offerToken)
+                                .build()))
+                .build();
+        return Single.fromCallable(() -> {
+            BillingResult billingResult = billingClient.launchBillingFlow(activity, billingFlowParams);
+            AtmobLog.d(TAG, "launchBillingFlow() called with: billingResult = [" + billingResult + "]");
+            return billingResult;
+        });
+    }
+
+
+    private static Single<BillingResult> launchSkuBillingFlow(BillingClient billingClient, Activity activity, Object productDetails, String orderNo) {
+        AtmobLog.i(TAG, "launchBillingFlow() called with: productDetails = [" + productDetails + "], orderNo = [" + orderNo + "]");
+        SkuDetails skuDetails = productDetails instanceof SkuDetails ? ((SkuDetails) productDetails) : null;
+        if (skuDetails == null) {
+            return Single.error(new IllegalStateException("Failed to launch billing flow, sku details is null"));
+        }
+        BillingFlowParams billingFlowParams = BillingFlowParams.newBuilder()
+                .setIsOfferPersonalized(true)
+                .setSkuDetails(skuDetails)
+                .setObfuscatedAccountId(orderNo)
+                .build();
+        return Single.fromCallable(() -> {
+            BillingResult billingResult = billingClient.launchBillingFlow(activity, billingFlowParams);
+            AtmobLog.d(TAG, "launchBillingFlow() called with: billingResult = [" + billingResult + "]");
+            return billingResult;
+        });
+    }
+
+    private static ProductDetails.SubscriptionOfferDetails getSubscriptionOfferDetails(ProductDetails details, String basePlanId) {
+        if (details.getSubscriptionOfferDetails() == null || details.getSubscriptionOfferDetails().isEmpty())
+            return null;
+        for (ProductDetails.SubscriptionOfferDetails subscriptionOfferDetail : details.getSubscriptionOfferDetails()) {
+            if (Objects.equals(subscriptionOfferDetail.getBasePlanId(), basePlanId)) {
+                return subscriptionOfferDetail;
+            }
+        }
+        return null;
+    }
+}

+ 103 - 6
app/src/main/java/com/atmob/voiceai/utils/MediaStoreHelper.java

@@ -1,16 +1,20 @@
 package com.atmob.voiceai.utils;
 
+import android.Manifest;
 import android.content.ContentResolver;
 import android.content.ContentValues;
 import android.content.Context;
+import android.content.Intent;
 import android.net.Uri;
 import android.os.Build;
+import android.os.Environment;
 import android.os.ParcelFileDescriptor;
 import android.provider.MediaStore;
 
 import androidx.annotation.IntDef;
 import androidx.annotation.RequiresApi;
 
+import com.atmob.common.permission.PermissionUtil;
 import com.atmob.common.runtime.ContextUtil;
 
 import java.io.File;
@@ -22,7 +26,6 @@ import java.lang.annotation.RetentionPolicy;
 
 public class MediaStoreHelper {
 
-
     public static final int TYPE_IMAGE = 1;
 
     public static final int TYPE_VIDEO = 2;
@@ -35,11 +38,11 @@ public class MediaStoreHelper {
 
     @Retention(RetentionPolicy.SOURCE)
     @IntDef({TYPE_IMAGE, TYPE_VIDEO, TYPE_AUDIO, TYPE_FILES, TYPE_DOWNLOADS})
-    @interface MediaType {
+    public @interface MediaType {
     }
 
-
     public static void saveToSharedStorage(@MediaType int mediaType, File file, String fileName) throws Exception {
+        assertPermission();
         if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.O) {
             saveToSharedStorage(mediaType, java.nio.file.Files.newInputStream(file.toPath()), fileName);
         } else {
@@ -47,6 +50,14 @@ public class MediaStoreHelper {
         }
     }
 
+    private static void assertPermission() {
+        if (Build.VERSION.SDK_INT < Build.VERSION_CODES.Q) {
+            if (!PermissionUtil.hasPermission(Manifest.permission.WRITE_EXTERNAL_STORAGE)) {
+                throw new IllegalStateException("Missing WRITE_EXTERNAL_STORAGE permission.");
+            }
+        }
+    }
+
     public static void saveToSharedStorage(@MediaType int mediaType, InputStream inputStream, String fileName) throws Exception {
         // Add a media item that other apps don't see until the item is
         // fully written to the media store.
@@ -67,7 +78,14 @@ public class MediaStoreHelper {
             mediaDetails.put(targetMedia.isPending(), 1);
         }
 
-        Uri mediaContentUri = resolver.insert(mediaCollection, mediaDetails);
+        Uri mediaContentUri;
+        try {
+            mediaContentUri = resolver.insert(mediaCollection, mediaDetails);
+        } catch (Exception ignore) {
+            // downgrade to the old way.
+            saveToExternalStorage(targetMedia, inputStream, fileName);
+            return;
+        }
 
         if (mediaContentUri == null) {
             throw new IllegalStateException("Failed to create new media item.");
@@ -99,6 +117,51 @@ public class MediaStoreHelper {
         }
     }
 
+    private static void saveToExternalStorage(Media media, InputStream inputStream, String fileName) throws Exception {
+        File externalDir = media.getExternalDir();
+        if (!Environment.isExternalStorageEmulated(externalDir)) {
+            throw new IllegalStateException("External storage is not emulated.");
+        }
+        if (!externalDir.exists() && !externalDir.mkdirs()) {
+            throw new IllegalStateException("Failed to create external storage directory: " + externalDir);
+        }
+        File targetFile = new File(externalDir, fileName);
+        targetFile = createNotRepeatingFile(targetFile, 1);
+        try (FileOutputStream fos = new FileOutputStream(targetFile)) {
+            byte[] buf = new byte[8192];
+            int len;
+            while ((len = inputStream.read(buf)) > 0) {
+                fos.write(buf, 0, len);
+            }
+            fos.flush();
+        } catch (Exception e) {
+            throw new IllegalStateException("Failed to save media file to external storage.", e);
+        }
+        // scan the file to make it visible to other apps.
+        sendMediaScanBroadcast(targetFile);
+    }
+
+    private static void sendMediaScanBroadcast(File targetFile) {
+        Context applicationContext = ContextUtil.getContext().getApplicationContext();
+        applicationContext.sendBroadcast(new Intent(Intent.ACTION_MEDIA_SCANNER_SCAN_FILE, Uri.fromFile(targetFile)));
+    }
+
+    private static File createNotRepeatingFile(File targetFile, int index) throws Exception {
+        if (targetFile.exists()) {
+            String name = targetFile.getName();
+            int dotIndex = name.lastIndexOf('.');
+            String prefix = name.substring(0, dotIndex);
+            String suffix = name.substring(dotIndex);
+            targetFile = new File(targetFile.getParentFile(), prefix + "(" + index + ")" + suffix);
+            return createNotRepeatingFile(targetFile, index + 1);
+        } else {
+            if (!targetFile.createNewFile()) {
+                throw new IllegalStateException("Failed to create new file: " + targetFile);
+            }
+        }
+        return targetFile;
+    }
+
     private static Media getTargetMedia(int mediaType) {
         switch (mediaType) {
             case TYPE_IMAGE:
@@ -135,10 +198,16 @@ public class MediaStoreHelper {
             return MediaStore.Images.Media.DISPLAY_NAME;
         }
 
+        @RequiresApi(api = Build.VERSION_CODES.Q)
         @Override
         public String isPending() {
             return MediaStore.Images.Media.IS_PENDING;
         }
+
+        @Override
+        public File getExternalDir() {
+            return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_PICTURES);
+        }
     }
 
     private static class Video implements Media {
@@ -158,10 +227,16 @@ public class MediaStoreHelper {
             return MediaStore.Video.Media.DISPLAY_NAME;
         }
 
+        @RequiresApi(api = Build.VERSION_CODES.Q)
         @Override
         public String isPending() {
             return MediaStore.Video.Media.IS_PENDING;
         }
+
+        @Override
+        public File getExternalDir() {
+            return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MOVIES);
+        }
     }
 
     private static class Audio implements Media {
@@ -181,10 +256,16 @@ public class MediaStoreHelper {
             return MediaStore.Audio.Media.DISPLAY_NAME;
         }
 
+        @RequiresApi(api = Build.VERSION_CODES.Q)
         @Override
         public String isPending() {
             return MediaStore.Audio.Media.IS_PENDING;
         }
+
+        @Override
+        public File getExternalDir() {
+            return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_MUSIC);
+        }
     }
 
     private static class Files implements Media {
@@ -203,10 +284,16 @@ public class MediaStoreHelper {
             return MediaStore.Files.FileColumns.DISPLAY_NAME;
         }
 
+        @RequiresApi(api = Build.VERSION_CODES.Q)
         @Override
         public String isPending() {
             return MediaStore.Files.FileColumns.IS_PENDING;
         }
+
+        @Override
+        public File getExternalDir() {
+            return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOCUMENTS);
+        }
     }
 
     private static class Downloads implements Media {
@@ -222,17 +309,27 @@ public class MediaStoreHelper {
             return MediaStore.Downloads.DISPLAY_NAME;
         }
 
+        @RequiresApi(api = Build.VERSION_CODES.Q)
         @Override
         public String isPending() {
             return MediaStore.Downloads.IS_PENDING;
         }
+
+        @Override
+        public File getExternalDir() {
+            return Environment.getExternalStoragePublicDirectory(Environment.DIRECTORY_DOWNLOADS);
+        }
     }
 
-    private interface Media {
+    private interface Media extends MediaDir {
         Uri getMediaCollection();
 
         String displayName();
 
         String isPending();
     }
-}
+
+    private interface MediaDir {
+        File getExternalDir();
+    }
+}

+ 2 - 2
app/src/main/res/layout/item_sub_goods_list.xml

@@ -68,7 +68,7 @@
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_marginTop="4dp"
-            android:text="@{goodsBean.content}"
+            android:text="@{goodsBean.priceContent}"
             android:textColor="@color/white50"
             android:textSize="12sp"
             app:layout_constraintBottom_toBottomOf="@+id/iv_goods_select"
@@ -81,7 +81,7 @@
             android:layout_width="wrap_content"
             android:layout_height="wrap_content"
             android:layout_marginEnd="20dp"
-            android:text="@{@string/sub_goods_price(goodsBean.getDollarAmount(2))}"
+            android:text="@{goodsBean.gpProductInfo.formatPrice}"
             android:textColor="@color/white"
             android:textSize="20sp"
             android:textStyle="bold"

+ 0 - 1
app/src/main/res/values/strings.xml

@@ -98,7 +98,6 @@
     <string name="sub_remove_ads">Remove Ads</string>
     <string name="sub_unlimited">Unlimited</string>
     <string name="sub_now">Subscribe Now</string>
-    <string name="sub_goods_price">$%s</string>
     <string name="dialog_retention_member_title">Are You Sure? This Is A</string>
     <string name="dialog_retention_member_content">LIMITED-TIME \nOFFER</string>
     <string name="dialog_renewal_member_hint">You won\'t have this opportunity again!</string>

+ 5 - 4
build.gradle

@@ -2,12 +2,12 @@
 buildscript {
     ext {
         compileSdkVersion = 34
-        applicationId = "com.funnyvoiceai.clonevoice"
+        applicationId = "com.shimeji.nice"
         minSdkVersion = 21
-        targetSdkVersion = 32
+        targetSdkVersion = 33
 
-        versionCode = 1
-        versionName = "1.0.0"
+        versionCode = 99
+        versionName = "1.43.26"
 
         hilt_version = '2.41'
         lifecycle_version = "2.6.1"
@@ -28,6 +28,7 @@ buildscript {
         constraintlayout_version = "2.1.4"
         stringfog_version = "4.0.1"
         media3_version = "1.3.1"
+        billing_version = "7.0.0"
     }
 
     dependencies {