diff --git a/app/build.gradle b/app/build.gradle index 60fc72c089..0184a48ba1 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -132,6 +132,7 @@ android { dimension "all" buildConfigField "boolean", "BETA_RELEASE", "true" buildConfigField "boolean", "PLAY_STORE_RELEASE", "false" + buildConfigField "boolean", "AMAZON_RELEASE", "false" buildConfigField "String", "PRO_FEATURES_URI", "\"https://email.faircode.eu/donate/\"" buildConfigField "String", "CHANGELOG", "\"https://github.com/M66B/FairEmail/releases/\"" buildConfigField "String", "GITHUB_LATEST_API", "\"https://api.github.com/repos/M66B/FairEmail/releases/latest\"" @@ -142,6 +143,7 @@ android { dimension "all" buildConfigField "boolean", "BETA_RELEASE", "true" buildConfigField "boolean", "PLAY_STORE_RELEASE", "false" + buildConfigField "boolean", "AMAZON_RELEASE", "false" buildConfigField "String", "PRO_FEATURES_URI", "\"https://email.faircode.eu/donate/\"" buildConfigField "String", "CHANGELOG", "\"https://github.com/M66B/FairEmail/releases/\"" buildConfigField "String", "GITHUB_LATEST_API", "\"https://api.github.com/repos/M66B/FairEmail/releases/latest\"" @@ -153,6 +155,19 @@ android { //minSdkVersion 23 buildConfigField "boolean", "BETA_RELEASE", "true" buildConfigField "boolean", "PLAY_STORE_RELEASE", "true" + buildConfigField "boolean", "AMAZON_RELEASE", "false" + buildConfigField "String", "PRO_FEATURES_URI", "\"https://email.faircode.eu/#pro\"" + buildConfigField "String", "CHANGELOG", "\"\"" + buildConfigField "String", "GITHUB_LATEST_API", "\"\"" + buildConfigField "String", "GITHUB_LATEST_URI", "\"\"" + buildConfigField "String", "GRAVATAR_URI", "\"\"" + } + amazon { + dimension "all" + //minSdkVersion 23 + buildConfigField "boolean", "BETA_RELEASE", "true" + buildConfigField "boolean", "PLAY_STORE_RELEASE", "false" + buildConfigField "boolean", "AMAZON_RELEASE", "true" buildConfigField "String", "PRO_FEATURES_URI", "\"https://email.faircode.eu/#pro\"" buildConfigField "String", "CHANGELOG", "\"\"" buildConfigField "String", "GITHUB_LATEST_API", "\"\"" @@ -166,7 +181,7 @@ android { // Builds: release, debug // Flavors: github, fdroid, play if (variant.buildType.name == "debug" && - (flavors.contains("fdroid") || flavors.contains("play"))) { + (flavors.contains("fdroid") || flavors.contains("play") || flavors.contains("_amazon"))) { setIgnore(true) } } @@ -236,7 +251,7 @@ configurations.all { } dependencies { - implementation fileTree(dir: 'libs', include: ['*.jar']) + //implementation fileTree(dir: 'libs', include: ['*.jar']) def startup_version = "1.1.0-beta01" def annotation_version = "1.1.0" @@ -380,6 +395,9 @@ dependencies { githubImplementation "com.android.billingclient:billing:$billingclient_version" playImplementation "com.android.billingclient:billing:$billingclient_version" + // https://developer.amazon.com/docs/in-app-purchasing/iap-get-started.html + amazonImplementation files('lib/in-app-purchasing-2.0.76.jar') + // https://javaee.github.io/javamail/ // https://projects.eclipse.org/projects/ee4j.javamail // https://mvnrepository.com/artifact/com.sun.mail diff --git a/app/lib/in-app-purchasing-2.0.76.jar b/app/lib/in-app-purchasing-2.0.76.jar new file mode 100644 index 0000000000..cd590eb9b9 Binary files /dev/null and b/app/lib/in-app-purchasing-2.0.76.jar differ diff --git a/app/proguard-rules.pro b/app/proguard-rules.pro index 1923c0aa3b..9b83c3d21e 100644 --- a/app/proguard-rules.pro +++ b/app/proguard-rules.pro @@ -132,3 +132,8 @@ -keep class org.commonmark.** {*;} -keepnames class io.noties.markwon.** {*;} -keepnames class org.commonmark.** {*;} + +#Amazon IAP +-dontwarn com.amazon.** +-keep class com.amazon.** {*;} +-keepattributes *Annotation* diff --git a/app/src/amazon/AndroidManifest.xml b/app/src/amazon/AndroidManifest.xml new file mode 100644 index 0000000000..58bc635abf --- /dev/null +++ b/app/src/amazon/AndroidManifest.xml @@ -0,0 +1,479 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/app/src/amazon/java/eu/faircode/email/ActivityBilling.java b/app/src/amazon/java/eu/faircode/email/ActivityBilling.java new file mode 100644 index 0000000000..773ed9c6c8 --- /dev/null +++ b/app/src/amazon/java/eu/faircode/email/ActivityBilling.java @@ -0,0 +1,368 @@ +package eu.faircode.email; + +/* + This file is part of FairEmail. + + FairEmail is free software: you can redistribute it and/or modify + it under the terms of the GNU General Public License as published by + the Free Software Foundation, either version 3 of the License, or + (at your option) any later version. + + FairEmail is distributed in the hope that it will be useful, + but WITHOUT ANY WARRANTY; without even the implied warranty of + MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the + GNU General Public License for more details. + + You should have received a copy of the GNU General Public License + along with FairEmail. If not, see . + + Copyright 2018-2021 by Marcel Bokhorst (M66B) +*/ + +import android.annotation.SuppressLint; +import android.content.BroadcastReceiver; +import android.content.Context; +import android.content.Intent; +import android.content.IntentFilter; +import android.content.SharedPreferences; +import android.net.Uri; +import android.os.Bundle; +import android.provider.Settings; +import android.text.TextUtils; + +import androidx.annotation.NonNull; +import androidx.fragment.app.FragmentManager; +import androidx.fragment.app.FragmentTransaction; +import androidx.lifecycle.Lifecycle; +import androidx.lifecycle.LifecycleObserver; +import androidx.lifecycle.LifecycleOwner; +import androidx.lifecycle.OnLifecycleEvent; +import androidx.localbroadcastmanager.content.LocalBroadcastManager; +import androidx.preference.PreferenceManager; + +import com.amazon.device.iap.PurchasingListener; +import com.amazon.device.iap.PurchasingService; +import com.amazon.device.iap.model.FulfillmentResult; +import com.amazon.device.iap.model.Product; +import com.amazon.device.iap.model.ProductDataResponse; +import com.amazon.device.iap.model.PurchaseResponse; +import com.amazon.device.iap.model.PurchaseUpdatesResponse; +import com.amazon.device.iap.model.Receipt; +import com.amazon.device.iap.model.RequestId; +import com.amazon.device.iap.model.UserDataResponse; + +import java.security.NoSuchAlgorithmException; +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Map; +import java.util.Set; + +public class ActivityBilling extends ActivityBase implements PurchasingListener, FragmentManager.OnBackStackChangedListener { + private boolean standalone = false; + private String currentUserId; + private String currentMarketplace; + private List listeners = new ArrayList<>(); + + static final String ACTION_PURCHASE = BuildConfig.APPLICATION_ID + ".ACTION_PURCHASE"; + static final String ACTION_PURCHASE_CONSUME = BuildConfig.APPLICATION_ID + ".ACTION_PURCHASE_CONSUME"; + static final String ACTION_PURCHASE_ERROR = BuildConfig.APPLICATION_ID + ".ACTION_PURCHASE_ERROR"; + + @Override + @SuppressLint("MissingSuperCall") + protected void onCreate(Bundle savedInstanceState) { + onCreate(savedInstanceState, true); + } + + protected void onCreate(Bundle savedInstanceState, boolean standalone) { + super.onCreate(savedInstanceState); + + this.standalone = standalone; + + if (standalone) { + setContentView(R.layout.activity_billing); + + FragmentTransaction fragmentTransaction = getSupportFragmentManager().beginTransaction(); + fragmentTransaction.replace(R.id.content_frame, new FragmentPro()).addToBackStack("pro"); + fragmentTransaction.commit(); + + getSupportActionBar().setDisplayHomeAsUpEnabled(true); + + getSupportFragmentManager().addOnBackStackChangedListener(this); + } + + if (Helper.isAmazonInstall() || isTesting(this)) { + Log.i("IAB start sandbox=" + PurchasingService.IS_SANDBOX_MODE); + PurchasingService.registerListener(this.getApplicationContext(), this); + } + } + + @Override + public void onBackStackChanged() { + if (getSupportFragmentManager().getBackStackEntryCount() == 0) + finish(); + } + + @Override + protected void onResume() { + super.onResume(); + + if (standalone) { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this); + IntentFilter iff = new IntentFilter(); + iff.addAction(ACTION_PURCHASE); + iff.addAction(ACTION_PURCHASE_CONSUME); + iff.addAction(ACTION_PURCHASE_ERROR); + lbm.registerReceiver(receiver, iff); + } + + update(); + } + + @Override + protected void onPause() { + super.onPause(); + + if (standalone) { + LocalBroadcastManager lbm = LocalBroadcastManager.getInstance(this); + lbm.unregisterReceiver(receiver); + } + } + + @Override + protected void onDestroy() { + super.onDestroy(); + } + + @NonNull + static String getSkuPro() { + return BuildConfig.APPLICATION_ID.replace(".debug", "") + ".pro"; + } + + static boolean isTesting(Context context) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + return (BuildConfig.DEBUG && prefs.getBoolean("test_iab", false)); + } + + private static String getChallenge(Context context) throws NoSuchAlgorithmException { + String android_id = Settings.Secure.getString(context.getContentResolver(), Settings.Secure.ANDROID_ID); + return Helper.sha256(android_id); + } + + private static String getResponse(Context context) throws NoSuchAlgorithmException { + return Helper.sha256(BuildConfig.APPLICATION_ID.replace(".debug", "") + getChallenge(context)); + } + + static boolean activatePro(Context context, Uri data) throws NoSuchAlgorithmException { + String response = data.getQueryParameter("response"); + return activatePro(context, response); + } + + static boolean activatePro(Context context, String response) throws NoSuchAlgorithmException { + String challenge = getChallenge(context); + Log.i("IAB challenge=" + challenge); + Log.i("IAB response=" + response); + String expected = getResponse(context); + if (expected.equals(response)) { + Log.i("IAB response valid"); + + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(context); + prefs.edit() + .putBoolean("pro", true) + .putBoolean("play_store", false) + .apply(); + + WidgetUnified.updateData(context); + return true; + } else { + Log.i("IAB response invalid"); + return false; + } + } + + static boolean isPro(Context context) { + if (BuildConfig.DEBUG && false) + return true; + return PreferenceManager.getDefaultSharedPreferences(context) + .getBoolean("pro", false); + } + + private BroadcastReceiver receiver = new BroadcastReceiver() { + @Override + public void onReceive(Context context, Intent intent) { + if (getLifecycle().getCurrentState().isAtLeast(Lifecycle.State.STARTED)) { + if (ACTION_PURCHASE.equals(intent.getAction())) + onPurchase(intent); + else if (ACTION_PURCHASE_CONSUME.equals(intent.getAction())) + onPurchaseConsume(intent); + else if (ACTION_PURCHASE_ERROR.equals(intent.getAction())) + onPurchaseError(intent); + } + } + }; + + private void onPurchase(Intent intent) { + if (Helper.isAmazonInstall() || isTesting(this)) { + String skuPro = getSkuPro(); + Log.i("IAB purchase SKU=" + skuPro); + RequestId requestId = PurchasingService.purchase(skuPro); + Log.i("IAB request=" + requestId); + } else + try { + Uri uri = Uri.parse(BuildConfig.PRO_FEATURES_URI + + "?challenge=" + getChallenge(this) + + "&version=" + BuildConfig.VERSION_CODE); + Helper.view(this, uri, true); + } catch (NoSuchAlgorithmException ex) { + Log.unexpectedError(getSupportFragmentManager(), ex); + } + } + + private void onPurchaseConsume(Intent intent) { + PurchasingService.getPurchaseUpdates(true); + } + + private void onPurchaseError(Intent intent) { + String message = intent.getStringExtra("message"); + Uri uri = Uri.parse(Helper.SUPPORT_URI); + if (!TextUtils.isEmpty(message)) + uri = uri.buildUpon().appendQueryParameter("message", "IAB: " + message).build(); + Helper.view(this, uri, true); + } + + private void update() { + if (Helper.isAmazonInstall() || isTesting(this)) { + Log.i("IAB update"); + PurchasingService.getUserData(); + PurchasingService.getPurchaseUpdates(true); // TODO: reset? + Set skus = new HashSet<>(); + skus.add(getSkuPro()); + PurchasingService.getProductData(skus); + } + } + + @Override + public void onUserDataResponse(UserDataResponse response) { + Log.i("IAB user data status=" + response.getRequestStatus()); + + switch (response.getRequestStatus()) { + case SUCCESSFUL: + Log.i("IAB user=" + response.getUserData().toString().replace('\n', '|')); + currentUserId = response.getUserData().getUserId(); + currentMarketplace = response.getUserData().getMarketplace(); + for (IBillingListener listener : listeners) + listener.onConnected(); + break; + + case FAILED: + case NOT_SUPPORTED: + break; + } + } + + @Override + public void onPurchaseUpdatesResponse(PurchaseUpdatesResponse response) { + Log.i("IAB purchase updates status=" + response.getRequestStatus()); + + switch (response.getRequestStatus()) { + case SUCCESSFUL: + for (Receipt receipt : response.getReceipts()) + handle(receipt); + + if (response.hasMore()) + PurchasingService.getPurchaseUpdates(false); + break; + + case FAILED: + break; + } + } + + @Override + public void onProductDataResponse(ProductDataResponse response) { + Log.i("IAB product data status=" + response.getRequestStatus()); + + switch (response.getRequestStatus()) { + case SUCCESSFUL: + for (final String sku : response.getUnavailableSkus()) + Log.i("IAB unavailable sku=" + sku); + + Map products = response.getProductData(); + for (final String key : products.keySet()) { + Product product = products.get(key); + Log.i("IAB product=" + product.toString().replace('\n', '|')); + if (getSkuPro().equals(product.getSku())) + for (IBillingListener listener : listeners) + listener.onSkuDetails(product.getSku(), product.getPrice()); + } + break; + + case FAILED: + break; + } + } + + @Override + public void onPurchaseResponse(PurchaseResponse response) { + Log.i("IAB purchase response status=" + response.getRequestStatus()); + + switch (response.getRequestStatus()) { + case SUCCESSFUL: + handle(response.getReceipt()); + break; + + case FAILED: + break; + } + } + + private void handle(Receipt receipt) { + Log.i("IAB receipt=" + receipt.toString().replace('\n', '|') + + " canceled=" + receipt.isCanceled() + "/" + receipt.getCancelDate()); + + if (getSkuPro().equals(receipt.getSku())) { + SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); + if (receipt.isCanceled()) { + prefs.edit().remove("pro").apply(); + PurchasingService.notifyFulfillment(receipt.getReceiptId(), FulfillmentResult.UNAVAILABLE); + } else { + prefs.edit().putBoolean("pro", true).apply(); + PurchasingService.notifyFulfillment(receipt.getReceiptId(), FulfillmentResult.FULFILLED); + } + + WidgetUnified.updateData(this); + + for (IBillingListener listener : listeners) + listener.onPurchased(receipt.getSku(), !receipt.isCanceled()); + } + } + + interface IBillingListener { + void onConnected(); + + void onDisconnected(); + + void onSkuDetails(String sku, String price); + + void onPurchasePending(String sku); + + void onPurchased(String sku, boolean purchased); + + void onError(String message); + } + + void addBillingListener(final IBillingListener listener, LifecycleOwner owner) { + Log.i("IAB adding billing listener=" + listener); + listeners.add(listener); + + update(); + + owner.getLifecycle().addObserver(new LifecycleObserver() { + @OnLifecycleEvent(Lifecycle.Event.ON_DESTROY) + public void onDestroyed() { + Log.i("IAB removing billing listener=" + listener); + listeners.remove(listener); + } + }); + } +} diff --git a/app/src/main/java/eu/faircode/email/Helper.java b/app/src/main/java/eu/faircode/email/Helper.java index 0584285c45..4e1a78d038 100644 --- a/app/src/main/java/eu/faircode/email/Helper.java +++ b/app/src/main/java/eu/faircode/email/Helper.java @@ -471,6 +471,10 @@ public class Helper { return BuildConfig.PLAY_STORE_RELEASE; } + static boolean isAmazonInstall() { + return BuildConfig.AMAZON_RELEASE; + } + static boolean hasPlayStore(Context context) { if (hasPlayStore == null) try {