android: Implement billing for Premium.

This commit is contained in:
bunnei 2020-04-23 22:06:52 -04:00
parent 9ee1489524
commit 73fc4515be
12 changed files with 351 additions and 4 deletions

View File

@ -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'
}

View File

@ -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;
}
}

View File

@ -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.

View File

@ -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<SettingViewHolde
view = inflater.inflate(R.layout.list_item_setting, parent, false);
return new DateTimeViewHolder(view, this);
case SettingsItem.TYPE_PREMIUM:
view = inflater.inflate(R.layout.premium_item_setting, parent, false);
return new PremiumViewHolder(view, this, mView);
default:
Log.error("[SettingsAdapter] Invalid view type: " + viewType);
@ -138,9 +143,8 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
mView.onSettingChanged();
}
public void onSingleChoiceClick(SingleChoiceSetting item, int position) {
public void onSingleChoiceClick(SingleChoiceSetting item) {
mClickedItem = item;
mClickedPosition = position;
int value = getSelectionForSingleChoiceValue(item);
@ -152,6 +156,19 @@ public final class SettingsAdapter extends RecyclerView.Adapter<SettingViewHolde
mDialog = builder.show();
}
public void onSingleChoiceClick(SingleChoiceSetting item, int position) {
mClickedPosition = position;
if (!item.isPremium() || MainActivity.isPremiumActive()) {
// Setting is either not Premium, or the user has Premium
onSingleChoiceClick(item);
return;
}
// User needs Premium, invoke the billing flow
MainActivity.invokePremiumBilling(() -> onSingleChoiceClick(item));
}
public void onStringSingleChoiceClick(StringSingleChoiceSetting item, int position) {
mClickedItem = item;
mClickedPosition = position;

View File

@ -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));
}

View File

@ -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);
}
}
}

View File

@ -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);
}
}

View File

@ -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;

View File

@ -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<Purchase> 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<SkuDetails> 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<String> 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);
}
}
}

View File

@ -0,0 +1,42 @@
<?xml version="1.0" encoding="utf-8"?>
<RelativeLayout xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:tools="http://schemas.android.com/tools"
android:layout_width="match_parent"
android:layout_height="wrap_content"
android:background="?android:attr/selectableItemBackground"
android:clickable="true"
android:focusable="true"
android:gravity="center_vertical"
android:minHeight="72dp"
android:paddingTop="@dimen/spacing_large"
android:paddingBottom="@dimen/spacing_large">
<TextView
android:id="@+id/text_setting_name"
style="@style/TextAppearance.AppCompat.Headline"
android:layout_width="0dp"
android:layout_height="wrap_content"
android:layout_alignParentStart="true"
android:layout_alignParentTop="true"
android:layout_alignParentEnd="true"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginEnd="@dimen/spacing_large"
android:textColor="?android:colorAccent"
android:textStyle="bold"
tools:text="Setting Name" />
<TextView
android:id="@+id/text_setting_description"
android:layout_width="wrap_content"
android:layout_height="wrap_content"
android:layout_below="@+id/text_setting_name"
android:layout_alignStart="@+id/text_setting_name"
android:layout_alignParentStart="true"
android:layout_alignParentEnd="true"
android:layout_marginStart="@dimen/spacing_large"
android:layout_marginTop="@dimen/spacing_small"
android:layout_marginEnd="@dimen/spacing_large"
android:visibility="visible"
tools:text="@string/app_disclaimer" />
</RelativeLayout>

View File

@ -2,12 +2,15 @@
<menu xmlns:android="http://schemas.android.com/apk/res/android"
xmlns:app="http://schemas.android.com/apk/res-auto">
<item
android:id="@+id/button_premium"
android:title="@string/premium_text"
app:showAsAction="ifRoom" />
<item
android:id="@+id/button_add_directory"
android:icon="@drawable/ic_folder"
android:title="@string/add_directory_title"
app:showAsAction="ifRoom" />
<item
android:id="@+id/menu_settings_core"
android:icon="@drawable/ic_settings_core"

View File

@ -41,7 +41,7 @@
<string name="cpu_jit_description">Uses the Just-in-Time (JIT) compiler for CPU emulation. When enabled, game performance will be significantly improved.</string>
<string name="init_clock">System clock type</string>
<string name="init_clock_description">Set the emulated 3DS clock to either reflect that of your device or start at a simulated date and time.</string>
<string name="design">Design</string>
<string name="design">Change Theme (Light, Dark)</string>
<!-- System settings strings -->
<string name="init_time">System clock starting time override</string>
@ -71,6 +71,13 @@
<string name="performance_warning">Turning off this setting will significantly reduce emulation performance! For the best experience, it is recommended that you leave this setting enabled.</string>
<string name="debug_warning">Warning: Modifying these settings will slow emulation</string>
<!-- Premium strings -->
<string name="premium_text">Premium</string>
<string name="premium_settings_upsell">Upgrade to Premium and support Citra!</string>
<string name="premium_settings_upsell_description">With Premium, you will support the developers to continue improving Citra, and gain access to these exclusive features!</string>
<string name="premium_settings_welcome">Welcome to Premium.</string>
<string name="premium_settings_welcome_description">Thank you for your support!</string>
<!-- Audio settings strings -->
<string name="audio_stretch">Enable audio stretching</string>
<string name="audio_stretch_description">Stretches audio to reduce stuttering. When enabled, increases audio latency and slightly reduces performance.</string>