From 36772fa7f298506f31811fdfd63a456da3acdc56 Mon Sep 17 00:00:00 2001 From: bunnei Date: Thu, 23 Apr 2020 22:06:52 -0400 Subject: [PATCH] android: Implement billing for Premium. --- src/android/app/build.gradle | 2 + .../settings/model/view/PremiumHeader.java | 14 ++ .../settings/model/view/SettingsItem.java | 8 + .../features/settings/ui/SettingsAdapter.java | 21 ++- .../ui/SettingsFragmentPresenter.java | 2 + .../ui/viewholder/PremiumViewHolder.java | 57 ++++++ .../citra/citra_emu/ui/main/MainActivity.java | 23 +++ .../citra_emu/ui/main/MainPresenter.java | 5 + .../citra/citra_emu/utils/BillingManager.java | 167 ++++++++++++++++++ .../main/res/layout/premium_item_setting.xml | 42 +++++ .../app/src/main/res/menu/menu_game_grid.xml | 5 +- .../app/src/main/res/values/strings.xml | 9 +- 12 files changed, 351 insertions(+), 4 deletions(-) create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java create mode 100644 src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java create mode 100644 src/android/app/src/main/res/layout/premium_item_setting.xml diff --git a/src/android/app/build.gradle b/src/android/app/build.gradle index 731e86664..f5e71995e 100644 --- a/src/android/app/build.gradle +++ b/src/android/app/build.gradle @@ -119,4 +119,6 @@ dependencies { implementation 'androidx.constraintlayout:constraintlayout:1.1.3' implementation 'androidx.localbroadcastmanager:localbroadcastmanager:1.0.0' implementation 'androidx.swiperefreshlayout:swiperefreshlayout:1.0.0' + + implementation 'com.android.billingclient:billing:2.2.0' } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java new file mode 100644 index 000000000..8942bf13a --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/PremiumHeader.java @@ -0,0 +1,14 @@ +package org.citra.citra_emu.features.settings.model.view; + +import org.citra.citra_emu.features.settings.model.Setting; + +public final class PremiumHeader extends SettingsItem { + public PremiumHeader() { + super(null, null, null, 0, 0); + } + + @Override + public int getType() { + return SettingsItem.TYPE_PREMIUM; + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java index 10d43da82..305352022 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/model/view/SettingsItem.java @@ -1,6 +1,7 @@ package org.citra.citra_emu.features.settings.model.view; import org.citra.citra_emu.features.settings.model.Setting; +import org.citra.citra_emu.features.settings.model.Settings; import org.citra.citra_emu.features.settings.ui.SettingsAdapter; /** @@ -19,6 +20,7 @@ public abstract class SettingsItem { public static final int TYPE_INPUT_BINDING = 5; public static final int TYPE_STRING_SINGLE_CHOICE = 6; public static final int TYPE_DATETIME_SETTING = 7; + public static final int TYPE_PREMIUM = 8; private String mKey; private String mSection; @@ -27,6 +29,7 @@ public abstract class SettingsItem { private int mNameId; private int mDescriptionId; + private boolean mIsPremium; /** * Base constructor. Takes a key / section name in case the third parameter, the Setting, @@ -45,6 +48,7 @@ public abstract class SettingsItem { mSetting = setting; mNameId = nameId; mDescriptionId = descriptionId; + mIsPremium = (section == Settings.SECTION_PREMIUM); } /** @@ -89,6 +93,10 @@ public abstract class SettingsItem { return mDescriptionId; } + public boolean isPremium() { + return mIsPremium; + } + /** * Used by {@link SettingsAdapter}'s onCreateViewHolder() * method to determine which type of ViewHolder should be created. diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java index 1236e1207..eca7e12e9 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsAdapter.java @@ -30,10 +30,12 @@ import org.citra.citra_emu.features.settings.ui.viewholder.CheckBoxSettingViewHo import org.citra.citra_emu.features.settings.ui.viewholder.DateTimeViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.HeaderViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.InputBindingSettingViewHolder; +import org.citra.citra_emu.features.settings.ui.viewholder.PremiumViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SettingViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SingleChoiceViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SliderViewHolder; import org.citra.citra_emu.features.settings.ui.viewholder.SubmenuViewHolder; +import org.citra.citra_emu.ui.main.MainActivity; import org.citra.citra_emu.utils.Log; import java.util.ArrayList; @@ -92,6 +94,9 @@ public final class SettingsAdapter extends RecyclerView.Adapter onSingleChoiceClick(item)); + } + public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) { mClickedItem = item; mClickedPosition = position; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java index be1e03bb7..b2eda82c3 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/SettingsFragmentPresenter.java @@ -16,6 +16,7 @@ import org.citra.citra_emu.features.settings.model.view.SingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SliderSetting; import org.citra.citra_emu.features.settings.model.view.StringSingleChoiceSetting; import org.citra.citra_emu.features.settings.model.view.SubmenuSetting; +import org.citra.citra_emu.features.settings.model.view.PremiumHeader; import org.citra.citra_emu.features.settings.utils.SettingsFile; import java.util.ArrayList; @@ -130,6 +131,7 @@ public final class SettingsFragmentPresenter { SettingSection premiumSection = mSettings.getSection(Settings.SECTION_PREMIUM); Setting design = premiumSection.getSetting(SettingsFile.KEY_DESIGN); + sl.add(new PremiumHeader()); sl.add(new SingleChoiceSetting(SettingsFile.KEY_DESIGN, Settings.SECTION_PREMIUM, R.string.design, 0, R.array.designNames, R.array.designValues, 0, design)); } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java new file mode 100644 index 000000000..be0853ff0 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/features/settings/ui/viewholder/PremiumViewHolder.java @@ -0,0 +1,57 @@ +package org.citra.citra_emu.features.settings.ui.viewholder; + +import android.view.View; +import android.widget.TextView; + +import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.view.SettingsItem; +import org.citra.citra_emu.features.settings.ui.SettingsAdapter; +import org.citra.citra_emu.features.settings.ui.SettingsFragmentView; +import org.citra.citra_emu.ui.main.MainActivity; + +public final class PremiumViewHolder extends SettingViewHolder { + private TextView mHeaderName; + private TextView mTextDescription; + private SettingsFragmentView mView; + + public PremiumViewHolder(View itemView, SettingsAdapter adapter, SettingsFragmentView view) { + super(itemView, adapter); + mView = view; + itemView.setOnClickListener(this); + } + + @Override + protected void findViews(View root) { + mHeaderName = root.findViewById(R.id.text_setting_name); + mTextDescription = root.findViewById(R.id.text_setting_description); + } + + @Override + public void bind(SettingsItem item) { + updateText(); + } + + @Override + public void onClick(View clicked) { + if (MainActivity.isPremiumActive()) { + return; + } + + // Invoke billing flow if Premium is not already active, then refresh the UI to indicate + // the purchase has completed. + MainActivity.invokePremiumBilling(() -> updateText()); + } + + /** + * Update the text shown to the user, based on whether Premium is active + */ + private void updateText() { + if (MainActivity.isPremiumActive()) { + mHeaderName.setText(R.string.premium_settings_welcome); + mTextDescription.setText(R.string.premium_settings_welcome_description); + } else { + mHeaderName.setText(R.string.premium_settings_upsell); + mTextDescription.setText(R.string.premium_settings_upsell_description); + } + } +} diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java index 4d3febf2e..5621ee3ad 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainActivity.java @@ -18,6 +18,7 @@ import org.citra.citra_emu.features.settings.ui.SettingsActivity; import org.citra.citra_emu.model.GameProvider; import org.citra.citra_emu.ui.platform.PlatformGamesFragment; import org.citra.citra_emu.utils.AddDirectoryHelper; +import org.citra.citra_emu.utils.BillingManager; import org.citra.citra_emu.utils.DirectoryInitialization; import org.citra.citra_emu.utils.FileBrowserHelper; import org.citra.citra_emu.utils.PermissionsHandler; @@ -35,6 +36,9 @@ public final class MainActivity extends AppCompatActivity implements MainView { private MainPresenter mPresenter = new MainPresenter(this); + // Singleton to manage user billing state + private static BillingManager mBillingManager; + @Override protected void onCreate(Bundle savedInstanceState) { super.onCreate(savedInstanceState); @@ -61,6 +65,9 @@ public final class MainActivity extends AppCompatActivity implements MainView { mPlatformGamesFragment = (PlatformGamesFragment) getSupportFragmentManager().getFragment(savedInstanceState, "mPlatformGamesFragment"); } + // Setup billing manager, so we can globally query for Premium status + mBillingManager = new BillingManager(this); + // Dismiss previous notifications (should not happen unless a crash occurred) EmulationActivity.tryDismissRunningNotification(this); } @@ -194,4 +201,20 @@ public final class MainActivity extends AppCompatActivity implements MainView { EmulationActivity.tryDismissRunningNotification(this); super.onDestroy(); } + + /** + * @return true if Premium subscription is currently active + */ + public static boolean isPremiumActive() { + return mBillingManager.isPremiumActive(); + } + + /** + * Invokes the billing flow for Premium + * + * @param callback Optional callback, called once, on completion of billing + */ + public static void invokePremiumBilling(Runnable callback) { + mBillingManager.invokePremiumBilling(callback); + } } diff --git a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java index 7631a10b4..cf8d57fbd 100644 --- a/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java +++ b/src/android/app/src/main/java/org/citra/citra_emu/ui/main/MainPresenter.java @@ -4,6 +4,7 @@ package org.citra.citra_emu.ui.main; import org.citra.citra_emu.BuildConfig; import org.citra.citra_emu.CitraApplication; import org.citra.citra_emu.R; +import org.citra.citra_emu.features.settings.model.Settings; import org.citra.citra_emu.features.settings.utils.SettingsFile; import org.citra.citra_emu.model.GameDatabase; import org.citra.citra_emu.utils.AddDirectoryHelper; @@ -33,6 +34,10 @@ public final class MainPresenter { case R.id.button_add_directory: mView.launchFileListActivity(); return true; + + case R.id.button_premium: + mView.launchSettingsActivity(Settings.SECTION_PREMIUM); + return true; } return false; diff --git a/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java new file mode 100644 index 000000000..34bf48a50 --- /dev/null +++ b/src/android/app/src/main/java/org/citra/citra_emu/utils/BillingManager.java @@ -0,0 +1,167 @@ +package org.citra.citra_emu.utils; + +import android.app.Activity; + +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.BillingResult; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; + +import java.util.ArrayList; +import java.util.List; + +public class BillingManager implements PurchasesUpdatedListener { + private final String BILLING_SKU_PREMIUM = "android.test.purchased"; + + private final Activity mActivity; + private BillingClient mBillingClient; + private SkuDetails mSkuPremium; + private boolean mIsPremiumActive = false; + private boolean mIsServiceConnected = false; + private Runnable mUpdateBillingCallback; + + public BillingManager(Activity activity) { + mActivity = activity; + mBillingClient = BillingClient.newBuilder(mActivity).enablePendingPurchases().setListener(this).build(); + querySkuDetails(); + } + + /** + * @return true if Premium subscription is currently active + */ + public boolean isPremiumActive() { + return mIsPremiumActive; + } + + /** + * Invokes the billing flow for Premium + * + * @param callback Optional callback, called once, on completion of billing + */ + public void invokePremiumBilling(Runnable callback) { + if (mSkuPremium == null) { + return; + } + + // Optional callback to refresh the UI for the caller when billing completes + mUpdateBillingCallback = callback; + + // Invoke the billing flow + BillingFlowParams flowParams = BillingFlowParams.newBuilder() + .setSkuDetails(mSkuPremium) + .build(); + mBillingClient.launchBillingFlow(mActivity, flowParams); + } + + @Override + public void onPurchasesUpdated(BillingResult billingResult, List purchaseList) { + if (purchaseList == null || purchaseList.isEmpty()) { + // Premium is not active, or billing is unavailable + return; + } + + Purchase premiumPurchase = null; + for (Purchase purchase : purchaseList) { + if (purchase.getSku().equals(BILLING_SKU_PREMIUM)) { + premiumPurchase = purchase; + } + } + + if (premiumPurchase != null) { + // Premium has been purchased + mIsPremiumActive = true; + + if (mUpdateBillingCallback != null) { + try { + mUpdateBillingCallback.run(); + } catch (Exception e) { + e.printStackTrace(); + } + mUpdateBillingCallback = null; + } + } + } + + private void onQuerySkuDetailsFinished(List skuDetailsList) { + if (skuDetailsList.isEmpty()) { + return; + } + + mSkuPremium = skuDetailsList.get(0); + + queryPurchases(); + } + + private void querySkuDetails() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); + List skuList = new ArrayList<>(); + + skuList.add(BILLING_SKU_PREMIUM); + params.setSkusList(skuList).setType(BillingClient.SkuType.INAPP); + + mBillingClient.querySkuDetailsAsync(params.build(), + (billingResult, skuDetailsList) -> onQuerySkuDetailsFinished(skuDetailsList)); + } + }; + + executeServiceRequest(queryToExecute); + } + + private void onQueryPurchasesFinished(PurchasesResult result) { + // Have we been disposed of in the meantime? If so, or bad result code, then quit + if (mBillingClient == null || result.getResponseCode() != BillingClient.BillingResponseCode.OK) { + return; + } + // Update the UI and purchases inventory with new list of purchases + onPurchasesUpdated(result.getBillingResult(), result.getPurchasesList()); + } + + private void queryPurchases() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + final PurchasesResult purchasesResult = mBillingClient.queryPurchases(BillingClient.SkuType.INAPP); + onQueryPurchasesFinished(purchasesResult); + } + }; + + executeServiceRequest(queryToExecute); + } + + private void startServiceConnection(final Runnable executeOnFinish) { + mBillingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(BillingResult billingResult) { + if (billingResult.getResponseCode() == BillingClient.BillingResponseCode.OK) { + mIsServiceConnected = true; + } + + if (executeOnFinish != null) { + executeOnFinish.run(); + } + } + + @Override + public void onBillingServiceDisconnected() { + mIsServiceConnected = false; + } + }); + } + + private void executeServiceRequest(Runnable runnable) { + if (mIsServiceConnected) { + runnable.run(); + } else { + // If billing service was disconnected, we try to reconnect 1 time. + startServiceConnection(runnable); + } + } +} diff --git a/src/android/app/src/main/res/layout/premium_item_setting.xml b/src/android/app/src/main/res/layout/premium_item_setting.xml new file mode 100644 index 000000000..d614139d1 --- /dev/null +++ b/src/android/app/src/main/res/layout/premium_item_setting.xml @@ -0,0 +1,42 @@ + + + + + + + + \ No newline at end of file diff --git a/src/android/app/src/main/res/menu/menu_game_grid.xml b/src/android/app/src/main/res/menu/menu_game_grid.xml index 737cfae88..921b2c268 100644 --- a/src/android/app/src/main/res/menu/menu_game_grid.xml +++ b/src/android/app/src/main/res/menu/menu_game_grid.xml @@ -2,12 +2,15 @@ + - Uses the Just-in-Time (JIT) compiler for CPU emulation. When enabled, game performance will be significantly improved. System clock type Set the emulated 3DS clock to either reflect that of your device or start at a simulated date and time. - Design + Change Theme (Light, Dark) System clock starting time override @@ -71,6 +71,13 @@ Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled. Warning: Modifying these settings will slow emulation + + Premium + Upgrade to Premium and support Citra! + With Premium, you will support the developers to continue improving Citra, and gain access to these exclusive features! + Welcome to Premium. + Thank you for your support! + Enable audio stretching Stretches audio to reduce stuttering. When enabled, increases audio latency and slightly reduces performance.