From 1e72821c5cc140fb338ff3ec4059272ff854cfaa Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Thu, 12 Sep 2024 12:49:28 -0400 Subject: [PATCH 01/26] 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 841a2ce818..96a2ff188d 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 8feecb8960..14c3fbaf24 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 0000000000..1d2548f313 --- /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 0000000000..026db5358a --- /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 0000000000..aaaaaa33b0 --- /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 0000000000..1f0611f6a6 --- /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 0000000000..3af89c6108 --- /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 0000000000..76fec35571 --- /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 0000000000..5b7b0bfe5f --- /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 0000000000..18d002cd2c --- /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 0000000000..dea2d096f6 --- /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 0000000000..4ad0cfe8a6 --- /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 0000000000..f9cebad45c --- /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 0000000000..bbc514e9e2 --- /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 0000000000..e6e7b76432 --- /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 0000000000..6790121e25 --- /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 0000000000..9a05d34773 --- /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 0000000000..0a91c4cbeb --- /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 0000000000..ec7ad93d5d --- /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 0000000000..95d95ee66f --- /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 0000000000..990742edd8 --- /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 0000000000..9f0d16925c --- /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 0000000000..c62192f49a --- /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 0000000000..5e3c3e16a4 --- /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 0000000000..ba98c7f4e9 --- /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 3726c8d11f..e49bbc550a 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 0000000000..34a2bc6a8e --- /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 0000000000..ef010c4fdf --- /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 0000000000..ab7068db91 --- /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 0000000000..dc3ee506d9 --- /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 dc620c308e..e0d82bae66 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 02/26] 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 95d95ee66f..f5281b9e96 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 9f0d16925c..0b0b33984c 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 0000000000..7e06d504f6 --- /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 03/26] 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 eb38ef32cb..799993b74e 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 0000000000..ad9fd70eb9 --- /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 0000000000..d66c94721b --- /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 04/26] 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 3283f13401..f0b1f8c82c 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" ); From 93314ed054b851cafe9c9173103eab1149c56bc4 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 10:22:26 -0500 Subject: [PATCH 05/26] Addressing PR feedback. Deleted some commented lines. Better exception logging in a few spots. --- app/src/org/commcare/CommCareApplication.java | 2 -- .../connect/models/ConnectAppRecord.java | 2 +- .../connect/models/ConnectJobRecord.java | 4 ---- .../commcare/connect/ConnectConstants.java | 4 ++-- .../connect/ConnectDatabaseHelper.java | 19 +++++++++---------- 5 files changed, 12 insertions(+), 19 deletions(-) diff --git a/app/src/org/commcare/CommCareApplication.java b/app/src/org/commcare/CommCareApplication.java index 0ee93c6490..f754fb9a46 100644 --- a/app/src/org/commcare/CommCareApplication.java +++ b/app/src/org/commcare/CommCareApplication.java @@ -555,7 +555,6 @@ public void initializeAppResources(CommCareApp app) { } catch (Exception e) { Log.i("FAILURE", "Problem with loading"); Log.i("FAILURE", "E: " + e.getMessage()); -// e.printStackTrace(); ForceCloseLogger.reportExceptionInBg(e); CrashUtil.reportException(e); resourceState = STATE_CORRUPTED; @@ -927,7 +926,6 @@ 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) { diff --git a/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java index 1d2548f313..143e3dd21b 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java @@ -16,7 +16,7 @@ @Table(ConnectAppRecord.STORAGE_KEY) public class ConnectAppRecord extends Persisted implements Serializable { /** - * Name of database that stores app info for Connect jobs + * Name of table that stores app info for Connect jobs */ public static final String STORAGE_KEY = "connect_apps"; diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index 18d002cd2c..5befe68fd5 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -238,10 +238,6 @@ public static ConnectJobRecord fromJson(JSONObject json) throws JSONException, P job.learnAppInfo = ConnectAppRecord.fromJson(json.getJSONObject(META_LEARN_APP), job.jobId, true); job.deliveryAppInfo = ConnectAppRecord.fromJson(json.getJSONObject(META_DELIVER_APP), job.jobId, false); - //In JSON but not in model - //job.? = json.has(META_DATE_CREATED) ? df.parse(json.getString(META_DATE_CREATED)) : null; - //job.? = json.has(META_DATE_MODIFIED) ? df.parse(json.getString(META_DATE_MODIFIED)) : null; - job.status = STATUS_AVAILABLE; if(job.getLearningCompletePercentage() > 0) { job.status = STATUS_LEARNING; diff --git a/app/src/org/commcare/connect/ConnectConstants.java b/app/src/org/commcare/connect/ConnectConstants.java index 0b0b33984c..90886c4649 100644 --- a/app/src/org/commcare/connect/ConnectConstants.java +++ b/app/src/org/commcare/connect/ConnectConstants.java @@ -6,7 +6,7 @@ * @author dviggiano */ public class ConnectConstants { - public static final int ConnectIdTaskIdOffset = 1000; + public static final int CONNECTID_TASKID_OFFSET = 1000; public static final String USERNAME = "USERNAME"; public static final String PASSWORD = "PASSWORD"; public static final String PIN = "PIN"; @@ -15,5 +15,5 @@ public class ConnectConstants { 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; + public final static int CONNECT_NO_ACTIVITY = ConnectConstants.CONNECTID_TASKID_OFFSET; } diff --git a/app/src/org/commcare/connect/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/ConnectDatabaseHelper.java index c62192f49a..e22b981934 100644 --- a/app/src/org/commcare/connect/ConnectDatabaseHelper.java +++ b/app/src/org/commcare/connect/ConnectDatabaseHelper.java @@ -82,9 +82,9 @@ private static byte[] getConnectDbPassphrase(Context context) { public static String getConnectDbEncodedPassphrase(Context context, boolean local) { try { - ConnectKeyRecord record = getKeyRecord(local); - if (record != null) { - return Base64.encode(EncryptionUtils.decryptFromBase64String(context, record.getEncryptedPassphrase())); + byte[] passBytes = getConnectDbPassphrase(context); + if (passBytes != null) { + return Base64.encode(passBytes); } } catch (Exception e) { Logger.exception("Getting DB passphrase", e); @@ -94,13 +94,11 @@ public static String getConnectDbEncodedPassphrase(Context context, boolean loca } private static ConnectKeyRecord getKeyRecord(boolean local) { - for (ConnectKeyRecord r : CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class)) { - if (r.getIsLocal() == local) { - return r; - } - } + Vector records = CommCareApplication.instance() + .getGlobalStorage(ConnectKeyRecord.class) + .getRecordsForValue(ConnectKeyRecord.IS_LOCAL, local); - return null; + return records.size() > 0 ? records.firstElement() : null; } public static void storeConnectDbPassphrase(Context context, String base64EncodedPassphrase, boolean isLocal) { @@ -162,7 +160,7 @@ public SQLiteDatabase getHandle() { } 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"); + Logger.exception("Corrupt Connect DB", e); } } return connectDatabase; @@ -194,6 +192,7 @@ public static ConnectUserRecord getUser(Context context) { break; } } catch (Exception e) { + Logger.exception("Corrupt Connect DB trying to get user", e); dbBroken = true; } } From 906137dddda08bff6efd751db158a01e8209306b Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 11:08:58 -0500 Subject: [PATCH 06/26] Simplified linkHqWorker to take ConnectLinkedAppRecord from caller instead of retrieving it again. --- .../connect/network/ApiConnectId.java | 34 ++++++++----------- 1 file changed, 15 insertions(+), 19 deletions(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index 7e06d504f6..a70c0b8f23 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -30,29 +30,25 @@ 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); + public static void linkHqWorker(Context context, String hqUsername, ConnectLinkedAppRecord appRecord, String connectToken) { + HashMap params = new HashMap<>(); + params.put("token", connectToken); - String url = ServerUrls.getKeyServer().replace("phone/keys/", - "settings/users/commcare/link_connectid_user/"); + 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(); + try { + ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, + API_VERSION_NONE, new AuthInfo.ProvidedAuth(hqUsername, appRecord.getPassword()), 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 + //Remember that we linked the user successfully + appRecord.setWorkerLinked(true); + ConnectDatabaseHelper.storeApp(context, appRecord); } + } catch (IOException e) { + //Don't care for now } } From a8bcc43e14e562c77ae53524f3dd16f3c04010d0 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 11:18:44 -0500 Subject: [PATCH 07/26] Better error handling when linkHqWorker fails --- app/src/org/commcare/connect/network/ApiConnectId.java | 7 ++++++- 1 file changed, 6 insertions(+), 1 deletion(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index a70c0b8f23..ff7435f3b8 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -40,15 +40,20 @@ public static void linkHqWorker(Context context, String hqUsername, ConnectLinke try { ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, API_VERSION_NONE, new AuthInfo.ProvidedAuth(hqUsername, appRecord.getPassword()), params, true, false); - if (postResult.e == null && postResult.responseCode == 200) { + if(postResult.e != null) { + Logger.exception("Network error linking HQ worker", postResult.e); + } else if (postResult.responseCode == 200) { postResult.responseStream.close(); //Remember that we linked the user successfully appRecord.setWorkerLinked(true); ConnectDatabaseHelper.storeApp(context, appRecord); + } else { + Logger.log("API Error", "API call to link HQ worker failed with code " + postResult.responseCode); } } catch (IOException e) { //Don't care for now + Logger.exception("Error linking HQ worker", e); } } From 445ec10a8f6c35d86471993b21af55a08d020048 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 11:41:12 -0500 Subject: [PATCH 08/26] Added ServerUrls.buildEndpoint helper method to build new endpoints using the configured key server and protocol --- .../org/commcare/connect/network/ApiConnectId.java | 9 +-------- app/src/org/commcare/preferences/ServerUrls.java | 12 ++++++++++++ 2 files changed, 13 insertions(+), 8 deletions(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index ff7435f3b8..4a62a52532 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -65,14 +65,7 @@ public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUs 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/"; + String url = ServerUrls.buildEndpoint("oauth/token/"); ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, API_VERSION_NONE, new AuthInfo.NoAuth(), params, true, false); diff --git a/app/src/org/commcare/preferences/ServerUrls.java b/app/src/org/commcare/preferences/ServerUrls.java index 103a692d2b..ed980d52d0 100644 --- a/app/src/org/commcare/preferences/ServerUrls.java +++ b/app/src/org/commcare/preferences/ServerUrls.java @@ -6,6 +6,9 @@ import org.commcare.CommCareApplication; import org.commcare.dalvik.R; +import java.net.MalformedURLException; +import java.net.URL; + /** * Created by amstone326 on 11/14/17. */ @@ -31,6 +34,15 @@ public static String getDataServerKey() { .getString(R.string.ota_restore_url)) ; } + public static String buildEndpoint(String path) { + try { + URL originalUrl = new URL(getKeyServer()); + return new URL(originalUrl, path).toString(); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } + } + public static String getKeyServer() { return CommCareApplication.instance().getCurrentApp().getAppPreferences() .getString(PREFS_KEY_SERVER_KEY, null); From b4dad7645467d93874873fc73347f7861b1e172f Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 12:26:50 -0500 Subject: [PATCH 09/26] Added SsoToken class with common code for retrieving token info from API response. --- .../models/ConnectLinkedAppRecord.java | 7 +-- .../connect/models/ConnectUserRecord.java | 7 +-- .../connect/ConnectDatabaseHelper.java | 5 ++- .../connect/network/ApiConnectId.java | 45 ++++++------------- .../commcare/connect/network/SsoToken.java | 38 ++++++++++++++++ 5 files changed, 62 insertions(+), 40 deletions(-) create mode 100644 app/src/org/commcare/connect/network/SsoToken.java diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java index e6e7b76432..61277f0461 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java @@ -1,6 +1,7 @@ package org.commcare.android.database.connect.models; import org.commcare.android.storage.framework.Persisted; +import org.commcare.connect.network.SsoToken; import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; @@ -111,9 +112,9 @@ public Date getHqTokenExpiration() { return hqTokenExpiration; } - public void updateHqToken(String token, Date expirationDate) { - hqToken = token; - hqTokenExpiration = expirationDate; + public void updateHqToken(SsoToken token) { + hqToken = token.token; + hqTokenExpiration = token.expiration; } public boolean getConnectIdLinked() { return connectIdLinked; } 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 f5281b9e96..5874fb8f61 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.connect.network.SsoToken; import org.commcare.core.network.AuthInfo; import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; @@ -190,9 +191,9 @@ public boolean shouldRequireSecondaryPhoneVerification() { return (new Date()).after(verifySecondaryPhoneByDate); } - public void updateConnectToken(String token, Date expirationDate) { - connectToken = token; - connectTokenExpiration = expirationDate; + public void updateConnectToken(SsoToken token) { + connectToken = token.token; + connectTokenExpiration = token.expiration; } public AuthInfo.TokenAuth getConnectToken() { diff --git a/app/src/org/commcare/connect/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/ConnectDatabaseHelper.java index e22b981934..ae4cb5df96 100644 --- a/app/src/org/commcare/connect/ConnectDatabaseHelper.java +++ b/app/src/org/commcare/connect/ConnectDatabaseHelper.java @@ -18,6 +18,7 @@ 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.connect.network.SsoToken; import org.commcare.dalvik.R; import org.commcare.models.database.AndroidDbHelper; import org.commcare.models.database.SqlStorage; @@ -248,13 +249,13 @@ 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) { + public static void storeHqToken(Context context, String appId, String userId, SsoToken token) { ConnectLinkedAppRecord record = getAppData(context, appId, userId); if (record == null) { record = new ConnectLinkedAppRecord(appId, userId, false, ""); } - record.updateHqToken(token, expiration); + record.updateHqToken(token); getConnectStorage(context, ConnectLinkedAppRecord.class).write(record); } diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index 4a62a52532..d414fa8f52 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -21,6 +21,7 @@ import org.json.JSONObject; import java.io.IOException; +import java.io.InputStream; import java.net.MalformedURLException; import java.net.URL; import java.util.Date; @@ -71,22 +72,12 @@ public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUs 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); - } + SsoToken token = SsoToken.fromResponseStream(postResult.responseStream); + + String seatedAppId = CommCareApplication.instance().getCurrentApp().getUniqueId(); + ConnectDatabaseHelper.storeHqToken(context, seatedAppId, hqUsername, token); + + return new AuthInfo.TokenAuth(token.token); } catch (IOException | JSONException e) { Logger.exception("Parsing return from HQ OIDC call", e); } @@ -130,22 +121,12 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { 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); - } + SsoToken token = SsoToken.fromResponseStream(postResult.responseStream); + + user.updateConnectToken(token); + ConnectDatabaseHelper.storeUser(context, user); + + return new AuthInfo.TokenAuth(token.token); } catch (IOException | JSONException e) { Logger.exception("Parsing return from Connect OIDC call", e); } diff --git a/app/src/org/commcare/connect/network/SsoToken.java b/app/src/org/commcare/connect/network/SsoToken.java new file mode 100644 index 0000000000..26f58bad4b --- /dev/null +++ b/app/src/org/commcare/connect/network/SsoToken.java @@ -0,0 +1,38 @@ +package org.commcare.connect.network; + +import org.commcare.connect.ConnectConstants; +import org.javarosa.core.io.StreamsUtil; +import org.json.JSONException; +import org.json.JSONObject; + +import java.io.IOException; +import java.io.InputStream; +import java.util.Date; + +public class SsoToken { + public String token; + public Date expiration; + + public SsoToken(String token, Date expiration) { + this.token = token; + this.expiration = expiration; + } + + public static SsoToken fromResponseStream(InputStream stream) throws IOException, JSONException { + String responseAsString = new String(StreamsUtil.inputStreamToByteArray( + stream)); + JSONObject json = new JSONObject(responseAsString); + String key = ConnectConstants.CONNECT_KEY_TOKEN; + + if (!json.has(key)) { + throw new RuntimeException("SSO API response missing access token"); + } + 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)); + + return new SsoToken(token, expiration); + } +} From 9312dfd821840ab74417f9798b1e576c10303257 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 14:48:27 -0500 Subject: [PATCH 10/26] Removed date-related functions from network helper class, using existing DateUtils functions instead. --- .../models/ConnectJobAssessmentRecord.java | 3 ++- .../models/ConnectJobDeliveryRecord.java | 5 ++-- .../models/ConnectJobLearningRecord.java | 3 ++- .../models/ConnectJobPaymentRecord.java | 8 +++--- .../models/ConnectJobPaymentRecordV3.java | 10 ------- .../connect/models/ConnectJobRecord.java | 9 ++++--- .../connect/network/ConnectNetworkHelper.java | 26 ------------------- 7 files changed, 17 insertions(+), 47 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java index 026db5358a..d4d224de1d 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java @@ -5,6 +5,7 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.javarosa.core.model.utils.DateUtils; import org.json.JSONException; import org.json.JSONObject; @@ -58,7 +59,7 @@ public static ConnectJobAssessmentRecord fromJson(JSONObject json, int jobId) th record.lastUpdate = new Date(); record.jobId = jobId; - record.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + record.date = json.has(META_DATE) ? DateUtils.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); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java index aaaaaa33b0..77faa1a5dc 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java @@ -6,6 +6,7 @@ import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; import org.commcare.utils.CrashUtil; +import org.javarosa.core.model.utils.DateUtils; import org.json.JSONException; import org.json.JSONObject; @@ -83,7 +84,7 @@ public static ConnectJobDeliveryRecord fromJson(JSONObject json, int jobId) thro deliveryId = json.has(META_ID) ? json.getInt(META_ID) : -1; delivery.deliveryId = deliveryId; dateString = json.getString(META_DATE); - delivery.date = ConnectNetworkHelper.convertUTCToDate(dateString); + delivery.date = DateUtils.parseDateTime(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) : ""; @@ -102,7 +103,7 @@ public static ConnectJobDeliveryRecord fromJson(JSONObject json, int jobId) thro } public int getDeliveryId() { return deliveryId; } - public Date getDate() { return ConnectNetworkHelper.convertDateToLocal(date); } + public Date getDate() { return date; } public String getStatus() { return status; } public String getEntityName() { return entityName; } public void setLastUpdate(Date lastUpdate) { this.lastUpdate = 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 index 3af89c6108..072be0fad7 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java @@ -5,6 +5,7 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.javarosa.core.model.utils.DateUtils; import org.json.JSONException; import org.json.JSONObject; @@ -54,7 +55,7 @@ public static ConnectJobLearningRecord fromJson(JSONObject json, int jobId) thro record.lastUpdate = new Date(); record.jobId = jobId; - record.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + record.date = json.has(META_DATE) ? DateUtils.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) : ""; diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java index 76fec35571..503811a560 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java @@ -5,6 +5,7 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.javarosa.core.model.utils.DateUtils; import org.json.JSONException; import org.json.JSONObject; @@ -67,16 +68,17 @@ public static ConnectJobPaymentRecord fromV3(ConnectJobPaymentRecordV3 oldRecord return newRecord; } - public static ConnectJobPaymentRecord fromJson(JSONObject json, int jobId) throws JSONException, ParseException { + public static ConnectJobPaymentRecord fromJson(JSONObject json, int jobId) throws JSONException { ConnectJobPaymentRecord payment = new ConnectJobPaymentRecord(); payment.jobId = jobId; - payment.date = json.has(META_DATE) ? ConnectNetworkHelper.parseDate(json.getString(META_DATE)) : new Date(); + payment.date = json.has(META_DATE) ? DateUtils.parseDateTime(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(); + payment.confirmedDate = json.has(META_CONFIRMED_DATE) && !json.isNull(META_CONFIRMED_DATE) ? + DateUtils.parseDate(json.getString(META_CONFIRMED_DATE)) : new Date(); return payment; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java index 5b7b0bfe5f..a57169d053 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecordV3.java @@ -36,16 +36,6 @@ public class ConnectJobPaymentRecordV3 extends Persisted implements Serializable 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;} diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index 5befe68fd5..1f38e7ab55 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -5,6 +5,7 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.javarosa.core.model.utils.DateUtils; import org.joda.time.LocalDate; import org.json.JSONArray; import org.json.JSONException; @@ -163,8 +164,8 @@ public static ConnectJobRecord fromJson(JSONObject json) throws JSONException, P 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.projectEndDate = json.has(META_END_DATE) ? DateUtils.parseDate(json.getString(META_END_DATE)) : new Date(); + job.projectStartDate = json.has(META_START_DATE) ? DateUtils.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; @@ -205,12 +206,12 @@ public static ConnectJobRecord fromJson(JSONObject json) throws JSONException, P key = META_END_DATE; if (claim.has(key)) { - job.projectEndDate = ConnectNetworkHelper.parseDate(claim.getString(key)); + job.projectEndDate = DateUtils.parseDate(claim.getString(key)); } key = META_CLAIM_DATE; if (claim.has(key)) { - job.dateClaimed = ConnectNetworkHelper.parseDate(claim.getString(key)); + job.dateClaimed = DateUtils.parseDate(claim.getString(key)); } key = META_PAYMENT_UNITS; diff --git a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java index 5e3c3e16a4..5099974a19 100644 --- a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java +++ b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java @@ -80,32 +80,6 @@ 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; } From 3d9b7eac192f9260dec58769589130f78abc46af Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 15:29:49 -0500 Subject: [PATCH 11/26] Extracted common code for building POST data from parameters, to be used in both sync and async POST functions. --- .../connect/network/ApiConnectId.java | 4 +- .../connect/network/ConnectNetworkHelper.java | 55 ++++++++----------- 2 files changed, 24 insertions(+), 35 deletions(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index d414fa8f52..efadf7dbed 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -41,9 +41,7 @@ public static void linkHqWorker(Context context, String hqUsername, ConnectLinke try { ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, API_VERSION_NONE, new AuthInfo.ProvidedAuth(hqUsername, appRecord.getPassword()), params, true, false); - if(postResult.e != null) { - Logger.exception("Network error linking HQ worker", postResult.e); - } else if (postResult.responseCode == 200) { + if (postResult.responseCode == 200) { postResult.responseStream.close(); //Remember that we linked the user successfully diff --git a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java index 5099974a19..faae404a7f 100644 --- a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java +++ b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java @@ -138,23 +138,7 @@ public static PostResult postSync(Context context, String url, String version, A 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); + RequestBody requestBody = buildPostFormHeaders(params, useFormEncoding, version, headers); ModernHttpRequester requester = CommCareApplication.instance().buildHttpRequester( context, @@ -182,6 +166,7 @@ public static PostResult postSync(Context context, String url, String version, A } } catch (IOException e) { exception = e; + Logger.exception("Exception during POST", e); } instance.onFinishProcessing(context, background); @@ -192,7 +177,7 @@ public static PostResult postSync(Context context, String url, String version, A if(!background) { setCallInProgress(null); } - return new PostResult(-1, null, null); + return new PostResult(-1, null, e); } } @@ -209,6 +194,23 @@ private boolean postInternal(Context context, String url, String version, AuthIn } HashMap headers = new HashMap<>(); + RequestBody requestBody = buildPostFormHeaders(params, useFormEncoding, version, headers); + + 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 RequestBody buildPostFormHeaders(HashMap params, boolean useFormEncoding, String version, HashMap outputHeaders) { RequestBody requestBody; if (useFormEncoding) { @@ -218,27 +220,16 @@ private boolean postInternal(Context context, String url, String version, AuthIn } requestBody = ModernHttpRequester.getPostBody(multimap); - headers = getContentHeadersForXFormPost(requestBody); + outputHeaders = 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(); + addVersionHeader(outputHeaders, version); - return true; + return requestBody; } private static HashMap getContentHeadersForXFormPost(RequestBody postBody) { From b2aa12110f7bc78c2d2378aac98e745ca727e8b4 Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 15:41:23 -0500 Subject: [PATCH 12/26] Moved Connect-related classes to v2.55 section --- .../android/tests/processing/FormStorageTest.java | 8 ++++---- 1 file changed, 4 insertions(+), 4 deletions(-) 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 2846a7bc91..7008949466 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,6 +355,10 @@ public class FormStorageTest { , "org.commcare.suite.model.EndpointArgument" , "org.commcare.suite.model.EndpointAction" , "org.commcare.suite.model.QueryGroup" + + // Added in 2.55 + , "org.javarosa.core.model.FormIndex" + , "org.commcare.models.database.InterruptedFormState" , "org.commcare.android.database.connect.models.ConnectLinkedAppRecordV3" , "org.commcare.android.database.connect.models.ConnectLinkedAppRecordV8" , "org.commcare.android.database.connect.models.ConnectLinkedAppRecordV9" @@ -376,10 +380,6 @@ public class FormStorageTest { , "org.commcare.android.database.global.models.ConnectKeyRecord" , "org.commcare.android.database.global.models.ConnectKeyRecordV6" , "org.commcare.android.database.connect.models.ConnectPaymentUnitRecord" - - // Added in 2.55 - , "org.javarosa.core.model.FormIndex" - , "org.commcare.models.database.InterruptedFormState" ); From ea2db9386e4535a6b108b3338509af7fb3edc87e Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Wed, 4 Dec 2024 15:54:37 -0500 Subject: [PATCH 13/26] Added static helper class to lazy load KeyStore singleton. --- .../connect/network/ConnectNetworkHelper.java | 9 ++---- .../commcare/utils/EncryptionKeyProvider.java | 31 ++++++++++++------- 2 files changed, 22 insertions(+), 18 deletions(-) diff --git a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java index faae404a7f..ca2f8ad65b 100644 --- a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java +++ b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java @@ -30,14 +30,9 @@ 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; @@ -57,9 +52,9 @@ public class ConnectNetworkHelper { public static class PostResult { public final int responseCode; public final InputStream responseStream; - public final IOException e; + public final Exception e; - public PostResult(int responseCode, InputStream responseStream, IOException e) { + public PostResult(int responseCode, InputStream responseStream, Exception e) { this.responseCode = responseCode; this.responseStream = responseStream; this.e = e; diff --git a/app/src/org/commcare/utils/EncryptionKeyProvider.java b/app/src/org/commcare/utils/EncryptionKeyProvider.java index dc3ee506d9..bf657e5472 100644 --- a/app/src/org/commcare/utils/EncryptionKeyProvider.java +++ b/app/src/org/commcare/utils/EncryptionKeyProvider.java @@ -7,6 +7,8 @@ import android.security.keystore.KeyGenParameterSpec; import android.security.keystore.KeyProperties; +import org.javarosa.core.services.Logger; + import androidx.annotation.RequiresApi; import java.io.IOException; @@ -42,16 +44,24 @@ public class EncryptionKeyProvider { 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); + private static class KeyStoreLoader { + static final KeyStore INSTANCE; + + static { + try { + INSTANCE = KeyStore.getInstance(KEYSTORE_NAME); + INSTANCE.load(null); + } catch (KeyStoreException | CertificateException | IOException | + NoSuchAlgorithmException e) { + Logger.exception("Initiating KeyStore", e); + throw new RuntimeException(e); + } } + } - return keystoreSingleton; + private static KeyStore getKeystore() { + return KeyStoreLoader.INSTANCE; } public EncryptionKeyAndTransform getKey(Context context, boolean trueForEncrypt) @@ -62,8 +72,8 @@ public EncryptionKeyAndTransform getKey(Context context, boolean 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 { + throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableEntryException, + InvalidAlgorithmParameterException, NoSuchProviderException { if (doesKeystoreContainEncryptionKey()) { KeyStore.Entry existingKey = keystore.getEntry(SECRET_NAME, null); @@ -81,8 +91,7 @@ private static EncryptionKeyAndTransform getKey(Context context, KeyStore keysto } } - private static boolean doesKeystoreContainEncryptionKey() throws CertificateException, - KeyStoreException, IOException, NoSuchAlgorithmException { + private static boolean doesKeystoreContainEncryptionKey() throws KeyStoreException { KeyStore keystore = getKeystore(); return keystore.containsAlias(SECRET_NAME); From d412539233cbfef2cf2f02236f5ce3d5427e9eee Mon Sep 17 00:00:00 2001 From: Dave Viggiano Date: Thu, 5 Dec 2024 11:31:22 -0500 Subject: [PATCH 14/26] Lint --- app/src/org/commcare/utils/EncryptionKeyProvider.java | 7 +++---- 1 file changed, 3 insertions(+), 4 deletions(-) diff --git a/app/src/org/commcare/utils/EncryptionKeyProvider.java b/app/src/org/commcare/utils/EncryptionKeyProvider.java index bf657e5472..02060dd3bd 100644 --- a/app/src/org/commcare/utils/EncryptionKeyProvider.java +++ b/app/src/org/commcare/utils/EncryptionKeyProvider.java @@ -9,8 +9,6 @@ import org.javarosa.core.services.Logger; -import androidx.annotation.RequiresApi; - import java.io.IOException; import java.math.BigInteger; import java.security.InvalidAlgorithmParameterException; @@ -29,6 +27,8 @@ import javax.crypto.KeyGenerator; import javax.security.auth.x500.X500Principal; +import androidx.annotation.RequiresApi; + /** * Class for providing encryption keys backed by Android Keystore * @@ -79,8 +79,7 @@ private static EncryptionKeyAndTransform getKey(Context context, KeyStore keysto 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) { + } else if (existingKey instanceof KeyStore.PrivateKeyEntry entry) { Key key = trueForEncrypt ? entry.getCertificate().getPublicKey() : entry.getPrivateKey(); return new EncryptionKeyAndTransform(key, getTransformationString(true)); } else { From aa616b4538bcc26a7f9820a293011e4f35ffdc71 Mon Sep 17 00:00:00 2001 From: parthmittal Date: Thu, 19 Dec 2024 10:56:43 +0530 Subject: [PATCH 15/26] -pr review bug fixes broke the database helper class in small classes --- .../database/ConnectAppDatabseUtil.java | 48 ++ .../database/ConnectDatabaseHelper.java | 119 ++++ .../database/ConnectDatabaseUtils.java | 79 +++ .../ConnectJobUtils.java} | 668 ++++++------------ .../database/ConnectUserDatabaseUtil.java | 38 + .../connect/network/ApiConnectId.java | 17 +- .../tasks/ConnectionDiagnosticTask.java | 18 +- .../commcare/utils/ConnectivityStatus.java | 19 +- .../commcare/utils/EncryptionKeyProvider.java | 20 +- .../org/commcare/utils/EncryptionUtils.java | 8 +- .../utils/MockEncryptionKeyProvider.java | 3 +- 11 files changed, 531 insertions(+), 506 deletions(-) create mode 100644 app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java create mode 100644 app/src/org/commcare/connect/database/ConnectDatabaseHelper.java create mode 100644 app/src/org/commcare/connect/database/ConnectDatabaseUtils.java rename app/src/org/commcare/connect/{ConnectDatabaseHelper.java => database/ConnectJobUtils.java} (64%) create mode 100644 app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java diff --git a/app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java b/app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java new file mode 100644 index 0000000000..fd632defd0 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java @@ -0,0 +1,48 @@ +package org.commcare.connect.database; + +import android.content.Context; + +import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.models.database.SqlStorage; + +import java.util.Vector; + +public class ConnectAppDatabseUtil { + public static ConnectLinkedAppRecord getAppData(Context context, String appId, String username) { + Vector records = ConnectDatabaseHelper.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 = ConnectDatabaseHelper.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) { + ConnectDatabaseHelper.getConnectStorage(context, ConnectLinkedAppRecord.class).write(record); + } +} diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java new file mode 100644 index 0000000000..1e4ac0aae5 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java @@ -0,0 +1,119 @@ +package org.commcare.connect.database; + +import android.content.Context; +import android.widget.Toast; + +import net.sqlcipher.database.SQLiteDatabase; + +import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.connect.network.SsoToken; +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.javarosa.core.services.Logger; +import org.javarosa.core.services.storage.Persistable; + +/** + * Helper class for accessing the Connect DB + * + * @author dviggiano + */ +public class ConnectDatabaseHelper { + private static final Object connectDbHandleLock = new Object(); + private static SQLiteDatabase connectDatabase; + static boolean dbBroken = false; + + public static void handleReceivedDbPassphrase(Context context, String remotePassphrase) { + ConnectDatabaseUtils.storeConnectDbPassphrase(context, remotePassphrase, false); + try { + String localPassphrase = ConnectDatabaseUtils.getConnectDbEncodedPassphrase(context, true); + + if (!remotePassphrase.equals(localPassphrase)) { + DatabaseConnectOpenHelper.rekeyDB(connectDatabase, remotePassphrase); + ConnectDatabaseUtils.storeConnectDbPassphrase(context, remotePassphrase, true); + } + } catch (Exception e) { + Logger.exception("Handling received DB passphrase", e); + handleCorruptDb(context); + } + } + + public static boolean dbExists(Context context) { + return DatabaseConnectOpenHelper.dbExists(context); + } + + public static boolean isDbBroken() { + return dbBroken; + } + + 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 = ConnectDatabaseUtils.getConnectDbPassphrase(context); + + DatabaseConnectOpenHelper helper = new DatabaseConnectOpenHelper(this.c); + + String remotePassphrase = ConnectDatabaseUtils.getConnectDbEncodedPassphrase(context, false); + String localPassphrase = ConnectDatabaseUtils.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.exception("Corrupt Connect DB", e); + } + } + return connectDatabase; + } + } + }); + } + + public static void teardown() { + synchronized (connectDbHandleLock) { + if (connectDatabase != null && connectDatabase.isOpen()) { + connectDatabase.close(); + connectDatabase = null; + } + } + } + + public static void handleCorruptDb(Context context) { + ConnectUserDatabaseUtil.forgetUser(context); + Toast.makeText(context, context.getString(R.string.connect_db_corrupt), Toast.LENGTH_LONG).show(); + } + + public static void storeHqToken(Context context, String appId, String userId, SsoToken token) { + ConnectLinkedAppRecord record = ConnectAppDatabseUtil.getAppData(context, appId, userId); + if (record == null) { + record = new ConnectLinkedAppRecord(appId, userId, false, ""); + } + + record.updateHqToken(token); + + getConnectStorage(context, ConnectLinkedAppRecord.class).write(record); + } + + public static void setRegistrationPhase(Context context, int phase) { + ConnectUserRecord user = ConnectUserDatabaseUtil.getUser(context); + if (user != null) { + user.setRegistrationPhase(phase); + ConnectUserDatabaseUtil.storeUser(context, user); + } + } + + +} diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java new file mode 100644 index 0000000000..d1d3cd12c3 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java @@ -0,0 +1,79 @@ +package org.commcare.connect.database; +import android.content.Context; +import org.commcare.CommCareApplication; +import org.commcare.android.database.global.models.ConnectKeyRecord; +import org.commcare.util.Base64; +import org.commcare.utils.EncryptionUtils; +import org.javarosa.core.services.Logger; +import java.util.Vector; + +public class ConnectDatabaseUtils { + 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); + } + } + + + static ConnectKeyRecord getKeyRecord(boolean local) { + Vector records = CommCareApplication.instance() + .getGlobalStorage(ConnectKeyRecord.class) + .getRecordsForValue(ConnectKeyRecord.IS_LOCAL, local); + + return records.size() > 0 ? records.firstElement() : 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 String getConnectDbEncodedPassphrase(Context context, boolean local) { + try { + byte[] passBytes = getConnectDbPassphrase(context); + if (passBytes != null) { + return Base64.encode(passBytes); + } + } catch (Exception e) { + Logger.exception("Getting DB passphrase", e); + } + + return null; + } + + public static byte[] getConnectDbPassphrase(Context context) { + try { + ConnectKeyRecord record = ConnectDatabaseUtils.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(); + ConnectDatabaseUtils.storeConnectDbPassphrase(context, passphrase, true); + + return passphrase; + } catch (Exception e) { + Logger.exception("Getting DB passphrase", e); + throw new RuntimeException(e); + } + } + +} diff --git a/app/src/org/commcare/connect/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/database/ConnectJobUtils.java similarity index 64% rename from app/src/org/commcare/connect/ConnectDatabaseHelper.java rename to app/src/org/commcare/connect/database/ConnectJobUtils.java index ae4cb5df96..316362d88d 100644 --- a/app/src/org/commcare/connect/ConnectDatabaseHelper.java +++ b/app/src/org/commcare/connect/database/ConnectJobUtils.java @@ -1,12 +1,8 @@ -package org.commcare.connect; +package org.commcare.connect.database; 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; @@ -14,21 +10,8 @@ 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.connect.network.SsoToken; -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; @@ -37,271 +20,7 @@ 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 { - byte[] passBytes = getConnectDbPassphrase(context); - if (passBytes != null) { - return Base64.encode(passBytes); - } - } catch (Exception e) { - Logger.exception("Getting DB passphrase", e); - } - - return null; - } - - private static ConnectKeyRecord getKeyRecord(boolean local) { - Vector records = CommCareApplication.instance() - .getGlobalStorage(ConnectKeyRecord.class) - .getRecordsForValue(ConnectKeyRecord.IS_LOCAL, local); - - return records.size() > 0 ? records.firstElement() : 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.exception("Corrupt Connect DB", e); - } - } - 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) { - Logger.exception("Corrupt Connect DB trying to get user", 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, SsoToken token) { - ConnectLinkedAppRecord record = getAppData(context, appId, userId); - if (record == null) { - record = new ConnectLinkedAppRecord(appId, userId, false, ""); - } - - record.updateHqToken(token); - - 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 class ConnectJobUtils { public static void upsertJob(Context context, ConnectJobRecord job) { List list = new ArrayList<>(); list.add(job); @@ -309,11 +28,11 @@ public static void upsertJob(Context context, ConnectJobRecord job) { } 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, + SqlStorage jobStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobRecord.class); + SqlStorage appInfoStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectAppRecord.class); + SqlStorage moduleStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectLearnModuleSummaryRecord.class); - SqlStorage paymentUnitStorage = getConnectStorage(context, + SqlStorage paymentUnitStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectPaymentUnitRecord.class); List existingList = getJobs(context, -1, jobStorage); @@ -470,165 +189,8 @@ public static int storeJobs(Context context, List jobs, boolea 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( + Vector jobs = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobRecord.class).getRecordsForValues( new String[]{ConnectJobRecord.META_JOB_ID}, new Object[]{jobId}); @@ -639,7 +201,7 @@ public static ConnectJobRecord getJob(Context context, int jobId) { public static List getJobs(Context context, int status, SqlStorage jobStorage) { if (jobStorage == null) { - jobStorage = getConnectStorage(context, ConnectJobRecord.class); + jobStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobRecord.class); } Vector jobs; @@ -657,13 +219,13 @@ public static List getJobs(Context context, int status, SqlSto } 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); + SqlStorage appInfoStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectAppRecord.class); + SqlStorage moduleStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectLearnModuleSummaryRecord.class); + SqlStorage deliveryStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobDeliveryRecord.class); + SqlStorage paymentStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobPaymentRecord.class); + SqlStorage learningStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobLearningRecord.class); + SqlStorage assessmentStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobAssessmentRecord.class); + SqlStorage paymentUnitStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectPaymentUnitRecord.class); for (ConnectJobRecord job : jobs) { //Retrieve learn and delivery app info Vector existingAppInfos = appInfoStorage.getRecordsForValues( @@ -777,9 +339,85 @@ public static List getFinishedJobs(Context context, SqlStorage return filtered; } + public static void storeDeliveries(Context context, List deliveries, int jobId, boolean pruneMissing) { + SqlStorage storage = ConnectDatabaseHelper.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 = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobPaymentRecord.class); + storage.write(payment); + } + + public static void storePayments(Context context, List payments, int jobId, boolean pruneMissing) { + SqlStorage storage = ConnectDatabaseHelper.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 List getDeliveries(Context context, int jobId, SqlStorage deliveryStorage) { if (deliveryStorage == null) { - deliveryStorage = getConnectStorage(context, ConnectJobDeliveryRecord.class); + deliveryStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobDeliveryRecord.class); } Vector deliveries = deliveryStorage.getRecordsForValues( @@ -791,7 +429,7 @@ public static List getDeliveries(Context context, int public static List getPayments(Context context, int jobId, SqlStorage paymentStorage) { if (paymentStorage == null) { - paymentStorage = getConnectStorage(context, ConnectJobPaymentRecord.class); + paymentStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobPaymentRecord.class); } Vector payments = paymentStorage.getRecordsForValues( @@ -803,7 +441,7 @@ public static List getPayments(Context context, int job public static List getLearnings(Context context, int jobId, SqlStorage learningStorage) { if (learningStorage == null) { - learningStorage = getConnectStorage(context, ConnectJobLearningRecord.class); + learningStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobLearningRecord.class); } Vector learnings = learningStorage.getRecordsForValues( @@ -815,7 +453,7 @@ public static List getLearnings(Context context, int j public static List getAssessments(Context context, int jobId, SqlStorage assessmentStorage) { if (assessmentStorage == null) { - assessmentStorage = getConnectStorage(context, ConnectJobAssessmentRecord.class); + assessmentStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobAssessmentRecord.class); } Vector assessments = assessmentStorage.getRecordsForValues( @@ -824,4 +462,120 @@ public static List getAssessments(Context context, i return new ArrayList<>(assessments); } + + public static void storeAssessments(Context context, List assessments, int jobId, boolean pruneMissing) { + SqlStorage storage = ConnectDatabaseHelper.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 updateJobLearnProgress(Context context, ConnectJobRecord job) { + SqlStorage jobStorage = ConnectDatabaseHelper.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 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 storeLearningRecords(Context context, List learnings, int jobId, boolean pruneMissing) { + SqlStorage storage = ConnectDatabaseHelper.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 ConnectAppRecord getAppRecord(Context context, String appId) { + Vector records = ConnectDatabaseHelper.getConnectStorage(context, ConnectAppRecord.class).getRecordsForValues( + new String[]{ConnectAppRecord.META_APP_ID}, + new Object[]{appId}); + return records.isEmpty() ? null : records.firstElement(); + } + } diff --git a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java new file mode 100644 index 0000000000..94e172a4f3 --- /dev/null +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -0,0 +1,38 @@ +package org.commcare.connect.database; + +import android.content.Context; + +import org.commcare.CommCareApplication; +import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.android.database.global.models.ConnectKeyRecord; +import org.commcare.models.database.connect.DatabaseConnectOpenHelper; +import org.javarosa.core.services.Logger; + +public class ConnectUserDatabaseUtil { + public static ConnectUserRecord getUser(Context context) { + ConnectUserRecord user = null; + if (ConnectDatabaseHelper.dbExists(context)) { + try { + for (ConnectUserRecord r : ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class)) { + user = r; + break; + } + } catch (Exception e) { + Logger.exception("Corrupt Connect DB trying to get user", e); + ConnectDatabaseHelper.dbBroken = true; + } + } + + return user; + } + + public static void storeUser(Context context, ConnectUserRecord user) { + ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class).write(user); + } + + public static void forgetUser(Context context) { + DatabaseConnectOpenHelper.deleteDb(context); + CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); + ConnectDatabaseHelper.dbBroken = false; + } +} diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index efadf7dbed..d2628a3ba8 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -7,24 +7,19 @@ 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.connect.database.ConnectAppDatabseUtil; +import org.commcare.connect.database.ConnectDatabaseHelper; import org.commcare.android.database.connect.models.ConnectUserRecord; +import org.commcare.connect.database.ConnectUserDatabaseUtil; 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.io.InputStream; -import java.net.MalformedURLException; -import java.net.URL; -import java.util.Date; import java.util.HashMap; public class ApiConnectId { @@ -46,7 +41,7 @@ public static void linkHqWorker(Context context, String hqUsername, ConnectLinke //Remember that we linked the user successfully appRecord.setWorkerLinked(true); - ConnectDatabaseHelper.storeApp(context, appRecord); + ConnectAppDatabseUtil.storeApp(context, appRecord); } else { Logger.log("API Error", "API call to link HQ worker failed with code " + postResult.responseCode); } @@ -98,7 +93,7 @@ public static ConnectNetworkHelper.PostResult makeHeartbeatRequestSync(Context c } public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { - ConnectUserRecord user = ConnectDatabaseHelper.getUser(context); + ConnectUserRecord user = ConnectUserDatabaseUtil.getUser(context); if (user != null) { AuthInfo.TokenAuth connectToken = user.getConnectToken(); @@ -122,7 +117,7 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { SsoToken token = SsoToken.fromResponseStream(postResult.responseStream); user.updateConnectToken(token); - ConnectDatabaseHelper.storeUser(context, user); + ConnectUserDatabaseUtil.storeUser(context, user); return new AuthInfo.TokenAuth(token.token); } catch (IOException | JSONException e) { diff --git a/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java b/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java index fafd8e8baa..b0b9875002 100644 --- a/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java +++ b/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java @@ -8,6 +8,7 @@ import org.commcare.core.network.CommCareNetworkService; import org.commcare.core.network.CommCareNetworkServiceGenerator; import org.commcare.tasks.templates.CommCareTask; +import org.commcare.utils.ConnectivityStatus; import org.javarosa.core.services.Logger; import java.io.IOException; @@ -44,11 +45,6 @@ public enum Test { private static final String commcareHTML = "success"; private static final String pingPrefix = "ping -c 1 "; - - //the various log messages that will be returned regarding the outcomes of the tests - private static final String logNotConnectedMessage = "Network test: Not connected."; - private static final String logConnectionSuccessMessage = "Network test: Success."; - private static final String logGoogleNullPointerMessage = "Google ping test: Process could not be started."; private static final String logGoogleIOErrorMessage = "Google ping test: Local error."; private static final String logGoogleInterruptedMessage = "Google ping test: Process was interrupted."; @@ -70,7 +66,7 @@ public ConnectionDiagnosticTask(Context c) { @Override protected Test doTaskBackground(Void... params) { Test out = null; - if (!isOnline(this.c)) { + if (!ConnectivityStatus.isNetworkAvailable(this.c)) { out = Test.isOnline; } else if (!pingSuccess(googleURL)) { out = Test.googlePing; @@ -81,17 +77,7 @@ protected Test doTaskBackground(Void... params) { } //checks if the network is connected or not. - private boolean isOnline(Context context) { - ConnectivityManager conManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo netInfo = conManager.getActiveNetworkInfo(); - boolean notInAirplaneMode = (netInfo != null && netInfo.isConnected()); - //if user is not online, log not connected. if online, log success - String logMessage = !notInAirplaneMode ? logNotConnectedMessage : logConnectionSuccessMessage; - Logger.log(CONNECTION_DIAGNOSTIC_REPORT, logMessage); - - return notInAirplaneMode; - } //check if a ping to a specific ip address (used for google url) is successful. private boolean pingSuccess(String url) { diff --git a/app/src/org/commcare/utils/ConnectivityStatus.java b/app/src/org/commcare/utils/ConnectivityStatus.java index c7d8edec96..f7e7714a22 100644 --- a/app/src/org/commcare/utils/ConnectivityStatus.java +++ b/app/src/org/commcare/utils/ConnectivityStatus.java @@ -5,18 +5,29 @@ import android.net.NetworkInfo; import android.provider.Settings; +import org.javarosa.core.services.Logger; + +import static org.commcare.tasks.ConnectionDiagnosticTask.CONNECTION_DIAGNOSTIC_REPORT; + /** * @author Phillip Mates (pmates@dimagi.com) */ public class ConnectivityStatus { + private static final String logNotConnectedMessage = "Network test: Not connected."; + private static final String logConnectionSuccessMessage = "Network test: Success."; public static boolean isAirplaneModeOn(Context context) { return Settings.Global.getInt(context.getApplicationContext().getContentResolver(), Settings.Global.AIRPLANE_MODE_ON, 0) != 0; } public static boolean isNetworkAvailable(Context context) { - ConnectivityManager connectivityManager - = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); - NetworkInfo activeNetworkInfo = connectivityManager.getActiveNetworkInfo(); - return activeNetworkInfo != null && activeNetworkInfo.isConnectedOrConnecting(); + ConnectivityManager conManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + NetworkInfo netInfo = conManager.getActiveNetworkInfo(); + boolean notInAirplaneMode = (netInfo != null && netInfo.isConnected()); + + //if user is not online, log not connected. if online, log success + String logMessage = !notInAirplaneMode ? logNotConnectedMessage : logConnectionSuccessMessage; + Logger.log(CONNECTION_DIAGNOSTIC_REPORT, logMessage); + + return notInAirplaneMode; } } diff --git a/app/src/org/commcare/utils/EncryptionKeyProvider.java b/app/src/org/commcare/utils/EncryptionKeyProvider.java index 02060dd3bd..1724846e78 100644 --- a/app/src/org/commcare/utils/EncryptionKeyProvider.java +++ b/app/src/org/commcare/utils/EncryptionKeyProvider.java @@ -71,6 +71,7 @@ public EncryptionKeyAndTransform getKey(Context context, boolean trueForEncrypt) } //Gets the SecretKey from the Android KeyStore (creates a new one the first time) + @SuppressLint("InlinedApi") //Suppressing since we check the API version elsewhere private static EncryptionKeyAndTransform getKey(Context context, KeyStore keystore, boolean trueForEncrypt) throws KeyStoreException, NoSuchAlgorithmException, UnrecoverableEntryException, InvalidAlgorithmParameterException, NoSuchProviderException { @@ -78,10 +79,10 @@ private static EncryptionKeyAndTransform getKey(Context context, KeyStore keysto if (doesKeystoreContainEncryptionKey()) { KeyStore.Entry existingKey = keystore.getEntry(SECRET_NAME, null); if (existingKey instanceof KeyStore.SecretKeyEntry entry) { - return new EncryptionKeyAndTransform(entry.getSecretKey(), getTransformationString(false)); + return new EncryptionKeyAndTransform(entry.getSecretKey(), String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING)); } else if (existingKey instanceof KeyStore.PrivateKeyEntry entry) { Key key = trueForEncrypt ? entry.getCertificate().getPublicKey() : entry.getPrivateKey(); - return new EncryptionKeyAndTransform(key, getTransformationString(true)); + return new EncryptionKeyAndTransform(key, "RSA/ECB/PKCS1Padding"); } else { throw new RuntimeException("Unrecognized key type retrieved from KeyStore"); } @@ -108,7 +109,7 @@ private static EncryptionKeyAndTransform generateKeyInKeystore(Context context, .build(); keyGenerator.init(keySpec); - return new EncryptionKeyAndTransform(keyGenerator.generateKey(), getTransformationString(false)); + return new EncryptionKeyAndTransform(keyGenerator.generateKey(), String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING)); } else { KeyPairGenerator generator = KeyPairGenerator.getInstance("RSA", KEYSTORE_NAME); @@ -132,19 +133,8 @@ private static EncryptionKeyAndTransform generateKeyInKeystore(Context context, KeyPair pair = generator.generateKeyPair(); Key key = trueForEncrypt ? pair.getPublic() : pair.getPrivate(); - return new EncryptionKeyAndTransform(key, getTransformationString(true)); + return new EncryptionKeyAndTransform(key, "RSA/ECB/PKCS1Padding"); } } - @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 e0d82bae66..7a945f9cae 100644 --- a/app/src/org/commcare/utils/EncryptionUtils.java +++ b/app/src/org/commcare/utils/EncryptionUtils.java @@ -51,6 +51,8 @@ public static byte[] generatePassphrase() { } byte[] result = new byte[PASSPHRASE_LENGTH]; + int maxAttempts = 100; // Safety limit + int attempts = 0; while (true) { random.nextBytes(result); @@ -67,11 +69,15 @@ public static byte[] generatePassphrase() { } } - if (!containsZero) { + if (!containsZero || ++attempts >= maxAttempts) { break; } } + if (attempts >= maxAttempts) { + throw new IllegalStateException("Failed to generate a passphrase without zeros after " + maxAttempts + " attempts"); + } + return result; } diff --git a/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java b/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java index d66c94721b..fac4d19ccd 100644 --- a/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java +++ b/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java @@ -21,9 +21,8 @@ public EncryptionKeyAndTransform getKey(Context context, boolean trueForEncrypt) //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); + "RSA/ECB/PKCS1Padding"); } } From 66cf541d56101c6c032f50d61571a7724b401ede Mon Sep 17 00:00:00 2001 From: parthmittal Date: Tue, 7 Jan 2025 17:19:45 +0530 Subject: [PATCH 16/26] - retrofit changes for api call --- app/build.gradle | 3 + app/res/values/strings.xml | 21 +- .../connect/network/ApiConnectId.java | 460 +++++++++++------- .../connect/network/connectId/ApiClient.java | 56 +++ .../network/connectId/ApiEndPoints.java | 28 ++ .../connect/network/connectId/ApiService.java | 63 +++ 6 files changed, 437 insertions(+), 194 deletions(-) create mode 100644 app/src/org/commcare/connect/network/connectId/ApiClient.java create mode 100644 app/src/org/commcare/connect/network/connectId/ApiEndPoints.java create mode 100644 app/src/org/commcare/connect/network/connectId/ApiService.java diff --git a/app/build.gradle b/app/build.gradle index a948b8989f..5b8af16265 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -84,6 +84,9 @@ dependencies { implementation 'joda-time:joda-time:2.9.4' implementation 'net.zetetic:android-database-sqlcipher:4.5.3@aar' implementation 'androidx.sqlite:sqlite:2.2.0' + implementation 'com.squareup.retrofit2:retrofit:2.9.0' + implementation 'com.squareup.okhttp3:logging-interceptor:4.11.0' + implementation 'com.squareup.retrofit2:converter-gson:2.9.0' implementation('org.apache.james:apache-mime4j:0.7.2') { exclude module: 'commons-io' } diff --git a/app/res/values/strings.xml b/app/res/values/strings.xml index 30ceacf3e4..bd4bff17ea 100644 --- a/app/res/values/strings.xml +++ b/app/res/values/strings.xml @@ -11,28 +11,9 @@ https://pact.dimagi.com/keys/getkey Your comment commcarehq-support@dimagi.com - + https://connectid.dimagi.com/users/fetch_db_key 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 diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index d2628a3ba8..d9deda8a41 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -1,53 +1,78 @@ package org.commcare.connect.network; - import android.content.Context; +import android.net.ConnectivityManager; +import android.os.Handler; import com.google.common.collect.ArrayListMultimap; -import com.google.common.collect.Multimap; import org.commcare.CommCareApplication; +import org.commcare.activities.CommCareActivity; import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; +import org.commcare.connect.ConnectConstants; +import org.commcare.android.database.connect.models.ConnectUserRecord; import org.commcare.connect.database.ConnectAppDatabseUtil; import org.commcare.connect.database.ConnectDatabaseHelper; -import org.commcare.android.database.connect.models.ConnectUserRecord; import org.commcare.connect.database.ConnectUserDatabaseUtil; +import org.commcare.connect.network.connectId.ApiClient; +import org.commcare.connect.network.connectId.ApiService; import org.commcare.core.network.AuthInfo; import org.commcare.dalvik.R; +import org.commcare.network.HttpUtils; 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.io.InputStream; +import java.net.MalformedURLException; +import java.net.URL; +import java.util.Date; import java.util.HashMap; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.Callback; +import retrofit2.HttpException; +import retrofit2.Response; + + public class ApiConnectId { private static final String API_VERSION_NONE = null; - private static final String API_VERSION_CONNECT_ID = "1.0"; + public static final String API_VERSION_CONNECT_ID = "1.0"; + private static final int NETWORK_ACTIVITY_ID = 7000; - public static void linkHqWorker(Context context, String hqUsername, ConnectLinkedAppRecord appRecord, String connectToken) { - HashMap params = new HashMap<>(); - params.put("token", connectToken); + private static ApiService apiService; - String url = ServerUrls.getKeyServer().replace("phone/keys/", - "settings/users/commcare/link_connectid_user/"); + public ApiConnectId() { + } - try { - ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, - API_VERSION_NONE, new AuthInfo.ProvidedAuth(hqUsername, appRecord.getPassword()), params, true, false); - if (postResult.responseCode == 200) { - postResult.responseStream.close(); + public static void linkHqWorker(Context context, String hqUsername, String hqPassword, String connectToken) { + String seatedAppId = CommCareApplication.instance().getCurrentApp().getUniqueId(); + ConnectLinkedAppRecord appRecord = ConnectAppDatabseUtil.getAppData(context, seatedAppId, hqUsername); + if (appRecord != null && !appRecord.getWorkerLinked()) { + HashMap params = new HashMap<>(); + params.put("token", connectToken); - //Remember that we linked the user successfully - appRecord.setWorkerLinked(true); - ConnectAppDatabseUtil.storeApp(context, appRecord); - } else { - Logger.log("API Error", "API call to link HQ worker failed with code " + postResult.responseCode); + 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, appRecord.getPassword()), params, true, false); + if (postResult.e == null && postResult.responseCode == 200) { + postResult.responseStream.close(); + + //Remember that we linked the user successfully + appRecord.setWorkerLinked(true); + ConnectAppDatabseUtil.storeApp(context, appRecord); + } + } catch (IOException e) { + Logger.exception("Linking HQ worker", e); } - } catch (IOException e) { - //Don't care for now - Logger.exception("Error linking HQ worker", e); } } @@ -59,18 +84,36 @@ public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUs params.put("username", hqUsername + "@" + HiddenPreferences.getUserDomain()); params.put("password", connectToken); - String url = ServerUrls.buildEndpoint("oauth/token/"); + 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 { - SsoToken token = SsoToken.fromResponseStream(postResult.responseStream); - - String seatedAppId = CommCareApplication.instance().getCurrentApp().getUniqueId(); - ConnectDatabaseHelper.storeHqToken(context, seatedAppId, hqUsername, token); - - return new AuthInfo.TokenAuth(token.token); + 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(); + SsoToken ssoToken = new SsoToken(token, expiration); + ConnectDatabaseHelper.storeHqToken(context, seatedAppId, hqUsername, ssoToken); + + return new AuthInfo.TokenAuth(token); + } } catch (IOException | JSONException e) { Logger.exception("Parsing return from HQ OIDC call", e); } @@ -83,7 +126,7 @@ public static ConnectNetworkHelper.PostResult makeHeartbeatRequestSync(Context c String url = context.getString(R.string.ConnectHeartbeatURL); HashMap params = new HashMap<>(); String token = FirebaseMessagingUtil.getFCMToken(); - if(token != null) { + 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); @@ -93,14 +136,14 @@ public static ConnectNetworkHelper.PostResult makeHeartbeatRequestSync(Context c } public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { +// AuthInfo.TokenAuth connectToken = ConnectManager.getConnectToken(); +// if (connectToken != null) { +// return connectToken; +// } + ConnectUserRecord user = ConnectUserDatabaseUtil.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"); @@ -114,12 +157,22 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { API_VERSION_CONNECT_ID, new AuthInfo.NoAuth(), params, true, false); if (postResult.responseCode == 200) { try { - SsoToken token = SsoToken.fromResponseStream(postResult.responseStream); - - user.updateConnectToken(token); - ConnectUserDatabaseUtil.storeUser(context, user); - - return new AuthInfo.TokenAuth(token.token); + 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(new SsoToken(token,expiration)); + ConnectUserDatabaseUtil.storeUser(context, user); + + return new AuthInfo.TokenAuth(token); + } } catch (IOException | JSONException e) { Logger.exception("Parsing return from Connect OIDC call", e); } @@ -136,233 +189,292 @@ public static void fetchDbPassphrase(Context context, ConnectUserRecord user, IA 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 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 + } + }); + } } - public static boolean changePassword(Context context, String username, String oldPassword, - String newPassword, IApiCallback callback) { - if (ConnectNetworkHelper.isBusy()) { - return false; + public static void dismissProgressDialog(Context context) { + if (context instanceof CommCareActivity) { + Handler handler = new Handler(context.getMainLooper()); + handler.post(() -> { + ((CommCareActivity)context).dismissProgressDialogForTask(NETWORK_ACTIVITY_ID); + }); } + } + static void callApi(Context context,Call call, IApiCallback callback) { + showProgressDialog(context); + call.enqueue(new Callback() { + @Override + public void onResponse(Call call, Response response) { + dismissProgressDialog(context); + if (response.isSuccessful() && response.body() != null) { + // Handle success + try (InputStream responseStream = response.body().byteStream()) { + callback.processSuccess(response.code(), responseStream); + } catch (IOException e) { + // Handle error when reading the stream + callback.processFailure(response.code(), e); + } + } else { + // Handle validation errors + handleApiError(response); + callback.processFailure(response.code(), null); + } + } - AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, oldPassword, false); - int urlId = R.string.ConnectChangePasswordURL; - - HashMap params = new HashMap<>(); - params.put("password", newPassword); + @Override + public void onFailure(Call call, Throwable t) { + dismissProgressDialog(context); + // Handle network errors, etc. + handleNetworkError(t); + callback.processNetworkFailure(); - 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; - } + public static void 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.checkPassword(params); + callApi(context,call,callback); + } - AuthInfo authInfo = new AuthInfo.NoAuth(); - int urlId = R.string.ConnectResetPasswordURL; + public static void resetPassword(Context context, String phoneNumber, String recoverySecret, + String newPassword, IApiCallback callback) { 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.resetPassword(params); + callApi(context,call, 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; + public static void checkPin(Context context, String phone, String secret, + String pin, IApiCallback callback) { 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.confirmPIN(params); + callApi(context,call,callback); } - public static boolean changePin(Context context, String username, String password, - String pin, IApiCallback callback) { - if (ConnectNetworkHelper.isBusy()) { - return false; - } + public static void changePin(Context context, String username, String password, + String pin, IApiCallback callback) { AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); - int urlId = R.string.ConnectSetPinURL; + String token = HttpUtils.getCredential(authInfo); 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.changePIN(token, params); + callApi(context,call,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 void checkPhoneAvailable(Context context, String phone, IApiCallback callback) { + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.checkPhoneNumber(phone); + callApi(context,call,callback); } - public static boolean registerUser(Context context, String username, String password, String displayName, - String phone, IApiCallback callback) { + public static void 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.registerUser(params); + callApi(context,call,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; + public static void changePhone(Context context, String username, String password, + String oldPhone, String newPhone, IApiCallback callback) { + //Update the phone number with the server + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + String token = HttpUtils.getCredential(authInfo); 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.changePhoneNo(token, params); + callApi(context,call,callback); } - public static boolean updateUserProfile(Context context, String username, - String password, String displayName, - String secondaryPhone, IApiCallback callback) { + public static void 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; - + AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + String token = HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); - if(secondaryPhone != null) { + if (secondaryPhone != null) { params.put("secondary_phone", secondaryPhone); } - if(displayName != null) { + 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.updateProfile(token, params); + callApi(context,call,callback); } - public static boolean requestRegistrationOtpPrimary(Context context, String username, String password, - IApiCallback callback) { - int urlId = R.string.ConnectValidatePhoneURL; + public static void requestRegistrationOtpPrimary(Context context, String username, String password, + IApiCallback callback) { AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); - + String token = HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); - - return ConnectNetworkHelper.post(context, context.getString(urlId), - API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.validatePhone(token,params); + callApi(context,call,callback); } - public static boolean requestRecoveryOtpPrimary(Context context, String phone, IApiCallback callback) { - int urlId = R.string.ConnectRecoverURL; - AuthInfo authInfo = new AuthInfo.NoAuth(); - + public static void requestRecoveryOtpPrimary(Context context, String phone, IApiCallback callback) { HashMap params = new HashMap<>(); params.put("phone", phone); - - return ConnectNetworkHelper.post(context, context.getString(urlId), - API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.requestOTPPrimary(params); + callApi(context,call,callback); } - public static boolean requestRecoveryOtpSecondary(Context context, String phone, String secret, - IApiCallback callback) { - int urlId = R.string.ConnectRecoverSecondaryURL; - AuthInfo authInfo = new AuthInfo.NoAuth(); - + public static void requestRecoveryOtpSecondary(Context context, String phone, String secret, + IApiCallback callback) { 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.recoverSecondary(params); + callApi(context,call,callback); } - public static boolean requestVerificationOtpSecondary(Context context, String username, String password, - IApiCallback callback) { - int urlId = R.string.ConnectVerifySecondaryURL; + public static void requestVerificationOtpSecondary(Context context, String username, String password, + IApiCallback callback) { AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); - + String basicToken= HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); - - return ConnectNetworkHelper.post(context, context.getString(urlId), - API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.validateSecondaryPhone(basicToken,params); + callApi(context,call,callback); } - public static boolean confirmRegistrationOtpPrimary(Context context, String username, String password, - String token, IApiCallback callback) { - int urlId = R.string.ConnectConfirmOTPURL; + public static void confirmRegistrationOtpPrimary(Context context, String username, String password, + String token, IApiCallback callback) { AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); - + String basicToken= HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); params.put("token", token); - return ConnectNetworkHelper.post(context, context.getString(urlId), - API_VERSION_CONNECT_ID, authInfo, params, false, false, callback); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.confirmOTP(basicToken,params); + callApi(context,call,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(); - + public static void confirmRecoveryOtpPrimary(Context context, String phone, String secret, + String token, IApiCallback callback) { 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.recoverConfirmOTP(params); + callApi(context,call,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(); - + public static void confirmRecoveryOtpSecondary(Context context, String phone, String secret, + String token, IApiCallback callback) { 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.recoverConfirmOTPSecondary(params); + callApi(context,call,callback); } - public static boolean confirmVerificationOtpSecondary(Context context, String username, String password, - String token, IApiCallback callback) { - int urlId = R.string.ConnectVerifyConfirmSecondaryOTPURL; + public static void confirmVerificationOtpSecondary(Context context, String username, String password, + String token, IApiCallback callback) { AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); + String token1 = HttpUtils.getCredential(authInfo); + HashMap params = new HashMap<>(); + params.put("token", token); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.confirmOTPSecondary(token1, params); + callApi(context,call,callback); + } + + public static void requestInitiateAccountDeactivation(Context context, String phone, String secretKey, IApiCallback callback) { + HashMap params = new HashMap<>(); + params.put("secret_key", secretKey); + params.put("phone_number", phone); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.accountDeactivation(params); + callApi(context,call,callback); + } + public static void confirmUserDeactivation(Context context, String phone, String secret, + String token, IApiCallback callback) { HashMap params = new HashMap<>(); + params.put("phone_number", 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); + apiService = ApiClient.getClient().create(ApiService.class); + Call call = apiService.confirmDeactivation(params); + callApi(context,call,callback); + } + + private static void handleApiError(Response response) { + if (response.code() == 400) { + // Bad request (e.g., validation failed) + System.out.println("Bad Request: " + response.message()); + } else if (response.code() == 401) { + // Unauthorized (e.g., invalid credentials) + System.out.println("Unauthorized: " + response.message()); + } else if (response.code() == 404) { + // Not found + System.out.println("Not Found: " + response.message()); + } else if (response.code() >= 500) { + // Server error + System.out.println("Server Error: " + response.message()); + } else { + System.out.println("API Error: " + response.message()); + } + } + + private static void handleNetworkError(Throwable t) { + if (t instanceof IOException) { + // IOException is usually a network error (no internet, timeout, etc.) + System.out.println("Network Error: " + t.getMessage()); + } else if (t instanceof HttpException) { + // Handle HTTP exceptions separately if needed + System.out.println("HTTP Error: " + t.getMessage()); + } else { + System.out.println("Unexpected Error: " + t.getMessage()); + } } -} +} \ No newline at end of file diff --git a/app/src/org/commcare/connect/network/connectId/ApiClient.java b/app/src/org/commcare/connect/network/connectId/ApiClient.java new file mode 100644 index 0000000000..a7c1daed50 --- /dev/null +++ b/app/src/org/commcare/connect/network/connectId/ApiClient.java @@ -0,0 +1,56 @@ +package org.commcare.connect.network.connectId; + +import org.commcare.connect.network.ApiConnectId; + +import java.io.IOException; +import java.util.Map; +import java.util.concurrent.TimeUnit; + +import okhttp3.Interceptor; +import okhttp3.OkHttpClient; +import okhttp3.Request; +import okhttp3.Response; +import okhttp3.logging.HttpLoggingInterceptor; +import retrofit2.Retrofit; +import retrofit2.converter.gson.GsonConverterFactory; + + +///Todo retry part of the api fails + +public class ApiClient { + private static final String BASE_URL = "https://connectid.dimagi.com"; // Replace with actual base URL + private static final String API_VERSION = ApiConnectId.API_VERSION_CONNECT_ID; // Replace with actual version value + + private static Retrofit retrofit; + + public static Retrofit getClient() { + HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); +// set your desired log level + logging.setLevel(HttpLoggingInterceptor.Level.BODY); + if (retrofit == null) { + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .addInterceptor(logging) + .addInterceptor(new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + Request requestWithHeaders = originalRequest.newBuilder() + .header("Accept", "application/json;version=" + API_VERSION) + .build(); + return chain.proceed(requestWithHeaders); + } + }) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + + retrofit = new Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build(); + } + return retrofit; + } +} \ No newline at end of file diff --git a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java new file mode 100644 index 0000000000..8a58fe04e7 --- /dev/null +++ b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java @@ -0,0 +1,28 @@ +package org.commcare.connect.network.connectId; + +public class ApiEndPoints { + public static final String ConnectTokenURL = "o/token/"; + public static final String ConnectHeartbeatURL = "/users/heartbeat"; + public static final String ConnectFetchDbKeyURL = "/users/fetch_db_key"; + public static final String ConnectChangePasswordURL = "/users/change_password"; + public static final String registerUser = "/users/register"; + public static final String phoneAvailable = "/users/phone_available?"; + public static final String changePhoneNo = "/users/change_phone"; + public static final String updateProfile = "/users/update_profile"; + public static final String validatePhone = "/users/validate_phone"; + public static final String recoverOTPPrimary = "/users/recover"; + public static final String recoverOTPSecondary = "/users/validate_secondary_phone"; + public static final String recoverConfirmOTPSecondary = "/users/recover/confirm_secondary_otp"; + public static final String confirmOTPSecondary = "/users/confirm_secondary_otp"; + public static final String accountDeactivation = "/users/recover/initiate_deactivation"; + public static final String confirmDeactivation = "/users/recover/confirm_deactivation"; + public static final String recoverConfirmOTP = "/users/recover/confirm_otp"; + public static final String recoverSecondary = "/users/recover/secondary"; + public static final String confirmOTP = "/users/confirm_otp"; + public static final String setPIN = "/users/set_recovery_pin"; + public static final String confirmPIN = "/users/recover/confirm_pin"; + public static final String resetPassword = "/users/recover/reset_password"; + public static final String changePassword = "/users/change_password"; + public static final String confirmPassword = "/users/recover/confirm_password"; + +} \ No newline at end of file diff --git a/app/src/org/commcare/connect/network/connectId/ApiService.java b/app/src/org/commcare/connect/network/connectId/ApiService.java new file mode 100644 index 0000000000..de92135e31 --- /dev/null +++ b/app/src/org/commcare/connect/network/connectId/ApiService.java @@ -0,0 +1,63 @@ +package org.commcare.connect.network.connectId; + +import java.util.Map; +import okhttp3.ResponseBody; +import retrofit2.Call; +import retrofit2.http.*; + +public interface ApiService { + + @GET(ApiEndPoints.phoneAvailable) + Call checkPhoneNumber(@Query("phone_number") String phoneNumber); + + @POST(ApiEndPoints.registerUser) + Call registerUser(@Body Map registrationRequest); + + @POST(ApiEndPoints.changePhoneNo) + Call changePhoneNo(@Header("Authorization") String token,@Body Map changeRequest); + + @POST(ApiEndPoints.updateProfile) + Call updateProfile(@Header("Authorization") String token,@Body Map updateProfile); + + @POST(ApiEndPoints.validatePhone) + Call validatePhone(@Header("Authorization") String token,@Body Map requestOTP); + + @POST(ApiEndPoints.recoverOTPPrimary) + Call requestOTPPrimary(@Body Map requestOTP); + + @POST(ApiEndPoints.recoverOTPSecondary) + Call validateSecondaryPhone(@Header("Authorization") String token,@Body Map validateSecondaryPhoneRequest); + + @POST(ApiEndPoints.recoverConfirmOTPSecondary) + Call recoverConfirmOTPSecondary(@Body Map recoverConfirmOTPSecondaryRequest); + + @POST(ApiEndPoints.confirmOTPSecondary) + Call confirmOTPSecondary(@Header("Authorization") String token,@Body Map confirmOTPSecondaryRequest); + + @POST(ApiEndPoints.accountDeactivation) + Call accountDeactivation(@Body Map accountDeactivationRequest); + + @POST(ApiEndPoints.confirmDeactivation) + Call confirmDeactivation(@Body Map confirmDeactivationRequest); + + @POST(ApiEndPoints.recoverConfirmOTP) + Call recoverConfirmOTP(@Body Map confirmOTPRequest); + + @POST(ApiEndPoints.confirmOTP) + Call confirmOTP(@Header("Authorization") String token,@Body Map confirmOTPRequest); + + @POST(ApiEndPoints.recoverSecondary) + Call recoverSecondary(@Body Map recoverSecondaryRequest); + @POST(ApiEndPoints.confirmPIN) + Call confirmPIN(@Body Map confirmPINRequest); + + @POST(ApiEndPoints.setPIN) + Call changePIN(@Header("Authorization") String token,@Body Map changePINRequest); + + @POST(ApiEndPoints.resetPassword) + Call resetPassword(@Body Map resetPasswordRequest); + @POST(ApiEndPoints.changePassword) + Call changePassword(@Header("Authorization") String token,@Body Map changePasswordRequest); + @POST(ApiEndPoints.confirmPassword) + Call checkPassword(@Body Map confirmPasswordRequest); +} \ No newline at end of file From 4f42de2e4c130e9967c184089c71faf6a3d80901 Mon Sep 17 00:00:00 2001 From: parthmittal Date: Tue, 7 Jan 2025 18:16:52 +0530 Subject: [PATCH 17/26] -bug fix for language change --- app/src/org/commcare/CommCareApplication.java | 1 + 1 file changed, 1 insertion(+) diff --git a/app/src/org/commcare/CommCareApplication.java b/app/src/org/commcare/CommCareApplication.java index f754fb9a46..97162aba82 100644 --- a/app/src/org/commcare/CommCareApplication.java +++ b/app/src/org/commcare/CommCareApplication.java @@ -286,6 +286,7 @@ protected void turnOnStrictMode() { @Override public void onConfigurationChanged(@NonNull Configuration newConfig) { super.onConfigurationChanged(newConfig); + LocalePreferences.saveDeviceLocale(newConfig.locale); } private void initNotifications() { From a91d8ab79023f6a7ceb3ff9f03c2535c75ea5d7b Mon Sep 17 00:00:00 2001 From: parthmittal Date: Tue, 21 Jan 2025 17:36:34 +0530 Subject: [PATCH 18/26] -foundation pr review changes --- .../commcare/connect/network/ApiConnectId.java | 10 +++------- .../connect/network/ConnectNetworkHelper.java | 16 ---------------- 2 files changed, 3 insertions(+), 23 deletions(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index d9deda8a41..e86cb16d3a 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -64,12 +64,11 @@ public static void linkHqWorker(Context context, String hqUsername, String hqPas ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, API_VERSION_NONE, new AuthInfo.ProvidedAuth(hqUsername, appRecord.getPassword()), params, true, false); if (postResult.e == null && postResult.responseCode == 200) { - postResult.responseStream.close(); - //Remember that we linked the user successfully appRecord.setWorkerLinked(true); ConnectAppDatabseUtil.storeApp(context, appRecord); } + postResult.responseStream.close(); } catch (IOException e) { Logger.exception("Linking HQ worker", e); } @@ -136,10 +135,6 @@ public static ConnectNetworkHelper.PostResult makeHeartbeatRequestSync(Context c } public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { -// AuthInfo.TokenAuth connectToken = ConnectManager.getConnectToken(); -// if (connectToken != null) { -// return connectToken; -// } ConnectUserRecord user = ConnectUserDatabaseUtil.getUser(context); @@ -159,7 +154,6 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { 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)) { @@ -173,10 +167,12 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { return new AuthInfo.TokenAuth(token); } + postResult.responseStream.close(); } catch (IOException | JSONException e) { Logger.exception("Parsing return from Connect OIDC call", e); } } + } return null; diff --git a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java index ca2f8ad65b..5a09a58435 100644 --- a/app/src/org/commcare/connect/network/ConnectNetworkHelper.java +++ b/app/src/org/commcare/connect/network/ConnectNetworkHelper.java @@ -87,22 +87,6 @@ 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) { From f51fc58fc707f66858bc48eca7d59a82bf990dab Mon Sep 17 00:00:00 2001 From: parthmittal Date: Tue, 21 Jan 2025 19:52:00 +0530 Subject: [PATCH 19/26] - making clint id as constant value --- app/src/org/commcare/connect/network/ApiConnectId.java | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index e86cb16d3a..2ffe4b3797 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -44,6 +44,8 @@ public class ApiConnectId { private static final String API_VERSION_NONE = null; public static final String API_VERSION_CONNECT_ID = "1.0"; private static final int NETWORK_ACTIVITY_ID = 7000; + private static final String HQ_CLIENT_ID = "4eHlQad1oasGZF0lPiycZIjyL0SY1zx7ZblA6SCV"; + private static final String CONNECT_CLIENT_ID = "zqFUtAAMrxmjnC1Ji74KAa6ZpY1mZly0J0PlalIa"; private static ApiService apiService; @@ -77,7 +79,7 @@ public static void linkHqWorker(Context context, String hqUsername, String hqPas public static AuthInfo.TokenAuth retrieveHqTokenApi(Context context, String hqUsername, String connectToken) { HashMap params = new HashMap<>(); - params.put("client_id", "4eHlQad1oasGZF0lPiycZIjyL0SY1zx7ZblA6SCV"); + params.put("client_id", HQ_CLIENT_ID); params.put("scope", "mobile_access sync"); params.put("grant_type", "password"); params.put("username", hqUsername + "@" + HiddenPreferences.getUserDomain()); @@ -140,7 +142,7 @@ public static AuthInfo.TokenAuth retrieveConnectIdTokenSync(Context context) { if (user != null) { HashMap params = new HashMap<>(); - params.put("client_id", "zqFUtAAMrxmjnC1Ji74KAa6ZpY1mZly0J0PlalIa"); + params.put("client_id", CONNECT_CLIENT_ID); params.put("scope", "openid"); params.put("grant_type", "password"); params.put("username", user.getUserId()); From c7fd1ecf588c7df3973c4cbe9f30d096816c023a Mon Sep 17 00:00:00 2001 From: parthmittal Date: Thu, 23 Jan 2025 11:04:37 +0530 Subject: [PATCH 20/26] -making dynamic url builder --- .../org/commcare/connect/network/ApiConnectId.java | 13 +++++++++++-- 1 file changed, 11 insertions(+), 2 deletions(-) diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index 2ffe4b3797..68d66ae935 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -59,8 +59,17 @@ public static void linkHqWorker(Context context, String hqUsername, String hqPas HashMap params = new HashMap<>(); params.put("token", connectToken); - String url = ServerUrls.getKeyServer().replace("phone/keys/", - "settings/users/commcare/link_connectid_user/"); + String host; + String domain; + String url; + try { + host = (new URL(ServerUrls.getKeyServer())).getHost(); + domain = HiddenPreferences.getUserDomainWithoutServerUrl(); + String myStr = "https://%s/a/%s/settings/users/commcare/link_connectid_user/"; + url = String.format(myStr, host, domain); + } catch (MalformedURLException e) { + throw new RuntimeException(e); + } try { ConnectNetworkHelper.PostResult postResult = ConnectNetworkHelper.postSync(context, url, From b1e4eff4e615744ebfeb8c7626713d9f5f83cf9f Mon Sep 17 00:00:00 2001 From: parthmittal Date: Thu, 23 Jan 2025 15:16:56 +0530 Subject: [PATCH 21/26] -coderabbit review changes --- .../connect/models/ConnectJobRecord.java | 2 +- .../connect/models/ConnectJobRecordV2.java | 17 ++++- .../connect/models/ConnectJobRecordV4.java | 1 - .../connect/models/ConnectJobRecordV7.java | 2 - .../models/ConnectLinkedAppRecord.java | 4 +- .../models/ConnectLinkedAppRecordV8.java | 5 +- .../models/ConnectLinkedAppRecordV9.java | 10 ++- .../models/ConnectPaymentUnitRecord.java | 3 +- .../connect/models/ConnectUserRecord.java | 4 +- ...eUtil.java => ConnectAppDatabaseUtil.java} | 2 +- .../database/ConnectDatabaseHelper.java | 2 +- .../database/ConnectDatabaseUtils.java | 3 + .../database/ConnectUserDatabaseUtil.java | 43 +++++++++---- .../connect/network/ApiConnectId.java | 62 ++++++++++--------- .../commcare/connect/network/SsoToken.java | 22 +++++-- .../connect/network/connectId/ApiClient.java | 5 +- .../network/connectId/ApiEndPoints.java | 8 +-- .../connect/DatabaseConnectOpenHelper.java | 11 +++- .../commcare/utils/ConnectivityStatus.java | 4 ++ .../utils/EncryptionKeyAndTransform.java | 34 +++++++++- .../org/commcare/utils/EncryptionUtils.java | 8 +-- .../commcare/utils/EncryptionUtilsTest.java | 45 +++++++++----- 22 files changed, 202 insertions(+), 95 deletions(-) rename app/src/org/commcare/connect/database/{ConnectAppDatabseUtil.java => ConnectAppDatabaseUtil.java} (98%) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index 1f38e7ab55..dbb4229b20 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -376,7 +376,7 @@ public int getAssessmentScore() { public Date getDateClaimed() { return dateClaimed; } public boolean getIsActive() { return isActive; } - public boolean setIsUserSuspended(boolean isUserSuspended) { return this.isUserSuspended=isUserSuspended; } + public void setIsUserSuspended(boolean isUserSuspended) { this.isUserSuspended=isUserSuspended; } public boolean getIsUserSuspended(){ return isUserSuspended; diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java index dea2d096f6..32437fb743 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java @@ -4,10 +4,13 @@ import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import org.commcare.utils.CrashUtil; import java.io.Serializable; import java.util.Date; +import static org.javarosa.core.services.Logger.log; + /** * Data class for holding info related to a Connect job * This version was used up to V2 of the DB @@ -36,7 +39,7 @@ public class ConnectJobRecordV2 extends Persisted implements Serializable { 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_DELIVERY_PROGRESS = "delivery_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"; @@ -114,7 +117,17 @@ public ConnectJobRecordV2() { 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 int getPaymentAccrued() { + if (paymentAccrued == null || paymentAccrued.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(paymentAccrued); + } catch (NumberFormatException e) { + log("Invalid format for paymentAccrued: " + paymentAccrued, e.getMessage()); + return 0; + } + } public String getCurrency() { return currency; } public int getNumLearningModules() { return numLearningModules; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java index 4ad0cfe8a6..62b083bba7 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java @@ -146,7 +146,6 @@ public static ConnectJobRecordV4 fromV2(ConnectJobRecordV2 oldRecord) { 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(); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java index f9cebad45c..068735d9d7 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV7.java @@ -159,9 +159,7 @@ public static ConnectJobRecordV7 fromV4(ConnectJobRecordV4 oldRecord) { 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(); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java index 61277f0461..dfe3b4ea8e 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecord.java @@ -113,8 +113,8 @@ public Date getHqTokenExpiration() { } public void updateHqToken(SsoToken token) { - hqToken = token.token; - hqTokenExpiration = token.expiration; + hqToken = token.getToken(); + hqTokenExpiration = token.getExpiration(); } public boolean getConnectIdLinked() { return connectIdLinked; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java index 9a05d34773..0a0401ef3a 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java @@ -5,6 +5,7 @@ import org.commcare.modern.database.Table; import org.commcare.modern.models.MetaField; +import java.time.Instant; import java.util.Date; /** @@ -105,9 +106,9 @@ public static ConnectLinkedAppRecordV8 fromV3(ConnectLinkedAppRecordV3 oldRecord newRecord.connectIdLinked = true; newRecord.linkOffered1 = true; - newRecord.linkOfferDate1 = new Date(); + newRecord.linkOfferDate1 = Date.from(Instant.now()); newRecord.linkOffered2 = false; - newRecord.linkOfferDate2 = new Date(); + newRecord.linkOfferDate2 = Date.from(Instant.now());; 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 index 0a91c4cbeb..932f7271ee 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java @@ -8,9 +8,13 @@ import java.util.Date; /** - * DB model holding info for an HQ app linked to ConnectID + * Migrates a V8 record to V9 format. + * New in V9: + * - Added usingLocalPassphrase field + * - Changed link offer date handling * - * @author dviggiano + * @return A new V9 record with migrated data + * @throws IllegalArgumentException if oldRecord is null */ @Table(ConnectLinkedAppRecordV9.STORAGE_KEY) public class ConnectLinkedAppRecordV9 extends Persisted { @@ -119,7 +123,7 @@ public static ConnectLinkedAppRecordV9 fromV8(ConnectLinkedAppRecordV8 oldRecord newRecord.linkOfferDate1 = newRecord.linkOffered1 ? oldRecord.getLinkOfferDate1() : new Date(); newRecord.linkOffered2 = oldRecord.getLinkOfferDate2() != null; newRecord.linkOfferDate2 = newRecord.linkOffered2 ? oldRecord.getLinkOfferDate2() : new Date(); - + // Default to true for backward compatibility 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 index ec7ad93d5d..570f633fde 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectPaymentUnitRecord.java @@ -18,7 +18,6 @@ public class ConnectPaymentUnitRecord extends Persisted implements Serializable 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"; @@ -57,7 +56,7 @@ public static ConnectPaymentUnitRecord fromJson(JSONObject json, int jobId) thro ConnectPaymentUnitRecord paymentUnit = new ConnectPaymentUnitRecord(); paymentUnit.jobId = jobId; - paymentUnit.unitId = json.getInt(META_ID); + paymentUnit.unitId = json.getInt(META_UNIT_ID); paymentUnit.name = json.getString(META_NAME); paymentUnit.maxTotal = json.getInt(META_TOTAL); paymentUnit.maxDaily = json.getInt(META_DAILY); 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 5874fb8f61..906b985df6 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectUserRecord.java @@ -192,8 +192,8 @@ public boolean shouldRequireSecondaryPhoneVerification() { } public void updateConnectToken(SsoToken token) { - connectToken = token.token; - connectTokenExpiration = token.expiration; + connectToken = token.getToken(); + connectTokenExpiration = token.getExpiration(); } public AuthInfo.TokenAuth getConnectToken() { diff --git a/app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java b/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java similarity index 98% rename from app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java rename to app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java index fd632defd0..16641f10e7 100644 --- a/app/src/org/commcare/connect/database/ConnectAppDatabseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java @@ -7,7 +7,7 @@ import java.util.Vector; -public class ConnectAppDatabseUtil { +public class ConnectAppDatabaseUtil { public static ConnectLinkedAppRecord getAppData(Context context, String appId, String username) { Vector records = ConnectDatabaseHelper.getConnectStorage(context, ConnectLinkedAppRecord.class) .getRecordsForValues( diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java index 1e4ac0aae5..1a9a6ab32d 100644 --- a/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java +++ b/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java @@ -97,7 +97,7 @@ public static void handleCorruptDb(Context context) { } public static void storeHqToken(Context context, String appId, String userId, SsoToken token) { - ConnectLinkedAppRecord record = ConnectAppDatabseUtil.getAppData(context, appId, userId); + ConnectLinkedAppRecord record = ConnectAppDatabaseUtil.getAppData(context, appId, userId); if (record == null) { record = new ConnectLinkedAppRecord(appId, userId, false, ""); } diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java index d1d3cd12c3..ca7713c6d2 100644 --- a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java +++ b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java @@ -67,6 +67,9 @@ public static byte[] getConnectDbPassphrase(Context context) { //LEGACY: If we get here, the passphrase hasn't been created yet so use a local one byte[] passphrase = EncryptionUtils.generatePassphrase(); + if (passphrase == null) { + throw new IllegalStateException("Generated passphrase is null"); + } ConnectDatabaseUtils.storeConnectDbPassphrase(context, passphrase, true); return passphrase; diff --git a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java index 94e172a4f3..3380ee7247 100644 --- a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -9,30 +9,51 @@ import org.javarosa.core.services.Logger; public class ConnectUserDatabaseUtil { + private static final Object lock = new Object(); public static ConnectUserRecord getUser(Context context) { - ConnectUserRecord user = null; - if (ConnectDatabaseHelper.dbExists(context)) { + synchronized (lock) { + if (!ConnectDatabaseHelper.dbExists(context)) { + return null; + } try { - for (ConnectUserRecord r : ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class)) { - user = r; - break; + Iterable records = ConnectDatabaseHelper.getConnectStorage( + context, ConnectUserRecord.class); + if (records.iterator().hasNext()) { + return records.iterator().next(); } + return null; } catch (Exception e) { Logger.exception("Corrupt Connect DB trying to get user", e); ConnectDatabaseHelper.dbBroken = true; + throw new RuntimeException("Failed to access Connect database", e); } } - - return user; } public static void storeUser(Context context, ConnectUserRecord user) { - ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class).write(user); + if (user == null) { + throw new IllegalArgumentException("User must not be null"); + } + synchronized (lock) { + try { + ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class).write(user); + } catch (Exception e) { + Logger.exception("Failed to store user", e); + throw new RuntimeException("Failed to store user in Connect database", e); + } + } } public static void forgetUser(Context context) { - DatabaseConnectOpenHelper.deleteDb(context); - CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); - ConnectDatabaseHelper.dbBroken = false; + synchronized (lock) { + try { + DatabaseConnectOpenHelper.deleteDb(context); + CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); + ConnectDatabaseHelper.dbBroken = false; + } catch (Exception e) { + Logger.exception("Failed to forget user", e); + throw new RuntimeException("Failed to clean up Connect database", e); + } + } } } diff --git a/app/src/org/commcare/connect/network/ApiConnectId.java b/app/src/org/commcare/connect/network/ApiConnectId.java index 68d66ae935..fa72110a0c 100644 --- a/app/src/org/commcare/connect/network/ApiConnectId.java +++ b/app/src/org/commcare/connect/network/ApiConnectId.java @@ -10,7 +10,7 @@ import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; import org.commcare.connect.ConnectConstants; import org.commcare.android.database.connect.models.ConnectUserRecord; -import org.commcare.connect.database.ConnectAppDatabseUtil; +import org.commcare.connect.database.ConnectAppDatabaseUtil; import org.commcare.connect.database.ConnectDatabaseHelper; import org.commcare.connect.database.ConnectUserDatabaseUtil; import org.commcare.connect.network.connectId.ApiClient; @@ -20,6 +20,7 @@ import org.commcare.network.HttpUtils; import org.commcare.preferences.HiddenPreferences; import org.commcare.preferences.ServerUrls; +import org.commcare.util.LogTypes; import org.commcare.utils.FirebaseMessagingUtil; import org.javarosa.core.io.StreamsUtil; import org.javarosa.core.services.Logger; @@ -47,14 +48,13 @@ public class ApiConnectId { private static final String HQ_CLIENT_ID = "4eHlQad1oasGZF0lPiycZIjyL0SY1zx7ZblA6SCV"; private static final String CONNECT_CLIENT_ID = "zqFUtAAMrxmjnC1Ji74KAa6ZpY1mZly0J0PlalIa"; - private static ApiService apiService; public ApiConnectId() { } public static void linkHqWorker(Context context, String hqUsername, String hqPassword, String connectToken) { String seatedAppId = CommCareApplication.instance().getCurrentApp().getUniqueId(); - ConnectLinkedAppRecord appRecord = ConnectAppDatabseUtil.getAppData(context, seatedAppId, hqUsername); + ConnectLinkedAppRecord appRecord = ConnectAppDatabaseUtil.getAppData(context, seatedAppId, hqUsername); if (appRecord != null && !appRecord.getWorkerLinked()) { HashMap params = new HashMap<>(); params.put("token", connectToken); @@ -77,7 +77,7 @@ public static void linkHqWorker(Context context, String hqUsername, String hqPas if (postResult.e == null && postResult.responseCode == 200) { //Remember that we linked the user successfully appRecord.setWorkerLinked(true); - ConnectAppDatabseUtil.storeApp(context, appRecord); + ConnectAppDatabaseUtil.storeApp(context, appRecord); } postResult.responseStream.close(); } catch (IOException e) { @@ -255,7 +255,7 @@ public static void checkPassword(Context context, String phone, String secret, params.put("phone", phone); params.put("secret_key", secret); params.put("password", password); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.checkPassword(params); callApi(context,call,callback); } @@ -267,7 +267,7 @@ public static void resetPassword(Context context, String phoneNumber, String rec params.put("phone", phoneNumber); params.put("secret_key", recoverySecret); params.put("password", newPassword); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.resetPassword(params); callApi(context,call, callback); } @@ -280,7 +280,7 @@ public static void checkPin(Context context, String phone, String secret, params.put("secret_key", secret); params.put("recovery_pin", pin); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.confirmPIN(params); callApi(context,call,callback); } @@ -294,13 +294,13 @@ public static void changePin(Context context, String username, String password, HashMap params = new HashMap<>(); params.put("recovery_pin", pin); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.changePIN(token, params); callApi(context,call,callback); } public static void checkPhoneAvailable(Context context, String phone, IApiCallback callback) { - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.checkPhoneNumber(phone); callApi(context,call,callback); } @@ -313,7 +313,7 @@ public static void registerUser(Context context, String username, String passwor params.put("name", displayName); params.put("phone_number", phone); params.put("fcm_token", FirebaseMessagingUtil.getFCMToken()); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.registerUser(params); callApi(context,call,callback); } @@ -327,7 +327,7 @@ public static void changePhone(Context context, String username, String password HashMap params = new HashMap<>(); params.put("old_phone_number", oldPhone); params.put("new_phone_number", newPhone); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.changePhoneNo(token, params); callApi(context,call,callback); } @@ -346,7 +346,7 @@ public static void updateUserProfile(Context context, String username, if (displayName != null) { params.put("name", displayName); } - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.updateProfile(token, params); callApi(context,call,callback); } @@ -356,7 +356,7 @@ public static void requestRegistrationOtpPrimary(Context context, String usernam AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); String token = HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.validatePhone(token,params); callApi(context,call,callback); } @@ -364,7 +364,7 @@ public static void requestRegistrationOtpPrimary(Context context, String usernam public static void requestRecoveryOtpPrimary(Context context, String phone, IApiCallback callback) { HashMap params = new HashMap<>(); params.put("phone", phone); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.requestOTPPrimary(params); callApi(context,call,callback); } @@ -374,7 +374,7 @@ public static void requestRecoveryOtpSecondary(Context context, String phone, St HashMap params = new HashMap<>(); params.put("phone", phone); params.put("secret_key", secret); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.recoverSecondary(params); callApi(context,call,callback); } @@ -384,7 +384,7 @@ public static void requestVerificationOtpSecondary(Context context, String usern AuthInfo authInfo = new AuthInfo.ProvidedAuth(username, password, false); String basicToken= HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.validateSecondaryPhone(basicToken,params); callApi(context,call,callback); } @@ -396,7 +396,7 @@ public static void confirmRegistrationOtpPrimary(Context context, String usernam HashMap params = new HashMap<>(); params.put("token", token); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.confirmOTP(basicToken,params); callApi(context,call,callback); } @@ -407,7 +407,7 @@ public static void confirmRecoveryOtpPrimary(Context context, String phone, Stri params.put("phone", phone); params.put("secret_key", secret); params.put("token", token); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.recoverConfirmOTP(params); callApi(context,call,callback); } @@ -418,7 +418,7 @@ public static void confirmRecoveryOtpSecondary(Context context, String phone, St params.put("phone", phone); params.put("secret_key", secret); params.put("token", token); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.recoverConfirmOTPSecondary(params); callApi(context,call,callback); } @@ -429,7 +429,7 @@ public static void confirmVerificationOtpSecondary(Context context, String usern String token1 = HttpUtils.getCredential(authInfo); HashMap params = new HashMap<>(); params.put("token", token); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.confirmOTPSecondary(token1, params); callApi(context,call,callback); } @@ -438,7 +438,7 @@ public static void requestInitiateAccountDeactivation(Context context, String ph HashMap params = new HashMap<>(); params.put("secret_key", secretKey); params.put("phone_number", phone); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.accountDeactivation(params); callApi(context,call,callback); } @@ -450,38 +450,40 @@ public static void confirmUserDeactivation(Context context, String phone, String params.put("secret_key", secret); params.put("token", token); - apiService = ApiClient.getClient().create(ApiService.class); + ApiService apiService = ApiClient.getClient().create(ApiService.class); Call call = apiService.confirmDeactivation(params); callApi(context,call,callback); } private static void handleApiError(Response response) { + String message = response.message(); if (response.code() == 400) { // Bad request (e.g., validation failed) - System.out.println("Bad Request: " + response.message()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Bad Request: " + message); } else if (response.code() == 401) { // Unauthorized (e.g., invalid credentials) - System.out.println("Unauthorized: " + response.message()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Unauthorized: " + message); } else if (response.code() == 404) { // Not found - System.out.println("Not Found: " + response.message()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Not Found: " + message); } else if (response.code() >= 500) { // Server error - System.out.println("Server Error: " + response.message()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Server Error: " + message); } else { - System.out.println("API Error: " + response.message()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "API Error: " + message); } } private static void handleNetworkError(Throwable t) { + String message = t.getMessage(); if (t instanceof IOException) { // IOException is usually a network error (no internet, timeout, etc.) - System.out.println("Network Error: " + t.getMessage()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Network Error: " + message); } else if (t instanceof HttpException) { // Handle HTTP exceptions separately if needed - System.out.println("HTTP Error: " + t.getMessage()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "HTTP Error: " + message); } else { - System.out.println("Unexpected Error: " + t.getMessage()); + Logger.log(LogTypes.TYPE_ERROR_SERVER_COMMS, "Unexpected Error: " + message); } } } \ No newline at end of file diff --git a/app/src/org/commcare/connect/network/SsoToken.java b/app/src/org/commcare/connect/network/SsoToken.java index 26f58bad4b..df77398060 100644 --- a/app/src/org/commcare/connect/network/SsoToken.java +++ b/app/src/org/commcare/connect/network/SsoToken.java @@ -10,12 +10,20 @@ import java.util.Date; public class SsoToken { - public String token; - public Date expiration; + + private final String token; + + private final Date expiration; public SsoToken(String token, Date expiration) { + if (token == null || expiration == null) { + throw new IllegalArgumentException("Token and expiration must not be null"); + } + if (token.isEmpty()) { + throw new IllegalArgumentException("Token must not be empty"); + } this.token = token; - this.expiration = expiration; + this.expiration = new Date(expiration.getTime()); } public static SsoToken fromResponseStream(InputStream stream) throws IOException, JSONException { @@ -30,9 +38,15 @@ public static SsoToken fromResponseStream(InputStream stream) throws IOException String token = json.getString(key); Date expiration = new Date(); key = ConnectConstants.CONNECT_KEY_EXPIRES; - int seconds = json.has(key) ? json.getInt(key) : 0; + long seconds = json.has(key) ? json.getLong(key) : 0L; expiration.setTime(expiration.getTime() + ((long)seconds * 1000)); return new SsoToken(token, expiration); } + public String getToken() { + return token; + } + public Date getExpiration() { + return expiration; + } } diff --git a/app/src/org/commcare/connect/network/connectId/ApiClient.java b/app/src/org/commcare/connect/network/connectId/ApiClient.java index a7c1daed50..11ac32f1f2 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiClient.java +++ b/app/src/org/commcare/connect/network/connectId/ApiClient.java @@ -1,6 +1,7 @@ package org.commcare.connect.network.connectId; import org.commcare.connect.network.ApiConnectId; +import org.commcare.dalvik.BuildConfig; import java.io.IOException; import java.util.Map; @@ -26,7 +27,9 @@ public class ApiClient { public static Retrofit getClient() { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); // set your desired log level - logging.setLevel(HttpLoggingInterceptor.Level.BODY); + logging.setLevel(BuildConfig.DEBUG ? + HttpLoggingInterceptor.Level.BODY : + HttpLoggingInterceptor.Level.NONE); if (retrofit == null) { OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(logging) diff --git a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java index 8a58fe04e7..35b9dd6713 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java +++ b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java @@ -1,10 +1,10 @@ package org.commcare.connect.network.connectId; public class ApiEndPoints { - public static final String ConnectTokenURL = "o/token/"; - public static final String ConnectHeartbeatURL = "/users/heartbeat"; - public static final String ConnectFetchDbKeyURL = "/users/fetch_db_key"; - public static final String ConnectChangePasswordURL = "/users/change_password"; + public static final String connectTokenURL = "o/token/"; + public static final String connectHeartbeatURL = "/users/heartbeat"; + public static final String connectFetchDbKeyURL = "/users/fetch_db_key"; + public static final String connectChangePasswordURL = "/users/change_password"; public static final String registerUser = "/users/register"; public static final String phoneAvailable = "/users/phone_available?"; public static final String changePhoneNo = "/users/change_phone"; diff --git a/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java b/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java index ef010c4fdf..6497e0df34 100644 --- a/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java +++ b/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java @@ -23,6 +23,7 @@ import org.commcare.modern.database.TableBuilder; import org.commcare.util.Base64; import org.commcare.util.Base64DecoderException; +import org.commcare.utils.CrashUtil; import java.io.File; @@ -73,7 +74,7 @@ public static void rekeyDB(SQLiteDatabase db, String newPassphrase) throws Base6 byte[] newBytes = Base64.decode(newPassphrase); String newKeyEncoded = UserSandboxUtils.getSqlCipherEncodedKey(newBytes); - db.query("PRAGMA rekey = '" + newKeyEncoded + "';"); + db.execSQL("PRAGMA rekey = '" + newKeyEncoded + "';"); db.close(); } } @@ -128,7 +129,13 @@ public SQLiteDatabase getWritableDatabase(String key) { return super.getWritableDatabase(key); } catch (SQLiteException sqle) { DbUtil.trySqlCipherDbUpdate(key, mContext, CONNECT_DB_LOCATOR); - return super.getWritableDatabase(key); + try { + return super.getWritableDatabase(key); + } catch (SQLiteException e) { + // Handle the exception, log the error, or inform the user + CrashUtil.log(e.getMessage()); + return null; + } } } diff --git a/app/src/org/commcare/utils/ConnectivityStatus.java b/app/src/org/commcare/utils/ConnectivityStatus.java index f7e7714a22..9c27138f68 100644 --- a/app/src/org/commcare/utils/ConnectivityStatus.java +++ b/app/src/org/commcare/utils/ConnectivityStatus.java @@ -21,6 +21,10 @@ public static boolean isAirplaneModeOn(Context context) { public static boolean isNetworkAvailable(Context context) { ConnectivityManager conManager = (ConnectivityManager)context.getSystemService(Context.CONNECTIVITY_SERVICE); + if (conManager == null) { + Logger.log(CONNECTION_DIAGNOSTIC_REPORT, logNotConnectedMessage); + return false; + } NetworkInfo netInfo = conManager.getActiveNetworkInfo(); boolean notInAirplaneMode = (netInfo != null && netInfo.isConnected()); diff --git a/app/src/org/commcare/utils/EncryptionKeyAndTransform.java b/app/src/org/commcare/utils/EncryptionKeyAndTransform.java index ab7068db91..79eebec9cc 100644 --- a/app/src/org/commcare/utils/EncryptionKeyAndTransform.java +++ b/app/src/org/commcare/utils/EncryptionKeyAndTransform.java @@ -8,11 +8,39 @@ * @author dviggiano */ public class EncryptionKeyAndTransform { - public Key key; - public String transformation; + private final Key key; + private final String transformation; public EncryptionKeyAndTransform(Key key, String transformation) { - this.key = key; + if (key == null) { + throw new IllegalArgumentException("Encryption key cannot be null"); + } + if (transformation == null || transformation.trim().isEmpty()) { + throw new IllegalArgumentException("Transformation string cannot be null or empty"); + } + if (!transformation.matches("[A-Za-z0-9]+/[A-Za-z0-9]+/[A-Za-z0-9Padding]+")) { + throw new IllegalArgumentException("Invalid transformation format. Expected: Algorithm/Mode/Padding"); + } + // Create defensive copy if key is not immutable + if (key instanceof javax.crypto.SecretKey) { + this.key = new javax.crypto.spec.SecretKeySpec(key.getEncoded(), key.getAlgorithm()); + } else { + this.key = key; + } this.transformation = transformation; } + + /** + * @return The encryption key + */ + public Key getKey() { + return key; + } + + /** + * @return The transformation string + */ + public String getTransformation() { + return transformation; + } } diff --git a/app/src/org/commcare/utils/EncryptionUtils.java b/app/src/org/commcare/utils/EncryptionUtils.java index 7a945f9cae..8a69c0d2a0 100644 --- a/app/src/org/commcare/utils/EncryptionUtils.java +++ b/app/src/org/commcare/utils/EncryptionUtils.java @@ -86,8 +86,8 @@ public static byte[] encrypt(byte[] bytes, EncryptionKeyAndTransform keyAndTrans IllegalBlockSizeException, BadPaddingException, InvalidAlgorithmParameterException, UnrecoverableEntryException, CertificateException, KeyStoreException, IOException, NoSuchProviderException { - Cipher cipher = Cipher.getInstance(keyAndTransform.transformation); - cipher.init(Cipher.ENCRYPT_MODE, keyAndTransform.key); + Cipher cipher = Cipher.getInstance(keyAndTransform.getTransformation()); + cipher.init(Cipher.ENCRYPT_MODE, keyAndTransform.getKey()); byte[] encrypted = cipher.doFinal(bytes); byte[] iv = cipher.getIV(); int ivLength = iv == null ? 0 : iv.length; @@ -136,9 +136,9 @@ public static byte[] decrypt(byte[] bytes, EncryptionKeyAndTransform keyAndTrans readIndex++; System.arraycopy(bytes, readIndex, encrypted, 0, encryptedLength); - Cipher cipher = Cipher.getInstance(keyAndTransform.transformation); + Cipher cipher = Cipher.getInstance(keyAndTransform.getTransformation()); - cipher.init(Cipher.DECRYPT_MODE, keyAndTransform.key, iv != null ? new IvParameterSpec(iv) : null); + cipher.init(Cipher.DECRYPT_MODE, keyAndTransform.getKey(), iv != null ? new IvParameterSpec(iv) : null); return cipher.doFinal(encrypted); } diff --git a/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java b/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java index ad9fd70eb9..98b200dacc 100644 --- a/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java +++ b/app/unit-tests/src/org/commcare/utils/EncryptionUtilsTest.java @@ -12,23 +12,34 @@ * @author dviggiano */ public class EncryptionUtilsTest { + private final EncryptionKeyProvider provider = new MockEncryptionKeyProvider(); + private static final String TEST_DATA = "This is a test string"; + + @Test + public void testEncryption() throws Exception { + byte[] testBytes = TEST_DATA.getBytes(StandardCharsets.UTF_8); + byte[] encrypted = EncryptionUtils.encrypt(testBytes, provider.getKey(null, true)); + Assert.assertNotNull("Encrypted data should not be null", encrypted); + Assert.assertFalse("Encrypted data should differ from input", + TEST_DATA.equals(new String(encrypted))); + } + @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); - } + public void testDecryption() throws Exception { + byte[] testBytes = TEST_DATA.getBytes(StandardCharsets.UTF_8); + byte[] encrypted = EncryptionUtils.encrypt(testBytes, provider.getKey(null, true)); + byte[] decrypted = EncryptionUtils.decrypt(encrypted, provider.getKey(null, false)); + String decryptedString = new String(decrypted, StandardCharsets.UTF_8); + Assert.assertEquals("Decrypted data should match original", TEST_DATA, decryptedString); + } + + @Test(expected = IllegalArgumentException.class) + public void testEncryptionWithNullInput() throws Exception { + EncryptionUtils.encrypt(null, provider.getKey(null, true)); + } + + @Test(expected = IllegalArgumentException.class) + public void testEncryptionWithNullKey() throws Exception { + EncryptionUtils.encrypt(TEST_DATA.getBytes(StandardCharsets.UTF_8), null); } } From d793b9a39f164003f87b02b53b76da1cae6fcd10 Mon Sep 17 00:00:00 2001 From: parthmittal Date: Thu, 23 Jan 2025 19:06:56 +0530 Subject: [PATCH 22/26] -coderabbit review changes --- app/build.gradle | 4 ++ .../connect/models/ConnectJobRecord.java | 5 +- .../connect/models/ConnectJobRecordV2.java | 1 + .../connect/models/ConnectJobRecordV4.java | 14 ++++- .../models/ConnectLinkedAppRecordV8.java | 2 +- .../database/ConnectAppDatabaseUtil.java | 55 +++++++++++++----- .../database/ConnectDatabaseHelper.java | 8 ++- .../connect/database/ConnectJobUtils.java | 2 +- .../database/ConnectUserDatabaseUtil.java | 23 ++++++-- .../commcare/connect/network/SsoToken.java | 12 ++-- .../connect/network/connectId/ApiClient.java | 58 ++++++++++--------- .../network/connectId/ApiEndPoints.java | 2 +- .../connect/ConnectDatabaseUpgrader.java | 7 ++- .../connect/DatabaseConnectOpenHelper.java | 2 +- .../org/commcare/preferences/ServerUrls.java | 21 ++++++- .../commcare/utils/ConnectivityStatus.java | 6 +- .../utils/EncryptionKeyAndTransform.java | 8 ++- .../commcare/utils/EncryptionKeyProvider.java | 14 +++-- 18 files changed, 165 insertions(+), 79 deletions(-) diff --git a/app/build.gradle b/app/build.gradle index 5b8af16265..e2482478ac 100644 --- a/app/build.gradle +++ b/app/build.gradle @@ -171,6 +171,8 @@ ext { HQ_API_PASSWORD = project.properties['HQ_API_PASSWORD'] ?: '' TEST_BUILD_TYPE = project.properties['TEST_BUILD_TYPE'] ?: 'debug' FIREBASE_DATABASE_URL = project.properties['FIREBASE_DATABASE_URL'] ?: '' + API_VERSION_CONNECT_ID = project.properties['API_VERSION_CONNECT_ID'] ?: '' + CONNECT_BASE_URL = project.properties['FIREBASE_DATABASE_URL'] ?: '' } afterEvaluate { @@ -289,6 +291,8 @@ android { buildConfigField "String", "FIREBASE_DATABASE_URL", "\"${project.ext.FIREBASE_DATABASE_URL}\"" + buildConfigField 'String', 'CONNECT_BASE_URL', "\"https://connectid.dimagi.com\"" + buildConfigField 'String', 'API_VERSION_CONNECT_ID', "\"1.0\"" buildConfigField 'String', 'CCC_HOST', "\"connect.dimagi.com\"" testInstrumentationRunner 'org.commcare.CommCareJUnitRunner' diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index dbb4229b20..6230a3fb02 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -275,10 +275,7 @@ public boolean isFinished() { 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 void setCompletedLearningModules(int numCompleted) { this.learningModulesCompleted = numCompleted; } public ConnectAppRecord getLearnAppInfo() { return learnAppInfo; } public void setLearnAppInfo(ConnectAppRecord appInfo) { this.learnAppInfo = appInfo; } public ConnectAppRecord getDeliveryAppInfo() { return deliveryAppInfo; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java index 32437fb743..c9efa5117b 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV2.java @@ -35,6 +35,7 @@ public class ConnectJobRecordV2 extends Persisted implements Serializable { 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"; diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java index 62b083bba7..63834f747e 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java @@ -1,5 +1,7 @@ package org.commcare.android.database.connect.models; +import android.util.Log; + import org.commcare.android.storage.framework.Persisted; import org.commcare.models.framework.Persisting; import org.commcare.modern.database.Table; @@ -116,7 +118,17 @@ public ConnectJobRecordV4() { 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 int getPaymentAccrued() { + if (paymentAccrued == null || paymentAccrued.isEmpty()) { + return 0; + } + try { + return Integer.parseInt(paymentAccrued); + } catch (NumberFormatException e) { + Log.e("ConnectJobRecordV4", "Failed to parse paymentAccrued: " + paymentAccrued, e); + return 0; + } + } public String getCurrency() { return currency; } public int getNumLearningModules() { return numLearningModules; } public void setLastUpdate(Date lastUpdate) { this.lastUpdate = lastUpdate; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java index 0a0401ef3a..abbb5d1180 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV8.java @@ -108,7 +108,7 @@ public static ConnectLinkedAppRecordV8 fromV3(ConnectLinkedAppRecordV3 oldRecord newRecord.linkOffered1 = true; newRecord.linkOfferDate1 = Date.from(Instant.now()); newRecord.linkOffered2 = false; - newRecord.linkOfferDate2 = Date.from(Instant.now());; + newRecord.linkOfferDate2 = Date.from(Instant.now()); return newRecord; } diff --git a/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java index 16641f10e7..114da944fe 100644 --- a/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java @@ -1,6 +1,7 @@ package org.commcare.connect.database; import android.content.Context; +import android.util.Log; import org.commcare.android.database.connect.models.ConnectLinkedAppRecord; import org.commcare.models.database.SqlStorage; @@ -17,29 +18,51 @@ public static ConnectLinkedAppRecord getAppData(Context context, String appId, S } public static void deleteAppData(Context context, ConnectLinkedAppRecord record) { - SqlStorage storage = ConnectDatabaseHelper.getConnectStorage(context, ConnectLinkedAppRecord.class); - storage.remove(record); + try { + SqlStorage storage = ConnectDatabaseHelper.getConnectStorage(context, ConnectLinkedAppRecord.class); + storage.remove(record); + } catch (Exception e) { + Log.e("Fail to delete data ",e.getMessage()); + } } + /** + * Stores or updates a ConnectLinkedAppRecord in the database. + * + * @param context The Android context + * @param appId Application identifier + * @param userId User identifier + * @param connectIdLinked Whether the app is linked to ConnectID + * @param passwordOrPin User's password or PIN + * @param workerLinked Whether the app is linked to a worker + * @param localPassphrase Whether using local passphrase + * @return The stored record + * throw error if storage operations fail + */ 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); - } + try { + 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); + record.setConnectIdLinked(connectIdLinked); + record.setIsUsingLocalPassphrase(localPassphrase); - if (workerLinked) { - //If passed in false, we'll leave the setting unchanged - record.setWorkerLinked(true); - } + if (workerLinked) { + //If passed in false, we'll leave the setting unchanged + record.setWorkerLinked(true); + } - storeApp(context, record); + storeApp(context, record); - return record; + return record; + }catch (Exception e){ + Log.e("Fail to delete data ",e.getMessage()); + return null; + } } public static void storeApp(Context context, ConnectLinkedAppRecord record) { diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java b/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java index 1a9a6ab32d..fc9969472b 100644 --- a/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java +++ b/app/src/org/commcare/connect/database/ConnectDatabaseHelper.java @@ -1,6 +1,8 @@ package org.commcare.connect.database; import android.content.Context; +import android.os.Handler; +import android.os.Looper; import android.widget.Toast; import net.sqlcipher.database.SQLiteDatabase; @@ -32,7 +34,7 @@ public static void handleReceivedDbPassphrase(Context context, String remotePass try { String localPassphrase = ConnectDatabaseUtils.getConnectDbEncodedPassphrase(context, true); - if (!remotePassphrase.equals(localPassphrase)) { + if (!remotePassphrase.equals(localPassphrase) && (connectDatabase == null || !connectDatabase.isOpen())) { DatabaseConnectOpenHelper.rekeyDB(connectDatabase, remotePassphrase); ConnectDatabaseUtils.storeConnectDbPassphrase(context, remotePassphrase, true); } @@ -93,7 +95,9 @@ public static void teardown() { public static void handleCorruptDb(Context context) { ConnectUserDatabaseUtil.forgetUser(context); - Toast.makeText(context, context.getString(R.string.connect_db_corrupt), Toast.LENGTH_LONG).show(); + new Handler(Looper.getMainLooper()).post(() -> + Toast.makeText(context, context.getString(R.string.connect_db_corrupt), Toast.LENGTH_LONG).show() + ); } public static void storeHqToken(Context context, String appId, String userId, SsoToken token) { diff --git a/app/src/org/commcare/connect/database/ConnectJobUtils.java b/app/src/org/commcare/connect/database/ConnectJobUtils.java index 316362d88d..01fc2fe526 100644 --- a/app/src/org/commcare/connect/database/ConnectJobUtils.java +++ b/app/src/org/commcare/connect/database/ConnectJobUtils.java @@ -513,7 +513,7 @@ public static void updateJobLearnProgress(Context context, ConnectJobRecord job) if (existingJobs.size() > 0) { ConnectJobRecord existing = existingJobs.get(0); - existing.setComletedLearningModules(job.getCompletedLearningModules()); + existing.setCompletedLearningModules(job.getCompletedLearningModules()); existing.setLastUpdate(new Date()); jobStorage.write(existing); diff --git a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java index 3380ee7247..53624d2f14 100644 --- a/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectUserDatabaseUtil.java @@ -9,9 +9,12 @@ import org.javarosa.core.services.Logger; public class ConnectUserDatabaseUtil { - private static final Object lock = new Object(); + private static final Object LOCK = new Object(); public static ConnectUserRecord getUser(Context context) { - synchronized (lock) { + if (context == null) { + throw new IllegalArgumentException("User must not be null"); + } + synchronized (LOCK) { if (!ConnectDatabaseHelper.dbExists(context)) { return null; } @@ -31,10 +34,13 @@ public static ConnectUserRecord getUser(Context context) { } public static void storeUser(Context context, ConnectUserRecord user) { + if (context == null) { + throw new IllegalArgumentException("User must not be null"); + } if (user == null) { throw new IllegalArgumentException("User must not be null"); } - synchronized (lock) { + synchronized (LOCK) { try { ConnectDatabaseHelper.getConnectStorage(context, ConnectUserRecord.class).write(user); } catch (Exception e) { @@ -45,11 +51,20 @@ public static void storeUser(Context context, ConnectUserRecord user) { } public static void forgetUser(Context context) { - synchronized (lock) { + if (context == null) { + throw new IllegalArgumentException("Context must not be null"); + } + synchronized (LOCK) { try { DatabaseConnectOpenHelper.deleteDb(context); CommCareApplication.instance().getGlobalStorage(ConnectKeyRecord.class).removeAll(); ConnectDatabaseHelper.dbBroken = false; + } catch (IllegalStateException e) { + Logger.exception("Database access error while forgetting user", e); + throw new RuntimeException("Failed to access database while cleaning up", e); + } catch (SecurityException e) { + Logger.exception("Permission denied while deleting database", e); + throw new RuntimeException("Failed to delete database due to permissions", e); } catch (Exception e) { Logger.exception("Failed to forget user", e); throw new RuntimeException("Failed to clean up Connect database", e); diff --git a/app/src/org/commcare/connect/network/SsoToken.java b/app/src/org/commcare/connect/network/SsoToken.java index df77398060..038d8a4685 100644 --- a/app/src/org/commcare/connect/network/SsoToken.java +++ b/app/src/org/commcare/connect/network/SsoToken.java @@ -10,11 +10,8 @@ import java.util.Date; public class SsoToken { - private final String token; - private final Date expiration; - public SsoToken(String token, Date expiration) { if (token == null || expiration == null) { throw new IllegalArgumentException("Token and expiration must not be null"); @@ -25,13 +22,11 @@ public SsoToken(String token, Date expiration) { this.token = token; this.expiration = new Date(expiration.getTime()); } - public static SsoToken fromResponseStream(InputStream stream) throws IOException, JSONException { String responseAsString = new String(StreamsUtil.inputStreamToByteArray( stream)); JSONObject json = new JSONObject(responseAsString); String key = ConnectConstants.CONNECT_KEY_TOKEN; - if (!json.has(key)) { throw new RuntimeException("SSO API response missing access token"); } @@ -40,7 +35,6 @@ public static SsoToken fromResponseStream(InputStream stream) throws IOException key = ConnectConstants.CONNECT_KEY_EXPIRES; long seconds = json.has(key) ? json.getLong(key) : 0L; expiration.setTime(expiration.getTime() + ((long)seconds * 1000)); - return new SsoToken(token, expiration); } public String getToken() { @@ -49,4 +43,8 @@ public String getToken() { public Date getExpiration() { return expiration; } -} + @Override + public String toString() { + return "SsoToken{expiration=" + expiration + '}'; + } +} \ No newline at end of file diff --git a/app/src/org/commcare/connect/network/connectId/ApiClient.java b/app/src/org/commcare/connect/network/connectId/ApiClient.java index 11ac32f1f2..9eee5158cd 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiClient.java +++ b/app/src/org/commcare/connect/network/connectId/ApiClient.java @@ -19,41 +19,43 @@ ///Todo retry part of the api fails public class ApiClient { - private static final String BASE_URL = "https://connectid.dimagi.com"; // Replace with actual base URL - private static final String API_VERSION = ApiConnectId.API_VERSION_CONNECT_ID; // Replace with actual version value + private static final String BASE_URL = BuildConfig.CONNECT_BASE_URL; // Replace with actual base URL + private static final String API_VERSION = BuildConfig.API_VERSION_CONNECT_ID; // Replace with actual version value private static Retrofit retrofit; + private ApiClient() {} + private static class RetrofitHolder { + private static final Retrofit INSTANCE = buildRetrofitClient(); + } public static Retrofit getClient() { + return RetrofitHolder.INSTANCE; + } + private static Retrofit buildRetrofitClient() { HttpLoggingInterceptor logging = new HttpLoggingInterceptor(); -// set your desired log level logging.setLevel(BuildConfig.DEBUG ? HttpLoggingInterceptor.Level.BODY : HttpLoggingInterceptor.Level.NONE); - if (retrofit == null) { - OkHttpClient okHttpClient = new OkHttpClient.Builder() - .addInterceptor(logging) - .addInterceptor(new Interceptor() { - @Override - public Response intercept(Chain chain) throws IOException { - Request originalRequest = chain.request(); - Request requestWithHeaders = originalRequest.newBuilder() - .header("Accept", "application/json;version=" + API_VERSION) - .build(); - return chain.proceed(requestWithHeaders); - } - }) - .connectTimeout(30, TimeUnit.SECONDS) - .readTimeout(30, TimeUnit.SECONDS) - .writeTimeout(30, TimeUnit.SECONDS) - .build(); - - retrofit = new Retrofit.Builder() - .baseUrl(BASE_URL) - .client(okHttpClient) - .addConverterFactory(GsonConverterFactory.create()) - .build(); - } - return retrofit; + OkHttpClient okHttpClient = new OkHttpClient.Builder() + .addInterceptor(logging) + .addInterceptor(new Interceptor() { + @Override + public Response intercept(Chain chain) throws IOException { + Request originalRequest = chain.request(); + Request requestWithHeaders = originalRequest.newBuilder() + .header("Accept", "application/json;version=" + API_VERSION) + .build(); + return chain.proceed(requestWithHeaders); + } + }) + .connectTimeout(30, TimeUnit.SECONDS) + .readTimeout(30, TimeUnit.SECONDS) + .writeTimeout(30, TimeUnit.SECONDS) + .build(); + return new Retrofit.Builder() + .baseUrl(BASE_URL) + .client(okHttpClient) + .addConverterFactory(GsonConverterFactory.create()) + .build(); } } \ No newline at end of file diff --git a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java index 35b9dd6713..b4c13c7940 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java +++ b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java @@ -6,7 +6,7 @@ public class ApiEndPoints { public static final String connectFetchDbKeyURL = "/users/fetch_db_key"; public static final String connectChangePasswordURL = "/users/change_password"; public static final String registerUser = "/users/register"; - public static final String phoneAvailable = "/users/phone_available?"; + public static final String phoneAvailable = "/users/phone_available"; public static final String changePhoneNo = "/users/change_phone"; public static final String updateProfile = "/users/update_profile"; public static final String validatePhone = "/users/validate_phone"; diff --git a/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java b/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java index 34a2bc6a8e..2fcd5fda3d 100644 --- a/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java +++ b/app/src/org/commcare/models/database/connect/ConnectDatabaseUpgrader.java @@ -1,6 +1,7 @@ package org.commcare.models.database.connect; import android.content.Context; +import android.util.Log; import net.sqlcipher.database.SQLiteDatabase; @@ -27,6 +28,7 @@ import org.commcare.models.database.DbUtil; import org.commcare.models.database.SqlStorage; import org.commcare.modern.database.TableBuilder; +import org.commcare.utils.CrashUtil; import org.javarosa.core.services.storage.Persistable; public class ConnectDatabaseUpgrader { @@ -146,7 +148,10 @@ private void upgradeTwoThree(SQLiteDatabase db) { } db.setTransactionSuccessful(); - } finally { + } catch (Exception e){ + CrashUtil.log(e.getMessage()); + } + 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 index 6497e0df34..04931a0f69 100644 --- a/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java +++ b/app/src/org/commcare/models/database/connect/DatabaseConnectOpenHelper.java @@ -134,7 +134,7 @@ public SQLiteDatabase getWritableDatabase(String key) { } catch (SQLiteException e) { // Handle the exception, log the error, or inform the user CrashUtil.log(e.getMessage()); - return null; + throw e; } } } diff --git a/app/src/org/commcare/preferences/ServerUrls.java b/app/src/org/commcare/preferences/ServerUrls.java index ed980d52d0..e78af8cca4 100644 --- a/app/src/org/commcare/preferences/ServerUrls.java +++ b/app/src/org/commcare/preferences/ServerUrls.java @@ -34,12 +34,27 @@ public static String getDataServerKey() { .getString(R.string.ota_restore_url)) ; } - public static String buildEndpoint(String path) { + /** + * Builds a complete URL by combining the key server URL with the given path. + * + * @param path The path to append to the key server URL + * @return The complete URL + * @throws IllegalArgumentException if path is null or key server URL is not set + * @throws MalformedURLException if the resulting URL is invalid + */ + public static String buildEndpoint(String path) throws MalformedURLException { + if (path == null) { + throw new IllegalArgumentException("Path cannot be null"); + } + String keyServer = getKeyServer(); + if (keyServer == null) { + throw new IllegalArgumentException("Key server URL is not set"); + } try { - URL originalUrl = new URL(getKeyServer()); + URL originalUrl = new URL(keyServer); return new URL(originalUrl, path).toString(); } catch (MalformedURLException e) { - throw new RuntimeException(e); + throw e; } } diff --git a/app/src/org/commcare/utils/ConnectivityStatus.java b/app/src/org/commcare/utils/ConnectivityStatus.java index 9c27138f68..1888bbda70 100644 --- a/app/src/org/commcare/utils/ConnectivityStatus.java +++ b/app/src/org/commcare/utils/ConnectivityStatus.java @@ -26,12 +26,12 @@ public static boolean isNetworkAvailable(Context context) { return false; } NetworkInfo netInfo = conManager.getActiveNetworkInfo(); - boolean notInAirplaneMode = (netInfo != null && netInfo.isConnected()); + boolean isConnected = (netInfo != null && netInfo.isConnected()); //if user is not online, log not connected. if online, log success - String logMessage = !notInAirplaneMode ? logNotConnectedMessage : logConnectionSuccessMessage; + String logMessage = !isConnected ? logNotConnectedMessage : logConnectionSuccessMessage; Logger.log(CONNECTION_DIAGNOSTIC_REPORT, logMessage); - return notInAirplaneMode; + return isConnected; } } diff --git a/app/src/org/commcare/utils/EncryptionKeyAndTransform.java b/app/src/org/commcare/utils/EncryptionKeyAndTransform.java index 79eebec9cc..7f0bacd792 100644 --- a/app/src/org/commcare/utils/EncryptionKeyAndTransform.java +++ b/app/src/org/commcare/utils/EncryptionKeyAndTransform.java @@ -18,12 +18,16 @@ public EncryptionKeyAndTransform(Key key, String transformation) { if (transformation == null || transformation.trim().isEmpty()) { throw new IllegalArgumentException("Transformation string cannot be null or empty"); } - if (!transformation.matches("[A-Za-z0-9]+/[A-Za-z0-9]+/[A-Za-z0-9Padding]+")) { + if (!transformation.matches("[A-Za-z0-9]+/[A-Za-z0-9]+/[A-Za-z0-9]+Padding")) { throw new IllegalArgumentException("Invalid transformation format. Expected: Algorithm/Mode/Padding"); } // Create defensive copy if key is not immutable if (key instanceof javax.crypto.SecretKey) { - this.key = new javax.crypto.spec.SecretKeySpec(key.getEncoded(), key.getAlgorithm()); + byte[] encodedKey = key.getEncoded(); + if (encodedKey == null) { + throw new IllegalArgumentException("Key encoding is null or unsupported for this key type"); + } + this.key = new javax.crypto.spec.SecretKeySpec(encodedKey, key.getAlgorithm()); } else { this.key = key; } diff --git a/app/src/org/commcare/utils/EncryptionKeyProvider.java b/app/src/org/commcare/utils/EncryptionKeyProvider.java index 1724846e78..7fcabbd9d8 100644 --- a/app/src/org/commcare/utils/EncryptionKeyProvider.java +++ b/app/src/org/commcare/utils/EncryptionKeyProvider.java @@ -78,12 +78,18 @@ private static EncryptionKeyAndTransform getKey(Context context, KeyStore keysto if (doesKeystoreContainEncryptionKey()) { KeyStore.Entry existingKey = keystore.getEntry(SECRET_NAME, null); - if (existingKey instanceof KeyStore.SecretKeyEntry entry) { - return new EncryptionKeyAndTransform(entry.getSecretKey(), String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING)); - } else if (existingKey instanceof KeyStore.PrivateKeyEntry entry) { + if (existingKey instanceof KeyStore.SecretKeyEntry) { + KeyStore.SecretKeyEntry entry = (KeyStore.SecretKeyEntry) existingKey; + return new EncryptionKeyAndTransform( + entry.getSecretKey(), + String.format("%s/%s/%s", ALGORITHM, BLOCK_MODE, PADDING) + ); + } else if (existingKey instanceof KeyStore.PrivateKeyEntry) { + KeyStore.PrivateKeyEntry entry = (KeyStore.PrivateKeyEntry) existingKey; Key key = trueForEncrypt ? entry.getCertificate().getPublicKey() : entry.getPrivateKey(); return new EncryptionKeyAndTransform(key, "RSA/ECB/PKCS1Padding"); - } else { + } + else { throw new RuntimeException("Unrecognized key type retrieved from KeyStore"); } } else { From 1473e3e1b92cfeb02b3bd3d5922488e161d2276a Mon Sep 17 00:00:00 2001 From: parthmittal Date: Fri, 24 Jan 2025 19:06:15 +0530 Subject: [PATCH 23/26] putting non null check on job records --- .../connect/models/ConnectJobRecordV4.java | 4 +- .../models/ConnectLinkedAppRecordV9.java | 4 +- .../connect/models/ConnectUserRecordV5.java | 38 +++++++++++++++---- .../commcare/connect/network/SsoToken.java | 2 +- 4 files changed, 38 insertions(+), 10 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java index 63834f747e..d607b84f7e 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecordV4.java @@ -10,6 +10,8 @@ import java.io.Serializable; import java.util.Date; +import androidx.annotation.NonNull; + /** * Data class for holding info related to a Connect job * @@ -143,7 +145,7 @@ public int getPaymentAccrued() { /** * Used for app db migration only */ - public static ConnectJobRecordV4 fromV2(ConnectJobRecordV2 oldRecord) { + public static ConnectJobRecordV4 fromV2(@NonNull ConnectJobRecordV2 oldRecord) { ConnectJobRecordV4 newRecord = new ConnectJobRecordV4(); newRecord.jobId = oldRecord.getJobId(); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java index 932f7271ee..9bf7e6b518 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java @@ -7,6 +7,8 @@ import java.util.Date; +import androidx.annotation.NonNull; + /** * Migrates a V8 record to V9 format. * New in V9: @@ -109,7 +111,7 @@ public Date getLinkOfferDate2() { public boolean isUsingLocalPassphrase() { return usingLocalPassphrase; } - public static ConnectLinkedAppRecordV9 fromV8(ConnectLinkedAppRecordV8 oldRecord) { + public static ConnectLinkedAppRecordV9 fromV8(@NonNull ConnectLinkedAppRecordV8 oldRecord) { ConnectLinkedAppRecordV9 newRecord = new ConnectLinkedAppRecordV9(); newRecord.appId = oldRecord.getAppId(); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java b/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java index 990742edd8..6b8d24f0cc 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java @@ -7,29 +7,53 @@ import java.util.Date; /** - * DB model for a ConnectID user and their info + * Database model for storing ConnectID user information and authentication state. + * This V5 version includes support for connect tokens and registration phases. + *

+ * This record is used by: + * - ApiConnectId for user authentication + * - Connect feature for user management + * - Database upgrade mechanisms for version migrations * * @author dviggiano + * @see org.commcare.connect.ConnectConstants */ -@Table(ConnectUserRecord.STORAGE_KEY) +@Table(ConnectUserRecordV5.STORAGE_KEY) public class ConnectUserRecordV5 extends Persisted { /** * Name of database that stores Connect user records */ public static final String STORAGE_KEY = "user_info"; - + /** + * Unique identifier for the ConnectID user. + * This ID is immutable and used as the primary key. + */ @Persisting(1) private String userId; - + /** + * User's password hash. + * Updated when password is changed or reset. + * @see #setPassword(String) + * @see #lastPasswordDate + */ @Persisting(2) private String password; - + /** + * User's display name. + * Can be updated by the user. + */ @Persisting(3) private String name; - + /** + * User's phone no. + * Used for authentication and recovery + */ @Persisting(4) private String primaryPhone; - + /** + * User's secondary phone no. + * Used for authentication and recovery + */ @Persisting(5) private String alternatePhone; diff --git a/app/src/org/commcare/connect/network/SsoToken.java b/app/src/org/commcare/connect/network/SsoToken.java index 038d8a4685..c626f7a03b 100644 --- a/app/src/org/commcare/connect/network/SsoToken.java +++ b/app/src/org/commcare/connect/network/SsoToken.java @@ -41,7 +41,7 @@ public String getToken() { return token; } public Date getExpiration() { - return expiration; + return new Date(expiration.getTime()); } @Override public String toString() { From 6d0dedbfe32144f879be786d7eefd1d032270cf3 Mon Sep 17 00:00:00 2001 From: parthmittal Date: Sun, 26 Jan 2025 18:01:12 +0530 Subject: [PATCH 24/26] -removed null safety --- .../connect/models/ConnectAppRecord.java | 14 ++++----- .../models/ConnectJobAssessmentRecord.java | 6 ++-- .../models/ConnectJobDeliveryRecord.java | 13 ++++----- .../models/ConnectJobLearningRecord.java | 6 ++-- .../models/ConnectJobPaymentRecord.java | 2 +- .../connect/models/ConnectJobRecord.java | 29 +++++++++---------- 6 files changed, 34 insertions(+), 36 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java index 143e3dd21b..6417cbfb68 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectAppRecord.java @@ -72,13 +72,13 @@ public static ConnectAppRecord fromJson(JSONObject json, int jobId, boolean isLe 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) : ""; + app.domain = json.getString(META_DOMAIN); + app.appId = json.getString(META_APP_ID); + app.name = json.getString(META_NAME); + app.description = json.getString(META_DESCRIPTION); + app.organization = json.getString(META_ORGANIZATION); + app.passingScore = json.getInt(META_PASSING_SCORE); + app.installUrl =json.getString(META_INSTALL_URL); JSONArray array = json.getJSONArray(META_MODULES); app.learnModules = new ArrayList<>(); diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java index d4d224de1d..a8c28bab82 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobAssessmentRecord.java @@ -59,9 +59,9 @@ public static ConnectJobAssessmentRecord fromJson(JSONObject json, int jobId) th record.lastUpdate = new Date(); record.jobId = jobId; - record.date = json.has(META_DATE) ? DateUtils.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.date = DateUtils.parseDateTime(json.getString(META_DATE)); + record.score = json.getInt(META_SCORE); + record.passingScore = json.getInt(META_PASSING_SCORE); record.passed = json.has(META_PASSED) && json.getBoolean(META_PASSED); return record; diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java index 77faa1a5dc..d948fc597e 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecord.java @@ -85,13 +85,12 @@ public static ConnectJobDeliveryRecord fromJson(JSONObject json, int jobId) thro delivery.deliveryId = deliveryId; dateString = json.getString(META_DATE); delivery.date = DateUtils.parseDateTime(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) : ""; + delivery.status = json.getString(META_STATUS); + delivery.unitName = json.getString(META_UNIT_NAME); + delivery.slug = json.getString(META_SLUG); + delivery.entityId = json.getString(META_ENTITY_ID); + delivery.entityName = json.getString(META_ENTITY_NAME); + delivery.reason = json.getString(META_REASON); return delivery; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java index 072be0fad7..a838068af6 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java @@ -55,9 +55,9 @@ public static ConnectJobLearningRecord fromJson(JSONObject json, int jobId) thro record.lastUpdate = new Date(); record.jobId = jobId; - record.date = json.has(META_DATE) ? DateUtils.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) : ""; + record.date = DateUtils.parseDateTime(json.getString(META_DATE)); + record.moduleId = json.getInt(META_MODULE); + record.duration = json.getString(META_DURATION); return record; } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java index 503811a560..41d465d259 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobPaymentRecord.java @@ -72,7 +72,7 @@ public static ConnectJobPaymentRecord fromJson(JSONObject json, int jobId) throw ConnectJobPaymentRecord payment = new ConnectJobPaymentRecord(); payment.jobId = jobId; - payment.date = json.has(META_DATE) ? DateUtils.parseDateTime(json.getString(META_DATE)) : new Date(); + payment.date = DateUtils.parseDateTime(json.getString(META_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") : ""; diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java index 6230a3fb02..78f230d47d 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobRecord.java @@ -19,6 +19,8 @@ import java.util.List; import java.util.Locale; +import androidx.annotation.NonNull; + /** * Data class for holding info related to a Connect job * @@ -161,27 +163,24 @@ public static ConnectJobRecord fromJson(JSONObject json) throws JSONException, P 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) ? DateUtils.parseDate(json.getString(META_END_DATE)) : new Date(); - job.projectStartDate = json.has(META_START_DATE) ? DateUtils.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; + job.title = json.getString(META_NAME); + job.description = json.getString(META_DESCRIPTION); + job.organization = json.getString(META_ORGANIZATION); + job.projectEndDate = DateUtils.parseDate(json.getString(META_END_DATE)); + job.projectStartDate = DateUtils.parseDate(json.getString(META_START_DATE)); + job.maxVisits = json.getInt(META_MAX_VISITS_PER_USER); + job.maxDailyVisits = json.getInt(META_MAX_DAILY_VISITS); + job.budgetPerVisit = json.getInt(META_BUDGET_PER_VISIT); 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.totalBudget = json.getInt(budgetPerUserKey); + job.currency = json.getString(META_CURRENCY); + job.shortDescription = 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.completedVisits = json.getInt(META_DELIVERY_PROGRESS); job.claimed = json.has(META_CLAIM) &&!json.isNull(META_CLAIM); From cc38e155cfdfec2f4cbb4e5ab9dffa869d37a335 Mon Sep 17 00:00:00 2001 From: parthmittal Date: Tue, 28 Jan 2025 11:59:59 +0530 Subject: [PATCH 25/26] -suggested changes from coderabitai --- .../models/ConnectJobDeliveryRecordV2.java | 6 +- .../models/ConnectJobLearningRecord.java | 4 +- .../models/ConnectLinkedAppRecordV9.java | 1 - .../connect/models/ConnectUserRecordV5.java | 5 +- .../database/ConnectAppDatabaseUtil.java | 4 +- .../database/ConnectDatabaseUtils.java | 8 +- .../connect/database/ConnectJobUtils.java | 156 ++++++++++-------- .../connect/network/connectId/ApiClient.java | 2 + .../network/connectId/ApiEndPoints.java | 2 +- .../connect/network/connectId/ApiService.java | 16 +- .../tasks/ConnectionDiagnosticTask.java | 3 - .../utils/MockEncryptionKeyProvider.java | 11 +- 12 files changed, 123 insertions(+), 95 deletions(-) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java index 1f0611f6a6..075c90cab4 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobDeliveryRecordV2.java @@ -38,7 +38,7 @@ public class ConnectJobDeliveryRecordV2 extends Persisted implements Serializabl private int deliveryId; @Persisting(3) @MetaField(META_DATE) - protected Date date; + private Date date; @Persisting(4) @MetaField(META_STATUS) private String status; @@ -56,7 +56,9 @@ public class ConnectJobDeliveryRecordV2 extends Persisted implements Serializabl private String entityname; @Persisting(9) private Date lastUpdate; - + /** + * Default constructor required for serialization + */ public ConnectJobDeliveryRecordV2() { } diff --git a/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java index a838068af6..2575722c61 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectJobLearningRecord.java @@ -15,7 +15,9 @@ /** * Data class for holding info related to the completion of a Connect job learning module - * + * This version (V2) includes additional fields for learning modules and payment tracking + * compared to V1. Migration from V1 automatically copies existing fields and initializes + * new fields with default values. * @author dviggiano */ @Table(ConnectJobLearningRecord.STORAGE_KEY) diff --git a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java index 9bf7e6b518..a6dff8df2a 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectLinkedAppRecordV9.java @@ -16,7 +16,6 @@ * - Changed link offer date handling * * @return A new V9 record with migrated data - * @throws IllegalArgumentException if oldRecord is null */ @Table(ConnectLinkedAppRecordV9.STORAGE_KEY) public class ConnectLinkedAppRecordV9 extends Persisted { diff --git a/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java b/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java index 6b8d24f0cc..e5f1e0bf1c 100644 --- a/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java +++ b/app/src/org/commcare/android/database/connect/models/ConnectUserRecordV5.java @@ -56,7 +56,10 @@ public class ConnectUserRecordV5 extends Persisted { */ @Persisting(5) private String alternatePhone; - + /** + * it tells about the current position of registration of user + * Used for smoot registration + */ @Persisting(6) private int registrationPhase; diff --git a/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java b/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java index 114da944fe..c1077c84fc 100644 --- a/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java +++ b/app/src/org/commcare/connect/database/ConnectAppDatabaseUtil.java @@ -22,7 +22,7 @@ public static void deleteAppData(Context context, ConnectLinkedAppRecord record) SqlStorage storage = ConnectDatabaseHelper.getConnectStorage(context, ConnectLinkedAppRecord.class); storage.remove(record); } catch (Exception e) { - Log.e("Fail to delete data ",e.getMessage()); + Log.e("ConnectAppDatabaseUtil", "Failed to delete app data for record: " + record.getUserId(), e); } } @@ -60,7 +60,7 @@ record = new ConnectLinkedAppRecord(appId, userId, connectIdLinked, passwordOrPi return record; }catch (Exception e){ - Log.e("Fail to delete data ",e.getMessage()); + Log.e("ConnectAppDatabaseUtil", "Failed to store app data for appId: " + appId + ", userId: " + userId, e); return null; } } diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java index ca7713c6d2..342a0e3893 100644 --- a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java +++ b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java @@ -5,13 +5,17 @@ import org.commcare.util.Base64; import org.commcare.utils.EncryptionUtils; import org.javarosa.core.services.Logger; +import org.jetbrains.annotations.NotNull; + import java.util.Vector; public class ConnectDatabaseUtils { - public static void storeConnectDbPassphrase(Context context, byte[] passphrase, boolean isLocal) { + public static void storeConnectDbPassphrase(@NotNull Context context, byte[] passphrase, boolean isLocal) { try { + if (passphrase == null || passphrase.length == 0) { + throw new IllegalArgumentException("Passphrase must not be null or empty"); + } String encoded = EncryptionUtils.encryptToBase64String(context, passphrase); - ConnectKeyRecord record = getKeyRecord(isLocal); if (record == null) { record = new ConnectKeyRecord(encoded, isLocal); diff --git a/app/src/org/commcare/connect/database/ConnectJobUtils.java b/app/src/org/commcare/connect/database/ConnectJobUtils.java index 01fc2fe526..420ac1b77b 100644 --- a/app/src/org/commcare/connect/database/ConnectJobUtils.java +++ b/app/src/org/commcare/connect/database/ConnectJobUtils.java @@ -21,6 +21,16 @@ import java.util.Vector; public class ConnectJobUtils { + static Vector jobIdsToDelete = new Vector<>(); + static Vector appInfoIdsToDelete = new Vector<>(); + static Vector moduleIdsToDelete = new Vector<>(); + static Vector paymentUnitIdsToDelete = new Vector<>(); + static SqlStorage jobStorage; + static SqlStorage appInfoStorage; + static SqlStorage moduleStorage; + static SqlStorage paymentUnitStorage; + static List existingList; + public static void upsertJob(Context context, ConnectJobRecord job) { List list = new ArrayList<>(); list.add(job); @@ -28,56 +38,19 @@ public static void upsertJob(Context context, ConnectJobRecord job) { } public static int storeJobs(Context context, List jobs, boolean pruneMissing) { - SqlStorage jobStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobRecord.class); - SqlStorage appInfoStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectAppRecord.class); - SqlStorage moduleStorage = ConnectDatabaseHelper.getConnectStorage(context, + jobStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectJobRecord.class); + appInfoStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectAppRecord.class); + moduleStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectLearnModuleSummaryRecord.class); - SqlStorage paymentUnitStorage = ConnectDatabaseHelper.getConnectStorage(context, + paymentUnitStorage = ConnectDatabaseHelper.getConnectStorage(context, ConnectPaymentUnitRecord.class); - List existingList = getJobs(context, -1, jobStorage); + 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); - } + deleteMissingJobs(jobs,pruneMissing); + //Note when jobs are found in the loop below, we retrieve the DB ID into the incoming job //Now insert/update jobs int newJobs = 0; for (ConnectJobRecord incomingJob : jobs) { @@ -148,45 +121,86 @@ public static int storeJobs(Context context, List jobs, boolea 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()); + storePaymentUnits(incomingJob,foundIndexes); - break; - } - } + } + + return newJobs; + } + + public static void deleteMissingJobs(List jobs,boolean pruneMissing){ + 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) { - paymentUnitIdsToDelete.add(existing.getID()); + 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); + } + } - for (ConnectPaymentUnitRecord record : incomingJob.getPaymentUnits()) { - record.setJobId(incomingJob.getJobId()); - paymentUnitStorage.write(record); + public static void storePaymentUnits(ConnectJobRecord incomingJob,Vector foundIndexes){ + 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()); } } - return newJobs; + paymentUnitStorage.removeAll(paymentUnitIdsToDelete); + + for (ConnectPaymentUnitRecord record : incomingJob.getPaymentUnits()) { + record.setJobId(incomingJob.getJobId()); + paymentUnitStorage.write(record); + } } public static ConnectJobRecord getJob(Context context, int jobId) { @@ -391,7 +405,7 @@ public static void storePayments(Context context, List for (ConnectJobPaymentRecord existing : existingList) { boolean stillExists = false; for (ConnectJobPaymentRecord incoming : payments) { - if (existing.getDate() == incoming.getDate()) { + if (existing.getDate() != null && existing.getDate().equals(incoming.getDate())) { incoming.setID(existing.getID()); stillExists = true; break; diff --git a/app/src/org/commcare/connect/network/connectId/ApiClient.java b/app/src/org/commcare/connect/network/connectId/ApiClient.java index 9eee5158cd..0bdb5c53ed 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiClient.java +++ b/app/src/org/commcare/connect/network/connectId/ApiClient.java @@ -36,6 +36,8 @@ private static Retrofit buildRetrofitClient() { logging.setLevel(BuildConfig.DEBUG ? HttpLoggingInterceptor.Level.BODY : HttpLoggingInterceptor.Level.NONE); + logging.redactHeader("Authorization"); + logging.redactHeader("Cookie"); OkHttpClient okHttpClient = new OkHttpClient.Builder() .addInterceptor(logging) .addInterceptor(new Interceptor() { diff --git a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java index b4c13c7940..ad84471314 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java +++ b/app/src/org/commcare/connect/network/connectId/ApiEndPoints.java @@ -1,7 +1,7 @@ package org.commcare.connect.network.connectId; public class ApiEndPoints { - public static final String connectTokenURL = "o/token/"; + public static final String connectTokenURL = "/o/token/"; public static final String connectHeartbeatURL = "/users/heartbeat"; public static final String connectFetchDbKeyURL = "/users/fetch_db_key"; public static final String connectChangePasswordURL = "/users/change_password"; diff --git a/app/src/org/commcare/connect/network/connectId/ApiService.java b/app/src/org/commcare/connect/network/connectId/ApiService.java index de92135e31..eca2642c6f 100644 --- a/app/src/org/commcare/connect/network/connectId/ApiService.java +++ b/app/src/org/commcare/connect/network/connectId/ApiService.java @@ -14,25 +14,25 @@ public interface ApiService { Call registerUser(@Body Map registrationRequest); @POST(ApiEndPoints.changePhoneNo) - Call changePhoneNo(@Header("Authorization") String token,@Body Map changeRequest); + Call changePhoneNo(@Header("Authorization") String token, @Body Map changeRequest); @POST(ApiEndPoints.updateProfile) - Call updateProfile(@Header("Authorization") String token,@Body Map updateProfile); + Call updateProfile(@Header("Authorization") String token, @Body Map updateProfile); @POST(ApiEndPoints.validatePhone) - Call validatePhone(@Header("Authorization") String token,@Body Map requestOTP); + Call validatePhone(@Header("Authorization") String token, @Body Map requestOTP); @POST(ApiEndPoints.recoverOTPPrimary) Call requestOTPPrimary(@Body Map requestOTP); @POST(ApiEndPoints.recoverOTPSecondary) - Call validateSecondaryPhone(@Header("Authorization") String token,@Body Map validateSecondaryPhoneRequest); + Call validateSecondaryPhone(@Header("Authorization") String token, @Body Map validateSecondaryPhoneRequest); @POST(ApiEndPoints.recoverConfirmOTPSecondary) Call recoverConfirmOTPSecondary(@Body Map recoverConfirmOTPSecondaryRequest); @POST(ApiEndPoints.confirmOTPSecondary) - Call confirmOTPSecondary(@Header("Authorization") String token,@Body Map confirmOTPSecondaryRequest); + Call confirmOTPSecondary(@Header("Authorization") String token, @Body Map confirmOTPSecondaryRequest); @POST(ApiEndPoints.accountDeactivation) Call accountDeactivation(@Body Map accountDeactivationRequest); @@ -44,7 +44,7 @@ public interface ApiService { Call recoverConfirmOTP(@Body Map confirmOTPRequest); @POST(ApiEndPoints.confirmOTP) - Call confirmOTP(@Header("Authorization") String token,@Body Map confirmOTPRequest); + Call confirmOTP(@Header("Authorization") String token, @Body Map confirmOTPRequest); @POST(ApiEndPoints.recoverSecondary) Call recoverSecondary(@Body Map recoverSecondaryRequest); @@ -52,12 +52,12 @@ public interface ApiService { Call confirmPIN(@Body Map confirmPINRequest); @POST(ApiEndPoints.setPIN) - Call changePIN(@Header("Authorization") String token,@Body Map changePINRequest); + Call changePIN(@Header("Authorization") String token, @Body Map changePINRequest); @POST(ApiEndPoints.resetPassword) Call resetPassword(@Body Map resetPasswordRequest); @POST(ApiEndPoints.changePassword) - Call changePassword(@Header("Authorization") String token,@Body Map changePasswordRequest); + Call changePassword(@Header("Authorization") String token, @Body Map changePasswordRequest); @POST(ApiEndPoints.confirmPassword) Call checkPassword(@Body Map confirmPasswordRequest); } \ No newline at end of file diff --git a/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java b/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java index b0b9875002..da9c7383a8 100644 --- a/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java +++ b/app/src/org/commcare/tasks/ConnectionDiagnosticTask.java @@ -1,9 +1,6 @@ package org.commcare.tasks; import android.content.Context; -import android.net.ConnectivityManager; -import android.net.NetworkInfo; - import org.commcare.android.logging.ForceCloseLogger; import org.commcare.core.network.CommCareNetworkService; import org.commcare.core.network.CommCareNetworkServiceGenerator; diff --git a/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java b/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java index fac4d19ccd..100d82de3d 100644 --- a/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java +++ b/app/unit-tests/src/org/commcare/utils/MockEncryptionKeyProvider.java @@ -8,7 +8,10 @@ /** * Mock key provider, creates an RSA KeyPair but doesn't store it for future usage - * + * Security considerations: + * - Reuses the same key pair across multiple calls + * - Keeps private key in memory + * - For testing purposes only, not suitable for production use * @author dviggiano */ public class MockEncryptionKeyProvider extends EncryptionKeyProvider { @@ -19,10 +22,12 @@ 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(); + KeyPairGenerator keyGen = KeyPairGenerator.getInstance("RSA"); + keyGen.initialize(2048); // Standard key size for RSA + keyPair = keyGen.generateKeyPair(); } - return new EncryptionKeyAndTransform(trueForEncrypt ? keyPair.getPrivate() : keyPair.getPublic(), + return new EncryptionKeyAndTransform(trueForEncrypt ? keyPair.getPublic() : keyPair.getPrivate(), "RSA/ECB/PKCS1Padding"); } } From 50b8a51f6a09ffaac680bcff995af8273738cc28 Mon Sep 17 00:00:00 2001 From: parthmittal Date: Tue, 28 Jan 2025 13:43:08 +0530 Subject: [PATCH 26/26] -rid of local paraphrase --- .../connect/database/ConnectDatabaseUtils.java | 13 ++++--------- 1 file changed, 4 insertions(+), 9 deletions(-) diff --git a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java index 342a0e3893..4bbfd6107f 100644 --- a/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java +++ b/app/src/org/commcare/connect/database/ConnectDatabaseUtils.java @@ -3,6 +3,7 @@ import org.commcare.CommCareApplication; import org.commcare.android.database.global.models.ConnectKeyRecord; import org.commcare.util.Base64; +import org.commcare.utils.CrashUtil; import org.commcare.utils.EncryptionUtils; import org.javarosa.core.services.Logger; import org.jetbrains.annotations.NotNull; @@ -67,16 +68,10 @@ public static byte[] getConnectDbPassphrase(Context context) { ConnectKeyRecord record = ConnectDatabaseUtils.getKeyRecord(true); if (record != null) { return EncryptionUtils.decryptFromBase64String(context, record.getEncryptedPassphrase()); + }else{ + CrashUtil.log("We dont find paraphrase in db"); + throw new RuntimeException(); } - - //LEGACY: If we get here, the passphrase hasn't been created yet so use a local one - byte[] passphrase = EncryptionUtils.generatePassphrase(); - if (passphrase == null) { - throw new IllegalStateException("Generated passphrase is null"); - } - ConnectDatabaseUtils.storeConnectDbPassphrase(context, passphrase, true); - - return passphrase; } catch (Exception e) { Logger.exception("Getting DB passphrase", e); throw new RuntimeException(e);