diff --git a/.travis.yml b/.travis.yml index 8e8284d1..229e3de6 100644 --- a/.travis.yml +++ b/.travis.yml @@ -5,7 +5,7 @@ android: - tools - platform-tools - build-tools-27.0.3 - - android-23 + - android-27 - extra-android-m2repository - extra-google-google_play_services - extra-google-m2repository diff --git a/app/build.gradle b/app/build.gradle index cac78844..204b3145 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -33,13 +33,13 @@ def gitCommitCount = 2007000 + Integer.parseInt('git rev-list --count HEAD'.execute([], project.rootDir).text.trim()) android { - compileSdkVersion 23 + compileSdkVersion 27 buildToolsVersion '27.0.3' defaultConfig { applicationId 'com.shalzz.attendance' minSdkVersion 14 - targetSdkVersion 23 + targetSdkVersion 27 versionCode gitCommitCount versionName gitTag @@ -129,24 +129,32 @@ android { } dependencies { - final SUPPORT_LIBRARY_VERSION = '23.4.0' + final SUPPORT_LIBRARY_VERSION = '27.1.1' final DAGGER_VERSION = '2.5' final ESPRESSO_VERSION = '2.2.1' final RUNNER_VERSION = '0.4' - final RETROFIT_VERSION = '2.2.0' + final RETROFIT_VERSION = '2.4.0' final BUTTERKNIFE_VERSION = '8.4.0' final AUTO_VALUE_VERSION = '1.5' final AUTO_VALUE_GSON_VERSION = '0.7.0' final AUTO_VALUE_PARCEL_VERSION = '0.2.5' implementation "com.google.android.gms:play-services-analytics:9.4.0" + + implementation "com.android.support:support-compat:$SUPPORT_LIBRARY_VERSION" + implementation "com.android.support:support-core-utils:$SUPPORT_LIBRARY_VERSION" + implementation "com.android.support:support-core-ui:$SUPPORT_LIBRARY_VERSION" + implementation "com.android.support:support-fragment:$SUPPORT_LIBRARY_VERSION" + implementation "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION" implementation "com.android.support:recyclerview-v7:$SUPPORT_LIBRARY_VERSION" - implementation "com.android.support:design:$SUPPORT_LIBRARY_VERSION" implementation "com.android.support:support-v4:$SUPPORT_LIBRARY_VERSION" implementation "com.android.support:appcompat-v7:$SUPPORT_LIBRARY_VERSION" + implementation "com.android.support:preference-v7:$SUPPORT_LIBRARY_VERSION" implementation "com.android.support:preference-v14:$SUPPORT_LIBRARY_VERSION" - implementation "com.android.support:support-annotations:25.3.1" + implementation "com.android.support:support-annotations:$SUPPORT_LIBRARY_VERSION" + implementation "com.android.support:design:$SUPPORT_LIBRARY_VERSION" implementation 'com.android.support.constraint:constraint-layout:1.0.2' + implementation 'com.android.billingclient:billing:1.0' implementation "android.arch.persistence:db:1.0.0" implementation "android.arch.persistence:db-framework:1.0.0" @@ -199,7 +207,7 @@ android { // Instrumentation test dependencies androidTestImplementation jUnit androidTestImplementation mockito - androidTestImplementation "com.android.support:support-annotations:25.3.1" + androidTestImplementation "com.android.support:support-annotations:$SUPPORT_LIBRARY_VERSION" androidTestImplementation("com.android.support.test.espresso:espresso-contrib:$ESPRESSO_VERSION") androidTestImplementation "com.android.support.test.espresso:espresso-core:$ESPRESSO_VERSION" androidTestImplementation "com.android.support.test:runner:$RUNNER_VERSION" diff --git a/app/proguard/proguard-square-okhttp3.pro b/app/proguard/proguard-square-okhttp3.pro index 64e68528..05c095fa 100644 --- a/app/proguard/proguard-square-okhttp3.pro +++ b/app/proguard/proguard-square-okhttp3.pro @@ -1,6 +1,7 @@ # OkHttp --keepattributes Signature --keepattributes *Annotation* --keep class okhttp3.** { *; } --keep interface okhttp3.** { *; } --dontwarn okhttp3.** \ No newline at end of file +-dontwarn okhttp3.** +-dontwarn okio.** +-dontwarn javax.annotation.** +-dontwarn org.conscrypt.** +# A resource is loaded with a relative path so the package of this class must be preserved. +-keepnames class okhttp3.internal.publicsuffix.PublicSuffixDatabase \ No newline at end of file diff --git a/app/proguard/proguard-square-okio.pro b/app/proguard/proguard-square-okio.pro deleted file mode 100644 index d267f5e6..00000000 --- a/app/proguard/proguard-square-okio.pro +++ /dev/null @@ -1,5 +0,0 @@ -## Okio ## --keep class sun.misc.Unsafe { *; } --dontwarn java.nio.file.* --dontwarn org.codehaus.mojo.animal_sniffer.IgnoreJRERequirement --dontwarn okio.** diff --git a/app/src/main/AndroidManifest.xml b/app/src/main/AndroidManifest.xml index ca6b0d08..0bb50cf5 100644 --- a/app/src/main/AndroidManifest.xml +++ b/app/src/main/AndroidManifest.xml @@ -33,7 +33,6 @@ - diff --git a/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl b/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl deleted file mode 100644 index 0092998a..00000000 --- a/app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl +++ /dev/null @@ -1,192 +0,0 @@ -/* - * Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.android.vending.billing; - -import android.os.Bundle; - -/** - * InAppBillingService is the service that provides in-app billing version 3 and beyond. - * This service provides the following features: - * 1. Provides a new API to get details of in-app items published for the app including - * price, type, title and description. - * 2. The purchase flow is synchronous and purchase information is available immediately - * after it completes. - * 3. Purchase information of in-app purchases is maintained within the Google Play system - * till the purchase is consumed. - * 4. An API to consume a purchase of an inapp item. All purchases of one-time - * in-app items are consumable and thereafter can be purchased again. - * 5. An API to get current purchases of the user immediately. This will not contain any - * consumed purchases. - * - * All calls will give a response code with the following possible values - * RESULT_OK = 0 - success - * RESULT_USER_CANCELED = 1 - User pressed back or canceled a dialog - * RESULT_SERVICE_UNAVAILABLE = 2 - The network connection is down - * RESULT_BILLING_UNAVAILABLE = 3 - This billing API version is not supported for the type requested - * RESULT_ITEM_UNAVAILABLE = 4 - Requested SKU is not available for purchase - * RESULT_DEVELOPER_ERROR = 5 - Invalid arguments provided to the API - * RESULT_ERROR = 6 - Fatal error during the API action - * RESULT_ITEM_ALREADY_OWNED = 7 - Failure to purchase since item is already owned - * RESULT_ITEM_NOT_OWNED = 8 - Failure to consume since item is not owned - */ -interface IInAppBillingService { - /** - * Checks support for the requested billing API version, package and in-app type. - * Minimum API version supported by this interface is 3. - * @param apiVersion billing API version that the app is using - * @param packageName the package name of the calling app - * @param type type of the in-app item being purchased ("inapp" for one-time purchases - * and "subs" for subscriptions) - * @return RESULT_OK(0) on success and appropriate response code on failures. - */ - int isBillingSupported(int apiVersion, String packageName, String type); - - /** - * Provides details of a list of SKUs - * Given a list of SKUs of a valid type in the skusBundle, this returns a bundle - * with a list JSON strings containing the productId, price, title and description. - * This API can be called with a maximum of 20 SKUs. - * @param apiVersion billing API version that the app is using - * @param packageName the package name of the calling app - * @param type of the in-app items ("inapp" for one-time purchases - * and "subs" for subscriptions) - * @param skusBundle bundle containing a StringArrayList of SKUs with key "ITEM_ID_LIST" - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes - * on failures. - * "DETAILS_LIST" with a StringArrayList containing purchase information - * in JSON format similar to: - * '{ "productId" : "exampleSku", - * "type" : "inapp", - * "price" : "$5.00", - * "price_currency": "USD", - * "price_amount_micros": 5000000, - * "title : "Example Title", - * "description" : "This is an example description" }' - */ - Bundle getSkuDetails(int apiVersion, String packageName, String type, in Bundle skusBundle); - - /** - * Returns a pending intent to launch the purchase flow for an in-app item by providing a SKU, - * the type, a unique purchase token and an optional developer payload. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param sku the SKU of the in-app item as published in the developer console - * @param type of the in-app item being purchased ("inapp" for one-time purchases - * and "subs" for subscriptions) - * @param developerPayload optional argument to be sent back with the purchase information - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes - * on failures. - * "BUY_INTENT" - PendingIntent to start the purchase flow - * - * The Pending intent should be launched with startIntentSenderForResult. When purchase flow - * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. - * If the purchase is successful, the result data will contain the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response - * codes on failures. - * "INAPP_PURCHASE_DATA" - String in JSON format similar to - * '{"orderId":"12999763169054705758.1371079406387615", - * "packageName":"com.example.app", - * "productId":"exampleSku", - * "purchaseTime":1345678900000, - * "purchaseToken" : "122333444455555", - * "developerPayload":"example developer payload" }' - * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that - * was signed with the private key of the developer - * TODO: change this to app-specific keys. - */ - Bundle getBuyIntent(int apiVersion, String packageName, String sku, String type, - String developerPayload); - - /** - * Returns the current SKUs owned by the user of the type and package name specified along with - * purchase information and a signature of the data to be validated. - * This will return all SKUs that have been purchased in V3 and managed items purchased using - * V1 and V2 that have not been consumed. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param type of the in-app items being requested ("inapp" for one-time purchases - * and "subs" for subscriptions) - * @param continuationToken to be set as null for the first call, if the number of owned - * skus are too many, a continuationToken is returned in the response bundle. - * This method can be called again with the continuation token to get the next set of - * owned skus. - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes - on failures. - * "INAPP_PURCHASE_ITEM_LIST" - StringArrayList containing the list of SKUs - * "INAPP_PURCHASE_DATA_LIST" - StringArrayList containing the purchase information - * "INAPP_DATA_SIGNATURE_LIST"- StringArrayList containing the signatures - * of the purchase information - * "INAPP_CONTINUATION_TOKEN" - String containing a continuation token for the - * next set of in-app purchases. Only set if the - * user has more owned skus than the current list. - */ - Bundle getPurchases(int apiVersion, String packageName, String type, String continuationToken); - - /** - * Consume the last purchase of the given SKU. This will result in this item being removed - * from all subsequent responses to getPurchases() and allow re-purchase of this item. - * @param apiVersion billing API version that the app is using - * @param packageName package name of the calling app - * @param purchaseToken token in the purchase information JSON that identifies the purchase - * to be consumed - * @return RESULT_OK(0) if consumption succeeded, appropriate response codes on failures. - */ - int consumePurchase(int apiVersion, String packageName, String purchaseToken); - - /** - * This API is currently under development. - */ - int stub(int apiVersion, String packageName, String type); - - /** - * Returns a pending intent to launch the purchase flow for upgrading or downgrading a - * subscription. The existing owned SKU(s) should be provided along with the new SKU that - * the user is upgrading or downgrading to. - * @param apiVersion billing API version that the app is using, must be 5 or later - * @param packageName package name of the calling app - * @param oldSkus the SKU(s) that the user is upgrading or downgrading from, - * if null or empty this method will behave like {@link #getBuyIntent} - * @param newSku the SKU that the user is upgrading or downgrading to - * @param type of the item being purchased, currently must be "subs" - * @param developerPayload optional argument to be sent back with the purchase information - * @return Bundle containing the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response codes - * on failures. - * "BUY_INTENT" - PendingIntent to start the purchase flow - * - * The Pending intent should be launched with startIntentSenderForResult. When purchase flow - * has completed, the onActivityResult() will give a resultCode of OK or CANCELED. - * If the purchase is successful, the result data will contain the following key-value pairs - * "RESPONSE_CODE" with int value, RESULT_OK(0) if success, appropriate response - * codes on failures. - * "INAPP_PURCHASE_DATA" - String in JSON format similar to - * '{"orderId":"12999763169054705758.1371079406387615", - * "packageName":"com.example.app", - * "productId":"exampleSku", - * "purchaseTime":1345678900000, - * "purchaseToken" : "122333444455555", - * "developerPayload":"example developer payload" }' - * "INAPP_DATA_SIGNATURE" - String containing the signature of the purchase data that - * was signed with the private key of the developer - * TODO: change this to app-specific keys. - */ - Bundle getBuyIntentToReplaceSkus(int apiVersion, String packageName, - in List oldSkus, String newSku, String type, String developerPayload); -} diff --git a/app/src/main/java/com/shalzz/attendance/MyApplication.java b/app/src/main/java/com/shalzz/attendance/MyApplication.java index 9fd07310..a0edd49e 100644 --- a/app/src/main/java/com/shalzz/attendance/MyApplication.java +++ b/app/src/main/java/com/shalzz/attendance/MyApplication.java @@ -53,7 +53,7 @@ public void onCreate() { Timber.plant(new BugsnagTree()); int nightMode = Integer.parseInt(sharedPref.getString( - getString(R.string.pref_key_day_night), "-1")); + getString(R.string.pref_key_day_night), "1")); //noinspection WrongConstant AppCompatDelegate.setDefaultNightMode(nightMode); } diff --git a/app/src/main/java/com/shalzz/attendance/billing/BillingConstants.java b/app/src/main/java/com/shalzz/attendance/billing/BillingConstants.java new file mode 100644 index 00000000..b24e05d7 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/billing/BillingConstants.java @@ -0,0 +1,47 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.shalzz.attendance.billing; + +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClient.SkuType; +import java.util.Arrays; +import java.util.List; + +/** + * Static fields and methods useful for billing + */ +public final class BillingConstants { + // SKUs for our products: the premium upgrade (non-consumable) and gas (consumable) + public static final String SKU_PRO_KEY = "pro_key"; + public static final String SKU_DONATION_200 = "donation_200"; + public static final String SKU_DONATION_250 = "donation_250"; + + // SKU for our subscription + + private static final String[] IN_APP_SKUS = {SKU_PRO_KEY, SKU_DONATION_200, SKU_DONATION_250}; + private static final String[] SUBSCRIPTIONS_SKUS = {}; + + private BillingConstants(){} + + /** + * Returns the list of all SKUs for the billing type specified + */ + public static final List getSkuList(@BillingClient.SkuType String billingType) { + return (billingType == SkuType.INAPP) ? Arrays.asList(IN_APP_SKUS) + : Arrays.asList(SUBSCRIPTIONS_SKUS); + } +} + diff --git a/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java new file mode 100644 index 00000000..3b267a87 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java @@ -0,0 +1,311 @@ +package com.shalzz.attendance.billing; + +import android.app.Activity; + +import com.android.billingclient.api.BillingClient; +import com.android.billingclient.api.BillingClient.BillingResponse; +import com.android.billingclient.api.BillingClient.FeatureType; +import com.android.billingclient.api.BillingClient.SkuType; +import com.android.billingclient.api.BillingClientStateListener; +import com.android.billingclient.api.BillingFlowParams; +import com.android.billingclient.api.ConsumeResponseListener; +import com.android.billingclient.api.Purchase; +import com.android.billingclient.api.Purchase.PurchasesResult; +import com.android.billingclient.api.PurchasesUpdatedListener; +import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.SkuDetailsResponseListener; +import com.shalzz.attendance.data.DataManager; +import com.shalzz.attendance.utils.RxUtil; + +import java.util.ArrayList; +import java.util.HashSet; +import java.util.List; +import java.util.Set; + +import io.reactivex.Observable; +import io.reactivex.ObservableOnSubscribe; +import io.reactivex.android.schedulers.AndroidSchedulers; +import io.reactivex.disposables.CompositeDisposable; +import io.reactivex.disposables.Disposable; +import io.reactivex.schedulers.Schedulers; +import io.reactivex.subjects.PublishSubject; +import timber.log.Timber; + +public class BillingManager implements PurchasesUpdatedListener { + + // Default value of mBillingClientResponseCode until BillingManager was not yet initialized + public static final int BILLING_MANAGER_NOT_INITIALIZED = -1; + + private BillingClient mBillingClient; + + private final BillingUpdatesListener mBillingUpdatesListener; + private int mBillingClientResponseCode = BILLING_MANAGER_NOT_INITIALIZED; + + private Set mTokensToBeConsumed; + + private Activity mActivity; + private DataManager mDataManager; + private CompositeDisposable mConnectionDisposable = new CompositeDisposable(); + private PublishSubject> publishSubject = PublishSubject.create(); + + /** + * Listener to the updates that happen when purchases list was updated or consumption of the + * item was finished + */ + public interface BillingUpdatesListener { + void onBillingClientSetupFinished(); + void onConsumeFinished(String token, @BillingResponse int result); + void onPurchasesUpdated(List purchases); + } + + public BillingManager(Activity activity, + DataManager dataManager, + final BillingUpdatesListener updatesListener) { + mActivity = activity; + mDataManager = dataManager; + mBillingUpdatesListener = updatesListener; + mBillingClient = BillingClient.newBuilder(mActivity).setListener(this).build(); + + mConnectionDisposable.add( + observePurchasesUpdates().subscribe(mBillingUpdatesListener::onPurchasesUpdated, + Timber::e) + ); + + // Setup all listeners before establishing a connection. + mConnectionDisposable.add( + connect() + .subscribe(code -> { + Timber.d("First Connection. Response code: %d", code); + if (code == BillingResponse.OK) { + // Notify the listener that the billing client is ready. + mBillingUpdatesListener.onBillingClientSetupFinished(); + // IAB is fully setup. Now get an inventory of stuff the user owns. + queryPurchases(); + } + })); + } + + private Observable connect() { + return Observable.create((ObservableOnSubscribe) source -> { + if (source.isDisposed()) return; + if (mBillingClient.isReady()) { + Timber.d("Client already connected. Response code: %d", + mBillingClientResponseCode); + source.onNext(mBillingClientResponseCode); + source.onComplete(); + } else { + mBillingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(@BillingResponse int billingResponseCode) { + Timber.d("Setup finished. Response code: %d", billingResponseCode); + mBillingClientResponseCode = billingResponseCode; + source.onNext(mBillingClientResponseCode); + source.onComplete(); + } + + @Override + public void onBillingServiceDisconnected() { + source.tryOnError(new Throwable("Billing Client disconnected!")); + Timber.w("onBillingServiceDisconnected()"); + } + }); + } + }) + .retry() + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); + } + + /** + * Handle a callback that purchases were updated from the Billing library + */ + @Override + public void onPurchasesUpdated(int resultCode, List purchases) { + if (resultCode == BillingResponse.OK) { + publishSubject.onNext(purchases); + } else if (resultCode == BillingResponse.USER_CANCELED) { + Timber.i("onPurchasesUpdated() - user cancelled the purchase flow - skipping"); + } else { + Timber.w("onPurchasesUpdated() got unknown resultCode: %d", resultCode); + } + } + + public Observable> observePurchasesUpdates() { + return publishSubject.concatMap(this::handlePurchases); + } + + /** + * Handles the purchase + *

Note: Notice that for each purchase, we check if signature is valid. + *

+ * @param purchases Purchases to be handled + */ + private Observable> handlePurchases(List purchases) { + return Observable.fromIterable(purchases) + .flatMap(purchase -> + mDataManager.verifyValidSignature(purchase, mActivity) + .filter(aBoolean -> aBoolean) + .map(aBoolean -> purchase) + ) + .doOnNext(purchase -> Timber.d("Got a verified purchase: %s", purchase)) + .toList() + .toObservable(); + } + + /** + * Start a purchase flow + */ + public void initiatePurchaseFlow(final String skuId, final @SkuType String billingType) { + initiatePurchaseFlow(skuId, null, billingType); + } + + /** + * Start a purchase or subscription replace flow + */ + public void initiatePurchaseFlow(final String skuId, final ArrayList oldSkus, + final @SkuType String billingType) { + + Disposable disposable = connect() + .subscribe(code -> { + if (code == BillingResponse.OK) { + Timber.d("Launching in-app purchase flow. Replace old SKU? %s", + (oldSkus != null)); + BillingFlowParams purchaseParams = BillingFlowParams.newBuilder() + .setSku(skuId).setType(billingType).setOldSkus(oldSkus).build(); + mBillingClient.launchBillingFlow(mActivity, purchaseParams); + } + }); + mConnectionDisposable.add(disposable); + } + + /** + * Returns the value Billing client response code or BILLING_MANAGER_NOT_INITIALIZED if the + * client connection response was not received yet. + */ + public int getBillingClientResponseCode() { + return mBillingClientResponseCode; + } + + /** + * Checks if subscriptions are supported for current client + *

Note: This method does not automatically retry for RESULT_SERVICE_DISCONNECTED. + * It is only used in unit tests and after queryPurchases execution, which already has + * a retry-mechanism implemented. + *

+ */ + private boolean areSubscriptionsSupported() { + int responseCode = mBillingClient.isFeatureSupported(FeatureType.SUBSCRIPTIONS); + if (responseCode != BillingResponse.OK) { + Timber.w("areSubscriptionsSupported() got an error response: %d", responseCode); + } + return responseCode == BillingResponse.OK; + } + + /** + * Handle a result from querying of purchases and report an updated list to the listener + */ + 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() != BillingResponse.OK) { + Timber.w("Billing client was null or result code (%d) was bad - quitting", + result.getResponseCode()); + return; + } + + Timber.d("Query inventory was successful."); + publishSubject.onNext(result.getPurchasesList()); + } + + /** + * Query purchases across various use cases and deliver the result in a formalized way through + * a listener + */ + public void queryPurchases() { + Disposable disposable = connect() + .subscribe(code -> { + if (code == BillingResponse.OK) { + long time = System.currentTimeMillis(); + PurchasesResult purchasesResult = mBillingClient.queryPurchases(SkuType.INAPP); + Timber.i("Querying purchases elapsed time: %s ms", + (System.currentTimeMillis() - time)); + // If there are subscriptions supported, we add subscription rows as well + if (areSubscriptionsSupported()) { + PurchasesResult subscriptionResult + = mBillingClient.queryPurchases(SkuType.SUBS); + Timber.i("Querying purchases and subscriptions elapsed time: %s ms", + (System.currentTimeMillis() - time)); + Timber.i( "Querying subscriptions result code: %d res: %d" + , subscriptionResult.getResponseCode() + , subscriptionResult.getPurchasesList().size()); + + if (subscriptionResult.getResponseCode() == BillingResponse.OK) { + purchasesResult.getPurchasesList().addAll( + subscriptionResult.getPurchasesList()); + } else { + Timber.e( "Got an error response trying to query subscription purchases"); + } + } else if (purchasesResult.getResponseCode() == BillingResponse.OK) { + Timber.i("Skipped subscription purchases query since they are not supported"); + } else { + Timber.w("queryPurchases() got an error response code: %s" + , purchasesResult.getResponseCode()); + } + onQueryPurchasesFinished(purchasesResult); + } + }); + mConnectionDisposable.add(disposable); + } + + public void querySkuDetailsAsync(@SkuType final String itemType, final List skuList, + final SkuDetailsResponseListener listener) { + + Disposable disposable = connect() + .subscribe(code -> { + if (code == BillingResponse.OK) { + // Query the purchase async + SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); + params.setSkusList(skuList).setType(itemType); + mBillingClient.querySkuDetailsAsync(params.build(), listener); + } + }); + mConnectionDisposable.add(disposable); + } + + public void consumeAsync(final String purchaseToken) { + // If we've already scheduled to consume this token - no action is needed (this could happen + // if you received the token when querying purchases inside onReceive() and later from + // onActivityResult() + if (mTokensToBeConsumed == null) { + mTokensToBeConsumed = new HashSet<>(); + } else if (mTokensToBeConsumed.contains(purchaseToken)) { + Timber.i("Token was already scheduled to be consumed - skipping..."); + return; + } + mTokensToBeConsumed.add(purchaseToken); + + final ConsumeResponseListener onConsumeListener = + (responseCode, purchaseToken1) -> + mBillingUpdatesListener.onConsumeFinished(purchaseToken1, responseCode); + + Disposable disposable = connect() + .subscribe(code -> { + if (code == BillingResponse.OK) { + mBillingClient.consumeAsync(purchaseToken, onConsumeListener); + } + }); + mConnectionDisposable.add(disposable); + } + + /** + * Clear the resources + */ + public void destroy() { + Timber.d( "Destroying the manager."); + + RxUtil.dispose(mConnectionDisposable); + if (mBillingClient != null && mBillingClient.isReady()) { + mBillingClient.endConnection(); + mBillingClient = null; + } + } +} diff --git a/app/src/main/java/com/shalzz/attendance/billing/BillingProvider.java b/app/src/main/java/com/shalzz/attendance/billing/BillingProvider.java new file mode 100644 index 00000000..86730210 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/billing/BillingProvider.java @@ -0,0 +1,25 @@ +/* + * Copyright 2017 Google Inc. All Rights Reserved. + * + * Licensed under the Apache License, Version 2.0 (the "License"); + * you may not use this file except in compliance with the License. + * You may obtain a copy of the License at + * + * http://www.apache.org/licenses/LICENSE-2.0 + * + * Unless required by applicable law or agreed to in writing, software + * distributed under the License is distributed on an "AS IS" BASIS, + * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + * See the License for the specific language governing permissions and + * limitations under the License. + */ +package com.shalzz.attendance.billing; + +/** + * An interface that provides an access to BillingLibrary methods + */ +public interface BillingProvider { + BillingManager getBillingManager(); + boolean isProKeyPurchased(); +} + diff --git a/app/src/main/java/com/shalzz/attendance/data/DataManager.java b/app/src/main/java/com/shalzz/attendance/data/DataManager.java index fb65c2ef..46a4cde1 100644 --- a/app/src/main/java/com/shalzz/attendance/data/DataManager.java +++ b/app/src/main/java/com/shalzz/attendance/data/DataManager.java @@ -1,5 +1,8 @@ package com.shalzz.attendance.data; +import android.content.Context; + +import com.android.billingclient.api.Purchase; import com.shalzz.attendance.data.local.DatabaseHelper; import com.shalzz.attendance.data.local.PreferencesHelper; import com.shalzz.attendance.data.model.ListFooter; @@ -7,6 +10,7 @@ import com.shalzz.attendance.data.model.Subject; import com.shalzz.attendance.data.model.User; import com.shalzz.attendance.data.remote.DataAPI; +import com.shalzz.attendance.utils.NetworkUtil; import com.shalzz.attendance.wrapper.DateHelper; import java.util.Date; @@ -76,6 +80,11 @@ public Single getUserCount() { return mDatabaseHelper.getUserCount().first(0); } + public Observable verifyValidSignature(Purchase purchase, Context context) { + return mDataAPI.verifyValidSignature(purchase.getOriginalJson(), + purchase.getSignature()); + } + public void resetTables() { mDatabaseHelper.resetTables(); } diff --git a/app/src/main/java/com/shalzz/attendance/data/remote/DataAPI.java b/app/src/main/java/com/shalzz/attendance/data/remote/DataAPI.java index 51de6924..53967d3e 100644 --- a/app/src/main/java/com/shalzz/attendance/data/remote/DataAPI.java +++ b/app/src/main/java/com/shalzz/attendance/data/remote/DataAPI.java @@ -23,13 +23,13 @@ import com.shalzz.attendance.data.model.Subject; import com.shalzz.attendance.data.model.User; -import java.util.Date; import java.util.List; import io.reactivex.Observable; import retrofit2.http.GET; import retrofit2.http.Header; import retrofit2.http.Path; +import retrofit2.http.Query; public interface DataAPI { @@ -45,4 +45,7 @@ public interface DataAPI { @GET("me/timetable/{date}") Observable> getTimetable(@Path("date") String date); + @GET("verify") + Observable verifyValidSignature(@Query("data") String signedData, + @Query("sig") String signature); } diff --git a/app/src/main/java/com/shalzz/attendance/data/remote/RetrofitException.java b/app/src/main/java/com/shalzz/attendance/data/remote/RetrofitException.java index 6f818f4d..50993092 100644 --- a/app/src/main/java/com/shalzz/attendance/data/remote/RetrofitException.java +++ b/app/src/main/java/com/shalzz/attendance/data/remote/RetrofitException.java @@ -39,7 +39,7 @@ static RetrofitException emptyResponseError(Retrofit retrofit, Context context) } static RetrofitException networkError(IOException exception, Context context) { - String message = context.getString(R.string.no_internet); + String message = context.getString(R.string.generic_server_down); return new RetrofitException(message, null, null, Kind.NETWORK, exception, null); } diff --git a/app/src/main/java/com/shalzz/attendance/data/remote/RxJava2ErrorCallAdapterFactory.java b/app/src/main/java/com/shalzz/attendance/data/remote/RxJava2ErrorCallAdapterFactory.java index e796d82c..4b219645 100644 --- a/app/src/main/java/com/shalzz/attendance/data/remote/RxJava2ErrorCallAdapterFactory.java +++ b/app/src/main/java/com/shalzz/attendance/data/remote/RxJava2ErrorCallAdapterFactory.java @@ -86,7 +86,6 @@ public Object adapt(Call call) .subscribeWith(new DisposableObserver() { @Override public void onNext(Object object) { - Timber.d("next: %s",object); if (object instanceof List && ((List)object).isEmpty() ) { source.tryOnError(RetrofitException. emptyResponseError(retrofit, context)); diff --git a/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java new file mode 100644 index 00000000..8833b239 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java @@ -0,0 +1,52 @@ +package com.shalzz.attendance.data.remote.interceptor; + +import android.content.Context; +import android.support.annotation.NonNull; + +import com.shalzz.attendance.injection.ApplicationContext; +import com.shalzz.attendance.utils.NetworkUtil; + +import java.io.IOException; +import java.util.concurrent.TimeUnit; + +import javax.inject.Inject; + +import okhttp3.CacheControl; +import okhttp3.Interceptor; +import okhttp3.Request; +import okhttp3.Response; + +public class CacheControlInterceptor implements Interceptor { + private Context mContext; + + @Inject + public CacheControlInterceptor(@ApplicationContext Context context) { + mContext = context; + } + + @Override + public Response intercept(@NonNull Chain chain) throws IOException { + if (NetworkUtil.isNetworkConnected(mContext)) { + Response originalResponse = chain.proceed(chain.request()); + int maxAge = 60; // read from cache for 1 minute + return originalResponse.newBuilder() + .header("Cache-Control", "public, max-age=" + maxAge) + .build(); + + } else { + Request request = chain.request(); + // only for the 'verify' api route + if (request.url().encodedPath().equals("/api/v1/verify")) { + CacheControl cacheControl = new CacheControl.Builder() + .onlyIfCached() + .maxStale(7, TimeUnit.DAYS) + .build(); + + request = request.newBuilder() + .cacheControl(cacheControl) + .build(); + } + return chain.proceed(request); + } + } +} diff --git a/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/LoggingInterceptor.java b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/LoggingInterceptor.java index 9a993f53..fa07d980 100644 --- a/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/LoggingInterceptor.java +++ b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/LoggingInterceptor.java @@ -37,9 +37,10 @@ public class LoggingInterceptor implements Interceptor { Response response = chain.proceed(request); long t2 = System.nanoTime(); - Timber.i("Received response %s for %s in %.1fms%n%s", + Timber.i("Received response %s for %s in %.1fms%nstatus: %s %n%scached: %s", response.message(), response.request().url(), - (t2 - t1) / 1e6d, response.headers()); + (t2 - t1) / 1e6d, response.code(), response.headers(), + response.cacheResponse() != null); return response; } diff --git a/app/src/main/java/com/shalzz/attendance/event/ProKeyPurchaseEvent.java b/app/src/main/java/com/shalzz/attendance/event/ProKeyPurchaseEvent.java new file mode 100644 index 00000000..c707f9b5 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/event/ProKeyPurchaseEvent.java @@ -0,0 +1,6 @@ +package com.shalzz.attendance.event; + +public class ProKeyPurchaseEvent { + + public ProKeyPurchaseEvent() { } +} diff --git a/app/src/main/java/com/shalzz/attendance/event/PurchaseEvent.java b/app/src/main/java/com/shalzz/attendance/event/PurchaseEvent.java new file mode 100644 index 00000000..c231fb5b --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/event/PurchaseEvent.java @@ -0,0 +1,17 @@ +package com.shalzz.attendance.event; + +import com.android.billingclient.api.Purchase; + +import java.util.List; + +public class PurchaseEvent { + private List mPurchaseList; + + public PurchaseEvent(List purchaseList) { + mPurchaseList = purchaseList; + } + + public List getPurchases() { + return mPurchaseList; + } +} diff --git a/app/src/main/java/com/shalzz/attendance/injection/component/ApplicationComponent.java b/app/src/main/java/com/shalzz/attendance/injection/component/ApplicationComponent.java index a58c8b8f..ececc30a 100644 --- a/app/src/main/java/com/shalzz/attendance/injection/component/ApplicationComponent.java +++ b/app/src/main/java/com/shalzz/attendance/injection/component/ApplicationComponent.java @@ -34,6 +34,7 @@ import com.shalzz.attendance.ui.settings.AboutSettingsFragment; import com.shalzz.attendance.ui.settings.SettingsFragment; import com.shalzz.attendance.data.local.PreferencesHelper; +import com.shalzz.attendance.utils.RxEventBus; import javax.inject.Named; import javax.inject.Singleton; @@ -53,10 +54,7 @@ public interface ApplicationComponent { PreferencesHelper preferenceManager(); DatabaseHelper databaseHelper(); DataManager dataManager(); + RxEventBus eventBus(); void inject(SyncService syncService); - - void inject(SettingsFragment settingsFragment); - - void inject(AboutSettingsFragment aboutSettingsFragment); } diff --git a/app/src/main/java/com/shalzz/attendance/injection/module/NetworkModule.java b/app/src/main/java/com/shalzz/attendance/injection/module/NetworkModule.java index 0e6acac7..4e975eba 100644 --- a/app/src/main/java/com/shalzz/attendance/injection/module/NetworkModule.java +++ b/app/src/main/java/com/shalzz/attendance/injection/module/NetworkModule.java @@ -31,15 +31,21 @@ import com.shalzz.attendance.data.remote.DataAPI; import com.shalzz.attendance.data.remote.RxJava2ErrorCallAdapterFactory; import com.shalzz.attendance.data.remote.interceptor.AuthInterceptor; +import com.shalzz.attendance.data.remote.interceptor.CacheControlInterceptor; import com.shalzz.attendance.data.remote.interceptor.HeaderInterceptor; import com.shalzz.attendance.data.remote.interceptor.LoggingInterceptor; import com.shalzz.attendance.injection.ApplicationContext; +import java.io.File; + import javax.inject.Singleton; import dagger.Module; import dagger.Provides; +import okhttp3.Cache; +import okhttp3.Interceptor; import okhttp3.OkHttpClient; +import okhttp3.internal.cache.CacheInterceptor; import retrofit2.Retrofit; import retrofit2.converter.gson.GsonConverterFactory; @@ -55,10 +61,18 @@ static Gson provideGson() { } @Provides @Singleton @NonNull - static OkHttpClient provideClient(PreferencesHelper preferences) { + static OkHttpClient provideClient(PreferencesHelper preferences, + @ApplicationContext Context context) { + //setup cache + File httpCacheDirectory = new File(context.getCacheDir(), "responses"); + int cacheSize = 10 * 1024 * 1024; // 10 MiB + Cache cache = new Cache(httpCacheDirectory, cacheSize); + final OkHttpClient.Builder okHttpBuilder = new OkHttpClient.Builder() + .cache(cache) .addInterceptor(new HeaderInterceptor()) .addInterceptor(new AuthInterceptor(preferences)) + .addNetworkInterceptor(new CacheControlInterceptor(context)) .addNetworkInterceptor(new LoggingInterceptor()); return okHttpBuilder.build(); diff --git a/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java b/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java index 722e8083..6a8ad48d 100644 --- a/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java +++ b/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java @@ -20,32 +20,37 @@ package com.shalzz.attendance.sync; import android.accounts.Account; -import android.app.NotificationManager; -import android.app.PendingIntent; import android.content.AbstractThreadedSyncAdapter; import android.content.ContentProviderClient; import android.content.Context; -import android.content.Intent; import android.content.SyncResult; -import android.graphics.BitmapFactory; import android.os.Bundle; -import android.support.v7.app.NotificationCompat; import com.bugsnag.android.Bugsnag; -import com.shalzz.attendance.R; -import com.shalzz.attendance.data.local.PreferencesHelper; -import com.shalzz.attendance.data.remote.DataAPI; -import com.shalzz.attendance.ui.main.MainActivity; +import com.shalzz.attendance.data.DataManager; +import com.shalzz.attendance.data.model.Period; +import com.shalzz.attendance.data.model.Subject; +import com.shalzz.attendance.data.remote.RetrofitException; +import com.shalzz.attendance.utils.RxUtil; +import java.util.Calendar; +import java.util.Date; + +import io.reactivex.Observable; +import io.reactivex.ObservableSource; +import io.reactivex.disposables.Disposable; +import io.reactivex.functions.Function; +import io.reactivex.observers.DisposableObserver; +import io.reactivex.schedulers.Schedulers; import timber.log.Timber; public class SyncAdapter extends AbstractThreadedSyncAdapter { - // Global variables private Context mContext; + private final DataManager mDataManager; - private final PreferencesHelper preferencesManager; - private final DataAPI api; + private Disposable mAttendanceDisposable; + private Disposable mTimetableDisposable; /** * Set up the sync adapter. This form of the @@ -55,15 +60,15 @@ public class SyncAdapter extends AbstractThreadedSyncAdapter { public SyncAdapter( Context context, boolean autoInitialize, - boolean allowParallelSyncs, PreferencesHelper preferencesManager, DataAPI api) { + boolean allowParallelSyncs, + DataManager dataManager) { super(context, autoInitialize, allowParallelSyncs); /* * If your app uses a content resolver, get an instance of it * from the incoming Context */ mContext = context; - this.preferencesManager = preferencesManager; - this.api = api; + mDataManager = dataManager; Bugsnag.setContext("Sync Adapter"); } @@ -71,40 +76,54 @@ public SyncAdapter( public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { Timber.i("Running sync adapter"); - } - /** - * Notifies the user that their timetable has changed. - */ - private void showNotification() { - NotificationCompat.Builder mBuilder = - (NotificationCompat.Builder) new NotificationCompat.Builder(mContext) - .setSmallIcon(R.drawable.ic_stat_human) - .setLargeIcon(BitmapFactory.decodeResource( - mContext.getResources(), - R.mipmap.ic_launcher)) - .setAutoCancel(true) - .setPriority(NotificationCompat.PRIORITY_LOW) - .setCategory(NotificationCompat.CATEGORY_RECOMMENDATION) - .setContentTitle(mContext.getString( - R.string.notify_timetable_changed_title)) - .setContentText(mContext.getString( - R.string.notify_timetable_changed_text)); - - Intent resultIntent = new Intent(mContext, MainActivity.class); - resultIntent.putExtra(MainActivity.LAUNCH_FRAGMENT_EXTRA, MainActivity - .Fragments.TIMETABLE.getValue()); - resultIntent.addFlags(Intent.FLAG_ACTIVITY_CLEAR_TOP | - Intent.FLAG_ACTIVITY_SINGLE_TOP) - .setAction(Intent.ACTION_MAIN) - .addCategory(Intent.CATEGORY_LAUNCHER); - - PendingIntent resultPendingIntent = PendingIntent.getActivity(mContext, - 0, resultIntent,PendingIntent.FLAG_UPDATE_CURRENT); - mBuilder.setContentIntent(resultPendingIntent); - NotificationManager mNotificationManager = - (NotificationManager) mContext.getSystemService( - Context.NOTIFICATION_SERVICE); - mNotificationManager.notify(0, mBuilder.build()); + RxUtil.dispose(mAttendanceDisposable); + mAttendanceDisposable = mDataManager.syncAttendance() + .subscribeOn(Schedulers.io()) + .subscribeWith(new DisposableObserver() { + @Override + public void onNext(Subject subject) { } + + @Override + public void onError(Throwable throwable) { + RetrofitException error = (RetrofitException) throwable; + if (error.getKind() == RetrofitException.Kind.UNEXPECTED) { + Timber.e(throwable); + } + } + + @Override + public void onComplete() { + RxUtil.dispose(mAttendanceDisposable); + } + }); + + RxUtil.dispose(mTimetableDisposable); + mTimetableDisposable = Observable + .range(-3, 7) + .concatMap((Function>) offset -> { + Calendar calendar = Calendar.getInstance(); + calendar.add(Calendar.DATE, offset); + return Observable.just(calendar.getTime()); + }) + .concatMap(mDataManager::syncDay) + .subscribeOn(Schedulers.io()) + .subscribeWith(new DisposableObserver() { + @Override + public void onNext(Period period) { } + + @Override + public void onError(Throwable throwable) { + RetrofitException error = (RetrofitException) throwable; + if (error.getKind() == RetrofitException.Kind.UNEXPECTED) { + Timber.e(throwable); + } + } + + @Override + public void onComplete() { + RxUtil.dispose(mTimetableDisposable); + } + }); } } diff --git a/app/src/main/java/com/shalzz/attendance/sync/SyncService.java b/app/src/main/java/com/shalzz/attendance/sync/SyncService.java index 78039536..ff533126 100644 --- a/app/src/main/java/com/shalzz/attendance/sync/SyncService.java +++ b/app/src/main/java/com/shalzz/attendance/sync/SyncService.java @@ -24,6 +24,7 @@ import android.os.IBinder; import com.shalzz.attendance.MyApplication; +import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.data.local.PreferencesHelper; import com.shalzz.attendance.data.remote.DataAPI; @@ -37,10 +38,7 @@ public class SyncService extends Service { @Inject - PreferencesHelper preferencesManager; - - @Inject - DataAPI api; + DataManager mDataManager; // Storage for an instance of the sync adapter private SyncAdapter sSyncAdapter = null; @@ -60,8 +58,7 @@ public void onCreate() { synchronized (sSyncAdapterLock) { if (sSyncAdapter == null) { sSyncAdapter = new SyncAdapter(getApplicationContext(), true, false, - preferencesManager, - api); + mDataManager); } } } diff --git a/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendanceListFragment.java b/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendanceListFragment.java index 331fc378..080e55d0 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendanceListFragment.java +++ b/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendanceListFragment.java @@ -430,7 +430,7 @@ public void showEmptyView(boolean show) { @Override public void showNetworkErrorView(String error) { Drawable emptyDrawable = new IconDrawable(mContext, - Iconify.IconValue.zmdi_network_alert) + Iconify.IconValue.zmdi_cloud_off) .colorRes(android.R.color.darker_gray); mEmptyView.ImageView.setImageDrawable(emptyDrawable); mEmptyView.TitleTextView.setText("Network Error"); diff --git a/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendancePresenter.java b/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendancePresenter.java index f9af75dc..d0ac4815 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendancePresenter.java +++ b/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendancePresenter.java @@ -19,12 +19,17 @@ package com.shalzz.attendance.ui.attendance; +import android.content.Context; + +import com.shalzz.attendance.R; import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.data.model.ListFooter; import com.shalzz.attendance.data.model.Subject; import com.shalzz.attendance.data.remote.RetrofitException; +import com.shalzz.attendance.injection.ApplicationContext; import com.shalzz.attendance.injection.ConfigPersistent; import com.shalzz.attendance.ui.base.BasePresenter; +import com.shalzz.attendance.utils.NetworkUtil; import com.shalzz.attendance.utils.RxUtil; import java.util.List; @@ -42,14 +47,16 @@ public class AttendancePresenter extends BasePresenter { private DataManager mDataManager; + private Context mContext; private Disposable mSyncDisposable; private Disposable mDbDisposable; private Disposable mFooterDisposable; @Inject - AttendancePresenter(DataManager dataManager) { + AttendancePresenter(DataManager dataManager, @ApplicationContext Context context) { mDataManager = dataManager; + mContext = context; } @Override @@ -95,14 +102,20 @@ public void onSuccess(Integer count) { //noinspection UnnecessaryReturnStatement return; } + else if (!NetworkUtil.isNetworkConnected(mContext)) { + if (count > 0) { + getMvpView().showRetryError( + mContext.getString(R.string.no_internet)); + } else { + getMvpView().showNoConnectionErrorView(); + } + } else if (count > 0) { getMvpView().showRetryError(error.getMessage()); } - else if (error.getKind() == RetrofitException.Kind.HTTP){ + else if (error.getKind() == RetrofitException.Kind.HTTP + || error.getKind() == RetrofitException.Kind.NETWORK){ getMvpView().showNetworkErrorView(error.getMessage()); - } - else if (error.getKind() == RetrofitException.Kind.NETWORK){ - getMvpView().showNoConnectionErrorView(); } else if (error.getKind() == RetrofitException.Kind.EMPTY_RESPONSE) { getMvpView().showEmptyErrorView(); // Prevent recursive calls diff --git a/app/src/main/java/com/shalzz/attendance/ui/day/DayFragment.java b/app/src/main/java/com/shalzz/attendance/ui/day/DayFragment.java index bb3103ca..94d467be 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/day/DayFragment.java +++ b/app/src/main/java/com/shalzz/attendance/ui/day/DayFragment.java @@ -23,6 +23,7 @@ import android.graphics.drawable.Drawable; import android.os.Bundle; import android.support.annotation.NonNull; +import android.support.design.widget.Snackbar; import android.support.v4.app.Fragment; import android.support.v7.widget.LinearLayoutManager; import android.support.v7.widget.RecyclerView; @@ -247,7 +248,7 @@ public void showNoConnectionErrorView() { @Override public void showNetworkErrorView(String error) { Drawable emptyDrawable = new IconDrawable(mContext, - Iconify.IconValue.zmdi_network_alert) + Iconify.IconValue.zmdi_cloud_off) .colorRes(android.R.color.darker_gray); mEmptyView.ImageView.setImageDrawable(emptyDrawable); mEmptyView.TitleTextView.setText(R.string.network_error_message); @@ -264,4 +265,12 @@ public void showError(String message) { stopRefreshing(); Miscellaneous.showSnackBar(mSwipeRefreshLayout, message); } + + @Override + public void showRetryError(String message) { + stopRefreshing(); + Snackbar.make(mRecyclerView, message, Snackbar.LENGTH_LONG) + .setAction("Retry", v -> mDayPresenter.syncDay(mDate)) + .show(); + } } diff --git a/app/src/main/java/com/shalzz/attendance/ui/day/DayMvpView.java b/app/src/main/java/com/shalzz/attendance/ui/day/DayMvpView.java index 45729cd5..7e13ba6d 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/day/DayMvpView.java +++ b/app/src/main/java/com/shalzz/attendance/ui/day/DayMvpView.java @@ -21,6 +21,8 @@ interface DayMvpView extends MvpView { void showError(String message); + void showRetryError(String message); + void showNoTimetableEmptyView(); void showNoConnectionErrorView(); diff --git a/app/src/main/java/com/shalzz/attendance/ui/day/DayPresenter.java b/app/src/main/java/com/shalzz/attendance/ui/day/DayPresenter.java index 2ae8394e..cd88c40e 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/day/DayPresenter.java +++ b/app/src/main/java/com/shalzz/attendance/ui/day/DayPresenter.java @@ -19,10 +19,15 @@ package com.shalzz.attendance.ui.day; +import android.content.Context; + +import com.shalzz.attendance.R; import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.data.model.Period; import com.shalzz.attendance.data.remote.RetrofitException; +import com.shalzz.attendance.injection.ApplicationContext; import com.shalzz.attendance.ui.base.BasePresenter; +import com.shalzz.attendance.utils.NetworkUtil; import com.shalzz.attendance.utils.RxUtil; import java.util.Date; @@ -40,13 +45,15 @@ class DayPresenter extends BasePresenter { private DataManager mDataManager; + private Context mContext; private Disposable mNetworkDisposable; private Disposable mDbDisposable; @Inject - DayPresenter(DataManager dataManager) { + DayPresenter(DataManager dataManager, @ApplicationContext Context context) { mDataManager = dataManager; + mContext = context; } @Override @@ -93,15 +100,21 @@ public void onSuccess(Integer count) { //noinspection UnnecessaryReturnStatement return; } + else if (!NetworkUtil.isNetworkConnected(mContext)) { + if (count > 0) { + getMvpView().showRetryError( + mContext.getString(R.string.no_internet)); + } else { + getMvpView().showNoConnectionErrorView(); + } + } else if (count > 0) { - getMvpView().showError(error.getMessage()); + getMvpView().showRetryError(error.getMessage()); } - else if (error.getKind() == RetrofitException.Kind.HTTP){ + else if (error.getKind() == RetrofitException.Kind.HTTP + || error.getKind() == RetrofitException.Kind.NETWORK){ getMvpView().showNetworkErrorView(error.getMessage()); - } - else if (error.getKind() == RetrofitException.Kind.NETWORK){ - getMvpView().showNoConnectionErrorView(); - } else if (error.getKind() == RetrofitException.Kind.EMPTY_RESPONSE) { + }else if (error.getKind() == RetrofitException.Kind.EMPTY_RESPONSE) { getMvpView().clearDay(); // Prevent recursive calls mDbDisposable.dispose(); diff --git a/app/src/main/java/com/shalzz/attendance/ui/login/LoginPresenter.java b/app/src/main/java/com/shalzz/attendance/ui/login/LoginPresenter.java index a991f70b..096ffb8e 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/login/LoginPresenter.java +++ b/app/src/main/java/com/shalzz/attendance/ui/login/LoginPresenter.java @@ -19,11 +19,16 @@ package com.shalzz.attendance.ui.login; +import android.content.Context; + +import com.shalzz.attendance.R; import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.data.model.User; import com.shalzz.attendance.data.remote.RetrofitException; +import com.shalzz.attendance.injection.ApplicationContext; import com.shalzz.attendance.injection.ConfigPersistent; import com.shalzz.attendance.ui.base.BasePresenter; +import com.shalzz.attendance.utils.NetworkUtil; import com.shalzz.attendance.utils.RxUtil; import javax.inject.Inject; @@ -38,12 +43,14 @@ public class LoginPresenter extends BasePresenter { private DataManager mDataManager; + private Context mContext; private Disposable mDisposable; @Inject - LoginPresenter(DataManager dataManager) { + LoginPresenter(DataManager dataManager, @ApplicationContext Context context) { mDataManager = dataManager; + mContext = context; } @Override @@ -59,6 +66,12 @@ public void detachView() { public void login(final String username) { checkViewAttached(); + if (!NetworkUtil.isNetworkConnected(mContext)) { + Timber.i("Sync canceled, connection not available"); + getMvpView().showError(mContext.getString(R.string.no_internet)); + return; + } + getMvpView().showProgressDialog(); RxUtil.dispose(mDisposable); mDisposable = mDataManager.syncUser("Bearer " + username) diff --git a/app/src/main/java/com/shalzz/attendance/ui/main/MainActivity.java b/app/src/main/java/com/shalzz/attendance/ui/main/MainActivity.java index ae193ee8..4fbc2b34 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/main/MainActivity.java +++ b/app/src/main/java/com/shalzz/attendance/ui/main/MainActivity.java @@ -47,11 +47,14 @@ import android.view.animation.DecelerateInterpolator; import android.widget.TextView; +import com.android.billingclient.api.BillingClient.BillingResponse; import com.bugsnag.android.Bugsnag; import com.github.amlcurran.showcaseview.ShowcaseView; import com.github.amlcurran.showcaseview.targets.Target; import com.shalzz.attendance.BuildConfig; import com.shalzz.attendance.R; +import com.shalzz.attendance.billing.BillingManager; +import com.shalzz.attendance.billing.BillingProvider; import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.data.model.User; import com.shalzz.attendance.ui.attendance.AttendanceListFragment; @@ -69,7 +72,7 @@ import butterknife.ButterKnife; import timber.log.Timber; -public class MainActivity extends BaseActivity implements MainMvpView { +public class MainActivity extends BaseActivity implements MainMvpView, BillingProvider { /** * To prevent saving the drawer position when logging out. @@ -145,6 +148,7 @@ public int getValue() { private Fragment fragment = null; // Our custom poor-man's back stack which has only one entry at maximum. private Fragment mPreviousFragment; + private BillingManager mBillingManager; public static class DrawerHeaderViewHolder extends RecyclerView.ViewHolder { @BindView(R.id.drawer_header_name) TextView tv_name; @@ -168,7 +172,8 @@ protected void onCreate(Bundle savedInstanceState) { mFragmentManager = getSupportFragmentManager(); DrawerheaderVH = new DrawerHeaderViewHolder(mNavigationView.getHeaderView(0)); - + mBillingManager = new BillingManager(this, mDataManager, + mMainPresenter.getUpdateListener()); setSupportActionBar(mToolbar); // Set the list's click listener @@ -181,6 +186,14 @@ protected void onCreate(Bundle savedInstanceState) { protected void onResume() { super.onResume(); showcaseView(); + // Note: We query purchases in onResume() to handle purchases completed while the activity + // is inactive. For example, this can happen if the activity is destroyed during the + // purchase flow. This ensures that when the activity is resumed it reflects the user's + // current purchases. + if (mBillingManager != null + && mBillingManager.getBillingClientResponseCode() == BillingResponse.OK) { + mBillingManager.queryPurchases(); + } } @Override @@ -597,10 +610,25 @@ public void onPause() { @Override public void onDestroy() { - super.onDestroy(); if(mDrawerLayout != null) mDrawerLayout.removeDrawerListener(mDrawerToggle); + if (mBillingManager != null) { + mBillingManager.destroy(); + } mMainPresenter.detachView(); + super.onDestroy(); + } + + /****** BillingProvider interface implementations*****/ + + @Override + public BillingManager getBillingManager() { + return mBillingManager; + } + + @Override + public boolean isProKeyPurchased() { + return mMainPresenter.isProKeyPurchased(); } /******* MVP View methods implementation *****/ diff --git a/app/src/main/java/com/shalzz/attendance/ui/main/MainMvpView.java b/app/src/main/java/com/shalzz/attendance/ui/main/MainMvpView.java index c7928bf1..b4c8a974 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/main/MainMvpView.java +++ b/app/src/main/java/com/shalzz/attendance/ui/main/MainMvpView.java @@ -1,5 +1,6 @@ package com.shalzz.attendance.ui.main; +import com.shalzz.attendance.billing.BillingManager; import com.shalzz.attendance.data.model.User; import com.shalzz.attendance.ui.base.MvpView; @@ -8,6 +9,8 @@ */ public interface MainMvpView extends MvpView { + BillingManager getBillingManager(); + void updateUserDetails(User user); void logout(); diff --git a/app/src/main/java/com/shalzz/attendance/ui/main/MainPresenter.java b/app/src/main/java/com/shalzz/attendance/ui/main/MainPresenter.java index d0cbc721..2b538b97 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/main/MainPresenter.java +++ b/app/src/main/java/com/shalzz/attendance/ui/main/MainPresenter.java @@ -22,22 +22,29 @@ import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; +import android.widget.Toast; +import com.android.billingclient.api.BillingClient.BillingResponse; +import com.android.billingclient.api.Purchase; import com.bugsnag.android.Bugsnag; import com.google.android.gms.analytics.HitBuilders; import com.google.android.gms.analytics.Tracker; import com.shalzz.attendance.R; +import com.shalzz.attendance.billing.BillingConstants; +import com.shalzz.attendance.billing.BillingManager.BillingUpdatesListener; import com.shalzz.attendance.data.DataManager; -import com.shalzz.attendance.data.local.DbOpenHelper; import com.shalzz.attendance.data.local.PreferencesHelper; import com.shalzz.attendance.data.model.User; -import com.shalzz.attendance.data.remote.RetrofitException; +import com.shalzz.attendance.event.ProKeyPurchaseEvent; import com.shalzz.attendance.injection.ApplicationContext; import com.shalzz.attendance.injection.ConfigPersistent; import com.shalzz.attendance.ui.base.BasePresenter; import com.shalzz.attendance.utils.Miscellaneous; +import com.shalzz.attendance.utils.RxEventBus; import com.shalzz.attendance.utils.RxUtil; +import java.util.List; + import javax.inject.Inject; import javax.inject.Named; @@ -55,11 +62,18 @@ public class MainPresenter extends BasePresenter { private Disposable mDisposable; private Context mContext; + private final UpdateListener mUpdateListener; + + // Tracks if we currently own a pro key + private boolean mIsProUnlocked; @Inject @Named("app") Tracker mTracker; + @Inject + RxEventBus mEventBus; + @Inject MainPresenter(DataManager dataManager, PreferencesHelper preferencesHelper, @@ -67,6 +81,7 @@ public class MainPresenter extends BasePresenter { mDataManager = dataManager; mPreferenceHelper = preferencesHelper; mContext = context; + mUpdateListener = new UpdateListener(); } @Override @@ -134,4 +149,58 @@ public void logout() { getMvpView().logout(); } + + public UpdateListener getUpdateListener() { + return mUpdateListener; + } + + public boolean isProKeyPurchased() { + return mIsProUnlocked; + } + + /** + * Handler to billing updates + */ + private class UpdateListener implements BillingUpdatesListener { + + @Override + public void onBillingClientSetupFinished() { + if(!isViewAttached()) + return; + int billingResponseCode = getMvpView().getBillingManager() + .getBillingClientResponseCode(); + + Timber.i("Billing response: %d", billingResponseCode); + switch (billingResponseCode) { + case BillingResponse.OK: + // If manager was connected successfully, do nothing + Timber.i("Billing response2: %d", billingResponseCode); + break; + case BillingResponse.BILLING_UNAVAILABLE: + Toast.makeText(mContext, R.string.error_billing_unavailable, Toast.LENGTH_LONG).show(); + break; + default: + Toast.makeText(mContext, R.string.error_billing_default, Toast.LENGTH_LONG).show(); + } + } + + @Override + public void onConsumeFinished(String token, int result) { + + + } + + @Override + public void onPurchasesUpdated(List purchaseList) { + for (Purchase purchase : purchaseList) { + switch (purchase.getSku()) { + case BillingConstants.SKU_PRO_KEY: + Timber.d("You are Premium! Congratulations!!!"); + mIsProUnlocked = true; + mEventBus.post(new ProKeyPurchaseEvent()); + break; + } + } + } + } } diff --git a/app/src/main/java/com/shalzz/attendance/ui/settings/AboutSettingsFragment.java b/app/src/main/java/com/shalzz/attendance/ui/settings/AboutSettingsFragment.java index 71a549b6..e4c957c9 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/settings/AboutSettingsFragment.java +++ b/app/src/main/java/com/shalzz/attendance/ui/settings/AboutSettingsFragment.java @@ -31,6 +31,7 @@ import com.shalzz.attendance.BuildConfig; import com.shalzz.attendance.MyApplication; import com.shalzz.attendance.R; +import com.shalzz.attendance.injection.ActivityContext; import com.shalzz.attendance.ui.main.MainActivity; import javax.inject.Inject; @@ -43,16 +44,18 @@ public class AboutSettingsFragment extends PreferenceFragmentCompat { - private Context mContext; private MainActivity mainActivity; @Inject @Named("app") Tracker mTracker; + @ActivityContext + @Inject + Context mContext; + @Override public void onCreatePreferences(Bundle bundle, String s) { - mContext = getActivity(); - MyApplication.get(mContext).getComponent().inject(this); + ((MainActivity) getActivity()).activityComponent().inject(this); Bugsnag.setContext("About"); mainActivity = ((MainActivity) getActivity()); mainActivity.setDrawerAsUp(true); diff --git a/app/src/main/java/com/shalzz/attendance/ui/settings/SettingsFragment.java b/app/src/main/java/com/shalzz/attendance/ui/settings/SettingsFragment.java index 281bbef3..919f0fc4 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/settings/SettingsFragment.java +++ b/app/src/main/java/com/shalzz/attendance/ui/settings/SettingsFragment.java @@ -20,7 +20,7 @@ package com.shalzz.attendance.ui.settings; import android.Manifest; -import android.app.NotificationManager; +import android.app.Activity; import android.app.backup.BackupManager; import android.content.Context; import android.content.SharedPreferences; @@ -39,19 +39,27 @@ import android.support.v7.preference.PreferenceScreen; import android.widget.Toast; +import com.android.billingclient.api.BillingClient; import com.bugsnag.android.Bugsnag; import com.google.android.gms.analytics.GoogleAnalytics; import com.google.android.gms.analytics.HitBuilders; import com.google.android.gms.analytics.Tracker; -import com.shalzz.attendance.MyApplication; import com.shalzz.attendance.R; +import com.shalzz.attendance.billing.BillingConstants; +import com.shalzz.attendance.billing.BillingProvider; import com.shalzz.attendance.data.local.PreferencesHelper; +import com.shalzz.attendance.event.ProKeyPurchaseEvent; +import com.shalzz.attendance.injection.ActivityContext; import com.shalzz.attendance.ui.main.MainActivity; +import com.shalzz.attendance.utils.RxEventBus; +import com.shalzz.attendance.utils.RxUtil; import com.shalzz.attendance.wrapper.MySyncManager; +import com.shalzz.attendance.wrapper.ProModeListPreference; import javax.inject.Inject; import javax.inject.Named; +import io.reactivex.disposables.Disposable; import timber.log.Timber; public class SettingsFragment extends PreferenceFragmentCompat implements @@ -59,11 +67,6 @@ public class SettingsFragment extends PreferenceFragmentCompat implements private final int MY_PERMISSIONS_REQUEST_GET_CONTACTS = 1; - private Context mContext; - private String key_sync_interval; - private String key_sync_day_night; - private SwitchPreference syncPref; - @Inject @Named("app") Tracker mTracker; @@ -71,17 +74,33 @@ public class SettingsFragment extends PreferenceFragmentCompat implements @Inject PreferencesHelper mPreferences; + @Inject + Activity mActivity; + + @ActivityContext + @Inject + Context mContext; + + @Inject + RxEventBus mEventBus; + + private BillingProvider mBillingProvider; + private String key_sync_interval; + private SwitchPreference syncPref; + private SwitchPreference proModePref; + private ProModeListPreference proThemePref; + private SwitchPreference weekendsPref; + + private Disposable PurchaseEventDisposable; + @Override public void onCreatePreferences(Bundle bundle, String s) { - mContext = getActivity(); - MyApplication.get(mContext).getComponent().inject(this); + ((MainActivity) getActivity()).activityComponent().inject(this); Bugsnag.setContext("Settings"); addPreferencesFromResource(R.xml.preferences); - key_sync_day_night = getString(R.string.pref_key_day_night); - ListPreference dayNightListPref = (ListPreference) findPreference(key_sync_day_night); - dayNightListPref.setSummary(dayNightListPref.getEntry()); + mBillingProvider = (BillingProvider) mActivity; key_sync_interval = getString(R.string.pref_key_sync_interval); ListPreference synclistPref = (ListPreference) findPreference(key_sync_interval); @@ -93,6 +112,13 @@ public void onCreatePreferences(Bundle bundle, String s) { toggleSync(false); syncPref.setChecked(false); } + + proThemePref = (ProModeListPreference) findPreference(getString(R.string.pref_key_day_night)); + weekendsPref = (SwitchPreference) findPreference(getString(R.string.pref_key_hide_weekends)); + + proModePref = (SwitchPreference) findPreference(getString(R.string.pref_key_pro_mode)); + PurchaseEventDisposable = mEventBus.filteredObservable(ProKeyPurchaseEvent.class) + .subscribe(proKeyPurchaseEvent -> proModePref.setChecked(true), Timber::e); } @Override @@ -104,15 +130,18 @@ public void onStart() { } public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - requestBackup(); - - if(key.equals(key_sync_day_night)) { - ListPreference connectionPref = (ListPreference) findPreference(key); - connectionPref.setSummary(connectionPref.getEntry()); + if(key.equals(getString(R.string.pref_key_day_night))) { + proThemePref.setSummary(proThemePref.getEntry()); //noinspection WrongConstant AppCompatDelegate.setDefaultNightMode(Integer.parseInt(sharedPreferences. getString(key,"-1"))); } + else if(key.equals(getString(R.string.pref_key_hide_weekends))) { + if (!mBillingProvider.isProKeyPurchased()) { + weekendsPref.setChecked(false); + Toast.makeText(mContext, "Pro key required!", Toast.LENGTH_SHORT).show(); + } + } else if (key.equals(getString(R.string.pref_key_sync))) { if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.GET_ACCOUNTS) != PackageManager.PERMISSION_GRANTED) { @@ -133,15 +162,8 @@ else if(key.equals(getString(R.string.pref_key_ga_opt_in))) { !optIn); Timber.i("Opted out of Google Analytics: %b", !optIn); } - else if(key.equals(getString(R.string.pref_key_notify_timetable_changed))) { - if(!sharedPreferences.getBoolean(key, true)) { - // Cancel a notification if it is shown. - NotificationManager mNotificationManager = - (NotificationManager) mContext.getSystemService( - Context.NOTIFICATION_SERVICE); - mNotificationManager.cancel(0 /** timetable changed notification id */); - } - } + + requestBackup(); } private void toggleSync(boolean sync) { @@ -182,6 +204,12 @@ public void onPause() { .unregisterOnSharedPreferenceChangeListener(this); } + @Override + public void onDestroy() { + super.onDestroy(); + RxUtil.dispose(PurchaseEventDisposable); + } + @Override public void onResume() { super.onResume(); @@ -191,6 +219,29 @@ public void onResume() { getPreferenceScreen().getSharedPreferences() .registerOnSharedPreferenceChangeListener(this); + if (mBillingProvider.isProKeyPurchased()) { + proModePref.setOnPreferenceClickListener(null); + proModePref.setChecked(true); + proModePref.setSelectable(false); + } else { + proModePref.setChecked(false); + proModePref.setOnPreferenceClickListener(preference -> { + proModePref.setChecked(false); + mBillingProvider.getBillingManager() + .initiatePurchaseFlow(BillingConstants.SKU_PRO_KEY, BillingClient.SkuType.INAPP); + return true; + }); + } + + proThemePref.setProModeListPreferenceClickListener(preference -> { + if (mBillingProvider.isProKeyPurchased()) { + proThemePref.showDialog(); + } else { + Toast.makeText(mContext, "Pro key required!", Toast.LENGTH_SHORT).show(); + } + return true; + }); + PreferenceCategory prefCategory = (PreferenceCategory) getPreferenceScreen() .getPreference(4); PreferenceScreen prefScreen = (PreferenceScreen) prefCategory.getPreference(0); @@ -203,7 +254,7 @@ public void onResume() { transaction.addToBackStack(null); // TODO: use an EventBus - ((MainActivity)getActivity()).mPopSettingsBackStack = true; + ((MainActivity)mActivity).mPopSettingsBackStack = true; transaction.commit(); mTracker.send(new HitBuilders.EventBuilder() diff --git a/app/src/main/java/com/shalzz/attendance/ui/splash/SplashActivity.java b/app/src/main/java/com/shalzz/attendance/ui/splash/SplashActivity.java index 3e1a9453..93dd1d72 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/splash/SplashActivity.java +++ b/app/src/main/java/com/shalzz/attendance/ui/splash/SplashActivity.java @@ -29,6 +29,7 @@ import com.google.android.gms.analytics.GoogleAnalytics; import com.google.android.gms.analytics.HitBuilders; import com.google.android.gms.analytics.Tracker; +import com.shalzz.attendance.BuildConfig; import com.shalzz.attendance.R; import com.shalzz.attendance.data.local.PreferencesHelper; import com.shalzz.attendance.ui.base.BaseActivity; @@ -58,7 +59,7 @@ protected void onCreate(Bundle savedInstanceState) { SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(this); boolean optIn = sharedPref.getBoolean(getString(R.string.pref_key_ga_opt_in), true); - GoogleAnalytics.getInstance(this).setAppOptOut(!optIn); + GoogleAnalytics.getInstance(this).setAppOptOut(!optIn || BuildConfig.DEBUG); Timber.i("Opted out of Google Analytics: %s", !optIn); mTracker.send(new HitBuilders.ScreenViewBuilder() diff --git a/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerAdapter.java b/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerAdapter.java index 859ebbb1..9c583300 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerAdapter.java +++ b/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerAdapter.java @@ -19,7 +19,7 @@ package com.shalzz.attendance.ui.timetable; -import android.content.Context; +import android.app.Activity; import android.content.SharedPreferences; import android.support.v4.app.FragmentManager; import android.support.v4.app.FragmentStatePagerAdapter; @@ -27,13 +27,18 @@ import android.util.SparseArray; import com.shalzz.attendance.R; +import com.shalzz.attendance.billing.BillingProvider; +import com.shalzz.attendance.event.ProKeyPurchaseEvent; import com.shalzz.attendance.ui.day.DayFragment; +import com.shalzz.attendance.utils.RxEventBus; +import com.shalzz.attendance.utils.RxUtil; import com.shalzz.attendance.wrapper.DateHelper; import java.util.Calendar; import java.util.Date; import io.reactivex.annotations.NonNull; +import io.reactivex.disposables.Disposable; import timber.log.Timber; public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { @@ -43,21 +48,42 @@ public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { private final SparseArray dates = new SparseArray<>(); private Date mToday; private Date mDate; - private boolean mShowWeekends; + private boolean mHideWeekends = false; private Callback mCallback; + private RxEventBus mEventBus; + private Disposable disposable; - TimeTablePagerAdapter(FragmentManager fm, Context context, Callback callback) { + TimeTablePagerAdapter(FragmentManager fm, Activity activity, Callback callback, + RxEventBus eventBus) { super(fm); mCallback = callback; + mEventBus = eventBus; - SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context); - mShowWeekends = sharedPref.getBoolean(context.getString(R.string - .pref_key_show_weekends), true); + checkPreferences(activity); + + disposable = mEventBus.filteredObservable(ProKeyPurchaseEvent.class) + .subscribe(proKeyPurchaseEvent -> { + checkPreferences(activity); + updateDates(); + scrollToToday(); + }); mToday = new Date(); setDate(mToday); } + private void checkPreferences(Activity activity) { + if (((BillingProvider)activity).isProKeyPurchased()) { + SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(activity); + mHideWeekends = sharedPref.getBoolean(activity.getString(R.string.pref_key_hide_weekends), false); + } else { + mHideWeekends = false; + } + } + + public void destroy() { + RxUtil.dispose(disposable); + } @Override public DayFragment getItem(int position) { return DayFragment.newInstance(dates.get(position)); @@ -88,24 +114,21 @@ public Date getDateForPosition(int position) { } public void scrollToDate(Date date) { - mCallback.scrollToPosition(indexOfValue(dates, date)); - } - - public void scrollToToday() { - Date date = mToday; - if(!mShowWeekends) { + if(mHideWeekends) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); - if(calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY) { - calendar.add(Calendar.DATE, 1); - } - if(calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) { + while(calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || + calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY ) { calendar.add(Calendar.DATE, 1); } date = calendar.getTime(); } + Timber.d("Date: %s", date); + mCallback.scrollToPosition(indexOfValue(dates, date)); + } - scrollToDate(date); + public void scrollToToday() { + scrollToDate(mToday); } public void setDate(@NonNull Date date) { @@ -123,13 +146,10 @@ private void updateDates() { for(int i =0; i < getCount() ; i++) { calendar.setTime(mDate); calendar.add(Calendar.DATE, -15+i); - if(!mShowWeekends) { + if(mHideWeekends) { calendar.add(Calendar.DATE, day_offset); - if (calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY) { - calendar.add(Calendar.DATE, 1); - ++day_offset; - } - if (calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY) { + while(calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || + calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY ) { calendar.add(Calendar.DATE, 1); ++day_offset; } diff --git a/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerFragment.java b/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerFragment.java index bf3e5dd9..de2dec3b 100644 --- a/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerFragment.java +++ b/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerFragment.java @@ -19,6 +19,7 @@ package com.shalzz.attendance.ui.timetable; +import android.app.Activity; import android.app.DatePickerDialog; import android.content.Context; import android.os.Bundle; @@ -41,6 +42,7 @@ import com.google.android.gms.analytics.Tracker; import com.shalzz.attendance.R; import com.shalzz.attendance.ui.main.MainActivity; +import com.shalzz.attendance.utils.RxEventBus; import com.shalzz.attendance.wrapper.DateHelper; import java.util.Calendar; @@ -65,6 +67,12 @@ public class TimeTablePagerFragment extends Fragment implements TimeTableMvpView @Inject TimeTablePresenter mTimeTablePresenter; + @Inject + Activity mActivity; + + @Inject + RxEventBus eventBus; + private int mPreviousPosition = 15; private TimeTablePagerAdapter mAdapter; private Context mContext; @@ -96,8 +104,9 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, actionbar = ((AppCompatActivity) getActivity()).getSupportActionBar(); mAdapter = new TimeTablePagerAdapter(getChildFragmentManager(), - mContext, - position -> mViewPager.setCurrentItem(position, true)); + mActivity, + position -> mViewPager.setCurrentItem(position, true), + eventBus); mViewPager.setOffscreenPageLimit(3); mViewPager.setAdapter(mAdapter); @@ -205,6 +214,7 @@ public void onDestroyView() { super.onDestroyView(); unbinder.unbind(); mTimeTablePresenter.detachView(); + mAdapter.destroy(); } /******* MVP View methods implementation *****/ diff --git a/app/src/main/java/com/shalzz/attendance/util/IabBroadcastReceiver.java b/app/src/main/java/com/shalzz/attendance/util/IabBroadcastReceiver.java deleted file mode 100644 index dc65171b..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/IabBroadcastReceiver.java +++ /dev/null @@ -1,60 +0,0 @@ -/* Copyright (c) 2014 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -import android.content.BroadcastReceiver; -import android.content.Context; -import android.content.Intent; - -/** - * Receiver for the "com.android.vending.billing.PURCHASES_UPDATED" Action - * from the Play Store. - * - *

It is possible that an in-app item may be acquired without the - * application calling getBuyIntent(), for example if the item can be - * redeemed from inside the Play Store using a promotional code. If this - * application isn't running at the time, then when it is started a call - * to getPurchases() will be sufficient notification. However, if the - * application is already running in the background when the item is acquired, - * a message to this BroadcastReceiver will indicate that the an item - * has been acquired.

- */ -public class IabBroadcastReceiver extends BroadcastReceiver { - /** - * Listener interface for received broadcast messages. - */ - public interface IabBroadcastListener { - void receivedBroadcast(); - } - - /** - * The Intent action that this Receiver should filter for. - */ - public static final String ACTION = "com.android.vending.billing.PURCHASES_UPDATED"; - - private final IabBroadcastListener mListener; - - public IabBroadcastReceiver(IabBroadcastListener listener) { - mListener = listener; - } - - @Override - public void onReceive(Context context, Intent intent) { - if (mListener != null) { - mListener.receivedBroadcast(); - } - } -} diff --git a/app/src/main/java/com/shalzz/attendance/util/IabException.java b/app/src/main/java/com/shalzz/attendance/util/IabException.java deleted file mode 100644 index 3b68c180..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/IabException.java +++ /dev/null @@ -1,43 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -/** - * Exception thrown when something went wrong with in-app billing. - * An IabException has an associated IabResult (an error). - * To get the IAB result that caused this exception to be thrown, - * call {@link #getResult()}. - */ -public class IabException extends Exception { - IabResult mResult; - - public IabException(IabResult r) { - this(r, null); - } - public IabException(int response, String message) { - this(new IabResult(response, message)); - } - public IabException(IabResult r, Exception cause) { - super(r.getMessage(), cause); - mResult = r; - } - public IabException(int response, String message, Exception cause) { - this(new IabResult(response, message), cause); - } - - /** Returns the IAB result (error) that this exception signals. */ - public IabResult getResult() { return mResult; } -} \ No newline at end of file diff --git a/app/src/main/java/com/shalzz/attendance/util/IabHelper.java b/app/src/main/java/com/shalzz/attendance/util/IabHelper.java deleted file mode 100644 index b0a157ef..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/IabHelper.java +++ /dev/null @@ -1,1099 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -import android.app.Activity; -import android.app.PendingIntent; -import android.content.ComponentName; -import android.content.Context; -import android.content.Intent; -import android.content.IntentSender.SendIntentException; -import android.content.ServiceConnection; -import android.content.pm.ResolveInfo; -import android.os.Bundle; -import android.os.Handler; -import android.os.IBinder; -import android.os.RemoteException; -import android.text.TextUtils; - -import com.android.vending.billing.IInAppBillingService; - -import org.json.JSONException; - -import java.util.ArrayList; -import java.util.List; - -import timber.log.Timber; - - -/** - * Provides convenience methods for in-app billing. You can create one instance of this - * class for your application and use it to process in-app billing operations. - * It provides synchronous (blocking) and asynchronous (non-blocking) methods for - * many common in-app billing operations, as well as automatic signature - * verification. - * - * After instantiating, you must perform setup in order to start using the object. - * To perform setup, call the {@link #startSetup} method and provide a listener; - * that listener will be notified when setup is complete, after which (and not before) - * you may call other methods. - * - * After setup is complete, you will typically want to request an inventory of owned - * items and subscriptions. See {@link #queryInventory}, {@link #queryInventoryAsync} - * and related methods. - * - * When you are done with this object, don't forget to call {@link #dispose} - * to ensure proper cleanup. This object holds a binding to the in-app billing - * service, which will leak unless you dispose of it correctly. If you created - * the object on an Activity's onCreate method, then the recommended - * place to dispose of it is the Activity's onDestroy method. It is invalid to - * dispose the object while an asynchronous operation is in progress. You can - * call {@link #disposeWhenFinished()} to ensure that any in-progress operation - * completes before the object is disposed. - * - * A note about threading: When using this object from a background thread, you may - * call the blocking versions of methods; when using from a UI thread, call - * only the asynchronous versions and handle the results via callbacks. - * Also, notice that you can only call one asynchronous operation at a time; - * attempting to start a second asynchronous operation while the first one - * has not yet completed will result in an exception being thrown. - * - */ -public class IabHelper { - // Is setup done? - boolean mSetupDone = false; - - // Has this object been disposed of? (If so, we should ignore callbacks, etc) - boolean mDisposed = false; - - // Do we need to dispose this object after an in-progress asynchronous operation? - boolean mDisposeAfterAsync = false; - - // Are subscriptions supported? - boolean mSubscriptionsSupported = false; - - // Is subscription update supported? - boolean mSubscriptionUpdateSupported = false; - - // Is an asynchronous operation in progress? - // (only one at a time can be in progress) - boolean mAsyncInProgress = false; - - // Ensure atomic access to mAsyncInProgress and mDisposeAfterAsync. - private final Object mAsyncInProgressLock = new Object(); - - // (for logging/debugging) - // if mAsyncInProgress == true, what asynchronous operation is in progress? - String mAsyncOperation = ""; - - // Context we were passed during initialization - Context mContext; - - // Connection to the service - IInAppBillingService mService; - ServiceConnection mServiceConn; - - // The request code used to launch purchase flow - int mRequestCode; - - // The item type of the current purchase flow - String mPurchasingItemType; - - // Public key for verifying signature, in base64 encoding - String mSignatureBase64 = null; - - // Billing response codes - public static final int BILLING_RESPONSE_RESULT_OK = 0; - public static final int BILLING_RESPONSE_RESULT_USER_CANCELED = 1; - public static final int BILLING_RESPONSE_RESULT_SERVICE_UNAVAILABLE = 2; - public static final int BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE = 3; - public static final int BILLING_RESPONSE_RESULT_ITEM_UNAVAILABLE = 4; - public static final int BILLING_RESPONSE_RESULT_DEVELOPER_ERROR = 5; - public static final int BILLING_RESPONSE_RESULT_ERROR = 6; - public static final int BILLING_RESPONSE_RESULT_ITEM_ALREADY_OWNED = 7; - public static final int BILLING_RESPONSE_RESULT_ITEM_NOT_OWNED = 8; - - // IAB Helper error codes - public static final int IABHELPER_ERROR_BASE = -1000; - public static final int IABHELPER_REMOTE_EXCEPTION = -1001; - public static final int IABHELPER_BAD_RESPONSE = -1002; - public static final int IABHELPER_VERIFICATION_FAILED = -1003; - public static final int IABHELPER_SEND_INTENT_FAILED = -1004; - public static final int IABHELPER_USER_CANCELLED = -1005; - public static final int IABHELPER_UNKNOWN_PURCHASE_RESPONSE = -1006; - public static final int IABHELPER_MISSING_TOKEN = -1007; - public static final int IABHELPER_UNKNOWN_ERROR = -1008; - public static final int IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE = -1009; - public static final int IABHELPER_INVALID_CONSUMPTION = -1010; - public static final int IABHELPER_SUBSCRIPTION_UPDATE_NOT_AVAILABLE = -1011; - - // Keys for the responses from InAppBillingService - public static final String RESPONSE_CODE = "RESPONSE_CODE"; - public static final String RESPONSE_GET_SKU_DETAILS_LIST = "DETAILS_LIST"; - public static final String RESPONSE_BUY_INTENT = "BUY_INTENT"; - public static final String RESPONSE_INAPP_PURCHASE_DATA = "INAPP_PURCHASE_DATA"; - public static final String RESPONSE_INAPP_SIGNATURE = "INAPP_DATA_SIGNATURE"; - public static final String RESPONSE_INAPP_ITEM_LIST = "INAPP_PURCHASE_ITEM_LIST"; - public static final String RESPONSE_INAPP_PURCHASE_DATA_LIST = "INAPP_PURCHASE_DATA_LIST"; - public static final String RESPONSE_INAPP_SIGNATURE_LIST = "INAPP_DATA_SIGNATURE_LIST"; - public static final String INAPP_CONTINUATION_TOKEN = "INAPP_CONTINUATION_TOKEN"; - - // Item types - public static final String ITEM_TYPE_INAPP = "inapp"; - public static final String ITEM_TYPE_SUBS = "subs"; - - // some fields on the getSkuDetails response bundle - public static final String GET_SKU_DETAILS_ITEM_LIST = "ITEM_ID_LIST"; - public static final String GET_SKU_DETAILS_ITEM_TYPE_LIST = "ITEM_TYPE_LIST"; - - /** - * Creates an instance. After creation, it will not yet be ready to use. You must perform - * setup by calling {@link #startSetup} and wait for setup to complete. This constructor does not - * block and is safe to call from a UI thread. - * - * @param ctx Your application or Activity context. Needed to bind to the in-app billing service. - * @param base64PublicKey Your application's public key, encoded in base64. - * This is used for verification of purchase signatures. You can find your app's base64-encoded - * public key in your application's page on Google Play Developer Console. Note that this - * is NOT your "developer public key". - */ - public IabHelper(Context ctx, String base64PublicKey) { - mContext = ctx.getApplicationContext(); - mSignatureBase64 = base64PublicKey; - logDebug("IAB helper created."); - } - - /** - * Callback for setup process. This listener's {@link #onIabSetupFinished} method is called - * when the setup process is complete. - */ - public interface OnIabSetupFinishedListener { - /** - * Called to notify that setup is complete. - * - * @param result The result of the setup process. - */ - void onIabSetupFinished(IabResult result); - } - - /** - * Starts the setup process. This will start up the setup process asynchronously. - * You will be notified through the listener when the setup process is complete. - * This method is safe to call from a UI thread. - * - * @param listener The listener to notify when the setup process is complete. - */ - public void startSetup(final OnIabSetupFinishedListener listener) { - // If already set up, can't do it again. - checkNotDisposed(); - if (mSetupDone) throw new IllegalStateException("IAB helper is already set up."); - - // Connection to IAB service - logDebug("Starting in-app billing setup."); - mServiceConn = new ServiceConnection() { - @Override - public void onServiceDisconnected(ComponentName name) { - logDebug("Billing service disconnected."); - mService = null; - } - - @Override - public void onServiceConnected(ComponentName name, IBinder service) { - if (mDisposed) return; - logDebug("Billing service connected."); - mService = IInAppBillingService.Stub.asInterface(service); - String packageName = mContext.getPackageName(); - try { - logDebug("Checking for in-app billing 3 support."); - - // check for in-app billing v3 support - int response = mService.isBillingSupported(3, packageName, ITEM_TYPE_INAPP); - if (response != BILLING_RESPONSE_RESULT_OK) { - if (listener != null) listener.onIabSetupFinished(new IabResult(response, - "Error checking for billing v3 support.")); - - // if in-app purchases aren't supported, neither are subscriptions - mSubscriptionsSupported = false; - mSubscriptionUpdateSupported = false; - return; - } else { - logDebug("In-app billing version 3 supported for " + packageName); - } - - // Check for v5 subscriptions support. This is needed for - // getBuyIntentToReplaceSku which allows for subscription update - response = mService.isBillingSupported(5, packageName, ITEM_TYPE_SUBS); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Subscription re-signup AVAILABLE."); - mSubscriptionUpdateSupported = true; - } else { - logDebug("Subscription re-signup not available."); - mSubscriptionUpdateSupported = false; - } - - if (mSubscriptionUpdateSupported) { - mSubscriptionsSupported = true; - } else { - // check for v3 subscriptions support - response = mService.isBillingSupported(3, packageName, ITEM_TYPE_SUBS); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Subscriptions AVAILABLE."); - mSubscriptionsSupported = true; - } else { - logDebug("Subscriptions NOT AVAILABLE. Response: " + response); - mSubscriptionsSupported = false; - mSubscriptionUpdateSupported = false; - } - } - - mSetupDone = true; - } - catch (RemoteException e) { - if (listener != null) { - listener.onIabSetupFinished(new IabResult(IABHELPER_REMOTE_EXCEPTION, - "RemoteException while setting up in-app billing.")); - } - e.printStackTrace(); - return; - } - - if (listener != null) { - listener.onIabSetupFinished(new IabResult(BILLING_RESPONSE_RESULT_OK, "Setup successful.")); - } - } - }; - - Intent serviceIntent = new Intent("com.android.vending.billing.InAppBillingService.BIND"); - serviceIntent.setPackage("com.android.vending"); - List intentServices = mContext.getPackageManager().queryIntentServices(serviceIntent, 0); - if (intentServices != null && !intentServices.isEmpty()) { - // service available to handle that Intent - mContext.bindService(serviceIntent, mServiceConn, Context.BIND_AUTO_CREATE); - } - else { - // no service available to handle that Intent - if (listener != null) { - listener.onIabSetupFinished( - new IabResult(BILLING_RESPONSE_RESULT_BILLING_UNAVAILABLE, - "Billing service unavailable on device.")); - } - } - } - - /** - * Dispose of object, releasing resources. It's very important to call this - * method when you are done with this object. It will release any resources - * used by it such as service connections. Naturally, once the object is - * disposed of, it can't be used again. - */ - public void dispose() throws IabAsyncInProgressException { - synchronized (mAsyncInProgressLock) { - if (mAsyncInProgress) { - throw new IabAsyncInProgressException("Can't dispose because an async operation " + - "(" + mAsyncOperation + ") is in progress."); - } - } - logDebug("Disposing."); - mSetupDone = false; - if (mServiceConn != null) { - logDebug("Unbinding from service."); - if (mContext != null) mContext.unbindService(mServiceConn); - } - mDisposed = true; - mContext = null; - mServiceConn = null; - mService = null; - mPurchaseListener = null; - } - - /** - * Disposes of object, releasing resources. If there is an in-progress async operation, this - * method will queue the dispose to occur after the operation has finished. - */ - public void disposeWhenFinished() { - synchronized (mAsyncInProgressLock) { - if (mAsyncInProgress) { - logDebug("Will dispose after async operation finishes."); - mDisposeAfterAsync = true; - } else { - try { - dispose(); - } catch (IabAsyncInProgressException e) { - // Should never be thrown, because we call dispose() only after checking that - // there's not already an async operation in progress. - } - } - } - } - - private void checkNotDisposed() { - if (mDisposed) throw new IllegalStateException("IabHelper was disposed of, so it cannot be used."); - } - - /** Returns whether subscriptions are supported. */ - public boolean subscriptionsSupported() { - checkNotDisposed(); - return mSubscriptionsSupported; - } - - - /** - * Callback that notifies when a purchase is finished. - */ - public interface OnIabPurchaseFinishedListener { - /** - * Called to notify that an in-app purchase finished. If the purchase was successful, - * then the sku parameter specifies which item was purchased. If the purchase failed, - * the sku and extraData parameters may or may not be null, depending on how far the purchase - * process went. - * - * @param result The result of the purchase. - * @param info The purchase information (null if purchase failed) - */ - void onIabPurchaseFinished(IabResult result, Purchase info); - } - - // The listener registered on launchPurchaseFlow, which we have to call back when - // the purchase finishes - OnIabPurchaseFinishedListener mPurchaseListener; - - public void launchPurchaseFlow(Activity act, String sku, int requestCode, OnIabPurchaseFinishedListener listener) - throws IabAsyncInProgressException { - launchPurchaseFlow(act, sku, requestCode, listener, ""); - } - - public void launchPurchaseFlow(Activity act, String sku, int requestCode, - OnIabPurchaseFinishedListener listener, String extraData) - throws IabAsyncInProgressException { - launchPurchaseFlow(act, sku, ITEM_TYPE_INAPP, null, requestCode, listener, extraData); - } - - public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, - OnIabPurchaseFinishedListener listener) throws IabAsyncInProgressException { - launchSubscriptionPurchaseFlow(act, sku, requestCode, listener, ""); - } - - public void launchSubscriptionPurchaseFlow(Activity act, String sku, int requestCode, - OnIabPurchaseFinishedListener listener, String extraData) - throws IabAsyncInProgressException { - launchPurchaseFlow(act, sku, ITEM_TYPE_SUBS, null, requestCode, listener, extraData); - } - - /** - * Initiate the UI flow for an in-app purchase. Call this method to initiate an in-app purchase, - * which will involve bringing up the Google Play screen. The calling activity will be paused - * while the user interacts with Google Play, and the result will be delivered via the - * activity's {@link android.app.Activity#onActivityResult} method, at which point you must call - * this object's {@link #handleActivityResult} method to continue the purchase flow. This method - * MUST be called from the UI thread of the Activity. - * - * @param act The calling activity. - * @param sku The sku of the item to purchase. - * @param itemType indicates if it's a product or a subscription (ITEM_TYPE_INAPP or - * ITEM_TYPE_SUBS) - * @param oldSkus A list of SKUs which the new SKU is replacing or null if there are none - * @param requestCode A request code (to differentiate from other responses -- as in - * {@link android.app.Activity#startActivityForResult}). - * @param listener The listener to notify when the purchase process finishes - * @param extraData Extra data (developer payload), which will be returned with the purchase - * data when the purchase completes. This extra data will be permanently bound to that - * purchase and will always be returned when the purchase is queried. - */ - public void launchPurchaseFlow(Activity act, String sku, String itemType, List oldSkus, - int requestCode, OnIabPurchaseFinishedListener listener, String extraData) - throws IabAsyncInProgressException { - checkNotDisposed(); - checkSetupDone("launchPurchaseFlow"); - flagStartAsync("launchPurchaseFlow"); - IabResult result; - - if (itemType.equals(ITEM_TYPE_SUBS) && !mSubscriptionsSupported) { - IabResult r = new IabResult(IABHELPER_SUBSCRIPTIONS_NOT_AVAILABLE, - "Subscriptions are not available."); - flagEndAsync(); - if (listener != null) listener.onIabPurchaseFinished(r, null); - return; - } - - try { - logDebug("Constructing buy intent for " + sku + ", item type: " + itemType); - Bundle buyIntentBundle; - if (oldSkus == null || oldSkus.isEmpty()) { - // Purchasing a new item or subscription re-signup - buyIntentBundle = mService.getBuyIntent(3, mContext.getPackageName(), sku, itemType, - extraData); - } else { - // Subscription upgrade/downgrade - if (!mSubscriptionUpdateSupported) { - IabResult r = new IabResult(IABHELPER_SUBSCRIPTION_UPDATE_NOT_AVAILABLE, - "Subscription updates are not available."); - flagEndAsync(); - if (listener != null) listener.onIabPurchaseFinished(r, null); - return; - } - buyIntentBundle = mService.getBuyIntentToReplaceSkus(5, mContext.getPackageName(), - oldSkus, sku, itemType, extraData); - } - int response = getResponseCodeFromBundle(buyIntentBundle); - if (response != BILLING_RESPONSE_RESULT_OK) { - logError("Unable to buy item, Error response: " + getResponseDesc(response)); - flagEndAsync(); - result = new IabResult(response, "Unable to buy item"); - if (listener != null) listener.onIabPurchaseFinished(result, null); - return; - } - - PendingIntent pendingIntent = buyIntentBundle.getParcelable(RESPONSE_BUY_INTENT); - logDebug("Launching buy intent for " + sku + ". Request code: " + requestCode); - mRequestCode = requestCode; - mPurchaseListener = listener; - mPurchasingItemType = itemType; - act.startIntentSenderForResult(pendingIntent.getIntentSender(), - requestCode, new Intent(), - Integer.valueOf(0), Integer.valueOf(0), - Integer.valueOf(0)); - } - catch (SendIntentException e) { - logError("SendIntentException while launching purchase flow for sku " + sku); - e.printStackTrace(); - flagEndAsync(); - - result = new IabResult(IABHELPER_SEND_INTENT_FAILED, "Failed to send intent."); - if (listener != null) listener.onIabPurchaseFinished(result, null); - } - catch (RemoteException e) { - logError("RemoteException while launching purchase flow for sku " + sku); - e.printStackTrace(); - flagEndAsync(); - - result = new IabResult(IABHELPER_REMOTE_EXCEPTION, "Remote exception while starting purchase flow"); - if (listener != null) listener.onIabPurchaseFinished(result, null); - } - } - - /** - * Handles an activity result that's part of the purchase flow in in-app billing. If you - * are calling {@link #launchPurchaseFlow}, then you must call this method from your - * Activity's {@link android.app.Activity@onActivityResult} method. This method - * MUST be called from the UI thread of the Activity. - * - * @param requestCode The requestCode as you received it. - * @param resultCode The resultCode as you received it. - * @param data The data (Intent) as you received it. - * @return Returns true if the result was related to a purchase flow and was handled; - * false if the result was not related to a purchase, in which case you should - * handle it normally. - */ - public boolean handleActivityResult(int requestCode, int resultCode, Intent data) { - IabResult result; - if (requestCode != mRequestCode) return false; - - checkNotDisposed(); - checkSetupDone("handleActivityResult"); - - // end of async purchase operation that started on launchPurchaseFlow - flagEndAsync(); - - if (data == null) { - logError("Null data in IAB activity result."); - result = new IabResult(IABHELPER_BAD_RESPONSE, "Null data in IAB result"); - if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); - return true; - } - - int responseCode = getResponseCodeFromIntent(data); - String purchaseData = data.getStringExtra(RESPONSE_INAPP_PURCHASE_DATA); - String dataSignature = data.getStringExtra(RESPONSE_INAPP_SIGNATURE); - - if (resultCode == Activity.RESULT_OK && responseCode == BILLING_RESPONSE_RESULT_OK) { - logDebug("Successful resultcode from purchase activity."); - logDebug("Purchase data: " + purchaseData); - logDebug("Data signature: " + dataSignature); - logDebug("Extras: " + data.getExtras()); - logDebug("Expected item type: " + mPurchasingItemType); - - if (purchaseData == null || dataSignature == null) { - logError("BUG: either purchaseData or dataSignature is null."); - logDebug("Extras: " + data.getExtras().toString()); - result = new IabResult(IABHELPER_UNKNOWN_ERROR, "IAB returned null purchaseData or dataSignature"); - if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); - return true; - } - - Purchase purchase = null; - try { - purchase = new Purchase(mPurchasingItemType, purchaseData, dataSignature); - String sku = purchase.getSku(); - - // Verify signature - if (!Security.verifyPurchase(mSignatureBase64, purchaseData, dataSignature)) { - logError("Purchase signature verification FAILED for sku " + sku); - result = new IabResult(IABHELPER_VERIFICATION_FAILED, "Signature verification failed for sku " + sku); - if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, purchase); - return true; - } - logDebug("Purchase signature successfully verified."); - } - catch (JSONException e) { - logError("Failed to parse purchase data."); - e.printStackTrace(); - result = new IabResult(IABHELPER_BAD_RESPONSE, "Failed to parse purchase data."); - if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); - return true; - } - - if (mPurchaseListener != null) { - mPurchaseListener.onIabPurchaseFinished(new IabResult(BILLING_RESPONSE_RESULT_OK, "Success"), purchase); - } - } - else if (resultCode == Activity.RESULT_OK) { - // result code was OK, but in-app billing response was not OK. - logDebug("Result code was OK but in-app billing response was not OK: " + getResponseDesc(responseCode)); - if (mPurchaseListener != null) { - result = new IabResult(responseCode, "Problem purchashing item."); - mPurchaseListener.onIabPurchaseFinished(result, null); - } - } - else if (resultCode == Activity.RESULT_CANCELED) { - logDebug("Purchase canceled - Response: " + getResponseDesc(responseCode)); - result = new IabResult(IABHELPER_USER_CANCELLED, "User canceled."); - if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); - } - else { - logError("Purchase failed. Result code: " + Integer.toString(resultCode) - + ". Response: " + getResponseDesc(responseCode)); - result = new IabResult(IABHELPER_UNKNOWN_PURCHASE_RESPONSE, "Unknown purchase response."); - if (mPurchaseListener != null) mPurchaseListener.onIabPurchaseFinished(result, null); - } - return true; - } - - public Inventory queryInventory() throws IabException { - return queryInventory(false, null, null); - } - - /** - * Queries the inventory. This will query all owned items from the server, as well as - * information on additional skus, if specified. This method may block or take long to execute. - * Do not call from a UI thread. For that, use the non-blocking version {@link #queryInventoryAsync}. - * - * @param querySkuDetails if true, SKU details (price, description, etc) will be queried as well - * as purchase information. - * @param moreItemSkus additional PRODUCT skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @param moreSubsSkus additional SUBSCRIPTIONS skus to query information on, regardless of ownership. - * Ignored if null or if querySkuDetails is false. - * @throws IabException if a problem occurs while refreshing the inventory. - */ - public Inventory queryInventory(boolean querySkuDetails, List moreItemSkus, - List moreSubsSkus) throws IabException { - checkNotDisposed(); - checkSetupDone("queryInventory"); - try { - Inventory inv = new Inventory(); - int r = queryPurchases(inv, ITEM_TYPE_INAPP); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying owned items)."); - } - - if (querySkuDetails) { - r = querySkuDetails(ITEM_TYPE_INAPP, inv, moreItemSkus); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying prices of items)."); - } - } - - // if subscriptions are supported, then also query for subscriptions - if (mSubscriptionsSupported) { - r = queryPurchases(inv, ITEM_TYPE_SUBS); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying owned subscriptions)."); - } - - if (querySkuDetails) { - r = querySkuDetails(ITEM_TYPE_SUBS, inv, moreSubsSkus); - if (r != BILLING_RESPONSE_RESULT_OK) { - throw new IabException(r, "Error refreshing inventory (querying prices of subscriptions)."); - } - } - } - - return inv; - } - catch (RemoteException e) { - throw new IabException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while refreshing inventory.", e); - } - catch (JSONException e) { - throw new IabException(IABHELPER_BAD_RESPONSE, "Error parsing JSON response while refreshing inventory.", e); - } - } - - /** - * Listener that notifies when an inventory query operation completes. - */ - public interface QueryInventoryFinishedListener { - /** - * Called to notify that an inventory query operation completed. - * - * @param result The result of the operation. - * @param inv The inventory. - */ - void onQueryInventoryFinished(IabResult result, Inventory inv); - } - - - /** - * Asynchronous wrapper for inventory query. This will perform an inventory - * query as described in {@link #queryInventory}, but will do so asynchronously - * and call back the specified listener upon completion. This method is safe to - * call from a UI thread. - * - * @param querySkuDetails as in {@link #queryInventory} - * @param moreItemSkus as in {@link #queryInventory} - * @param moreSubsSkus as in {@link #queryInventory} - * @param listener The listener to notify when the refresh operation completes. - */ - public void queryInventoryAsync(final boolean querySkuDetails, final List moreItemSkus, - final List moreSubsSkus, final QueryInventoryFinishedListener listener) - throws IabAsyncInProgressException { - final Handler handler = new Handler(); - checkNotDisposed(); - checkSetupDone("queryInventory"); - flagStartAsync("refresh inventory"); - (new Thread(new Runnable() { - public void run() { - IabResult result = new IabResult(BILLING_RESPONSE_RESULT_OK, "Inventory refresh successful."); - Inventory inv = null; - try { - inv = queryInventory(querySkuDetails, moreItemSkus, moreSubsSkus); - } - catch (IabException ex) { - result = ex.getResult(); - } - - flagEndAsync(); - - final IabResult result_f = result; - final Inventory inv_f = inv; - if (!mDisposed && listener != null) { - handler.post(new Runnable() { - public void run() { - listener.onQueryInventoryFinished(result_f, inv_f); - } - }); - } - } - })).start(); - } - - public void queryInventoryAsync(QueryInventoryFinishedListener listener) - throws IabAsyncInProgressException{ - queryInventoryAsync(false, null, null, listener); - } - - /** - * Consumes a given in-app product. Consuming can only be done on an item - * that's owned, and as a result of consumption, the user will no longer own it. - * This method may block or take long to return. Do not call from the UI thread. - * For that, see {@link #consumeAsync}. - * - * @param itemInfo The PurchaseInfo that represents the item to consume. - * @throws IabException if there is a problem during consumption. - */ - void consume(Purchase itemInfo) throws IabException { - checkNotDisposed(); - checkSetupDone("consume"); - - if (!itemInfo.mItemType.equals(ITEM_TYPE_INAPP)) { - throw new IabException(IABHELPER_INVALID_CONSUMPTION, - "Items of type '" + itemInfo.mItemType + "' can't be consumed."); - } - - try { - String token = itemInfo.getToken(); - String sku = itemInfo.getSku(); - if (token == null || token.equals("")) { - logError("Can't consume "+ sku + ". No token."); - throw new IabException(IABHELPER_MISSING_TOKEN, "PurchaseInfo is missing token for sku: " - + sku + " " + itemInfo); - } - - logDebug("Consuming sku: " + sku + ", token: " + token); - int response = mService.consumePurchase(3, mContext.getPackageName(), token); - if (response == BILLING_RESPONSE_RESULT_OK) { - logDebug("Successfully consumed sku: " + sku); - } - else { - logDebug("Error consuming consuming sku " + sku + ". " + getResponseDesc(response)); - throw new IabException(response, "Error consuming sku " + sku); - } - } - catch (RemoteException e) { - throw new IabException(IABHELPER_REMOTE_EXCEPTION, "Remote exception while consuming. PurchaseInfo: " + itemInfo, e); - } - } - - /** - * Callback that notifies when a consumption operation finishes. - */ - public interface OnConsumeFinishedListener { - /** - * Called to notify that a consumption has finished. - * - * @param purchase The purchase that was (or was to be) consumed. - * @param result The result of the consumption operation. - */ - void onConsumeFinished(Purchase purchase, IabResult result); - } - - /** - * Callback that notifies when a multi-item consumption operation finishes. - */ - public interface OnConsumeMultiFinishedListener { - /** - * Called to notify that a consumption of multiple items has finished. - * - * @param purchases The purchases that were (or were to be) consumed. - * @param results The results of each consumption operation, corresponding to each - * sku. - */ - void onConsumeMultiFinished(List purchases, List results); - } - - /** - * Asynchronous wrapper to item consumption. Works like {@link #consume}, but - * performs the consumption in the background and notifies completion through - * the provided listener. This method is safe to call from a UI thread. - * - * @param purchase The purchase to be consumed. - * @param listener The listener to notify when the consumption operation finishes. - */ - public void consumeAsync(Purchase purchase, OnConsumeFinishedListener listener) - throws IabAsyncInProgressException { - checkNotDisposed(); - checkSetupDone("consume"); - List purchases = new ArrayList(); - purchases.add(purchase); - consumeAsyncInternal(purchases, listener, null); - } - - /** - * Same as {@link #consumeAsync}, but for multiple items at once. - * @param purchases The list of PurchaseInfo objects representing the purchases to consume. - * @param listener The listener to notify when the consumption operation finishes. - */ - public void consumeAsync(List purchases, OnConsumeMultiFinishedListener listener) - throws IabAsyncInProgressException { - checkNotDisposed(); - checkSetupDone("consume"); - consumeAsyncInternal(purchases, null, listener); - } - - /** - * Returns a human-readable description for the given response code. - * - * @param code The response code - * @return A human-readable string explaining the result code. - * It also includes the result code numerically. - */ - public static String getResponseDesc(int code) { - String[] iab_msgs = ("0:OK/1:User Canceled/2:Unknown/" + - "3:Billing Unavailable/4:Item unavailable/" + - "5:Developer Error/6:Error/7:Item Already Owned/" + - "8:Item not owned").split("/"); - String[] iabhelper_msgs = ("0:OK/-1001:Remote exception during initialization/" + - "-1002:Bad response received/" + - "-1003:Purchase signature verification failed/" + - "-1004:Send intent failed/" + - "-1005:User cancelled/" + - "-1006:Unknown purchase response/" + - "-1007:Missing token/" + - "-1008:Unknown error/" + - "-1009:Subscriptions not available/" + - "-1010:Invalid consumption attempt").split("/"); - - if (code <= IABHELPER_ERROR_BASE) { - int index = IABHELPER_ERROR_BASE - code; - if (index >= 0 && index < iabhelper_msgs.length) return iabhelper_msgs[index]; - else return String.valueOf(code) + ":Unknown IAB Helper Error"; - } - else if (code < 0 || code >= iab_msgs.length) - return String.valueOf(code) + ":Unknown"; - else - return iab_msgs[code]; - } - - - // Checks that setup was done; if not, throws an exception. - void checkSetupDone(String operation) { - if (!mSetupDone) { - logError("Illegal state for operation (" + operation + "): IAB helper is not set up."); - throw new IllegalStateException("IAB helper is not set up. Can't perform operation: " + operation); - } - } - - // Workaround to bug where sometimes response codes come as Long instead of Integer - int getResponseCodeFromBundle(Bundle b) { - Object o = b.get(RESPONSE_CODE); - if (o == null) { - logDebug("Bundle with null response code, assuming OK (known issue)"); - return BILLING_RESPONSE_RESULT_OK; - } - else if (o instanceof Integer) return ((Integer)o).intValue(); - else if (o instanceof Long) return (int)((Long)o).longValue(); - else { - logError("Unexpected type for bundle response code."); - logError(o.getClass().getName()); - throw new RuntimeException("Unexpected type for bundle response code: " + o.getClass().getName()); - } - } - - // Workaround to bug where sometimes response codes come as Long instead of Integer - int getResponseCodeFromIntent(Intent i) { - Object o = i.getExtras().get(RESPONSE_CODE); - if (o == null) { - logError("Intent with no response code, assuming OK (known issue)"); - return BILLING_RESPONSE_RESULT_OK; - } - else if (o instanceof Integer) return ((Integer)o).intValue(); - else if (o instanceof Long) return (int)((Long)o).longValue(); - else { - logError("Unexpected type for intent response code."); - logError(o.getClass().getName()); - throw new RuntimeException("Unexpected type for intent response code: " + o.getClass().getName()); - } - } - - void flagStartAsync(String operation) throws IabAsyncInProgressException { - synchronized (mAsyncInProgressLock) { - if (mAsyncInProgress) { - throw new IabAsyncInProgressException("Can't start async operation (" + - operation + ") because another async operation (" + mAsyncOperation + - ") is in progress."); - } - mAsyncOperation = operation; - mAsyncInProgress = true; - logDebug("Starting async operation: " + operation); - } - } - - void flagEndAsync() { - synchronized (mAsyncInProgressLock) { - logDebug("Ending async operation: " + mAsyncOperation); - mAsyncOperation = ""; - mAsyncInProgress = false; - if (mDisposeAfterAsync) { - try { - dispose(); - } catch (IabAsyncInProgressException e) { - // Should not be thrown, because we reset mAsyncInProgress immediately before - // calling dispose(). - } - } - } - } - - /** - * Exception thrown when the requested operation cannot be started because an async operation - * is still in progress. - */ - public static class IabAsyncInProgressException extends Exception { - public IabAsyncInProgressException(String message) { - super(message); - } - } - - int queryPurchases(Inventory inv, String itemType) throws JSONException, RemoteException { - // Query purchases - logDebug("Querying owned items, item type: " + itemType); - logDebug("Package name: " + mContext.getPackageName()); - boolean verificationFailed = false; - String continueToken = null; - - do { - logDebug("Calling getPurchases with continuation token: " + continueToken); - Bundle ownedItems = mService.getPurchases(3, mContext.getPackageName(), - itemType, continueToken); - - int response = getResponseCodeFromBundle(ownedItems); - logDebug("Owned items response: " + String.valueOf(response)); - if (response != BILLING_RESPONSE_RESULT_OK) { - logDebug("getPurchases() failed: " + getResponseDesc(response)); - return response; - } - if (!ownedItems.containsKey(RESPONSE_INAPP_ITEM_LIST) - || !ownedItems.containsKey(RESPONSE_INAPP_PURCHASE_DATA_LIST) - || !ownedItems.containsKey(RESPONSE_INAPP_SIGNATURE_LIST)) { - logError("Bundle returned from getPurchases() doesn't contain required fields."); - return IABHELPER_BAD_RESPONSE; - } - - ArrayList ownedSkus = ownedItems.getStringArrayList( - RESPONSE_INAPP_ITEM_LIST); - ArrayList purchaseDataList = ownedItems.getStringArrayList( - RESPONSE_INAPP_PURCHASE_DATA_LIST); - ArrayList signatureList = ownedItems.getStringArrayList( - RESPONSE_INAPP_SIGNATURE_LIST); - - for (int i = 0; i < purchaseDataList.size(); ++i) { - String purchaseData = purchaseDataList.get(i); - String signature = signatureList.get(i); - String sku = ownedSkus.get(i); - if (Security.verifyPurchase(mSignatureBase64, purchaseData, signature)) { - logDebug("Sku is owned: " + sku); - Purchase purchase = new Purchase(itemType, purchaseData, signature); - - if (TextUtils.isEmpty(purchase.getToken())) { - logWarn("BUG: empty/null token!"); - logDebug("Purchase data: " + purchaseData); - } - - // Record ownership and token - inv.addPurchase(purchase); - } - else { - logWarn("Purchase signature verification **FAILED**. Not adding item."); - logDebug(" Purchase data: " + purchaseData); - logDebug(" Signature: " + signature); - verificationFailed = true; - } - } - - continueToken = ownedItems.getString(INAPP_CONTINUATION_TOKEN); - logDebug("Continuation token: " + continueToken); - } while (!TextUtils.isEmpty(continueToken)); - - return verificationFailed ? IABHELPER_VERIFICATION_FAILED : BILLING_RESPONSE_RESULT_OK; - } - - int querySkuDetails(String itemType, Inventory inv, List moreSkus) - throws RemoteException, JSONException { - logDebug("Querying SKU details."); - ArrayList skuList = new ArrayList(); - skuList.addAll(inv.getAllOwnedSkus(itemType)); - if (moreSkus != null) { - for (String sku : moreSkus) { - if (!skuList.contains(sku)) { - skuList.add(sku); - } - } - } - - if (skuList.size() == 0) { - logDebug("queryPrices: nothing to do because there are no SKUs."); - return BILLING_RESPONSE_RESULT_OK; - } - - // Split the sku list in blocks of no more than 20 elements. - ArrayList> packs = new ArrayList>(); - ArrayList tempList; - int n = skuList.size() / 20; - int mod = skuList.size() % 20; - for (int i = 0; i < n; i++) { - tempList = new ArrayList(); - for (String s : skuList.subList(i * 20, i * 20 + 20)) { - tempList.add(s); - } - packs.add(tempList); - } - if (mod != 0) { - tempList = new ArrayList(); - for (String s : skuList.subList(n * 20, n * 20 + mod)) { - tempList.add(s); - } - packs.add(tempList); - } - - for (ArrayList skuPartList : packs) { - Bundle querySkus = new Bundle(); - querySkus.putStringArrayList(GET_SKU_DETAILS_ITEM_LIST, skuPartList); - Bundle skuDetails = mService.getSkuDetails(3, mContext.getPackageName(), - itemType, querySkus); - - if (!skuDetails.containsKey(RESPONSE_GET_SKU_DETAILS_LIST)) { - int response = getResponseCodeFromBundle(skuDetails); - if (response != BILLING_RESPONSE_RESULT_OK) { - logDebug("getSkuDetails() failed: " + getResponseDesc(response)); - return response; - } else { - logError("getSkuDetails() returned a bundle with neither an error nor a detail list."); - return IABHELPER_BAD_RESPONSE; - } - } - - ArrayList responseList = skuDetails.getStringArrayList( - RESPONSE_GET_SKU_DETAILS_LIST); - - for (String thisResponse : responseList) { - SkuDetails d = new SkuDetails(itemType, thisResponse); - logDebug("Got sku details: " + d); - inv.addSkuDetails(d); - } - } - - return BILLING_RESPONSE_RESULT_OK; - } - - void consumeAsyncInternal(final List purchases, - final OnConsumeFinishedListener singleListener, - final OnConsumeMultiFinishedListener multiListener) - throws IabAsyncInProgressException { - final Handler handler = new Handler(); - flagStartAsync("consume"); - (new Thread(new Runnable() { - public void run() { - final List results = new ArrayList(); - for (Purchase purchase : purchases) { - try { - consume(purchase); - results.add(new IabResult(BILLING_RESPONSE_RESULT_OK, "Successful consume of sku " + purchase.getSku())); - } - catch (IabException ex) { - results.add(ex.getResult()); - } - } - - flagEndAsync(); - if (!mDisposed && singleListener != null) { - handler.post(new Runnable() { - public void run() { - singleListener.onConsumeFinished(purchases.get(0), results.get(0)); - } - }); - } - if (!mDisposed && multiListener != null) { - handler.post(new Runnable() { - public void run() { - multiListener.onConsumeMultiFinished(purchases, results); - } - }); - } - } - })).start(); - } - - void logDebug(String msg) { - Timber.d(msg); - } - - void logError(String msg) { - Timber.e("In-app billing error: %s", msg); - } - - void logWarn(String msg) { - Timber.w("In-app billing warning: %s", msg); - } -} diff --git a/app/src/main/java/com/shalzz/attendance/util/IabResult.java b/app/src/main/java/com/shalzz/attendance/util/IabResult.java deleted file mode 100644 index a1e9877e..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/IabResult.java +++ /dev/null @@ -1,45 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -/** - * Represents the result of an in-app billing operation. - * A result is composed of a response code (an integer) and possibly a - * message (String). You can get those by calling - * {@link #getResponse} and {@link #getMessage()}, respectively. You - * can also inquire whether a result is a success or a failure by - * calling {@link #isSuccess()} and {@link #isFailure()}. - */ -public class IabResult { - int mResponse; - String mMessage; - - public IabResult(int response, String message) { - mResponse = response; - if (message == null || message.trim().length() == 0) { - mMessage = IabHelper.getResponseDesc(response); - } - else { - mMessage = message + " (response: " + IabHelper.getResponseDesc(response) + ")"; - } - } - public int getResponse() { return mResponse; } - public String getMessage() { return mMessage; } - public boolean isSuccess() { return mResponse == IabHelper.BILLING_RESPONSE_RESULT_OK; } - public boolean isFailure() { return !isSuccess(); } - public String toString() { return "IabResult: " + getMessage(); } -} - diff --git a/app/src/main/java/com/shalzz/attendance/util/Inventory.java b/app/src/main/java/com/shalzz/attendance/util/Inventory.java deleted file mode 100644 index 42bd8872..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/Inventory.java +++ /dev/null @@ -1,91 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -import java.util.ArrayList; -import java.util.HashMap; -import java.util.List; -import java.util.Map; - -/** - * Represents a block of information about in-app items. - * An Inventory is returned by such methods as {@link IabHelper#queryInventory}. - */ -public class Inventory { - Map mSkuMap = new HashMap(); - Map mPurchaseMap = new HashMap(); - - Inventory() { } - - /** Returns the listing details for an in-app product. */ - public SkuDetails getSkuDetails(String sku) { - return mSkuMap.get(sku); - } - - /** Returns purchase information for a given product, or null if there is no purchase. */ - public Purchase getPurchase(String sku) { - return mPurchaseMap.get(sku); - } - - /** Returns whether or not there exists a purchase of the given product. */ - public boolean hasPurchase(String sku) { - return mPurchaseMap.containsKey(sku); - } - - /** Return whether or not details about the given product are available. */ - public boolean hasDetails(String sku) { - return mSkuMap.containsKey(sku); - } - - /** - * Erase a purchase (locally) from the inventory, given its product ID. This just - * modifies the Inventory object locally and has no effect on the server! This is - * useful when you have an existing Inventory object which you know to be up to date, - * and you have just consumed an item successfully, which means that erasing its - * purchase data from the Inventory you already have is quicker than querying for - * a new Inventory. - */ - public void erasePurchase(String sku) { - if (mPurchaseMap.containsKey(sku)) mPurchaseMap.remove(sku); - } - - /** Returns a list of all owned product IDs. */ - List getAllOwnedSkus() { - return new ArrayList(mPurchaseMap.keySet()); - } - - /** Returns a list of all owned product IDs of a given type */ - List getAllOwnedSkus(String itemType) { - List result = new ArrayList(); - for (Purchase p : mPurchaseMap.values()) { - if (p.getItemType().equals(itemType)) result.add(p.getSku()); - } - return result; - } - - /** Returns a list of all purchases. */ - List getAllPurchases() { - return new ArrayList(mPurchaseMap.values()); - } - - void addSkuDetails(SkuDetails d) { - mSkuMap.put(d.getSku(), d); - } - - void addPurchase(Purchase p) { - mPurchaseMap.put(p.getSku(), p); - } -} diff --git a/app/src/main/java/com/shalzz/attendance/util/Purchase.java b/app/src/main/java/com/shalzz/attendance/util/Purchase.java deleted file mode 100644 index ade772f7..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/Purchase.java +++ /dev/null @@ -1,66 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -import org.json.JSONException; -import org.json.JSONObject; - -/** - * Represents an in-app billing purchase. - */ -public class Purchase { - String mItemType; // ITEM_TYPE_INAPP or ITEM_TYPE_SUBS - String mOrderId; - String mPackageName; - String mSku; - long mPurchaseTime; - int mPurchaseState; - String mDeveloperPayload; - String mToken; - String mOriginalJson; - String mSignature; - boolean mIsAutoRenewing; - - public Purchase(String itemType, String jsonPurchaseInfo, String signature) throws JSONException { - mItemType = itemType; - mOriginalJson = jsonPurchaseInfo; - JSONObject o = new JSONObject(mOriginalJson); - mOrderId = o.optString("orderId"); - mPackageName = o.optString("packageName"); - mSku = o.optString("productId"); - mPurchaseTime = o.optLong("purchaseTime"); - mPurchaseState = o.optInt("purchaseState"); - mDeveloperPayload = o.optString("developerPayload"); - mToken = o.optString("token", o.optString("purchaseToken")); - mIsAutoRenewing = o.optBoolean("autoRenewing"); - mSignature = signature; - } - - public String getItemType() { return mItemType; } - public String getOrderId() { return mOrderId; } - public String getPackageName() { return mPackageName; } - public String getSku() { return mSku; } - public long getPurchaseTime() { return mPurchaseTime; } - public int getPurchaseState() { return mPurchaseState; } - public String getDeveloperPayload() { return mDeveloperPayload; } - public String getToken() { return mToken; } - public String getOriginalJson() { return mOriginalJson; } - public String getSignature() { return mSignature; } - public boolean isAutoRenewing() { return mIsAutoRenewing; } - - @Override - public String toString() { return "PurchaseInfo(type:" + mItemType + "):" + mOriginalJson; } -} diff --git a/app/src/main/java/com/shalzz/attendance/util/Security.java b/app/src/main/java/com/shalzz/attendance/util/Security.java deleted file mode 100644 index 6141ed76..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/Security.java +++ /dev/null @@ -1,121 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -import android.text.TextUtils; -import android.util.Base64; - -import java.security.InvalidKeyException; -import java.security.KeyFactory; -import java.security.NoSuchAlgorithmException; -import java.security.PublicKey; -import java.security.Signature; -import java.security.SignatureException; -import java.security.spec.InvalidKeySpecException; -import java.security.spec.X509EncodedKeySpec; - -import timber.log.Timber; - -/** - * Security-related methods. For a secure implementation, all of this code - * should be implemented on a server that communicates with the - * application on the device. For the sake of simplicity and clarity of this - * example, this code is included here and is executed on the device. If you - * must verify the purchases on the phone, you should obfuscate this code to - * make it harder for an attacker to replace the code with stubs that treat all - * purchases as verified. - */ -public class Security { - - private static final String KEY_FACTORY_ALGORITHM = "RSA"; - private static final String SIGNATURE_ALGORITHM = "SHA1withRSA"; - - /** - * Verifies that the data was signed with the given signature, and returns - * the verified purchase. The data is in JSON format and signed - * with a private key. The data also contains the {@link PurchaseState} - * and product ID of the purchase. - * @param base64PublicKey the base64-encoded public key to use for verifying. - * @param signedData the signed JSON string (signed, not encrypted) - * @param signature the signature for the data, signed with the private key - */ - public static boolean verifyPurchase(String base64PublicKey, String signedData, String signature) { - if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) || - TextUtils.isEmpty(signature)) { - Timber.e("Purchase verification failed: missing data."); - return false; - } - - PublicKey key = Security.generatePublicKey(base64PublicKey); - return Security.verify(key, signedData, signature); - } - - /** - * Generates a PublicKey instance from a string containing the - * Base64-encoded public key. - * - * @param encodedPublicKey Base64-encoded public key - * @throws IllegalArgumentException if encodedPublicKey is invalid - */ - public static PublicKey generatePublicKey(String encodedPublicKey) { - try { - byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT); - KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); - return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); - } catch (NoSuchAlgorithmException e) { - throw new RuntimeException(e); - } catch (InvalidKeySpecException e) { - Timber.e("Invalid key specification."); - throw new IllegalArgumentException(e); - } - } - - /** - * Verifies that the signature from the server matches the computed - * signature on the data. Returns true if the data is correctly signed. - * - * @param publicKey public key associated with the developer account - * @param signedData signed data from server - * @param signature server signature - * @return true if the data and signature match - */ - public static boolean verify(PublicKey publicKey, String signedData, String signature) { - byte[] signatureBytes; - try { - signatureBytes = Base64.decode(signature, Base64.DEFAULT); - } catch (IllegalArgumentException e) { - Timber.e("Base64 decoding failed."); - return false; - } - try { - Signature sig = Signature.getInstance(SIGNATURE_ALGORITHM); - sig.initVerify(publicKey); - sig.update(signedData.getBytes()); - if (!sig.verify(signatureBytes)) { - Timber.e("Signature verification failed."); - return false; - } - return true; - } catch (NoSuchAlgorithmException e) { - Timber.e("NoSuchAlgorithmException."); - } catch (InvalidKeyException e) { - Timber.e("Invalid key specification."); - } catch (SignatureException e) { - Timber.e("Signature exception."); - } - return false; - } -} diff --git a/app/src/main/java/com/shalzz/attendance/util/SkuDetails.java b/app/src/main/java/com/shalzz/attendance/util/SkuDetails.java deleted file mode 100644 index e46e724c..00000000 --- a/app/src/main/java/com/shalzz/attendance/util/SkuDetails.java +++ /dev/null @@ -1,64 +0,0 @@ -/* Copyright (c) 2012 Google Inc. - * - * Licensed under the Apache License, Version 2.0 (the "License"); - * you may not use this file except in compliance with the License. - * You may obtain a copy of the License at - * - * http://www.apache.org/licenses/LICENSE-2.0 - * - * Unless required by applicable law or agreed to in writing, software - * distributed under the License is distributed on an "AS IS" BASIS, - * WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. - * See the License for the specific language governing permissions and - * limitations under the License. - */ - -package com.shalzz.attendance.util; - -import org.json.JSONException; -import org.json.JSONObject; - -/** - * Represents an in-app product's listing details. - */ -public class SkuDetails { - private final String mItemType; - private final String mSku; - private final String mType; - private final String mPrice; - private final long mPriceAmountMicros; - private final String mPriceCurrencyCode; - private final String mTitle; - private final String mDescription; - private final String mJson; - - public SkuDetails(String jsonSkuDetails) throws JSONException { - this(IabHelper.ITEM_TYPE_INAPP, jsonSkuDetails); - } - - public SkuDetails(String itemType, String jsonSkuDetails) throws JSONException { - mItemType = itemType; - mJson = jsonSkuDetails; - JSONObject o = new JSONObject(mJson); - mSku = o.optString("productId"); - mType = o.optString("type"); - mPrice = o.optString("price"); - mPriceAmountMicros = o.optLong("price_amount_micros"); - mPriceCurrencyCode = o.optString("price_currency_code"); - mTitle = o.optString("title"); - mDescription = o.optString("description"); - } - - public String getSku() { return mSku; } - public String getType() { return mType; } - public String getPrice() { return mPrice; } - public long getPriceAmountMicros() { return mPriceAmountMicros; } - public String getPriceCurrencyCode() { return mPriceCurrencyCode; } - public String getTitle() { return mTitle; } - public String getDescription() { return mDescription; } - - @Override - public String toString() { - return "SkuDetails:" + mJson; - } -} diff --git a/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java b/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java new file mode 100644 index 00000000..2b7ef5c3 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java @@ -0,0 +1,33 @@ +package com.shalzz.attendance.utils; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import retrofit2.HttpException; +import retrofit2.http.Headers; + +public class NetworkUtil { + + /** + * Returns true if the Throwable is an instance of RetrofitError with an + * http status code equals to the given one. + */ + public static boolean isHttpStatusCode(Throwable throwable, int statusCode) { + return throwable instanceof HttpException + && ((HttpException) throwable).code() == statusCode; + } + + public static boolean isNetworkConnected(Context context) { + ConnectivityManager cm = + (ConnectivityManager) context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo activeNetwork = cm.getActiveNetworkInfo(); + return activeNetwork != null && activeNetwork.isConnectedOrConnecting(); + } + + public static String getCacheControlHeaders(Context context) { + return isNetworkConnected(context) ? + "public, max-age=60" : "public, only-if-cached, max-stale=604800"; + } + +} \ No newline at end of file diff --git a/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java b/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java new file mode 100644 index 00000000..700c536a --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java @@ -0,0 +1,50 @@ +package com.shalzz.attendance.wrapper; + +import android.content.Context; +import android.support.v7.preference.ListPreference; +import android.support.v7.preference.Preference; +import android.util.AttributeSet; + +/** + * @author shalzz + */ +public class ProModeListPreference extends ListPreference { + + private Context mContext; + private OnProModeListPreferenceClickListener mCallback; + + public ProModeListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { + super(context, attrs, defStyleAttr, defStyleRes); + mContext = context; + } + + public ProModeListPreference(Context context, AttributeSet attrs, int defStyleAttr) { + super(context, attrs, defStyleAttr); + } + + public ProModeListPreference(Context context, AttributeSet attrs) { + super(context, attrs); + } + + public ProModeListPreference(Context context) { + super(context); + } + + public void showDialog() { + super.onClick(); + } + + public void setProModeListPreferenceClickListener(OnProModeListPreferenceClickListener listener) { + mCallback = listener; + } + + @Override + protected void onClick() { + if (mCallback != null) + mCallback.onPreferenceClick(this); + } + + public interface OnProModeListPreferenceClickListener { + boolean onPreferenceClick(ProModeListPreference preference); + } +} diff --git a/app/src/main/play/en-US/whatsnew b/app/src/main/play/en-US/whatsnew index ad80e140..6804a3a4 100644 --- a/app/src/main/play/en-US/whatsnew +++ b/app/src/main/play/en-US/whatsnew @@ -1,2 +1,2 @@ -• Refactored to support more colleges +• Introduced Pro Mode which unlocks additional features. • Bug fixes and improvements diff --git a/app/src/main/res/values/arrays.xml b/app/src/main/res/values/arrays.xml index af605f4e..c334192b 100644 --- a/app/src/main/res/values/arrays.xml +++ b/app/src/main/res/values/arrays.xml @@ -43,6 +43,22 @@ 1440 + + ₹200 + ₹250 + ₹300 + ₹400 + ₹500 + + + + 200 + 250 + 300 + 400 + 500 + + Default to system\'s setting Change between day/night based on the time of day diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd4034c9..3a834cdb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,16 +38,16 @@ pref_key_proxy_username pref_key_proxy_password ga_opt_in - notify_timetable_changed - show_weekends + hide_weekends day_night bugsnag_opt_in + pro_mode It seems the web server is down or not responding. - No Connection + No Connection. Please try again when you are connected to the internet Network error occurred. Proxy Authentication failed. Please check your credentials. An unexpected error occurred @@ -59,6 +59,9 @@ No connection. Please try again when you are connected to the internet Try Again + Billing unavailable. Make sure your Google Play app + is setup correctly + Billing unavailable. Please check your device. You don\'t have any classes on this day :) @@ -104,17 +107,22 @@ Build Version Google Analytics Collect and send anonymous data to help improve the app - Notifications - Receive a notification when your - timetable changes - Weekends - Display weekends as well - Display only weekdays - Day/Night Theme + Hide Weekends (Pro feature) + Show or Hide the weekends + Display only weekdays + Day/Night Theme (Pro feature) Select Night Mode + Choose a Day or Night Theme Crash Reports Send crash reports with diagnostic information to help debug and improve the app + Upgrade to Pro + Thanks for using College Academics. Consider upgrading + to Pro Mode to unlock additional features. + Pro Mode is now unlocked with additional + features available. + Donation Amount + Expandable item @@ -148,11 +156,6 @@ Proxy About - - Timetable has changed - Touch to see - your timetable. - Contacts permission (GET_ACCOUNTS) is required to manage a sync account diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index b1ac7755..0d2116d5 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -21,27 +21,28 @@ - + - + android:key="@string/pref_key_hide_weekends" + android:defaultValue="false" + android:title="@string/pref_hide_weekends" + android:summary="@string/pref_hide_weekends_summary"/> diff --git a/app/src/test/java/com/shalzz/attendance/DataAPITest.java b/app/src/test/java/com/shalzz/attendance/DataAPITest.java new file mode 100644 index 00000000..aa44a63d --- /dev/null +++ b/app/src/test/java/com/shalzz/attendance/DataAPITest.java @@ -0,0 +1,14 @@ +package com.shalzz.attendance; + +import com.shalzz.attendance.data.remote.DataAPI; + +import org.junit.Test; +import static junit.framework.Assert.assertEquals; + +public class DataAPITest { + + @Test + public void ApiEndpointIsCorrect() { + assertEquals(DataAPI.ENDPOINT, "https://academics.8bitlabs.in/api/v1/"); + } +} diff --git a/app/src/test/java/com/shalzz/attendance/DataManagerTest.java b/app/src/test/java/com/shalzz/attendance/DataManagerTest.java index 0f68d2de..f0a6b92a 100644 --- a/app/src/test/java/com/shalzz/attendance/DataManagerTest.java +++ b/app/src/test/java/com/shalzz/attendance/DataManagerTest.java @@ -131,9 +131,11 @@ public void syncUserDoesNotCallDatabaseWhenApiFails() { when(mMockDataAPI.getUser(USERID)) .thenReturn(Observable.error(new RuntimeException())); - mDataManager.syncUser(USERID).subscribe(new TestObserver<>()); + TestObserver result = new TestObserver<>(); + mDataManager.syncUser(USERID).subscribe(result); // Verify right calls to helper methods verify(mMockDataAPI).getUser(USERID); + result.assertNoValues(); verify(mMockDatabaseHelper, never()).setUser(ArgumentMatchers.any()); }