From a8bce9c13acfd88291ab22b7b02436e43f3d87ed Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 14 Apr 2018 16:21:20 +0530 Subject: [PATCH 01/22] fix background sync --- .../RxJava2ErrorCallAdapterFactory.java | 1 - .../shalzz/attendance/sync/SyncAdapter.java | 77 +++++++++++++++++-- .../shalzz/attendance/sync/SyncService.java | 9 +-- 3 files changed, 72 insertions(+), 15 deletions(-) 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/sync/SyncAdapter.java b/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java index 722e8083..768a8064 100644 --- a/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java +++ b/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java @@ -33,19 +33,31 @@ 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.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.ui.main.MainActivity; +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 +67,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,6 +83,55 @@ public SyncAdapter( public void onPerformSync(Account account, Bundle extras, String authority, ContentProviderClient provider, SyncResult syncResult) { Timber.i("Running sync adapter"); + + 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); } } } From 1e01cf8904fea43023fe0df5e68fb1ce39311881 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 14 Apr 2018 17:18:12 +0530 Subject: [PATCH 02/22] use cloud off symbol for network error --- .../shalzz/attendance/ui/attendance/AttendanceListFragment.java | 2 +- app/src/main/java/com/shalzz/attendance/ui/day/DayFragment.java | 2 +- 2 files changed, 2 insertions(+), 2 deletions(-) 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/day/DayFragment.java b/app/src/main/java/com/shalzz/attendance/ui/day/DayFragment.java index bb3103ca..52fd15e2 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 @@ -247,7 +247,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); From b374e09c58d5f7cd2b8ff1088d9b94766c74452e Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 14 Apr 2018 17:18:51 +0530 Subject: [PATCH 03/22] fix scrolling to date when weekends are disabled --- .../ui/timetable/TimeTablePagerAdapter.java | 24 +++++++------------ 1 file changed, 9 insertions(+), 15 deletions(-) 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..201149f7 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 @@ -88,24 +88,21 @@ public Date getDateForPosition(int position) { } public void scrollToDate(Date date) { - mCallback.scrollToPosition(indexOfValue(dates, date)); - } - - public void scrollToToday() { - Date date = mToday; if(!mShowWeekends) { 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) { @@ -125,11 +122,8 @@ private void updateDates() { calendar.add(Calendar.DATE, -15+i); if(!mShowWeekends) { 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; } From cbd1bcb50716b6f970cf2a5de34cfce44faa2e75 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 15 Apr 2018 13:01:52 +0530 Subject: [PATCH 04/22] invert show weekends preference --- .../attendance/ui/timetable/TimeTablePagerAdapter.java | 10 +++++----- app/src/main/res/values/strings.xml | 8 ++++---- app/src/main/res/xml/preferences.xml | 10 +++++----- 3 files changed, 14 insertions(+), 14 deletions(-) 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 201149f7..374378c4 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 @@ -43,7 +43,7 @@ public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { private final SparseArray dates = new SparseArray<>(); private Date mToday; private Date mDate; - private boolean mShowWeekends; + private boolean mHideWeekends; private Callback mCallback; TimeTablePagerAdapter(FragmentManager fm, Context context, Callback callback) { @@ -51,8 +51,8 @@ public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { mCallback = callback; SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context); - mShowWeekends = sharedPref.getBoolean(context.getString(R.string - .pref_key_show_weekends), true); + mHideWeekends = sharedPref.getBoolean(context.getString(R.string.pref_key_hide_weekends), + false); mToday = new Date(); setDate(mToday); @@ -88,7 +88,7 @@ public Date getDateForPosition(int position) { } public void scrollToDate(Date date) { - if(!mShowWeekends) { + if(mHideWeekends) { Calendar calendar = Calendar.getInstance(); calendar.setTime(date); while(calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || @@ -120,7 +120,7 @@ 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); while(calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SATURDAY || calendar.get(Calendar.DAY_OF_WEEK) == Calendar.SUNDAY ) { diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index dd4034c9..4b03ae19 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -39,7 +39,7 @@ pref_key_proxy_password ga_opt_in notify_timetable_changed - show_weekends + hide_weekends day_night bugsnag_opt_in @@ -107,9 +107,9 @@ Notifications Receive a notification when your timetable changes - Weekends - Display weekends as well - Display only weekdays + Hide Weekends + Display weekends as well + Display only weekdays Day/Night Theme Select Night Mode Crash Reports diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index b1ac7755..14c707fb 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -32,11 +32,11 @@ + android:key="@string/pref_key_hide_weekends" + android:defaultValue="false" + android:title="@string/pref_hide_weekends" + android:summaryOn="@string/pref_hide_weekends_summary_on" + android:summaryOff="@string/pref_hide_weekends_summary_off"/> Date: Sun, 15 Apr 2018 13:37:20 +0530 Subject: [PATCH 05/22] gradle: update support libraries --- app/build.gradle | 19 ++++++--- .../shalzz/attendance/sync/SyncAdapter.java | 42 ------------------- 2 files changed, 13 insertions(+), 48 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index cac78844..e9e2a323 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,7 +129,7 @@ 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' @@ -140,12 +140,19 @@ android { 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 "android.arch.persistence:db:1.0.0" @@ -199,7 +206,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/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java b/app/src/main/java/com/shalzz/attendance/sync/SyncAdapter.java index 768a8064..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,24 +20,17 @@ 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.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.ui.main.MainActivity; import com.shalzz.attendance.utils.RxUtil; import java.util.Calendar; @@ -133,39 +126,4 @@ public void onComplete() { } }); } - - /** - * 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()); - } } From 95e880956e8aa36d28990b7a21f069b164192f25 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 15 Apr 2018 17:23:47 +0530 Subject: [PATCH 06/22] optout of GA on Debug build --- .../java/com/shalzz/attendance/ui/splash/SplashActivity.java | 3 ++- 1 file changed, 2 insertions(+), 1 deletion(-) 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() From e0c9ac75af7d223e5756be79c4ee1888c4ea7a05 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 15 Apr 2018 18:03:01 +0530 Subject: [PATCH 07/22] LoggingInterceptor: log out status code --- .../data/remote/interceptor/LoggingInterceptor.java | 4 ++-- app/src/test/java/com/shalzz/attendance/DataManagerTest.java | 4 +++- 2 files changed, 5 insertions(+), 3 deletions(-) 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..4522ae6e 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,9 @@ 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%s", response.message(), response.request().url(), - (t2 - t1) / 1e6d, response.headers()); + (t2 - t1) / 1e6d, response.code(), response.headers()); return response; } 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()); } From 1bfddeae52d90dd349f59a00191159915b223db0 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 15 Apr 2018 18:13:47 +0530 Subject: [PATCH 08/22] remove timetable notification preference --- app/src/main/res/values/strings.xml | 9 --------- app/src/main/res/xml/preferences.xml | 5 ----- 2 files changed, 14 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 4b03ae19..30483a8e 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -38,7 +38,6 @@ pref_key_proxy_username pref_key_proxy_password ga_opt_in - notify_timetable_changed hide_weekends day_night bugsnag_opt_in @@ -104,9 +103,6 @@ Build Version Google Analytics Collect and send anonymous data to help improve the app - Notifications - Receive a notification when your - timetable changes Hide Weekends Display weekends as well Display only weekdays @@ -148,11 +144,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 14c707fb..b724ca37 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -37,11 +37,6 @@ android:title="@string/pref_hide_weekends" android:summaryOn="@string/pref_hide_weekends_summary_on" android:summaryOff="@string/pref_hide_weekends_summary_off"/> - From d48fd43c58a0cd9adf668f9d2b844ad29126d6f0 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 15 Apr 2018 18:15:20 +0530 Subject: [PATCH 09/22] add initial donation/pro mode preference --- .../com/shalzz/attendance/MyApplication.java | 2 +- .../ui/settings/SettingsFragment.java | 41 ++++++++++++----- .../wrapper/ProModeListPreference.java | 45 +++++++++++++++++++ app/src/main/res/values/arrays.xml | 16 +++++++ app/src/main/res/values/strings.xml | 15 ++++++- app/src/main/res/xml/preferences.xml | 10 ++++- 6 files changed, 112 insertions(+), 17 deletions(-) create mode 100644 app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java 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/ui/settings/SettingsFragment.java b/app/src/main/java/com/shalzz/attendance/ui/settings/SettingsFragment.java index 281bbef3..b3dcbccb 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,6 @@ package com.shalzz.attendance.ui.settings; import android.Manifest; -import android.app.NotificationManager; import android.app.backup.BackupManager; import android.content.Context; import android.content.SharedPreferences; @@ -39,6 +38,7 @@ import android.support.v7.preference.PreferenceScreen; import android.widget.Toast; +import com.afollestad.materialdialogs.MaterialDialog; import com.bugsnag.android.Bugsnag; import com.google.android.gms.analytics.GoogleAnalytics; import com.google.android.gms.analytics.HitBuilders; @@ -107,12 +107,20 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin requestBackup(); if(key.equals(key_sync_day_night)) { - ListPreference connectionPref = (ListPreference) findPreference(key); - connectionPref.setSummary(connectionPref.getEntry()); + ListPreference listPref = (ListPreference) findPreference(key); + listPref.setSummary(listPref.getEntry()); //noinspection WrongConstant AppCompatDelegate.setDefaultNightMode(Integer.parseInt(sharedPreferences. getString(key,"-1"))); } + else if(key.equals(getString(R.string.pref_key_hide_weekends))) { + // if not paid user + // show dialog for pro mode + // if dialog cancels + // set value back to default + SwitchPreference switchPref = (SwitchPreference) findPreference(key); + switchPref.setChecked(false); + } else if (key.equals(getString(R.string.pref_key_sync))) { if (ContextCompat.checkSelfPermission(mContext, Manifest.permission.GET_ACCOUNTS) != PackageManager.PERMISSION_GRANTED) { @@ -133,15 +141,6 @@ 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 */); - } - } } private void toggleSync(boolean sync) { @@ -191,6 +190,24 @@ public void onResume() { getPreferenceScreen().getSharedPreferences() .registerOnSharedPreferenceChangeListener(this); + PreferenceCategory prefCatGeneral = (PreferenceCategory) getPreferenceScreen() + .getPreference(0); + SwitchPreference proModePref = (SwitchPreference) prefCatGeneral.getPreference(0); + proModePref.setOnPreferenceClickListener(preference -> { + proModePref.setChecked(false); + new MaterialDialog.Builder(mContext) + .title(R.string.pref_dialog_title_donate) + .items(R.array.pref_donate_entries) + .itemsCallback((dialog, view, which, text) -> { + String values[] = mContext.getResources() + .getStringArray(R.array.pref_donate_values); + Toast.makeText(mContext, values[which], Toast.LENGTH_LONG).show(); + dialog.dismiss(); + }) + .show(); + return true; + }); + PreferenceCategory prefCategory = (PreferenceCategory) getPreferenceScreen() .getPreference(4); PreferenceScreen prefScreen = (PreferenceScreen) prefCategory.getPreference(0); 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..d3b69ba0 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java @@ -0,0 +1,45 @@ +package com.shalzz.attendance.wrapper; + +import android.content.Context; +import android.support.v4.content.res.TypedArrayUtils; +import android.support.v7.preference.ListPreference; +import android.util.AttributeSet; +import android.widget.Toast; + +import com.shalzz.attendance.R; + +import timber.log.Timber; + +/** + * @author shalzz + */ +public class ProModeListPreference extends ListPreference { + + Context mContext; + + 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); + } + + @Override + protected void onClick() { + if (false) { + super.onClick(); + } else { + Timber.d("Clicked!!! "); + } + } +} 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 30483a8e..fc381e71 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -41,6 +41,7 @@ hide_weekends day_night bugsnag_opt_in + pro_mode @@ -103,15 +104,25 @@ Build Version Google Analytics Collect and send anonymous data to help improve the app - Hide Weekends + Hide Weekends (Pro feature) Display weekends as well Display only weekdays - Day/Night Theme + Day/Night Theme (Pro feature) Select Night Mode Crash Reports Send crash reports with diagnostic information to help debug and improve the app + Donation Amount + Upgrade to Pro + Thanks for using College Academics. Consider donating + to help support my work (I develop this is for + free). Donating any amount enables Pro mode as well as removes + the ads. + Thanks for Donating. Pro Mode with additional + features are now enabled. + + Expandable item Touch a Subject for more details about it diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index b724ca37..305f456c 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -21,9 +21,15 @@ - + Date: Sun, 15 Apr 2018 22:08:37 +0530 Subject: [PATCH 10/22] use a static summary for night theme and hide weekends pref --- .../shalzz/attendance/ui/settings/SettingsFragment.java | 7 +------ app/src/main/res/values/strings.xml | 6 +++--- app/src/main/res/xml/preferences.xml | 4 ++-- 3 files changed, 6 insertions(+), 11 deletions(-) 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 b3dcbccb..6c5becc7 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 @@ -61,7 +61,6 @@ public class SettingsFragment extends PreferenceFragmentCompat implements private Context mContext; private String key_sync_interval; - private String key_sync_day_night; private SwitchPreference syncPref; @Inject @@ -79,10 +78,6 @@ public void onCreatePreferences(Bundle bundle, String s) { 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()); - key_sync_interval = getString(R.string.pref_key_sync_interval); ListPreference synclistPref = (ListPreference) findPreference(key_sync_interval); synclistPref.setSummary(synclistPref.getEntry()); @@ -106,7 +101,7 @@ public void onStart() { public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { requestBackup(); - if(key.equals(key_sync_day_night)) { + if(key.equals(getString(R.string.pref_key_day_night))) { ListPreference listPref = (ListPreference) findPreference(key); listPref.setSummary(listPref.getEntry()); //noinspection WrongConstant diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index fc381e71..519adb16 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -105,15 +105,14 @@ Google Analytics Collect and send anonymous data to help improve the app Hide Weekends (Pro feature) - Display weekends as well + 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 - - Donation Amount Upgrade to Pro Thanks for using College Academics. Consider donating to help support my work (I develop this is for @@ -121,6 +120,7 @@ the ads. Thanks for Donating. Pro Mode with additional features are now enabled. + Donation Amount diff --git a/app/src/main/res/xml/preferences.xml b/app/src/main/res/xml/preferences.xml index 305f456c..0d2116d5 100644 --- a/app/src/main/res/xml/preferences.xml +++ b/app/src/main/res/xml/preferences.xml @@ -32,6 +32,7 @@ android:defaultValue="1" android:title="@string/pref_day_night" android:dialogTitle="@string/pref_day_night_dialog" + android:summary="@string/pref_day_night_summary" android:entries="@array/pref_day_night_entries" android:entryValues="@array/pref_day_night_values"/> @@ -41,8 +42,7 @@ android:key="@string/pref_key_hide_weekends" android:defaultValue="false" android:title="@string/pref_hide_weekends" - android:summaryOn="@string/pref_hide_weekends_summary_on" - android:summaryOff="@string/pref_hide_weekends_summary_off"/> + android:summary="@string/pref_hide_weekends_summary"/> From 3186876cbca7da36c44613e919e4285a7c91577a Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Mon, 16 Apr 2018 18:13:39 +0530 Subject: [PATCH 11/22] seperatly handle client and server network errors --- .../data/remote/RetrofitException.java | 2 +- .../ui/attendance/AttendancePresenter.java | 24 ++++++++++++---- .../shalzz/attendance/ui/day/DayFragment.java | 9 ++++++ .../shalzz/attendance/ui/day/DayMvpView.java | 2 ++ .../attendance/ui/day/DayPresenter.java | 28 ++++++++++++++----- .../attendance/ui/login/LoginPresenter.java | 15 +++++++++- .../shalzz/attendance/utils/NetworkUtil.java | 27 ++++++++++++++++++ app/src/main/res/values/strings.xml | 2 +- 8 files changed, 94 insertions(+), 15 deletions(-) create mode 100644 app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java 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/ui/attendance/AttendancePresenter.java b/app/src/main/java/com/shalzz/attendance/ui/attendance/AttendancePresenter.java index f9af75dc..169a59f2 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,21 @@ public void onSuccess(Integer count) { //noinspection UnnecessaryReturnStatement return; } + else if (!NetworkUtil.isNetworkConnected(mContext)) { + Timber.i("Sync canceled, connection not available"); + 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 52fd15e2..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; @@ -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..31972ef8 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,22 @@ public void onSuccess(Integer count) { //noinspection UnnecessaryReturnStatement return; } + else if (!NetworkUtil.isNetworkConnected(mContext)) { + Timber.i("Sync canceled, connection not available"); + 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/utils/NetworkUtil.java b/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java new file mode 100644 index 00000000..70b6ec1b --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java @@ -0,0 +1,27 @@ +package com.shalzz.attendance.utils; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.NetworkInfo; + +import retrofit2.HttpException; + +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(); + } + +} \ No newline at end of file diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 519adb16..89d37c49 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -47,7 +47,7 @@ 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 From 4d00800bf07740c2d61c1179e2dace8a97207c08 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Thu, 19 Apr 2018 13:32:44 +0530 Subject: [PATCH 12/22] implement IAP for ProKey with infinite retry for billingclient --- app/build.gradle | 1 + app/src/main/AndroidManifest.xml | 1 - .../vending/billing/IInAppBillingService.aidl | 192 --- .../attendance/billing/BillingConstants.java | 47 + .../attendance/billing/BillingManager.java | 338 +++++ .../attendance/billing/BillingProvider.java | 25 + .../{util => billing}/Security.java | 78 +- .../attendance/event/ProKeyPurchaseEvent.java | 6 + .../attendance/event/PurchaseEvent.java | 17 + .../component/ApplicationComponent.java | 6 +- .../attendance/ui/main/MainActivity.java | 33 +- .../attendance/ui/main/MainMvpView.java | 3 + .../attendance/ui/main/MainPresenter.java | 75 +- .../ui/settings/AboutSettingsFragment.java | 9 +- .../ui/settings/SettingsFragment.java | 97 +- .../ui/timetable/TimeTablePagerAdapter.java | 14 +- .../ui/timetable/TimeTablePagerFragment.java | 6 +- .../attendance/util/IabBroadcastReceiver.java | 60 - .../shalzz/attendance/util/IabException.java | 43 - .../com/shalzz/attendance/util/IabHelper.java | 1099 ----------------- .../com/shalzz/attendance/util/IabResult.java | 45 - .../com/shalzz/attendance/util/Inventory.java | 91 -- .../com/shalzz/attendance/util/Purchase.java | 66 - .../shalzz/attendance/util/SkuDetails.java | 64 - .../wrapper/ProModeListPreference.java | 29 +- app/src/main/res/values/strings.xml | 3 + 26 files changed, 688 insertions(+), 1760 deletions(-) delete mode 100644 app/src/main/aidl/com/android/vending/billing/IInAppBillingService.aidl create mode 100644 app/src/main/java/com/shalzz/attendance/billing/BillingConstants.java create mode 100644 app/src/main/java/com/shalzz/attendance/billing/BillingManager.java create mode 100644 app/src/main/java/com/shalzz/attendance/billing/BillingProvider.java rename app/src/main/java/com/shalzz/attendance/{util => billing}/Security.java (62%) create mode 100644 app/src/main/java/com/shalzz/attendance/event/ProKeyPurchaseEvent.java create mode 100644 app/src/main/java/com/shalzz/attendance/event/PurchaseEvent.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/IabBroadcastReceiver.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/IabException.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/IabHelper.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/IabResult.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/Inventory.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/Purchase.java delete mode 100644 app/src/main/java/com/shalzz/attendance/util/SkuDetails.java diff --git a/app/build.gradle b/app/build.gradle index e9e2a323..0a528022 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -154,6 +154,7 @@ android { 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" 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/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..2e176b0f --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java @@ -0,0 +1,338 @@ +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.SkuDetails; +import com.android.billingclient.api.SkuDetailsParams; +import com.android.billingclient.api.SkuDetailsResponseListener; +import com.shalzz.attendance.utils.RxUtil; + +import java.io.IOException; +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.disposables.Disposable; +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 final List mPurchases = new ArrayList<>(); + private Set mTokensToBeConsumed; + + private Activity mActivity; + private Disposable mConnectionDisposable; + + /* BASE_64_ENCODED_PUBLIC_KEY should be YOUR APPLICATION'S PUBLIC KEY + * (that you got from the Google Play developer console). This is not your + * developer public key, it's the *app-specific* public key. + * + * Instead of just storing the entire literal string here embedded in the + * program, construct the key at runtime from pieces or + * use bit manipulation (for example, XOR with some other string) to hide + * the actual key. The key itself is not secret information, but we don't + * want to make it easy for an attacker to replace the public key with one + * of their own and then fake messages from the server. + */ + private static final String BASE_64_ENCODED_PUBLIC_KEY = "CONSTRUCT_YOUR_KEY_AND_PLACE_IT_HERE"; + + /** + * 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, + final BillingUpdatesListener updatesListener) { + mActivity = activity; + mBillingUpdatesListener = updatesListener; + mBillingClient = BillingClient.newBuilder(mActivity).setListener(this).build(); + + // Start the setup asynchronously. + // The specified listener is called once setup completes. + // New purchases are reported through the onPurchasesUpdated() callback + // of the class specified using the setListener() method above. + executeServiceRequest(() -> { + // 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 void executeServiceRequest(final Runnable executeOnSuccess) { + RxUtil.dispose(mConnectionDisposable); + mConnectionDisposable = Observable.create((ObservableOnSubscribe) source -> { + Timber.d("Called"); + if (source.isDisposed()) return; + if (mBillingClient.isReady()) { + if (executeOnSuccess != null) { + executeOnSuccess.run(); + return; + } + } + mBillingClient.startConnection(new BillingClientStateListener() { + @Override + public void onBillingSetupFinished(@BillingResponse int billingResponseCode) { + Timber.d("Setup finished. Response code: %d", billingResponseCode); + mBillingClientResponseCode = billingResponseCode; + source.onNext(mBillingClientResponseCode); + } + + @Override + public void onBillingServiceDisconnected() { + source.tryOnError(new Throwable("Billing Client disconnected!")); + Timber.w("onBillingServiceDisconnected1()"); + } + }); + }) + .retry() + .subscribe(code -> { + if (code == BillingResponse.OK) { + if (executeOnSuccess != null) { + executeOnSuccess.run(); + } + } + }, throwable -> { + Timber.w("onBillingServiceDisconnected()"); + }); + } + + /** + * Handle a callback that purchases were updated from the Billing library + */ + @Override + public void onPurchasesUpdated(int resultCode, List purchases) { + if (resultCode == BillingResponse.OK) { + for (Purchase purchase : purchases) { + handlePurchase(purchase); + } + mBillingUpdatesListener.onPurchasesUpdated(mPurchases); + } else if (resultCode == BillingResponse.USER_CANCELED) { + Timber.i("onPurchasesUpdated() - user cancelled the purchase flow - skipping"); + } else { + Timber.w("onPurchasesUpdated() got unknown resultCode: %d", resultCode); + } + } + + /** + * 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) { + Runnable purchaseFlowRequest = () -> { + 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); + }; + + executeServiceRequest(purchaseFlowRequest); + } + + /** + * Clear the resources + */ + public void destroy() { + Timber.d( "Destroying the manager."); + + RxUtil.dispose(mConnectionDisposable); + if (mBillingClient != null && mBillingClient.isReady()) { + mBillingClient.endConnection(); + mBillingClient = null; + } + } + + public void querySkuDetailsAsync(@SkuType final String itemType, final List skuList, + final SkuDetailsResponseListener listener) { + // Creating a runnable from the request to use it inside our connection retry policy below + Runnable queryRequest = new Runnable() { + @Override + public void run() { + // Query the purchase async + SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); + params.setSkusList(skuList).setType(itemType); + mBillingClient.querySkuDetailsAsync(params.build(), + new SkuDetailsResponseListener() { + @Override + public void onSkuDetailsResponse(int responseCode, + List skuDetailsList) { + listener.onSkuDetailsResponse(responseCode, skuDetailsList); + } + }); + } + }; + + executeServiceRequest(queryRequest); + } + + 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); + + Runnable consumeRequest = () -> mBillingClient.consumeAsync(purchaseToken, onConsumeListener); + + executeServiceRequest(consumeRequest); + } + + /** + * Returns the value Billing client response code or BILLING_MANAGER_NOT_INITIALIZED if the + * clien connection response was not received yet. + */ + public int getBillingClientResponseCode() { + return mBillingClientResponseCode; + } + + /** + * Handles the purchase + *

Note: Notice that for each purchase, we check if signature is valid on the client. + * It's recommended to move this check into your backend. + * See {@link Security#verifyPurchase(String, String, String)} + *

+ * @param purchase Purchase to be handled + */ + private void handlePurchase(Purchase purchase) { + if (!verifyValidSignature(purchase.getOriginalJson(), purchase.getSignature())) { + Timber.i( "Got a purchase: %s ; but signature is bad. Skipping...", purchase); + return; + } + + Timber.d("Got a verified purchase: %s", purchase); + + mPurchases.add(purchase); + } + + /** + * 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."); + + // Update the UI and purchases inventory with new list of purchases + mPurchases.clear(); + onPurchasesUpdated(BillingResponse.OK, result.getPurchasesList()); + } + + /** + * 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. + *

+ */ + public 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; + } + + /** + * Query purchases across various use cases and deliver the result in a formalized way through + * a listener + */ + public void queryPurchases() { + Runnable queryToExecute = new Runnable() { + @Override + public void run() { + long time = System.currentTimeMillis(); + PurchasesResult purchasesResult = mBillingClient.queryPurchases(SkuType.INAPP); + Timber.i("Querying purchases elapsed time: " + (System.currentTimeMillis() - time) + + "ms"); + // 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: " + + (System.currentTimeMillis() - time) + "ms"); + Timber.i( "Querying subscriptions result code: " + + subscriptionResult.getResponseCode() + + " res: " + 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); + } + }; + + executeServiceRequest(queryToExecute); + } + + /** + * Verifies that the purchase was signed correctly for this developer's public key. + *

Note: It's strongly recommended to perform such check on your backend since hackers can + * replace this method with "constant true" if they decompile/rebuild your app. + *

+ */ + private boolean verifyValidSignature(String signedData, String signature) { + try { + return Security.verifyPurchase(BASE_64_ENCODED_PUBLIC_KEY, signedData, signature); + } catch (IOException e) { + Timber.e(e, "Got an exception trying to validate a purchase"); + return false; + } + } +} 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/util/Security.java b/app/src/main/java/com/shalzz/attendance/billing/Security.java similarity index 62% rename from app/src/main/java/com/shalzz/attendance/util/Security.java rename to app/src/main/java/com/shalzz/attendance/billing/Security.java index 6141ed76..c7b9434d 100644 --- a/app/src/main/java/com/shalzz/attendance/util/Security.java +++ b/app/src/main/java/com/shalzz/attendance/billing/Security.java @@ -1,4 +1,5 @@ -/* Copyright (c) 2012 Google Inc. +/* + * 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. @@ -13,11 +14,12 @@ * limitations under the License. */ -package com.shalzz.attendance.util; +package com.shalzz.attendance.billing; import android.text.TextUtils; import android.util.Base64; - +import com.android.billingclient.util.BillingHelper; +import java.io.IOException; import java.security.InvalidKeyException; import java.security.KeyFactory; import java.security.NoSuchAlgorithmException; @@ -27,65 +29,62 @@ 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. + * 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. */ public class Security { + private static final String TAG = "IABUtil/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. + * Verifies that the data was signed with the given signature, and returns the verified + * 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 + * @throws IOException if encoding algorithm is not supported or key specification + * is invalid */ - 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."); + public static boolean verifyPurchase(String base64PublicKey, String signedData, + String signature) throws IOException { + if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) + || TextUtils.isEmpty(signature)) { + BillingHelper.logWarn(TAG, "Purchase verification failed: missing data."); return false; } - PublicKey key = Security.generatePublicKey(base64PublicKey); - return Security.verify(key, signedData, signature); + PublicKey key = generatePublicKey(base64PublicKey); + return verify(key, signedData, signature); } /** - * Generates a PublicKey instance from a string containing the - * Base64-encoded public key. + * 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 + * @throws IOException if encoding algorithm is not supported or key specification + * is invalid */ - public static PublicKey generatePublicKey(String encodedPublicKey) { + public static PublicKey generatePublicKey(String encodedPublicKey) throws IOException { try { byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT); KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); } catch (NoSuchAlgorithmException e) { + // "RSA" is guaranteed to be available. throw new RuntimeException(e); } catch (InvalidKeySpecException e) { - Timber.e("Invalid key specification."); - throw new IllegalArgumentException(e); + String msg = "Invalid key specification: " + e; + BillingHelper.logWarn(TAG, msg); + throw new IOException(msg); } } /** - * Verifies that the signature from the server matches the computed - * signature on the data. Returns true if the data is correctly signed. + * 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 @@ -97,24 +96,25 @@ public static boolean verify(PublicKey publicKey, String signedData, String sign try { signatureBytes = Base64.decode(signature, Base64.DEFAULT); } catch (IllegalArgumentException e) { - Timber.e("Base64 decoding failed."); + BillingHelper.logWarn(TAG, "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."); + Signature signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM); + signatureAlgorithm.initVerify(publicKey); + signatureAlgorithm.update(signedData.getBytes()); + if (!signatureAlgorithm.verify(signatureBytes)) { + BillingHelper.logWarn(TAG, "Signature verification failed."); return false; } return true; } catch (NoSuchAlgorithmException e) { - Timber.e("NoSuchAlgorithmException."); + // "RSA" is guaranteed to be available. + throw new RuntimeException(e); } catch (InvalidKeyException e) { - Timber.e("Invalid key specification."); + BillingHelper.logWarn(TAG, "Invalid key specification."); } catch (SignatureException e) { - Timber.e("Signature exception."); + BillingHelper.logWarn(TAG, "Signature exception."); } return false; } 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/ui/main/MainActivity.java b/app/src/main/java/com/shalzz/attendance/ui/main/MainActivity.java index ae193ee8..845b55d9 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,7 @@ protected void onCreate(Bundle savedInstanceState) { mFragmentManager = getSupportFragmentManager(); DrawerheaderVH = new DrawerHeaderViewHolder(mNavigationView.getHeaderView(0)); - + mBillingManager = new BillingManager(this, mMainPresenter.getUpdateListener()); setSupportActionBar(mToolbar); // Set the list's click listener @@ -181,6 +185,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 +609,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..8ed2499f 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,31 @@ import android.content.Context; import android.content.SharedPreferences; import android.preference.PreferenceManager; +import android.widget.Toast; +import com.android.billingclient.api.BillingClient; +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.event.PurchaseEvent; 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 +64,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 +83,7 @@ public class MainPresenter extends BasePresenter { mDataManager = dataManager; mPreferenceHelper = preferencesHelper; mContext = context; + mUpdateListener = new UpdateListener(); } @Override @@ -134,4 +151,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 6c5becc7..76eb930a 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,6 +20,7 @@ package com.shalzz.attendance.ui.settings; import android.Manifest; +import android.app.Activity; import android.app.backup.BackupManager; import android.content.Context; import android.content.SharedPreferences; @@ -38,20 +39,27 @@ import android.support.v7.preference.PreferenceScreen; import android.widget.Toast; -import com.afollestad.materialdialogs.MaterialDialog; +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,10 +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 SwitchPreference syncPref; - @Inject @Named("app") Tracker mTracker; @@ -70,14 +74,32 @@ 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 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); + mBillingProvider = (BillingProvider) mActivity; + key_sync_interval = getString(R.string.pref_key_sync_interval); ListPreference synclistPref = (ListPreference) findPreference(key_sync_interval); synclistPref.setSummary(synclistPref.getEntry()); @@ -88,6 +110,10 @@ public void onCreatePreferences(Bundle bundle, String s) { toggleSync(false); syncPref.setChecked(false); } + + proModePref = (SwitchPreference) findPreference(getString(R.string.pref_key_pro_mode)); + PurchaseEventDisposable = mEventBus.filteredObservable(ProKeyPurchaseEvent.class) + .subscribe(proKeyPurchaseEvent -> proModePref.setChecked(true), Timber::e); } @Override @@ -99,8 +125,6 @@ public void onStart() { } public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { - requestBackup(); - if(key.equals(getString(R.string.pref_key_day_night))) { ListPreference listPref = (ListPreference) findPreference(key); listPref.setSummary(listPref.getEntry()); @@ -109,12 +133,11 @@ public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, Strin getString(key,"-1"))); } else if(key.equals(getString(R.string.pref_key_hide_weekends))) { - // if not paid user - // show dialog for pro mode - // if dialog cancels - // set value back to default - SwitchPreference switchPref = (SwitchPreference) findPreference(key); - switchPref.setChecked(false); + if (!mBillingProvider.isProKeyPurchased()) { + SwitchPreference switchPref = (SwitchPreference) findPreference(key); + switchPref.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) != @@ -136,6 +159,8 @@ else if(key.equals(getString(R.string.pref_key_ga_opt_in))) { !optIn); Timber.i("Opted out of Google Analytics: %b", !optIn); } + + requestBackup(); } private void toggleSync(boolean sync) { @@ -176,6 +201,12 @@ public void onPause() { .unregisterOnSharedPreferenceChangeListener(this); } + @Override + public void onDestroy() { + super.onDestroy(); + RxUtil.dispose(PurchaseEventDisposable); + } + @Override public void onResume() { super.onResume(); @@ -187,19 +218,25 @@ public void onResume() { PreferenceCategory prefCatGeneral = (PreferenceCategory) getPreferenceScreen() .getPreference(0); - SwitchPreference proModePref = (SwitchPreference) prefCatGeneral.getPreference(0); - proModePref.setOnPreferenceClickListener(preference -> { - proModePref.setChecked(false); - new MaterialDialog.Builder(mContext) - .title(R.string.pref_dialog_title_donate) - .items(R.array.pref_donate_entries) - .itemsCallback((dialog, view, which, text) -> { - String values[] = mContext.getResources() - .getStringArray(R.array.pref_donate_values); - Toast.makeText(mContext, values[which], Toast.LENGTH_LONG).show(); - dialog.dismiss(); - }) - .show(); + + if (mBillingProvider.isProKeyPurchased()) { + proModePref.setChecked(true); + } else { + proModePref.setOnPreferenceClickListener(preference -> { + proModePref.setChecked(false); + mBillingProvider.getBillingManager() + .initiatePurchaseFlow(BillingConstants.SKU_PRO_KEY, BillingClient.SkuType.INAPP); + return true; + }); + } + + ProModeListPreference proThemePref = (ProModeListPreference) prefCatGeneral.getPreference(1); + proThemePref.setProModeListPreferenceClickListener(preference -> { + if (mBillingProvider.isProKeyPurchased()) { + proThemePref.showDialog(); + } else { + Toast.makeText(mContext, "Pro key required!", Toast.LENGTH_SHORT).show(); + } return true; }); @@ -215,7 +252,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/timetable/TimeTablePagerAdapter.java b/app/src/main/java/com/shalzz/attendance/ui/timetable/TimeTablePagerAdapter.java index 374378c4..3c9b6532 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,6 +27,7 @@ import android.util.SparseArray; import com.shalzz.attendance.R; +import com.shalzz.attendance.billing.BillingProvider; import com.shalzz.attendance.ui.day.DayFragment; import com.shalzz.attendance.wrapper.DateHelper; @@ -46,13 +47,16 @@ public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { private boolean mHideWeekends; private Callback mCallback; - TimeTablePagerAdapter(FragmentManager fm, Context context, Callback callback) { + TimeTablePagerAdapter(FragmentManager fm, Activity activity, Callback callback) { super(fm); mCallback = callback; - SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(context); - mHideWeekends = sharedPref.getBoolean(context.getString(R.string.pref_key_hide_weekends), - false); + if (((BillingProvider)activity).isProKeyPurchased()) { + SharedPreferences sharedPref = PreferenceManager.getDefaultSharedPreferences(activity); + mHideWeekends = sharedPref.getBoolean(activity.getString(R.string.pref_key_hide_weekends), false); + } else { + mHideWeekends = false; + } mToday = new Date(); setDate(mToday); 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..7fbdbffa 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; @@ -65,6 +66,9 @@ public class TimeTablePagerFragment extends Fragment implements TimeTableMvpView @Inject TimeTablePresenter mTimeTablePresenter; + @Inject + Activity mActivity; + private int mPreviousPosition = 15; private TimeTablePagerAdapter mAdapter; private Context mContext; @@ -96,7 +100,7 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, actionbar = ((AppCompatActivity) getActivity()).getSupportActionBar(); mAdapter = new TimeTablePagerAdapter(getChildFragmentManager(), - mContext, + mActivity, position -> mViewPager.setCurrentItem(position, true)); mViewPager.setOffscreenPageLimit(3); 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/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/wrapper/ProModeListPreference.java b/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java index d3b69ba0..700c536a 100644 --- a/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java +++ b/app/src/main/java/com/shalzz/attendance/wrapper/ProModeListPreference.java @@ -1,21 +1,17 @@ package com.shalzz.attendance.wrapper; import android.content.Context; -import android.support.v4.content.res.TypedArrayUtils; import android.support.v7.preference.ListPreference; +import android.support.v7.preference.Preference; import android.util.AttributeSet; -import android.widget.Toast; - -import com.shalzz.attendance.R; - -import timber.log.Timber; /** * @author shalzz */ public class ProModeListPreference extends ListPreference { - Context mContext; + private Context mContext; + private OnProModeListPreferenceClickListener mCallback; public ProModeListPreference(Context context, AttributeSet attrs, int defStyleAttr, int defStyleRes) { super(context, attrs, defStyleAttr, defStyleRes); @@ -34,12 +30,21 @@ public ProModeListPreference(Context context) { super(context); } + public void showDialog() { + super.onClick(); + } + + public void setProModeListPreferenceClickListener(OnProModeListPreferenceClickListener listener) { + mCallback = listener; + } + @Override protected void onClick() { - if (false) { - super.onClick(); - } else { - Timber.d("Clicked!!! "); - } + if (mCallback != null) + mCallback.onPreferenceClick(this); + } + + public interface OnProModeListPreferenceClickListener { + boolean onPreferenceClick(ProModeListPreference preference); } } diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 89d37c49..f617a531 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -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 :) From f975a982da6a3b3c5f21598d07941e6dbcf33b85 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Thu, 19 Apr 2018 19:53:19 +0530 Subject: [PATCH 13/22] Rxify BillingManager with server side purchase verification --- app/proguard/proguard-square-okio.pro | 5 - .../attendance/billing/BillingManager.java | 376 ++++++++---------- .../shalzz/attendance/billing/Security.java | 121 ------ .../shalzz/attendance/data/DataManager.java | 9 + .../attendance/data/remote/DataAPI.java | 9 +- .../interceptor/CacheControlInterceptor.java | 9 + .../attendance/ui/main/MainActivity.java | 3 +- .../attendance/ui/main/MainPresenter.java | 2 - .../ui/settings/SettingsFragment.java | 3 + .../shalzz/attendance/utils/NetworkUtil.java | 6 + 10 files changed, 210 insertions(+), 333 deletions(-) delete mode 100644 app/proguard/proguard-square-okio.pro delete mode 100644 app/src/main/java/com/shalzz/attendance/billing/Security.java create mode 100644 app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java 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/java/com/shalzz/attendance/billing/BillingManager.java b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java index 2e176b0f..7f07b388 100644 --- a/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java +++ b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java @@ -12,9 +12,9 @@ import com.android.billingclient.api.Purchase; import com.android.billingclient.api.Purchase.PurchasesResult; import com.android.billingclient.api.PurchasesUpdatedListener; -import com.android.billingclient.api.SkuDetails; import com.android.billingclient.api.SkuDetailsParams; import com.android.billingclient.api.SkuDetailsResponseListener; +import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.utils.RxUtil; import java.io.IOException; @@ -25,7 +25,11 @@ 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 { @@ -38,24 +42,12 @@ public class BillingManager implements PurchasesUpdatedListener { private final BillingUpdatesListener mBillingUpdatesListener; private int mBillingClientResponseCode = BILLING_MANAGER_NOT_INITIALIZED; - private final List mPurchases = new ArrayList<>(); private Set mTokensToBeConsumed; private Activity mActivity; - private Disposable mConnectionDisposable; - - /* BASE_64_ENCODED_PUBLIC_KEY should be YOUR APPLICATION'S PUBLIC KEY - * (that you got from the Google Play developer console). This is not your - * developer public key, it's the *app-specific* public key. - * - * Instead of just storing the entire literal string here embedded in the - * program, construct the key at runtime from pieces or - * use bit manipulation (for example, XOR with some other string) to hide - * the actual key. The key itself is not secret information, but we don't - * want to make it easy for an attacker to replace the public key with one - * of their own and then fake messages from the server. - */ - private static final String BASE_64_ENCODED_PUBLIC_KEY = "CONSTRUCT_YOUR_KEY_AND_PLACE_IT_HERE"; + 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 @@ -68,59 +60,61 @@ public interface BillingUpdatesListener { } public BillingManager(Activity activity, + DataManager dataManager, final BillingUpdatesListener updatesListener) { mActivity = activity; + mDataManager = dataManager; mBillingUpdatesListener = updatesListener; mBillingClient = BillingClient.newBuilder(mActivity).setListener(this).build(); - // Start the setup asynchronously. - // The specified listener is called once setup completes. - // New purchases are reported through the onPurchasesUpdated() callback - // of the class specified using the setListener() method above. - executeServiceRequest(() -> { - // 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(); - }); + 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 void executeServiceRequest(final Runnable executeOnSuccess) { - RxUtil.dispose(mConnectionDisposable); - mConnectionDisposable = Observable.create((ObservableOnSubscribe) source -> { - Timber.d("Called"); + private Observable connect() { + return Observable.create((ObservableOnSubscribe) source -> { if (source.isDisposed()) return; if (mBillingClient.isReady()) { - if (executeOnSuccess != null) { - executeOnSuccess.run(); - return; - } + 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()"); + } + }); } - mBillingClient.startConnection(new BillingClientStateListener() { - @Override - public void onBillingSetupFinished(@BillingResponse int billingResponseCode) { - Timber.d("Setup finished. Response code: %d", billingResponseCode); - mBillingClientResponseCode = billingResponseCode; - source.onNext(mBillingClientResponseCode); - } - - @Override - public void onBillingServiceDisconnected() { - source.tryOnError(new Throwable("Billing Client disconnected!")); - Timber.w("onBillingServiceDisconnected1()"); - } - }); }) .retry() - .subscribe(code -> { - if (code == BillingResponse.OK) { - if (executeOnSuccess != null) { - executeOnSuccess.run(); - } - } - }, throwable -> { - Timber.w("onBillingServiceDisconnected()"); - }); + .subscribeOn(Schedulers.io()) + .observeOn(AndroidSchedulers.mainThread()); } /** @@ -129,10 +123,7 @@ public void onBillingServiceDisconnected() { @Override public void onPurchasesUpdated(int resultCode, List purchases) { if (resultCode == BillingResponse.OK) { - for (Purchase purchase : purchases) { - handlePurchase(purchase); - } - mBillingUpdatesListener.onPurchasesUpdated(mPurchases); + publishSubject.onNext(purchases); } else if (resultCode == BillingResponse.USER_CANCELED) { Timber.i("onPurchasesUpdated() - user cancelled the purchase flow - skipping"); } else { @@ -140,6 +131,28 @@ public void onPurchasesUpdated(int resultCode, List purchases) { } } + 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 */ @@ -152,99 +165,41 @@ public void initiatePurchaseFlow(final String skuId, final @SkuType String billi */ public void initiatePurchaseFlow(final String skuId, final ArrayList oldSkus, final @SkuType String billingType) { - Runnable purchaseFlowRequest = () -> { - 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); - }; - - executeServiceRequest(purchaseFlowRequest); - } - - /** - * Clear the resources - */ - public void destroy() { - Timber.d( "Destroying the manager."); - - RxUtil.dispose(mConnectionDisposable); - if (mBillingClient != null && mBillingClient.isReady()) { - mBillingClient.endConnection(); - mBillingClient = null; - } - } - public void querySkuDetailsAsync(@SkuType final String itemType, final List skuList, - final SkuDetailsResponseListener listener) { - // Creating a runnable from the request to use it inside our connection retry policy below - Runnable queryRequest = new Runnable() { - @Override - public void run() { - // Query the purchase async - SkuDetailsParams.Builder params = SkuDetailsParams.newBuilder(); - params.setSkusList(skuList).setType(itemType); - mBillingClient.querySkuDetailsAsync(params.build(), - new SkuDetailsResponseListener() { - @Override - public void onSkuDetailsResponse(int responseCode, - List skuDetailsList) { - listener.onSkuDetailsResponse(responseCode, skuDetailsList); - } - }); - } - }; - - executeServiceRequest(queryRequest); - } - - 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); - - Runnable consumeRequest = () -> mBillingClient.consumeAsync(purchaseToken, onConsumeListener); - - executeServiceRequest(consumeRequest); + 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 - * clien connection response was not received yet. + * client connection response was not received yet. */ public int getBillingClientResponseCode() { return mBillingClientResponseCode; } /** - * Handles the purchase - *

Note: Notice that for each purchase, we check if signature is valid on the client. - * It's recommended to move this check into your backend. - * See {@link Security#verifyPurchase(String, String, String)} + * 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. *

- * @param purchase Purchase to be handled */ - private void handlePurchase(Purchase purchase) { - if (!verifyValidSignature(purchase.getOriginalJson(), purchase.getSignature())) { - Timber.i( "Got a purchase: %s ; but signature is bad. Skipping...", purchase); - return; + private boolean areSubscriptionsSupported() { + int responseCode = mBillingClient.isFeatureSupported(FeatureType.SUBSCRIPTIONS); + if (responseCode != BillingResponse.OK) { + Timber.w("areSubscriptionsSupported() got an error response: %d", responseCode); } - - Timber.d("Got a verified purchase: %s", purchase); - - mPurchases.add(purchase); + return responseCode == BillingResponse.OK; } /** @@ -259,25 +214,7 @@ private void onQueryPurchasesFinished(PurchasesResult result) { } Timber.d("Query inventory was successful."); - - // Update the UI and purchases inventory with new list of purchases - mPurchases.clear(); - onPurchasesUpdated(BillingResponse.OK, result.getPurchasesList()); - } - - /** - * 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. - *

- */ - public 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; + publishSubject.onNext(result.getPurchasesList()); } /** @@ -285,54 +222,91 @@ public boolean areSubscriptionsSupported() { * a listener */ public void queryPurchases() { - Runnable queryToExecute = new Runnable() { - @Override - public void run() { - long time = System.currentTimeMillis(); - PurchasesResult purchasesResult = mBillingClient.queryPurchases(SkuType.INAPP); - Timber.i("Querying purchases elapsed time: " + (System.currentTimeMillis() - time) - + "ms"); - // 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: " - + (System.currentTimeMillis() - time) + "ms"); - Timber.i( "Querying subscriptions result code: " - + subscriptionResult.getResponseCode() - + " res: " + 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"); + 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); } - } 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) { - executeServiceRequest(queryToExecute); + 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); } /** - * Verifies that the purchase was signed correctly for this developer's public key. - *

Note: It's strongly recommended to perform such check on your backend since hackers can - * replace this method with "constant true" if they decompile/rebuild your app. - *

+ * Clear the resources */ - private boolean verifyValidSignature(String signedData, String signature) { - try { - return Security.verifyPurchase(BASE_64_ENCODED_PUBLIC_KEY, signedData, signature); - } catch (IOException e) { - Timber.e(e, "Got an exception trying to validate a purchase"); - return false; + 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/Security.java b/app/src/main/java/com/shalzz/attendance/billing/Security.java deleted file mode 100644 index c7b9434d..00000000 --- a/app/src/main/java/com/shalzz/attendance/billing/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.billing; - -import android.text.TextUtils; -import android.util.Base64; -import com.android.billingclient.util.BillingHelper; -import java.io.IOException; -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; - -/** - * 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. - */ -public class Security { - private static final String TAG = "IABUtil/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. - * @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 - * @throws IOException if encoding algorithm is not supported or key specification - * is invalid - */ - public static boolean verifyPurchase(String base64PublicKey, String signedData, - String signature) throws IOException { - if (TextUtils.isEmpty(signedData) || TextUtils.isEmpty(base64PublicKey) - || TextUtils.isEmpty(signature)) { - BillingHelper.logWarn(TAG, "Purchase verification failed: missing data."); - return false; - } - - PublicKey key = generatePublicKey(base64PublicKey); - return verify(key, signedData, signature); - } - - /** - * Generates a PublicKey instance from a string containing the Base64-encoded public key. - * - * @param encodedPublicKey Base64-encoded public key - * @throws IOException if encoding algorithm is not supported or key specification - * is invalid - */ - public static PublicKey generatePublicKey(String encodedPublicKey) throws IOException { - try { - byte[] decodedKey = Base64.decode(encodedPublicKey, Base64.DEFAULT); - KeyFactory keyFactory = KeyFactory.getInstance(KEY_FACTORY_ALGORITHM); - return keyFactory.generatePublic(new X509EncodedKeySpec(decodedKey)); - } catch (NoSuchAlgorithmException e) { - // "RSA" is guaranteed to be available. - throw new RuntimeException(e); - } catch (InvalidKeySpecException e) { - String msg = "Invalid key specification: " + e; - BillingHelper.logWarn(TAG, msg); - throw new IOException(msg); - } - } - - /** - * 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) { - BillingHelper.logWarn(TAG, "Base64 decoding failed."); - return false; - } - try { - Signature signatureAlgorithm = Signature.getInstance(SIGNATURE_ALGORITHM); - signatureAlgorithm.initVerify(publicKey); - signatureAlgorithm.update(signedData.getBytes()); - if (!signatureAlgorithm.verify(signatureBytes)) { - BillingHelper.logWarn(TAG, "Signature verification failed."); - return false; - } - return true; - } catch (NoSuchAlgorithmException e) { - // "RSA" is guaranteed to be available. - throw new RuntimeException(e); - } catch (InvalidKeyException e) { - BillingHelper.logWarn(TAG, "Invalid key specification."); - } catch (SignatureException e) { - BillingHelper.logWarn(TAG, "Signature exception."); - } - return false; - } -} 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..d801e54e 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,18 +23,18 @@ 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 { - String ENDPOINT = "https://academics.8bitlabs.in/api/v1/"; -// String ENDPOINT = "http://192.168.1.160:8080/api/v1/"; +// String ENDPOINT = "https://academics.8bitlabs.in/api/v1/"; + String ENDPOINT = "http://192.168.1.160:8080/api/v1/"; @GET("me") Observable getUser(@Header("Authorization") String authorization); @@ -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/interceptor/CacheControlInterceptor.java b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java new file mode 100644 index 00000000..4bc00df5 --- /dev/null +++ b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java @@ -0,0 +1,9 @@ +package com.shalzz.attendance.data.remote.interceptor; + +import okhttp3.Interceptor; + +/** + * @author shalzz + */ +public class CacheControlInterceptor implements Interceptor { +} 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 845b55d9..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 @@ -172,7 +172,8 @@ protected void onCreate(Bundle savedInstanceState) { mFragmentManager = getSupportFragmentManager(); DrawerheaderVH = new DrawerHeaderViewHolder(mNavigationView.getHeaderView(0)); - mBillingManager = new BillingManager(this, mMainPresenter.getUpdateListener()); + mBillingManager = new BillingManager(this, mDataManager, + mMainPresenter.getUpdateListener()); setSupportActionBar(mToolbar); // Set the list's click listener 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 8ed2499f..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 @@ -24,7 +24,6 @@ import android.preference.PreferenceManager; import android.widget.Toast; -import com.android.billingclient.api.BillingClient; import com.android.billingclient.api.BillingClient.BillingResponse; import com.android.billingclient.api.Purchase; import com.bugsnag.android.Bugsnag; @@ -37,7 +36,6 @@ import com.shalzz.attendance.data.local.PreferencesHelper; import com.shalzz.attendance.data.model.User; import com.shalzz.attendance.event.ProKeyPurchaseEvent; -import com.shalzz.attendance.event.PurchaseEvent; import com.shalzz.attendance.injection.ApplicationContext; import com.shalzz.attendance.injection.ConfigPersistent; import com.shalzz.attendance.ui.base.BasePresenter; 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 76eb930a..6f1c720d 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 @@ -220,8 +220,11 @@ public void onResume() { .getPreference(0); if (mBillingProvider.isProKeyPurchased()) { + proModePref.setOnPreferenceClickListener(null); proModePref.setChecked(true); + proModePref.setSelectable(false); } else { + proModePref.setChecked(false); proModePref.setOnPreferenceClickListener(preference -> { proModePref.setChecked(false); mBillingProvider.getBillingManager() diff --git a/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java b/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java index 70b6ec1b..2b7ef5c3 100644 --- a/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java +++ b/app/src/main/java/com/shalzz/attendance/utils/NetworkUtil.java @@ -5,6 +5,7 @@ import android.net.NetworkInfo; import retrofit2.HttpException; +import retrofit2.http.Headers; public class NetworkUtil { @@ -24,4 +25,9 @@ public static boolean isNetworkConnected(Context context) { 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 From 660e914c25ba3558b0390e73ebb89cfeacddeb8a Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 21 Apr 2018 12:02:46 +0530 Subject: [PATCH 14/22] add offline response caching for okHttp --- app/build.gradle | 2 +- app/proguard/proguard-square-okhttp3.pro | 11 +++-- .../interceptor/CacheControlInterceptor.java | 47 +++++++++++++++++++ .../interceptor/LoggingInterceptor.java | 5 +- .../injection/module/NetworkModule.java | 16 ++++++- .../ui/attendance/AttendancePresenter.java | 1 - .../attendance/ui/day/DayPresenter.java | 1 - 7 files changed, 72 insertions(+), 11 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 0a528022..204b3145 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -133,7 +133,7 @@ android { 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' 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/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java b/app/src/main/java/com/shalzz/attendance/data/remote/interceptor/CacheControlInterceptor.java index 4bc00df5..a7aa78e9 100644 --- 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 @@ -1,9 +1,56 @@ 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; /** * @author shalzz */ 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 { + Request request = chain.request(); + + if (NetworkUtil.isNetworkConnected(mContext)) { + CacheControl cacheControl = new CacheControl.Builder() + .maxAge( 1, TimeUnit.MINUTES ) + .immutable() + .build(); + + request = request.newBuilder() + .cacheControl(cacheControl) + .build(); + } else if (request.url().encodedPath().equals("/api/v1/verify")) {// only for the 'verify' + // api route + 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 4522ae6e..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%nstatus: %s %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.code(), response.headers()); + (t2 - t1) / 1e6d, response.code(), response.headers(), + response.cacheResponse() != null); return response; } 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..c132e194 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)) + .addInterceptor(new CacheControlInterceptor(context)) .addNetworkInterceptor(new LoggingInterceptor()); return okHttpBuilder.build(); 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 169a59f2..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 @@ -103,7 +103,6 @@ public void onSuccess(Integer count) { return; } else if (!NetworkUtil.isNetworkConnected(mContext)) { - Timber.i("Sync canceled, connection not available"); if (count > 0) { getMvpView().showRetryError( mContext.getString(R.string.no_internet)); 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 31972ef8..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 @@ -101,7 +101,6 @@ public void onSuccess(Integer count) { return; } else if (!NetworkUtil.isNetworkConnected(mContext)) { - Timber.i("Sync canceled, connection not available"); if (count > 0) { getMvpView().showRetryError( mContext.getString(R.string.no_internet)); From 0a1cd2b7d29f3c1bd2efc1704eebc9f34c712bef Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 21 Apr 2018 13:29:42 +0530 Subject: [PATCH 15/22] revert back to the public API url and add a test --- .../com/shalzz/attendance/data/remote/DataAPI.java | 4 ++-- .../java/com/shalzz/attendance/DataAPITest.java | 14 ++++++++++++++ 2 files changed, 16 insertions(+), 2 deletions(-) create mode 100644 app/src/test/java/com/shalzz/attendance/DataAPITest.java 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 d801e54e..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 @@ -33,8 +33,8 @@ public interface DataAPI { -// String ENDPOINT = "https://academics.8bitlabs.in/api/v1/"; - String ENDPOINT = "http://192.168.1.160:8080/api/v1/"; + String ENDPOINT = "https://academics.8bitlabs.in/api/v1/"; +// String ENDPOINT = "http://192.168.1.160:8080/api/v1/"; @GET("me") Observable getUser(@Header("Authorization") String authorization); 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/"); + } +} From 61c8cb2ef17a07d8ae2843008b1f88b6c2cf3462 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 21 Apr 2018 13:50:09 +0530 Subject: [PATCH 16/22] update travis.yml --- .travis.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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 From 2e47c37c5d8d1870fbca9e2be0da05532bfbc013 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 21 Apr 2018 15:13:31 +0530 Subject: [PATCH 17/22] Pro mode: unpersonalise pref strings --- app/src/main/res/values/strings.xml | 10 ++++------ 1 file changed, 4 insertions(+), 6 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index f617a531..64d69a9a 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -117,12 +117,10 @@ Send crash reports with diagnostic information to help debug and improve the app Upgrade to Pro - Thanks for using College Academics. Consider donating - to help support my work (I develop this is for - free). Donating any amount enables Pro mode as well as removes - the ads. - Thanks for Donating. Pro Mode with additional - features are now enabled. + Thanks for using College Academics. Consider upgrading + to Pro Mode to unlock additional features and remove ads. + Pro Mode is now unlocked with additional + features available and ads removed. Donation Amount From b6a0e49f432b27dda41fee34def0a6a0f209f4f1 Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sat, 21 Apr 2018 15:17:16 +0530 Subject: [PATCH 18/22] remove references to ads until they are implemented --- app/src/main/res/values/strings.xml | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/app/src/main/res/values/strings.xml b/app/src/main/res/values/strings.xml index 64d69a9a..3a834cdb 100644 --- a/app/src/main/res/values/strings.xml +++ b/app/src/main/res/values/strings.xml @@ -118,9 +118,9 @@ 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 and remove ads. + to Pro Mode to unlock additional features.
Pro Mode is now unlocked with additional - features available and ads removed. + features available.
Donation Amount From 587932733ab58143f23b1fda8a97245787bb379e Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 22 Apr 2018 08:40:02 +0530 Subject: [PATCH 19/22] Timetable: update TimetableAdapter on purchase event --- .../ui/timetable/TimeTablePagerAdapter.java | 33 +++++++++++++++---- .../ui/timetable/TimeTablePagerFragment.java | 8 ++++- 2 files changed, 34 insertions(+), 7 deletions(-) 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 3c9b6532..b9a094e9 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 @@ -28,13 +28,17 @@ 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 { @@ -44,24 +48,41 @@ public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { private final SparseArray dates = new SparseArray<>(); private Date mToday; private Date mDate; - private boolean mHideWeekends; + private boolean mHideWeekends = false; private Callback mCallback; + private RxEventBus mEventBus; + private Disposable disposable; - TimeTablePagerAdapter(FragmentManager fm, Activity activity, Callback callback) { + TimeTablePagerAdapter(FragmentManager fm, Activity activity, Callback callback, + RxEventBus eventBus) { super(fm); mCallback = callback; + mEventBus = eventBus; + checkPreferences(activity); + + disposable = mEventBus.filteredObservable(ProKeyPurchaseEvent.class) + .subscribe(proKeyPurchaseEvent -> { + checkPreferences(activity); + updateDates(); + }); + + 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; } + } - mToday = new Date(); - setDate(mToday); - } - + public void destroy() { + RxUtil.dispose(disposable); + } @Override public DayFragment getItem(int position) { return DayFragment.newInstance(dates.get(position)); 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 7fbdbffa..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 @@ -42,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; @@ -69,6 +70,9 @@ public class TimeTablePagerFragment extends Fragment implements TimeTableMvpView @Inject Activity mActivity; + @Inject + RxEventBus eventBus; + private int mPreviousPosition = 15; private TimeTablePagerAdapter mAdapter; private Context mContext; @@ -101,7 +105,8 @@ public View onCreateView(LayoutInflater inflater, ViewGroup container, mAdapter = new TimeTablePagerAdapter(getChildFragmentManager(), mActivity, - position -> mViewPager.setCurrentItem(position, true)); + position -> mViewPager.setCurrentItem(position, true), + eventBus); mViewPager.setOffscreenPageLimit(3); mViewPager.setAdapter(mAdapter); @@ -209,6 +214,7 @@ public void onDestroyView() { super.onDestroyView(); unbinder.unbind(); mTimeTablePresenter.detachView(); + mAdapter.destroy(); } /******* MVP View methods implementation *****/ From efe19eaf99870dc37cbbab917bb73b5f4a64dbda Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 22 Apr 2018 08:40:34 +0530 Subject: [PATCH 20/22] add response caching and offline caching for verfiy api --- .../interceptor/CacheControlInterceptor.java | 40 +++++++++---------- .../injection/module/NetworkModule.java | 2 +- 2 files changed, 19 insertions(+), 23 deletions(-) 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 index a7aa78e9..8833b239 100644 --- 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 @@ -16,9 +16,6 @@ import okhttp3.Request; import okhttp3.Response; -/** - * @author shalzz - */ public class CacheControlInterceptor implements Interceptor { private Context mContext; @@ -29,28 +26,27 @@ public CacheControlInterceptor(@ApplicationContext Context context) { @Override public Response intercept(@NonNull Chain chain) throws IOException { - Request request = chain.request(); - if (NetworkUtil.isNetworkConnected(mContext)) { - CacheControl cacheControl = new CacheControl.Builder() - .maxAge( 1, TimeUnit.MINUTES ) - .immutable() + 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(); - request = request.newBuilder() - .cacheControl(cacheControl) - .build(); - } else if (request.url().encodedPath().equals("/api/v1/verify")) {// only for the 'verify' - // api route - CacheControl cacheControl = new CacheControl.Builder() - .onlyIfCached() - .maxStale( 7, TimeUnit.DAYS ) - .build(); - - request = request.newBuilder() - .cacheControl(cacheControl) - .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); } - return chain.proceed(request); } } 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 c132e194..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 @@ -72,7 +72,7 @@ static OkHttpClient provideClient(PreferencesHelper preferences, .cache(cache) .addInterceptor(new HeaderInterceptor()) .addInterceptor(new AuthInterceptor(preferences)) - .addInterceptor(new CacheControlInterceptor(context)) + .addNetworkInterceptor(new CacheControlInterceptor(context)) .addNetworkInterceptor(new LoggingInterceptor()); return okHttpBuilder.build(); From 2a00d1b9a06fd448cb6c9daf6209812f98c7ba3c Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 22 Apr 2018 17:30:30 +0530 Subject: [PATCH 21/22] minor fix for PurchaseEvent in Timetable adapter --- .../shalzz/attendance/billing/BillingManager.java | 1 - .../attendance/ui/settings/SettingsFragment.java | 15 +++++++-------- .../ui/timetable/TimeTablePagerAdapter.java | 1 + 3 files changed, 8 insertions(+), 9 deletions(-) diff --git a/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java index 7f07b388..3b267a87 100644 --- a/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java +++ b/app/src/main/java/com/shalzz/attendance/billing/BillingManager.java @@ -17,7 +17,6 @@ import com.shalzz.attendance.data.DataManager; import com.shalzz.attendance.utils.RxUtil; -import java.io.IOException; import java.util.ArrayList; import java.util.HashSet; import java.util.List; 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 6f1c720d..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 @@ -88,6 +88,8 @@ public class SettingsFragment extends PreferenceFragmentCompat implements private String key_sync_interval; private SwitchPreference syncPref; private SwitchPreference proModePref; + private ProModeListPreference proThemePref; + private SwitchPreference weekendsPref; private Disposable PurchaseEventDisposable; @@ -111,6 +113,9 @@ public void onCreatePreferences(Bundle bundle, String s) { 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); @@ -126,16 +131,14 @@ public void onStart() { public void onSharedPreferenceChanged(SharedPreferences sharedPreferences, String key) { if(key.equals(getString(R.string.pref_key_day_night))) { - ListPreference listPref = (ListPreference) findPreference(key); - listPref.setSummary(listPref.getEntry()); + 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()) { - SwitchPreference switchPref = (SwitchPreference) findPreference(key); - switchPref.setChecked(false); + weekendsPref.setChecked(false); Toast.makeText(mContext, "Pro key required!", Toast.LENGTH_SHORT).show(); } } @@ -216,9 +219,6 @@ public void onResume() { getPreferenceScreen().getSharedPreferences() .registerOnSharedPreferenceChangeListener(this); - PreferenceCategory prefCatGeneral = (PreferenceCategory) getPreferenceScreen() - .getPreference(0); - if (mBillingProvider.isProKeyPurchased()) { proModePref.setOnPreferenceClickListener(null); proModePref.setChecked(true); @@ -233,7 +233,6 @@ public void onResume() { }); } - ProModeListPreference proThemePref = (ProModeListPreference) prefCatGeneral.getPreference(1); proThemePref.setProModeListPreferenceClickListener(preference -> { if (mBillingProvider.isProKeyPurchased()) { proThemePref.showDialog(); 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 b9a094e9..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 @@ -65,6 +65,7 @@ public class TimeTablePagerAdapter extends FragmentStatePagerAdapter { .subscribe(proKeyPurchaseEvent -> { checkPreferences(activity); updateDates(); + scrollToToday(); }); mToday = new Date(); From e47fe7bac6b34983bde312e36dab8190d6dcf71b Mon Sep 17 00:00:00 2001 From: Shaleen Jain Date: Sun, 22 Apr 2018 18:31:03 +0530 Subject: [PATCH 22/22] update whatsnew for v3.1.0 release --- app/src/main/play/en-US/whatsnew | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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