From 1e72821c5cc140fb338ff3ec4059272ff854cfaa Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Thu, 12 Sep 2024 12:49:28 -0400 Subject: [PATCH 1/4] Created branch with foundational code for ConnectID and later Connect. Added database models and helper class for storage (with upgrader). Added network helper class to wrap common functionality for API calls. Added code to support encryption via a key stored in the Android Keystore. --- app/res/values/strings.xml | 24 + app/src/org/commcare/CommCareApplication.java | 63 +- .../connect/models/ConnectAppRecord.java | 105 +++ .../models/ConnectJobAssessmentRecord.java | 74 ++ .../models/ConnectJobDeliveryRecord.java | 133 +++ .../models/ConnectJobDeliveryRecordV2.java | 72 ++ .../models/ConnectJobLearningRecord.java | 68 ++ .../models/ConnectJobPaymentRecord.java | 118 +++ .../models/ConnectJobPaymentRecordV3.java | 54 ++ .../connect/models/ConnectJobRecord.java | 488 +++++++++++ .../connect/models/ConnectJobRecordV2.java | 129 +++ .../connect/models/ConnectJobRecordV4.java | 162 ++++ .../connect/models/ConnectJobRecordV7.java | 179 ++++ .../ConnectLearnModuleSummaryRecord.java | 77 ++ .../models/ConnectLinkedAppRecord.java | 175 ++++ .../models/ConnectLinkedAppRecordV3.java | 68 ++ .../models/ConnectLinkedAppRecordV8.java | 114 +++ .../models/ConnectLinkedAppRecordV9.java | 127 +++ .../models/ConnectPaymentUnitRecord.java | 78 ++ .../connect/models/ConnectUserRecord.java | 221 +++++ .../connect/models/ConnectUserRecordV5.java | 83 ++ .../commcare/connect/ConnectConstants.java | 17 + .../connect/ConnectDatabaseHelper.java | 827 ++++++++++++++++++ .../connect/network/ConnectNetworkHelper.java | 504 +++++++++++ .../connect/network/IApiCallback.java | 14 + .../analytics/FirebaseAnalyticsUtil.java | 2 +- .../connect/ConnectDatabaseUpgrader.java | 448 ++++++++++ .../connect/DatabaseConnectOpenHelper.java | 141 +++ .../utils/EncryptionKeyAndTransform.java | 18 + .../commcare/utils/EncryptionKeyProvider.java | 142 +++ .../org/commcare/utils/EncryptionUtils.java | 150 +++- 31 files changed, 4846 insertions(+), 29 deletions(-) create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectLearnModuleSummaryRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV3.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java create mode 100644 app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java create mode 100644 app/src/org/commcare/connect/ConnectConstants.java create mode 100644 app/src/org/commcare/connect/ConnectDatabaseHelper.java create mode 100644 app/src/org/commcare/connect/network/ConnectNetworkHelper.java create mode 100644 app/src/org/commcare/connect/network/IApiCallback.java create mode 100644 app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java create mode 100644 app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java create mode 100644 app/src/org/commcare/utils/EncryptionKeyAndTransform.java create mode 100644 app/src/org/commcare/utils/EncryptionKeyProvider.java diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 841a2ce81..96a2ff188 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -12,6 +12,27 @@ Your comment commcarehq-support@dimagi.com + https://connectid.dimagi.com/o/token/ + https://connectid.dimagi.com/users/heartbeat + https://connectid.dimagi.com/users/fetch_db_key + https://connectid.dimagi.com/users/change_password + https://connectid.dimagi.com/users/recover/reset_password + https://connectid.dimagi.com/users/recover/confirm_password + https://connectid.dimagi.com/users/set_recovery_pin + https://connectid.dimagi.com/users/recover/confirm_pin + https://connectid.dimagi.com/users/update_profile + https://connectid.dimagi.com/users/change_phone + https://connectid.dimagi.com/users/phone_available + https://connectid.dimagi.com/users/recover + https://connectid.dimagi.com/users/recover/secondary + https://connectid.dimagi.com/users/validate_secondary_phone + https://connectid.dimagi.com/users/validate_phone + https://connectid.dimagi.com/users/recover/confirm_otp + https://connectid.dimagi.com/users/recover/confirm_secondary_otp + https://connectid.dimagi.com/users/confirm_secondary_otp + https://connectid.dimagi.com/users/confirm_otp + https://connectid.dimagi.com/users/register + App Manager @@ -384,6 +405,7 @@ Error while sending forms Forms are not available. Make sure your phone storage is available No network connection. Please check your internet and try again. + The app is outdated and can no longer communicate with the server. Please update the app on the Google Play Store. Go to App Manager Retry Recovery @@ -456,4 +478,6 @@ notification-channel-push-notifications Required CommCare App is not installed on device Audio Recording Notification + + A problem occurred with the database, please recover your account. diff --git a/app/src/org/commcare/CommCareApplication.java b/app/src/org/commcare/CommCareApplication.java index 8feecb896..14c3fbaf2 100644 --- a/app/src/org/commcare/CommCareApplication.java +++ b/app/src/org/commcare/CommCareApplication.java @@ -16,18 +16,6 @@ import android.text.format.DateUtils; import android.util.Log; -import androidx.annotation.NonNull; -import androidx.multidex.MultiDexApplication; -import androidx.preference.PreferenceManager; -import androidx.work.BackoffPolicy; -import androidx.work.Constraints; -import androidx.work.ExistingPeriodicWorkPolicy; -import androidx.work.ExistingWorkPolicy; -import androidx.work.NetworkType; -import androidx.work.OneTimeWorkRequest; -import androidx.work.PeriodicWorkRequest; -import androidx.work.WorkManager; - import com.google.common.collect.Multimap; import com.google.firebase.analytics.FirebaseAnalytics; @@ -105,6 +93,7 @@ import org.commcare.utils.CommCareUtil; import org.commcare.utils.CrashUtil; import org.commcare.utils.DeviceIdentifier; +import org.commcare.utils.EncryptionKeyProvider; import org.commcare.utils.FileUtil; import org.commcare.utils.FirebaseMessagingUtil; import org.commcare.utils.GlobalConstants; @@ -135,6 +124,17 @@ import javax.annotation.Nullable; import javax.crypto.SecretKey; +import androidx.annotation.NonNull; +import androidx.multidex.MultiDexApplication; +import androidx.preference.PreferenceManager; +import androidx.work.BackoffPolicy; +import androidx.work.Constraints; +import androidx.work.ExistingPeriodicWorkPolicy; +import androidx.work.ExistingWorkPolicy; +import androidx.work.NetworkType; +import androidx.work.OneTimeWorkRequest; +import androidx.work.PeriodicWorkRequest; +import androidx.work.WorkManager; import io.noties.markwon.Markwon; import io.noties.markwon.ext.strikethrough.StrikethroughPlugin; import io.noties.markwon.ext.tables.TablePlugin; @@ -198,6 +198,7 @@ public class CommCareApplication extends MultiDexApplication { private boolean invalidateCacheOnRestore; private CommCareNoficationManager noficationManager; + private EncryptionKeyProvider encryptionKeyProvider; private boolean backgroundSyncSafe; @@ -253,6 +254,9 @@ public void onCreate() { FirebaseMessagingUtil.verifyToken(); + //Create standard provider + setEncryptionKeyProvider(new EncryptionKeyProvider()); + customiseOkHttp(); setRxJavaGlobalHandler(); @@ -273,9 +277,8 @@ protected void turnOnStrictMode() { } @Override - public void onConfigurationChanged(Configuration newConfig) { + public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); - LocalePreferences.saveDeviceLocale(newConfig.locale); } private void initNotifications() { @@ -294,11 +297,11 @@ private void logFirstCommCareRun() { } } - public void setBackgroundSyncSafe(boolean backgroundSyncSafe){ + public void setBackgroundSyncSafe(boolean backgroundSyncSafe) { this.backgroundSyncSafe = backgroundSyncSafe; } - public boolean isBackgroundSyncSafe(){ + public boolean isBackgroundSyncSafe() { return this.backgroundSyncSafe; } @@ -339,11 +342,11 @@ private void configureCommCareEngineConstantsAndStaticRegistrations() { // md5 hasher. Major speed improvements. AndroidClassHasher.registerAndroidClassHashStrategy(); - ActivityManager am = (ActivityManager)getSystemService(ACTIVITY_SERVICE); + ActivityManager am = (ActivityManager) getSystemService(ACTIVITY_SERVICE); int memoryClass = am.getMemoryClass(); PerformanceTuningUtil.updateMaxPrefetchCaseBlock( - PerformanceTuningUtil.guessLargestSupportedBulkCaseFetchSizeFromHeap(memoryClass * 1024 * 1024)); + PerformanceTuningUtil.guessLargestSupportedBulkCaseFetchSizeFromHeap((long) memoryClass * 1024 * 1024)); } public void startUserSession(byte[] symmetricKey, UserKeyRecord record, boolean restoreSession) { @@ -419,12 +422,13 @@ synchronized public FirebaseAnalytics getAnalyticsInstance() { analyticsInstance = FirebaseAnalytics.getInstance(this); } analyticsInstance.setUserId(getUserIdOrNull()); + return analyticsInstance; } public int[] getCommCareVersion() { String[] components = BuildConfig.VERSION_NAME.split("\\."); - int[] versions = new int[] {0, 0, 0}; + int[] versions = new int[]{0, 0, 0}; for (int i = 0; i < components.length; i++) { versions[i] = Integer.parseInt(components[i]); } @@ -469,7 +473,7 @@ public void initializeGlobalResources(CommCareApp app) { @NonNull public String getPhoneId() { - /** + /* * https://source.android.com/devices/tech/config/device-identifiers * https://issuetracker.google.com/issues/129583175#comment10 * Starting from Android 10, apps cannot access non-resettable device ids unless they have special career permission. @@ -513,7 +517,7 @@ private void setRoots() { private void initializeAnAppOnStartup() { SharedPreferences prefs = PreferenceManager.getDefaultSharedPreferences(this); String lastAppId = prefs.getString(LoginActivity.KEY_LAST_APP, ""); - if (!"".equals(lastAppId)) { + if (!lastAppId.isEmpty()) { ApplicationRecord lastApp = MultipleAppsUtil.getAppById(lastAppId); if (lastApp == null || !lastApp.isUsable()) { AppUtils.initFirstUsableAppRecord(); @@ -544,7 +548,7 @@ public void initializeAppResources(CommCareApp app) { } catch (Exception e) { Log.i("FAILURE", "Problem with loading"); Log.i("FAILURE", "E: " + e.getMessage()); - e.printStackTrace(); +// e.printStackTrace(); ForceCloseLogger.reportExceptionInBg(e); CrashUtil.reportException(e); resourceState = STATE_CORRUPTED; @@ -730,7 +734,7 @@ public void onServiceConnected(ComponentName className, IBinder service) { synchronized (serviceLock) { mCurrentServiceBindTimeout = MAX_BIND_TIMEOUT; - mBoundService = ((CommCareSessionService.LocalBinder)service).getService(); + mBoundService = ((CommCareSessionService.LocalBinder) service).getService(); mBoundService.showLoggedInNotification(null); // Don't let anyone touch this until it's logged in @@ -916,7 +920,7 @@ public static boolean areAutomatedActionsInvalid() { /** * Whether the current login is a "demo" mode login. - * + *

* Returns a provided default value if there is no active user login */ public static boolean isInDemoMode(boolean defaultValue) { @@ -971,8 +975,7 @@ public CommCareSessionService getSession() { public static boolean isSessionActive() { try { return CommCareApplication.instance().getSession() != null; - } - catch (SessionUnavailableException e){ + } catch (SessionUnavailableException e) { return false; } } @@ -1152,6 +1155,14 @@ public void setInvalidateCacheFlag(boolean b) { invalidateCacheOnRestore = b; } + public void setEncryptionKeyProvider(EncryptionKeyProvider provider) { + encryptionKeyProvider = provider; + } + + public EncryptionKeyProvider getEncryptionKeyProvider() { + return encryptionKeyProvider; + } + public PrototypeFactory getPrototypeFactory(Context c) { return AndroidPrototypeFactorySetup.getPrototypeFactory(c); } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java new file mode 100644 index 000000000..1d2548f31 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java @@ -0,0 +1,105 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.ArrayList; +import java.util.Date; +import java.util.List; + +@Table(ConnectAppRecord.STORAGE_KEY) +public class ConnectAppRecord extends Persisted implements Serializable { + /** + * Name of database that stores app info for Connect jobs + */ + public static final String STORAGE_KEY = "connect_apps"; + + public static final String META_JOB_ID = "job_id"; + public static final String META_DOMAIN = "cc_domain"; + public static final String META_APP_ID = "cc_app_id"; + public static final String META_NAME = "name"; + public static final String META_DESCRIPTION = "description"; + public static final String META_ORGANIZATION = "organization"; + public static final String META_PASSING_SCORE = "passing_score"; + public static final String META_INSTALL_URL = "install_url"; + public static final String META_MODULES = "learn_modules"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + private boolean isLearning; + @Persisting(3) + @MetaField(META_DOMAIN) + private String domain; + @Persisting(4) + @MetaField(META_APP_ID) + private String appId; + @Persisting(5) + @MetaField(META_NAME) + private String name; + @Persisting(6) + @MetaField(META_DESCRIPTION) + private String description; + @Persisting(7) + @MetaField(META_ORGANIZATION) + private String organization; + + @Persisting(8) + @MetaField(META_PASSING_SCORE) + private int passingScore; + @Persisting(9) + @MetaField(META_INSTALL_URL) + private String installUrl; + @Persisting(10) + private Date lastUpdate; + + private List learnModules; + + public ConnectAppRecord() { + + } + + public static ConnectAppRecord fromJson(JSONObject json, int jobId, boolean isLearning) throws JSONException { + ConnectAppRecord app = new ConnectAppRecord(); + + app.jobId = jobId; + app.isLearning = isLearning; + + app.domain = json.has(META_DOMAIN) ? json.getString(META_DOMAIN) : ""; + app.appId = json.has(META_APP_ID) ? json.getString(META_APP_ID) : ""; + app.name = json.has(META_NAME) ? json.getString(META_NAME) : ""; + app.description = json.has(META_DESCRIPTION) ? json.getString(META_DESCRIPTION) : ""; + app.organization = json.has(META_ORGANIZATION) ? json.getString(META_ORGANIZATION) : ""; + app.passingScore = json.has(META_PASSING_SCORE) && !json.isNull(META_PASSING_SCORE) ? json.getInt(META_PASSING_SCORE) : -1; + app.installUrl = json.has(META_INSTALL_URL) ? json.getString(META_INSTALL_URL) : ""; + + JSONArray array = json.getJSONArray(META_MODULES); + app.learnModules = new ArrayList<>(); + for(int i=0; i getLearnModules() { return learnModules; } + public String getInstallUrl() { return installUrl; } + public void setLearnModules(List modules) { learnModules = modules; } + public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java new file mode 100644 index 000000000..026db5358 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java @@ -0,0 +1,74 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.ConnectNetworkHelper; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.Date; + +/** + * Data class for holding info related to a Connect job assessment + * + * @author dviggiano + */ +@Table(ConnectJobAssessmentRecord.STORAGE_KEY) +public class ConnectJobAssessmentRecord extends Persisted implements Serializable { + /** + * Name of database that stores Connect job assessments + */ + public static final String STORAGE_KEY = "connect_assessments"; + + public static final String META_JOB_ID = "id"; + public static final String META_DATE = "date"; + public static final String META_SCORE = "score"; + public static final String META_PASSING_SCORE = "passing_score"; + public static final String META_PASSED = "passed"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_DATE) + private Date date; + @Persisting(3) + @MetaField(META_SCORE) + private int score; + @Persisting(4) + @MetaField(META_PASSING_SCORE) + private int passingScore; + @Persisting(5) + @MetaField(META_PASSED) + private boolean passed; + @Persisting(6) + private Date lastUpdate; + + public ConnectJobAssessmentRecord() { + + } + + public static ConnectJobAssessmentRecord fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + ConnectJobAssessmentRecord record = new ConnectJobAssessmentRecord(); + + record.lastUpdate = new Date(); + + record.jobId = jobId; + record.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + record.score = json.has(META_SCORE) ? json.getInt(META_SCORE) : -1; + record.passingScore = json.has(META_PASSING_SCORE) ? json.getInt(META_PASSING_SCORE) : -1; + record.passed = json.has(META_PASSED) && json.getBoolean(META_PASSED); + + return record; + } + + public Date getDate() { return date; } + public int getScore() { return score; } + public int getPassingScore() { return passingScore; } + + public void setLastUpdate(Date date) { lastUpdate = date; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java new file mode 100644 index 000000000..aaaaaa33b --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java @@ -0,0 +1,133 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.ConnectNetworkHelper; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.commcare.utils.CrashUtil; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.Date; +import java.util.Locale; + +/** + * Data class for holding info related to a Connect job delivery + * + * @author dviggiano + */ +@Table(ConnectJobDeliveryRecord.STORAGE_KEY) +public class ConnectJobDeliveryRecord extends Persisted implements Serializable { + /** + * Name of database that stores info for Connect deliveries + */ + public static final String STORAGE_KEY = "connect_deliveries"; + + public static final String META_JOB_ID = "job_id"; + public static final String META_ID = "id"; + public static final String META_STATUS = "status"; + public static final String META_REASON = "reason"; + public static final String META_DATE = "visit_date"; + public static final String META_UNIT_NAME = "deliver_unit_name"; + public static final String META_SLUG = "deliver_unit_slug"; + public static final String META_ENTITY_ID = "entity_id"; + public static final String META_ENTITY_NAME = "entity_name"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + + @Persisting(2) + @MetaField(META_ID) + private int deliveryId; + @Persisting(3) + @MetaField(META_DATE) + private Date date; + @Persisting(4) + @MetaField(META_STATUS) + private String status; + @Persisting(5) + @MetaField(META_UNIT_NAME) + private String unitName; + @Persisting(6) + @MetaField(META_SLUG) + private String slug; + @Persisting(7) + @MetaField(META_ENTITY_ID) + private String entityId; + @Persisting(8) + @MetaField(META_ENTITY_NAME) + private String entityName; + @Persisting(9) + private Date lastUpdate; + @Persisting(10) + @MetaField(META_REASON) + private String reason; + + public ConnectJobDeliveryRecord() { + date = new Date(); + lastUpdate = new Date(); + } + + public static ConnectJobDeliveryRecord fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + int deliveryId = -1; + String dateString = "(error)"; + try { + ConnectJobDeliveryRecord delivery = new ConnectJobDeliveryRecord(); + delivery.jobId = jobId; + delivery.lastUpdate = new Date(); + + deliveryId = json.has(META_ID) ? json.getInt(META_ID) : -1; + delivery.deliveryId = deliveryId; + dateString = json.getString(META_DATE); + delivery.date = ConnectNetworkHelper.convertUTCToDate(dateString); + delivery.status = json.has(META_STATUS) ? json.getString(META_STATUS) : ""; + delivery.unitName = json.has(META_UNIT_NAME) ? json.getString(META_UNIT_NAME) : ""; + delivery.slug = json.has(META_SLUG) ? json.getString(META_SLUG) : ""; + delivery.entityId = json.has(META_ENTITY_ID) ? json.getString(META_ENTITY_ID) : ""; + delivery.entityName = json.has(META_ENTITY_NAME) ? json.getString(META_ENTITY_NAME) : ""; + + delivery.reason = json.has(META_REASON) && !json.isNull(META_REASON) ? json.getString(META_REASON) : ""; + + return delivery; + } + catch(Exception e) { + String message = String.format(Locale.getDefault(), "Error parsing delivery %d: date = '%s'", deliveryId, dateString); + CrashUtil.reportException(new Exception(message, e)); + return null; + } + } + + public int getDeliveryId() { return deliveryId; } + public Date getDate() { return ConnectNetworkHelper.convertDateToLocal(date); } + public String getStatus() { return status; } + public String getEntityName() { return entityName; } + public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } + + public int getJobId() { return jobId; } + public String getUnitName() { return unitName; } + public String getSlug() { return slug; } + public String getEntityId() { return entityId; } + public Date getLastUpdate() { return lastUpdate; } + public String getReason() { return reason; } + + public static ConnectJobDeliveryRecord fromV2(ConnectJobDeliveryRecordV2 oldRecord) { + ConnectJobDeliveryRecord newRecord = new ConnectJobDeliveryRecord(); + + newRecord.jobId = oldRecord.getJobId(); + newRecord.deliveryId = oldRecord.getDeliveryId(); + newRecord.date = oldRecord.date; + newRecord.status = oldRecord.getStatus(); + newRecord.unitName = oldRecord.getUnitName(); + newRecord.slug = oldRecord.getSlug(); + newRecord.entityId = oldRecord.getEntityId(); + newRecord.entityName = oldRecord.getEntityName(); + newRecord.lastUpdate = oldRecord.getLastUpdate(); + newRecord.reason = ""; + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java new file mode 100644 index 000000000..1f0611f6a --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java @@ -0,0 +1,72 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.io.Serializable; +import java.util.Date; + +/** + * Data class for holding info related to a Connect job delivery + * This version was used up to V2 of the DB + * @author dviggiano + */ +@Table(ConnectJobDeliveryRecordV2.STORAGE_KEY) +public class ConnectJobDeliveryRecordV2 extends Persisted implements Serializable { + /** + * Name of database that stores info for Connect deliveries + */ + public static final String STORAGE_KEY = "connect_deliveries"; + + public static final String META_JOB_ID = "job_id"; + public static final String META_ID = "id"; + public static final String META_STATUS = "status"; + public static final String META_DATE = "visit_date"; + public static final String META_UNIT_NAME = "deliver_unit_name"; + public static final String META_SLUG = "deliver_unit_slug"; + public static final String META_ENTITY_ID = "entity_id"; + public static final String META_ENTITY_NAME = "entity_name"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + + @Persisting(2) + @MetaField(META_ID) + private int deliveryId; + @Persisting(3) + @MetaField(META_DATE) + protected Date date; + @Persisting(4) + @MetaField(META_STATUS) + private String status; + @Persisting(5) + @MetaField(META_UNIT_NAME) + private String unitName; + @Persisting(6) + @MetaField(META_SLUG) + private String slug; + @Persisting(7) + @MetaField(META_ENTITY_ID) + private String entityId; + @Persisting(8) + @MetaField(META_ENTITY_NAME) + private String entityname; + @Persisting(9) + private Date lastUpdate; + + public ConnectJobDeliveryRecordV2() { + } + + public int getDeliveryId() { return deliveryId; } + public Date getDate() { return date; } + public String getStatus() { return status; } + public String getEntityName() { return entityname; } + public int getJobId() { return jobId; } + public String getUnitName() { return unitName; } + public String getSlug() { return slug; } + public String getEntityId() { return entityId; } + public Date getLastUpdate() { return lastUpdate; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java new file mode 100644 index 000000000..3af89c610 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java @@ -0,0 +1,68 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.ConnectNetworkHelper; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.Date; + +/** + * Data class for holding info related to the completion of a Connect job learning module + * + * @author dviggiano + */ +@Table(ConnectJobLearningRecord.STORAGE_KEY) +public class ConnectJobLearningRecord extends Persisted implements Serializable { + /** + * Name of database that stores Connect job learning records + */ + public static final String STORAGE_KEY = "connect_learning_completion"; + + public static final String META_JOB_ID = "id"; + public static final String META_DATE = "date"; + public static final String META_MODULE = "module"; + public static final String META_DURATION = "duration"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_DATE) + private Date date; + @Persisting(3) + @MetaField(META_MODULE) + private int moduleId; + @Persisting(4) + @MetaField(META_DURATION) + private String duration; + @Persisting(5) + private Date lastUpdate; + + public ConnectJobLearningRecord() { + + } + + public static ConnectJobLearningRecord fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + ConnectJobLearningRecord record = new ConnectJobLearningRecord(); + + record.lastUpdate = new Date(); + + record.jobId = jobId; + record.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + record.moduleId = json.has(META_MODULE) ? json.getInt(META_MODULE) : -1; + record.duration = json.has(META_DURATION) ? json.getString(META_DURATION) : ""; + + return record; + } + + public int getModuleId() { return moduleId; } + public Date getDate() { return date; } + + public void setLastUpdate(Date date) { lastUpdate = date; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java new file mode 100644 index 000000000..76fec3557 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java @@ -0,0 +1,118 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.ConnectNetworkHelper; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.Date; +import java.util.Locale; +import java.util.concurrent.TimeUnit; + +@Table(ConnectJobPaymentRecord.STORAGE_KEY) +public class ConnectJobPaymentRecord extends Persisted implements Serializable { + /** + * Name of database that stores app info for Connect jobs + */ + public static final String STORAGE_KEY = "connect_payments"; + + public static final String META_JOB_ID = "job_id"; + public static final String META_AMOUNT = "amount"; + public static final String META_DATE = "date_paid"; + public static final String META_PAYMENT_ID = "payment_id"; + public static final String META_CONFIRMED = "confirmed"; + public static final String META_CONFIRMED_DATE = "date_confirmed"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + + @Persisting(2) + @MetaField(META_DATE) + private Date date; + + @Persisting(3) + @MetaField(META_AMOUNT) + private String amount; + + @Persisting(4) + @MetaField(META_PAYMENT_ID) + private String paymentId; + @Persisting(5) + @MetaField(META_CONFIRMED) + private boolean confirmed; + + @Persisting(6) + @MetaField(META_CONFIRMED_DATE) + private Date confirmedDate; + + public ConnectJobPaymentRecord() {} + + public static ConnectJobPaymentRecord fromV3(ConnectJobPaymentRecordV3 oldRecord) { + ConnectJobPaymentRecord newRecord = new ConnectJobPaymentRecord(); + + newRecord.jobId = oldRecord.getJobId(); + newRecord.date = oldRecord.getDate(); + newRecord.amount = oldRecord.getAmount(); + + newRecord.paymentId = "-1"; + newRecord.confirmed = false; + newRecord.confirmedDate = new Date(); + + return newRecord; + } + + public static ConnectJobPaymentRecord fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + ConnectJobPaymentRecord payment = new ConnectJobPaymentRecord(); + + payment.jobId = jobId; + payment.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + payment.amount = String.format(Locale.ENGLISH, "%d", json.has(META_AMOUNT) ? json.getInt(META_AMOUNT) : 0); + + payment.paymentId = json.has("id") ? json.getString("id") : ""; + payment.confirmed = json.has(META_CONFIRMED) && json.getBoolean(META_CONFIRMED); + payment.confirmedDate = json.has(META_CONFIRMED_DATE) && !json.isNull(META_CONFIRMED_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_CONFIRMED_DATE)) : new Date(); + + return payment; + } + + public String getPaymentId() {return paymentId; } + public Date getDate() { return date;} + + public String getAmount() { return amount; } + + public boolean getConfirmed() {return confirmed; } + public Date getConfirmedDate() {return confirmedDate; } + + public void setConfirmed(boolean confirmed) { + this.confirmed = confirmed; + if(confirmed) { + confirmedDate = new Date(); + } + } + + public boolean allowConfirm() { + if (confirmed) { + return false; + } + + long millis = (new Date()).getTime() - date.getTime(); + long days = TimeUnit.DAYS.convert(millis, TimeUnit.MILLISECONDS); + return days < 7; + } + + public boolean allowConfirmUndo() { + if (!confirmed) { + return false; + } + + long millis = (new Date()).getTime() - confirmedDate.getTime(); + long days = TimeUnit.DAYS.convert(millis, TimeUnit.MILLISECONDS); + return days < 1; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java new file mode 100644 index 000000000..5b7b0bfe5 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java @@ -0,0 +1,54 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.ConnectNetworkHelper; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.Date; +import java.util.Locale; + +@Table(ConnectJobPaymentRecordV3.STORAGE_KEY) +public class ConnectJobPaymentRecordV3 extends Persisted implements Serializable { + /** + * Name of database that stores app info for Connect jobs + */ + public static final String STORAGE_KEY = "connect_payments"; + + public static final String META_JOB_ID = "job_id"; + public static final String META_AMOUNT = "amount"; + public static final String META_DATE = "date_paid"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_DATE) + private Date date; + @Persisting(3) + @MetaField(META_AMOUNT) + private String amount; + + public ConnectJobPaymentRecordV3() {} + + public static ConnectJobPaymentRecordV3 fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + ConnectJobPaymentRecordV3 payment = new ConnectJobPaymentRecordV3(); + + payment.jobId = jobId; + payment.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + payment.amount = String.format(Locale.ENGLISH, "%d", json.has(META_AMOUNT) ? json.getInt(META_AMOUNT) : 0); + + return payment; + } + + public int getJobId() { return jobId; } + + public Date getDate() { return date;} + + public String getAmount() { return amount; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java new file mode 100644 index 000000000..18d002cd2 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -0,0 +1,488 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.ConnectNetworkHelper; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.joda.time.LocalDate; +import org.json.JSONArray; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; +import java.util.ArrayList; +import java.util.Date; +import java.util.Hashtable; +import java.util.List; +import java.util.Locale; + +/** + * Data class for holding info related to a Connect job + * + * @author dviggiano + */ +@Table(ConnectJobRecord.STORAGE_KEY) +public class ConnectJobRecord extends Persisted implements Serializable { + /** + * Name of database that stores Connect jobs/opportunities + */ + public static final String STORAGE_KEY = "connect_jobs"; + + public static final int STATUS_AVAILABLE_NEW = 1; + public static final int STATUS_AVAILABLE = 2; + public static final int STATUS_LEARNING = 3; + public static final int STATUS_DELIVERING = 4; + + public static final String META_JOB_ID = "id"; + public static final String META_NAME = "name"; + public static final String META_DESCRIPTION = "description"; + public static final String META_ORGANIZATION = "organization"; + public static final String META_END_DATE = "end_date"; + public static final String META_MAX_VISITS_PER_USER = "max_visits_per_user"; + public static final String META_MAX_DAILY_VISITS = "daily_max_visits_per_user"; + public static final String META_BUDGET_PER_VISIT = "budget_per_visit"; + public static final String META_BUDGET_TOTAL = "total_budget"; + public static final String META_LAST_WORKED_DATE = "last_worked"; + public static final String META_STATUS = "status"; + public static final String META_LEARN_MODULES = "total_modules"; + public static final String META_COMPLETED_MODULES = "completed_modules"; + + public static final String META_LEARN_PROGRESS = "learn_progress"; + public static final String META_DELIVERY_PROGRESS = "deliver_progress"; + public static final String META_LEARN_APP = "learn_app"; + public static final String META_DELIVER_APP = "deliver_app"; + public static final String META_CLAIM = "claim"; + public static final String META_CLAIM_DATE = "date_claimed"; + public static final String META_MAX_PAYMENTS = "max_payments"; + public static final String META_CURRENCY = "currency"; + public static final String META_ACCRUED = "payment_accrued"; + public static final String META_SHORT_DESCRIPTION = "short_description"; + public static final String META_START_DATE = "start_date"; + public static final String META_IS_ACTIVE = "is_active"; + public static final String META_PAYMENT_UNITS = "payment_units"; + public static final String META_PAYMENT_UNIT = "payment_unit"; + public static final String META_MAX_VISITS = "max_visits"; + + public static final String META_USER_SUSPENDED = "is_user_suspended"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_NAME) + private String title; + @Persisting(3) + @MetaField(META_DESCRIPTION) + private String description; + @Persisting(4) + @MetaField(META_ORGANIZATION) + private String organization; + @Persisting(5) + @MetaField(META_END_DATE) + private Date projectEndDate; + @Persisting(6) + @MetaField(META_BUDGET_PER_VISIT) + private int budgetPerVisit; + @Persisting(7) + @MetaField(META_BUDGET_TOTAL) + private int totalBudget; + @Persisting(8) + @MetaField(META_MAX_VISITS_PER_USER) + private int maxVisits; + @Persisting(9) + @MetaField(META_MAX_DAILY_VISITS) + private int maxDailyVisits; + @Persisting(10) + @MetaField(META_DELIVERY_PROGRESS) + private int completedVisits; + @Persisting(11) + @MetaField(META_LAST_WORKED_DATE) + private Date lastWorkedDate; + @Persisting(12) + @MetaField(META_STATUS) + private int status; + @Persisting(13) + @MetaField(META_LEARN_MODULES) + private int numLearningModules; + @Persisting(14) + @MetaField(META_COMPLETED_MODULES) + private int learningModulesCompleted; + @Persisting(15) + @MetaField(META_CURRENCY) + private String currency; + @Persisting(16) + @MetaField(META_ACCRUED) + private String paymentAccrued; + @Persisting(17) + @MetaField(META_SHORT_DESCRIPTION) + private String shortDescription; + @Persisting(18) + private Date lastUpdate; + @Persisting(19) + private Date lastLearnUpdate; + @Persisting(20) + private Date lastDeliveryUpdate; + @Persisting(21) + @MetaField(META_CLAIM_DATE) + private Date dateClaimed; + @Persisting(22) + @MetaField(META_START_DATE) + private Date projectStartDate; + @Persisting(23) + @MetaField(META_IS_ACTIVE) + private boolean isActive; + + @Persisting(24) + @MetaField(META_USER_SUSPENDED) + private boolean isUserSuspended; + + private List deliveries; + private List payments; + private List learnings; + private List assessments; + private ConnectAppRecord learnAppInfo; + private ConnectAppRecord deliveryAppInfo; + private List paymentUnits; + + private boolean claimed; + + public ConnectJobRecord() { + lastUpdate = new Date(); + lastLearnUpdate = new Date(); + dateClaimed = new Date(); + lastDeliveryUpdate = new Date(); + lastWorkedDate = new Date(); + } + + public static ConnectJobRecord fromJson(JSONObject json) throws JSONException, ParseException { + ConnectJobRecord job = new ConnectJobRecord(); + + job.jobId = json.getInt(META_JOB_ID); + job.title = json.has(META_NAME) ? json.getString(META_NAME) : ""; + job.description = json.has(META_DESCRIPTION) ? json.getString(META_DESCRIPTION) : ""; + job.organization = json.has(META_ORGANIZATION) ? json.getString(META_ORGANIZATION) : ""; + job.projectEndDate = json.has(META_END_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_END_DATE)) : new Date(); + job.projectStartDate = json.has(META_START_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_START_DATE)) : new Date(); + job.maxVisits = json.has(META_MAX_VISITS_PER_USER) ? json.getInt(META_MAX_VISITS_PER_USER) : -1; + job.maxDailyVisits = json.has(META_MAX_DAILY_VISITS) ? json.getInt(META_MAX_DAILY_VISITS) : -1; + job.budgetPerVisit = json.has(META_BUDGET_PER_VISIT) ? json.getInt(META_BUDGET_PER_VISIT) : -1; + String budgetPerUserKey = "budget_per_user"; + job.totalBudget = json.has(budgetPerUserKey) ? json.getInt(budgetPerUserKey) : -1; + job.currency = json.has(META_CURRENCY) && !json.isNull(META_CURRENCY) ? json.getString(META_CURRENCY) : ""; + job.shortDescription = json.has(META_SHORT_DESCRIPTION) && !json.isNull(META_SHORT_DESCRIPTION) ? + json.getString(META_SHORT_DESCRIPTION) : ""; + + job.paymentAccrued = ""; + + job.deliveries = new ArrayList<>(); + job.payments = new ArrayList<>(); + job.learnings = new ArrayList<>(); + job.assessments = new ArrayList<>(); + job.completedVisits = json.has(META_DELIVERY_PROGRESS) ? json.getInt(META_DELIVERY_PROGRESS) : -1; + + job.claimed = json.has(META_CLAIM) &&!json.isNull(META_CLAIM); + + job.isActive = !json.has(META_IS_ACTIVE) || json.getBoolean(META_IS_ACTIVE); + + job.isUserSuspended = json.has(META_USER_SUSPENDED) && json.getBoolean(META_USER_SUSPENDED); + + + JSONArray unitsJson = json.getJSONArray(META_PAYMENT_UNITS); + job.paymentUnits = new ArrayList<>(); + for(int i=0; i 0) { + job.status = STATUS_LEARNING; + if(job.claimed) { + job.status = STATUS_DELIVERING; + } + } + + return job; + } + + public boolean isFinished() { + return !isActive || getDaysRemaining() <= 0; + } + + public int getJobId() { return jobId; } + public String getTitle() { return title; } + public String getDescription() { return description; } + public String getShortDescription() { return shortDescription; } + public boolean getIsNew() { return status == STATUS_AVAILABLE_NEW; } + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public int getCompletedVisits() { return completedVisits; } + public int getMaxVisits() { return maxVisits; } + public void setMaxVisits(int max) { maxVisits = max; } + public int getMaxDailyVisits() { return maxDailyVisits; } + public int getBudgetPerVisit() { return budgetPerVisit; } + public int getPercentComplete() { return maxVisits > 0 ? 100 * completedVisits / maxVisits : 0; } + public Date getProjectStartDate() { return projectStartDate; } + public Date getProjectEndDate() { return projectEndDate; } + public void setProjectEndDate(Date date) { projectEndDate = date; } + public int getPaymentAccrued() { return paymentAccrued != null && paymentAccrued.length() > 0 ? Integer.parseInt(paymentAccrued) : 0; } + public void setPaymentAccrued(int paymentAccrued) { this.paymentAccrued = Integer.toString(paymentAccrued); } + public String getCurrency() { return currency; } + public int getNumLearningModules() { return numLearningModules; } + public int getCompletedLearningModules() { return learningModulesCompleted; } + public int getLearningPercentComplete() { + return numLearningModules > 0 ? (100 * learningModulesCompleted / numLearningModules) : 100; + } + public void setComletedLearningModules(int numCompleted) { this.learningModulesCompleted = numCompleted; } + public ConnectAppRecord getLearnAppInfo() { return learnAppInfo; } + public void setLearnAppInfo(ConnectAppRecord appInfo) { this.learnAppInfo = appInfo; } + public ConnectAppRecord getDeliveryAppInfo() { return deliveryAppInfo; } + public void setDeliveryAppInfo(ConnectAppRecord appInfo) { this.deliveryAppInfo = appInfo; } + public List getDeliveries() { return deliveries; } + public void setDeliveries(List deliveries) { + this.deliveries = deliveries; + if(deliveries.size() > 0) { + completedVisits = deliveries.size(); + } + } + public List getPayments() { return payments; } + public void setPayments(List payments) { + this.payments = payments; + } + + public List getLearnings() { + return learnings; + } + public void setLearnings(List learnings) { + this.learnings = learnings; + } + + public List getAssessments() { + return assessments; + } + public void setAssessments(List assessments) { + this.assessments = assessments; + } + public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } + + public int getDaysRemaining() { + Date startDate = new Date(); + if(projectStartDate != null && projectStartDate.after(startDate)) { + startDate = projectStartDate; + } + double millis = projectEndDate.getTime() - (startDate).getTime(); + //Ceiling means we'll get 0 within 24 hours of the end date + //(since the end date has 00:00 time, but project is valid until midnight) + int days = (int)Math.ceil(millis / 1000 / 3600 / 24); + //Now plus 1 so we report i.e. 1 day remaining on the last day + return days >= 0 ? (days + 1) : 0; + } + + public int getMaxPossibleVisits() { + return maxVisits; + } + + public int getLearningCompletePercentage() { + int numLearning = getNumLearningModules(); + return numLearning > 0 ? (100 * getCompletedLearningModules() / getNumLearningModules()) : 100; + } + + public boolean attemptedAssessment() { + return getLearningCompletePercentage() >= 100 && assessments != null && assessments.size() > 0; + } + + public boolean passedAssessment() { + return getLearningCompletePercentage() >= 100 && getAssessmentScore() >= getLearnAppInfo().getPassingScore(); + } + + public int getAssessmentScore() { + int mostRecentFailingScore = 0; + int firstPassingScore = -1; + + if (assessments != null) { + for (ConnectJobAssessmentRecord record : assessments) { + int score = record.getScore(); + if (score >= record.getPassingScore()) { + if (firstPassingScore == -1) { + firstPassingScore = score; + } + } else { + mostRecentFailingScore = score; + } + } + } + + if (firstPassingScore != -1) { + return firstPassingScore; + } else { + return mostRecentFailingScore; + } + } + + public Date getLastUpdate() { return lastUpdate; } + + public Date getLastLearnUpdate() { return lastLearnUpdate; } + public void setLastLearnUpdate(Date date) { lastLearnUpdate = date; } + public Date getLastDeliveryUpdate() { return lastDeliveryUpdate; } + public void setLastDeliveryUpdate(Date date) { lastDeliveryUpdate = date; } + public String getOrganization() { return organization; } + public int getTotalBudget() { return totalBudget; } + public Date getLastWorkedDate() { return lastWorkedDate; } + public Date getDateClaimed() { return dateClaimed; } + public boolean getIsActive() { return isActive; } + + public boolean setIsUserSuspended(boolean isUserSuspended) { return this.isUserSuspended=isUserSuspended; } + + public boolean getIsUserSuspended(){ + return isUserSuspended; + } + + public String getMoneyString(int value) { + String currency = ""; + if(this.currency != null && this.currency.length() > 0) { + currency = " " + this.currency; + } + + return String.format(Locale.getDefault(), "%d%s", value, currency); + } + + + public int numberOfDeliveriesToday() { + int dailyVisitCount = 0; + Date today = new Date(); + for (ConnectJobDeliveryRecord record : deliveries) { + if(sameDay(today, record.getDate())) { + dailyVisitCount++; + } + } + + return dailyVisitCount; + } + + private static boolean sameDay(Date date1, Date date2) { + LocalDate dt1 = new LocalDate(date1); + LocalDate dt2 = new LocalDate(date2); + + return dt1.equals(dt2); + } + + public List getPaymentUnits() { + return paymentUnits; + } + + public boolean isMultiPayment() { + return paymentUnits.size() > 1; + } + + + + public Hashtable getDeliveryCountsPerPaymentUnit(boolean todayOnly) { + Hashtable paymentCounts = new Hashtable<>(); + for(int i = 0; i < deliveries.size(); i++) { + ConnectJobDeliveryRecord delivery = deliveries.get(i); + if(!todayOnly || sameDay(new Date(), delivery.getDate())) { + int oldCount = 0; + if (paymentCounts.containsKey(delivery.getSlug())) { + oldCount = paymentCounts.get(delivery.getSlug()); + } + + paymentCounts.put(delivery.getSlug(), oldCount + 1); + } + } + + return paymentCounts; + } + + public void setPaymentUnits(List units) { + paymentUnits = units; + } + + public boolean readyToTransitionToDelivery() { + return status == STATUS_LEARNING && passedAssessment(); + } + + public static ConnectJobRecord fromV7(ConnectJobRecordV7 oldRecord) { + ConnectJobRecord newRecord = new ConnectJobRecord(); + + newRecord.jobId = oldRecord.getJobId(); + newRecord.title = oldRecord.getTitle(); + newRecord.description = oldRecord.getDescription(); + newRecord.status = oldRecord.getStatus(); + newRecord.completedVisits = oldRecord.getCompletedVisits(); + newRecord.maxDailyVisits = oldRecord.getMaxDailyVisits(); + newRecord.maxVisits = oldRecord.getMaxVisits(); + newRecord.budgetPerVisit = oldRecord.getBudgetPerVisit(); + newRecord.totalBudget = oldRecord.getTotalBudget(); + newRecord.projectEndDate = oldRecord.getProjectEndDate(); + newRecord.lastWorkedDate = oldRecord.getLastWorkedDate(); + newRecord.deliveries = new ArrayList<>(); + newRecord.payments = new ArrayList<>(); + newRecord.learnings = new ArrayList<>(); + newRecord.assessments = new ArrayList<>(); + newRecord.paymentUnits = new ArrayList<>(); + + newRecord.organization = oldRecord.getOrganization(); + newRecord.numLearningModules = oldRecord.getNumLearningModules(); + newRecord.learningModulesCompleted = oldRecord.getLearningModulesCompleted(); + newRecord.currency = oldRecord.getCurrency(); + newRecord.paymentAccrued = Integer.toString(oldRecord.getPaymentAccrued()); + newRecord.shortDescription = oldRecord.getShortDescription(); + newRecord.lastUpdate = oldRecord.getLastUpdate(); + newRecord.lastLearnUpdate = oldRecord.getLastLearnUpdate(); + newRecord.lastDeliveryUpdate = oldRecord.getLastDeliveryUpdate(); + newRecord.dateClaimed = oldRecord.getDateClaimed(); + newRecord.projectStartDate = oldRecord.getProjectStartDate(); + newRecord.isActive = oldRecord.getIsActive(); + newRecord.isUserSuspended= false; + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java new file mode 100644 index 000000000..dea2d096f --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java @@ -0,0 +1,129 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.io.Serializable; +import java.util.Date; + +/** + * Data class for holding info related to a Connect job + * This version was used up to V2 of the DB + * + * @author dviggiano + */ +@Table(ConnectJobRecordV2.STORAGE_KEY) +public class ConnectJobRecordV2 extends Persisted implements Serializable { + /** + * Name of database that stores Connect jobs/opportunities + */ + public static final String STORAGE_KEY = "connect_jobs"; + + + public static final String META_JOB_ID = "id"; + public static final String META_NAME = "name"; + public static final String META_DESCRIPTION = "description"; + public static final String META_ORGANIZATION = "organization"; + public static final String META_END_DATE = "end_date"; + public static final String META_MAX_VISITS = "max_visits_per_user"; + public static final String META_MAX_DAILY_VISITS = "daily_max_visits_per_user"; + public static final String META_BUDGET_PER_VISIT = "budget_per_visit"; + public static final String META_BUDGET_TOTAL = "total_budget"; + public static final String META_LAST_WORKED_DATE = "last_worked"; + public static final String META_STATUS = "status"; + public static final String META_LEARN_MODULES = "total_modules"; + public static final String META_COMPLETED_MODULES = "completed_modules"; + + public static final String META_DELIVERY_PROGRESS = "deliver_progress"; + public static final String META_CURRENCY = "currency"; + public static final String META_ACCRUED = "payment_accrued"; + public static final String META_SHORT_DESCRIPTION = "short_description"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_NAME) + private String title; + @Persisting(3) + @MetaField(META_DESCRIPTION) + private String description; + @Persisting(4) + @MetaField(META_ORGANIZATION) + private String organization; + @Persisting(5) + @MetaField(META_END_DATE) + private Date projectEndDate; + @Persisting(6) + @MetaField(META_BUDGET_PER_VISIT) + private int budgetPerVisit; + @Persisting(7) + @MetaField(META_BUDGET_TOTAL) + private int totalBudget; + @Persisting(8) + @MetaField(META_MAX_VISITS) + private int maxVisits; + @Persisting(9) + @MetaField(META_MAX_DAILY_VISITS) + private int maxDailyVisits; + @Persisting(10) + @MetaField(META_DELIVERY_PROGRESS) + private int completedVisits; + @Persisting(11) + @MetaField(META_LAST_WORKED_DATE) + private Date lastWorkedDate; + @Persisting(12) + @MetaField(META_STATUS) + private int status; + @Persisting(13) + @MetaField(META_LEARN_MODULES) + private int numLearningModules; + @Persisting(14) + @MetaField(META_COMPLETED_MODULES) + private int learningModulesCompleted; + @Persisting(15) + @MetaField(META_CURRENCY) + private String currency; + @Persisting(16) + @MetaField(META_ACCRUED) + private String paymentAccrued; + @Persisting(17) + @MetaField(META_SHORT_DESCRIPTION) + private String shortDescription; + @Persisting(18) + private Date lastUpdate; + @Persisting(19) + private Date lastLearnUpdate; + @Persisting(20) + private Date lastDeliveryUpdate; + + public ConnectJobRecordV2() { + + } + + public int getJobId() { return jobId; } + public String getTitle() { return title; } + public String getDescription() { return description; } + public String getShortDescription() { return shortDescription; } + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public int getCompletedVisits() { return completedVisits; } + public int getMaxVisits() { return maxVisits; } + public int getMaxDailyVisits() { return maxDailyVisits; } + public int getBudgetPerVisit() { return budgetPerVisit; } + public Date getProjectEndDate() { return projectEndDate; } + public int getPaymentAccrued() { return paymentAccrued != null && paymentAccrued.length() > 0 ? Integer.parseInt(paymentAccrued) : 0; } + public String getCurrency() { return currency; } + public int getNumLearningModules() { return numLearningModules; } + + public Date getLastUpdate() { return lastUpdate; } + + public Date getLastLearnUpdate() { return lastLearnUpdate; } + public Date getLastDeliveryUpdate() { return lastDeliveryUpdate; } + public String getOrganization() { return organization; } + public int getTotalBudget() { return totalBudget; } + public Date getLastWorkedDate() { return lastWorkedDate; } + public int getLearningModulesCompleted() { return learningModulesCompleted; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java new file mode 100644 index 000000000..4ad0cfe8a --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java @@ -0,0 +1,162 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.io.Serializable; +import java.util.Date; + +/** + * Data class for holding info related to a Connect job + * + * @author dviggiano + */ +@Table(ConnectJobRecordV4.STORAGE_KEY) +public class ConnectJobRecordV4 extends Persisted implements Serializable { + /** + * Name of database that stores Connect jobs/opportunities + */ + public static final String STORAGE_KEY = "connect_jobs"; + + public static final String META_JOB_ID = "id"; + public static final String META_NAME = "name"; + public static final String META_DESCRIPTION = "description"; + public static final String META_ORGANIZATION = "organization"; + public static final String META_END_DATE = "end_date"; + public static final String META_MAX_VISITS = "max_visits_per_user"; + public static final String META_MAX_DAILY_VISITS = "daily_max_visits_per_user"; + public static final String META_BUDGET_PER_VISIT = "budget_per_visit"; + public static final String META_BUDGET_TOTAL = "total_budget"; + public static final String META_LAST_WORKED_DATE = "last_worked"; + public static final String META_STATUS = "status"; + public static final String META_LEARN_MODULES = "total_modules"; + public static final String META_COMPLETED_MODULES = "completed_modules"; + + public static final String META_DELIVERY_PROGRESS = "deliver_progress"; + public static final String META_CLAIM_DATE = "date_claimed"; + public static final String META_CURRENCY = "currency"; + public static final String META_ACCRUED = "payment_accrued"; + public static final String META_SHORT_DESCRIPTION = "short_description"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_NAME) + private String title; + @Persisting(3) + @MetaField(META_DESCRIPTION) + private String description; + @Persisting(4) + @MetaField(META_ORGANIZATION) + private String organization; + @Persisting(5) + @MetaField(META_END_DATE) + private Date projectEndDate; + @Persisting(6) + @MetaField(META_BUDGET_PER_VISIT) + private int budgetPerVisit; + @Persisting(7) + @MetaField(META_BUDGET_TOTAL) + private int totalBudget; + @Persisting(8) + @MetaField(META_MAX_VISITS) + private int maxVisits; + @Persisting(9) + @MetaField(META_MAX_DAILY_VISITS) + private int maxDailyVisits; + @Persisting(10) + @MetaField(META_DELIVERY_PROGRESS) + private int completedVisits; + @Persisting(11) + @MetaField(META_LAST_WORKED_DATE) + private Date lastWorkedDate; + @Persisting(12) + @MetaField(META_STATUS) + private int status; + @Persisting(13) + @MetaField(META_LEARN_MODULES) + private int numLearningModules; + @Persisting(14) + @MetaField(META_COMPLETED_MODULES) + private int learningModulesCompleted; + @Persisting(15) + @MetaField(META_CURRENCY) + private String currency; + @Persisting(16) + @MetaField(META_ACCRUED) + private String paymentAccrued; + @Persisting(17) + @MetaField(META_SHORT_DESCRIPTION) + private String shortDescription; + @Persisting(18) + private Date lastUpdate; + @Persisting(19) + private Date lastLearnUpdate; + @Persisting(20) + private Date lastDeliveryUpdate; + @Persisting(21) + @MetaField(META_CLAIM_DATE) + private Date dateClaimed; + + public ConnectJobRecordV4() { + + } + + public int getJobId() { return jobId; } + public String getTitle() { return title; } + public String getDescription() { return description; } + public String getShortDescription() { return shortDescription; } + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public int getCompletedVisits() { return completedVisits; } + public int getMaxVisits() { return maxVisits; } + public int getMaxDailyVisits() { return maxDailyVisits; } + public int getBudgetPerVisit() { return budgetPerVisit; } + public Date getProjectEndDate() { return projectEndDate; } + public int getPaymentAccrued() { return paymentAccrued != null && paymentAccrued.length() > 0 ? Integer.parseInt(paymentAccrued) : 0; } + public String getCurrency() { return currency; } + public int getNumLearningModules() { return numLearningModules; } + public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } + public Date getLastUpdate() { return lastUpdate; } + public Date getLastLearnUpdate() { return lastLearnUpdate; } + public Date getLastDeliveryUpdate() { return lastDeliveryUpdate; } + public String getOrganization() { return organization; } + public int getTotalBudget() { return totalBudget; } + public Date getLastWorkedDate() { return lastWorkedDate; } + public int getLearningModulesCompleted() { return learningModulesCompleted; } + + /** + * Used for app db migration only + */ + public static ConnectJobRecordV4 fromV2(ConnectJobRecordV2 oldRecord) { + ConnectJobRecordV4 newRecord = new ConnectJobRecordV4(); + + newRecord.jobId = oldRecord.getJobId(); + newRecord.title = oldRecord.getTitle(); + newRecord.description = oldRecord.getDescription(); + newRecord.status = oldRecord.getStatus(); + newRecord.completedVisits = oldRecord.getCompletedVisits(); + newRecord.maxDailyVisits = oldRecord.getMaxDailyVisits(); + newRecord.maxVisits = oldRecord.getMaxVisits(); + newRecord.budgetPerVisit = oldRecord.getBudgetPerVisit(); + newRecord.totalBudget = oldRecord.getTotalBudget(); + newRecord.projectEndDate = oldRecord.getProjectEndDate(); + newRecord.lastWorkedDate = oldRecord.getLastWorkedDate(); + newRecord.organization = oldRecord.getOrganization(); + newRecord.lastWorkedDate = oldRecord.getLastWorkedDate(); + newRecord.numLearningModules = oldRecord.getNumLearningModules(); + newRecord.learningModulesCompleted = oldRecord.getLearningModulesCompleted(); + newRecord.currency = oldRecord.getCurrency(); + newRecord.paymentAccrued = Integer.toString(oldRecord.getPaymentAccrued()); + newRecord.shortDescription = oldRecord.getShortDescription(); + newRecord.lastUpdate = oldRecord.getLastUpdate(); + newRecord.lastLearnUpdate = oldRecord.getLastLearnUpdate(); + newRecord.lastDeliveryUpdate = oldRecord.getLastDeliveryUpdate(); + newRecord.dateClaimed = new Date(); + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java new file mode 100644 index 000000000..f9cebad45 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java @@ -0,0 +1,179 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.io.Serializable; +import java.util.Date; + +/** + * Data class for holding info related to a Connect job + * + * @author dviggiano + */ +@Table(ConnectJobRecordV7.STORAGE_KEY) +public class ConnectJobRecordV7 extends Persisted implements Serializable { + /** + * Name of database that stores Connect jobs/opportunities + */ + public static final String STORAGE_KEY = "connect_jobs"; + + public static final String META_JOB_ID = "id"; + public static final String META_NAME = "name"; + public static final String META_DESCRIPTION = "description"; + public static final String META_ORGANIZATION = "organization"; + public static final String META_END_DATE = "end_date"; + public static final String META_MAX_VISITS = "max_visits_per_user"; + public static final String META_MAX_DAILY_VISITS = "daily_max_visits_per_user"; + public static final String META_BUDGET_PER_VISIT = "budget_per_visit"; + public static final String META_BUDGET_TOTAL = "total_budget"; + public static final String META_LAST_WORKED_DATE = "last_worked"; + public static final String META_STATUS = "status"; + public static final String META_LEARN_MODULES = "total_modules"; + public static final String META_COMPLETED_MODULES = "completed_modules"; + + public static final String META_DELIVERY_PROGRESS = "deliver_progress"; + public static final String META_CLAIM_DATE = "date_claimed"; + public static final String META_CURRENCY = "currency"; + public static final String META_ACCRUED = "payment_accrued"; + public static final String META_SHORT_DESCRIPTION = "short_description"; + public static final String META_START_DATE = "start_date"; + public static final String META_IS_ACTIVE = "is_active"; + + + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + @Persisting(2) + @MetaField(META_NAME) + private String title; + @Persisting(3) + @MetaField(META_DESCRIPTION) + private String description; + @Persisting(4) + @MetaField(META_ORGANIZATION) + private String organization; + @Persisting(5) + @MetaField(META_END_DATE) + private Date projectEndDate; + @Persisting(6) + @MetaField(META_BUDGET_PER_VISIT) + private int budgetPerVisit; + @Persisting(7) + @MetaField(META_BUDGET_TOTAL) + private int totalBudget; + @Persisting(8) + @MetaField(META_MAX_VISITS) + private int maxVisits; + @Persisting(9) + @MetaField(META_MAX_DAILY_VISITS) + private int maxDailyVisits; + @Persisting(10) + @MetaField(META_DELIVERY_PROGRESS) + private int completedVisits; + @Persisting(11) + @MetaField(META_LAST_WORKED_DATE) + private Date lastWorkedDate; + @Persisting(12) + @MetaField(META_STATUS) + private int status; + @Persisting(13) + @MetaField(META_LEARN_MODULES) + private int numLearningModules; + @Persisting(14) + @MetaField(META_COMPLETED_MODULES) + private int learningModulesCompleted; + @Persisting(15) + @MetaField(META_CURRENCY) + private String currency; + @Persisting(16) + @MetaField(META_ACCRUED) + private String paymentAccrued; + @Persisting(17) + @MetaField(META_SHORT_DESCRIPTION) + private String shortDescription; + @Persisting(18) + private Date lastUpdate; + @Persisting(19) + private Date lastLearnUpdate; + @Persisting(20) + private Date lastDeliveryUpdate; + @Persisting(21) + @MetaField(META_CLAIM_DATE) + private Date dateClaimed; + + @Persisting(22) + @MetaField(META_START_DATE) + private Date projectStartDate; + @Persisting(23) + @MetaField(META_IS_ACTIVE) + private boolean isActive; + + public ConnectJobRecordV7() { + + } + + public int getJobId() { return jobId; } + public String getTitle() { return title; } + public String getDescription() { return description; } + public String getShortDescription() { return shortDescription; } + public int getStatus() { return status; } + public void setStatus(int status) { this.status = status; } + public int getCompletedVisits() { return completedVisits; } + public int getMaxVisits() { return maxVisits; } + public int getMaxDailyVisits() { return maxDailyVisits; } + public int getBudgetPerVisit() { return budgetPerVisit; } + public Date getProjectEndDate() { return projectEndDate; } + public int getPaymentAccrued() { return paymentAccrued != null && paymentAccrued.length() > 0 ? Integer.parseInt(paymentAccrued) : 0; } + public String getCurrency() { return currency; } + public int getNumLearningModules() { return numLearningModules; } + public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } + public Date getLastUpdate() { return lastUpdate; } + public Date getLastLearnUpdate() { return lastLearnUpdate; } + public Date getLastDeliveryUpdate() { return lastDeliveryUpdate; } + public String getOrganization() { return organization; } + public int getTotalBudget() { return totalBudget; } + public Date getLastWorkedDate() { return lastWorkedDate; } + public int getLearningModulesCompleted() { return learningModulesCompleted; } + + public boolean getIsActive() { return isActive; } + + public Date getProjectStartDate() { return projectStartDate; } + + public Date getDateClaimed() { return dateClaimed; } + + public static ConnectJobRecordV7 fromV4(ConnectJobRecordV4 oldRecord) { + ConnectJobRecordV7 newRecord = new ConnectJobRecordV7(); + + newRecord.jobId = oldRecord.getJobId(); + newRecord.title = oldRecord.getTitle(); + newRecord.description = oldRecord.getDescription(); + newRecord.status = oldRecord.getStatus(); + newRecord.completedVisits = oldRecord.getCompletedVisits(); + newRecord.maxDailyVisits = oldRecord.getMaxDailyVisits(); + newRecord.maxVisits = oldRecord.getMaxVisits(); + newRecord.budgetPerVisit = oldRecord.getBudgetPerVisit(); + newRecord.totalBudget = oldRecord.getTotalBudget(); + newRecord.projectEndDate = oldRecord.getProjectEndDate(); + newRecord.lastWorkedDate = oldRecord.getLastWorkedDate(); + + newRecord.organization = oldRecord.getOrganization(); + newRecord.lastWorkedDate = oldRecord.getLastWorkedDate(); + newRecord.numLearningModules = oldRecord.getNumLearningModules(); + newRecord.learningModulesCompleted = oldRecord.getLearningModulesCompleted(); + newRecord.currency = oldRecord.getCurrency(); + newRecord.paymentAccrued = Integer.toString(oldRecord.getPaymentAccrued()); + newRecord.shortDescription = oldRecord.getShortDescription(); + newRecord.lastUpdate = oldRecord.getLastUpdate(); + newRecord.lastLearnUpdate = oldRecord.getLastLearnUpdate(); + newRecord.lastDeliveryUpdate = oldRecord.getLastDeliveryUpdate(); + newRecord.dateClaimed = new Date(); + newRecord.projectStartDate = new Date(); + newRecord.isActive = true; + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLearnModuleSummaryRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectLearnModuleSummaryRecord.java new file mode 100644 index 000000000..bbc514e9e --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectLearnModuleSummaryRecord.java @@ -0,0 +1,77 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.util.Date; + +@Table(ConnectLearnModuleSummaryRecord.STORAGE_KEY) +public class ConnectLearnModuleSummaryRecord extends Persisted implements Serializable { + /** + * Name of database that stores info for Connect learn modules + */ + public static final String STORAGE_KEY = "connect_learn_modules"; + + public static final String META_SLUG = "slug"; + public static final String META_NAME = "name"; + public static final String META_DESCRIPTION = "description"; + public static final String META_ESTIMATE = "time_estimate"; + public static final String META_JOB_ID = "job_id"; + public static final String META_INDEX = "module_index"; + + @Persisting(1) + @MetaField(META_SLUG) + private String slug; + + @Persisting(2) + @MetaField(META_NAME) + private String name; + + @Persisting(3) + @MetaField(META_DESCRIPTION) + private String description; + + @Persisting(4) + @MetaField(META_ESTIMATE) + private int timeEstimate; + + @Persisting(5) + @MetaField(META_JOB_ID) + private int jobId; + + @Persisting(6) + @MetaField(META_INDEX) + private int moduleIndex; + + @Persisting(7) + private Date lastUpdate; + + public ConnectLearnModuleSummaryRecord() { + + } + + public static ConnectLearnModuleSummaryRecord fromJson(JSONObject json, int moduleIndex) throws JSONException { + ConnectLearnModuleSummaryRecord info = new ConnectLearnModuleSummaryRecord(); + + info.moduleIndex = moduleIndex; + + info.slug = json.has(META_SLUG) ? json.getString(META_SLUG) : null; + info.name = json.has(META_NAME) ? json.getString(META_NAME) : null; + info.description = json.has(META_DESCRIPTION) ? json.getString(META_DESCRIPTION) : null; + info.timeEstimate = json.has(META_ESTIMATE) ? json.getInt(META_ESTIMATE) : -1; + + return info; + } + + public void setJobId(int jobId) { this.jobId = jobId; } + public String getSlug() { return slug; } + public int getModuleIndex() { return moduleIndex; } + public String getName() { return name; } + public int getTimeEstimate() { return timeEstimate; } + public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java new file mode 100644 index 000000000..e6e7b7643 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java @@ -0,0 +1,175 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.util.Date; + +/** + * DB model holding info for an HQ app linked to ConnectID + * + * @author dviggiano + */ +@Table(ConnectLinkedAppRecord.STORAGE_KEY) +public class ConnectLinkedAppRecord extends Persisted { + /** + * Name of database that stores Connect user records + */ + public static final String STORAGE_KEY = "app_info"; + + public static final String META_APP_ID = "app_id"; + public static final String META_USER_ID = "user_id"; + public static final String META_CONNECTID_LINKED = "connectid_linked"; + public static final String META_OFFERED_1 = "link_offered_1"; + public static final String META_OFFERED_1_DATE = "link_offered_1_date"; + public static final String META_OFFERED_2 = "link_offered_2"; + public static final String META_OFFERED_2_DATE = "link_offered_2_date"; + public static final String META_LOCAL_PASSPHRASE = "using_local_passphrase"; + public static final String META_LAST_ACCESSED = "last_accessed"; + + @Persisting(1) + @MetaField(META_APP_ID) + private String appId; + @Persisting(2) + @MetaField(META_USER_ID) + private String userId; + @Persisting(3) + private String password; + @Persisting(4) + private boolean workerLinked; + @Persisting(value = 5, nullable = true) + private String hqToken; + @Persisting(6) + private Date hqTokenExpiration; + @Persisting(7) + @MetaField(META_CONNECTID_LINKED) + private boolean connectIdLinked; + @Persisting(8) + @MetaField(META_OFFERED_1) + private boolean linkOffered1; + @Persisting(9) + @MetaField(META_OFFERED_1_DATE) + private Date linkOfferDate1; + @Persisting(10) + @MetaField(META_OFFERED_2) + private boolean linkOffered2; + @Persisting(11) + @MetaField(META_OFFERED_2_DATE) + private Date linkOfferDate2; + + @Persisting(12) + @MetaField(META_LOCAL_PASSPHRASE) + private boolean usingLocalPassphrase; + + @Persisting(13) + @MetaField(META_LAST_ACCESSED) + private Date lastAccessed; + + public ConnectLinkedAppRecord() { + hqTokenExpiration = new Date(); + linkOfferDate1 = new Date(); + linkOfferDate2 = new Date(); + lastAccessed = new Date(); + } + + public ConnectLinkedAppRecord(String appId, String userId, boolean connectIdLinked, String password) { + this(); + + this.appId = appId; + this.userId = userId; + this.connectIdLinked = connectIdLinked; + this.password = password; + } + + public String getUserId() { + return userId; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean getWorkerLinked() { + return workerLinked; + } + + public void setWorkerLinked(boolean linked) { + workerLinked = linked; + } + + public String getHqToken() { + return hqToken; + } + + public Date getHqTokenExpiration() { + return hqTokenExpiration; + } + + public void updateHqToken(String token, Date expirationDate) { + hqToken = token; + hqTokenExpiration = expirationDate; + } + + public boolean getConnectIdLinked() { return connectIdLinked; } + public void setConnectIdLinked(boolean linked) { connectIdLinked = linked; } + + public void linkToConnectId(String password) { + connectIdLinked = true; + this.password = password; + } + + public void severConnectIdLink() { + connectIdLinked = false; + password = ""; + linkOffered1 = false; + linkOffered2 = false; + } + + public Date getLinkOfferDate1() { + return linkOffered1 ? linkOfferDate1 : null; + } + public void setLinkOfferDate1(Date date) { + linkOffered1 = true; + linkOfferDate1 = date; + } + + public Date getLinkOfferDate2() { + return linkOffered2 ? linkOfferDate2 : null; + } + public void setLinkOfferDate2(Date date) { + linkOffered2 = true; + linkOfferDate2 = date; + } + + public boolean isUsingLocalPassphrase() { return usingLocalPassphrase; } + public void setIsUsingLocalPassphrase(boolean using) { usingLocalPassphrase = using; } + + public Date getLastAccessed() { return lastAccessed; } + public void setLastAccessed(Date date) { lastAccessed = date; } + + public static ConnectLinkedAppRecord fromV9(ConnectLinkedAppRecordV9 oldRecord) { + ConnectLinkedAppRecord newRecord = new ConnectLinkedAppRecord(); + + newRecord.appId = oldRecord.getAppId(); + newRecord.userId = oldRecord.getUserId(); + newRecord.password = oldRecord.getPassword(); + newRecord.workerLinked = oldRecord.getWorkerLinked(); + newRecord.hqToken = oldRecord.getHqToken(); + newRecord.hqTokenExpiration = oldRecord.getHqTokenExpiration(); + newRecord.connectIdLinked = oldRecord.getConnectIdLinked(); + newRecord.linkOffered1 = oldRecord.getLinkOfferDate1() != null; + newRecord.linkOfferDate1 = newRecord.linkOffered1 ? oldRecord.getLinkOfferDate1() : new Date(); + newRecord.linkOffered2 = oldRecord.getLinkOfferDate2() != null; + newRecord.linkOfferDate2 = newRecord.linkOffered2 ? oldRecord.getLinkOfferDate2() : new Date(); + + newRecord.usingLocalPassphrase = oldRecord.isUsingLocalPassphrase(); + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV3.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV3.java new file mode 100644 index 000000000..6790121e2 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV3.java @@ -0,0 +1,68 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.util.Date; + +/** + * DB model holding info for an HQ app linked to ConnectID + * + * @author dviggiano + */ +@Table(ConnectLinkedAppRecordV3.STORAGE_KEY) +public class ConnectLinkedAppRecordV3 extends Persisted { + /** + * Name of database that stores Connect user records + */ + public static final String STORAGE_KEY = "app_info"; + + public static final String META_APP_ID = "app_id"; + public static final String META_USER_ID = "user_id"; + + @Persisting(1) + @MetaField(META_APP_ID) + private String appId; + @Persisting(2) + @MetaField(META_USER_ID) + private String userId; + @Persisting(3) + private String password; + @Persisting(4) + private boolean workerLinked; + @Persisting(value = 5, nullable = true) + private String hqToken; + @Persisting(6) + private Date hqTokenExpiration; + + public ConnectLinkedAppRecordV3() { + hqTokenExpiration = new Date(); + } + + public String getAppId() { return appId; } + public String getUserId() { + return userId; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean getWorkerLinked() { + return workerLinked; + } + + public String getHqToken() { + return hqToken; + } + + public Date getHqTokenExpiration() { + return hqTokenExpiration; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java new file mode 100644 index 000000000..9a05d3477 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java @@ -0,0 +1,114 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.util.Date; + +/** + * DB model holding info for an HQ app linked to ConnectID + * + * @author dviggiano + */ +@Table(ConnectLinkedAppRecord.STORAGE_KEY) +public class ConnectLinkedAppRecordV8 extends Persisted { + /** + * Name of database that stores Connect user records + */ + public static final String STORAGE_KEY = "app_info"; + + public static final String META_APP_ID = "app_id"; + public static final String META_USER_ID = "user_id"; + public static final String META_CONNECTID_LINKED = "connectid_linked"; + public static final String META_OFFERED_1 = "link_offered_1"; + public static final String META_OFFERED_1_DATE = "link_offered_1_date"; + public static final String META_OFFERED_2 = "link_offered_2"; + public static final String META_OFFERED_2_DATE = "link_offered_2_date"; + + @Persisting(1) + @MetaField(META_APP_ID) + private String appId; + @Persisting(2) + @MetaField(META_USER_ID) + private String userId; + @Persisting(3) + private String password; + @Persisting(4) + private boolean workerLinked; + @Persisting(value = 5, nullable = true) + private String hqToken; + @Persisting(6) + private Date hqTokenExpiration; + @Persisting(7) + @MetaField(META_CONNECTID_LINKED) + private boolean connectIdLinked; + @Persisting(8) + @MetaField(META_OFFERED_1) + private boolean linkOffered1; + @Persisting(9) + @MetaField(META_OFFERED_1_DATE) + private Date linkOfferDate1; + @Persisting(10) + @MetaField(META_OFFERED_2) + private boolean linkOffered2; + @Persisting(11) + @MetaField(META_OFFERED_2_DATE) + private Date linkOfferDate2; + + public String getAppId(){ return appId; } + + public String getUserId() { + return userId; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean getWorkerLinked() { + return workerLinked; + } + + public String getHqToken() { + return hqToken; + } + + public Date getHqTokenExpiration() { + return hqTokenExpiration; + } + + public boolean getConnectIdLinked() { return connectIdLinked; } + + public Date getLinkOfferDate1() { + return linkOffered1 ? linkOfferDate1 : null; + } + + public Date getLinkOfferDate2() { + return linkOffered2 ? linkOfferDate2 : null; + } + + public static ConnectLinkedAppRecordV8 fromV3(ConnectLinkedAppRecordV3 oldRecord) { + ConnectLinkedAppRecordV8 newRecord = new ConnectLinkedAppRecordV8(); + + newRecord.appId = oldRecord.getAppId(); + newRecord.userId = oldRecord.getUserId(); + newRecord.password = oldRecord.getPassword(); + newRecord.workerLinked = oldRecord.getWorkerLinked(); + newRecord.hqToken = oldRecord.getHqToken(); + newRecord.hqTokenExpiration = oldRecord.getHqTokenExpiration(); + + newRecord.connectIdLinked = true; + newRecord.linkOffered1 = true; + newRecord.linkOfferDate1 = new Date(); + newRecord.linkOffered2 = false; + newRecord.linkOfferDate2 = new Date(); + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java new file mode 100644 index 000000000..0a91c4cbe --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java @@ -0,0 +1,127 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.util.Date; + +/** + * DB model holding info for an HQ app linked to ConnectID + * + * @author dviggiano + */ +@Table(ConnectLinkedAppRecordV9.STORAGE_KEY) +public class ConnectLinkedAppRecordV9 extends Persisted { + /** + * Name of database that stores Connect user records + */ + public static final String STORAGE_KEY = "app_info"; + + public static final String META_APP_ID = "app_id"; + public static final String META_USER_ID = "user_id"; + public static final String META_CONNECTID_LINKED = "connectid_linked"; + public static final String META_OFFERED_1 = "link_offered_1"; + public static final String META_OFFERED_1_DATE = "link_offered_1_date"; + public static final String META_OFFERED_2 = "link_offered_2"; + public static final String META_OFFERED_2_DATE = "link_offered_2_date"; + public static final String META_LOCAL_PASSPHRASE = "using_local_passphrase"; + + @Persisting(1) + @MetaField(META_APP_ID) + private String appId; + @Persisting(2) + @MetaField(META_USER_ID) + private String userId; + @Persisting(3) + private String password; + @Persisting(4) + private boolean workerLinked; + @Persisting(value = 5, nullable = true) + private String hqToken; + @Persisting(6) + private Date hqTokenExpiration; + @Persisting(7) + @MetaField(META_CONNECTID_LINKED) + private boolean connectIdLinked; + @Persisting(8) + @MetaField(META_OFFERED_1) + private boolean linkOffered1; + @Persisting(9) + @MetaField(META_OFFERED_1_DATE) + private Date linkOfferDate1; + @Persisting(10) + @MetaField(META_OFFERED_2) + private boolean linkOffered2; + @Persisting(11) + @MetaField(META_OFFERED_2_DATE) + private Date linkOfferDate2; + + @Persisting(12) + @MetaField(META_LOCAL_PASSPHRASE) + private boolean usingLocalPassphrase; + + public ConnectLinkedAppRecordV9() { + hqTokenExpiration = new Date(); + linkOfferDate1 = new Date(); + linkOfferDate2 = new Date(); + } + + public String getAppId(){ return appId; } + public String getUserId() { + return userId; + } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public boolean getWorkerLinked() { + return workerLinked; + } + + public String getHqToken() { + return hqToken; + } + + public Date getHqTokenExpiration() { + return hqTokenExpiration; + } + + public boolean getConnectIdLinked() { return connectIdLinked; } + + public Date getLinkOfferDate1() { + return linkOffered1 ? linkOfferDate1 : null; + } + + public Date getLinkOfferDate2() { + return linkOffered2 ? linkOfferDate2 : null; + } + + public boolean isUsingLocalPassphrase() { return usingLocalPassphrase; } + + public static ConnectLinkedAppRecordV9 fromV8(ConnectLinkedAppRecordV8 oldRecord) { + ConnectLinkedAppRecordV9 newRecord = new ConnectLinkedAppRecordV9(); + + newRecord.appId = oldRecord.getAppId(); + newRecord.userId = oldRecord.getUserId(); + newRecord.password = oldRecord.getPassword(); + newRecord.workerLinked = oldRecord.getWorkerLinked(); + newRecord.hqToken = oldRecord.getHqToken(); + newRecord.hqTokenExpiration = oldRecord.getHqTokenExpiration(); + newRecord.connectIdLinked = oldRecord.getConnectIdLinked(); + newRecord.linkOffered1 = oldRecord.getLinkOfferDate1() != null; + newRecord.linkOfferDate1 = newRecord.linkOffered1 ? oldRecord.getLinkOfferDate1() : new Date(); + newRecord.linkOffered2 = oldRecord.getLinkOfferDate2() != null; + newRecord.linkOfferDate2 = newRecord.linkOffered2 ? oldRecord.getLinkOfferDate2() : new Date(); + + newRecord.usingLocalPassphrase = true; + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java new file mode 100644 index 000000000..ec7ad93d5 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java @@ -0,0 +1,78 @@ +package org.commcare.android.database.connect.models; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.Serializable; +import java.text.ParseException; + +@Table(ConnectPaymentUnitRecord.STORAGE_KEY) +public class ConnectPaymentUnitRecord extends Persisted implements Serializable { + /** + * Name of database that stores Connect payment units + */ + public static final String STORAGE_KEY = "connect_payment_units"; + + public static final String META_JOB_ID = "job_id"; + public static final String META_ID = "id"; + public static final String META_UNIT_ID = "unit_id"; + public static final String META_NAME = "name"; + public static final String META_TOTAL = "max_total"; + public static final String META_DAILY = "max_daily"; + public static final String META_AMOUNT = "amount"; + + @Persisting(1) + @MetaField(META_JOB_ID) + private int jobId; + + @Persisting(2) + @MetaField(META_UNIT_ID) + private int unitId; + + @Persisting(3) + @MetaField(META_NAME) + private String name; + + @Persisting(4) + @MetaField(META_TOTAL) + private int maxTotal; + + @Persisting(5) + @MetaField(META_DAILY) + private int maxDaily; + + @Persisting(6) + @MetaField(META_AMOUNT) + private int amount; + + public ConnectPaymentUnitRecord() { + + } + + public static ConnectPaymentUnitRecord fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + ConnectPaymentUnitRecord paymentUnit = new ConnectPaymentUnitRecord(); + + paymentUnit.jobId = jobId; + paymentUnit.unitId = json.getInt(META_ID); + paymentUnit.name = json.getString(META_NAME); + paymentUnit.maxTotal = json.getInt(META_TOTAL); + paymentUnit.maxDaily = json.getInt(META_DAILY); + paymentUnit.amount = json.getInt(META_AMOUNT); + + return paymentUnit; + } + + public int getJobId() { return jobId; } + public void setJobId(int jobId) { this.jobId = jobId; } + + public String getName() { return name; } + public int getUnitId() { return unitId; } + public int getMaxTotal() { return maxTotal; } + public void setMaxTotal(int max) { maxTotal = max; } + public int getMaxDaily() { return maxDaily; } + public int getAmount() { return amount; } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java new file mode 100644 index 000000000..95d95ee66 --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java @@ -0,0 +1,221 @@ +package org.commcare.android.database.connect.models; + +import android.content.Intent; + +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.ConnectConstants; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; +import org.commcare.modern.models.MetaField; + +import java.util.Date; +import java.util.concurrent.TimeUnit; + +/** + * DB model for a ConnectID user and their info + * + * @author dviggiano + */ +@Table(ConnectUserRecord.STORAGE_KEY) +public class ConnectUserRecord extends Persisted { + /** + * Name of database that stores Connect user records + */ + public static final String STORAGE_KEY = "user_info"; + public static final String META_PIN = "pin"; + public static final String META_SECONDARY_PHONE_VERIFIED = "secondary_phone_verified"; + public static final String META_VERIFY_SECONDARY_PHONE_DATE = "verify_secondary_phone_by_date"; + + @Persisting(1) + private String userId; + + @Persisting(2) + private String password; + + @Persisting(3) + private String name; + + @Persisting(4) + private String primaryPhone; + + @Persisting(5) + private String alternatePhone; + + @Persisting(6) + private int registrationPhase; + + @Persisting(7) + private Date lastPasswordDate; + + @Persisting(value = 8, nullable = true) + private String connectToken; + + @Persisting(value = 9, nullable = true) + private Date connectTokenExpiration; + @Persisting(value=10, nullable = true) + @MetaField(META_PIN) + private String pin; + @Persisting(11) + @MetaField(META_SECONDARY_PHONE_VERIFIED) + private boolean secondaryPhoneVerified; + + @Persisting(12) + @MetaField(META_VERIFY_SECONDARY_PHONE_DATE) + private Date verifySecondaryPhoneByDate; + + public ConnectUserRecord() { + registrationPhase = ConnectConstants.CONNECT_NO_ACTIVITY; + lastPasswordDate = new Date(); + connectTokenExpiration = new Date(); + secondaryPhoneVerified = true; + verifySecondaryPhoneByDate = new Date(); + } + + public ConnectUserRecord(String primaryPhone, String userId, String password, String name, + String alternatePhone) { + this(); + this.primaryPhone = primaryPhone; + this.alternatePhone = alternatePhone; + this.userId = userId; + this.password = password; + this.name = name; + + connectTokenExpiration = new Date(); + } + + public static ConnectUserRecord getUserFromIntent(Intent intent) { + return new ConnectUserRecord( + intent.getStringExtra(ConnectConstants.PHONE), + intent.getStringExtra(ConnectConstants.USERNAME), + intent.getStringExtra(ConnectConstants.PASSWORD), + intent.getStringExtra(ConnectConstants.NAME), + intent.getStringExtra(ConnectConstants.ALT_PHONE)); + } + + public void putUserInIntent(Intent intent) { + intent.putExtra(ConnectConstants.PHONE, primaryPhone); + intent.putExtra(ConnectConstants.USERNAME, userId); + intent.putExtra(ConnectConstants.PASSWORD, password); + intent.putExtra(ConnectConstants.NAME, name); + intent.putExtra(ConnectConstants.ALT_PHONE, alternatePhone); + } + + public String getUserId() { + return userId; + } + + public String getPrimaryPhone() { + return primaryPhone; + } + + public void setPrimaryPhone(String primaryPhone) { + this.primaryPhone = primaryPhone; + } + + public String getAlternatePhone() { + return alternatePhone; + } + + public void setAlternatePhone(String alternatePhone) { + this.alternatePhone = alternatePhone; + } + public void setPin(String pin) { this.pin = pin; } + public String getPin() { return pin; } + + public String getPassword() { + return password; + } + + public void setPassword(String password) { + this.password = password; + } + + public String getName() { + return name; + } + + public void setName(String name) { + this.name = name; + } + + public int getRegistrationPhase() { + return registrationPhase; + } + + public void setRegistrationPhase(int phase) { + registrationPhase = phase; + } + + public Date getLastPinDate() { + return lastPasswordDate; + } + public void setLastPinDate(Date date) { lastPasswordDate = date; } + + public boolean getSecondaryPhoneVerified() { + return secondaryPhoneVerified; + } + public void setSecondaryPhoneVerified(boolean verified) { secondaryPhoneVerified = verified; } + public Date getSecondaryPhoneVerifyByDate() { + return verifySecondaryPhoneByDate; + } + public void setSecondaryPhoneVerifyByDate(Date date) { verifySecondaryPhoneByDate = date; } + + public boolean shouldForcePin() { + return shouldForceRecoveryLogin() && pin != null && pin.length() > 0; + } + + public boolean shouldForcePassword() { + return shouldForceRecoveryLogin() && !shouldForcePin(); + } + + private boolean shouldForceRecoveryLogin() { + Date pinDate = getLastPinDate(); + boolean forcePin = pinDate == null; + if (!forcePin) { + //See how much time has passed since last PIN login + long millis = (new Date()).getTime() - pinDate.getTime(); + long days = TimeUnit.DAYS.convert(millis, TimeUnit.MILLISECONDS); + forcePin = days >= 7; + } + + return forcePin; + } + + public boolean shouldRequireSecondaryPhoneVerification() { + if(secondaryPhoneVerified) { + return false; + } + + return (new Date()).after(verifySecondaryPhoneByDate); + } + + public void updateConnectToken(String token, Date expirationDate) { + connectToken = token; + connectTokenExpiration = expirationDate; + } + + public String getConnectToken() { + return connectToken; + } + + public Date getConnectTokenExpiration() { + return connectTokenExpiration; + } + + public static ConnectUserRecord fromV5(ConnectUserRecordV5 oldRecord) { + ConnectUserRecord newRecord = new ConnectUserRecord(); + + newRecord.userId = oldRecord.getUserId(); + newRecord.password = oldRecord.getPassword(); + newRecord.name = oldRecord.getName(); + newRecord.primaryPhone = oldRecord.getPrimaryPhone(); + newRecord.alternatePhone = oldRecord.getAlternatePhone(); + newRecord.registrationPhase = oldRecord.getRegistrationPhase(); + newRecord.lastPasswordDate = oldRecord.getLastPasswordDate(); + newRecord.connectToken = oldRecord.getConnectToken(); + newRecord.connectTokenExpiration = oldRecord.getConnectTokenExpiration(); + newRecord.secondaryPhoneVerified = true; + + return newRecord; + } +} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java b/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java new file mode 100644 index 000000000..990742edd --- /dev/null +++ b/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java @@ -0,0 +1,83 @@ +package org.commcare.android.database.connect.models; +import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.ConnectConstants; +import org.commcare.models.framework.Persisting; +import org.commcare.modern.database.Table; + +import java.util.Date; + +/** + * DB model for a ConnectID user and their info + * + * @author dviggiano + */ +@Table(ConnectUserRecord.STORAGE_KEY) +public class ConnectUserRecordV5 extends Persisted { + /** + * Name of database that stores Connect user records + */ + public static final String STORAGE_KEY = "user_info"; + + @Persisting(1) + private String userId; + + @Persisting(2) + private String password; + + @Persisting(3) + private String name; + + @Persisting(4) + private String primaryPhone; + + @Persisting(5) + private String alternatePhone; + + @Persisting(6) + private int registrationPhase; + + @Persisting(7) + private Date lastPasswordDate; + + @Persisting(value = 8, nullable = true) + private String connectToken; + + @Persisting(value = 9, nullable = true) + private Date connectTokenExpiration; + + public ConnectUserRecordV5() { + registrationPhase = ConnectConstants.CONNECT_NO_ACTIVITY; + lastPasswordDate = new Date(); + connectTokenExpiration = new Date(); + } + + public String getUserId() {return userId; } + public String getPrimaryPhone() { + return primaryPhone; + } + public String getAlternatePhone() { + return alternatePhone; + } + public String getPassword() { + return password; + } + public void setPassword(String password) { + this.password = password; + } + public String getName() { + return name; + } + public void setName(String name) { + this.name = name; + } + public int getRegistrationPhase() { return registrationPhase; } + public Date getLastPasswordDate() { + return lastPasswordDate; + } + public String getConnectToken() { + return connectToken; + } + public Date getConnectTokenExpiration() { + return connectTokenExpiration; + } +} diff --git a/app/src/org/commcare/connect/ConnectConstants.java b/app/src/org/commcare/connect/ConnectConstants.java new file mode 100644 index 000000000..9f0d16925 --- /dev/null +++ b/app/src/org/commcare/connect/ConnectConstants.java @@ -0,0 +1,17 @@ +package org.commcare.connect; + +/** + * Constants used for ConnectID, i.e. when passing params to activities + * + * @author dviggiano + */ +public class ConnectConstants { + public static final int ConnectIdTaskIdOffset = 1000; + public static final String USERNAME = "USERNAME"; + public static final String PASSWORD = "PASSWORD"; + public static final String PIN = "PIN"; + public static final String NAME = "NAME"; + public static final String PHONE = "PHONE"; + public static final String ALT_PHONE = "ALT_PHONE"; + public final static int CONNECT_NO_ACTIVITY = ConnectConstants.ConnectIdTaskIdOffset; +} diff --git a/app/src/org/commcare/connect/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/ConnectDatabaseHelper.java new file mode 100644 index 000000000..c62192f49 --- /dev/null +++ b/app/src/org/commcare/connect/ConnectDatabaseHelper.java @@ -0,0 +1,827 @@ +package org.commcare.connect; + +import android.content.Context; +import android.os.Build; +import android.widget.Toast; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.commcare.CommCareApplication; +import org.commcare.android.database.connect.models.ConnectAppRecord; +import org.commcare.android.database.connect.models.ConnectJobAssessmentRecord; +import org.commcare.android.database.connect.models.ConnectJobDeliveryRecord; +import org.commcare.android.database.connect.models.ConnectJobLearningRecord; +import org.commcare.android.database.connect.models.ConnectJobPaymentRecord; +import org.commcare.android.database.connect.models.ConnectJobRecord; +import org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecord; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.android.database.connect.models.ConnectPaymentUnitRecord; +import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.android.database.global.models.ConnectKeyRecord; +import org.commcare.dalvik.R; +import org.commcare.models.database.AndroidDbHelper; +import org.commcare.models.database.SqlStorage; +import org.commcare.models.database.connect.DatabaseConnectOpenHelper; +import org.commcare.models.database.user.UserSandboxUtils; +import org.commcare.modern.database.Table; +import org.commcare.util.Base64; +import org.commcare.utils.EncryptionUtils; +import org.javarosa.core.services.Logger; +import org.javarosa.core.services.storage.Persistable; + +import java.util.ArrayList; +import java.util.Comparator; +import java.util.Date; +import java.util.List; +import java.util.Objects; +import java.util.Vector; + +/** + * Helper class for accessing the Connect DB + * + * @author dviggiano + */ +public class ConnectDatabaseHelper { + private static final Object connectDbHandleLock = new Object(); + private static SQLiteDatabase connectDatabase; + private static boolean dbBroken = false; + + public static void handleReceivedDbPassphrase(Context context, String remotePassphrase) { + storeConnectDbPassphrase(context, remotePassphrase, false); + + try { + String localPassphrase = getConnectDbEncodedPassphrase(context, true); + + if (!remotePassphrase.equals(localPassphrase)) { + DatabaseConnectOpenHelper.rekeyDB(connectDatabase, remotePassphrase); + storeConnectDbPassphrase(context, remotePassphrase, true); + } + } catch (Exception e) { + Logger.exception("Handling received DB passphrase", e); + handleCorruptDb(context); + } + } + + private static byte[] getConnectDbPassphrase(Context context) { + try { + ConnectKeyRecord record = getKeyRecord(true); + if (record != null) { + return EncryptionUtils.decryptFromBase64String(context, record.getEncryptedPassphrase()); + } + + //LEGACY: If we get here, the passphrase hasn't been created yet so use a local one + byte[] passphrase = EncryptionUtils.generatePassphrase(); + storeConnectDbPassphrase(context, passphrase, true); + + return passphrase; + } catch (Exception e) { + Logger.exception("Getting DB passphrase", e); + throw new RuntimeException(e); + } + } + + public static String getConnectDbEncodedPassphrase(Context context, boolean local) { + try { + ConnectKeyRecord record = getKeyRecord(local); + if (record != null) { + return Base64.encode(EncryptionUtils.decryptFromBase64String(context, record.getEncryptedPassphrase())); + } + } catch (Exception e) { + Logger.exception("Getting DB passphrase", e); + } + + return null; + } + + private static ConnectKeyRecord getKeyRecord(boolean local) { + for (ConnectKeyRecord r : CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class)) { + if (r.getIsLocal() == local) { + return r; + } + } + + return null; + } + + public static void storeConnectDbPassphrase(Context context, String base64EncodedPassphrase, boolean isLocal) { + try { + byte[] bytes = Base64.decode(base64EncodedPassphrase); + storeConnectDbPassphrase(context, bytes, isLocal); + } catch (Exception e) { + Logger.exception("Encoding DB passphrase to Base64", e); + throw new RuntimeException(e); + } + } + + public static void storeConnectDbPassphrase(Context context, byte[] passphrase, boolean isLocal) { + try { + String encoded = EncryptionUtils.encryptToBase64String(context, passphrase); + + ConnectKeyRecord record = getKeyRecord(isLocal); + if (record == null) { + record = new ConnectKeyRecord(encoded, isLocal); + } else { + record.setEncryptedPassphrase(encoded); + } + + CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).write(record); + } catch (Exception e) { + Logger.exception("Storing DB passphrase", e); + throw new RuntimeException(e); + } + } + + public static boolean dbExists(Context context) { + return DatabaseConnectOpenHelper.dbExists(context); + } + + public static boolean isDbBroken() { + return dbBroken; + } + + private static SqlStorage getConnectStorage(Context context, Class c) { + return new SqlStorage<>(c.getAnnotation(Table.class).value(), c, new AndroidDbHelper(context) { + @Override + public SQLiteDatabase getHandle() { + synchronized (connectDbHandleLock) { + if (!dbBroken && (connectDatabase == null || !connectDatabase.isOpen())) { + try { + byte[] passphrase = getConnectDbPassphrase(context); + + DatabaseConnectOpenHelper helper = new DatabaseConnectOpenHelper(this.c); + + String remotePassphrase = getConnectDbEncodedPassphrase(context, false); + String localPassphrase = getConnectDbEncodedPassphrase(context, true); + if (remotePassphrase != null && remotePassphrase.equals(localPassphrase)) { + //Using the UserSandboxUtils helper method to align with other code + connectDatabase = helper.getWritableDatabase(UserSandboxUtils.getSqlCipherEncodedKey(passphrase)); + } else { + //LEGACY: Used to open the DB using the byte[], not String overload + connectDatabase = helper.getWritableDatabase(passphrase); + } + } catch (Exception e) { + //Flag the DB as broken if we hit an error opening it (usually means corrupted or bad encryption) + dbBroken = true; + Logger.log("DB ERROR", "Connect DB is corrupt"); + } + } + return connectDatabase; + } + } + }); + } + + public static void teardown() { + synchronized (connectDbHandleLock) { + if (connectDatabase != null && connectDatabase.isOpen()) { + connectDatabase.close(); + connectDatabase = null; + } + } + } + + public static void handleCorruptDb(Context context) { + ConnectDatabaseHelper.forgetUser(context); + Toast.makeText(context, context.getString(R.string.connect_db_corrupt), Toast.LENGTH_LONG).show(); + } + + public static ConnectUserRecord getUser(Context context) { + ConnectUserRecord user = null; + if (dbExists(context)) { + try { + for (ConnectUserRecord r : getConnectStorage(context, ConnectUserRecord.class)) { + user = r; + break; + } + } catch (Exception e) { + dbBroken = true; + } + } + + return user; + } + + public static void storeUser(Context context, ConnectUserRecord user) { + getConnectStorage(context, ConnectUserRecord.class).write(user); + } + + public static void forgetUser(Context context) { + DatabaseConnectOpenHelper.deleteDb(context); + CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); + dbBroken = false; + } + + public static ConnectLinkedAppRecord getAppData(Context context, String appId, String username) { + Vector records = getConnectStorage(context, ConnectLinkedAppRecord.class) + .getRecordsForValues( + new String[]{ConnectLinkedAppRecord.META_APP_ID, ConnectLinkedAppRecord.META_USER_ID}, + new Object[]{appId, username}); + return records.isEmpty() ? null : records.firstElement(); + } + + public static void deleteAppData(Context context, ConnectLinkedAppRecord record) { + SqlStorage storage = getConnectStorage(context, ConnectLinkedAppRecord.class); + storage.remove(record); + } + + public static ConnectLinkedAppRecord storeApp(Context context, String appId, String userId, boolean connectIdLinked, String passwordOrPin, boolean workerLinked, boolean localPassphrase) { + ConnectLinkedAppRecord record = getAppData(context, appId, userId); + if (record == null) { + record = new ConnectLinkedAppRecord(appId, userId, connectIdLinked, passwordOrPin); + } else if (!record.getPassword().equals(passwordOrPin)) { + record.setPassword(passwordOrPin); + } + + record.setConnectIdLinked(connectIdLinked); + record.setIsUsingLocalPassphrase(localPassphrase); + + if (workerLinked) { + //If passed in false, we'll leave the setting unchanged + record.setWorkerLinked(true); + } + + storeApp(context, record); + + return record; + } + + public static void storeApp(Context context, ConnectLinkedAppRecord record) { + getConnectStorage(context, ConnectLinkedAppRecord.class).write(record); + } + + public static void storeHqToken(Context context, String appId, String userId, String token, Date expiration) { + ConnectLinkedAppRecord record = getAppData(context, appId, userId); + if (record == null) { + record = new ConnectLinkedAppRecord(appId, userId, false, ""); + } + + record.updateHqToken(token, expiration); + + getConnectStorage(context, ConnectLinkedAppRecord.class).write(record); + } + + public static void setRegistrationPhase(Context context, int phase) { + ConnectUserRecord user = getUser(context); + if (user != null) { + user.setRegistrationPhase(phase); + storeUser(context, user); + } + } + + public static Date getLastJobsUpdate(Context context) { + Date lastDate = null; + for (ConnectJobRecord job : getJobs(context, -1, null)) { + if (lastDate == null || lastDate.before(job.getLastUpdate())) { + lastDate = job.getLastUpdate(); + } + } + + return lastDate != null ? lastDate : new Date(); + } + + public static void updateJobLearnProgress(Context context, ConnectJobRecord job) { + SqlStorage jobStorage = getConnectStorage(context, ConnectJobRecord.class); + + job.setLastLearnUpdate(new Date()); + + //Check for existing DB ID + Vector existingJobs = + jobStorage.getRecordsForValues( + new String[]{ConnectJobRecord.META_JOB_ID}, + new Object[]{job.getJobId()}); + + if (existingJobs.size() > 0) { + ConnectJobRecord existing = existingJobs.get(0); + existing.setComletedLearningModules(job.getCompletedLearningModules()); + existing.setLastUpdate(new Date()); + jobStorage.write(existing); + + //Also update learning and assessment records + storeLearningRecords(context, job.getLearnings(), job.getJobId(), true); + storeAssessments(context, job.getAssessments(), job.getJobId(), true); + } + } + + public static void upsertJob(Context context, ConnectJobRecord job) { + List list = new ArrayList<>(); + list.add(job); + storeJobs(context, list, false); + } + + public static int storeJobs(Context context, List jobs, boolean pruneMissing) { + SqlStorage jobStorage = getConnectStorage(context, ConnectJobRecord.class); + SqlStorage appInfoStorage = getConnectStorage(context, ConnectAppRecord.class); + SqlStorage moduleStorage = getConnectStorage(context, + ConnectLearnModuleSummaryRecord.class); + SqlStorage paymentUnitStorage = getConnectStorage(context, + ConnectPaymentUnitRecord.class); + + List existingList = getJobs(context, -1, jobStorage); + + //Delete jobs that are no longer available + Vector jobIdsToDelete = new Vector<>(); + Vector appInfoIdsToDelete = new Vector<>(); + Vector moduleIdsToDelete = new Vector<>(); + Vector paymentUnitIdsToDelete = new Vector<>(); + //Note when jobs are found in the loop below, we retrieve the DB ID into the incoming job + for (ConnectJobRecord existing : existingList) { + boolean stillExists = false; + for (ConnectJobRecord incoming : jobs) { + if (existing.getJobId() == incoming.getJobId()) { + incoming.setID(existing.getID()); + stillExists = true; + break; + } + } + + if (!stillExists && pruneMissing) { + //Mark the job, learn/deliver app infos, and learn module infos for deletion + //Remember their IDs so we can delete them all at once after the loop + jobIdsToDelete.add(existing.getID()); + + appInfoIdsToDelete.add(existing.getLearnAppInfo().getID()); + appInfoIdsToDelete.add(existing.getDeliveryAppInfo().getID()); + + for (ConnectLearnModuleSummaryRecord module : existing.getLearnAppInfo().getLearnModules()) { + moduleIdsToDelete.add(module.getID()); + } + + for (ConnectPaymentUnitRecord record : existing.getPaymentUnits()) { + paymentUnitIdsToDelete.add(record.getID()); + } + } + } + + if (pruneMissing) { + jobStorage.removeAll(jobIdsToDelete); + appInfoStorage.removeAll(appInfoIdsToDelete); + moduleStorage.removeAll(moduleIdsToDelete); + paymentUnitStorage.removeAll(paymentUnitIdsToDelete); + } + + //Now insert/update jobs + int newJobs = 0; + for (ConnectJobRecord incomingJob : jobs) { + incomingJob.setLastUpdate(new Date()); + + if (incomingJob.getID() <= 0) { + newJobs++; + if (incomingJob.getStatus() == ConnectJobRecord.STATUS_AVAILABLE) { + incomingJob.setStatus(ConnectJobRecord.STATUS_AVAILABLE_NEW); + } + } + + //Now insert/update the job + jobStorage.write(incomingJob); + + //Next, store the learn and delivery app info + incomingJob.getLearnAppInfo().setJobId(incomingJob.getJobId()); + incomingJob.getDeliveryAppInfo().setJobId(incomingJob.getJobId()); + Vector records = appInfoStorage.getRecordsForValues( + new String[]{ConnectAppRecord.META_JOB_ID}, + new Object[]{incomingJob.getJobId()}); + + for (ConnectAppRecord existing : records) { + ConnectAppRecord incomingAppInfo = existing.getIsLearning() ? incomingJob.getLearnAppInfo() : incomingJob.getDeliveryAppInfo(); + incomingAppInfo.setID(existing.getID()); + } + + incomingJob.getLearnAppInfo().setLastUpdate(new Date()); + appInfoStorage.write(incomingJob.getLearnAppInfo()); + + incomingJob.getDeliveryAppInfo().setLastUpdate(new Date()); + appInfoStorage.write(incomingJob.getDeliveryAppInfo()); + + //Store the info for the learn modules + //Delete modules that are no longer available + Vector foundIndexes = new Vector<>(); + //Note: Reusing this vector + moduleIdsToDelete.clear(); + Vector existingLearnModules = + moduleStorage.getRecordsForValues( + new String[]{ConnectLearnModuleSummaryRecord.META_JOB_ID}, + new Object[]{incomingJob.getJobId()}); + for (ConnectLearnModuleSummaryRecord existing : existingLearnModules) { + boolean stillExists = false; + if (!foundIndexes.contains(existing.getModuleIndex())) { + for (ConnectLearnModuleSummaryRecord incoming : + incomingJob.getLearnAppInfo().getLearnModules()) { + if (Objects.equals(existing.getModuleIndex(), incoming.getModuleIndex())) { + incoming.setID(existing.getID()); + stillExists = true; + foundIndexes.add(existing.getModuleIndex()); + + break; + } + } + } + + if (!stillExists) { + moduleIdsToDelete.add(existing.getID()); + } + } + + moduleStorage.removeAll(moduleIdsToDelete); + + for (ConnectLearnModuleSummaryRecord module : incomingJob.getLearnAppInfo().getLearnModules()) { + module.setJobId(incomingJob.getJobId()); + module.setLastUpdate(new Date()); + moduleStorage.write(module); + } + + + //Store the payment units + //Delete payment units that are no longer available + foundIndexes = new Vector<>(); + //Note: Reusing this vector + paymentUnitIdsToDelete.clear(); + Vector existingPaymentUnits = + paymentUnitStorage.getRecordsForValues( + new String[]{ConnectPaymentUnitRecord.META_JOB_ID}, + new Object[]{incomingJob.getJobId()}); + for (ConnectPaymentUnitRecord existing : existingPaymentUnits) { + boolean stillExists = false; + if (!foundIndexes.contains(existing.getUnitId())) { + for (ConnectPaymentUnitRecord incoming : + incomingJob.getPaymentUnits()) { + if (Objects.equals(existing.getUnitId(), incoming.getUnitId())) { + incoming.setID(existing.getID()); + stillExists = true; + foundIndexes.add(existing.getUnitId()); + + break; + } + } + } + + if (!stillExists) { + paymentUnitIdsToDelete.add(existing.getID()); + } + } + + paymentUnitStorage.removeAll(paymentUnitIdsToDelete); + + for (ConnectPaymentUnitRecord record : incomingJob.getPaymentUnits()) { + record.setJobId(incomingJob.getJobId()); + paymentUnitStorage.write(record); + } + } + + return newJobs; + } + + public static void storeLearningRecords(Context context, List learnings, int jobId, boolean pruneMissing) { + SqlStorage storage = getConnectStorage(context, ConnectJobLearningRecord.class); + + List existingList = getLearnings(context, jobId, storage); + + //Delete records that are no longer available + Vector recordIdsToDelete = new Vector<>(); + for (ConnectJobLearningRecord existing : existingList) { + boolean stillExists = false; + for (ConnectJobLearningRecord incoming : learnings) { + if (existing.getModuleId() == incoming.getModuleId() && existing.getDate().equals(incoming.getDate())) { + incoming.setID(existing.getID()); + stillExists = true; + break; + } + } + + if (!stillExists && pruneMissing) { + //Mark the record for deletion + //Remember the ID so we can delete them all at once after the loop + recordIdsToDelete.add(existing.getID()); + } + } + + if (pruneMissing) { + storage.removeAll(recordIdsToDelete); + } + + //Now insert/update records + for (ConnectJobLearningRecord incomingRecord : learnings) { + incomingRecord.setLastUpdate(new Date()); + + //Now insert/update the record + storage.write(incomingRecord); + } + } + + public static void storeAssessments(Context context, List assessments, int jobId, boolean pruneMissing) { + SqlStorage storage = getConnectStorage(context, ConnectJobAssessmentRecord.class); + + List existingList = getAssessments(context, jobId, storage); + + //Delete records that are no longer available + Vector recordIdsToDelete = new Vector<>(); + for (ConnectJobAssessmentRecord existing : existingList) { + boolean stillExists = false; + for (ConnectJobAssessmentRecord incoming : assessments) { + if (existing.getScore() == incoming.getScore() && existing.getDate().equals(incoming.getDate())) { + incoming.setID(existing.getID()); + stillExists = true; + break; + } + } + + if (!stillExists && pruneMissing) { + //Mark the record for deletion + //Remember the ID so we can delete them all at once after the loop + recordIdsToDelete.add(existing.getID()); + } + } + + if (pruneMissing) { + storage.removeAll(recordIdsToDelete); + } + + //Now insert/update records + for (ConnectJobAssessmentRecord incomingRecord : assessments) { + incomingRecord.setLastUpdate(new Date()); + + //Now insert/update the record + storage.write(incomingRecord); + } + } + + public static void storeDeliveries(Context context, List deliveries, int jobId, boolean pruneMissing) { + SqlStorage storage = getConnectStorage(context, ConnectJobDeliveryRecord.class); + + List existingList = getDeliveries(context, jobId, storage); + + //Delete jobs that are no longer available + Vector recordIdsToDelete = new Vector<>(); + for (ConnectJobDeliveryRecord existing : existingList) { + boolean stillExists = false; + for (ConnectJobDeliveryRecord incoming : deliveries) { + if (existing.getDeliveryId() == incoming.getDeliveryId()) { + incoming.setID(existing.getID()); + stillExists = true; + break; + } + } + + if (!stillExists && pruneMissing) { + //Mark the delivery for deletion + //Remember the ID so we can delete them all at once after the loop + recordIdsToDelete.add(existing.getID()); + } + } + + if (pruneMissing) { + storage.removeAll(recordIdsToDelete); + } + + //Now insert/update deliveries + for (ConnectJobDeliveryRecord incomingRecord : deliveries) { + incomingRecord.setLastUpdate(new Date()); + + //Now insert/update the delivery + storage.write(incomingRecord); + } + } + + public static void storePayment(Context context, ConnectJobPaymentRecord payment) { + SqlStorage storage = getConnectStorage(context, ConnectJobPaymentRecord.class); + storage.write(payment); + } + + public static void storePayments(Context context, List payments, int jobId, boolean pruneMissing) { + SqlStorage storage = getConnectStorage(context, ConnectJobPaymentRecord.class); + + List existingList = getPayments(context, jobId, storage); + + //Delete payments that are no longer available + Vector recordIdsToDelete = new Vector<>(); + for (ConnectJobPaymentRecord existing : existingList) { + boolean stillExists = false; + for (ConnectJobPaymentRecord incoming : payments) { + if (existing.getDate() == incoming.getDate()) { + incoming.setID(existing.getID()); + stillExists = true; + break; + } + } + + if (!stillExists && pruneMissing) { + //Mark the delivery for deletion + //Remember the ID so we can delete them all at once after the loop + recordIdsToDelete.add(existing.getID()); + } + } + + if (pruneMissing) { + storage.removeAll(recordIdsToDelete); + } + + //Now insert/update deliveries + for (ConnectJobPaymentRecord incomingRecord : payments) { + storage.write(incomingRecord); + } + } + + public static ConnectAppRecord getAppRecord(Context context, String appId) { + Vector records = getConnectStorage(context, ConnectAppRecord.class).getRecordsForValues( + new String[]{ConnectAppRecord.META_APP_ID}, + new Object[]{appId}); + return records.isEmpty() ? null : records.firstElement(); + } + + public static ConnectJobRecord getJob(Context context, int jobId) { + Vector jobs = getConnectStorage(context, ConnectJobRecord.class).getRecordsForValues( + new String[]{ConnectJobRecord.META_JOB_ID}, + new Object[]{jobId}); + + populateJobs(context, jobs); + + return jobs.isEmpty() ? null : jobs.firstElement(); + } + + public static List getJobs(Context context, int status, SqlStorage jobStorage) { + if (jobStorage == null) { + jobStorage = getConnectStorage(context, ConnectJobRecord.class); + } + + Vector jobs; + if (status > 0) { + jobs = jobStorage.getRecordsForValues( + new String[]{ConnectJobRecord.META_STATUS}, + new Object[]{status}); + } else { + jobs = jobStorage.getRecordsForValues(new String[]{}, new Object[]{}); + } + + populateJobs(context, jobs); + + return new ArrayList<>(jobs); + } + + private static void populateJobs(Context context, Vector jobs) { + SqlStorage appInfoStorage = getConnectStorage(context, ConnectAppRecord.class); + SqlStorage moduleStorage = getConnectStorage(context, ConnectLearnModuleSummaryRecord.class); + SqlStorage deliveryStorage = getConnectStorage(context, ConnectJobDeliveryRecord.class); + SqlStorage paymentStorage = getConnectStorage(context, ConnectJobPaymentRecord.class); + SqlStorage learningStorage = getConnectStorage(context, ConnectJobLearningRecord.class); + SqlStorage assessmentStorage = getConnectStorage(context, ConnectJobAssessmentRecord.class); + SqlStorage paymentUnitStorage = getConnectStorage(context, ConnectPaymentUnitRecord.class); + for (ConnectJobRecord job : jobs) { + //Retrieve learn and delivery app info + Vector existingAppInfos = appInfoStorage.getRecordsForValues( + new String[]{ConnectAppRecord.META_JOB_ID}, + new Object[]{job.getJobId()}); + + for (ConnectAppRecord info : existingAppInfos) { + if (info.getIsLearning()) { + job.setLearnAppInfo(info); + } else { + job.setDeliveryAppInfo(info); + } + } + + //Retrieve learn modules + Vector existingModules = moduleStorage.getRecordsForValues( + new String[]{ConnectLearnModuleSummaryRecord.META_JOB_ID}, + new Object[]{job.getJobId()}); + + List modules = new ArrayList<>(existingModules); + if (Build.VERSION.SDK_INT >= Build.VERSION_CODES.N) { + modules.sort(Comparator.comparingInt(ConnectLearnModuleSummaryRecord::getModuleIndex)); + } + //else { + //TODO: Brute force sort + //} + + if (job.getLearnAppInfo() != null) { + job.getLearnAppInfo().setLearnModules(modules); + } + + //Retrieve payment units + job.setPaymentUnits(paymentUnitStorage.getRecordsForValues( + new String[]{ConnectPaymentUnitRecord.META_JOB_ID}, + new Object[]{job.getJobId()})); + + //Retrieve related data + job.setDeliveries(getDeliveries(context, job.getJobId(), deliveryStorage)); + job.setPayments(getPayments(context, job.getJobId(), paymentStorage)); + job.setLearnings(getLearnings(context, job.getJobId(), learningStorage)); + job.setAssessments(getAssessments(context, job.getJobId(), assessmentStorage)); + } + } + + public static List getAvailableJobs(Context context) { + return getAvailableJobs(context, null); + } + + public static List getAvailableJobs(Context context, SqlStorage jobStorage) { + List jobs = getJobs(context, ConnectJobRecord.STATUS_AVAILABLE, jobStorage); + jobs.addAll(getJobs(context, ConnectJobRecord.STATUS_AVAILABLE_NEW, jobStorage)); + + List filtered = new ArrayList<>(); + for (ConnectJobRecord record : jobs) { + if (!record.isFinished()) { + filtered.add(record); + } + } + + return filtered; + } + + public static List getTrainingJobs(Context context) { + return getTrainingJobs(context, null); + } + + public static List getTrainingJobs(Context context, SqlStorage jobStorage) { + List jobs = getJobs(context, ConnectJobRecord.STATUS_LEARNING, jobStorage); + + List filtered = new ArrayList<>(); + for (ConnectJobRecord record : jobs) { + if (!record.isFinished()) { + filtered.add(record); + } + } + + return filtered; + } + + public static List getDeliveryJobs(Context context) { + return getDeliveryJobs(context, null); + } + + public static List getDeliveryJobs(Context context, SqlStorage jobStorage) { + List jobs = getJobs(context, ConnectJobRecord.STATUS_DELIVERING, jobStorage); + + List filtered = new ArrayList<>(); + for (ConnectJobRecord record : jobs) { + if (!record.isFinished() && !record.getIsUserSuspended()) { + filtered.add(record); + } + } + + return filtered; + } + + public static List getFinishedJobs(Context context) { + return getFinishedJobs(context, null); + } + + public static List getFinishedJobs(Context context, SqlStorage jobStorage) { + List jobs = getJobs(context, -1, jobStorage); + + List filtered = new ArrayList<>(); + for (ConnectJobRecord record : jobs) { + if (record.isFinished() || record.getIsUserSuspended()) { + filtered.add(record); + } + } + + return filtered; + } + + public static List getDeliveries(Context context, int jobId, SqlStorage deliveryStorage) { + if (deliveryStorage == null) { + deliveryStorage = getConnectStorage(context, ConnectJobDeliveryRecord.class); + } + + Vector deliveries = deliveryStorage.getRecordsForValues( + new String[]{ConnectJobDeliveryRecord.META_JOB_ID}, + new Object[]{jobId}); + + return new ArrayList<>(deliveries); + } + + public static List getPayments(Context context, int jobId, SqlStorage paymentStorage) { + if (paymentStorage == null) { + paymentStorage = getConnectStorage(context, ConnectJobPaymentRecord.class); + } + + Vector payments = paymentStorage.getRecordsForValues( + new String[]{ConnectJobPaymentRecord.META_JOB_ID}, + new Object[]{jobId}); + + return new ArrayList<>(payments); + } + + public static List getLearnings(Context context, int jobId, SqlStorage learningStorage) { + if (learningStorage == null) { + learningStorage = getConnectStorage(context, ConnectJobLearningRecord.class); + } + + Vector learnings = learningStorage.getRecordsForValues( + new String[]{ConnectJobLearningRecord.META_JOB_ID}, + new Object[]{jobId}); + + return new ArrayList<>(learnings); + } + + public static List getAssessments(Context context, int jobId, SqlStorage assessmentStorage) { + if (assessmentStorage == null) { + assessmentStorage = getConnectStorage(context, ConnectJobAssessmentRecord.class); + } + + Vector assessments = assessmentStorage.getRecordsForValues( + new String[]{ConnectJobAssessmentRecord.META_JOB_ID}, + new Object[]{jobId}); + + return new ArrayList<>(assessments); + } +} diff --git a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java new file mode 100644 index 000000000..5e3c3e16a --- /dev/null +++ b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java @@ -0,0 +1,504 @@ +package org.commcare.connect.network; + +import android.content.Context; +import android.net.ConnectivityManager; +import android.net.Network; +import android.net.NetworkCapabilities; +import android.net.NetworkInfo; +import android.os.Build; +import android.os.Handler; +import android.widget.Toast; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.ImmutableMultimap; +import com.google.common.collect.Multimap; +import com.google.gson.Gson; + +import org.commcare.CommCareApplication; +import org.commcare.activities.CommCareActivity; +import org.commcare.core.interfaces.HttpResponseProcessor; +import org.commcare.core.network.AuthInfo; +import org.commcare.core.network.HTTPMethod; +import org.commcare.core.network.ModernHttpRequester; +import org.commcare.dalvik.R; +import org.commcare.interfaces.ConnectorWithHttpResponseProcessor; +import org.commcare.tasks.ModernHttpTask; +import org.commcare.tasks.templates.CommCareTask; +import org.commcare.utils.CrashUtil; +import org.javarosa.core.services.Logger; + +import java.io.IOException; +import java.io.InputStream; +import java.net.UnknownHostException; +import java.text.DateFormat; +import java.text.ParseException; +import java.text.SimpleDateFormat; +import java.util.Date; +import java.util.HashMap; +import java.util.Locale; +import java.util.Map; +import java.util.TimeZone; + +import okhttp3.MediaType; +import okhttp3.RequestBody; +import okhttp3.ResponseBody; +import retrofit2.Response; + +/** + * Helper class for making network calls related to Connect + * Calls may go to ConnectID server or HQ server (for SSO) + * + * @author dviggiano + */ +public class ConnectNetworkHelper { + /** + * Helper class to hold the results of a network request + */ + public static class PostResult { + public final int responseCode; + public final InputStream responseStream; + public final IOException e; + + public PostResult(int responseCode, InputStream responseStream, IOException e) { + this.responseCode = responseCode; + this.responseStream = responseStream; + this.e = e; + } + } + + private String callInProgress = null; + + private ConnectNetworkHelper() { + //Private constructor for singleton + } + + private static class Loader { + static final ConnectNetworkHelper INSTANCE = new ConnectNetworkHelper(); + } + + private static ConnectNetworkHelper getInstance() { + return Loader.INSTANCE; + } + + private static final DateFormat dateFormat = new SimpleDateFormat("yyyy-MM-dd", Locale.getDefault()); + public static Date parseDate(String dateStr) throws ParseException { + Date issueDate=dateFormat.parse(dateStr); + return issueDate; + } + + private static final SimpleDateFormat utcFormat = new SimpleDateFormat("yyyy-MM-dd'T'HH:mm:ss.SSS'Z'", Locale.getDefault()); + + public static Date convertUTCToDate(String utcDateString) throws ParseException { + utcFormat.setTimeZone(TimeZone.getTimeZone("UTC")); + + return utcFormat.parse(utcDateString); + } + + public static Date convertDateToLocal(Date utcDate) { + utcFormat.setTimeZone(TimeZone.getDefault()); + + try { + String localDateString = utcFormat.format(utcDate); + return utcFormat.parse(localDateString); + } + catch (ParseException e) { + return utcDate; + } + } + + public static String getCallInProgress() { + return getInstance().callInProgress; + } + + public static boolean isBusy() { + return getCallInProgress() != null; + } + + private static void setCallInProgress(String call) { + getInstance().callInProgress = call; + } + + public static boolean isOnline(Context context) { + ConnectivityManager manager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + if(Build.VERSION.SDK_INT >= Build.VERSION_CODES.M) { + Network network = manager.getActiveNetwork(); + if(network == null) { + return false; + } + + NetworkCapabilities capabilities = manager.getNetworkCapabilities(network); + return capabilities != null && capabilities.hasTransport(NetworkCapabilities.TRANSPORT_WIFI) || capabilities.hasTransport(NetworkCapabilities.TRANSPORT_CELLULAR); + } else { + NetworkInfo info = manager.getActiveNetworkInfo(); + return info != null && info.isConnected(); + } + } + + public static boolean post(Context context, String url, String version, AuthInfo authInfo, + HashMap params, boolean useFormEncoding, + boolean background, IApiCallback handler) { + return getInstance().postInternal(context, url, version, authInfo, params, useFormEncoding, + background, handler); + } + + public static boolean get(Context context, String url, String version, AuthInfo authInfo, + Multimap params, boolean background, IApiCallback handler) { + return getInstance().getInternal(context, url, version, authInfo, params, background, handler); + } + + private static void addVersionHeader(HashMap headers, String version) { + if(version != null) { + headers.put("Accept", "application/json;version=" + version); + } + } + + public static PostResult postSync(Context context, String url, String version, AuthInfo authInfo, + HashMap params, boolean useFormEncoding, + boolean background) { + ConnectNetworkHelper instance = getInstance(); + + if(!background) { + setCallInProgress(url); + instance.showProgressDialog(context); + } + + try { + HashMap headers = new HashMap<>(); + RequestBody requestBody; + + if (useFormEncoding) { + Multimap multimap = ArrayListMultimap.create(); + for (Map.Entry entry : params.entrySet()) { + multimap.put(entry.getKey(), entry.getValue()); + } + + requestBody = ModernHttpRequester.getPostBody(multimap); + headers = getContentHeadersForXFormPost(requestBody); + } else { + Gson gson = new Gson(); + String json = gson.toJson(params); + requestBody = RequestBody.create(MediaType.parse("application/json"), json); + } + + addVersionHeader(headers, version); + + ModernHttpRequester requester = CommCareApplication.instance().buildHttpRequester( + context, + url, + ImmutableMultimap.of(), + headers, + requestBody, + null, + HTTPMethod.POST, + authInfo, + null, + false); + + int responseCode = -1; + InputStream stream = null; + IOException exception = null; + try { + Response response = requester.makeRequest(); + responseCode = response.code(); + if (response.isSuccessful()) { + stream = requester.getResponseStream(response); + } else if (response.errorBody() != null) { + String error = response.errorBody().string(); + Logger.log("Netowrk Error", error); + } + } catch (IOException e) { + exception = e; + } + + instance.onFinishProcessing(context, background); + + return new PostResult(responseCode, stream, exception); + } + catch(Exception e) { + if(!background) { + setCallInProgress(null); + } + return new PostResult(-1, null, null); + } + } + + private boolean postInternal(Context context, String url, String version, AuthInfo authInfo, + HashMap params, boolean useFormEncoding, + boolean background, IApiCallback handler) { + if(!background) { + if (isBusy()) { + return false; + } + setCallInProgress(url); + + showProgressDialog(context); + } + + HashMap headers = new HashMap<>(); + RequestBody requestBody; + + if (useFormEncoding) { + Multimap multimap = ArrayListMultimap.create(); + for (Map.Entry entry : params.entrySet()) { + multimap.put(entry.getKey(), entry.getValue()); + } + + requestBody = ModernHttpRequester.getPostBody(multimap); + headers = getContentHeadersForXFormPost(requestBody); + } else { + Gson gson = new Gson(); + String json = gson.toJson(params); + requestBody = RequestBody.create(MediaType.parse("application/json"), json); + } + + addVersionHeader(headers, version); + + ModernHttpTask postTask = + new ModernHttpTask(context, url, + ImmutableMultimap.of(), + headers, + requestBody, + HTTPMethod.POST, + authInfo); + postTask.connect(getResponseProcessor(context, url, background, handler)); + + postTask.executeParallel(); + + return true; + } + + private static HashMap getContentHeadersForXFormPost(RequestBody postBody) { + HashMap headers = new HashMap<>(); + headers.put("Content-Type", "application/x-www-form-urlencoded"); + try { + headers.put("Content-Length", String.valueOf(postBody.contentLength())); + } catch (IOException e) { + //Empty headers if something goes wrong + } + return headers; + } + + public PostResult getSync(Context context, String url, AuthInfo authInfo, boolean background, + Multimap params) { + if(!background) { + setCallInProgress(url); + showProgressDialog(context); + } + + HashMap headers = new HashMap<>(); + + //TODO: Figure out how to send GET request the right way + StringBuilder getUrl = new StringBuilder(url); + if (params.size() > 0) { + boolean first = true; + for (Map.Entry entry : params.entries()) { + String delim = "&"; + if (first) { + delim = "?"; + first = false; + } + getUrl.append(delim).append(entry.getKey()).append("=").append(entry.getValue()); + } + } + + ModernHttpRequester requester = CommCareApplication.instance().buildHttpRequester( + context, + getUrl.toString(), + ImmutableMultimap.of(), + headers, + null, + null, + HTTPMethod.GET, + authInfo, + null, + true); + + int responseCode = -1; + InputStream stream = null; + IOException exception = null; + try { + Response response = requester.makeRequest(); + responseCode = response.code(); + if (response.isSuccessful()) { + stream = requester.getResponseStream(response); + } + } catch (IOException e) { + exception = e; + } + + onFinishProcessing(context, background); + + return new PostResult(responseCode, stream, exception); + } + + private boolean getInternal(Context context, String url, String version, AuthInfo authInfo, + Multimap params, boolean background, IApiCallback handler) { + if(!background) { + if (isBusy()) { + return false; + } + setCallInProgress(url); + + showProgressDialog(context); + } + + //TODO: Figure out how to send GET request the right way + StringBuilder getUrl = new StringBuilder(url); + if (params.size() > 0) { + boolean first = true; + for (Map.Entry entry : params.entries()) { + String delim = "&"; + if (first) { + delim = "?"; + first = false; + } + getUrl.append(delim).append(entry.getKey()).append("=").append(entry.getValue()); + } + } + + HashMap headers = new HashMap<>(); + addVersionHeader(headers, version); + + ModernHttpTask getTask = + new ModernHttpTask(context, getUrl.toString(), + ArrayListMultimap.create(), + headers, + authInfo); + getTask.connect(getResponseProcessor(context, url, background, handler)); + getTask.executeParallel(); + + return true; + } + + private ConnectorWithHttpResponseProcessor getResponseProcessor( + Context context, String url, boolean background, IApiCallback handler) { + return new ConnectorWithHttpResponseProcessor<>() { + @Override + public void processSuccess(int responseCode, InputStream responseData, String apiVersion) { + onFinishProcessing(context, background); + handler.processSuccess(responseCode, responseData); + } + + @Override + public void processClientError(int responseCode) { + onFinishProcessing(context, background); + + String message = String.format(Locale.getDefault(), "Call:%s\nResponse code:%d", url, responseCode); + CrashUtil.reportException(new Exception(message)); + + if(responseCode == 406) { + //API version is too old, require app update. + handler.processOldApiError(); + } else { + //400 error + handler.processFailure(responseCode, null); + } + } + + @Override + public void processServerError(int responseCode) { + onFinishProcessing(context, background); + + String message = String.format(Locale.getDefault(), "Call:%s\nResponse code:%d", url, responseCode); + CrashUtil.reportException(new Exception(message)); + + //500 error for internal server error + handler.processFailure(responseCode, null); + } + + @Override + public void processOther(int responseCode) { + onFinishProcessing(context, background); + + String message = String.format(Locale.getDefault(), "Call:%s\nResponse code:%d", url, responseCode); + CrashUtil.reportException(new Exception(message)); + + handler.processFailure(responseCode, null); + } + + @Override + public void handleIOException(IOException exception) { + onFinishProcessing(context, background); + if (exception instanceof UnknownHostException) { + handler.processNetworkFailure(); + } else { + handler.processFailure(-1, exception); + } + } + + @Override + public void connectTask(CommCareTask task) { + } + + @Override + public void startBlockingForTask(int id) { + } + + @Override + public void stopBlockingForTask(int id) { + } + + @Override + public void taskCancelled() { + } + + @Override + public HttpResponseProcessor getReceiver() { + return this; + } + + @Override + public void startTaskTransition() { + } + + @Override + public void stopTaskTransition(int taskId) { + } + + @Override + public void hideTaskCancelButton() { + } + }; + } + + private void onFinishProcessing(Context context, boolean background) { + if(!background) { + setCallInProgress(null); + dismissProgressDialog(context); + } + } + + public static void showNetworkError(Context context) { + Toast.makeText(context, context.getString(R.string.recovery_network_unavailable), + Toast.LENGTH_SHORT).show(); + } + + public static void showOutdatedApiError(Context context) { + Toast.makeText(context, context.getString(R.string.recovery_network_outdated), + Toast.LENGTH_LONG).show(); + } + + private static final int NETWORK_ACTIVITY_ID = 7000; + + private void showProgressDialog(Context context) { + if (context instanceof CommCareActivity) { + Handler handler = new Handler(context.getMainLooper()); + handler.post(() -> { + try { + ((CommCareActivity)context).showProgressDialog(NETWORK_ACTIVITY_ID); + } catch(Exception e) { + //Ignore, ok if showing fails + } + }); + } + } + + private void dismissProgressDialog(Context context) { + if (context instanceof CommCareActivity) { + Handler handler = new Handler(context.getMainLooper()); + handler.post(() -> { + ((CommCareActivity)context).dismissProgressDialogForTask(NETWORK_ACTIVITY_ID); + }); + } + } +} diff --git a/app/src/org/commcare/connect/network/IApiCallback.java b/app/src/org/commcare/connect/network/IApiCallback.java new file mode 100644 index 000000000..ba98c7f4e --- /dev/null +++ b/app/src/org/commcare/connect/network/IApiCallback.java @@ -0,0 +1,14 @@ +package org.commcare.connect.network; + +import java.io.IOException; +import java.io.InputStream; + +/** + * Interface for callbacks when network request completes + */ +public interface IApiCallback { + void processSuccess(int responseCode, InputStream responseData); + void processFailure(int responseCode, IOException e); + void processNetworkFailure(); + void processOldApiError(); +} diff --git a/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java b/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java index 3726c8d11..e49bbc550 100644 --- a/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java +++ b/app/src/org/commcare/google/services/analytics/FirebaseAnalyticsUtil.java @@ -261,7 +261,7 @@ public static void reportPracticeModeUsage(OfflineUserRestore currentOfflineUser public static void reportPrivilegeEnabled(String privilegeName, String usernameUsedToActivate) { reportEvent(CCAnalyticsEvent.ENABLE_PRIVILEGE, new String[]{FirebaseAnalytics.Param.ITEM_NAME, CCAnalyticsParam.USERNAME}, - new String[]{privilegeName, EncryptionUtils.getMD5HashAsString(usernameUsedToActivate)}); + new String[]{privilegeName, EncryptionUtils.getMd5HashAsString(usernameUsedToActivate)}); } public static void reportTimedSession(String sessionType, double timeInSeconds, double timeInMinutes) { diff --git a/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java b/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java new file mode 100644 index 000000000..34a2bc6a8 --- /dev/null +++ b/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java @@ -0,0 +1,448 @@ +package org.commcare.models.database.connect; + +import android.content.Context; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.commcare.android.database.connect.models.ConnectAppRecord; +import org.commcare.android.database.connect.models.ConnectJobAssessmentRecord; +import org.commcare.android.database.connect.models.ConnectJobDeliveryRecord; +import org.commcare.android.database.connect.models.ConnectJobDeliveryRecordV2; +import org.commcare.android.database.connect.models.ConnectJobLearningRecord; +import org.commcare.android.database.connect.models.ConnectJobPaymentRecord; +import org.commcare.android.database.connect.models.ConnectJobPaymentRecordV3; +import org.commcare.android.database.connect.models.ConnectJobRecord; +import org.commcare.android.database.connect.models.ConnectJobRecordV2; +import org.commcare.android.database.connect.models.ConnectJobRecordV4; +import org.commcare.android.database.connect.models.ConnectJobRecordV7; +import org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecord; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecordV3; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecordV8; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecordV9; +import org.commcare.android.database.connect.models.ConnectPaymentUnitRecord; +import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.android.database.connect.models.ConnectUserRecordV5; +import org.commcare.models.database.ConcreteAndroidDbHelper; +import org.commcare.models.database.DbUtil; +import org.commcare.models.database.SqlStorage; +import org.commcare.modern.database.TableBuilder; +import org.javarosa.core.services.storage.Persistable; + +public class ConnectDatabaseUpgrader { + private final Context c; + + public ConnectDatabaseUpgrader(Context c) { + this.c = c; + } + + public void upgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + if (oldVersion == 1) { + upgradeOneTwo(db); + oldVersion = 2; + } + + if (oldVersion == 2) { + upgradeTwoThree(db); + oldVersion = 3; + } + + if (oldVersion == 3) { + upgradeThreeFour(db); + oldVersion = 4; + } + + if (oldVersion == 4) { + upgradeFourFive(db); + oldVersion = 5; + } + + if (oldVersion == 5) { + upgradeFiveSix(db); + oldVersion = 6; + } + + if (oldVersion == 6) { + upgradeSixSeven(db); + oldVersion = 7; + } + + if (oldVersion == 7) { + upgradeSevenEight(db); + oldVersion = 8; + } + + if (oldVersion == 8) { + upgradeEightNine(db); + oldVersion = 9; + } + + if (oldVersion == 9) { + upgradeNineTen(db); + oldVersion = 10; + } + } + + private void upgradeOneTwo(SQLiteDatabase db) { + addTableForNewModel(db, ConnectJobRecord.STORAGE_KEY, new ConnectJobRecordV2()); + addTableForNewModel(db, ConnectAppRecord.STORAGE_KEY, new ConnectAppRecord()); + addTableForNewModel(db, ConnectLearnModuleSummaryRecord.STORAGE_KEY, new ConnectLearnModuleSummaryRecord()); + addTableForNewModel(db, ConnectJobDeliveryRecord.STORAGE_KEY, new ConnectJobDeliveryRecordV2()); + addTableForNewModel(db, ConnectJobLearningRecord.STORAGE_KEY, new ConnectJobLearningRecord()); + addTableForNewModel(db, ConnectJobAssessmentRecord.STORAGE_KEY, new ConnectJobAssessmentRecord()); + addTableForNewModel(db, ConnectJobPaymentRecord.STORAGE_KEY, new ConnectJobPaymentRecordV3()); + addTableForNewModel(db, ConnectLinkedAppRecord.STORAGE_KEY, new ConnectLinkedAppRecordV3()); + } + + private void upgradeTwoThree(SQLiteDatabase db) { + db.beginTransaction(); + + try { + db.execSQL(DbUtil.addColumnToTable( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.META_CLAIM_DATE, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectJobDeliveryRecord.STORAGE_KEY, + ConnectJobDeliveryRecord.META_REASON, + "TEXT")); + //First, migrate the old ConnectJobRecord in storage to the new version + SqlStorage oldStorage = new SqlStorage<>( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecordV2.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectJobRecordV2 oldRecord = (ConnectJobRecordV2)r; + ConnectJobRecordV4 newRecord = ConnectJobRecordV4.fromV2(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + //Next, migrate the old ConnectJobDeliveryRecord in storage to the new version + oldStorage = new SqlStorage<>( + ConnectJobDeliveryRecord.STORAGE_KEY, + ConnectJobDeliveryRecordV2.class, + new ConcreteAndroidDbHelper(c, db)); + + newStorage = new SqlStorage<>( + ConnectJobDeliveryRecord.STORAGE_KEY, + ConnectJobDeliveryRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectJobDeliveryRecordV2 oldRecord = (ConnectJobDeliveryRecordV2)r; + ConnectJobDeliveryRecord newRecord = ConnectJobDeliveryRecord.fromV2(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void upgradeThreeFour(SQLiteDatabase db) { + db.beginTransaction(); + + try { + //First, migrate the old ConnectLinkedAppRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_CONNECTID_LINKED, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_OFFERED_1, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_OFFERED_1_DATE, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_OFFERED_2, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_OFFERED_2_DATE, + "TEXT")); + + SqlStorage oldStorage = new SqlStorage<>( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecordV3.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectLinkedAppRecordV3 oldRecord = (ConnectLinkedAppRecordV3)r; + ConnectLinkedAppRecordV8 newRecord = ConnectLinkedAppRecordV8.fromV3(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + //Next, migrate the old ConnectJobPaymentRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectJobPaymentRecord.STORAGE_KEY, + ConnectJobPaymentRecord.META_PAYMENT_ID, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectJobPaymentRecord.STORAGE_KEY, + ConnectJobPaymentRecord.META_CONFIRMED, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectJobPaymentRecord.STORAGE_KEY, + ConnectJobPaymentRecord.META_CONFIRMED_DATE, + "TEXT")); + + oldStorage = new SqlStorage<>( + ConnectJobPaymentRecord.STORAGE_KEY, + ConnectJobPaymentRecordV3.class, + new ConcreteAndroidDbHelper(c, db)); + + newStorage = new SqlStorage<>( + ConnectJobPaymentRecord.STORAGE_KEY, + ConnectJobPaymentRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectJobPaymentRecordV3 oldRecord = (ConnectJobPaymentRecordV3)r; + ConnectJobPaymentRecord newRecord = ConnectJobPaymentRecord.fromV3(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void upgradeFourFive(SQLiteDatabase db) { + db.beginTransaction(); + + try { + //First, migrate the old ConnectJobRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.META_START_DATE, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.META_IS_ACTIVE, + "TEXT")); + + + SqlStorage oldStorage = new SqlStorage<>( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecordV4.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectJobRecordV4 oldRecord = (ConnectJobRecordV4)r; + ConnectJobRecordV7 newRecord = ConnectJobRecordV7.fromV4(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void upgradeFiveSix(SQLiteDatabase db) { + db.beginTransaction(); + + try { + //First, migrate the old ConnectUserRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_PIN, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_SECONDARY_PHONE_VERIFIED, + "TEXT")); + + db.execSQL(DbUtil.addColumnToTable( + ConnectUserRecord.STORAGE_KEY, + ConnectUserRecord.META_VERIFY_SECONDARY_PHONE_DATE, + "TEXT")); + + SqlStorage oldStorage = new SqlStorage<>( + ConnectUserRecord.STORAGE_KEY, + ConnectUserRecordV5.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectUserRecord.STORAGE_KEY, + ConnectUserRecordV5.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectUserRecordV5 oldRecord = (ConnectUserRecordV5)r; + ConnectUserRecord newRecord = ConnectUserRecord.fromV5(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void upgradeSixSeven(SQLiteDatabase db) { + addTableForNewModel(db, ConnectPaymentUnitRecord.STORAGE_KEY, new ConnectPaymentUnitRecord()); + } + + private void upgradeSevenEight(SQLiteDatabase db) { + db.beginTransaction(); + + try { + //First, migrate the old ConnectJobRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.META_USER_SUSPENDED, + "TEXT")); + + + SqlStorage oldStorage = new SqlStorage<>( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecordV7.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectJobRecord.STORAGE_KEY, + ConnectJobRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectJobRecordV7 oldRecord = (ConnectJobRecordV7)r; + ConnectJobRecord newRecord = ConnectJobRecord.fromV7(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void upgradeEightNine(SQLiteDatabase db) { + db.beginTransaction(); + + try { + //Migrate the old ConnectLinkedAppRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_LOCAL_PASSPHRASE, + "TEXT")); + + + SqlStorage oldStorage = new SqlStorage<>( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecordV8.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectLinkedAppRecordV8 oldRecord = (ConnectLinkedAppRecordV8)r; + ConnectLinkedAppRecordV9 newRecord = ConnectLinkedAppRecordV9.fromV8(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private void upgradeNineTen(SQLiteDatabase db) { + db.beginTransaction(); + + try { + //Migrate the old ConnectLinkedAppRecord in storage to the new version + db.execSQL(DbUtil.addColumnToTable( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.META_LAST_ACCESSED, + "TEXT")); + + + SqlStorage oldStorage = new SqlStorage<>( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecordV9.class, + new ConcreteAndroidDbHelper(c, db)); + + SqlStorage newStorage = new SqlStorage<>( + ConnectLinkedAppRecord.STORAGE_KEY, + ConnectLinkedAppRecord.class, + new ConcreteAndroidDbHelper(c, db)); + + for (Persistable r : oldStorage) { + ConnectLinkedAppRecordV9 oldRecord = (ConnectLinkedAppRecordV9)r; + ConnectLinkedAppRecord newRecord = ConnectLinkedAppRecord.fromV9(oldRecord); + //set this new record to have same ID as the old one + newRecord.setID(oldRecord.getID()); + newStorage.write(newRecord); + } + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } + + private static void addTableForNewModel(SQLiteDatabase db, String storageKey, + Persistable modelToAdd) { + db.beginTransaction(); + try { + TableBuilder builder = new TableBuilder(storageKey); + builder.addData(modelToAdd); + db.execSQL(builder.getTableCreateString()); + + db.setTransactionSuccessful(); + } finally { + db.endTransaction(); + } + } +} diff --git a/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java b/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java new file mode 100644 index 000000000..ef010c4fd --- /dev/null +++ b/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java @@ -0,0 +1,141 @@ +package org.commcare.models.database.connect; + +import android.content.Context; + +import net.sqlcipher.database.SQLiteDatabase; +import net.sqlcipher.database.SQLiteException; +import net.sqlcipher.database.SQLiteOpenHelper; + +import org.commcare.android.database.connect.models.ConnectAppRecord; +import org.commcare.android.database.connect.models.ConnectJobAssessmentRecord; +import org.commcare.android.database.connect.models.ConnectJobDeliveryRecord; +import org.commcare.android.database.connect.models.ConnectJobLearningRecord; +import org.commcare.android.database.connect.models.ConnectJobPaymentRecord; +import org.commcare.android.database.connect.models.ConnectJobRecord; +import org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecord; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.android.database.connect.models.ConnectPaymentUnitRecord; +import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.logging.DataChangeLog; +import org.commcare.logging.DataChangeLogger; +import org.commcare.models.database.DbUtil; +import org.commcare.models.database.user.UserSandboxUtils; +import org.commcare.modern.database.TableBuilder; +import org.commcare.util.Base64; +import org.commcare.util.Base64DecoderException; + +import java.io.File; + +/** + * The helper for opening/updating the Connect (encrypted) db space for CommCare. + * + * @author dviggiano + */ +public class DatabaseConnectOpenHelper extends SQLiteOpenHelper { + /** + * V.2 - Added ConnectJobRecord, ConnectAppInfo, and ConnectLearningModuleInfo tables + * V.3 - Added date_claimed column to ConnectJobRecord, + * and reason column to ConnectJobDeliveryRecord + * V.4 - Added confirmed and confirmedDate fields to ConnectJobPaymentRecord + * Added link offer info to ConnectLinkedAppRecord + * V.5 - Added projectStartDate and isActive to ConnectJobRecord + * V.6 - Added pin,secondaryPhoneVerified, and registrationDate fields to ConnectUserRecord + * V.7 - Added ConnectPaymentUnitRecord table + * V.8 - Added is_user_suspended to ConnectJobRecord + * V.9 - Added using_local_passphrase to ConnectLinkedAppRecord + * V.10 - Added last_accessed column to ConnectLinkedAppRecord + */ + private static final int CONNECT_DB_VERSION = 10; + + private static final String CONNECT_DB_LOCATOR = "database_connect"; + + private final Context mContext; + + public DatabaseConnectOpenHelper(Context context) { + super(context, CONNECT_DB_LOCATOR, null, CONNECT_DB_VERSION); + this.mContext = context; + } + + private static File getDbFile(Context context) { + return context.getDatabasePath(CONNECT_DB_LOCATOR); + } + + public static boolean dbExists(Context context) { + return getDbFile(context).exists(); + } + + public static void deleteDb(Context context) { + getDbFile(context).delete(); + } + + public static void rekeyDB(SQLiteDatabase db, String newPassphrase) throws Base64DecoderException { + if(db != null) { + byte[] newBytes = Base64.decode(newPassphrase); + String newKeyEncoded = UserSandboxUtils.getSqlCipherEncodedKey(newBytes); + + db.query("PRAGMA rekey = '" + newKeyEncoded + "';"); + db.close(); + } + } + + @Override + public void onCreate(SQLiteDatabase database) { + database.beginTransaction(); + try { + TableBuilder builder = new TableBuilder(ConnectUserRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectLinkedAppRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectJobRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectAppRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectLearnModuleSummaryRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectJobLearningRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectJobAssessmentRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectJobDeliveryRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectJobPaymentRecord.class); + database.execSQL(builder.getTableCreateString()); + + builder = new TableBuilder(ConnectPaymentUnitRecord.class); + database.execSQL(builder.getTableCreateString()); + + DbUtil.createNumbersTable(database); + + database.setVersion(CONNECT_DB_VERSION); + + database.setTransactionSuccessful(); + } finally { + database.endTransaction(); + } + } + + @Override + public SQLiteDatabase getWritableDatabase(String key) { + try { + return super.getWritableDatabase(key); + } catch (SQLiteException sqle) { + DbUtil.trySqlCipherDbUpdate(key, mContext, CONNECT_DB_LOCATOR); + return super.getWritableDatabase(key); + } + } + + @Override + public void onUpgrade(SQLiteDatabase db, int oldVersion, int newVersion) { + DataChangeLogger.log(new DataChangeLog.DbUpgradeStart("Connect", oldVersion, newVersion)); + new ConnectDatabaseUpgrader(mContext).upgrade(db, oldVersion, newVersion); + DataChangeLogger.log(new DataChangeLog.DbUpgradeComplete("Connect", oldVersion, newVersion)); + } +} diff --git a/app/src/org/commcare/utils/EncryptionKeyAndTransform.java b/app/src/org/commcare/utils/EncryptionKeyAndTransform.java new file mode 100644 index 000000000..ab7068db9 --- /dev/null +++ b/app/src/org/commcare/utils/EncryptionKeyAndTransform.java @@ -0,0 +1,18 @@ +package org.commcare.utils; + +import java.security.Key; + +/** + * Utility class for holding an encryption key and transformation string pair + * + * @author dviggiano + */ +public class EncryptionKeyAndTransform { + public Key key; + public String transformation; + + public EncryptionKeyAndTransform(Key key, String transformation) { + this.key = key; + this.transformation = transformation; + } +} diff --git a/app/src/org/commcare/utils/EncryptionKeyProvider.java b/app/src/org/commcare/utils/EncryptionKeyProvider.java new file mode 100644 index 000000000..dc3ee506d --- /dev/null +++ b/app/src/org/commcare/utils/EncryptionKeyProvider.java @@ -0,0 +1,142 @@ +package org.commcare.utils; + +import android.annotation.SuppressLint; +import android.content.Context; +import android.os.Build; +import android.security.KeyPairGeneratorSpec; +import android.security.keystore.KeyGenParameterSpec; +import android.security.keystore.KeyProperties; + +import androidx.annotation.RequiresApi; + +import java.io.IOException; +import java.math.BigInteger; +import java.security.InvalidAlgorithmParameterException; +import java.security.Key; +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.KeyStore; +import java.security.KeyStoreException; +import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.UnrecoverableEntryException; +import java.security.cert.CertificateException; +import java.util.Calendar; +import java.util.GregorianCalendar; + +import javax.crypto.KeyGenerator; +import javax.security.auth.x500.X500Principal; + +/** + * Class for providing encryption keys backed by Android Keystore + * + * @author dviggiano + */ +public class EncryptionKeyProvider { + private static final String KEYSTORE_NAME = "AndroidKeyStore"; + private static final String SECRET_NAME = "secret"; + + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String ALGORITHM = KeyProperties.KEY_ALGORITHM_AES; + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String BLOCK_MODE = KeyProperties.BLOCK_MODE_CBC; + @RequiresApi(api = Build.VERSION_CODES.M) + private static final String PADDING = KeyProperties.ENCRYPTION_PADDING_PKCS7; + private static KeyStore keystoreSingleton = null; + + private static KeyStore getKeystore() throws KeyStoreException, CertificateException, + IOException, NoSuchAlgorithmException { + if (keystoreSingleton == null) { + keystoreSingleton = KeyStore.getInstance(KEYSTORE_NAME); + keystoreSingleton.load(null); + } + + return keystoreSingleton; + } + + public EncryptionKeyAndTransform getKey(Context context, boolean trueForEncrypt) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException, + InvalidAlgorithmParameterException, UnrecoverableEntryException, NoSuchProviderException { + return getKey(context, getKeystore(), trueForEncrypt); + } + + //Gets the SecretKey from the Android KeyStore (creates a new one the first time) + private static EncryptionKeyAndTransform getKey(Context context, KeyStore keystore, boolean trueForEncrypt) + throws CertificateException, KeyStoreException, IOException, NoSuchAlgorithmException, + UnrecoverableEntryException, InvalidAlgorithmParameterException, NoSuchProviderException { + + if (doesKeystoreContainEncryptionKey()) { + KeyStore.Entry existingKey = keystore.getEntry(SECRET_NAME, null); + if (existingKey instanceof KeyStore.SecretKeyEntry entry) { + return new EncryptionKeyAndTransform(entry.getSecretKey(), getTransformationString(false)); + } + if (existingKey instanceof KeyStore.PrivateKeyEntry entry) { + Key key = trueForEncrypt ? entry.getCertificate().getPublicKey() : entry.getPrivateKey(); + return new EncryptionKeyAndTransform(key, getTransformationString(true)); + } else { + throw new RuntimeException("Unrecognized key type retrieved from KeyStore"); + } + } else { + return generateKeyInKeystore(context, trueForEncrypt); + } + } + + private static boolean doesKeystoreContainEncryptionKey() throws CertificateException, + KeyStoreException, IOException, NoSuchAlgorithmException { + KeyStore keystore = getKeystore(); + + return keystore.containsAlias(SECRET_NAME); + } + + private static EncryptionKeyAndTransform generateKeyInKeystore(Context context, boolean trueForEncrypt) + throws NoSuchAlgorithmException, NoSuchProviderException, InvalidAlgorithmParameterException { + if (android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.M) { + KeyGenerator keyGenerator = KeyGenerator.getInstance( + KeyProperties.KEY_ALGORITHM_AES, KEYSTORE_NAME); + KeyGenParameterSpec keySpec = new KeyGenParameterSpec.Builder(SECRET_NAME, + KeyProperties.PURPOSE_ENCRYPT | KeyProperties.PURPOSE_DECRYPT) + .setBlockModes(KeyProperties.BLOCK_MODE_CBC) + .setEncryptionPaddings(KeyProperties.ENCRYPTION_PADDING_PKCS7) + .build(); + + keyGenerator.init(keySpec); + return new EncryptionKeyAndTransform(keyGenerator.generateKey(), getTransformationString(false)); + } else { + KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", KEYSTORE_NAME); + + GregorianCalendar start = new GregorianCalendar(); + GregorianCalendar end = new GregorianCalendar(); + end.add(Calendar.YEAR, 100); + KeyPairGeneratorSpec keySpec = new KeyPairGeneratorSpec.Builder(context) + // You'll use the alias later to retrieve the key. It's a key for the key! + .setAlias(SECRET_NAME) + // The subject used for the self-signed certificate of the generated pair + .setSubject(new X500Principal(String.format("CN=%s", SECRET_NAME))) + // The serial number used for the self-signed certificate of the + // generated pair. + .setSerialNumber(BigInteger.valueOf(1337)) + // Date range of validity for the generated pair. + .setStartDate(start.getTime()) + .setEndDate(end.getTime()) + .build(); + + generator.initialize(keySpec); + KeyPair pair = generator.generateKeyPair(); + + Key key = trueForEncrypt ? pair.getPublic() : pair.getPrivate(); + return new EncryptionKeyAndTransform(key, getTransformationString(true)); + } + } + + @SuppressLint("InlinedApi") //Suppressing since we check the API version elsewhere + public static String getTransformationString(boolean useRsa) { + String transformation; + if (useRsa) { + transformation = "RSA/ECB/PKCS1Padding"; + } else { + transformation = String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING); + } + + return transformation; + } +} diff --git a/app/src/org/commcare/utils/EncryptionUtils.java b/app/src/org/commcare/utils/EncryptionUtils.java index dc620c308..e0d82bae6 100644 --- a/app/src/org/commcare/utils/EncryptionUtils.java +++ b/app/src/org/commcare/utils/EncryptionUtils.java @@ -1,18 +1,164 @@ package org.commcare.utils; +import android.content.Context; + +import org.commcare.CommCareApplication; import org.commcare.util.Base64; +import org.commcare.util.Base64DecoderException; +import java.io.IOException; +import java.security.InvalidAlgorithmParameterException; +import java.security.InvalidKeyException; +import java.security.KeyStoreException; import java.security.MessageDigest; import java.security.NoSuchAlgorithmException; +import java.security.NoSuchProviderException; +import java.security.SecureRandom; +import java.security.UnrecoverableEntryException; +import java.security.UnrecoverableKeyException; +import java.security.cert.CertificateException; +import java.util.Random; + +import javax.crypto.BadPaddingException; +import javax.crypto.Cipher; +import javax.crypto.IllegalBlockSizeException; +import javax.crypto.NoSuchPaddingException; +import javax.crypto.spec.IvParameterSpec; /** - * Utility class for encrypting submissions during the SaveToDiskTask. + * Utility class for encryption functionality. + * Usages include: + * -Generating/storing/retrieving an encrypted, base64-encoded passphrase for the Connect DB + * -Encrypting submissions during the SaveToDiskTask. * * @author mitchellsundt@gmail.com */ + public class EncryptionUtils { - public static String getMD5HashAsString(String plainText) { + private static final int PASSPHRASE_LENGTH = 32; + + //Generate a random passphrase + public static byte[] generatePassphrase() { + Random random; + try { + //Use SecureRandom if possible (specifying algorithm for older versions of Android) + random = android.os.Build.VERSION.SDK_INT >= android.os.Build.VERSION_CODES.O ? + SecureRandom.getInstanceStrong() : SecureRandom.getInstance("SHA1PRNG"); + } catch (NoSuchAlgorithmException e) { + //Fallback to basic Random + random = new Random(); + } + + byte[] result = new byte[PASSPHRASE_LENGTH]; + + while (true) { + random.nextBytes(result); + + //Make sure there are no zeroes in the passphrase + //SQLCipher passphrases must not contain any zero byte-values + //For more, see "Creating the Passphrase" section here: + //https://commonsware.com/Room/pages/chap-passphrase-001.html + boolean containsZero = false; + for (byte b : result) { + if (b == 0) { + containsZero = true; + break; + } + } + + if (!containsZero) { + break; + } + } + + return result; + } + + public static byte[] encrypt(byte[] bytes, EncryptionKeyAndTransform keyAndTransform) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidKeyException, + IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, + UnrecoverableEntryException, CertificateException, KeyStoreException, IOException, + NoSuchProviderException { + Cipher cipher = Cipher.getInstance(keyAndTransform.transformation); + cipher.init(Cipher.ENCRYPT_MODE, keyAndTransform.key); + byte[] encrypted = cipher.doFinal(bytes); + byte[] iv = cipher.getIV(); + int ivLength = iv == null ? 0 : iv.length; + + byte[] output = new byte[encrypted.length + ivLength + 3]; + int writeIndex = 0; + output[writeIndex] = (byte)ivLength; + writeIndex++; + if (ivLength > 0) { + System.arraycopy(iv, 0, output, writeIndex, iv.length); + writeIndex += iv.length; + } + + output[writeIndex] = (byte)(encrypted.length / 256); + writeIndex++; + output[writeIndex] = (byte)(encrypted.length % 256); + writeIndex++; + System.arraycopy(encrypted, 0, output, writeIndex, encrypted.length); + + return output; + } + + public static byte[] decrypt(byte[] bytes, EncryptionKeyAndTransform keyAndTransform) + throws NoSuchPaddingException, NoSuchAlgorithmException, InvalidAlgorithmParameterException, + InvalidKeyException, IllegalBlockSizeException, BadPaddingException, + UnrecoverableEntryException { + int readIndex = 0; + int ivLength = bytes[readIndex]; + readIndex++; + if (ivLength < 0) { + //Note: Early chance to catch decryption error + throw new UnrecoverableKeyException("Negative IV length"); + } + byte[] iv = null; + if (ivLength > 0) { + iv = new byte[ivLength]; + System.arraycopy(bytes, readIndex, iv, 0, ivLength); + readIndex += ivLength; + } + + int encryptedLength = bytes[readIndex] * 256; + readIndex++; + encryptedLength += bytes[readIndex]; + + byte[] encrypted = new byte[encryptedLength]; + readIndex++; + System.arraycopy(bytes, readIndex, encrypted, 0, encryptedLength); + + Cipher cipher = Cipher.getInstance(keyAndTransform.transformation); + + cipher.init(Cipher.DECRYPT_MODE, keyAndTransform.key, iv != null ? new IvParameterSpec(iv) : null); + + return cipher.doFinal(encrypted); + } + + //Encrypts a byte[] and converts to a base64 string for DB storage + public static String encryptToBase64String(Context context, byte[] input) throws + InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, + UnrecoverableEntryException, CertificateException, NoSuchAlgorithmException, + BadPaddingException, KeyStoreException, IOException, InvalidKeyException, NoSuchProviderException { + byte[] encrypted = encrypt(input, CommCareApplication.instance().getEncryptionKeyProvider() + .getKey(context, true)); + return Base64.encode(encrypted); + } + + //Decrypts a base64 string (from DB storage) into a byte[] + public static byte[] decryptFromBase64String(Context context, String base64) throws Base64DecoderException, + InvalidAlgorithmParameterException, NoSuchPaddingException, IllegalBlockSizeException, + UnrecoverableEntryException, CertificateException, NoSuchAlgorithmException, + BadPaddingException, KeyStoreException, IOException, InvalidKeyException, NoSuchProviderException { + byte[] encrypted = Base64.decode(base64); + + return decrypt(encrypted, CommCareApplication.instance().getEncryptionKeyProvider() + .getKey(context, false)); + } + + public static String getMd5HashAsString(String plainText) { try { MessageDigest md = MessageDigest.getInstance("MD5"); md.update(plainText.getBytes()); From 56bb1fbd5841874bc878db514253d83b6e6cdfb3 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Thu, 12 Sep 2024 12:59:09 -0400 Subject: [PATCH 2/4] Added wrapper class for ConnectID API calls --- .../connect/models/ConnectUserRecord.java | 11 +- .../commcare/connect/ConnectConstants.java | 2 + .../connect/network/ApiConnectId.java | 400 ++++++++++++++++++ 3 files changed, 408 insertions(+), 5 deletions(-) create mode 100644 app/src/org/commcare/connect/network/ApiConnectId.java diff --git a/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java index 95d95ee66..f5281b9e9 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java @@ -4,6 +4,7 @@ import org.commcare.android.storage.framework.Persisted; import org.commcare.connect.ConnectConstants; +import org.commcare.core.network.AuthInfo; import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; @@ -194,12 +195,12 @@ public void updateConnectToken(String token, Date expirationDate) { connectTokenExpiration = expirationDate; } - public String getConnectToken() { - return connectToken; - } + public AuthInfo.TokenAuth getConnectToken() { + if((new Date()).compareTo(connectTokenExpiration) < 0) { + return new AuthInfo.TokenAuth(connectToken); + } - public Date getConnectTokenExpiration() { - return connectTokenExpiration; + return null; } public static ConnectUserRecord fromV5(ConnectUserRecordV5 oldRecord) { diff --git a/app/src/org/commcare/connect/ConnectConstants.java b/app/src/org/commcare/connect/ConnectConstants.java index 9f0d16925..0b0b33984 100644 --- a/app/src/org/commcare/connect/ConnectConstants.java +++ b/app/src/org/commcare/connect/ConnectConstants.java @@ -13,5 +13,7 @@ public class ConnectConstants { public static final String NAME = "NAME"; public static final String PHONE = "PHONE"; public static final String ALT_PHONE = "ALT_PHONE"; + public static final String CONNECT_KEY_TOKEN = "access_token"; + public static final String CONNECT_KEY_EXPIRES = "expires_in"; public final static int CONNECT_NO_ACTIVITY = ConnectConstants.ConnectIdTaskIdOffset; } diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java new file mode 100644 index 000000000..7e06d504f --- /dev/null +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -0,0 +1,400 @@ +package org.commcare.connect.network; + +import android.content.Context; + +import com.google.common.collect.ArrayListMultimap; +import com.google.common.collect.Multimap; + +import org.commcare.CommCareApplication; +import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.connect.ConnectConstants; +import org.commcare.connect.ConnectDatabaseHelper; +import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.core.network.AuthInfo; +import org.commcare.dalvik.R; +import org.commcare.preferences.HiddenPreferences; +import org.commcare.preferences.ServerUrls; +import org.commcare.utils.FirebaseMessagingUtil; +import org.javarosa.core.io.StreamsUtil; +import org.javarosa.core.services.Logger; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Date; +import java.util.HashMap; + +public class ApiConnectId { + private static final String API_VERSION_NONE = null; + private static final String API_VERSION_CONNECT_ID = "1.0"; + + public static void linkHqWorker(Context context, String hqUsername, String hqPassword, String connectToken) { + String seatedAppId = CommCareApplication.instance().getCurrentApp().getUniqueId(); + ConnectLinkedAppRecord appRecord = ConnectDatabaseHelper.getAppData(context, seatedAppId, hqUsername); + if (appRecord != null && !appRecord.getWorkerLinked()) { + HashMap params = new HashMap<>(); + params.put("token", connectToken); + + String url = ServerUrls.getKeyServer().replace("phone/keys/", + "settings/users/commcare/link_connectid_user/"); + + try { + ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, + API_VERSION_NONE, new AuthInfo.ProvidedAuth(hqUsername, hqPassword), params, true, false); + if (postResult.e == null && postResult.responseCode == 200) { + postResult.responseStream.close(); + + //Remember that we linked the user successfully + appRecord.setWorkerLinked(true); + ConnectDatabaseHelper.storeApp(context, appRecord); + } + } catch (IOException e) { + //Don't care for now + } + } + } + + public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUsername, String connectToken) { + HashMap params = new HashMap<>(); + params.put("client_id", "4eHlQad1oasGZF0lPiycZIjyL0SY1zx7ZblA6SCV"); + params.put("scope", "mobile_access sync"); + params.put("grant_type", "password"); + params.put("username", hqUsername + "@" + HiddenPreferences.getUserDomain()); + params.put("password", connectToken); + + String host; + try { + host = (new URL(ServerUrls.getKeyServer())).getHost(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + + String url = "https://" + host + "/oauth/token/"; + + ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, + API_VERSION_NONE, new AuthInfo.NoAuth(), params, true, false); + if (postResult.responseCode == 200) { + try { + String responseAsString = new String(StreamsUtil.inputStreamToByteArray( + postResult.responseStream)); + JSONObject json = new JSONObject(responseAsString); + String key = ConnectConstants.CONNECT_KEY_TOKEN; + if (json.has(key)) { + String token = json.getString(key); + Date expiration = new Date(); + key = ConnectConstants.CONNECT_KEY_EXPIRES; + int seconds = json.has(key) ? json.getInt(key) : 0; + expiration.setTime(expiration.getTime() + ((long)seconds * 1000)); + + String seatedAppId = CommCareApplication.instance().getCurrentApp().getUniqueId(); + ConnectDatabaseHelper.storeHqToken(context, seatedAppId, hqUsername, token, expiration); + + return new AuthInfo.TokenAuth(token); + } + } catch (IOException | JSONException e) { + Logger.exception("Parsing return from HQ OIDC call", e); + } + } + + return null; + } + + public static ConnectNetworkHelper.PostResult makeHeartbeatRequestSync(Context context) { + String url = context.getString(R.string.ConnectHeartbeatURL); + HashMap params = new HashMap<>(); + String token = FirebaseMessagingUtil.getFCMToken(); + if(token != null) { + params.put("fcm_token", token); + boolean useFormEncoding = true; + return ConnectNetworkHelper.postSync(context, url, API_VERSION_CONNECT_ID, retrieveConnectIdTokenSync(context), params, useFormEncoding, true); + } + + return new ConnectNetworkHelper.PostResult(-1, null, null); + } + + public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { + ConnectUserRecord user = ConnectDatabaseHelper.getUser(context); + + if (user != null) { + AuthInfo.TokenAuth connectToken = user.getConnectToken(); + if (connectToken != null) { + return connectToken; + } + + HashMap params = new HashMap<>(); + params.put("client_id", "zqFUtAAMrxmjnC1Ji74KAa6ZpY1mZly0J0PlalIa"); + params.put("scope", "openid"); + params.put("grant_type", "password"); + params.put("username", user.getUserId()); + params.put("password", user.getPassword()); + + String url = context.getString(R.string.ConnectTokenURL); + + ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, + API_VERSION_CONNECT_ID, new AuthInfo.NoAuth(), params, true, false); + if (postResult.responseCode == 200) { + try { + String responseAsString = new String(StreamsUtil.inputStreamToByteArray( + postResult.responseStream)); + postResult.responseStream.close(); + JSONObject json = new JSONObject(responseAsString); + String key = ConnectConstants.CONNECT_KEY_TOKEN; + if (json.has(key)) { + String token = json.getString(key); + Date expiration = new Date(); + key = ConnectConstants.CONNECT_KEY_EXPIRES; + int seconds = json.has(key) ? json.getInt(key) : 0; + expiration.setTime(expiration.getTime() + ((long)seconds * 1000)); + user.updateConnectToken(token, expiration); + ConnectDatabaseHelper.storeUser(context, user); + + return new AuthInfo.TokenAuth(token); + } + } catch (IOException | JSONException e) { + Logger.exception("Parsing return from Connect OIDC call", e); + } + } + } + + return null; + } + + public static void fetchDbPassphrase(Context context, ConnectUserRecord user, IApiCallback callback) { + ConnectNetworkHelper.get(context, + context.getString(R.string.ConnectFetchDbKeyURL), + API_VERSION_CONNECT_ID, new AuthInfo.ProvidedAuth(user.getUserId(), user.getPassword(), false), + ArrayListMultimap.create(), true, callback); + } + + public static boolean checkPassword(Context context, String phone, String secret, + String password, IApiCallback callback) { + HashMap params = new HashMap<>(); + params.put("phone", phone); + params.put("secret_key", secret); + params.put("password", password); + + return ConnectNetworkHelper.post(context, context.getString(R.string.ConnectConfirmPasswordURL), + API_VERSION_CONNECT_ID, new AuthInfo.NoAuth(), params, false, false, callback); + } + + public static boolean changePassword(Context context, String username, String oldPassword, + String newPassword, IApiCallback callback) { + if (ConnectNetworkHelper.isBusy()) { + return false; + } + + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, oldPassword, false); + int urlId = R.string.ConnectChangePasswordURL; + + HashMap params = new HashMap<>(); + params.put("password", newPassword); + + return ConnectNetworkHelper.post(context, context.getString(urlId), API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean resetPassword(Context context, String phoneNumber, String recoverySecret, + String newPassword, IApiCallback callback) { + if (ConnectNetworkHelper.isBusy()) { + return false; + } + + AuthInfo authInfo = new AuthInfo.NoAuth(); + int urlId = R.string.ConnectResetPasswordURL; + + HashMap params = new HashMap<>(); + params.put("phone", phoneNumber); + params.put("secret_key", recoverySecret); + params.put("password", newPassword); + + return ConnectNetworkHelper.post(context, context.getString(urlId), API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean checkPin(Context context, String phone, String secret, + String pin, IApiCallback callback) { + if (ConnectNetworkHelper.isBusy()) { + return false; + } + + AuthInfo authInfo = new AuthInfo.NoAuth(); + int urlId = R.string.ConnectConfirmPinURL; + + HashMap params = new HashMap<>(); + params.put("phone", phone); + params.put("secret_key", secret); + params.put("recovery_pin", pin); + + return ConnectNetworkHelper.post(context, context.getString(urlId), API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean changePin(Context context, String username, String password, + String pin, IApiCallback callback) { + if (ConnectNetworkHelper.isBusy()) { + return false; + } + + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + int urlId = R.string.ConnectSetPinURL; + + HashMap params = new HashMap<>(); + params.put("recovery_pin", pin); + + return ConnectNetworkHelper.post(context, context.getString(urlId), API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean checkPhoneAvailable(Context context, String phone, IApiCallback callback) { + Multimap params = ArrayListMultimap.create(); + params.put("phone_number", phone); + + return ConnectNetworkHelper.get(context, + context.getString(R.string.ConnectPhoneAvailableURL), + API_VERSION_CONNECT_ID, new AuthInfo.NoAuth(), params, false, callback); + } + + public static boolean registerUser(Context context, String username, String password, String displayName, + String phone, IApiCallback callback) { + HashMap params = new HashMap<>(); + params.put("username", username); + params.put("password", password); + params.put("name", displayName); + params.put("phone_number", phone); + params.put("fcm_token", FirebaseMessagingUtil.getFCMToken()); + + return ConnectNetworkHelper.post(context, + context.getString(R.string.ConnectRegisterURL), + API_VERSION_CONNECT_ID, new AuthInfo.NoAuth(), params, false, false, callback); + } + + public static boolean changePhone(Context context, String username, String password, + String oldPhone, String newPhone, IApiCallback callback) { + //Update the phone number with the server + int urlId = R.string.ConnectChangePhoneURL; + + HashMap params = new HashMap<>(); + params.put("old_phone_number", oldPhone); + params.put("new_phone_number", newPhone); + + return ConnectNetworkHelper.post(context, context.getString(urlId), API_VERSION_CONNECT_ID, + new AuthInfo.ProvidedAuth(username, password, false), params, false, false, + callback); + } + + public static boolean updateUserProfile(Context context, String username, + String password, String displayName, + String secondaryPhone, IApiCallback callback) { + //Update the phone number with the server + int urlId = R.string.ConnectUpdateProfileURL; + + HashMap params = new HashMap<>(); + if(secondaryPhone != null) { + params.put("secondary_phone", secondaryPhone); + } + + if(displayName != null) { + params.put("name", displayName); + } + + return ConnectNetworkHelper.post(context, context.getString(urlId), API_VERSION_CONNECT_ID, + new AuthInfo.ProvidedAuth(username, password, false), params, false, false, + callback); + } + + public static boolean requestRegistrationOtpPrimary(Context context, String username, String password, + IApiCallback callback) { + int urlId = R.string.ConnectValidatePhoneURL; + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + + HashMap params = new HashMap<>(); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean requestRecoveryOtpPrimary(Context context, String phone, IApiCallback callback) { + int urlId = R.string.ConnectRecoverURL; + AuthInfo authInfo = new AuthInfo.NoAuth(); + + HashMap params = new HashMap<>(); + params.put("phone", phone); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean requestRecoveryOtpSecondary(Context context, String phone, String secret, + IApiCallback callback) { + int urlId = R.string.ConnectRecoverSecondaryURL; + AuthInfo authInfo = new AuthInfo.NoAuth(); + + HashMap params = new HashMap<>(); + params.put("phone", phone); + params.put("secret_key", secret); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean requestVerificationOtpSecondary(Context context, String username, String password, + IApiCallback callback) { + int urlId = R.string.ConnectVerifySecondaryURL; + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + + HashMap params = new HashMap<>(); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean confirmRegistrationOtpPrimary(Context context, String username, String password, + String token, IApiCallback callback) { + int urlId = R.string.ConnectConfirmOTPURL; + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + + HashMap params = new HashMap<>(); + params.put("token", token); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean confirmRecoveryOtpPrimary(Context context, String phone, String secret, + String token, IApiCallback callback) { + int urlId = R.string.ConnectRecoverConfirmOTPURL; + AuthInfo authInfo = new AuthInfo.NoAuth(); + + HashMap params = new HashMap<>(); + params.put("phone", phone); + params.put("secret_key", secret); + params.put("token", token); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean confirmRecoveryOtpSecondary(Context context, String phone, String secret, + String token, IApiCallback callback) { + int urlId = R.string.ConnectRecoverConfirmSecondaryOTPURL; + AuthInfo authInfo = new AuthInfo.NoAuth(); + + HashMap params = new HashMap<>(); + params.put("phone", phone); + params.put("secret_key", secret); + params.put("token", token); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } + + public static boolean confirmVerificationOtpSecondary(Context context, String username, String password, + String token, IApiCallback callback) { + int urlId = R.string.ConnectVerifyConfirmSecondaryOTPURL; + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + + HashMap params = new HashMap<>(); + params.put("token", token); + + return ConnectNetworkHelper.post(context, context.getString(urlId), + API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + } +} From 1351a0a6bc68e445065542b89606dbf2af857973 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Thu, 12 Sep 2024 13:12:55 -0400 Subject: [PATCH 3/4] Added unit tests for encryption, and mock encryption provider to support test framework --- .../org/commcare/CommCareTestApplication.java | 3 ++ .../commcare/utils/EncryptionUtilsTest.java | 34 +++++++++++++++++++ .../utils/MockEncryptionKeyProvider.java | 29 ++++++++++++++++ 3 files changed, 66 insertions(+) create mode 100644 app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java create mode 100644 app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java diff --git a/app/unit-tests/src/org/commcare/CommCareTestApplication.java b/app/unit-tests/src/org/commcare/CommCareTestApplication.java index eb38ef32c..799993b74 100644 --- a/app/unit-tests/src/org/commcare/CommCareTestApplication.java +++ b/app/unit-tests/src/org/commcare/CommCareTestApplication.java @@ -27,6 +27,7 @@ import org.commcare.network.LocalReferencePullResponseFactory; import org.commcare.services.CommCareSessionService; import org.commcare.utils.AndroidCacheDirSetup; +import org.commcare.utils.MockEncryptionKeyProvider; import org.javarosa.core.model.User; import org.javarosa.core.reference.ReferenceManager; import org.javarosa.core.reference.ResourceReferenceFactory; @@ -74,6 +75,8 @@ public void onCreate() { super.onCreate(); + setEncryptionKeyProvider(new MockEncryptionKeyProvider()); + // allow "jr://resource" references ReferenceManager.instance().addReferenceFactory(new ResourceReferenceFactory()); diff --git a/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java b/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java new file mode 100644 index 000000000..ad9fd70eb --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java @@ -0,0 +1,34 @@ +package org.commcare.utils; + +import junit.framework.Assert; + +import org.junit.Test; + +import java.nio.charset.StandardCharsets; + +/** + * Unit test for the encryption and decryption of a string + * + * @author dviggiano + */ +public class EncryptionUtilsTest { + @Test + public void testEncryption() { + try { + String testData = "This is a test string"; + byte[] testBytes = testData.getBytes(StandardCharsets.UTF_8); + + EncryptionKeyProvider provider = new MockEncryptionKeyProvider(); + + byte[] encrypted = EncryptionUtils.encrypt(testBytes, provider.getKey(null, true)); + String encryptedString = new String(encrypted); + Assert.assertFalse(testData.equals(encryptedString)); + + byte[] decrypted = EncryptionUtils.decrypt(encrypted, provider.getKey(null, false)); + String decryptedString = new String(decrypted); + Assert.assertEquals(testData, decryptedString); + } catch (Exception e) { + Assert.fail("Exception: " + e); + } + } +} diff --git a/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java b/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java new file mode 100644 index 000000000..d66c94721 --- /dev/null +++ b/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java @@ -0,0 +1,29 @@ +package org.commcare.utils; + +import android.content.Context; + +import java.security.KeyPair; +import java.security.KeyPairGenerator; +import java.security.NoSuchAlgorithmException; + +/** + * Mock key provider, creates an RSA KeyPair but doesn't store it for future usage + * + * @author dviggiano + */ +public class MockEncryptionKeyProvider extends EncryptionKeyProvider { + private KeyPair keyPair = null; + + @Override + public EncryptionKeyAndTransform getKey(Context context, boolean trueForEncrypt) + throws NoSuchAlgorithmException { + if (keyPair == null) { + //Create an RSA keypair that we can use to encrypt and decrypt + keyPair = KeyPairGenerator.getInstance("RSA").generateKeyPair(); + } + String transformation = EncryptionKeyProvider.getTransformationString(true); + + return new EncryptionKeyAndTransform(trueForEncrypt ? keyPair.getPrivate() : keyPair.getPublic(), + transformation); + } +} From fcfbb71ee30cc9e3ba5e4a5679cd69ecdeab20b2 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Mon, 16 Sep 2024 12:21:51 -0400 Subject: [PATCH 4/4] Added externalizables to test. --- .../tests/processing/FormStorageTest.java | 19 +++++++++++++++++++ 1 file changed, 19 insertions(+) diff --git a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java index 3283f1340..f0b1f8c82 100644 --- a/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java +++ b/app/unit-tests/src/org/commcare/android/tests/processing/FormStorageTest.java @@ -355,8 +355,27 @@ public class FormStorageTest { , "org.commcare.suite.model.EndpointArgument" , "org.commcare.suite.model.EndpointAction" , "org.commcare.suite.model.QueryGroup" + , "org.commcare.android.database.connect.models.ConnectLinkedAppRecordV3" + , "org.commcare.android.database.connect.models.ConnectLinkedAppRecordV8" + , "org.commcare.android.database.connect.models.ConnectLinkedAppRecordV9" + , "org.commcare.android.database.connect.models.ConnectLinkedAppRecord" + , "org.commcare.android.database.connect.models.ConnectUserRecordV5" + , "org.commcare.android.database.connect.models.ConnectUserRecord" + , "org.commcare.android.database.connect.models.ConnectAppRecord" + , "org.commcare.android.database.connect.models.ConnectJobDeliveryRecordV2" + , "org.commcare.android.database.connect.models.ConnectJobDeliveryRecord" + , "org.commcare.android.database.connect.models.ConnectJobPaymentRecordV3" + , "org.commcare.android.database.connect.models.ConnectJobPaymentRecord" + , "org.commcare.android.database.connect.models.ConnectJobRecordV2" + , "org.commcare.android.database.connect.models.ConnectJobRecordV4" + , "org.commcare.android.database.connect.models.ConnectJobRecordV7" + , "org.commcare.android.database.connect.models.ConnectJobRecord" + , "org.commcare.android.database.connect.models.ConnectLearnModuleSummaryRecord" + , "org.commcare.android.database.connect.models.ConnectJobLearningRecord" + , "org.commcare.android.database.connect.models.ConnectJobAssessmentRecord" , "org.commcare.android.database.global.models.ConnectKeyRecord" , "org.commcare.android.database.global.models.ConnectKeyRecordV6" + , "org.commcare.android.database.connect.models.ConnectPaymentUnitRecord" );